Files
2026-09-15 13:06:45 -05:00

218 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Referential-integrity check for a migrated Ignition project.
The style checker tests NAMES. This tests that everything still POINTS somewhere
real after a namespace + function rename: embedded views, style classes, view
params, and every call into the project library. A migration can be perfectly
named and completely broken; this is the half that catches that.
Usage: python3 verify_refs.py <project_dir> [--old OldNamespace]
"""
import ast
import json
import os
import re
import sys
PERSP = "com.inductiveautomation.perspective"
EMBED = {"ia.display.view", "ia.display.flex-repeater"}
FAILS = []
def fail(where, msg):
FAILS.append((where, msg))
def walk(node, visit, path="root"):
if not isinstance(node, dict):
return
visit(node, path)
for i, c in enumerate(node.get("children") or []):
n = (c.get("meta") or {}).get("name", "?") if isinstance(c, dict) else "?"
walk(c, visit, "%s/%s[%d]" % (path, n, i))
def main():
proj = os.path.abspath(sys.argv[1])
# The project's previous namespace, e.g. "MyCompany". Any surviving mention
# of it in the converted project is a missed rename.
old = ""
if "--old" in sys.argv:
old = sys.argv[sys.argv.index("--old") + 1]
views_root = os.path.join(proj, PERSP, "views")
styles_root = os.path.join(proj, PERSP, "style-classes")
sp_root = os.path.join(proj, "ignition", "script-python")
def collect(base, payload):
out = set()
for dp, _d, fs in os.walk(base):
if payload in fs:
r = os.path.relpath(dp, base)
if r != ".":
out.add(r.replace(os.sep, "/"))
return out
views = collect(views_root, "view.json")
styles = collect(styles_root, "style.json")
# ---- project library: package path -> {function names} ----------------
lib = {}
for dp, _d, fs in os.walk(sp_root):
if "code.py" not in fs:
continue
pkg = os.path.relpath(dp, sp_root).replace(os.sep, ".")
src = open(os.path.join(dp, "code.py"), encoding="utf-8",
errors="replace").read()
try:
tree = ast.parse(src)
lib[pkg] = {n.name for n in tree.body
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
except SyntaxError as e:
fail(pkg + "/code.py", "does not parse: line %s %s" % (e.lineno, e.msg))
lib[pkg] = set()
if re.search(r"""(^|[^A-Za-z0-9_'"])[fF]["']""", src):
fail(pkg + "/code.py", "f-string present (Jython 2.7 cannot parse)")
lib_roots = {p.split(".")[0] for p in lib}
# ---- per view ---------------------------------------------------------
for vp in sorted(views):
vfile = os.path.join(views_root, vp.replace("/", os.sep), "view.json")
where = "views/" + vp
raw = open(vfile, encoding="utf-8", errors="replace").read()
try:
view = json.loads(raw)
except Exception as e:
fail(where, "invalid JSON: %s" % e)
continue
if old and old in raw:
hits = len(re.findall(re.escape(old), raw))
fail(where, "%d leftover reference(s) to old namespace %r" % (hits, old))
if "session.custom" in raw:
fail(where, "still reads session.custom (Exchange resources must not)")
declared = set((view.get("params") or {}).keys())
declared_custom = set((view.get("custom") or {}).keys())
for k in (view.get("propConfig") or {}):
if k.startswith("params."):
declared.add(k.split(".")[1].split("[")[0])
elif k.startswith("custom."):
declared_custom.add(k.split(".")[1].split("[")[0])
def visit(node, cpath):
if node.get("type") in EMBED:
p = (node.get("props") or {}).get("path")
bound = "props.path" in (node.get("propConfig") or {})
if isinstance(p, str) and p:
if p not in views:
fail(where, "%s embeds missing view %r" % (cpath, p))
elif not bound:
pass
cls = ((node.get("props") or {}).get("style") or {}).get("classes")
if isinstance(cls, str):
for c in cls.split():
if c and c not in styles:
fail(where, "%s references missing style class %r" % (cpath, c))
walk(view.get("root") or {}, visit)
# bindings: params/custom resolution, tab indent, library calls
def each_cfg():
for k, v in (view.get("propConfig") or {}).items():
yield "view." + k, v
acc = []
walk(view.get("root") or {},
lambda n, p: [acc.append(("%s %s" % (p, k), v))
for k, v in (n.get("propConfig") or {}).items()])
for it in acc:
yield it
for owner, cfg in each_cfg():
b = (cfg or {}).get("binding")
if not b:
continue
blobs = [json.dumps(b.get("config") or {})]
for tr in b.get("transforms") or []:
code = tr.get("code", "")
blobs.append(code)
if tr.get("type") == "script" and code and not code.startswith(("\t", "\n")):
fail(where, "%s: script transform not tab-indented" % owner)
blob = " ".join(blobs)
for m in re.finditer(r"view\.params\.([A-Za-z0-9_]+)", blob):
if m.group(1) not in declared:
fail(where, "%s: undeclared view param %r" % (owner, m.group(1)))
for m in re.finditer(r"view\.custom\.([A-Za-z0-9_]+)", blob):
if m.group(1) not in declared_custom:
fail(where, "%s: undeclared view custom prop %r" % (owner, m.group(1)))
check_lib_calls(raw, where, lib, lib_roots)
# ---- library calling itself ------------------------------------------
for dp, _d, fs in os.walk(sp_root):
if "code.py" not in fs:
continue
p = os.path.join(dp, "code.py")
rel = os.path.relpath(p, proj)
src = open(p, encoding="utf-8", errors="replace").read()
if old and old in src:
fail(rel, "leftover reference to old namespace %r" % old)
check_lib_calls(src, rel, lib, lib_roots)
# ---- resource.json integrity -----------------------------------------
for dp, ds, fs in os.walk(proj):
if ".resources" in ds:
fail(os.path.relpath(dp, proj), "authored .resources directory")
ds.remove(".resources")
if "resource.json" not in fs:
continue
rel = os.path.relpath(dp, proj)
try:
res = json.load(open(os.path.join(dp, "resource.json")))
except Exception as e:
fail(rel, "invalid resource.json: %s" % e)
continue
declared = res.get("files") or []
payloads = [f for f in fs if f != "resource.json"]
for f in declared:
if f not in payloads:
fail(rel, "resource.json declares missing file %r" % f)
for f in payloads:
if f not in declared:
fail(rel, "undeclared payload file %r" % f)
want = "A" if ("script-python" in rel or "global-props" in rel) else "G"
if res.get("scope") != want:
fail(rel, "scope %r, expected %r" % (res.get("scope"), want))
for where, msg in FAILS:
print("BROKEN %s: %s" % (where, msg))
print("refs: %d view(s), %d style class(es), %d library module(s), %d breakage(s)"
% (len(views), len(styles), len(lib), len(FAILS)))
return 1 if FAILS else 0
def check_lib_calls(text, where, lib, lib_roots):
"""Every <root>.<pkg>.<fn> reference must resolve to a real function."""
for root in lib_roots:
# (?<![.\w]) so a property path like session.custom.Foo.bar is not
# mistaken for a call into a library package named Foo.
for m in re.finditer(r"(?<![.\w])" + re.escape(root)
+ r"((?:\.[A-Za-z_][A-Za-z0-9_]*)+)", text):
parts = m.group(1).lstrip(".").split(".")
for split in range(len(parts) - 1, 0, -1):
pkg = ".".join([root] + parts[:split])
fn = parts[split]
if pkg in lib:
if fn not in lib[pkg]:
fail(where, "calls %s.%s which does not exist in %s/code.py"
% (pkg, fn, pkg.replace(".", "/")))
break
# No prefix resolves to a library module, so this is not a library
# reference at all. The same dotted namespace is used for message
# types and popup ids (exchange.<resource>.<handler>), which are
# strings, not code -- flagging those would be a false positive.
if __name__ == "__main__":
sys.exit(main())