exchange work

This commit is contained in:
2026-09-15 13:06:45 -05:00
parent 8a3a0acb75
commit 58610c1e1a
265 changed files with 33534 additions and 69 deletions

View File

@@ -0,0 +1,180 @@
---
name: ignition-exchange-convert
description: Convert an existing Ignition project into a publishable Exchange resource — re-namespacing views/styles/scripts, migrating Perspective session properties to view properties, renaming Python to the guide's conventions — and prove the conversion did not break anything. Use when asked to make a project Exchange-ready, de-brand a project for publication, or refactor one out of session-scoped state. Bundles verify_refs.py and parity.py, which catch the two failure modes a conformance checker cannot see: dangling references and silently dropped features. Not for merely checking conformance (use ignition-exchange-conformance) or uploading (use ignition-exchange-publish).
---
# Converting a project into an Exchange resource
A conversion is a large mechanical rename plus one genuine refactor. It is
**very easy to produce a project that is perfectly named and completely broken**
so the verification gates below are not optional garnish, they are the point.
Check the target shape first with
[`ignition-exchange-conformance`](../ignition-exchange-conformance/SKILL.md);
this skill is what to do about the findings.
## Work on a copy
Never convert in place. Copy the project to its new name, leave the original
untouched, and diff against it at the end. The original is your parity baseline —
without it you cannot prove you did not drop a feature.
Ignition project folder name **is** the project name, so the copy's directory
must already be the new lowercase-dashed name.
## Order matters
Do it in this order. Step 2 invalidates bindings that step 3 would otherwise
have to rewrite twice.
1. **Delete what must not ship.** Vision `client-tags/`, scratch views, dev-only
resources.
2. **Session properties → view properties**, while paths are still the familiar
old ones. Verify the app still works before touching namespaces.
3. **Re-namespace** views, style classes, scripts, page config — one mechanical
sweep.
4. **Python pass** — tabs, camelCase, docstrings, loggers.
5. **Content sweep** — branding, internal names, placeholder text.
6. **Verify** (gates below), then package.
## Step 2 is the real refactor
Everything else is search-and-replace. This one changes how the app works.
The guide forbids session custom properties in an Exchange resource because
importing merges them into the host project's session props. Replace with:
- **State lives on the root view** as `custom` properties.
- **Flows down** to child views as `params` (the guide is explicit: `params` =
public/configuration, `custom` = internal).
- **Flows up** as page-scoped messages handled on the root view, named
`exchange.resourceName.handlerName`.
**Bindings must stay reactive.** Do not park binding-read state in
`system.util.getGlobals()` — globals do not trigger Perspective binding updates.
Globals are only for non-reactive cross-session data, and most resources need
none.
Before rewriting a binding, check **what it actually depended on**. A bidirectional
binding that writes a session prop does not necessarily re-trigger the data fetch;
if the fetch keyed off a `refreshToken`, then an explicit Apply button was already
the commit point and your message-based version preserves behaviour exactly. Read
the dependency graph rather than assuming.
## Jython 2.7 constraints
Gateway-scoped code is Jython 2.7. Violations are silent until they reach the gateway.
- **No f-strings.** `%` formatting only.
- No `typing`, `statistics`, `zoneinfo`.
- Java exceptions bypass `except Exception:` — use bare `except:` with
`sys.exc_info()` in gateway-facing defensive code.
- **Scripts embedded in `view.json`** are JSON strings that must be tab-indented:
the first character of every line in the block is a tab.
## Python renames
The guide wants camelCase functions and variables (deliberately not PEP-8) and
tab indentation. Two traps:
- **Rename through the tokenizer, not regex.** A blind replace will corrupt
string literals and attribute names that happen to match (`lt.tm_wday`).
- **Do not rename data-dictionary keys.** Keys inside dicts returned to view
bindings are a wire format between the scripts and every binding that reads
them. They are not covered by any rule and renaming them is a large, risky
diff for no conformance gain.
Tab conversion applies to **indentation** only — bracket continuation lines
starting with spaces are fine and normal. Verify with the tokenizer's INDENT
tokens, not by grepping for leading spaces.
## Verification gates
Run all four. Each catches something the others cannot.
### 1 · Conformance
```bash
python3 skills/ignition-exchange-conformance/exchange_lint.py <new-project>
```
Target: **0 failures**. Warnings are a judgement call.
### 2 · Referential integrity
```bash
python3 skills/ignition-exchange-convert/verify_refs.py <new-project> --old <OldNamespace>
```
Resolves every embedded view path, style-class reference, `view.params.X` /
`view.custom.X`, and every call into the project library against the functions
that actually exist. Also flags leftover old-namespace strings, surviving
`session.custom` reads, untabbed script transforms, f-strings, and `resource.json`
drift. Target: **0 breakages**.
With ~40 function renames, a missed call site is the single most likely defect,
and nothing else finds it.
### 3 · Feature parity
```bash
python3 skills/ignition-exchange-convert/parity.py <original-project> <new-project>
```
Fingerprints every view — component types and names, bindings by target property
and type, script transforms, event and message handlers, embedded views, declared
params — with names normalised for case, punctuation and snake/camel. **A pure
rename cancels out; a dropped feature does not.**
Target: **zero `LOST` or `MISSING` lines.** `ADDED` lines are expected — the
session-props refactor legitimately adds params and handlers. Read the diff, do
not just count it.
### 4 · Behaviour
If the project has a test suite over its pure modules, run it against the
converted library. Point the package name at the new location and alias
camelCase functions **and their keyword arguments** back to the original names —
a renamed kwarg (`higher_is_worse``higherIsWorse`) breaks callers that pass it
by name, which no static check catches.
Compare against the **original project's** result, not against zero failures. A
pre-existing failure staying pre-existing is a pass.
### Then: load it on a gateway
Static analysis cannot prove a screen renders. Load the project and open it.
A clean project load proves resources parse; only a browser proves components
render.
## De-branding
Conformance checkers read names, never label text. Sweep user-visible strings
explicitly — `props.text`, `title`, `placeholder`, `tooltip` — for company names,
project codenames, contest or demo references, internal hostnames, ticket numbers.
Strip the text rather than deleting the component, so layout and parity are
unchanged.
## Testing a minimum-version claim
To claim a floor lower than the gateway you built on, load the project on that
version — **against a copy of the files**. An older gateway rewrites every
`resource.json` with `lastModification` metadata and leaves root-owned
`.resources/` artifacts behind that your user account cannot delete (clean up
with a throwaway container rather than sudo).
A clean load proves resources parse. It does **not** prove screens render — a
component property introduced in a later version fails at render time, not load
time. Open the client.
## Reporting
Report what the gates actually said, with numbers. A conversion reported
accurately with two known gaps is worth far more than one claimed clean. If a
gate is a false positive, say which and why — the tooling is not infallible and
both bundled scripts have known blind spots documented in their docstrings.
## Provenance
Procedure and both scripts derived from a full conversion of a 20-view / 32-style
/ 3-module Perspective project (2026-09-15), verified on Ignition 8.1.20 and
8.3.7. The gate thresholds are the ones that actually caught defects during it.

View File

@@ -0,0 +1,264 @@
#!/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())

View File

@@ -0,0 +1,217 @@
#!/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())