Files
BAT/exchangeResources/claude-skills/ignition-exchange-convert/parity.py
2026-09-15 13:06:45 -05:00

265 lines
10 KiB
Python

#!/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 <original_project> <converted_project>
"""
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())