From 9bfd9aa264b7ce75221fe9831e3b8969d668631e Mon Sep 17 00:00:00 2001 From: Phil Barkow Date: Wed, 20 May 2026 09:30:42 +0200 Subject: [PATCH 1/3] Add support for Reading SAP enhancements --- internal/mcp/handlers_amdp.go | 3 + internal/mcp/handlers_analysis.go | 43 ++ internal/mcp/handlers_debugger.go | 3 + internal/mcp/handlers_grep.go | 69 ++- internal/mcp/handlers_help.go | 8 +- internal/mcp/handlers_source.go | 130 ++++- internal/mcp/handlers_universal.go | 5 +- internal/mcp/server.go | 3 + pkg/adt/client.go | 6 + pkg/adt/enhancements.go | 801 +++++++++++++++++++++++++++++ pkg/adt/enhancements_test.go | 684 ++++++++++++++++++++++++ pkg/adt/websocket_base.go | 52 +- pkg/adt/websocket_rfc.go | 44 ++ pkg/adt/workflows_grep.go | 422 +++++++++++++++ pkg/adt/workflows_grep_test.go | 202 ++++++++ pkg/adt/workflows_source.go | 9 +- 16 files changed, 2457 insertions(+), 27 deletions(-) create mode 100644 pkg/adt/enhancements.go create mode 100644 pkg/adt/enhancements_test.go create mode 100644 pkg/adt/workflows_grep_test.go 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..bb824869 --- /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-LEGO 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..9406ac27 --- /dev/null +++ b/pkg/adt/enhancements_test.go @@ -0,0 +1,684 @@ +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. +// +// Regression target: pre-fix, the include footer called GetEnhancement(name) +// which routed through resolveEnhancement, dropping EnhInclude and forcing +// the RFC step to guess "ISM_SAPLVKMPE" — which doesn't exist as a REPOSRC +// row, so all 7 non-LEGO HOOK_IMPL ENHOs on RVKMP901 rendered as +// "[source body unavailable]" even though the bridge worked. +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) } } From 491072ffa439cec4e597e43b8381a984e73953c0 Mon Sep 17 00:00:00 2001 From: Phil Barkow Date: Wed, 20 May 2026 09:32:06 +0200 Subject: [PATCH 2/3] Fix regex compatibility for older versions than 7.55 of SAP in ADT_VSP --- embedded/abap/zcl_vsp_amdp_service.clas.abap | 26 ++- embedded/abap/zcl_vsp_apc_handler.clas.abap | 43 ++++- embedded/abap/zcl_vsp_debug_service.clas.abap | 29 +++- embedded/abap/zcl_vsp_git_service.clas.abap | 40 ++++- .../abap/zcl_vsp_report_service.clas.abap | 37 +++- embedded/abap/zcl_vsp_rfc_service.clas.abap | 158 +++++++++++------- embedded/abap/zcl_vsp_utils.clas.abap | 29 +++- 7 files changed, 275 insertions(+), 87 deletions(-) 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. From 431a1a00f5a6af15556577190fe34991c7b0509c Mon Sep 17 00:00:00 2001 From: Phil Barkow Date: Wed, 20 May 2026 09:34:21 +0200 Subject: [PATCH 3/3] Update descriptions --- pkg/adt/enhancements.go | 2 +- pkg/adt/enhancements_test.go | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/pkg/adt/enhancements.go b/pkg/adt/enhancements.go index bb824869..d84eb5d8 100644 --- a/pkg/adt/enhancements.go +++ b/pkg/adt/enhancements.go @@ -112,7 +112,7 @@ func (c *Client) GetEnhancement(ctx context.Context, name string) (string, error // "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-LEGO HOOK_IMPL plug-ins. +// most non-company HOOK_IMPL plug-ins. // // Returns the same step-4 metadata-only error as GetEnhancement when no path // resolves the body. diff --git a/pkg/adt/enhancements_test.go b/pkg/adt/enhancements_test.go index 9406ac27..76e2d8bb 100644 --- a/pkg/adt/enhancements_test.go +++ b/pkg/adt/enhancements_test.go @@ -348,12 +348,6 @@ func TestGetEnhancement_FallsBackToRFC(t *testing.T) { // 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. -// -// Regression target: pre-fix, the include footer called GetEnhancement(name) -// which routed through resolveEnhancement, dropping EnhInclude and forcing -// the RFC step to guess "ISM_SAPLVKMPE" — which doesn't exist as a REPOSRC -// row, so all 7 non-LEGO HOOK_IMPL ENHOs on RVKMP901 rendered as -// "[source body unavailable]" even though the bridge worked. func TestGetEnhancementByRef_PreservesEnhInclude(t *testing.T) { mock := &routedMock{ byPath: map[string]*http.Response{