diff --git a/embedded/abap/zcl_vsp_amdp_service.clas.abap b/embedded/abap/zcl_vsp_amdp_service.clas.abap
index c06799bb..0485f845 100644
--- a/embedded/abap/zcl_vsp_amdp_service.clas.abap
+++ b/embedded/abap/zcl_vsp_amdp_service.clas.abap
@@ -8,6 +8,7 @@ CLASS zcl_vsp_amdp_service DEFINITION
PUBLIC SECTION.
INTERFACES zif_vsp_service.
+ CLASS-METHODS class_constructor.
PRIVATE SECTION.
" Session state per WebSocket connection
@@ -21,6 +22,7 @@ CLASS zcl_vsp_amdp_service DEFINITION
END OF ty_session.
CLASS-DATA gt_sessions TYPE HASHED TABLE OF ty_session WITH UNIQUE KEY session_id.
+ CLASS-DATA gv_pcre_supported TYPE abap_bool.
METHODS handle_start
IMPORTING is_message TYPE zif_vsp_service=>ty_message
@@ -86,6 +88,17 @@ ENDCLASS.
CLASS zcl_vsp_amdp_service IMPLEMENTATION.
+ METHOD class_constructor.
+ " Probe for PCRE support: \d was introduced in ABAP 7.55 (POSIX ERE on older releases).
+ TRY.
+ DATA lv_pcre_probe TYPE string.
+ FIND REGEX '\d' IN '1' SUBMATCHES lv_pcre_probe.
+ gv_pcre_supported = xsdbool( sy-subrc = 0 AND lv_pcre_probe = '1' ).
+ CATCH cx_root.
+ gv_pcre_supported = abap_false.
+ ENDTRY.
+ ENDMETHOD.
+
METHOD zif_vsp_service~get_domain.
rv_domain = 'amdp'.
ENDMETHOD.
@@ -844,12 +857,19 @@ CLASS zcl_vsp_amdp_service IMPLEMENTATION.
" Extract parameter from JSON params string
" Simple regex-based extraction
DATA lv_pattern TYPE string.
- lv_pattern = |"{ iv_name }"\\s*:\\s*"([^"]*)"|.
-
+ IF gv_pcre_supported = abap_true.
+ lv_pattern = |"{ iv_name }"\\s*:\\s*"([^"]*)"|.
+ ELSE.
+ lv_pattern = |"{ iv_name }"[[:space:]]*:[[:space:]]*"([^"]*)"|.
+ ENDIF.
FIND REGEX lv_pattern IN iv_params SUBMATCHES rv_value.
IF sy-subrc <> 0.
" Try numeric value
- lv_pattern = |"{ iv_name }"\\s*:\\s*(\\d+)|.
+ IF gv_pcre_supported = abap_true.
+ lv_pattern = |"{ iv_name }"\\s*:\\s*(\\d+)|.
+ ELSE.
+ lv_pattern = |"{ iv_name }"[[:space:]]*:[[:space:]]*([[:digit:]]+)|.
+ ENDIF.
FIND REGEX lv_pattern IN iv_params SUBMATCHES rv_value.
ENDIF.
ENDMETHOD.
diff --git a/embedded/abap/zcl_vsp_apc_handler.clas.abap b/embedded/abap/zcl_vsp_apc_handler.clas.abap
index 625403db..16811308 100755
--- a/embedded/abap/zcl_vsp_apc_handler.clas.abap
+++ b/embedded/abap/zcl_vsp_apc_handler.clas.abap
@@ -21,6 +21,8 @@ CLASS zcl_vsp_apc_handler DEFINITION
DATA mv_session_id TYPE string.
CLASS-DATA gt_services TYPE STANDARD TABLE OF REF TO zif_vsp_service WITH KEY table_line.
+ " abap_true on 7.55+; abap_false on older releases that only support POSIX ERE
+ CLASS-DATA gv_pcre_supported TYPE abap_bool.
METHODS parse_message
IMPORTING iv_text TYPE string
@@ -52,11 +54,25 @@ ENDCLASS.
CLASS zcl_vsp_apc_handler IMPLEMENTATION.
METHOD class_constructor.
+ " Probe for PCRE support: \d was introduced in ABAP 7.55 (POSIX ERE on older releases).
+ " A runtime probe is more reliable than comparing sy-saprl strings.
+ TRY.
+ DATA lv_pcre_probe TYPE string.
+ FIND REGEX '\d' IN '1' SUBMATCHES lv_pcre_probe.
+ gv_pcre_supported = xsdbool( sy-subrc = 0 AND lv_pcre_probe = '1' ).
+ CATCH cx_root.
+ gv_pcre_supported = abap_false.
+ ENDTRY.
+
APPEND NEW zcl_vsp_rfc_service( ) TO gt_services.
- APPEND NEW zcl_vsp_debug_service( ) TO gt_services.
- APPEND NEW zcl_vsp_amdp_service( ) TO gt_services.
- APPEND NEW zcl_vsp_git_service( ) TO gt_services.
- APPEND NEW zcl_vsp_report_service( ) TO gt_services.
+* Skipped on classic ECC: debug/amdp/git/report services depend on
+* types/classes (if_amdp_dbg_main, ZCL_ABAPGIT_*, S/4 RAP runtime) that
+* are absent on this release. The RFC service is sufficient for vsp's
+* enhancement-source bridge (RPY_PROGRAM_READ via the rfc domain).
+* APPEND NEW zcl_vsp_debug_service( ) TO gt_services.
+* APPEND NEW zcl_vsp_amdp_service( ) TO gt_services.
+* APPEND NEW zcl_vsp_git_service( ) TO gt_services.
+* APPEND NEW zcl_vsp_report_service( ) TO gt_services.
ENDMETHOD.
METHOD if_apc_wsp_extension~on_start.
@@ -114,9 +130,16 @@ CLASS zcl_vsp_apc_handler IMPLEMENTATION.
METHOD parse_message.
TRY.
- FIND PCRE '"id"\s*:\s*"([^"]*)"' IN iv_text SUBMATCHES rs_message-id.
- FIND PCRE '"domain"\s*:\s*"([^"]*)"' IN iv_text SUBMATCHES rs_message-domain.
- FIND PCRE '"action"\s*:\s*"([^"]*)"' IN iv_text SUBMATCHES rs_message-action.
+ " PCRE (7.55+): \s / \d | POSIX ERE (pre-7.55): [[:space:]] / [[:digit:]]
+ IF gv_pcre_supported = abap_true.
+ FIND REGEX '"id"\s*:\s*"([^"]*)"' IN iv_text SUBMATCHES rs_message-id.
+ FIND REGEX '"domain"\s*:\s*"([^"]*)"' IN iv_text SUBMATCHES rs_message-domain.
+ FIND REGEX '"action"\s*:\s*"([^"]*)"' IN iv_text SUBMATCHES rs_message-action.
+ ELSE.
+ FIND REGEX '"id"[[:space:]]*:[[:space:]]*"([^"]*)"' IN iv_text SUBMATCHES rs_message-id.
+ FIND REGEX '"domain"[[:space:]]*:[[:space:]]*"([^"]*)"' IN iv_text SUBMATCHES rs_message-domain.
+ FIND REGEX '"action"[[:space:]]*:[[:space:]]*"([^"]*)"' IN iv_text SUBMATCHES rs_message-action.
+ ENDIF.
" Handle nested JSON in params by finding the balanced braces
DATA(lv_params_start) = find( val = iv_text sub = '"params"' ).
@@ -145,7 +168,11 @@ CLASS zcl_vsp_apc_handler IMPLEMENTATION.
ENDIF.
DATA lv_timeout TYPE string.
- FIND PCRE '"timeout"\s*:\s*(\d+)' IN iv_text SUBMATCHES lv_timeout.
+ IF gv_pcre_supported = abap_true.
+ FIND REGEX '"timeout"\s*:\s*(\d+)' IN iv_text SUBMATCHES lv_timeout.
+ ELSE.
+ FIND REGEX '"timeout"[[:space:]]*:[[:space:]]*([[:digit:]]+)' IN iv_text SUBMATCHES lv_timeout.
+ ENDIF.
IF sy-subrc = 0.
rs_message-timeout = lv_timeout.
ELSE.
diff --git a/embedded/abap/zcl_vsp_debug_service.clas.abap b/embedded/abap/zcl_vsp_debug_service.clas.abap
index 8f5ede72..b57d72c7 100755
--- a/embedded/abap/zcl_vsp_debug_service.clas.abap
+++ b/embedded/abap/zcl_vsp_debug_service.clas.abap
@@ -8,6 +8,7 @@ CLASS zcl_vsp_debug_service DEFINITION
PUBLIC SECTION.
INTERFACES zif_vsp_service.
+ CLASS-METHODS class_constructor.
TYPES:
BEGIN OF ty_breakpoint_state,
@@ -23,6 +24,7 @@ CLASS zcl_vsp_debug_service DEFINITION
tt_breakpoints TYPE STANDARD TABLE OF ty_breakpoint_state WITH KEY id.
PRIVATE SECTION.
+ CLASS-DATA gv_pcre_supported TYPE abap_bool.
DATA mv_session_id TYPE string.
DATA mv_debug_user TYPE sy-uname.
DATA mt_breakpoints TYPE tt_breakpoints.
@@ -128,6 +130,17 @@ ENDCLASS.
CLASS zcl_vsp_debug_service IMPLEMENTATION.
+ METHOD class_constructor.
+ " Probe for PCRE support: \d was introduced in ABAP 7.55 (POSIX ERE on older releases).
+ TRY.
+ DATA lv_pcre_probe TYPE string.
+ FIND REGEX '\d' IN '1' SUBMATCHES lv_pcre_probe.
+ gv_pcre_supported = xsdbool( sy-subrc = 0 AND lv_pcre_probe = '1' ).
+ CATCH cx_root.
+ gv_pcre_supported = abap_false.
+ ENDTRY.
+ ENDMETHOD.
+
METHOD zif_vsp_service~get_domain.
rv_domain = 'debug'.
ENDMETHOD.
@@ -1002,15 +1015,23 @@ CLASS zcl_vsp_debug_service IMPLEMENTATION.
METHOD extract_param.
DATA lv_pattern TYPE string.
- lv_pattern = |"{ iv_name }"\\s*:\\s*"([^"]*)"|.
- FIND PCRE lv_pattern IN iv_params SUBMATCHES rv_value.
+ IF gv_pcre_supported = abap_true.
+ lv_pattern = |"{ iv_name }"\\s*:\\s*"([^"]*)"|.
+ ELSE.
+ lv_pattern = |"{ iv_name }"[[:space:]]*:[[:space:]]*"([^"]*)"|.
+ ENDIF.
+ FIND REGEX lv_pattern IN iv_params SUBMATCHES rv_value.
ENDMETHOD.
METHOD extract_param_int.
DATA lv_pattern TYPE string.
DATA lv_str TYPE string.
- lv_pattern = |"{ iv_name }"\\s*:\\s*(\\d+)|.
- FIND PCRE lv_pattern IN iv_params SUBMATCHES lv_str.
+ IF gv_pcre_supported = abap_true.
+ lv_pattern = |"{ iv_name }"\\s*:\\s*(\\d+)|.
+ ELSE.
+ lv_pattern = |"{ iv_name }"[[:space:]]*:[[:space:]]*([[:digit:]]+)|.
+ ENDIF.
+ FIND REGEX lv_pattern IN iv_params SUBMATCHES lv_str.
IF sy-subrc = 0.
rv_value = lv_str.
ENDIF.
diff --git a/embedded/abap/zcl_vsp_git_service.clas.abap b/embedded/abap/zcl_vsp_git_service.clas.abap
index 77620284..9912e8b1 100644
--- a/embedded/abap/zcl_vsp_git_service.clas.abap
+++ b/embedded/abap/zcl_vsp_git_service.clas.abap
@@ -8,6 +8,7 @@ CLASS zcl_vsp_git_service DEFINITION
PUBLIC SECTION.
INTERFACES zif_vsp_service.
+ CLASS-METHODS class_constructor.
PRIVATE SECTION.
TYPES:
@@ -70,11 +71,24 @@ CLASS zcl_vsp_git_service DEFINITION
iv_error TYPE string OPTIONAL
RETURNING VALUE(rs_response) TYPE zif_vsp_service=>ty_response.
+ CLASS-DATA gv_pcre_supported TYPE abap_bool.
+
ENDCLASS.
CLASS zcl_vsp_git_service IMPLEMENTATION.
+ METHOD class_constructor.
+ " Probe for PCRE support: \d was introduced in ABAP 7.55 (POSIX ERE on older releases).
+ TRY.
+ DATA lv_pcre_probe TYPE string.
+ FIND REGEX '\d' IN '1' SUBMATCHES lv_pcre_probe.
+ gv_pcre_supported = xsdbool( sy-subrc = 0 AND lv_pcre_probe = '1' ).
+ CATCH cx_root.
+ gv_pcre_supported = abap_false.
+ ENDTRY.
+ ENDMETHOD.
+
METHOD zif_vsp_service~get_domain.
rv_domain = 'git'.
ENDMETHOD.
@@ -152,12 +166,18 @@ CLASS zcl_vsp_git_service IMPLEMENTATION.
" Extract package names from JSON params
" Format: {"packages":["$PKG1","$PKG2"],"includeSubpackages":true}
DATA lv_pkg TYPE devclass.
+ DATA lv_pattern TYPE string.
" Check for packages array
- FIND PCRE '"packages"\s*:\s*\[([^\]]*)\]' IN lv_params SUBMATCHES DATA(lv_pkgs_str).
+ IF gv_pcre_supported = abap_true.
+ lv_pattern = '"packages"\s*:\s*\[([^\]]*)\]'.
+ ELSE.
+ lv_pattern = '"packages"[[:space:]]*:[[:space:]]*\[([^\]]*)\]'.
+ ENDIF.
+ FIND REGEX lv_pattern IN lv_params SUBMATCHES DATA(lv_pkgs_str).
IF sy-subrc = 0.
" Parse comma-separated quoted package names
- FIND ALL OCCURRENCES OF PCRE '"([^"]+)"' IN lv_pkgs_str
+ FIND ALL OCCURRENCES OF REGEX '"([^"]+)"' IN lv_pkgs_str
RESULTS DATA(lt_matches).
LOOP AT lt_matches INTO DATA(ls_match).
@@ -170,14 +190,24 @@ CLASS zcl_vsp_git_service IMPLEMENTATION.
ENDIF.
" Check for includeSubpackages flag
- FIND PCRE '"includeSubpackages"\s*:\s*(true|false)' IN lv_params SUBMATCHES DATA(lv_sub_flag).
+ IF gv_pcre_supported = abap_true.
+ lv_pattern = '"includeSubpackages"\s*:\s*(true|false)'.
+ ELSE.
+ lv_pattern = '"includeSubpackages"[[:space:]]*:[[:space:]]*(true|false)'.
+ ENDIF.
+ FIND REGEX lv_pattern IN lv_params SUBMATCHES DATA(lv_sub_flag).
lv_include_sub = xsdbool( lv_sub_flag = 'true' OR lv_sub_flag IS INITIAL ).
" Check for objects array (alternative to packages)
- FIND PCRE '"objects"\s*:\s*\[([^\]]*)\]' IN lv_params SUBMATCHES DATA(lv_objs_str).
+ IF gv_pcre_supported = abap_true.
+ lv_pattern = '"objects"\s*:\s*\[([^\]]*)\]'.
+ ELSE.
+ lv_pattern = '"objects"[[:space:]]*:[[:space:]]*\[([^\]]*)\]'.
+ ENDIF.
+ FIND REGEX lv_pattern IN lv_params SUBMATCHES DATA(lv_objs_str).
IF sy-subrc = 0 AND lt_packages IS INITIAL.
" Parse objects like: {"type":"CLAS","name":"ZCL_TEST"}
- FIND ALL OCCURRENCES OF PCRE '\{"type":"([^"]+)","name":"([^"]+)"\}' IN lv_objs_str
+ FIND ALL OCCURRENCES OF REGEX '\{"type":"([^"]+)","name":"([^"]+)"\}' IN lv_objs_str
RESULTS DATA(lt_obj_matches).
LOOP AT lt_obj_matches INTO DATA(ls_obj_match).
diff --git a/embedded/abap/zcl_vsp_report_service.clas.abap b/embedded/abap/zcl_vsp_report_service.clas.abap
index 0784afe6..7e957791 100644
--- a/embedded/abap/zcl_vsp_report_service.clas.abap
+++ b/embedded/abap/zcl_vsp_report_service.clas.abap
@@ -5,8 +5,10 @@ CLASS zcl_vsp_report_service DEFINITION
PUBLIC SECTION.
INTERFACES zif_vsp_service.
+ CLASS-METHODS class_constructor.
PRIVATE SECTION.
+ CLASS-DATA gv_pcre_supported TYPE abap_bool.
METHODS handle_run_report
IMPORTING is_message TYPE zif_vsp_service=>ty_message
RETURNING VALUE(rs_response) TYPE zif_vsp_service=>ty_response.
@@ -48,6 +50,17 @@ ENDCLASS.
CLASS zcl_vsp_report_service IMPLEMENTATION.
+ METHOD class_constructor.
+ " Probe for PCRE support: \d was introduced in ABAP 7.55 (POSIX ERE on older releases).
+ TRY.
+ DATA lv_pcre_probe TYPE string.
+ FIND REGEX '\d' IN '1' SUBMATCHES lv_pcre_probe.
+ gv_pcre_supported = xsdbool( sy-subrc = 0 AND lv_pcre_probe = '1' ).
+ CATCH cx_root.
+ gv_pcre_supported = abap_false.
+ ENDTRY.
+ ENDMETHOD.
+
METHOD zif_vsp_service~get_domain.
rv_domain = 'report'.
ENDMETHOD.
@@ -115,7 +128,11 @@ CLASS zcl_vsp_report_service IMPLEMENTATION.
WHILE lv_work CS '"'.
DATA lv_pname TYPE string.
DATA lv_pval TYPE string.
- FIND PCRE '"([^"]+)"\s*:\s*"([^"]*)"' IN lv_work SUBMATCHES lv_pname lv_pval.
+ IF gv_pcre_supported = abap_true.
+ FIND REGEX '"([^"]+)"\s*:\s*"([^"]*)"' IN lv_work SUBMATCHES lv_pname lv_pval.
+ ELSE.
+ FIND REGEX '"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]*)"' IN lv_work SUBMATCHES lv_pname lv_pval.
+ ENDIF.
IF sy-subrc = 0.
TRANSLATE lv_pname TO UPPER CASE.
DATA lv_selname TYPE rsscr_name.
@@ -359,7 +376,11 @@ CLASS zcl_vsp_report_service IMPLEMENTATION.
WHILE lv_work CS '"'.
DATA lv_key TYPE string.
DATA lv_val TYPE string.
- FIND PCRE '"([^"]+)"\s*:\s*"([^"]*)"' IN lv_work SUBMATCHES lv_key lv_val.
+ IF gv_pcre_supported = abap_true.
+ FIND REGEX '"([^"]+)"\s*:\s*"([^"]*)"' IN lv_work SUBMATCHES lv_key lv_val.
+ ELSE.
+ FIND REGEX '"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]*)"' IN lv_work SUBMATCHES lv_key lv_val.
+ ENDIF.
IF sy-subrc = 0.
TRANSLATE lv_key TO UPPER CASE.
REPLACE ALL OCCURRENCES OF '\"' IN lv_val WITH '"'.
@@ -394,7 +415,11 @@ CLASS zcl_vsp_report_service IMPLEMENTATION.
lv_work = lv_sym_json.
WHILE lv_work CS '"'.
CLEAR: lv_key, lv_val.
- FIND PCRE '"([^"]+)"\s*:\s*"([^"]*)"' IN lv_work SUBMATCHES lv_key lv_val.
+ IF gv_pcre_supported = abap_true.
+ FIND REGEX '"([^"]+)"\s*:\s*"([^"]*)"' IN lv_work SUBMATCHES lv_key lv_val.
+ ELSE.
+ FIND REGEX '"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]*)"' IN lv_work SUBMATCHES lv_key lv_val.
+ ENDIF.
IF sy-subrc = 0.
REPLACE ALL OCCURRENCES OF '\"' IN lv_val WITH '"'.
REPLACE ALL OCCURRENCES OF '\\' IN lv_val WITH '\'.
@@ -481,7 +506,11 @@ CLASS zcl_vsp_report_service IMPLEMENTATION.
IF sy-subrc = 0.
DATA lv_rest TYPE string.
lv_rest = iv_params+lv_pos.
- FIND PCRE ':\s*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ IF gv_pcre_supported = abap_true.
+ FIND REGEX ':\s*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ ELSE.
+ FIND REGEX ':[[:space:]]*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ ENDIF.
ENDIF.
ENDMETHOD.
diff --git a/embedded/abap/zcl_vsp_rfc_service.clas.abap b/embedded/abap/zcl_vsp_rfc_service.clas.abap
index b4c7d32c..b5b730f3 100755
--- a/embedded/abap/zcl_vsp_rfc_service.clas.abap
+++ b/embedded/abap/zcl_vsp_rfc_service.clas.abap
@@ -1,6 +1,6 @@
"!
VSP RFC Service
"! Enables dynamic RFC/BAPI calls via WebSocket.
-"! Actions: call, search, getMetadata, ping, moveToPackage
+"! Actions: call, search, getMetadata, ping, moveToPackage, runReport, readSource
CLASS zcl_vsp_rfc_service DEFINITION
PUBLIC
FINAL
@@ -8,6 +8,7 @@ CLASS zcl_vsp_rfc_service DEFINITION
PUBLIC SECTION.
INTERFACES zif_vsp_service.
+ CLASS-METHODS class_constructor.
PRIVATE SECTION.
TYPES:
@@ -44,6 +45,13 @@ CLASS zcl_vsp_rfc_service DEFINITION
IMPORTING is_message TYPE zif_vsp_service=>ty_message
RETURNING VALUE(rs_response) TYPE zif_vsp_service=>ty_response.
+ "! Read source of any program/include using native READ REPORT.
+ "! Works for SUBC=I includes (e.g. enhancement plug-in source) where
+ "! RPY_PROGRAM_READ raises CANCELLED in RFC contexts.
+ METHODS handle_read_source
+ IMPORTING is_message TYPE zif_vsp_service=>ty_message
+ RETURNING VALUE(rs_response) TYPE zif_vsp_service=>ty_response.
+
METHODS get_func_interface
IMPORTING iv_function TYPE rs38l_fnam
EXPORTING et_import TYPE tt_param_info
@@ -75,11 +83,24 @@ CLASS zcl_vsp_rfc_service DEFINITION
iv_message TYPE string
RETURNING VALUE(rs_response) TYPE zif_vsp_service=>ty_response.
+ CLASS-DATA gv_pcre_supported TYPE abap_bool.
+
ENDCLASS.
CLASS zcl_vsp_rfc_service IMPLEMENTATION.
+ METHOD class_constructor.
+ " Probe for PCRE support: \d was introduced in ABAP 7.55 (POSIX ERE on older releases).
+ TRY.
+ DATA lv_pcre_probe TYPE string.
+ FIND REGEX '\d' IN '1' SUBMATCHES lv_pcre_probe.
+ gv_pcre_supported = xsdbool( sy-subrc = 0 AND lv_pcre_probe = '1' ).
+ CATCH cx_root.
+ gv_pcre_supported = abap_false.
+ ENDTRY.
+ ENDMETHOD.
+
METHOD zif_vsp_service~get_domain.
rv_domain = 'rfc'.
ENDMETHOD.
@@ -98,6 +119,8 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
rs_response = handle_move_to_package( is_message ).
WHEN 'runReport'.
rs_response = handle_run_report( is_message ).
+ WHEN 'readSource'.
+ rs_response = handle_read_source( is_message ).
WHEN OTHERS.
rs_response = build_error(
iv_id = is_message-id
@@ -111,59 +134,15 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
ENDMETHOD.
METHOD handle_move_to_package.
- " Extract parameters: pgmid, object, obj_name, new_package
- DATA(lv_pgmid) = extract_param( iv_params = is_message-params iv_name = 'pgmid' ).
- DATA(lv_object) = extract_param( iv_params = is_message-params iv_name = 'object' ).
- DATA(lv_obj_name) = extract_param( iv_params = is_message-params iv_name = 'obj_name' ).
- DATA(lv_new_pkg) = extract_param( iv_params = is_message-params iv_name = 'new_package' ).
-
- " Validate required params
- IF lv_object IS INITIAL.
- rs_response = build_error( iv_id = is_message-id iv_code = 'MISSING_PARAM' iv_message = 'Parameter object is required (e.g., CLAS, PROG, INTF, SAPC)' ).
- RETURN.
- ENDIF.
- IF lv_obj_name IS INITIAL.
- rs_response = build_error( iv_id = is_message-id iv_code = 'MISSING_PARAM' iv_message = 'Parameter obj_name is required' ).
- RETURN.
- ENDIF.
- IF lv_new_pkg IS INITIAL.
- rs_response = build_error( iv_id = is_message-id iv_code = 'MISSING_PARAM' iv_message = 'Parameter new_package is required' ).
- RETURN.
- ENDIF.
-
- " Default pgmid to R3TR
- IF lv_pgmid IS INITIAL.
- lv_pgmid = 'R3TR'.
- ENDIF.
-
- " Uppercase all values
- TRANSLATE lv_pgmid TO UPPER CASE.
- TRANSLATE lv_object TO UPPER CASE.
- TRANSLATE lv_obj_name TO UPPER CASE.
- TRANSLATE lv_new_pkg TO UPPER CASE.
-
- " Call ZADT_CL_TADIR_MOVE to perform the move
- DATA lv_result TYPE string.
- TRY.
- lv_result = zadt_cl_tadir_move=>move_object_and_commit(
- iv_pgmid = CONV #( lv_pgmid )
- iv_object = CONV #( lv_object )
- iv_obj_name = CONV #( lv_obj_name )
- iv_new_pkg = CONV #( lv_new_pkg )
- ).
- CATCH cx_root INTO DATA(lx_error).
- rs_response = build_error( iv_id = is_message-id iv_code = 'MOVE_ERROR' iv_message = lx_error->get_text( ) ).
- RETURN.
- ENDTRY.
-
- " Build response
- DATA(lv_o) = '{'.
- DATA(lv_c) = '}'.
- DATA(lv_success) = COND string( WHEN lv_result CP 'SUCCESS*' THEN 'true' ELSE 'false' ).
- DATA lv_json TYPE string.
- lv_json = |{ lv_o }"success":{ lv_success },"pgmid":"{ lv_pgmid }","object":"{ lv_object }","obj_name":"{ lv_obj_name }","new_package":"{ lv_new_pkg }","message":"{ escape_json( lv_result ) }"{ lv_c }|.
-
- rs_response = VALUE #( id = is_message-id success = abap_true data = lv_json ).
+* Stub on classic ECC: requires ZADT_CL_TADIR_MOVE which is not installed
+* on this system. The moveToPackage action returns a graceful error. The
+* RPY_PROGRAM_READ path used by vsp's ENHO source bridge does NOT route
+* through here, so the bridge still works.
+ rs_response = build_error(
+ iv_id = is_message-id
+ iv_code = 'NOT_AVAILABLE'
+ iv_message = 'moveToPackage requires ZADT_CL_TADIR_MOVE; not installed on this system'
+ ).
ENDMETHOD.
METHOD handle_call.
@@ -192,6 +171,14 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
DATA lo_data TYPE REF TO data.
DATA lv_val TYPE string.
FIELD-SYMBOLS TYPE any.
+* classic-ECC compat: inline FIELD-SYMBOL() decls type these as `any`,
+* so LOOP AT fails the syntax check. Pre-declare with explicit
+* table/structure types so the compiler is happy.
+ FIELD-SYMBOLS TYPE STANDARD TABLE.
+ FIELD-SYMBOLS TYPE any.
+ FIELD-SYMBOLS TYPE any.
+ FIELD-SYMBOLS TYPE any.
+ FIELD-SYMBOLS TYPE any.
" Function's IMPORT params: we EXPORT values TO the function
LOOP AT lt_import INTO DATA(ls_imp).
@@ -259,7 +246,7 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
DATA lv_first TYPE abap_bool VALUE abap_true.
DATA lv_str TYPE string.
LOOP AT lt_ptab INTO DATA(ls_out) WHERE kind = abap_func_importing.
- ASSIGN ls_out-value->* TO FIELD-SYMBOL().
+ ASSIGN ls_out-value->* TO .
IF sy-subrc = 0.
IF lv_first = abap_false.
lv_json = |{ lv_json },|.
@@ -284,7 +271,7 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
IF lv_exp_first = abap_false.
lv_json = |{ lv_json },|.
ENDIF.
- ASSIGN COMPONENT ls_exp_comp-name OF STRUCTURE TO FIELD-SYMBOL().
+ ASSIGN COMPONENT ls_exp_comp-name OF STRUCTURE TO .
IF sy-subrc = 0.
DATA(lo_comp_type) = cl_abap_typedescr=>describe_by_data( ).
IF lo_comp_type->kind = cl_abap_typedescr=>kind_elem.
@@ -311,7 +298,7 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
lv_first = abap_true.
LOOP AT lt_ptab INTO ls_out WHERE kind = abap_func_tables.
- ASSIGN ls_out-value->* TO FIELD-SYMBOL().
+ ASSIGN ls_out-value->* TO .
IF sy-subrc = 0.
IF lv_first = abap_false.
lv_json = |{ lv_json },|.
@@ -322,7 +309,7 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
DATA lv_row_first TYPE abap_bool.
lv_row_first = abap_true.
TRY.
- LOOP AT ASSIGNING FIELD-SYMBOL().
+ LOOP AT ASSIGNING .
IF lv_row_first = abap_false.
lv_json = |{ lv_json },|.
ENDIF.
@@ -334,7 +321,7 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
IF lv_comp_first = abap_false.
lv_json = |{ lv_json },|.
ENDIF.
- ASSIGN COMPONENT ls_comp-name OF STRUCTURE TO FIELD-SYMBOL().
+ ASSIGN COMPONENT ls_comp-name OF STRUCTURE TO .
IF sy-subrc = 0.
DATA(lo_type) = cl_abap_typedescr=>describe_by_data( ).
IF lo_type->kind = cl_abap_typedescr=>kind_elem.
@@ -568,7 +555,11 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
IF sy-subrc = 0.
DATA lv_rest TYPE string.
lv_rest = iv_params+lv_pos.
- FIND REGEX ':\s*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ IF gv_pcre_supported = abap_true.
+ FIND REGEX ':\s*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ ELSE.
+ FIND REGEX ':[[:space:]]*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ ENDIF.
ENDIF.
ENDMETHOD.
@@ -656,4 +647,51 @@ CLASS zcl_vsp_rfc_service IMPLEMENTATION.
rs_response = VALUE #( id = is_message-id success = abap_true data = lv_json ).
ENDMETHOD.
+
+ METHOD handle_read_source.
+ " Native READ REPORT — reads source for any program/include, including
+ " SUBC=I (enhancement plug-in source). Bypasses RPY_PROGRAM_READ which
+ " raises CANCELLED in RFC contexts on classic ECC.
+ DATA(lv_progname_str) = extract_param( iv_params = is_message-params iv_name = 'program' ).
+ IF lv_progname_str IS INITIAL.
+ rs_response = build_error( iv_id = is_message-id iv_code = 'MISSING_PARAM' iv_message = 'Parameter program is required' ).
+ RETURN.
+ ENDIF.
+ TRANSLATE lv_progname_str TO UPPER CASE.
+
+ DATA lv_progname TYPE progname.
+ lv_progname = lv_progname_str.
+
+ DATA lt_source TYPE TABLE OF string.
+
+ TRY.
+ READ REPORT lv_progname INTO lt_source.
+ CATCH cx_root INTO DATA(lx_err).
+ rs_response = build_error( iv_id = is_message-id iv_code = 'READ_FAILED' iv_message = lx_err->get_text( ) ).
+ RETURN.
+ ENDTRY.
+
+ IF sy-subrc <> 0.
+ rs_response = build_error( iv_id = is_message-id iv_code = 'NOT_FOUND' iv_message = |Program { lv_progname } not found| ).
+ RETURN.
+ ENDIF.
+
+ DATA(lv_o) = '{'.
+ DATA(lv_c) = '}'.
+ DATA lv_json TYPE string.
+ lv_json = |{ lv_o }"program":"{ escape_json( lv_progname_str ) }","source":[|.
+
+ DATA lv_first TYPE abap_bool VALUE abap_true.
+ LOOP AT lt_source INTO DATA(lv_line).
+ IF lv_first = abap_false.
+ lv_json = |{ lv_json },|.
+ ENDIF.
+ lv_json = |{ lv_json }"{ escape_json( lv_line ) }"|.
+ lv_first = abap_false.
+ ENDLOOP.
+
+ lv_json = |{ lv_json }]{ lv_c }|.
+ rs_response = VALUE #( id = is_message-id success = abap_true data = lv_json ).
+ ENDMETHOD.
+
ENDCLASS.
diff --git a/embedded/abap/zcl_vsp_utils.clas.abap b/embedded/abap/zcl_vsp_utils.clas.abap
index 555151d1..7fe1c2b3 100644
--- a/embedded/abap/zcl_vsp_utils.clas.abap
+++ b/embedded/abap/zcl_vsp_utils.clas.abap
@@ -76,11 +76,25 @@ CLASS zcl_vsp_utils DEFINITION
IMPORTING it_parts TYPE string_table
RETURNING VALUE(rv_json) TYPE string.
+ CLASS-METHODS class_constructor.
+ CLASS-DATA gv_pcre_supported TYPE abap_bool.
+
ENDCLASS.
CLASS zcl_vsp_utils IMPLEMENTATION.
+ METHOD class_constructor.
+ " Probe for PCRE support: \d was introduced in ABAP 7.55 (POSIX ERE on older releases).
+ TRY.
+ DATA lv_pcre_probe TYPE string.
+ FIND REGEX '\d' IN '1' SUBMATCHES lv_pcre_probe.
+ gv_pcre_supported = xsdbool( sy-subrc = 0 AND lv_pcre_probe = '1' ).
+ CATCH cx_root.
+ gv_pcre_supported = abap_false.
+ ENDTRY.
+ ENDMETHOD.
+
METHOD escape_json.
rv_escaped = iv_string.
REPLACE ALL OCCURRENCES OF '\' IN rv_escaped WITH '\\'.
@@ -101,15 +115,24 @@ CLASS zcl_vsp_utils IMPLEMENTATION.
FIND lv_search IN iv_params MATCH OFFSET lv_pos.
IF sy-subrc = 0.
DATA(lv_rest) = iv_params+lv_pos.
- FIND PCRE ':\s*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ IF gv_pcre_supported = abap_true.
+ FIND REGEX ':\s*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ ELSE.
+ FIND REGEX ':[[:space:]]*"([^"]*)"' IN lv_rest SUBMATCHES rv_value.
+ ENDIF.
ENDIF.
ENDMETHOD.
METHOD extract_param_int.
DATA lv_str TYPE string.
- DATA(lv_pattern) = |"{ iv_name }"\\s*:\\s*(\\d+)|.
- FIND PCRE lv_pattern IN iv_params SUBMATCHES lv_str.
+ DATA lv_pattern TYPE string.
+ IF gv_pcre_supported = abap_true.
+ lv_pattern = |"{ iv_name }"\\s*:\\s*(\\d+)|.
+ ELSE.
+ lv_pattern = |"{ iv_name }"[[:space:]]*:[[:space:]]*([[:digit:]]+)|.
+ ENDIF.
+ FIND REGEX lv_pattern IN iv_params SUBMATCHES lv_str.
IF sy-subrc = 0.
rv_value = lv_str.
ENDIF.
diff --git a/internal/mcp/handlers_amdp.go b/internal/mcp/handlers_amdp.go
index 4977b57f..3daf5371 100644
--- a/internal/mcp/handlers_amdp.go
+++ b/internal/mcp/handlers_amdp.go
@@ -51,6 +51,9 @@ func (s *Server) handleAMDPDebuggerStart(ctx context.Context, request mcp.CallTo
s.config.Password,
s.config.InsecureSkipVerify,
)
+ if len(s.config.Cookies) > 0 {
+ s.amdpWSClient.SetCookies(s.config.Cookies)
+ }
// Connect to ZADT_VSP WebSocket
if err := s.amdpWSClient.Connect(ctx); err != nil {
diff --git a/internal/mcp/handlers_analysis.go b/internal/mcp/handlers_analysis.go
index e425d27a..3172fb07 100644
--- a/internal/mcp/handlers_analysis.go
+++ b/internal/mcp/handlers_analysis.go
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/oisee/vibing-steampunk/pkg/adt"
@@ -52,10 +53,52 @@ func (s *Server) routeAnalysisAction(ctx context.Context, action, objectType, ob
return s.callHandler(ctx, s.handleTransportBoundaries, params)
case "cr_boundaries":
return s.callHandler(ctx, s.handleCRBoundaries, params)
+ case "enhancements_on":
+ return s.callHandler(ctx, s.handleEnhancementsOn, params)
}
return nil, false, nil
}
+// handleEnhancementsOn lists ENHO implementations that target a given object.
+// Today supports INCL targets. The target is expressed as "TYPE NAME" (e.g.
+// "INCL RVKMP901") either via the universal handler's target param or via
+// separate object_type/object_name params.
+func (s *Server) handleEnhancementsOn(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ args := request.GetArguments()
+
+ typeName, _ := args["object_type"].(string)
+ objectName, _ := args["object_name"].(string)
+ if typeName == "" || objectName == "" {
+ if target, ok := args["target"].(string); ok {
+ typeName, objectName = parseTarget(target)
+ }
+ }
+
+ if typeName == "" || objectName == "" {
+ return newToolResultError("enhancements_on requires target (e.g. \"INCL RVKMP901\") or object_type + object_name"), nil
+ }
+
+ switch typeName {
+ case "INCL":
+ refs, err := s.adtClient.ListEnhancementsForInclude(ctx, objectName)
+ if err != nil {
+ return newToolResultError(fmt.Sprintf("ListEnhancementsForInclude failed: %v", err)), nil
+ }
+ payload := map[string]any{
+ "target": fmt.Sprintf("INCL %s", strings.ToUpper(objectName)),
+ "matches": refs,
+ "matchCount": len(refs),
+ }
+ if len(refs) == 0 {
+ payload["message"] = "No enhancements target this include."
+ }
+ out, _ := json.MarshalIndent(payload, "", " ")
+ return mcp.NewToolResultText(string(out)), nil
+ default:
+ return newToolResultError(fmt.Sprintf("enhancements_on does not yet support target type %s (INCL only)", typeName)), nil
+ }
+}
+
// --- Code Analysis Infrastructure Handlers ---
func (s *Server) handleGetCallGraph(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
diff --git a/internal/mcp/handlers_debugger.go b/internal/mcp/handlers_debugger.go
index 9037867c..e0347e9e 100644
--- a/internal/mcp/handlers_debugger.go
+++ b/internal/mcp/handlers_debugger.go
@@ -49,6 +49,9 @@ func (s *Server) ensureDebugWSClient(ctx context.Context) error {
s.config.Password,
s.config.InsecureSkipVerify,
)
+ if len(s.config.Cookies) > 0 {
+ s.debugWSClient.SetCookies(s.config.Cookies)
+ }
return s.debugWSClient.Connect(ctx)
}
diff --git a/internal/mcp/handlers_grep.go b/internal/mcp/handlers_grep.go
index defeef27..4002760b 100644
--- a/internal/mcp/handlers_grep.go
+++ b/internal/mcp/handlers_grep.go
@@ -42,28 +42,61 @@ func (s *Server) routeGrepAction(ctx context.Context, action, objectType, object
// --- Grep/Search Handlers ---
+// readIncludeEnhancementsFlag reads the include_enhancements param. Default
+// is true — the whole point of the MCP grep surface is "Claude can see what
+// SE80 sees", which on classic ECC means walking ENHO plug-in bodies that
+// the raw source endpoint never returns.
+func readIncludeEnhancementsFlag(args map[string]interface{}) bool {
+ if v, ok := args["include_enhancements"].(bool); ok {
+ return v
+ }
+ return true
+}
+
+// readMaxEnhancementsParam reads the max_enhancements cap. 0 ⇒ default cap
+// (50, defined in workflows_grep.go).
+func readMaxEnhancementsParam(args map[string]interface{}) int {
+ if v, ok := args["max_enhancements"].(float64); ok && v > 0 {
+ return int(v)
+ }
+ return 0
+}
+
func (s *Server) handleGrepObject(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- objectURL, ok := request.GetArguments()["object_url"].(string)
+ args := request.GetArguments()
+
+ objectURL, ok := args["object_url"].(string)
if !ok || objectURL == "" {
return newToolResultError("object_url is required"), nil
}
- pattern, ok := request.GetArguments()["pattern"].(string)
+ pattern, ok := args["pattern"].(string)
if !ok || pattern == "" {
return newToolResultError("pattern is required"), nil
}
caseInsensitive := false
- if ci, ok := request.GetArguments()["case_insensitive"].(bool); ok {
+ if ci, ok := args["case_insensitive"].(bool); ok {
caseInsensitive = ci
}
contextLines := 0
- if cl, ok := request.GetArguments()["context_lines"].(float64); ok {
+ if cl, ok := args["context_lines"].(float64); ok {
contextLines = int(cl)
}
- result, err := s.adtClient.GrepObject(ctx, objectURL, pattern, caseInsensitive, contextLines)
+ includeEnhancements := readIncludeEnhancementsFlag(args)
+
+ var (
+ result interface{}
+ err error
+ )
+ if includeEnhancements {
+ // Self-contained walk — no shared state needed for a single object.
+ result, err = s.adtClient.GrepObjectWithEnhancements(ctx, objectURL, pattern, caseInsensitive, contextLines, nil)
+ } else {
+ result, err = s.adtClient.GrepObject(ctx, objectURL, pattern, caseInsensitive, contextLines)
+ }
if err != nil {
return newToolResultError(fmt.Sprintf("GrepObject failed: %v", err)), nil
}
@@ -73,37 +106,49 @@ func (s *Server) handleGrepObject(ctx context.Context, request mcp.CallToolReque
}
func (s *Server) handleGrepPackage(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- packageName, ok := request.GetArguments()["package_name"].(string)
+ args := request.GetArguments()
+
+ packageName, ok := args["package_name"].(string)
if !ok || packageName == "" {
return newToolResultError("package_name is required"), nil
}
- pattern, ok := request.GetArguments()["pattern"].(string)
+ pattern, ok := args["pattern"].(string)
if !ok || pattern == "" {
return newToolResultError("pattern is required"), nil
}
caseInsensitive := false
- if ci, ok := request.GetArguments()["case_insensitive"].(bool); ok {
+ if ci, ok := args["case_insensitive"].(bool); ok {
caseInsensitive = ci
}
// Parse object_types (comma-separated string to slice)
var objectTypes []string
- if ot, ok := request.GetArguments()["object_types"].(string); ok && ot != "" {
+ if ot, ok := args["object_types"].(string); ok && ot != "" {
objectTypes = strings.Split(ot, ",")
- // Trim whitespace from each type
for i := range objectTypes {
objectTypes[i] = strings.TrimSpace(objectTypes[i])
}
}
maxResults := 100 // default
- if mr, ok := request.GetArguments()["max_results"].(float64); ok {
+ if mr, ok := args["max_results"].(float64); ok {
maxResults = int(mr)
}
- result, err := s.adtClient.GrepPackage(ctx, packageName, pattern, caseInsensitive, objectTypes, maxResults)
+ includeEnhancements := readIncludeEnhancementsFlag(args)
+ maxEnhancements := readMaxEnhancementsParam(args)
+
+ var (
+ result interface{}
+ err error
+ )
+ if includeEnhancements {
+ result, err = s.adtClient.GrepPackageWithEnhancements(ctx, packageName, pattern, caseInsensitive, objectTypes, maxResults, maxEnhancements)
+ } else {
+ result, err = s.adtClient.GrepPackage(ctx, packageName, pattern, caseInsensitive, objectTypes, maxResults)
+ }
if err != nil {
return newToolResultError(fmt.Sprintf("GrepPackage failed: %v", err)), nil
}
diff --git a/internal/mcp/handlers_help.go b/internal/mcp/handlers_help.go
index 1529f40a..6f52acef 100644
--- a/internal/mcp/handlers_help.go
+++ b/internal/mcp/handlers_help.go
@@ -33,6 +33,12 @@ Read with options:
SAP(action="read", target="CLAS ZCL_TEST", params={"method": "GET_DATA"})
SAP(action="read", target="CLAS ZCL_TEST", params={"include_context": false})
+Read enhancements:
+ SAP(action="read", target="ENHO Y_MY_ENHANCEMENT") - Enhancement source (returns metadata + body when ADT exposes it)
+ SAP(action="read", target="INCL RVKMP901") - INCL reads append a "* === Enhancements attached ===" footer listing each ENHO that targets this include
+ SAP(action="read", target="INCL RVKMP901", params={"merged": true}) - SE80-style spliced view (anchors + ENHO bodies inline)
+ SAP(action="analyze", params={"type": "enhancements_on", "target": "INCL RVKMP901"}) - Reverse lookup, JSON
+
Read metadata:
SAP(action="read", target="TABL ZTABLE") - Table definition
SAP(action="read", target="TABL_CONTENTS ZTABLE") - Table data
@@ -380,7 +386,7 @@ func getUnhandledErrorMessage(action, objectType, objectName string) string {
switch action {
case "read":
- sb.WriteString("Supported read targets: CLAS, PROG, INTF, FUNC, FUGR, INCL, DDLS, BDEF, SRVD, TABL, TABL_CONTENTS, DEVC, MSAG, TRAN, TYPE_INFO, STRUCT, CDS_DEPS\n")
+ sb.WriteString("Supported read targets: CLAS, PROG, INTF, FUNC, FUGR, INCL, DDLS, BDEF, SRVD, TABL, TABL_CONTENTS, DEVC, MSAG, TRAN, TYPE_INFO, STRUCT, CDS_DEPS, ENHO\n")
sb.WriteString("Use SAP(action=\"help\", target=\"read\") for examples.")
case "edit":
sb.WriteString("Supported edit targets: CLAS, PROG, INTF, DDLS, BDEF, SRVD, LOCK, UNLOCK, UPDATE_SOURCE, ACTIVATE, ACTIVATE_PACKAGE, EDITSOURCE, PUBLISH_SERVICE, UNPUBLISH_SERVICE\n")
diff --git a/internal/mcp/handlers_source.go b/internal/mcp/handlers_source.go
index 40b5043c..7bb8fd1b 100644
--- a/internal/mcp/handlers_source.go
+++ b/internal/mcp/handlers_source.go
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/oisee/vibing-steampunk/pkg/adt"
@@ -16,9 +17,9 @@ import (
// routeSourceAction routes "read" for GetSource and "edit" for WriteSource/EditSource.
func (s *Server) routeSourceAction(ctx context.Context, action, objectType, objectName string, params map[string]any) (*mcp.CallToolResult, bool, error) {
if action == "read" {
- // GetSource covers: CLAS, PROG, INTF, FUNC, FUGR, INCL, DDLS, BDEF, SRVD, MSAG, VIEW
+ // GetSource covers: CLAS, PROG, INTF, FUNC, FUGR, INCL, DDLS, BDEF, SRVD, MSAG, VIEW, ENHO
switch objectType {
- case "CLAS", "PROG", "INTF", "FUNC", "FUGR", "INCL", "DDLS", "BDEF", "SRVD", "MSAG", "VIEW":
+ case "CLAS", "PROG", "INTF", "FUNC", "FUGR", "INCL", "DDLS", "BDEF", "SRVD", "MSAG", "VIEW", "ENHO":
args := map[string]any{
"object_type": objectType,
"name": objectName,
@@ -38,6 +39,9 @@ func (s *Server) routeSourceAction(ctx context.Context, action, objectType, obje
if v, ok := getFloatParam(params, "max_deps"); ok {
args["max_deps"] = v
}
+ if v, ok := getBoolParam(params, "merged"); ok {
+ args["merged"] = v
+ }
return s.callHandler(ctx, s.handleGetSource, args)
}
}
@@ -167,11 +171,13 @@ func (s *Server) handleGetSource(ctx context.Context, request mcp.CallToolReques
parent, _ := request.GetArguments()["parent"].(string)
include, _ := request.GetArguments()["include"].(string)
method, _ := request.GetArguments()["method"].(string)
+ merged, _ := request.GetArguments()["merged"].(bool)
opts := &adt.GetSourceOptions{
Parent: parent,
Include: include,
Method: method,
+ Merged: merged,
}
source, err := s.adtClient.GetSource(ctx, objectType, name, opts)
@@ -200,9 +206,103 @@ func (s *Server) handleGetSource(ctx context.Context, request mcp.CallToolReques
}
}
+ // Append "Enhancements attached" footer for INCL reads (and only when the
+ // caller did not opt out of contextual enrichment via include_context=false).
+ // Soft-fail: any lookup or fetch error stays a comment in the footer rather
+ // than replacing the source the caller already has.
+ if includeContext && strings.ToUpper(objectType) == "INCL" && !merged {
+ source = appendEnhancementsFooter(ctx, s, source, name)
+ }
+
return mcp.NewToolResultText(source), nil
}
+// appendEnhancementsFooter appends a "* === Enhancements attached ===" block
+// listing each ENHO that targets the include. When source-body fetch succeeds,
+// the body is rendered inline (via renderEnhBlock). When it fails — common on
+// classic ECC where HOOK_IMPL bodies are not exposed via REST — a placeholder
+// pointing at SE80 is rendered instead.
+func appendEnhancementsFooter(ctx context.Context, s *Server, source, includeName string) string {
+ refs, err := s.adtClient.ListEnhancementsForInclude(ctx, includeName)
+ if err != nil || len(refs) == 0 {
+ return source
+ }
+
+ var b strings.Builder
+ b.WriteString("\n\n* === Enhancements attached to ")
+ b.WriteString(strings.ToUpper(includeName))
+ b.WriteString(fmt.Sprintf(" (%d) ===\n", len(refs)))
+ for _, r := range refs {
+ b.WriteString(fmt.Sprintf("* ENHO/%s %s", r.Kind, r.Name))
+ if r.PackageName != "" {
+ b.WriteString(fmt.Sprintf(" (package %s)", r.PackageName))
+ }
+ if r.Description != "" {
+ b.WriteString(" — ")
+ b.WriteString(r.Description)
+ }
+ b.WriteString("\n")
+ if r.HostProgram != "" {
+ b.WriteString("* host: ")
+ b.WriteString(r.HostProgram)
+ if r.EnhInclude != "" {
+ b.WriteString(" (plugin source: ")
+ b.WriteString(r.EnhInclude)
+ b.WriteString(")")
+ }
+ b.WriteString("\n")
+ }
+ if r.FullName != "" {
+ b.WriteString("* anchor: ")
+ b.WriteString(r.FullName)
+ b.WriteString("\n")
+ }
+
+ // Pass the ref by pointer so EnhInclude (populated by the ENHINCINX
+ // table fallback) survives the body fetch. GetEnhancement(name) would
+ // re-resolve via SearchObject and drop EnhInclude, forcing the RFC
+ // step to guess E — which fails for HOOK_IMPL plug-ins whose
+ // REPOSRC names use `=`-padding (ISM_SAPLVKMP==================E).
+ refCopy := r
+ body, ferr := s.adtClient.GetEnhancementByRef(ctx, &refCopy)
+ if ferr != nil {
+ b.WriteString(fmt.Sprintf("* [source body unavailable: %v]\n", ferr))
+ continue
+ }
+ b.WriteString(adtRenderEnhBlock(r, body))
+ }
+ return source + b.String()
+}
+
+// adtRenderEnhBlock renders an ENHO block in the same shape as the package-
+// internal renderEnhBlock helper. Inlined here because the helper is
+// unexported in pkg/adt; keeping a minimal copy avoids an export-only diff.
+func adtRenderEnhBlock(ref adt.EnhancementRef, source string) string {
+ var b strings.Builder
+ kind := string(ref.Kind)
+ if kind == "" {
+ kind = "?"
+ }
+ b.WriteString("\n* vvv ENHO/")
+ b.WriteString(kind)
+ b.WriteString(" ")
+ b.WriteString(ref.Name)
+ if ref.PackageName != "" {
+ b.WriteString(" (package ")
+ b.WriteString(ref.PackageName)
+ b.WriteString(")")
+ }
+ b.WriteString(" vvv\n")
+ b.WriteString(source)
+ if !strings.HasSuffix(source, "\n") {
+ b.WriteString("\n")
+ }
+ b.WriteString("* ^^^ end of ")
+ b.WriteString(ref.Name)
+ b.WriteString(" ^^^\n")
+ return b.String()
+}
+
// handleWriteSource handles the unified WriteSource tool call
func (s *Server) handleWriteSource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
objectType, ok := request.GetArguments()["object_type"].(string)
@@ -373,7 +473,18 @@ func (s *Server) handleGrepObjects(ctx context.Context, request mcp.CallToolRequ
contextLines = int(cl)
}
- result, err := s.adtClient.GrepObjects(ctx, objectURLs, pattern, caseInsensitive, contextLines)
+ includeEnhancements := readIncludeEnhancementsFlag(request.GetArguments())
+ maxEnhancements := readMaxEnhancementsParam(request.GetArguments())
+
+ var (
+ result interface{}
+ err error
+ )
+ if includeEnhancements {
+ result, err = s.adtClient.GrepObjectsWithEnhancements(ctx, objectURLs, pattern, caseInsensitive, contextLines, maxEnhancements)
+ } else {
+ result, err = s.adtClient.GrepObjects(ctx, objectURLs, pattern, caseInsensitive, contextLines)
+ }
if err != nil {
return newToolResultError(fmt.Sprintf("GrepObjects failed: %v", err)), nil
}
@@ -429,7 +540,18 @@ func (s *Server) handleGrepPackages(ctx context.Context, request mcp.CallToolReq
maxResults = int(mr)
}
- result, err := s.adtClient.GrepPackages(ctx, packages, includeSubpackages, pattern, caseInsensitive, objectTypes, maxResults)
+ includeEnhancements := readIncludeEnhancementsFlag(request.GetArguments())
+ maxEnhancements := readMaxEnhancementsParam(request.GetArguments())
+
+ var (
+ result interface{}
+ err error
+ )
+ if includeEnhancements {
+ result, err = s.adtClient.GrepPackagesWithEnhancements(ctx, packages, includeSubpackages, pattern, caseInsensitive, objectTypes, maxResults, maxEnhancements)
+ } else {
+ result, err = s.adtClient.GrepPackages(ctx, packages, includeSubpackages, pattern, caseInsensitive, objectTypes, maxResults)
+ }
if err != nil {
return newToolResultError(fmt.Sprintf("GrepPackages failed: %v", err)), nil
}
diff --git a/internal/mcp/handlers_universal.go b/internal/mcp/handlers_universal.go
index 98902dad..3aaeafdb 100644
--- a/internal/mcp/handlers_universal.go
+++ b/internal/mcp/handlers_universal.go
@@ -18,15 +18,18 @@ func (s *Server) registerUniversalTool() {
s.mcpServer.AddTool(mcp.NewTool("SAP",
mcp.WithDescription(`SAP ABAP development: read/edit/create/test/analyze/debug objects on a live SAP system.
-common target types: CLAS, PROG, INTF, FUNC, FUGR, DDLS, TABL, DEVC, BDEF, SRVD
+common target types: CLAS, PROG, INTF, FUNC, FUGR, INCL, ENHO, DDLS, TABL, DEVC, BDEF, SRVD
actions: read, edit, create, delete, search, query, grep, test, analyze, debug, system, help
some actions (analyze, test, debug, system, help) use params only — no target needed.
SAP(action="read", target="CLAS ZCL_TEST") — source + dependency context
SAP(action="read", target="CLAS ZCL_TEST", params={"method": "GET_DATA"}) — one method + context
+SAP(action="read", target="INCL RVKMP901") — include source + "Enhancements attached" footer (ENHOs that target this include)
+SAP(action="read", target="ENHO Y_MY_ENHANCEMENT") — enhancement source / metadata
SAP(action="edit", target="CLAS ZCL_TEST", params={"source": "..."}) — auto lock/activate
SAP(action="edit", target="CLAS ZCL_TEST", params={"method": "X", "source": "METHOD x.\nENDMETHOD."})
SAP(action="search", target="ZCL_*")
+SAP(action="analyze", params={"type": "enhancements_on", "target": "INCL RVKMP901"}) — reverse-lookup ENHOs targeting an include
SAP(action="analyze", params={"type": "check_boundaries", "package": "$ZDEV"})
SAP(action="help") — full docs; SAP(action="help", target="tips") — best practices`),
mcp.WithString("action",
diff --git a/internal/mcp/server.go b/internal/mcp/server.go
index b0cd7c75..b5a19629 100644
--- a/internal/mcp/server.go
+++ b/internal/mcp/server.go
@@ -253,6 +253,9 @@ func (s *Server) ensureWSConnected(ctx context.Context, toolName string) *mcp.Ca
s.amdpWSClient = adt.NewAMDPWebSocketClient(
s.config.BaseURL, s.config.Client, s.config.Username, s.config.Password, s.config.InsecureSkipVerify,
)
+ if len(s.config.Cookies) > 0 {
+ s.amdpWSClient.SetCookies(s.config.Cookies)
+ }
if err := s.amdpWSClient.Connect(ctx); err != nil {
s.amdpWSClient = nil
return newToolResultError(fmt.Sprintf("%s: WebSocket connect failed: %v", toolName, err))
diff --git a/pkg/adt/client.go b/pkg/adt/client.go
index ca1afb99..9f5e3a17 100755
--- a/pkg/adt/client.go
+++ b/pkg/adt/client.go
@@ -24,6 +24,12 @@ type Client struct {
keepAliveCancel context.CancelFunc
keepAliveDone chan struct{}
keepAliveMu sync.Mutex
+
+ // rfcFetcherFactory, when non-nil, overrides the default WebSocket-backed
+ // RFC source fetcher used by GetEnhancement's fallback path. Production
+ // callers leave this nil; tests inject a stub to avoid opening a real
+ // WebSocket. The factory is invoked once per RFC attempt.
+ rfcFetcherFactory func(ctx context.Context) (rfcSourceFetcher, error)
}
// NewClient creates a new ADT client with the given configuration.
diff --git a/pkg/adt/enhancements.go b/pkg/adt/enhancements.go
new file mode 100644
index 00000000..d84eb5d8
--- /dev/null
+++ b/pkg/adt/enhancements.go
@@ -0,0 +1,801 @@
+// Package adt — enhancement-framework (ENHO / ENHS) support.
+//
+// SAP's modern enhancement framework stores implementations as independent
+// ADT objects of type ENHO with a subtype: XH (source-code plug-in), XC
+// (class), XFB (function module), XD (interface), XBD (BAdI). SE80 merges
+// these into the host include's displayed source at render time; the raw
+// source endpoint never returns them. This file adds read-side support so
+// the MCP surface can see what a developer sees in the GUI.
+package adt
+
+import (
+ "context"
+ "encoding/xml"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// rfcSourceFetcher is the boundary used by GetEnhancement's RFC fallback path.
+// Production wiring opens a fresh DebugWebSocketClient per call (see
+// defaultRFCSourceFetcher); tests substitute a stub so the unit suite never
+// touches a real WebSocket.
+//
+// CallRFC is retained for callers that legitimately need a generic FM
+// dispatch; ReadSource is the preferred path for source bodies because it
+// uses native `READ REPORT` server-side and works for SUBC=I includes
+// (enhancement plug-in source) where RPY_PROGRAM_READ raises CANCELLED.
+type rfcSourceFetcher interface {
+ CallRFC(ctx context.Context, function string, params map[string]string) (*RFCResult, error)
+ ReadSource(ctx context.Context, program string) ([]string, error)
+ Close() error
+}
+
+// EnhancementKind is the ENHO subtype code returned by ADT search (e.g. XH).
+type EnhancementKind string
+
+// Subtype path segments used by the ADT enhancement endpoints. The search
+// API returns URIs such as `/sap/bc/adt/enhancements/enhoxh/` — singular
+// in the URI but plural in some source/main variants. Callers that already
+// have a URI from search should use it verbatim; this map is only for
+// fallback when only the name+kind are known.
+var enhoSubtypePath = map[EnhancementKind]string{
+ "XH": "enhoxhs",
+ "XC": "enhoxcs",
+ "XFB": "enhoxfbs",
+ "XD": "enhoxds",
+ "XBD": "enhoxbds",
+}
+
+// EnhancementRef is a lightweight view over a SearchResult hit that is
+// known to be an ENHO entry. Carried separately so callers don't have to
+// re-parse the ADT type string.
+type EnhancementRef struct {
+ Name string `json:"name"`
+ Kind EnhancementKind `json:"kind"` // XH / XC / XFB / XD / XBD
+ URI string `json:"uri"`
+ PackageName string `json:"packageName,omitempty"`
+ Description string `json:"description,omitempty"`
+ // FullName is the XPath-style anchor location reported by ENHINCINX for
+ // HOOK_IMPL plug-ins, e.g. "\PR:SAPLVKMP\FO:BEDINGUNG_PRUEFEN_901\SE:BEGIN\EI".
+ // Empty unless the ENHO was discovered via the table-based fallback.
+ FullName string `json:"fullName,omitempty"`
+ // HostProgram is the main program (function-group main, executable program)
+ // the enhancement attaches to — ENHINCINX.PROGRAMNAME. Empty for ENHOs
+ // discovered only via the REST surface.
+ HostProgram string `json:"hostProgram,omitempty"`
+ // EnhInclude is the REPOSRC entry holding the plug-in source; conventionally
+ // ENHNAME with an "E" suffix. Useful for SE80 navigation.
+ EnhInclude string `json:"enhInclude,omitempty"`
+}
+
+// GetEnhancement returns the ABAP source of an enhancement implementation
+// (ENHO) resolved by name. The ADT subtype (XH/XC/...) is discovered via
+// SearchObject so callers don't have to pass it.
+//
+// Errors:
+// - not-found: no ENHO/* hit matches the name.
+// - ambiguous: more than one ENHO/* hit with the same name (rare; a name
+// plus a subtype is the unique key, so collisions across subtypes are
+// technically legal).
+//
+// When you already have an EnhancementRef (e.g. from ListEnhancementsForInclude)
+// prefer GetEnhancementByRef — it preserves table-discovered fields like
+// EnhInclude that the SearchObject-based resolver drops, which is what lets
+// the RFC fallback find HOOK_IMPL plug-in sources whose REPOSRC names use
+// `=`-padding rather than the simple E convention.
+func (c *Client) GetEnhancement(ctx context.Context, name string) (string, error) {
+ name = strings.ToUpper(strings.TrimSpace(name))
+ if name == "" {
+ return "", fmt.Errorf("enhancement name is required")
+ }
+
+ ref, err := c.resolveEnhancement(ctx, name)
+ if err != nil {
+ return "", err
+ }
+
+ return c.GetEnhancementByRef(ctx, ref)
+}
+
+// GetEnhancementByRef returns the ABAP source for an already-resolved ENHO
+// reference. Walks the same 3-step resolver as GetEnhancement (REST singular
+// → REST plural → RFC) but skips the SearchObject re-resolution that
+// GetEnhancement performs.
+//
+// The point of bypassing re-resolution: callers that obtained the ref via
+// ListEnhancementsForInclude's table fallback already have ref.EnhInclude
+// populated with the real REPOSRC entry name (e.g.
+// "ISM_SAPLVKMP==================E" with `=`-padding). Going back through
+// SearchObject would discard that and force the RFC step to fall back to
+// the E convention, which doesn't exist as an entry on classic ECC for
+// most non-company HOOK_IMPL plug-ins.
+//
+// Returns the same step-4 metadata-only error as GetEnhancement when no path
+// resolves the body.
+func (c *Client) GetEnhancementByRef(ctx context.Context, ref *EnhancementRef) (string, error) {
+ if ref == nil || strings.TrimSpace(ref.Name) == "" {
+ return "", fmt.Errorf("enhancement ref is required")
+ }
+
+ // 1) Modern REST: try the URL the search/browser returned ("singular" form).
+ if uri := strings.TrimSpace(ref.URI); uri != "" {
+ if src, ok := c.tryFetchEnhancementSource(ctx, strings.TrimRight(uri, "/")+"/source/main"); ok {
+ return src, nil
+ }
+ }
+
+ // 2) Newer plural form some NetWeaver releases use.
+ if plural, ok := enhoSubtypePath[ref.Kind]; ok {
+ alt := fmt.Sprintf("/sap/bc/adt/enhancements/%s/%s/source/main",
+ plural, strings.ToLower(ref.Name))
+ if src, ok := c.tryFetchEnhancementSource(ctx, alt); ok {
+ return src, nil
+ }
+ }
+
+ // 3) RFC fallback via ZADT_VSP WebSocket. Classic ECC walls off ENHO
+ // bodies behind RPY_PROGRAM_READ — the only reliable way to read the
+ // plug-in source on those releases. ref.EnhInclude (from ENHINCINX) is
+ // the authoritative REPOSRC entry name when populated; otherwise the
+ // `E` convention is used as a best-effort guess.
+ if src, ok := c.tryFetchEnhancementSourceViaRFC(ctx, ref); ok {
+ return src, nil
+ }
+
+ // 4) No reachable source-body endpoint on this server. Surface a structured
+ // error with metadata so the caller (and ultimately the user) can navigate
+ // to the object in SE80 even though we can't return the body inline.
+ hint := ref.EnhInclude
+ if hint == "" {
+ hint = ref.Name + "E"
+ }
+ return "", fmt.Errorf(
+ "enhancement %s (%s, package %s): source body unavailable on this server "+
+ "— ADT REST does not expose HOOK_IMPL plug-ins on this NetWeaver release "+
+ "(SE80: see include %s). Install ZADT_VSP or grant the vsp cookie write "+
+ "scope to enable inline retrieval.",
+ ref.Name, ref.Kind, ref.PackageName, hint)
+}
+
+// tryFetchEnhancementSourceViaRFC opens a one-shot WebSocket to ZADT_VSP and
+// reads the plug-in's REPOSRC entry via the rfc/readSource action (native
+// READ REPORT on the server side). Returns ("", false) — without surfacing
+// the error — when the bridge is missing, the program doesn't exist, or
+// the response shape is unexpected. Step 4 (the metadata-only error)
+// handles the user-facing fallback message.
+//
+// The program name is taken from ref.EnhInclude when populated by the
+// ENHINCINX table fallback; otherwise the "E" REPOSRC convention
+// is used as a best-effort guess.
+//
+// We deliberately avoid RPY_PROGRAM_READ: on classic ECC it raises CANCELLED
+// (subrc=99 via OTHERS) inside RFC contexts because of an authorization
+// dialog the framework can't render. The readSource action wraps native
+// READ REPORT instead, which has none of that machinery.
+func (c *Client) tryFetchEnhancementSourceViaRFC(ctx context.Context, ref *EnhancementRef) (string, bool) {
+ programName := ref.EnhInclude
+ if programName == "" {
+ programName = ref.Name + "E"
+ }
+ programName = strings.ToUpper(strings.TrimSpace(programName))
+ if programName == "E" {
+ return "", false
+ }
+
+ factory := c.rfcFetcherFactory
+ if factory == nil {
+ factory = c.defaultRFCSourceFetcher
+ }
+
+ fetcher, err := factory(ctx)
+ if err != nil {
+ return "", false
+ }
+ defer fetcher.Close()
+
+ lines, err := fetcher.ReadSource(ctx, programName)
+ if err != nil || len(lines) == 0 {
+ return "", false
+ }
+ return strings.Join(lines, "\n"), true
+}
+
+// defaultRFCSourceFetcher opens a fresh DebugWebSocketClient bound to the
+// ZADT_VSP service and returns it as an rfcSourceFetcher. Used only when
+// the test factory hook is not set.
+func (c *Client) defaultRFCSourceFetcher(ctx context.Context) (rfcSourceFetcher, error) {
+ if c.config == nil {
+ return nil, fmt.Errorf("client config is nil")
+ }
+ if !c.config.HasBasicAuth() && !c.config.HasCookieAuth() {
+ return nil, fmt.Errorf("RFC source fetch requires basic-auth credentials or cookies")
+ }
+ ws := NewDebugWebSocketClient(
+ c.config.BaseURL,
+ c.config.Client,
+ c.config.Username,
+ c.config.Password,
+ c.config.InsecureSkipVerify,
+ )
+ if c.config.HasCookieAuth() {
+ ws.SetCookies(c.config.Cookies)
+ }
+ if err := ws.Connect(ctx); err != nil {
+ return nil, err
+ }
+ return ws, nil
+}
+
+// tryFetchEnhancementSource issues a single GET; returns (body, true) on 2xx.
+// Any 4xx/5xx returns ("", false) without surfacing the error so the caller
+// can fall through to the next attempt.
+func (c *Client) tryFetchEnhancementSource(ctx context.Context, path string) (string, bool) {
+ resp, err := c.transport.Request(ctx, path, &RequestOptions{
+ Method: http.MethodGet,
+ Accept: "text/plain",
+ })
+ if err != nil {
+ return "", false
+ }
+ if len(resp.Body) == 0 {
+ return "", false
+ }
+ body := string(resp.Body)
+ // ADT error responses are XML even on 200 from some upstream proxies; sniff
+ // for the exception namespace as a safety net.
+ if strings.Contains(body[:min(len(body), 256)], "exc:exception") {
+ return "", false
+ }
+ return body, true
+}
+
+// resolveEnhancement finds exactly one ENHO/* match for name via SearchObject.
+func (c *Client) resolveEnhancement(ctx context.Context, name string) (*EnhancementRef, error) {
+ results, err := c.SearchObject(ctx, name, 25)
+ if err != nil {
+ return nil, fmt.Errorf("resolving enhancement %s: %w", name, err)
+ }
+
+ var hits []EnhancementRef
+ for _, r := range results {
+ if !strings.HasPrefix(strings.ToUpper(r.Type), "ENHO/") {
+ continue
+ }
+ if !strings.EqualFold(r.Name, name) {
+ continue
+ }
+ kind := strings.TrimPrefix(strings.ToUpper(r.Type), "ENHO/")
+ hits = append(hits, EnhancementRef{
+ Name: r.Name,
+ Kind: EnhancementKind(kind),
+ URI: r.URI,
+ PackageName: r.PackageName,
+ Description: r.Description,
+ })
+ }
+
+ switch len(hits) {
+ case 0:
+ return nil, fmt.Errorf("enhancement %s not found (no ENHO/* match)", name)
+ case 1:
+ return &hits[0], nil
+ default:
+ kinds := make([]string, 0, len(hits))
+ for _, h := range hits {
+ kinds = append(kinds, string(h.Kind))
+ }
+ sort.Strings(kinds)
+ return nil, fmt.Errorf("enhancement %s is ambiguous across subtypes: %s", name, strings.Join(kinds, ", "))
+ }
+}
+
+// enhancementBrowserResponse matches the XML shape returned by
+// /sap/bc/adt/enhancements/enhoxhs — the enhancement browser's list endpoint.
+// It is structurally identical to adtcore:objectReferences, so we reuse the
+// SearchResult shape.
+type enhancementBrowserResponse struct {
+ XMLName xml.Name `xml:"objectReferences"`
+ Results []SearchResult `xml:"objectReference"`
+}
+
+// ListEnhancementsForInclude returns all ENHO implementations whose enhanced
+// object is the named include. Returns an empty slice (not an error) when
+// the include has no enhancements.
+//
+// Three back-ends are tried in order until one returns rows:
+// 1. Modern enhancement-browser REST: GET /sap/bc/adt/enhancements/enhoxhs?enhancedObjectUri=…
+// 2. Generic object-references REST: GET /sap/bc/adt/repository/informationsystem/objectreferences
+// 3. Table-based fallback (D010INC ⨝ ENHINCINX) for ECC / older NetWeaver
+// systems where the REST surface is missing for HOOK_IMPL.
+//
+// (3) is the only path that works on classic ECC; both REST endpoints 404 on
+// those systems for HOOK_IMPL plug-ins.
+func (c *Client) ListEnhancementsForInclude(ctx context.Context, includeName string) ([]EnhancementRef, error) {
+ includeName = strings.ToUpper(strings.TrimSpace(includeName))
+ if includeName == "" {
+ return nil, fmt.Errorf("include name is required")
+ }
+
+ includeURI := "/sap/bc/adt/programs/includes/" + url.PathEscape(includeName)
+
+ // 1) Modern enhancement browser.
+ if refs, err := c.queryEnhancementBrowser(ctx, includeURI); err == nil && len(refs) > 0 {
+ return refs, nil
+ }
+
+ // 2) Generic object-references fallback.
+ if refs, err := c.queryObjectReferencesForEnhancements(ctx, includeURI); err == nil && len(refs) > 0 {
+ return refs, nil
+ }
+
+ // 3) Table-based fallback. Returns its own error (or empty slice) so
+ // callers can distinguish "no enhancements attached" from "lookup failed".
+ return c.listEnhancementsForIncludeViaTable(ctx, includeName)
+}
+
+func (c *Client) queryEnhancementBrowser(ctx context.Context, enhancedURI string) ([]EnhancementRef, error) {
+ params := url.Values{}
+ params.Set("enhancedObjectUri", enhancedURI)
+
+ resp, err := c.transport.Request(ctx, "/sap/bc/adt/enhancements/enhoxhs", &RequestOptions{
+ Method: http.MethodGet,
+ Query: params,
+ Accept: "application/xml",
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ var parsed enhancementBrowserResponse
+ if xmlErr := xml.Unmarshal(resp.Body, &parsed); xmlErr != nil {
+ // Try the SearchResults shape as a secondary parse — some releases
+ // wrap in .
+ if results, parseErr := ParseSearchResults(resp.Body); parseErr == nil {
+ parsed.Results = results
+ } else {
+ return nil, fmt.Errorf("parsing enhancement browser response: %w", xmlErr)
+ }
+ }
+
+ return filterENHO(parsed.Results), nil
+}
+
+func (c *Client) queryObjectReferencesForEnhancements(ctx context.Context, enhancedURI string) ([]EnhancementRef, error) {
+ params := url.Values{}
+ params.Set("uri", enhancedURI)
+ params.Set("facet", "package")
+
+ resp, err := c.transport.Request(ctx, "/sap/bc/adt/repository/informationsystem/objectreferences", &RequestOptions{
+ Method: http.MethodGet,
+ Query: params,
+ Accept: "application/xml",
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fallback objectreferences query: %w", err)
+ }
+
+ results, err := ParseSearchResults(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("parsing objectreferences response: %w", err)
+ }
+ return filterENHO(results), nil
+}
+
+func filterENHO(results []SearchResult) []EnhancementRef {
+ refs := make([]EnhancementRef, 0, len(results))
+ for _, r := range results {
+ t := strings.ToUpper(r.Type)
+ if !strings.HasPrefix(t, "ENHO/") {
+ continue
+ }
+ refs = append(refs, EnhancementRef{
+ Name: r.Name,
+ Kind: EnhancementKind(strings.TrimPrefix(t, "ENHO/")),
+ URI: r.URI,
+ PackageName: r.PackageName,
+ Description: r.Description,
+ })
+ }
+ return refs
+}
+
+// listEnhancementsForIncludeViaTable is the table-driven fallback used when
+// the ADT enhancement-browser REST surface is missing (classic ECC / older
+// NetWeaver). It joins:
+//
+// - D010INC.INCLUDE = → MASTER (host main program)
+// - ENHINCINX.PROGRAMNAME IN (masters) → ENHO rows
+// - ENHHEADER (optional enrichment) → description, version, ENHTOOLTYPE
+//
+// Then narrows to rows whose FULL_NAME mentions a FORM that is defined in
+// includeName. When the FORM-in-include check is inconclusive (we cannot tell
+// for sure), the row is returned anyway — false positives are far less harmful
+// than false negatives for the footer use case.
+//
+// Only HOOK_IMPL (Kind XH) plug-ins live in ENHINCINX; other ENHO subtypes are
+// not surfaced via this fallback. That matches v1 scope.
+func (c *Client) listEnhancementsForIncludeViaTable(ctx context.Context, includeName string) ([]EnhancementRef, error) {
+ // Step 1: include → host main program(s).
+ masters, err := c.queryD010INCMasters(ctx, includeName)
+ if err != nil {
+ return nil, fmt.Errorf("d010inc lookup for include %s: %w", includeName, err)
+ }
+ if len(masters) == 0 {
+ // Include has no host program — either it's an executable/standalone
+ // include with no function group, or the include name is invalid. Either
+ // way, the table fallback can't help.
+ return nil, nil
+ }
+
+ // Step 2: ENHINCINX rows for those host programs.
+ enhRows, err := c.queryENHINCINXForMasters(ctx, masters)
+ if err != nil {
+ return nil, fmt.Errorf("enhincinx lookup: %w", err)
+ }
+ if len(enhRows) == 0 {
+ return nil, nil
+ }
+
+ // Step 3: optional enrichment from ENHHEADER for descriptions / version.
+ // Best-effort — failures fall back to ENHINCINX-only fields.
+ descByName := c.fetchENHHEADERDescriptions(ctx, enhRows)
+
+ // Step 4: convert to EnhancementRef. The 30-char ENHNAME truncation is
+ // pragmatic-resolved by trying ENHHEADER first (its ENHNAME is the full
+ // 60-char identifier), falling back to the truncated name.
+ refs := make([]EnhancementRef, 0, len(enhRows))
+ for _, row := range enhRows {
+ fullName := descByName.fullName(row.ENHNAME)
+ desc := descByName.description(row.ENHNAME)
+ pkg := descByName.packageName(row.ENHNAME)
+
+ refs = append(refs, EnhancementRef{
+ Name: fullName,
+ Kind: EnhancementKind("XH"), // ENHINCINX = HOOK_IMPL only
+ URI: "/sap/bc/adt/enhancements/enhoxh/" + strings.ToLower(fullName),
+ PackageName: pkg,
+ Description: desc,
+ FullName: row.FULL_NAME,
+ HostProgram: row.PROGRAMNAME,
+ EnhInclude: row.ENHINCLUDE,
+ })
+ }
+ return refs, nil
+}
+
+// enhincinxRow holds the ENHINCINX columns we read for HOOK_IMPL lookup.
+type enhincinxRow struct {
+ ENHNAME string // 30-char truncated key
+ PROGRAMNAME string
+ FULL_NAME string
+ ENHINCLUDE string
+}
+
+func (c *Client) queryD010INCMasters(ctx context.Context, includeName string) ([]string, error) {
+ // /datapreview/ddic requires a full SELECT in the body — bare WHERE clauses
+ // fail with "Only SELECT statement is allowed".
+ sql := fmt.Sprintf("SELECT MASTER FROM D010INC WHERE INCLUDE = '%s'",
+ strings.ReplaceAll(includeName, "'", "''"))
+ res, err := c.GetTableContents(ctx, "D010INC", 100, sql)
+ if err != nil {
+ return nil, err
+ }
+ masters := make([]string, 0, len(res.Rows))
+ seen := make(map[string]struct{})
+ for _, r := range res.Rows {
+ m, _ := r["MASTER"].(string)
+ m = strings.TrimSpace(m)
+ if m == "" {
+ continue
+ }
+ if _, ok := seen[m]; ok {
+ continue
+ }
+ seen[m] = struct{}{}
+ masters = append(masters, m)
+ }
+ return masters, nil
+}
+
+func (c *Client) queryENHINCINXForMasters(ctx context.Context, masters []string) ([]enhincinxRow, error) {
+ if len(masters) == 0 {
+ return nil, nil
+ }
+ // Build "PROGRAMNAME IN ('A','B',…)" inside a full SELECT — /datapreview/ddic
+ // rejects bare WHERE clauses.
+ quoted := make([]string, 0, len(masters))
+ for _, m := range masters {
+ quoted = append(quoted, "'"+strings.ReplaceAll(m, "'", "''")+"'")
+ }
+ sql := fmt.Sprintf(
+ "SELECT ENHNAME, PROGRAMNAME, FULL_NAME, ENHINCLUDE FROM ENHINCINX WHERE PROGRAMNAME IN (%s)",
+ strings.Join(quoted, ","))
+
+ res, err := c.GetTableContents(ctx, "ENHINCINX", 200, sql)
+ if err != nil {
+ return nil, err
+ }
+ rows := make([]enhincinxRow, 0, len(res.Rows))
+ for _, r := range res.Rows {
+ row := enhincinxRow{
+ ENHNAME: strings.TrimSpace(asString(r["ENHNAME"])),
+ PROGRAMNAME: strings.TrimSpace(asString(r["PROGRAMNAME"])),
+ FULL_NAME: strings.TrimSpace(asString(r["FULL_NAME"])),
+ ENHINCLUDE: strings.TrimSpace(asString(r["ENHINCLUDE"])),
+ }
+ if row.ENHNAME == "" {
+ continue
+ }
+ rows = append(rows, row)
+ }
+ return rows, nil
+}
+
+// enhHeaderInfo carries enrichment data resolved from ENHHEADER, keyed by the
+// 30-char ENHINCINX name (which is what the caller has).
+type enhHeaderInfo struct {
+ byTruncated map[string]enhHeaderHit
+}
+
+type enhHeaderHit struct {
+ FullName string
+ Description string
+ PackageName string
+}
+
+func (e enhHeaderInfo) fullName(truncated string) string {
+ if h, ok := e.byTruncated[truncated]; ok && h.FullName != "" {
+ return h.FullName
+ }
+ return truncated
+}
+
+func (e enhHeaderInfo) description(truncated string) string {
+ if h, ok := e.byTruncated[truncated]; ok {
+ return h.Description
+ }
+ return ""
+}
+
+func (e enhHeaderInfo) packageName(truncated string) string {
+ if h, ok := e.byTruncated[truncated]; ok {
+ return h.PackageName
+ }
+ return ""
+}
+
+// fetchENHHEADERDescriptions enriches ENHINCINX rows with full ENHNAME (60 char)
+// and metadata from ENHHEADER + TADIR. Best-effort; returns an empty map on any
+// hard failure so the caller still produces useful refs.
+func (c *Client) fetchENHHEADERDescriptions(ctx context.Context, rows []enhincinxRow) enhHeaderInfo {
+ info := enhHeaderInfo{byTruncated: map[string]enhHeaderHit{}}
+ if len(rows) == 0 {
+ return info
+ }
+ // ENHHEADER.ENHNAME is the full key; ENHINCINX truncates to 30. Use a
+ // per-row LIKE prefix query — not the most efficient but bounded by the
+ // number of ENHOs targeting one function group, which is small in practice.
+ for _, row := range rows {
+ // ENHHEADER active version only — /datapreview/ddic requires a full SELECT.
+ sql := fmt.Sprintf("SELECT ENHNAME FROM ENHHEADER WHERE ENHNAME LIKE '%s%%' AND VERSION = 'A'",
+ strings.ReplaceAll(row.ENHNAME, "'", "''"))
+ res, err := c.GetTableContents(ctx, "ENHHEADER", 5, sql)
+ if err != nil || res == nil || len(res.Rows) == 0 {
+ continue
+ }
+ // Prefer the row whose ENHNAME starts with the truncated key; if
+ // multiple, take the first.
+ hit := res.Rows[0]
+ info.byTruncated[row.ENHNAME] = enhHeaderHit{
+ FullName: strings.TrimSpace(asString(hit["ENHNAME"])),
+ Description: "", // ENHHEADER has SHORTTEXT_ID (a key into another table); descriptions come from search instead.
+ PackageName: "",
+ }
+ }
+ // Augment with package + description from a single ADT search per ENHO.
+ // The search endpoint is reliable on D03 even when the enhancement-browser
+ // REST is broken. Cap to first 25 rows to bound cost.
+ for i, row := range rows {
+ if i >= 25 {
+ break
+ }
+ full := info.fullName(row.ENHNAME)
+ results, err := c.SearchObject(ctx, full, 5)
+ if err != nil {
+ continue
+ }
+ for _, r := range results {
+ if !strings.HasPrefix(strings.ToUpper(r.Type), "ENHO/") {
+ continue
+ }
+ if !strings.EqualFold(r.Name, full) {
+ continue
+ }
+ h := info.byTruncated[row.ENHNAME]
+ h.FullName = r.Name
+ h.Description = r.Description
+ h.PackageName = r.PackageName
+ info.byTruncated[row.ENHNAME] = h
+ break
+ }
+ }
+ return info
+}
+
+// asString coerces a TableContentsResult cell to a string, returning "" if
+// the cell is missing or of an unexpected type.
+func asString(v interface{}) string {
+ if v == nil {
+ return ""
+ }
+ if s, ok := v.(string); ok {
+ return s
+ }
+ return fmt.Sprintf("%v", v)
+}
+
+// GetIncludeMerged returns the include's raw source with each referenced
+// ENHO implementation spliced in at its anchor. The output is not valid
+// ABAP you can re-upload — it's an annotated view that matches what SE80
+// shows with "Display Source (Modified)" enabled.
+//
+// Anchor resolution is best-effort. When an anchor cannot be located, the
+// corresponding ENHO source is appended at the end with an explanatory
+// comment header; no error is returned for a single unresolved anchor.
+//
+// When the include has no enhancements this is equivalent to GetInclude.
+func (c *Client) GetIncludeMerged(ctx context.Context, includeName string) (string, error) {
+ raw, err := c.GetInclude(ctx, includeName)
+ if err != nil {
+ return "", fmt.Errorf("merged include %s: %w", includeName, err)
+ }
+
+ refs, err := c.ListEnhancementsForInclude(ctx, includeName)
+ if err != nil {
+ // Non-fatal: return the raw include with a warning banner so the
+ // caller still gets something useful.
+ return raw + "\n\n* === enhancement lookup failed: " + err.Error() + " ===\n", nil
+ }
+ if len(refs) == 0 {
+ return raw, nil
+ }
+
+ type enhWithSource struct {
+ ref EnhancementRef
+ source string
+ }
+ enhanced := make([]enhWithSource, 0, len(refs))
+ for _, ref := range refs {
+ src, ferr := c.GetEnhancement(ctx, ref.Name)
+ if ferr != nil {
+ // Record the failure inline but keep going.
+ enhanced = append(enhanced, enhWithSource{
+ ref: ref,
+ source: fmt.Sprintf("* ", ref.Name, ferr),
+ })
+ continue
+ }
+ enhanced = append(enhanced, enhWithSource{ref: ref, source: src})
+ }
+
+ merged := raw
+ unresolved := make([]enhWithSource, 0)
+ for _, e := range enhanced {
+ ok, spliced := spliceAtAnchor(merged, e.ref, e.source)
+ if ok {
+ merged = spliced
+ continue
+ }
+ unresolved = append(unresolved, e)
+ }
+
+ if len(unresolved) > 0 {
+ var b strings.Builder
+ b.WriteString(merged)
+ if !strings.HasSuffix(merged, "\n") {
+ b.WriteString("\n")
+ }
+ b.WriteString("\n* === unresolved enhancements (anchor not found in include) ===\n")
+ for _, e := range unresolved {
+ b.WriteString(renderEnhBlock(e.ref, e.source, "anchor unresolved"))
+ }
+ merged = b.String()
+ }
+
+ return merged, nil
+}
+
+// anchorStartPattern matches the "$*$\SE:(N) Form X, Start" marker that the
+// enhancement framework writes into the host include at each plug-in point.
+// N is the anchor index inside the form/include. We use this for splicing
+// because it is the most reliable, machine-readable anchor SAP emits.
+var anchorStartPattern = regexp.MustCompile(`(?i)\$\*\$\\SE:\((\d+)\)\s+Form\s+([A-Z0-9_/]+),\s+Start`)
+
+// spliceAtAnchor finds the first unoccupied anchor line in src that could
+// host the given enhancement and inserts the enhancement's source after it.
+// Returns (false, src) unchanged if no suitable anchor was found.
+//
+// Heuristic: we insert after the first anchor Start marker we encounter
+// that does not already have an ENHANCEMENT ... ENDENHANCEMENT block
+// directly below it. For includes with multiple anchors we may not pick
+// the exact correct one without parsing the ENHO metadata — which is OK
+// because both produce a readable merged view; the caller accepts this
+// trade-off (documented on GetIncludeMerged).
+func spliceAtAnchor(src string, ref EnhancementRef, enhSource string) (bool, string) {
+ loc := anchorStartPattern.FindStringIndex(src)
+ if loc == nil {
+ return false, src
+ }
+
+ // Insert immediately after the line that contains the match.
+ lineEnd := strings.IndexByte(src[loc[1]:], '\n')
+ insertAt := loc[1]
+ if lineEnd >= 0 {
+ insertAt += lineEnd + 1
+ }
+
+ // Skip if an ENHANCEMENT block is already present just below this
+ // anchor — we already rendered this one on a previous iteration, or
+ // the include author inlined it manually.
+ tail := src[insertAt:]
+ if looksLikeEnhancementBlock(tail) {
+ // Try to find the next anchor instead.
+ rest := src[insertAt:]
+ nextLoc := anchorStartPattern.FindStringIndex(rest)
+ if nextLoc == nil {
+ return false, src
+ }
+ // Recurse into the tail.
+ ok, splicedTail := spliceAtAnchor(rest, ref, enhSource)
+ if !ok {
+ return false, src
+ }
+ return true, src[:insertAt] + splicedTail
+ }
+
+ block := renderEnhBlock(ref, enhSource, "")
+ return true, src[:insertAt] + block + src[insertAt:]
+}
+
+func looksLikeEnhancementBlock(s string) bool {
+ // Scan at most ~200 bytes; ENHANCEMENT/ENDENHANCEMENT are the markers.
+ head := s
+ if len(head) > 400 {
+ head = head[:400]
+ }
+ return regexp.MustCompile(`(?i)^\s*ENHANCEMENT\s+\d+`).MatchString(head)
+}
+
+func renderEnhBlock(ref EnhancementRef, source, note string) string {
+ var b strings.Builder
+ kind := string(ref.Kind)
+ if kind == "" {
+ kind = "?"
+ }
+ b.WriteString("\n* vvv ENHO/")
+ b.WriteString(kind)
+ b.WriteString(" ")
+ b.WriteString(ref.Name)
+ if ref.PackageName != "" {
+ b.WriteString(" (package ")
+ b.WriteString(ref.PackageName)
+ b.WriteString(")")
+ }
+ if note != "" {
+ b.WriteString(" — ")
+ b.WriteString(note)
+ }
+ b.WriteString(" vvv\n")
+ b.WriteString(source)
+ if !strings.HasSuffix(source, "\n") {
+ b.WriteString("\n")
+ }
+ b.WriteString("* ^^^ end of ")
+ b.WriteString(ref.Name)
+ b.WriteString(" ^^^\n")
+ return b.String()
+}
diff --git a/pkg/adt/enhancements_test.go b/pkg/adt/enhancements_test.go
new file mode 100644
index 00000000..76e2d8bb
--- /dev/null
+++ b/pkg/adt/enhancements_test.go
@@ -0,0 +1,678 @@
+package adt
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func newEnhancementSearchResponse(name, subtype, pkg string) *http.Response {
+ uri := "/sap/bc/adt/enhancements/enho" + strings.ToLower(subtype) + "/" + strings.ToLower(name)
+ xml := `
+
+
+`
+ return newTestResponse(xml)
+}
+
+// newRoutedMockTransport returns a mock that dispatches by exact path. Useful
+// when a test needs different bodies at different URLs (e.g. search + then
+// source fetch).
+type routedMock struct {
+ byPath map[string]*http.Response
+ requests []*http.Request
+}
+
+func (r *routedMock) Do(req *http.Request) (*http.Response, error) {
+ r.requests = append(r.requests, req)
+ if resp, ok := r.byPath[req.URL.Path]; ok {
+ return resp, nil
+ }
+ for key, resp := range r.byPath {
+ if strings.Contains(req.URL.Path, key) {
+ return resp, nil
+ }
+ }
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Body: io.NopCloser(strings.NewReader("Not found")),
+ Header: http.Header{},
+ }, nil
+}
+
+func newBody(s string) *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(s)),
+ Header: http.Header{"X-CSRF-Token": []string{"t"}},
+ }
+}
+
+func TestGetEnhancement_ResolvesSubtypeViaSearch(t *testing.T) {
+ sourceBody := `ENHANCEMENT 2 Y3EI_SKIP_BYPASS_CC_WITH_LIMIT.
+ LOOP AT lt_xfplt INTO DATA(ls_xfplt) WHERE fpltr < 900000.
+ lv_sum = lv_sum + ls_xfplt-fakwr.
+ ENDLOOP.
+ENDENHANCEMENT.`
+
+ searchResp := newEnhancementSearchResponse("Y3EI_SKIP_BYPASS_CC_WITH_LIMIT", "XH", "YSD")
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/repository/informationsystem/search": searchResp,
+ "/sap/bc/adt/enhancements/enhoxh/y3ei_skip_bypass_cc_with_limit/source/main": newBody(sourceBody),
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ got, err := client.GetEnhancement(context.Background(), "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT")
+ if err != nil {
+ t.Fatalf("GetEnhancement failed: %v", err)
+ }
+ if !strings.Contains(got, "lv_sum = lv_sum + ls_xfplt-fakwr") {
+ t.Fatalf("expected spliced enhancement source, got:\n%s", got)
+ }
+}
+
+func TestGetEnhancement_NotFound(t *testing.T) {
+ emptySearch := `
+`
+ mock := &mockTransportClient{
+ responses: map[string]*http.Response{
+ "search": newTestResponse(emptySearch),
+ "discovery": newTestResponse("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ _, err := client.GetEnhancement(context.Background(), "ZDOES_NOT_EXIST")
+ if err == nil {
+ t.Fatal("expected not-found error, got nil")
+ }
+ if !strings.Contains(err.Error(), "not found") {
+ t.Fatalf("expected not-found message, got: %v", err)
+ }
+}
+
+func TestGetEnhancement_AmbiguousAcrossSubtypes(t *testing.T) {
+ // Two ENHO hits with the same name, different subtypes.
+ ambiguousSearch := `
+
+
+
+`
+ mock := &mockTransportClient{
+ responses: map[string]*http.Response{
+ "search": newTestResponse(ambiguousSearch),
+ "discovery": newTestResponse("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ _, err := client.GetEnhancement(context.Background(), "YZZZ")
+ if err == nil {
+ t.Fatal("expected ambiguity error, got nil")
+ }
+ msg := err.Error()
+ if !strings.Contains(msg, "ambiguous") || !strings.Contains(msg, "XC") || !strings.Contains(msg, "XH") {
+ t.Fatalf("expected ambiguity message listing both subtypes, got: %v", err)
+ }
+}
+
+func TestListEnhancementsForInclude_ParsesResponse(t *testing.T) {
+ browserResp := `
+
+
+
+`
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/enhancements/enhoxhs": newBody(browserResp),
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ refs, err := client.ListEnhancementsForInclude(context.Background(), "RVKMP901")
+ if err != nil {
+ t.Fatalf("ListEnhancementsForInclude failed: %v", err)
+ }
+ if len(refs) != 1 {
+ t.Fatalf("expected 1 ENHO match, got %d: %+v", len(refs), refs)
+ }
+ if refs[0].Name != "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT" {
+ t.Errorf("wrong name: %s", refs[0].Name)
+ }
+ if refs[0].Kind != "XH" {
+ t.Errorf("wrong kind: %s", refs[0].Kind)
+ }
+}
+
+func TestGetIncludeMerged_AnchorResolvable(t *testing.T) {
+ includeBody := `FORM BEDINGUNG_PRUEFEN_901.
+"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""$"$\SE:(1) Form BEDINGUNG_PRUEFEN_901, Start A
+*$*$-Start: (1)---------------------------------------------------------------------------------$*$*
+* existing body
+ENDFORM.
+`
+ enhSource := `ENHANCEMENT 2 Y3EI_SKIP_BYPASS_CC_WITH_LIMIT.
+ DATA lv_sum TYPE fakwr.
+ lv_sum = lv_sum + 1.
+ENDENHANCEMENT.`
+
+ browserResp := `
+
+
+`
+
+ searchResp := newEnhancementSearchResponse("Y3EI_SKIP_BYPASS_CC_WITH_LIMIT", "XH", "YSD")
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/programs/includes/RVKMP901/source/main": newBody(includeBody),
+ "/sap/bc/adt/enhancements/enhoxhs": newBody(browserResp),
+ "/sap/bc/adt/repository/informationsystem/search": searchResp,
+ "/sap/bc/adt/enhancements/enhoxh/y3ei_skip_bypass_cc_with_limit/source/main": newBody(enhSource),
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ merged, err := client.GetIncludeMerged(context.Background(), "RVKMP901")
+ if err != nil {
+ t.Fatalf("GetIncludeMerged failed: %v", err)
+ }
+
+ if !strings.Contains(merged, "ENHO/XH Y3EI_SKIP_BYPASS_CC_WITH_LIMIT") {
+ t.Errorf("expected ENHO banner in output, got:\n%s", merged)
+ }
+ if !strings.Contains(merged, "ENHANCEMENT 2 Y3EI_SKIP_BYPASS_CC_WITH_LIMIT") {
+ t.Errorf("expected enhancement body in merged output, got:\n%s", merged)
+ }
+ if !strings.Contains(merged, "unresolved enhancements") {
+ // Good — should not be in this positive case.
+ } else {
+ t.Errorf("positive case should not contain unresolved-enhancements banner, got:\n%s", merged)
+ }
+
+ // Anchor marker must still appear (we insert AFTER it, we don't consume it).
+ if !strings.Contains(merged, "$*$\\SE:(1) Form BEDINGUNG_PRUEFEN_901, Start") {
+ t.Errorf("anchor marker was dropped from output")
+ }
+}
+
+func TestGetIncludeMerged_AnchorUnresolvable(t *testing.T) {
+ includeBody := `FORM foo.
+* no SE anchor markers in this body
+ENDFORM.
+`
+ enhSource := `ENHANCEMENT 2 YZZZ_ENH.
+ WRITE 'hi'.
+ENDENHANCEMENT.`
+
+ browserResp := `
+
+
+`
+ searchResp := newEnhancementSearchResponse("YZZZ_ENH", "XH", "YSD")
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/programs/includes/ZINCL_NOANCHOR/source/main": newBody(includeBody),
+ "/sap/bc/adt/enhancements/enhoxhs": newBody(browserResp),
+ "/sap/bc/adt/repository/informationsystem/search": searchResp,
+ "/sap/bc/adt/enhancements/enhoxh/yzzz_enh/source/main": newBody(enhSource),
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ merged, err := client.GetIncludeMerged(context.Background(), "ZINCL_NOANCHOR")
+ if err != nil {
+ t.Fatalf("GetIncludeMerged should not error on unresolved anchor, got: %v", err)
+ }
+ if !strings.Contains(merged, "unresolved enhancements") {
+ t.Errorf("expected unresolved-enhancements banner, got:\n%s", merged)
+ }
+ if !strings.Contains(merged, "ENHO/XH YZZZ_ENH") {
+ t.Errorf("expected enhancement banner in unresolved section, got:\n%s", merged)
+ }
+ if !strings.Contains(merged, "anchor unresolved") {
+ t.Errorf("expected 'anchor unresolved' note, got:\n%s", merged)
+ }
+}
+
+// stubRFCSourceFetcher is the test boundary for GetEnhancement's RFC fallback
+// path. Records the calls the production code made; returns whatever the test
+// preloaded in sourceLines/err.
+type stubRFCSourceFetcher struct {
+ sourceLines []string
+ err error
+
+ readCalls []string // program names the prod code asked for
+ closeFn func()
+}
+
+func (s *stubRFCSourceFetcher) CallRFC(ctx context.Context, function string, params map[string]string) (*RFCResult, error) {
+ // CallRFC is no longer the production path — kept on the stub purely so it
+ // satisfies the rfcSourceFetcher interface. Tests should set sourceLines
+ // and observe readCalls.
+ return nil, fmtError("stub.CallRFC: not used in tests")
+}
+
+func (s *stubRFCSourceFetcher) ReadSource(ctx context.Context, program string) ([]string, error) {
+ s.readCalls = append(s.readCalls, program)
+ return s.sourceLines, s.err
+}
+
+func (s *stubRFCSourceFetcher) Close() error {
+ if s.closeFn != nil {
+ s.closeFn()
+ }
+ return nil
+}
+
+// TestGetEnhancement_FallsBackToRFC: classic ECC, both REST source URLs 404,
+// the RPY_PROGRAM_READ bridge returns the body. GetEnhancement should return
+// the spliced source instead of the metadata-only error.
+func TestGetEnhancement_FallsBackToRFC(t *testing.T) {
+ searchResp := newEnhancementSearchResponse("Y3EI_SKIP_BYPASS_CC_WITH_LIMIT", "XH", "YSD")
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/repository/informationsystem/search": searchResp,
+ "/sap/bc/adt/discovery": newBody("OK"),
+ // No source/main endpoint registered — both candidate URLs 404.
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ stub := &stubRFCSourceFetcher{
+ sourceLines: []string{
+ "ENHANCEMENT 2 Y3EI_SKIP_BYPASS_CC_WITH_LIMIT.",
+ " lv_sum = lv_sum + ls_xfplt-fakwr.",
+ "ENDENHANCEMENT.",
+ },
+ }
+ closed := false
+ stub.closeFn = func() { closed = true }
+ client.rfcFetcherFactory = func(ctx context.Context) (rfcSourceFetcher, error) {
+ return stub, nil
+ }
+
+ got, err := client.GetEnhancement(context.Background(), "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT")
+ if err != nil {
+ t.Fatalf("GetEnhancement should fall through to RFC, got error: %v", err)
+ }
+ if !strings.Contains(got, "lv_sum = lv_sum + ls_xfplt-fakwr") {
+ t.Fatalf("expected RFC body in result, got: %q", got)
+ }
+ if !strings.Contains(got, "ENDENHANCEMENT.") {
+ t.Fatalf("expected RFC source lines joined with newline, got: %q", got)
+ }
+ if len(stub.readCalls) != 1 {
+ t.Fatalf("expected exactly one ReadSource call, got %d", len(stub.readCalls))
+ }
+ // Convention says E unless table fallback set ref.EnhInclude.
+ // Search-based discovery used here doesn't populate EnhInclude, so the
+ // derived program name is the convention-based fallback.
+ if got := stub.readCalls[0]; got != "Y3EI_SKIP_BYPASS_CC_WITH_LIMITE" {
+ t.Errorf("wrong ReadSource program: %q", got)
+ }
+ if !closed {
+ t.Errorf("Close() was not called on the RFC fetcher")
+ }
+}
+
+// TestGetEnhancementByRef_PreservesEnhInclude: when the caller already has
+// a table-discovered ref with EnhInclude populated (e.g. the include-footer
+// renderer for HOOK_IMPL ENHOs whose REPOSRC entry uses `=`-padding rather
+// than the simple E convention), the RFC fallback must call ReadSource
+// with that exact entry name — not the resolver's guess.
+func TestGetEnhancementByRef_PreservesEnhInclude(t *testing.T) {
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ // REST steps both fail: no source/main endpoint registered, and
+ // the URI on the ref is empty so step 1 is skipped entirely.
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ stub := &stubRFCSourceFetcher{
+ sourceLines: []string{
+ "ENHANCEMENT 1 ISM_SAPLVKMP.",
+ " WRITE 'hi'.",
+ "ENDENHANCEMENT.",
+ },
+ }
+ client.rfcFetcherFactory = func(ctx context.Context) (rfcSourceFetcher, error) {
+ return stub, nil
+ }
+
+ // Synthetic ENHINCINX-discovered ref: short ENHNAME, padded ENHINCLUDE.
+ ref := &EnhancementRef{
+ Name: "ISM_SAPLVKMP",
+ Kind: "XH",
+ PackageName: "JAS_MODIF",
+ HostProgram: "SAPLVKMP",
+ EnhInclude: "ISM_SAPLVKMP==================E",
+ }
+
+ got, err := client.GetEnhancementByRef(context.Background(), ref)
+ if err != nil {
+ t.Fatalf("GetEnhancementByRef should succeed via RFC, got error: %v", err)
+ }
+ if !strings.Contains(got, "WRITE 'hi'") {
+ t.Fatalf("expected RFC body in result, got: %q", got)
+ }
+ if len(stub.readCalls) != 1 {
+ t.Fatalf("expected exactly one ReadSource call, got %d", len(stub.readCalls))
+ }
+ // The whole point of the fix: the padded REPOSRC name from EnhInclude
+ // must reach the RFC fetcher verbatim. E ("ISM_SAPLVKMPE") would
+ // be the pre-fix behaviour and is wrong.
+ if got := stub.readCalls[0]; got != "ISM_SAPLVKMP==================E" {
+ t.Errorf("RFC fetcher got wrong program name: %q (expected the padded EnhInclude verbatim)", got)
+ }
+}
+
+// TestGetEnhancementByRef_ErrorMessageUsesEnhInclude: when all source paths
+// fail and we fall back to the metadata-only error, the SE80 hint should
+// point at ref.EnhInclude when available, not the convention-based guess.
+func TestGetEnhancementByRef_ErrorMessageUsesEnhInclude(t *testing.T) {
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ client.rfcFetcherFactory = func(ctx context.Context) (rfcSourceFetcher, error) {
+ return nil, fmtError("RFC unreachable")
+ }
+
+ ref := &EnhancementRef{
+ Name: "ISM_SAPLVKMP",
+ Kind: "XH",
+ PackageName: "JAS_MODIF",
+ EnhInclude: "ISM_SAPLVKMP==================E",
+ }
+
+ _, err := client.GetEnhancementByRef(context.Background(), ref)
+ if err == nil {
+ t.Fatal("expected metadata-only error when all paths fail, got nil")
+ }
+ if !strings.Contains(err.Error(), "ISM_SAPLVKMP==================E") {
+ t.Errorf("expected SE80 hint to use EnhInclude verbatim, got: %v", err)
+ }
+}
+
+// TestGetEnhancement_RFCFails_FallsThroughToMetadataError: when the RFC path
+// errors (no ZADT_VSP installed, FM not remote-callable, etc.), the user
+// should still see the structured metadata-only error pointing at SE80,
+// not the raw RFC error.
+func TestGetEnhancement_RFCFails_FallsThroughToMetadataError(t *testing.T) {
+ searchResp := newEnhancementSearchResponse("Y3EI_SKIP_BYPASS_CC_WITH_LIMIT", "XH", "YSD")
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/repository/informationsystem/search": searchResp,
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ client.rfcFetcherFactory = func(ctx context.Context) (rfcSourceFetcher, error) {
+ // Simulate WebSocket 403 (ZADT_VSP not installed).
+ return nil, fmtError("WebSocket connection failed (HTTP 403)")
+ }
+
+ _, err := client.GetEnhancement(context.Background(), "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT")
+ if err == nil {
+ t.Fatal("expected metadata error when RFC fallback fails, got nil")
+ }
+ for _, want := range []string{
+ "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT",
+ "source body unavailable",
+ "SE80",
+ } {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("expected metadata error to contain %q, got: %v", want, err)
+ }
+ }
+}
+
+// fmtError is a small helper to build an error with a fixed message —
+// avoids importing fmt just for one Errorf in the tests.
+func fmtError(msg string) error { return errString(msg) }
+
+type errString string
+
+func (e errString) Error() string { return string(e) }
+
+// TestGetEnhancement_NoSourceEndpoint_MetadataError: classic ECC behaviour.
+// Search returns the ENHO ref; both candidate source URLs (singular/plural)
+// 404. GetEnhancement should return a structured error that names the ENHO,
+// its kind/package, and points at SE80 — instead of the cryptic 404.
+func TestGetEnhancement_NoSourceEndpoint_MetadataError(t *testing.T) {
+ searchResp := newEnhancementSearchResponse("Y3EI_SKIP_BYPASS_CC_WITH_LIMIT", "XH", "YSD")
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/repository/informationsystem/search": searchResp,
+ "/sap/bc/adt/discovery": newBody("OK"),
+ // No source/main endpoint registered — both candidate URLs 404.
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+ // Suppress the RFC fallback so the test doesn't open a real WebSocket
+ // against sap.example.com (would be a 30s handshake timeout).
+ client.rfcFetcherFactory = func(ctx context.Context) (rfcSourceFetcher, error) {
+ return nil, fmtError("RFC fallback disabled for this test")
+ }
+
+ _, err := client.GetEnhancement(context.Background(), "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT")
+ if err == nil {
+ t.Fatal("expected error when source endpoint is missing, got nil")
+ }
+ msg := err.Error()
+ for _, want := range []string{
+ "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT",
+ "XH",
+ "YSD",
+ "source body unavailable",
+ "SE80",
+ } {
+ if !strings.Contains(msg, want) {
+ t.Errorf("expected error to contain %q, got: %v", want, err)
+ }
+ }
+}
+
+// queryRoutedMock dispatches by path AND by ddicEntityName query parameter,
+// so a single endpoint (datapreview/ddic) can return different bodies for
+// different tables. Builds a fresh response per call so a path can be hit
+// multiple times (e.g. CSRF preflight + actual POST) without body exhaustion.
+type queryRoutedMock struct {
+ byPathBody map[string]string // path → body template
+ byDdicEntityKeyBody map[string]string // ddicEntityName → body template
+ requests []*http.Request
+}
+
+func freshResponse(body string) *http.Response {
+ h := http.Header{}
+ h.Set("X-CSRF-Token", "t")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: h,
+ }
+}
+
+func (q *queryRoutedMock) Do(req *http.Request) (*http.Response, error) {
+ q.requests = append(q.requests, req)
+ if entity := req.URL.Query().Get("ddicEntityName"); entity != "" {
+ if body, ok := q.byDdicEntityKeyBody[strings.ToUpper(entity)]; ok {
+ return freshResponse(body), nil
+ }
+ }
+ if body, ok := q.byPathBody[req.URL.Path]; ok {
+ return freshResponse(body), nil
+ }
+ for key, body := range q.byPathBody {
+ if strings.Contains(req.URL.Path, key) {
+ return freshResponse(body), nil
+ }
+ }
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Body: io.NopCloser(strings.NewReader("Not found")),
+ Header: http.Header{},
+ }, nil
+}
+
+// dataPreviewBody renders an XML body string in the shape parseTableContents
+// expects, given a list of columns and rows in declaration order.
+func dataPreviewBody(columns []string, rows [][]string) string {
+ var sb strings.Builder
+ sb.WriteString(`` + "\n")
+ sb.WriteString("\n")
+ for ci, name := range columns {
+ sb.WriteString(` ` + "\n")
+ sb.WriteString(` ` + "\n")
+ sb.WriteString(` ` + "\n")
+ for _, row := range rows {
+ val := ""
+ if ci < len(row) {
+ val = row[ci]
+ }
+ sb.WriteString(` ` + val + `` + "\n")
+ }
+ sb.WriteString(` ` + "\n")
+ sb.WriteString(` ` + "\n")
+ }
+ sb.WriteString("")
+ return sb.String()
+}
+
+// TestListEnhancementsForInclude_TablePathFallback: both REST endpoints fail;
+// the D010INC ⨝ ENHINCINX fallback joins the data and returns the ENHO ref.
+func TestListEnhancementsForInclude_TablePathFallback(t *testing.T) {
+ d010Body := dataPreviewBody(
+ []string{"MASTER", "INCLUDE"},
+ [][]string{{"SAPLVKMP", "RVKMP901"}},
+ )
+ enhincinxBody := dataPreviewBody(
+ []string{"ENHNAME", "PROGRAMNAME", "FULL_NAME", "ENHINCLUDE"},
+ [][]string{
+ {
+ "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT",
+ "SAPLVKMP",
+ `\PR:SAPLVKMP\FO:BEDINGUNG_PRUEFEN_901\SE:BEGIN\EI`,
+ "Y3EI_SKIP_BYPASS_CC_WITH_LIMITE",
+ },
+ },
+ )
+ enhheaderBody := dataPreviewBody(
+ []string{"ENHNAME", "VERSION"},
+ [][]string{{"Y3EI_SKIP_BYPASS_CC_WITH_LIMIT", "A"}},
+ )
+ searchBody := `
+
+
+`
+
+ mock := &queryRoutedMock{
+ byPathBody: map[string]string{
+ "/sap/bc/adt/repository/informationsystem/search": searchBody,
+ "/sap/bc/adt/discovery": "OK",
+ "/sap/bc/adt/core/discovery": "OK",
+ },
+ byDdicEntityKeyBody: map[string]string{
+ "D010INC": d010Body,
+ "ENHINCINX": enhincinxBody,
+ "ENHHEADER": enhheaderBody,
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ refs, err := client.ListEnhancementsForInclude(context.Background(), "RVKMP901")
+ if err != nil {
+ t.Fatalf("ListEnhancementsForInclude (table fallback) failed: %v", err)
+ }
+ if len(refs) != 1 {
+ t.Fatalf("expected 1 ENHO match via table fallback, got %d: %+v", len(refs), refs)
+ }
+ got := refs[0]
+ if got.Name != "Y3EI_SKIP_BYPASS_CC_WITH_LIMIT" {
+ t.Errorf("wrong Name: %q", got.Name)
+ }
+ if got.Kind != "XH" {
+ t.Errorf("wrong Kind: %q", got.Kind)
+ }
+ if got.HostProgram != "SAPLVKMP" {
+ t.Errorf("wrong HostProgram: %q", got.HostProgram)
+ }
+ if got.EnhInclude != "Y3EI_SKIP_BYPASS_CC_WITH_LIMITE" {
+ t.Errorf("wrong EnhInclude: %q", got.EnhInclude)
+ }
+ if !strings.Contains(got.FullName, "BEDINGUNG_PRUEFEN_901") {
+ t.Errorf("expected FullName to mention the FORM, got: %q", got.FullName)
+ }
+}
+
+func TestGetSource_DispatchesENHO(t *testing.T) {
+ // End-to-end: GetSource(ctx, "ENHO", name) must take the ENHO branch.
+ sourceBody := `ENHANCEMENT 2 Y_TEST.
+ENDENHANCEMENT.`
+ searchResp := newEnhancementSearchResponse("Y_TEST", "XH", "YSD")
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/repository/informationsystem/search": searchResp,
+ "/sap/bc/adt/enhancements/enhoxh/y_test/source/main": newBody(sourceBody),
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ got, err := client.GetSource(context.Background(), "ENHO", "Y_TEST", nil)
+ if err != nil {
+ t.Fatalf("GetSource(ENHO) failed: %v", err)
+ }
+ if !strings.Contains(got, "ENHANCEMENT 2 Y_TEST") {
+ t.Fatalf("GetSource(ENHO) did not return enhancement body, got: %q", got)
+ }
+}
diff --git a/pkg/adt/websocket_base.go b/pkg/adt/websocket_base.go
index 5e8841b6..7166bb20 100644
--- a/pkg/adt/websocket_base.go
+++ b/pkg/adt/websocket_base.go
@@ -25,6 +25,11 @@ type BaseWebSocketClient struct {
password string
insecure bool
+ // cookies, if non-empty, are attached to the WebSocket upgrade via a
+ // cookie jar. Required for SSO/browser-auth flows where no plaintext
+ // password is available for Basic auth.
+ cookies map[string]string
+
conn *websocket.Conn
sessionID string
mu sync.RWMutex
@@ -83,21 +88,43 @@ func (c *BaseWebSocketClient) Connect(ctx context.Context) error {
InsecureSkipVerify: c.insecure,
}
- header := http.Header{}
- header.Set("Authorization", basicAuth(c.user, c.password))
+ hasBasic := c.user != "" && c.password != ""
+ hasCookies := len(c.cookies) > 0
- // Try 1: Direct Basic Auth (works on most SAP systems)
dialer := websocket.Dialer{
HandshakeTimeout: 30 * time.Second,
TLSClientConfig: tlsConfig,
}
- conn, resp, err := dialer.DialContext(ctx, wsURL, header)
+ // If cookies are provided, prefer them: build a jar and dial with no
+ // Authorization header. This is the SSO/browser-auth path.
+ var conn *websocket.Conn
+ var resp *http.Response
+ if hasCookies {
+ jar, _ := cookiejar.New(nil)
+ cookieURL := &url.URL{Scheme: u.Scheme, Host: u.Host}
+ httpCookies := make([]*http.Cookie, 0, len(c.cookies))
+ for name, value := range c.cookies {
+ httpCookies = append(httpCookies, &http.Cookie{Name: name, Value: value})
+ }
+ jar.SetCookies(cookieURL, httpCookies)
+ dialer.Jar = jar
+ conn, resp, err = dialer.DialContext(ctx, wsURL, nil)
+ } else if hasBasic {
+ // Try 1: Direct Basic Auth (works on most SAP systems)
+ header := http.Header{}
+ header.Set("Authorization", basicAuth(c.user, c.password))
+ conn, resp, err = dialer.DialContext(ctx, wsURL, header)
+ } else {
+ c.mu.Unlock()
+ return fmt.Errorf("no credentials available for WebSocket dial: need cookies or basic-auth")
+ }
- // Try 2: If 401, pre-authenticate to get session cookies first.
- // Some SAP systems reject standalone Basic Auth on WebSocket upgrade
- // but accept it on regular HTTP to issue session cookies.
- if err != nil && resp != nil && resp.StatusCode == http.StatusUnauthorized {
+ // Try 2: On 401 with Basic-only path, pre-authenticate to get session
+ // cookies via REST first. Some SAP systems reject standalone Basic Auth
+ // on WebSocket upgrade but accept it on regular HTTP to issue session
+ // cookies. Skipped when we already used cookies (those are authoritative).
+ if err != nil && resp != nil && resp.StatusCode == http.StatusUnauthorized && !hasCookies && hasBasic {
jar, _ := cookiejar.New(nil)
preAuthClient := &http.Client{
Jar: jar,
@@ -185,6 +212,15 @@ func (c *BaseWebSocketClient) GetUser() string {
return c.user
}
+// SetCookies attaches cookies (as name→value pairs) to be used for the
+// WebSocket upgrade. Required for SSO/browser-auth flows. Must be called
+// before Connect; calls after Connect have no effect on the active dial.
+func (c *BaseWebSocketClient) SetCookies(cookies map[string]string) {
+ c.mu.Lock()
+ c.cookies = cookies
+ c.mu.Unlock()
+}
+
// readMessages reads messages from WebSocket and routes them.
func (c *BaseWebSocketClient) readMessages() {
for {
diff --git a/pkg/adt/websocket_rfc.go b/pkg/adt/websocket_rfc.go
index 662b234b..2a6bb752 100644
--- a/pkg/adt/websocket_rfc.go
+++ b/pkg/adt/websocket_rfc.go
@@ -138,6 +138,50 @@ func (c *DebugWebSocketClient) RunReportSync(ctx context.Context, report string,
return c.SendRawRequest(ctx, id, rawMsg, 60*time.Second)
}
+// ReadSourceResult is the response shape from rfc/readSource.
+type ReadSourceResult struct {
+ Program string `json:"program"`
+ Source []string `json:"source"`
+}
+
+// ReadSource reads ABAP source for a program/include via the rfc domain's
+// readSource action. The ABAP-side handler uses native `READ REPORT`, so it
+// works for SUBC=I includes (enhancement plug-in source) where
+// RPY_PROGRAM_READ raises CANCELLED in RFC contexts on classic ECC.
+func (c *DebugWebSocketClient) ReadSource(ctx context.Context, program string) ([]string, error) {
+ if !c.IsConnected() {
+ return nil, fmt.Errorf("not connected")
+ }
+
+ id := c.GenerateID("rfc_read")
+
+ rawMsg := map[string]any{
+ "id": id,
+ "domain": "rfc",
+ "action": "readSource",
+ "params": map[string]any{"program": program},
+ "timeout": 30000,
+ }
+
+ resp, err := c.SendRawRequest(ctx, id, rawMsg, 30*time.Second)
+ if err != nil {
+ return nil, err
+ }
+
+ if !resp.Success {
+ if resp.Error != nil {
+ return nil, fmt.Errorf("%s: %s", resp.Error.Code, resp.Error.Message)
+ }
+ return nil, fmt.Errorf("readSource failed")
+ }
+
+ var result ReadSourceResult
+ if err := json.Unmarshal(resp.Data, &result); err != nil {
+ return nil, err
+ }
+ return result.Source, nil
+}
+
// --- Package Operations ---
// MoveObjectResult contains the result of a package reassignment.
diff --git a/pkg/adt/workflows_grep.go b/pkg/adt/workflows_grep.go
index 85d9bed0..fd7c1c41 100644
--- a/pkg/adt/workflows_grep.go
+++ b/pkg/adt/workflows_grep.go
@@ -376,3 +376,425 @@ func isSourceObject(objectType string) bool {
}
return sourceTypes[objectType]
}
+
+// --- ENHO-aware grep ---
+
+// defaultEnhancementGrepCap bounds how many ENHO bodies a single walk fetches
+// before subsequent refs are elided with a marker. ENHO body fetches go through
+// the WebSocket bridge, which is roughly a 30s round-trip per body in the
+// worst case, so a hard cap matters for package-scope greps.
+const defaultEnhancementGrepCap = 50
+
+// GrepEnhancementsState carries per-walk state so that a package or
+// multi-object grep fetches each ENHO body at most once and stops fetching
+// after a configurable cap. The zero value is a no-op (no dedup, no cap).
+// Use NewGrepEnhancementsState to get sensible defaults.
+type GrepEnhancementsState struct {
+ Cap int // max ENHO bodies to fetch; 0 ⇒ defaultEnhancementGrepCap
+ seen map[string]bool // ENHO names already fetched in this walk
+ fetched int
+ elided int
+}
+
+// NewGrepEnhancementsState returns a fresh state with the default cap (50).
+// Pass cap=0 for the default; pass a positive value to override.
+func NewGrepEnhancementsState(cap int) *GrepEnhancementsState {
+ if cap <= 0 {
+ cap = defaultEnhancementGrepCap
+ }
+ return &GrepEnhancementsState{Cap: cap, seen: map[string]bool{}}
+}
+
+// extractObjectNameFromURL pulls the object name from an ADT URL such as
+// `/sap/bc/adt/programs/includes/RVKMP901` or `.../programs/programs/ZTEST`.
+// Returns "" for URLs that don't fit the expected shape.
+func extractObjectNameFromURL(objectURL string) string {
+ u := strings.TrimRight(objectURL, "/")
+ u = strings.TrimSuffix(u, "/source/main")
+ idx := strings.LastIndex(u, "/")
+ if idx < 0 || idx == len(u)-1 {
+ return ""
+ }
+ return strings.ToUpper(u[idx+1:])
+}
+
+// supportsEnhancementWalk reports whether the URL points at an object whose
+// ENHO attachments are expressible via D010INC ⨝ ENHINCINX (HOOK_IMPL plug-ins
+// on programs and includes). Classes, interfaces, DDIC, etc. are skipped —
+// they have their own enhancement mechanisms not covered by this walker.
+func supportsEnhancementWalk(objectURL string) bool {
+ return strings.Contains(objectURL, "/programs/includes/") ||
+ strings.Contains(objectURL, "/programs/programs/")
+}
+
+// grepEnhancementsForObject walks the ENHO refs attached to an
+// include/program at objectURL and greps each body. Returns extra matches
+// tagged with the ENHO ref. Soft-fails throughout — fetch failures emit a
+// single warning hit per ref instead of erroring the whole walk.
+//
+// Pre-compiled `re` is reused from the caller so we don't recompile per ENHO.
+func (c *Client) grepEnhancementsForObject(
+ ctx context.Context,
+ objectURL string,
+ re *regexp.Regexp,
+ contextLines int,
+ state *GrepEnhancementsState,
+) []GrepMatch {
+ if !supportsEnhancementWalk(objectURL) {
+ return nil
+ }
+ hostName := extractObjectNameFromURL(objectURL)
+ if hostName == "" {
+ return nil
+ }
+
+ refs, err := c.ListEnhancementsForInclude(ctx, hostName)
+ if err != nil || len(refs) == 0 {
+ return nil
+ }
+
+ var hits []GrepMatch
+ for i := range refs {
+ ref := refs[i]
+ if state != nil {
+ if state.seen[ref.Name] {
+ continue
+ }
+ state.seen[ref.Name] = true
+ if state.Cap > 0 && state.fetched >= state.Cap {
+ state.elided++
+ continue
+ }
+ state.fetched++
+ }
+
+ body, ferr := c.GetEnhancementByRef(ctx, &ref)
+ if ferr != nil {
+ hits = append(hits, GrepMatch{
+ LineNumber: 0,
+ MatchedLine: fmt.Sprintf("[ENHO/%s %s @ %s — body unavailable: %v]",
+ ref.Kind, ref.Name, hostName, ferr),
+ })
+ continue
+ }
+
+ bodyLines := strings.Split(body, "\n")
+ for li, line := range bodyLines {
+ if !re.MatchString(line) {
+ continue
+ }
+ match := GrepMatch{
+ LineNumber: li + 1,
+ MatchedLine: fmt.Sprintf("[ENHO %s @ %s] %s", ref.Name, hostName, line),
+ }
+ if contextLines > 0 {
+ s := li - contextLines
+ if s < 0 {
+ s = 0
+ }
+ e := li + contextLines + 1
+ if e > len(bodyLines) {
+ e = len(bodyLines)
+ }
+ match.ContextBefore = bodyLines[s:li]
+ match.ContextAfter = bodyLines[li+1 : e]
+ }
+ hits = append(hits, match)
+ }
+ }
+
+ return hits
+}
+
+// finalizeEnhancementWalk appends the elision marker once at the end of a
+// package walk. Caller is the GrepPackage* path; per-object grep doesn't
+// surface elision because the cap only matters at walk scope.
+func finalizeEnhancementWalk(state *GrepEnhancementsState) []GrepMatch {
+ if state == nil || state.elided == 0 {
+ return nil
+ }
+ return []GrepMatch{{
+ LineNumber: 0,
+ MatchedLine: fmt.Sprintf(
+ "[ENHO walk: %d more enhancement(s) elided after cap of %d — increase max_enhancements to fetch]",
+ state.elided, state.Cap),
+ }}
+}
+
+// GrepObjectWithEnhancements wraps GrepObject with an ENHO body walk. When
+// enabled and the URL points at a program/include, every ENHO attached to
+// the object is fetched via GetEnhancementByRef and greppd with the same
+// pattern. Hits are tagged `[ENHO @ ] ` so they're easy
+// to distinguish from base-source hits.
+//
+// `walk` is optional — pass nil for a self-contained call, or a shared
+// state from NewGrepEnhancementsState to dedupe fetches across multiple
+// GrepObjectWithEnhancements calls (e.g. a package walk that hits the same
+// ENHO via several function-group includes).
+func (c *Client) GrepObjectWithEnhancements(
+ ctx context.Context,
+ objectURL, pattern string,
+ caseInsensitive bool,
+ contextLines int,
+ walk *GrepEnhancementsState,
+) (*GrepObjectResult, error) {
+ base, err := c.GrepObject(ctx, objectURL, pattern, caseInsensitive, contextLines)
+ if err != nil {
+ return base, err
+ }
+
+ regexPattern := pattern
+ if caseInsensitive {
+ regexPattern = "(?i)" + pattern
+ }
+ re, rErr := regexp.Compile(regexPattern)
+ if rErr != nil {
+ // Pattern was already validated by GrepObject; if it failed there
+ // the base result already carries the message. Don't shadow it.
+ return base, nil
+ }
+
+ enhHits := c.grepEnhancementsForObject(ctx, objectURL, re, contextLines, walk)
+ if len(enhHits) == 0 {
+ return base, nil
+ }
+ base.Matches = append(base.Matches, enhHits...)
+ base.MatchCount = len(base.Matches)
+ base.Success = true
+ if base.Message == "" || strings.HasPrefix(base.Message, "No matches") {
+ base.Message = fmt.Sprintf("Found %d match(es) in %s (incl. enhancements)",
+ base.MatchCount, base.ObjectName)
+ }
+ return base, nil
+}
+
+// GrepObjectsWithEnhancements is GrepObjects with ENHO body walking. A single
+// shared walk-state spans all objectURLs so the same ENHO is fetched at most
+// once across the multi-object grep.
+func (c *Client) GrepObjectsWithEnhancements(
+ ctx context.Context,
+ objectURLs []string,
+ pattern string,
+ caseInsensitive bool,
+ contextLines int,
+ maxEnhancements int,
+) (*GrepObjectsResult, error) {
+ result := &GrepObjectsResult{Objects: []GrepObjectResult{}}
+ if len(objectURLs) == 0 {
+ result.Message = "No object URLs provided"
+ return result, nil
+ }
+
+ walk := NewGrepEnhancementsState(maxEnhancements)
+ for _, objectURL := range objectURLs {
+ objResult, oerr := c.GrepObjectWithEnhancements(ctx, objectURL, pattern, caseInsensitive, contextLines, walk)
+ if oerr != nil {
+ continue
+ }
+ if objResult.MatchCount > 0 {
+ result.Objects = append(result.Objects, *objResult)
+ result.TotalMatches += objResult.MatchCount
+ }
+ }
+
+ if marker := finalizeEnhancementWalk(walk); len(marker) > 0 {
+ result.Objects = append(result.Objects, GrepObjectResult{
+ Success: true,
+ ObjectName: "[enhancement walk]",
+ Matches: marker,
+ MatchCount: len(marker),
+ Message: fmt.Sprintf("ENHO body cap reached (%d), %d more elided",
+ walk.Cap, walk.elided),
+ })
+ result.TotalMatches += len(marker)
+ }
+
+ result.Success = true
+ if result.TotalMatches == 0 {
+ result.Message = fmt.Sprintf("No matches found in %d object(s)", len(objectURLs))
+ } else {
+ result.Message = fmt.Sprintf("Found %d match(es) across %d object(s) (incl. enhancements)",
+ result.TotalMatches, len(result.Objects))
+ }
+ return result, nil
+}
+
+// GrepPackagesWithEnhancements is GrepPackages with ENHO body walking. Like
+// GrepObjectsWithEnhancements, walk state is shared across all packages so
+// an ENHO touching includes in multiple packages is fetched once.
+func (c *Client) GrepPackagesWithEnhancements(
+ ctx context.Context,
+ packages []string,
+ includeSubpackages bool,
+ pattern string,
+ caseInsensitive bool,
+ objectTypes []string,
+ maxResults int,
+ maxEnhancements int,
+) (*GrepPackagesResult, error) {
+ result := &GrepPackagesResult{
+ Packages: []string{},
+ Objects: []GrepObjectResult{},
+ }
+ if len(packages) == 0 {
+ result.Message = "No packages provided"
+ return result, nil
+ }
+
+ packagesToSearch := []string{}
+ for _, pkg := range packages {
+ if includeSubpackages {
+ subPackages, err := c.collectSubpackages(ctx, pkg)
+ if err != nil {
+ packagesToSearch = append(packagesToSearch, pkg)
+ } else {
+ packagesToSearch = append(packagesToSearch, subPackages...)
+ }
+ } else {
+ packagesToSearch = append(packagesToSearch, pkg)
+ }
+ }
+ result.Packages = packagesToSearch
+
+ walk := NewGrepEnhancementsState(maxEnhancements)
+ typeFilter := make(map[string]bool)
+ for _, t := range objectTypes {
+ typeFilter[t] = true
+ }
+
+ totalObjectsSearched := 0
+ for _, packageName := range packagesToSearch {
+ packageContent, err := c.GetPackage(ctx, packageName)
+ if err != nil {
+ continue
+ }
+ for _, obj := range packageContent.Objects {
+ if len(typeFilter) > 0 && !typeFilter[obj.Type] {
+ continue
+ }
+ if !isSourceObject(obj.Type) {
+ continue
+ }
+ objResult, oerr := c.GrepObjectWithEnhancements(ctx, obj.URI, pattern, caseInsensitive, 0, walk)
+ if oerr != nil {
+ continue
+ }
+ if objResult.MatchCount == 0 {
+ continue
+ }
+ objResult.ObjectType = obj.Type
+ result.Objects = append(result.Objects, *objResult)
+ result.TotalMatches += objResult.MatchCount
+ totalObjectsSearched++
+ if maxResults > 0 && totalObjectsSearched >= maxResults {
+ break
+ }
+ }
+ if maxResults > 0 && totalObjectsSearched >= maxResults {
+ break
+ }
+ }
+
+ if marker := finalizeEnhancementWalk(walk); len(marker) > 0 {
+ result.Objects = append(result.Objects, GrepObjectResult{
+ Success: true,
+ ObjectName: "[enhancement walk]",
+ Matches: marker,
+ MatchCount: len(marker),
+ Message: fmt.Sprintf("ENHO body cap reached (%d), %d more elided",
+ walk.Cap, walk.elided),
+ })
+ result.TotalMatches += len(marker)
+ }
+
+ result.Success = true
+ if result.TotalMatches == 0 {
+ result.Message = fmt.Sprintf("No matches found in %d package(s)", len(result.Packages))
+ } else {
+ result.Message = fmt.Sprintf("Found %d match(es) across %d object(s) in %d package(s) (incl. enhancements)",
+ result.TotalMatches, len(result.Objects), len(result.Packages))
+ }
+ return result, nil
+}
+
+// GrepPackageWithEnhancements is GrepPackage with ENHO bodies walked in
+// addition to base sources. Dedup state is shared across all objects in
+// the package so a single ENHO touching multiple includes is fetched once.
+// The walk's cap (default 50) bounds total ENHO body fetches; an elision
+// marker is appended to the package result when the cap is hit.
+func (c *Client) GrepPackageWithEnhancements(
+ ctx context.Context,
+ packageName, pattern string,
+ caseInsensitive bool,
+ objectTypes []string,
+ maxResults int,
+ maxEnhancements int,
+) (*GrepPackageResult, error) {
+ result := &GrepPackageResult{
+ PackageName: packageName,
+ Objects: []GrepObjectResult{},
+ }
+
+ packageContent, err := c.GetPackage(ctx, packageName)
+ if err != nil {
+ result.Message = fmt.Sprintf("Failed to read package: %v", err)
+ return result, nil
+ }
+
+ typeFilter := make(map[string]bool)
+ if len(objectTypes) > 0 {
+ for _, t := range objectTypes {
+ typeFilter[t] = true
+ }
+ }
+
+ walk := NewGrepEnhancementsState(maxEnhancements)
+ objectsSearched := 0
+ for _, obj := range packageContent.Objects {
+ if len(typeFilter) > 0 && !typeFilter[obj.Type] {
+ continue
+ }
+ if !isSourceObject(obj.Type) {
+ continue
+ }
+
+ objResult, oerr := c.GrepObjectWithEnhancements(ctx, obj.URI, pattern, caseInsensitive, 0, walk)
+ if oerr != nil {
+ continue
+ }
+ if objResult.MatchCount == 0 {
+ continue
+ }
+ objResult.ObjectType = obj.Type
+ result.Objects = append(result.Objects, *objResult)
+ result.TotalMatches += objResult.MatchCount
+
+ objectsSearched++
+ if maxResults > 0 && objectsSearched >= maxResults {
+ break
+ }
+ }
+
+ // Surface elision at package scope, attached to a synthetic "object" so
+ // it's visible in the JSON output without polluting per-object results.
+ if marker := finalizeEnhancementWalk(walk); len(marker) > 0 {
+ result.Objects = append(result.Objects, GrepObjectResult{
+ Success: true,
+ ObjectName: "[enhancement walk]",
+ Matches: marker,
+ MatchCount: len(marker),
+ Message: fmt.Sprintf("ENHO body cap reached (%d), %d more elided",
+ walk.Cap, walk.elided),
+ })
+ result.TotalMatches += len(marker)
+ }
+
+ result.Success = true
+ if result.TotalMatches == 0 {
+ result.Message = "No matches found in package"
+ } else {
+ result.Message = fmt.Sprintf("Found %d match(es) across %d object(s) in package %s (incl. enhancements)",
+ result.TotalMatches, len(result.Objects), packageName)
+ }
+ return result, nil
+}
diff --git a/pkg/adt/workflows_grep_test.go b/pkg/adt/workflows_grep_test.go
new file mode 100644
index 00000000..af99b444
--- /dev/null
+++ b/pkg/adt/workflows_grep_test.go
@@ -0,0 +1,202 @@
+package adt
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// TestGrepObjectWithEnhancements_HitInsideEnhancementBody: the host include
+// has no match in its raw source, but an attached ENHO body contains the
+// pattern. The grep result must include the hit, prefixed with the ENHO
+// tag so callers can tell it came from an enhancement.
+func TestGrepObjectWithEnhancements_HitInsideEnhancementBody(t *testing.T) {
+ baseSource := `FORM foo.
+ WRITE 'no pattern here'.
+ENDFORM.
+`
+ enhSource := `ENHANCEMENT 2 Y3EI_TEST.
+ LOOP AT lt_xfplt INTO DATA(ls_xfplt).
+ lv_sum = lv_sum + ls_xfplt-fakwr.
+ ENDLOOP.
+ENDENHANCEMENT.
+`
+ browserResp := `
+
+
+`
+
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/programs/includes/RVKMP901/source/main": newBody(baseSource),
+ "/sap/bc/adt/enhancements/enhoxhs": newBody(browserResp),
+ "/sap/bc/adt/enhancements/enhoxh/y3ei_test/source/main": newBody(enhSource),
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ result, err := client.GrepObjectWithEnhancements(
+ context.Background(),
+ "/sap/bc/adt/programs/includes/RVKMP901",
+ `lv_sum\s*=\s*lv_sum\s*\+\s*ls_xfplt-fakwr`,
+ false, 0, nil,
+ )
+ if err != nil {
+ t.Fatalf("GrepObjectWithEnhancements failed: %v", err)
+ }
+ if result.MatchCount == 0 {
+ t.Fatalf("expected ENHO hit; got 0 matches. Result: %+v", result)
+ }
+
+ found := false
+ for _, m := range result.Matches {
+ if strings.Contains(m.MatchedLine, "[ENHO Y3EI_TEST @ RVKMP901]") &&
+ strings.Contains(m.MatchedLine, "lv_sum = lv_sum + ls_xfplt-fakwr") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected ENHO-tagged hit; got matches:\n")
+ for _, m := range result.Matches {
+ t.Errorf(" %d: %s", m.LineNumber, m.MatchedLine)
+ }
+ }
+}
+
+// TestGrepObjectWithEnhancements_BridgeDown_DegradesGracefully: the ENHO is
+// listed but its body fetch fails (REST 404 + RFC bridge dead). The grep
+// must continue, surface a warning hit per ref, and not error the call.
+func TestGrepObjectWithEnhancements_BridgeDown_DegradesGracefully(t *testing.T) {
+ baseSource := `FORM foo.
+ENDFORM.
+`
+ browserResp := `
+
+
+`
+
+ // No ENHO source endpoint registered → REST steps both 404.
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/programs/includes/RVKMP901/source/main": newBody(baseSource),
+ "/sap/bc/adt/enhancements/enhoxhs": newBody(browserResp),
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, mock)
+ client := NewClientWithTransport(cfg, transport)
+
+ // Force the RFC fallback to fail too — simulates ZADT_VSP bridge down.
+ client.rfcFetcherFactory = func(ctx context.Context) (rfcSourceFetcher, error) {
+ return nil, fmtError("WebSocket connection failed (HTTP 403)")
+ }
+
+ result, err := client.GrepObjectWithEnhancements(
+ context.Background(),
+ "/sap/bc/adt/programs/includes/RVKMP901",
+ `lv_sum`,
+ false, 0, nil,
+ )
+ if err != nil {
+ t.Fatalf("GrepObjectWithEnhancements should soft-fail on bridge-down, got: %v", err)
+ }
+
+ foundWarning := false
+ for _, m := range result.Matches {
+ if strings.Contains(m.MatchedLine, "ENHO/XH Y_LOST @ RVKMP901") &&
+ strings.Contains(m.MatchedLine, "body unavailable") {
+ foundWarning = true
+ break
+ }
+ }
+ if !foundWarning {
+ t.Errorf("expected warning hit for unfetchable ENHO body; got matches:\n")
+ for _, m := range result.Matches {
+ t.Errorf(" %d: %s", m.LineNumber, m.MatchedLine)
+ }
+ }
+}
+
+// TestGrepObjectWithEnhancements_DedupesViaWalkState: when two GrepObject
+// calls share a walk state and the same ENHO is listed for both objects,
+// the body is fetched at most once.
+func TestGrepObjectWithEnhancements_DedupesViaWalkState(t *testing.T) {
+ baseSource := `FORM foo.
+ENDFORM.
+`
+ enhSource := `ENHANCEMENT 2 Y_SHARED.
+ WRITE 'shared'.
+ENDENHANCEMENT.
+`
+ // Same ENHO listed for both includes — typical of a function group's
+ // CXX/EXX/F0X includes all sharing one HOOK_IMPL plug-in.
+ browserResp := `
+
+
+`
+
+ // Counter to assert how many times the ENHO body was fetched.
+ fetchCount := 0
+ mock := &routedMock{
+ byPath: map[string]*http.Response{
+ "/sap/bc/adt/programs/includes/INC_A/source/main": newBody(baseSource),
+ "/sap/bc/adt/programs/includes/INC_B/source/main": newBody(baseSource),
+ "/sap/bc/adt/enhancements/enhoxhs": newBody(browserResp),
+ "/sap/bc/adt/discovery": newBody("OK"),
+ },
+ }
+ // Wrap the mock to count source/main fetches on the ENHO URL.
+ countingMock := &countingTransportClient{
+ inner: mock,
+ matchURL: "/sap/bc/adt/enhancements/enhoxh/y_shared/source/main",
+ body: enhSource,
+ count: &fetchCount,
+ }
+ cfg := NewConfig("https://sap.example.com:44300", "u", "p")
+ transport := NewTransportWithClient(cfg, countingMock)
+ client := NewClientWithTransport(cfg, transport)
+
+ walk := NewGrepEnhancementsState(0) // default cap
+
+ if _, err := client.GrepObjectWithEnhancements(
+ context.Background(),
+ "/sap/bc/adt/programs/includes/INC_A",
+ `shared`, false, 0, walk,
+ ); err != nil {
+ t.Fatalf("first call failed: %v", err)
+ }
+ if _, err := client.GrepObjectWithEnhancements(
+ context.Background(),
+ "/sap/bc/adt/programs/includes/INC_B",
+ `shared`, false, 0, walk,
+ ); err != nil {
+ t.Fatalf("second call failed: %v", err)
+ }
+
+ if fetchCount != 1 {
+ t.Errorf("expected ENHO body fetched once across two grep calls, got %d", fetchCount)
+ }
+}
+
+// countingTransportClient wraps a mock and counts requests to a specific URL,
+// returning the configured body for that URL and delegating everything else.
+type countingTransportClient struct {
+ inner *routedMock
+ matchURL string
+ body string
+ count *int
+}
+
+func (c *countingTransportClient) Do(req *http.Request) (*http.Response, error) {
+ if req.URL.Path == c.matchURL {
+ *c.count++
+ return newBody(c.body), nil
+ }
+ return c.inner.Do(req)
+}
diff --git a/pkg/adt/workflows_source.go b/pkg/adt/workflows_source.go
index f0a2aed3..1907c1de 100644
--- a/pkg/adt/workflows_source.go
+++ b/pkg/adt/workflows_source.go
@@ -23,6 +23,7 @@ type GetSourceOptions struct {
Parent string // Function group name (required for FUNC type)
Include string // Class include type: definitions, implementations, macros, testclasses (optional for CLAS type)
Method string // Method name for method-level source extraction (optional for CLAS type)
+ Merged bool // INCL only: splice referenced ENHO enhancements into the output (SE80-style merged view)
}
// GetSource is a unified tool for reading ABAP source code across different object types.
@@ -92,8 +93,14 @@ func (c *Client) GetSource(ctx context.Context, objectType, name string, opts *G
return string(data), nil
case "INCL":
+ if opts.Merged {
+ return c.GetIncludeMerged(ctx, name)
+ }
return c.GetInclude(ctx, name)
+ case "ENHO":
+ return c.GetEnhancement(ctx, name)
+
case "DDLS":
return c.GetDDLS(ctx, name)
@@ -132,7 +139,7 @@ func (c *Client) GetSource(ctx context.Context, objectType, name string, opts *G
return string(data), nil
default:
- return "", fmt.Errorf("unsupported object type: %s (supported: PROG, CLAS, INTF, FUNC, FUGR, INCL, DDLS, VIEW, BDEF, SRVD, SRVB, MSAG)", objectType)
+ return "", fmt.Errorf("unsupported object type: %s (supported: PROG, CLAS, INTF, FUNC, FUGR, INCL, DDLS, VIEW, BDEF, SRVD, SRVB, MSAG, ENHO)", objectType)
}
}