#!/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 [--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 .. reference must resolve to a real function.""" for root in lib_roots: # (?.), which are # strings, not code -- flagging those would be a false positive. if __name__ == "__main__": sys.exit(main())