#!/usr/bin/env python3 """Functional-parity check between an original Ignition project and a converted copy. Renaming is supposed to be behaviour-preserving. This compares what the two projects DO, ignoring what things are called: every view, every component, every binding, every event/message handler, every embedded view and style reference is fingerprinted with names normalised (case, punctuation, snake/camel folded) so a pure rename cancels out and a DROPPED FEATURE does not. Usage: python3 parity.py """ import json import os import re import sys from collections import Counter PERSP = "com.inductiveautomation.perspective" EMBED = {"ia.display.view", "ia.display.flex-repeater"} DIFFS = [] def diff(scope, msg): DIFFS.append((scope, msg)) def norm(s): """Fold a name to bare lowercase alphanumerics so renames cancel out.""" return re.sub(r"[^a-z0-9]", "", str(s).lower()) def segs(path): return [p for p in str(path).replace("\\", "/").split("/") if p] def common_prefix_len(paths): """How many leading segments every resource shares -- i.e. the namespace. Never eats the final segment, so a single-resource project still has a name. """ lists = [segs(p) for p in paths if segs(p)] if not lists: return 0 n = 0 while True: if any(len(l) <= n + 1 for l in lists): break first = lists[0][n] if any(l[n] != first for l in lists): break n += 1 return n def key_of(path, strip): """Namespace-stripped identity: folds case, punctuation and re-foldering.""" return norm("".join(segs(path)[strip:])) def collect(base, payload): out = {} if not os.path.isdir(base): return out for dp, _d, fs in os.walk(base): if payload in fs: rel = os.path.relpath(dp, base).replace(os.sep, "/") if rel != ".": out[rel] = os.path.join(dp, payload) return out 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 view_fingerprint(vfile, embed_key): """A behaviour fingerprint for one view, insensitive to naming.""" view = json.load(open(vfile, encoding="utf-8")) fp = { "types": Counter(), "names": Counter(), "bindings": Counter(), "transforms": Counter(), "events": Counter(), "handlers": Counter(), "embeds": Counter(), "stylerefs": 0, "params": set(), "components": 0, } def record_props(node, owner): for key, cfg in (node.get("propConfig") or {}).items(): b = (cfg or {}).get("binding") if not b: continue # the bound property is the feature; its binding type is how it is fed fp["bindings"][(norm(key), b.get("type"))] += 1 for tr in (b.get("transforms") or []): fp["transforms"][tr.get("type")] += 1 scripts = node.get("scripts") or {} for ev in (scripts.get("customMethods") or []): fp["events"][("method", norm(ev.get("name")))] += 1 for mh in (scripts.get("messageHandlers") or []): fp["handlers"][norm((mh.get("messageType") or "").split(".")[-1])] += 1 for comp in (scripts.get("extensionFunctions") or {}): fp["events"][("ext", norm(comp))] += 1 events = node.get("events") or {} for domain, acts in events.items(): if isinstance(acts, dict): for act in acts: fp["events"][(norm(domain), norm(act))] += 1 record_props(view, "view") for k in (view.get("propConfig") or {}): if k.startswith("params."): fp["params"].add(norm(k.split(".")[1].split("[")[0])) for k in (view.get("params") or {}): fp["params"].add(norm(k)) def visit(node, cpath): if cpath != "root": fp["components"] += 1 fp["types"][node.get("type")] += 1 nm = (node.get("meta") or {}).get("name") if nm: fp["names"][norm(nm)] += 1 record_props(node, cpath) if node.get("type") in EMBED: p = (node.get("props") or {}).get("path") if isinstance(p, str) and p: fp["embeds"][embed_key(p)] += 1 cls = ((node.get("props") or {}).get("style") or {}).get("classes") if isinstance(cls, str): fp["stylerefs"] += len([c for c in cls.split() if c]) walk(view.get("root") or {}, visit) return fp def compare_counter(scope, label, a, b): lost = a - b gained = b - a for k, n in sorted(lost.items(), key=lambda kv: str(kv[0])): diff(scope, "LOST %s %s x%d" % (label, k, n)) for k, n in sorted(gained.items(), key=lambda kv: str(kv[0])): diff(scope, "ADDED %s %s x%d" % (label, k, n)) def main(): old_p, new_p = os.path.abspath(sys.argv[1]), os.path.abspath(sys.argv[2]) old_views = collect(os.path.join(old_p, PERSP, "views"), "view.json") new_views = collect(os.path.join(new_p, PERSP, "views"), "view.json") old_styles = collect(os.path.join(old_p, PERSP, "style-classes"), "style.json") new_styles = collect(os.path.join(new_p, PERSP, "style-classes"), "style.json") # Strip each project's own namespace so a pure re-namespacing cancels out. ov_strip = common_prefix_len(old_views) nv_strip = common_prefix_len(new_views) os_strip = common_prefix_len(old_styles) ns_strip = common_prefix_len(new_styles) def index(d, strip): out = {} for k, v in d.items(): out.setdefault(key_of(k, strip), []).append((k, v)) return out oi, ni = index(old_views, ov_strip), index(new_views, nv_strip) for key in sorted(set(oi) - set(ni)): diff("views", "MISSING view '%s' (no counterpart for %s)" % (key, oi[key][0][0])) for key in sorted(set(ni) - set(oi)): diff("views", "EXTRA view '%s' (%s)" % (key, ni[key][0][0])) # Style classes legitimately change shape (Grade/A -> grade-a, and # E-STYLE-HIER re-folders them), so identity matching would be noise. # What must not change is that none disappeared -- and verify_refs.py # separately proves every remaining reference still resolves. if len(old_styles) != len(new_styles): diff("styles", "style class count %d -> %d" % (len(old_styles), len(new_styles))) okeys = {key_of(k, os_strip) for k in old_styles} nkeys = {key_of(k, ns_strip) for k in new_styles} for k in sorted(okeys - nkeys): diff("styles", "no class resembling '%s' in the new project" % k) # ---- per-view behaviour --------------------------------------------- for key in sorted(set(oi) & set(ni)): ofp = view_fingerprint(oi[key][0][1], lambda p: key_of(p, ov_strip)) nfp = view_fingerprint(ni[key][0][1], lambda p: key_of(p, nv_strip)) scope = "view %s" % key if ofp["components"] != nfp["components"]: diff(scope, "component count %d -> %d" % (ofp["components"], nfp["components"])) if ofp["stylerefs"] != nfp["stylerefs"]: diff(scope, "style class references %d -> %d" % (ofp["stylerefs"], nfp["stylerefs"])) for label in ("types", "names", "bindings", "transforms", "events", "handlers", "embeds"): compare_counter(scope, label[:-1] if label.endswith("s") else label, ofp[label], nfp[label]) lost_params = ofp["params"] - nfp["params"] if lost_params: diff(scope, "params no longer declared: %s" % ", ".join(sorted(lost_params))) # ---- library surface ------------------------------------------------- def lib(root): out = {} base = os.path.join(root, "ignition", "script-python") for dp, _d, fs in os.walk(base): if "code.py" not in fs: continue mod = os.path.basename(dp) src = open(os.path.join(dp, "code.py"), encoding="utf-8", errors="replace").read() fns = {} for m in re.finditer(r"^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", src, re.M): args = [a.strip().split("=")[0].strip() for a in m.group(2).split(",") if a.strip()] fns[norm(m.group(1))] = len(args) out[norm(mod)] = fns return out ol, nl = lib(old_p), lib(new_p) for mod in sorted(set(ol) - set(nl)): diff("library", "MISSING module %s" % mod) for mod in sorted(set(ol) & set(nl)): for fn in sorted(set(ol[mod]) - set(nl[mod])): diff("library", "MISSING function %s.%s" % (mod, fn)) for fn in sorted(set(ol[mod]) & set(nl[mod])): if ol[mod][fn] != nl[mod][fn]: diff("library", "arity change %s.%s: %d -> %d args" % (mod, fn, ol[mod][fn], nl[mod][fn])) # ---- pages ----------------------------------------------------------- def pages(root): p = os.path.join(root, PERSP, "page-config", "config.json") if not os.path.exists(p): return {} return (json.load(open(p)) or {}).get("pages") or {} op, np_ = pages(old_p), pages(new_p) if len(op) != len(np_): diff("pages", "page count %d -> %d" % (len(op), len(np_))) ot = {key_of(v.get("viewPath", ""), ov_strip) for v in op.values()} nt = {key_of(v.get("viewPath", ""), nv_strip) for v in np_.values()} for k in sorted(ot - nt): diff("pages", "no page maps to view %s any more" % k) for scope, msg in DIFFS: print("DIFF %-28s %s" % (scope, msg)) print("parity: %d view(s) vs %d, %d style(s) vs %d, %d difference(s)" % (len(old_views), len(new_views), len(old_styles), len(new_styles), len(DIFFS))) return 1 if DIFFS else 0 if __name__ == "__main__": sys.exit(main())