The 0.6.3 fix replaced 'first-non-empty-pattern-wins' with category iteration but used a HARDCODED list of 'well-known V3.5 GUIDs' that was wrong. None of those GUIDs matched the actual Build category, so compile errors stayed invisible (compile_project still reported '0 errors' even when the IDE-side download path saw them). Diagnosed via a one-shot probe injected into compile_project on 2026-04-29: dumped attrs of script_engine.system, then called script_engine.system.get_message_categories() (the METHOD) directly. That returned the actual 7 category GUIDs in this CODESYS V3.5 SP22 Patch 1 install: 05581bd1-66d3-4251-aff2-047cc8e9adf7 Offline Help 936e1a33-3af8-47fa-b40b-903f0ae0b6cc Application Composer a9b26e07-6ae1-4c06-9cd1-9ddddd397a2d SVN 0a6fcb64-7f24-43c6-a6d3-f70cb5d31114 (no parameterless ctor; Git) 194b48a9-ab51-43ae-b9a9-51d3edaaddf3 Script Messages 97f48d64-a2a3-4856-b640-75c046e37ea9 Build <-- the one we needed 220493a1-f49b-4416-9a3f-a545db707cbe Additional code checks Real fix: replace the hardcoded list in _enumerate_categories() in both compile_project.py and get_compile_messages.py with a call to system.get_message_categories(); label each one via get_message_category_description(guid). Iterate per category as before. GUIDs are now discovered at runtime so the same code works on any SP and any locale. Verification: injected `THIS_IS_NOT_VALID_IEC_KEYWORD;` into PLC_PRG.implementation, ran compile_project; output now reads "1 error(s), 2 warning(s). ERROR: Identifier 'THIS_IS_NOT_VALID_IEC_KEYWORD' not defined". Restored PLC_PRG; 0 error(s) again. End-to-end fix confirmed. Bumped 0.6.3 -> 0.6.4.
336 lines
12 KiB
Python
336 lines
12 KiB
Python
import sys, scriptengine as script_engine, os, traceback, json
|
|
|
|
|
|
# ============================================================================
|
|
# Inlined helpers (shared shape with get_compile_messages.py).
|
|
# Inlined rather than imported because the IPC executor concatenates helper
|
|
# scripts at runtime; siblings in src/scripts/ don't have a sys.path entry.
|
|
# Keep these two copies in sync.
|
|
# ============================================================================
|
|
|
|
_JSON_INT64_MAX = 9223372036854775807
|
|
|
|
|
|
def _coerce_int(v):
|
|
if v is None:
|
|
return None
|
|
try:
|
|
return int(v)
|
|
except (TypeError, ValueError, OverflowError):
|
|
return None
|
|
|
|
|
|
def _coerce_str(v):
|
|
if v is None:
|
|
return None
|
|
try:
|
|
return str(v)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _coerce_for_json(obj):
|
|
if isinstance(obj, bool):
|
|
return obj
|
|
if isinstance(obj, (int, long)): # noqa: F821
|
|
try:
|
|
if obj > _JSON_INT64_MAX or obj < -_JSON_INT64_MAX - 1:
|
|
return str(obj)
|
|
return int(obj)
|
|
except Exception:
|
|
return str(obj)
|
|
if isinstance(obj, dict):
|
|
out = {}
|
|
for k, v in obj.items():
|
|
try:
|
|
key = k if isinstance(k, str) else str(k)
|
|
except Exception:
|
|
continue
|
|
out[key] = _coerce_for_json(v)
|
|
return out
|
|
if isinstance(obj, (list, tuple)):
|
|
return [_coerce_for_json(v) for v in obj]
|
|
return obj
|
|
|
|
|
|
def _build_message_entry(msg, category_name=None):
|
|
entry = {}
|
|
if hasattr(msg, 'severity'):
|
|
try:
|
|
sev = str(msg.severity).lower()
|
|
except Exception:
|
|
sev = 'unknown'
|
|
if 'error' in sev:
|
|
entry['severity'] = 'error'
|
|
elif 'warning' in sev:
|
|
entry['severity'] = 'warning'
|
|
elif 'info' in sev:
|
|
entry['severity'] = 'info'
|
|
else:
|
|
entry['severity'] = sev
|
|
else:
|
|
entry['severity'] = 'unknown'
|
|
text = None
|
|
for attr in ('text', 'message'):
|
|
if hasattr(msg, attr):
|
|
text = _coerce_str(getattr(msg, attr))
|
|
if text is not None:
|
|
break
|
|
if text is None:
|
|
text = _coerce_str(msg)
|
|
entry['text'] = text
|
|
if hasattr(msg, 'object_name'):
|
|
entry['object'] = _coerce_str(msg.object_name)
|
|
elif hasattr(msg, 'source'):
|
|
entry['object'] = _coerce_str(msg.source)
|
|
if hasattr(msg, 'line_number'):
|
|
entry['line'] = _coerce_int(msg.line_number)
|
|
elif hasattr(msg, 'position'):
|
|
entry['line'] = _coerce_int(msg.position)
|
|
cat = None
|
|
for attr in ('category', 'category_name', 'category_guid'):
|
|
if hasattr(msg, attr):
|
|
try:
|
|
v = getattr(msg, attr)
|
|
if v is not None:
|
|
cat = _coerce_str(v)
|
|
if cat:
|
|
break
|
|
except Exception:
|
|
pass
|
|
if not cat and category_name:
|
|
cat = category_name
|
|
if cat:
|
|
entry['category'] = cat
|
|
return entry
|
|
|
|
|
|
def _enumerate_categories(script_engine_arg):
|
|
"""Returns a list of (label, category_guid_or_None) tuples for every
|
|
message category the IDE exposes plus a (None, None) sentinel for the
|
|
no-filter call.
|
|
|
|
Discovery path: script_engine.system.get_message_categories() (the
|
|
METHOD) returns the live List[Guid] of category IDs registered in
|
|
this IDE session. script_engine.system.get_message_category_description(guid)
|
|
gives the human-readable label per category ('Build', 'Application
|
|
Composer', 'SVN', 'Script Messages', 'Additional code checks', etc.).
|
|
|
|
History: pre-0.6.4 the function looked at `system.message_categories`
|
|
(a property) which doesn't exist, then fell back to a hardcoded list
|
|
of GUIDs that happened to NOT include the actual Build category
|
|
(97f48d64-a2a3-4856-b640-75c046e37ea9). Result: compile errors were
|
|
invisible. Verified by diagnostic probe with intentional syntax-error
|
|
injection on 2026-04-29."""
|
|
cats = [('<default-no-filter>', None)]
|
|
se_sys = getattr(script_engine_arg, 'system', None)
|
|
if se_sys is None or not hasattr(se_sys, 'get_message_categories'):
|
|
return cats
|
|
try:
|
|
guids = se_sys.get_message_categories()
|
|
except Exception as e:
|
|
print("DEBUG: get_message_categories() raised: %s" % e)
|
|
return cats
|
|
if guids is None:
|
|
return cats
|
|
for g in guids:
|
|
label = None
|
|
try:
|
|
if hasattr(se_sys, 'get_message_category_description'):
|
|
label = _coerce_str(se_sys.get_message_category_description(g))
|
|
except Exception:
|
|
label = None
|
|
if not label:
|
|
label = _coerce_str(g) or '<unnamed>'
|
|
cats.append((label, g))
|
|
return cats
|
|
|
|
|
|
def _extract_all_messages(target_app, script_engine_arg):
|
|
all_entries = []
|
|
seen = set()
|
|
|
|
def _add(entry):
|
|
key = (
|
|
entry.get('severity'),
|
|
entry.get('text'),
|
|
entry.get('object'),
|
|
entry.get('line'),
|
|
)
|
|
if key in seen:
|
|
return False
|
|
seen.add(key)
|
|
all_entries.append(entry)
|
|
return True
|
|
|
|
if target_app is not None and hasattr(target_app, 'get_message_objects'):
|
|
for label, cat in _enumerate_categories(script_engine_arg):
|
|
try:
|
|
if cat is None:
|
|
msgs = target_app.get_message_objects()
|
|
else:
|
|
try:
|
|
msgs = target_app.get_message_objects(cat)
|
|
except TypeError:
|
|
continue
|
|
except Exception as e:
|
|
print("DEBUG: app.get_message_objects(%s) failed: %s" % (label, e))
|
|
continue
|
|
if not msgs:
|
|
continue
|
|
count_before = len(all_entries)
|
|
try:
|
|
for m in msgs:
|
|
try:
|
|
_add(_build_message_entry(m, label))
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
added = len(all_entries) - count_before
|
|
if added > 0:
|
|
print("DEBUG: app.get_message_objects(%s) added %d new" % (label, added))
|
|
|
|
se_sys = getattr(script_engine_arg, 'system', None)
|
|
if se_sys is not None and hasattr(se_sys, 'get_message_objects'):
|
|
for label, cat in _enumerate_categories(script_engine_arg):
|
|
try:
|
|
if cat is None:
|
|
msgs = se_sys.get_message_objects()
|
|
else:
|
|
try:
|
|
msgs = se_sys.get_message_objects(cat)
|
|
except TypeError:
|
|
continue
|
|
except Exception as e:
|
|
print("DEBUG: system.get_message_objects(%s) failed: %s" % (label, e))
|
|
continue
|
|
if not msgs:
|
|
continue
|
|
count_before = len(all_entries)
|
|
try:
|
|
for m in msgs:
|
|
try:
|
|
_add(_build_message_entry(m, label))
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
added = len(all_entries) - count_before
|
|
if added > 0:
|
|
print("DEBUG: system.get_message_objects(%s) added %d new" % (label, added))
|
|
|
|
if se_sys is not None and hasattr(se_sys, 'get_messages'):
|
|
try:
|
|
msgs = se_sys.get_messages()
|
|
if msgs:
|
|
count_before = len(all_entries)
|
|
for m in msgs:
|
|
try:
|
|
_add(_build_message_entry(m, '<legacy>'))
|
|
except Exception:
|
|
pass
|
|
added = len(all_entries) - count_before
|
|
if added > 0:
|
|
print("DEBUG: system.get_messages() added %d new" % added)
|
|
except Exception as e:
|
|
print("DEBUG: system.get_messages() failed: %s" % e)
|
|
|
|
return all_entries
|
|
|
|
|
|
def _count_severity(entries):
|
|
e = w = i = o = 0
|
|
for entry in entries:
|
|
sev = entry.get('severity', 'unknown')
|
|
if sev == 'error':
|
|
e += 1
|
|
elif sev == 'warning':
|
|
w += 1
|
|
elif sev == 'info':
|
|
i += 1
|
|
else:
|
|
o += 1
|
|
return e, w, i, o
|
|
|
|
|
|
def _render_messages_block(entries):
|
|
try:
|
|
messages_json = json.dumps(_coerce_for_json(entries))
|
|
except TypeError as je:
|
|
print("WARN: json.dumps raised %s -- retrying with default=str fallback" % je)
|
|
messages_json = json.dumps(_coerce_for_json(entries), default=lambda o: str(o))
|
|
e, w, i, o = _count_severity(entries)
|
|
return messages_json, e, w, i, o
|
|
|
|
|
|
# ============================================================================
|
|
# Main
|
|
# ============================================================================
|
|
|
|
try:
|
|
print("DEBUG: compile_project script: Project='%s'" % PROJECT_FILE_PATH)
|
|
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
|
project_name = os.path.basename(PROJECT_FILE_PATH)
|
|
target_app = None
|
|
app_name = "N/A"
|
|
|
|
try:
|
|
target_app = primary_project.active_application
|
|
if target_app:
|
|
app_name = getattr(target_app, 'get_name', lambda: "Unnamed App (Active)")()
|
|
print("DEBUG: Found active application: %s" % app_name)
|
|
except Exception as active_err:
|
|
print("WARN: Could not get active application: %s. Searching..." % active_err)
|
|
|
|
if not target_app:
|
|
print("DEBUG: Searching for first compilable application...")
|
|
apps = []
|
|
try:
|
|
all_children = primary_project.get_children(True)
|
|
for child in all_children:
|
|
if hasattr(child, 'is_application') and child.is_application and hasattr(child, 'build'):
|
|
app_name_found = getattr(child, 'get_name', lambda: "Unnamed App")()
|
|
print("DEBUG: Found potential application object: %s" % app_name_found)
|
|
apps.append(child)
|
|
break
|
|
except Exception as find_err:
|
|
print("WARN: Error finding application object: %s" % find_err)
|
|
|
|
if not apps:
|
|
raise RuntimeError("No compilable application found in project '%s'" % project_name)
|
|
target_app = apps[0]
|
|
app_name = getattr(target_app, 'get_name', lambda: "Unnamed App (First Found)")()
|
|
print("WARN: Compiling first found application: %s" % app_name)
|
|
|
|
if not hasattr(target_app, 'build'):
|
|
raise TypeError("Selected object '%s' is not an application or doesn't support build()." % app_name)
|
|
|
|
print("DEBUG: Calling build() on app '%s'..." % app_name)
|
|
target_app.build()
|
|
print("DEBUG: Build command executed for application '%s'." % app_name)
|
|
|
|
# Aggregate messages across every category we can probe (the old
|
|
# 'first-non-empty-pattern-wins' logic missed Build/Compile messages
|
|
# because their category isn't the IDE's default-active one).
|
|
messages = _extract_all_messages(target_app, script_engine)
|
|
messages_json, errors, warnings, infos, others = _render_messages_block(messages)
|
|
|
|
print("### COMPILE_MESSAGES_START ###")
|
|
print(messages_json)
|
|
print("### COMPILE_MESSAGES_END ###")
|
|
print("Compile Initiated For Application: %s" % app_name)
|
|
print("In Project: %s" % project_name)
|
|
print("Errors: %d" % errors)
|
|
print("Warnings: %d" % warnings)
|
|
print("Infos: %d" % infos)
|
|
print("Others: %d" % others)
|
|
print("Total: %d" % len(messages))
|
|
print("SCRIPT_SUCCESS: Application compilation initiated.")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
detailed_error = traceback.format_exc()
|
|
error_message = "Error initiating compilation for project %s: %s\n%s" % (PROJECT_FILE_PATH, e, detailed_error)
|
|
print(error_message)
|
|
print("SCRIPT_ERROR: %s" % error_message)
|
|
sys.exit(1)
|