Proxy: local abaplint messages on a bare 'save failed' (EPOD syntax check cannot see the rejected source)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
86
harness/localcheck.py
Normal file
86
harness/localcheck.py
Normal file
@@ -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)
|
||||||
@@ -4,6 +4,8 @@ import json
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from . import localcheck
|
||||||
|
|
||||||
MODEL_TOOLS = {
|
MODEL_TOOLS = {
|
||||||
"sap_search_object", "sap_pull_source", "sap_object_structure", "sap_object_members",
|
"sap_search_object", "sap_pull_source", "sap_object_structure", "sap_object_members",
|
||||||
"sap_usage_references", "sap_element_info", "sap_inactive_objects", "sap_short_dumps",
|
"sap_usage_references", "sap_element_info", "sap_inactive_objects", "sap_short_dumps",
|
||||||
@@ -56,8 +58,10 @@ class BudgetExceeded(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class ToolProxy:
|
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.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.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.started = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) # dump times are UTC
|
||||||
self.prefix = prefix.upper()
|
self.prefix = prefix.upper()
|
||||||
@@ -163,7 +167,12 @@ class ToolProxy:
|
|||||||
src = self.last_source.get(str(args.get("objectName", "")).upper())
|
src = self.last_source.get(str(args.get("objectName", "")).upper())
|
||||||
if src is not None:
|
if src is not None:
|
||||||
err, text = self._activation_fallback(dict(args, source=src), err, text)
|
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))
|
result = (err, self._filter(tool, text))
|
||||||
|
if text != raw_text:
|
||||||
|
entry["raw_result"] = raw_text
|
||||||
if tool in WRITE_TOOLS:
|
if tool in WRITE_TOOLS:
|
||||||
failed = err or '"success":false' in text.replace(" ", "")
|
failed = err or '"success":false' in text.replace(" ", "")
|
||||||
self.fail_streak = self.fail_streak + 1 if failed else 0
|
self.fail_streak = self.fail_streak + 1 if failed else 0
|
||||||
@@ -183,6 +192,15 @@ class ToolProxy:
|
|||||||
self.log.flush()
|
self.log.flush()
|
||||||
return result
|
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):
|
def _activation_fallback(self, args, err, text):
|
||||||
"""EPOD does not activate a second write of a PROG or FUNC (outcome notExecuted, and sap_activate
|
"""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."""
|
cannot help). Write and activate the same source through ADT REST; the agent sees one normal result."""
|
||||||
|
|||||||
Reference in New Issue
Block a user