From a4eb567e4c1fbca8ca11cd59254c5af86c66a463 Mon Sep 17 00:00:00 2001 From: Kral Date: Mon, 5 Oct 2026 12:03:54 +0200 Subject: [PATCH] Proxy: local abaplint messages on a bare 'save failed' (EPOD syntax check cannot see the rejected source) Co-Authored-By: Claude Sonnet 5.5 --- harness/localcheck.py | 86 +++++++++++++++++++++++++++++++++++++++++++ harness/proxy.py | 20 +++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 harness/localcheck.py diff --git a/harness/localcheck.py b/harness/localcheck.py new file mode 100644 index 0000000..c23a4e4 --- /dev/null +++ b/harness/localcheck.py @@ -0,0 +1,86 @@ +"""Local syntax check with abaplint (parser errors) for a source that SAP could not save. + +SAP answers a failed write with "An error occured during the save operation" and nothing else, and the +EPOD syntax check reads the stored version (the old one or none), so it cannot check the rejected source. +The proxy runs this check on the rejected source and adds the messages (line + text) to the tool result. +""" +import json +import os +import re +import shutil +import subprocess +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ABAPLINT = os.path.join(ROOT, "node_modules", ".bin", "abaplint") +VERSIONS = {"v702", "v740sp05", "v750", "v758"} +BARE_SAVE_FAILURE = re.compile(r"during the save operation", re.I) + + +def is_bare_save_failure(text): + """True when the write result is the detail-free 'save operation' error.""" + try: + obj = json.loads(text) + except (TypeError, ValueError): + return bool(BARE_SAVE_FAILURE.search(str(text))) + if not isinstance(obj, dict) or obj.get("success") is not False: + return False + return bool(BARE_SAVE_FAILURE.search(str(obj.get("error") or obj.get("message") or ""))) \ + and not (obj.get("activation") or {}).get("messages") + + +def filename(object_type, name, include_type=None, function_group=None): + n = re.sub(r"\W", "_", str(name).lower()) + t = str(object_type).upper() + if t == "CLAS": + return f"{n}.clas.{'testclasses.' if str(include_type).lower() == 'testclasses' else ''}abap" + if t == "INTF": + return f"{n}.intf.abap" + if t == "PROG": + return f"{n}.prog.abap" + if t == "FUNC" and function_group: + fg = re.sub(r"\W", "_", str(function_group).lower()) + return f"{fg}.fugr.{n}.abap" + if t == "DDLS": + return f"{n}.ddls.asddls" + return None # TABL, FUGR, ...: no local parser check + + +def parser_messages(object_type, name, source, include_type=None, function_group=None, release=None): + """[{"line", "severity", "message"}] from abaplint parser_error; [] when nothing is found or no check.""" + fn = filename(object_type, name, include_type, function_group) + if not fn or not source or not os.path.exists(ABAPLINT): + return [] + version = release if release in VERSIONS else "v758" + d = tempfile.mkdtemp(prefix="proxylint_") + try: + os.makedirs(os.path.join(d, "src")) + open(os.path.join(d, "src", fn), "w").write(source) + cfg = {"global": {"files": "/src/**/*.*"}, "dependencies": [], + "syntax": {"version": version, "errorNamespace": "^(Z|Y)"}, "rules": {"parser_error": True}} + json.dump(cfg, open(os.path.join(d, "abaplint.json"), "w")) + p = subprocess.run([ABAPLINT, "abaplint.json", "-f", "json"], cwd=d, capture_output=True, text=True, + timeout=60) + issues = json.loads(p.stdout or "[]") + except Exception: # noqa: BLE001 the hint is optional; the original SAP result stays + return [] + finally: + shutil.rmtree(d, ignore_errors=True) + return [{"line": i.get("start", {}).get("row"), "severity": "E", "message": i.get("description", "")} + for i in issues if i.get("key") == "parser_error"][:10] + + +def augment(text, messages): + """Add the messages to a failed-write result (JSON object keeps valid JSON).""" + if not messages: + return text + try: + obj = json.loads(text) + if isinstance(obj, dict): + obj["syntaxCheck"] = {"source": "local abaplint (the rejected source, not stored in SAP)", + "messages": messages} + return json.dumps(obj) + except (TypeError, ValueError): + pass + return text + "\nSyntax check (local abaplint, rejected source): " + "; ".join( + f"line {m['line']}: {m['message']}" for m in messages) diff --git a/harness/proxy.py b/harness/proxy.py index d3698a2..34dacce 100644 --- a/harness/proxy.py +++ b/harness/proxy.py @@ -4,6 +4,8 @@ import json import re import time +from . import localcheck + MODEL_TOOLS = { "sap_search_object", "sap_pull_source", "sap_object_structure", "sap_object_members", "sap_usage_references", "sap_element_info", "sap_inactive_objects", "sap_short_dumps", @@ -56,8 +58,10 @@ class BudgetExceeded(Exception): class ToolProxy: - def __init__(self, mcp, prefix, budget, log_path, tool_schema=None): + def __init__(self, mcp, prefix, budget, log_path, tool_schema=None, release=None): self.mcp = mcp + self.release = release # task release target, for the local syntax check + self.syntax_hints = 0 # failed writes that got local syntax messages self.variant = VARIANTS.get(tool_schema) if tool_schema else None self.started = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) # dump times are UTC self.prefix = prefix.upper() @@ -163,7 +167,12 @@ class ToolProxy: src = self.last_source.get(str(args.get("objectName", "")).upper()) if src is not None: err, text = self._activation_fallback(dict(args, source=src), err, text) + raw_text = text + if tool in ("sap_push_source", "sap_push_element") and localcheck.is_bare_save_failure(text): + text = self._add_syntax_messages(args, text) result = (err, self._filter(tool, text)) + if text != raw_text: + entry["raw_result"] = raw_text if tool in WRITE_TOOLS: failed = err or '"success":false' in text.replace(" ", "") self.fail_streak = self.fail_streak + 1 if failed else 0 @@ -183,6 +192,15 @@ class ToolProxy: self.log.flush() return result + def _add_syntax_messages(self, args, text): + """SAP says only "save operation failed". The EPOD syntax check reads the stored version, not the + rejected source, so the proxy checks the rejected source with local abaplint and adds the messages.""" + msgs = localcheck.parser_messages(args.get("objectType"), args.get("objectName"), args.get("source"), + args.get("includeType"), args.get("functionGroup"), self.release) + if msgs: + self.syntax_hints += 1 + return localcheck.augment(text, msgs) + def _activation_fallback(self, args, err, text): """EPOD does not activate a second write of a PROG or FUNC (outcome notExecuted, and sap_activate cannot help). Write and activate the same source through ADT REST; the agent sees one normal result."""