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,149 @@
---
name: ignition-exchange-conformance
description: Check whether an Ignition project follows Inductive Automation's Exchange Resources Style Guide before publishing it to the Ignition Exchange — resource namespacing, Perspective/Vision/Python naming, session-property and client-tag prohibitions, database and logger conventions. Use when asked whether a project is Exchange-ready, when reviewing a project intended for the Exchange, or before packaging one for upload. Bundles a stdlib-only checker (exchange_lint.py) with 38 rules traceable to guide sections. Not for converting a project into that shape (use ignition-exchange-convert) or for packaging and upload (use ignition-exchange-publish).
---
# Ignition Exchange conformance
Tests an Ignition project against the **Exchange Resources Style Guide v1.1.0**.
Run `exchange_lint.py` (next to this file) rather than checking by eye — the rules
are numerous, mechanical, and easy to half-apply.
```bash
python3 skills/ignition-exchange-conformance/exchange_lint.py /path/to/Project
```
Stdlib only, Python 3.8+. Accepts a project folder, a folder containing several
projects (a gateway's `projects/` directory, an unpacked backup, a repo), or a
project export `.zip`. Infers the resource name from the namespace the project
already uses — no configuration.
Exit codes: `0` clean · `1` findings · `2` bad invocation.
## The one rule that is actually mandatory
The guide states exactly one `must`:
> Views, Styles, Scripts, etc. **must** all be contained within an appropriately
> named folder. This allows easy imports into existing projects, where those
> project resources won't overlap, interfere, or overwrite existing parts of a
> user's project.
Everything else — every naming convention — the guide explicitly calls optional:
> We encourage you to follow the naming conventions outlined here, however
> **these conventions are not required**.
So `exchange_lint.py` reports **FAIL** only for namespacing, the storage
prohibitions, and the project-name character set. Naming is **warn**. Don't
report warnings as blockers, and don't contort a project to silence them —
a consistent house style is defensible and the guide says so.
## Namespace roots — two spellings, both correct
Per the guide's Project Browser screenshots:
| Under `Exchange/<ResourceName>/` | Under `exchange/<resource-name>/` |
|---|---|
| Views, Windows, Templates | Style classes |
| Named queries, Reports | Project library (scripts) |
| SFCs, Transaction groups | WebDev sources |
| Alarm pipelines, Tags | Images |
Uppercase roots take Title Case or PascalCase names; lowercase roots take
kebab-case — **except the project library**, which is code-facing and uses
lowercase or camelCase (`exchange.resourceName.scriptName`), matching the
logger convention `exchange.resourceName.LoggerName`.
## The storage prohibitions
Both are FAILs because both break a clean import, and both are easy to miss:
- **Perspective session custom properties.** Importing merges them into the host
project's session props. Use view `params`/`custom` plus, for genuinely
non-reactive cross-session state, `system.util.getGlobals()['exchange']['resourceName']`.
- **Vision client tags.** Same reasoning.
## What the checker cannot tell you
A clean report is not "ready to publish". Four gaps, all of which have bitten:
1. **Content.** It reads names and structure, never label text. A visible string
like `"Acme Internal — do not distribute"` passes silently. Always sweep
user-visible strings separately:
```bash
python3 - <<'PY'
import json, glob, re
bad = re.compile(r"internal|confidential|do not|contest|demo only|todo|fixme", re.I)
for f in glob.glob("<project>/com.inductiveautomation.perspective/views/**/view.json", recursive=True):
v = json.load(open(f))
def walk(n):
yield n
for c in (n.get("children") or []): yield from walk(c)
for n in walk(v.get("root") or {}):
t = (n.get("props") or {}).get("text")
if isinstance(t, str) and bad.search(t): print(f, repr(t))
PY
```
2. **Referential integrity.** It does not check that embedded view paths, style
classes or script calls resolve. See [`ignition-exchange-convert`](../ignition-exchange-convert/SKILL.md).
3. **Runtime.** Nothing is executed or rendered. Load it on a gateway.
4. **The shipped package.** `E-UP-DOCS` checks the working directory, not the
export zip. See [`ignition-exchange-publish`](../ignition-exchange-publish/SKILL.md).
## Findings that need interpretation
**`E-STYLE-HIER` false positives.** It decides where a style class belongs by
finding which views reference it, reading only literal `props.style.classes`
strings. A class name built in a binding —
`'exchange/my-resource/priority/' + level.lower()` — is invisible, so a shared
class gets reported as single-use. Verify before acting; do not duplicate style
classes to silence it.
**`E-PAGE-ROOT` is not in the guide.** It is an added caution: a page mapped to
`/` takes over the root URL of any project the resource is imported into. Real
trade-off — a resource with no `/` page cannot be launched from the gateway's
Perspective project list, only from its full page URL
(`/data/perspective/client/<project>/<page>`). Flag the trade-off; let the user
decide.
**`E-PY-VAR` volume.** A project written in idiomatic snake_case produces
hundreds of warnings, since the guide wants camelCase for variables and
functions (deliberately diverging from PEP-8; `UPPER_SNAKE` constants are
exempt). Offer `--ignore E-PY-VAR,E-PY-FUNC` rather than a mass rename, unless
the user wants full conformance.
## Useful flags
| Flag | Effect |
|---|---|
| `--strict` | Warnings become failures |
| `--ignore E-PY-VAR,E-COMP-NAME` | Suppress rules |
| `-v` | Every occurrence (default collapses at 8 per rule) |
| `--json` | Machine-readable, for CI |
| `--list-rules` | All 38 rules with descriptions |
## Reporting results
The summary reads `N of 38 rules exercised`. Quote it. A project with no
database, tags or named queries legitimately leaves those rules unexercised, and
a narrow pass should never be presented as a broad one.
## Self-test
`test_exchange_lint.py` builds a conforming and a non-conforming project in a
temp dir, asserts the checker is silent on the first and fires the expected rule
id on the second, and asserts every declared rule is reachable in the source.
Run it after changing any rule.
```bash
python3 skills/ignition-exchange-conformance/test_exchange_lint.py
```
## Provenance
Rules derived from Exchange Resources Style Guide v1.1.0. Checker exercised
against four real Perspective projects and validated on Ignition **8.1.20** and
**8.3.7** gateways (2026-09-15). Where a rule is an inference rather than guide
text it says so in its description — currently only `E-PAGE-ROOT`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""Self-test for exchange_lint.py.
Builds two throwaway Ignition projects in a temp directory -- one that follows
the Exchange Resources Style Guide and one that breaks a known rule per check --
then asserts the checker stays silent on the first and fires the expected rule
id on the second. This is what keeps the rules honest: a rule that cannot fail
and a rule that always fires both show up here.
Run directly (python3 test_exchange_lint.py) or under pytest.
"""
import json
import os
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import exchange_lint as el # noqa: E402
RESOURCE = "SampleResource"
PERSP = el.PERSPECTIVE
# ------------------------------------------------------------------ fixtures
def write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(text)
def write_json(path, obj):
write(path, json.dumps(obj, indent=2))
RESOURCE_JSON = {"scope": "G", "version": 1, "restricted": False,
"overridable": True, "files": ["view.json"], "attributes": {}}
CLEAN_CODE = '''"""Sample library module."""
def formatCount(rawCount):
"""Return rawCount rendered for display."""
# pad small values so the column stays aligned
displayValue = "%d" % rawCount
return displayValue
'''
DIRTY_CODE = '''def format_count(raw_count):
#no space after hash
display_value = "%d" % raw_count
return display_value
'''
def build_project(root, name, clean=True):
"""Create a minimal Perspective project; clean=False breaks one rule per check."""
proj = os.path.join(root, name)
ns_upper = "Exchange/%s" % RESOURCE if clean else "MyCompany"
ns_lower = "exchange/sample-resource" if clean else "MyCompany"
lib_pkg = "exchange/sampleresource" if clean else "MyCompany"
write_json(os.path.join(proj, "project.json"), {
"title": "Sample Resource" if clean else "",
"description": "A sample Exchange resource." if clean else "",
"enabled": True, "inheritable": False, "parent": "",
})
view_dir = os.path.join(proj, PERSP, "views", *ns_upper.split("/"), "Dashboard")
write_json(os.path.join(view_dir, "resource.json"), RESOURCE_JSON)
write_json(os.path.join(view_dir, "view.json"), {
"params": {"startMs": 0} if clean else {"start_ms": 0},
"custom": {},
"root": {
"type": "ia.container.flex",
"meta": {"name": "root"},
"props": {"style": {"classes": "%s/dashboard/card" % ns_lower
if clean else "%s/Card" % ns_lower}},
"children": [{
"type": "ia.display.label",
"meta": {"name": "TitleLabel" if clean else "titleLabel"},
"custom": {"labelText": ""} if clean else {"label_text": ""},
"props": {},
"scripts": {"messageHandlers": [{
"messageType": ("exchange.sampleResource.refresh"
if clean else "refresh")}]},
}],
},
})
style_dir = os.path.join(proj, PERSP, "style-classes", *ns_lower.split("/"),
*(("dashboard", "card") if clean else ("Card",)))
write_json(os.path.join(style_dir, "resource.json"),
dict(RESOURCE_JSON, files=["style.json"]))
write_json(os.path.join(style_dir, "style.json"), {"style": {}})
write_json(os.path.join(proj, PERSP, "page-config", "config.json"),
{"pages": {"/sample-resource" if clean else "/": {
"viewPath": "%s/Dashboard" % ns_upper}}, "sharedDocks": {}})
write_json(os.path.join(proj, PERSP, "session-props", "props.json"),
{"custom": {} if clean else {"MyCompany": {"selectedTab": 0}},
"props": {}})
code_dir = os.path.join(proj, "ignition", "script-python",
*lib_pkg.split("/"), "fmt")
write_json(os.path.join(code_dir, "resource.json"),
dict(RESOURCE_JSON, scope="A", files=["code.py"]))
write(os.path.join(code_dir, "code.py"), CLEAN_CODE if clean else DIRTY_CODE)
if clean:
write(os.path.join(proj, "README.md"), "# Sample Resource\n\nInstall steps.\n")
else:
vis_ctags = os.path.join(proj, el.VISION, "client-tags")
write_json(os.path.join(vis_ctags, "resource.json"),
dict(RESOURCE_JSON, files=["data.bin"]))
write(os.path.join(vis_ctags, "data.bin"), "x")
return proj
SQL_CLEAN = """
CREATE TABLE ex_sr_contacts (
id INT NOT NULL AUTO_INCREMENT,
contact_name VARCHAR(64),
PRIMARY KEY (id)
);
CREATE TABLE ex_sr_calls (
id INT NOT NULL AUTO_INCREMENT,
ex_sr_contacts_id INT,
PRIMARY KEY (id),
FOREIGN KEY (ex_sr_contacts_id) REFERENCES ex_sr_contacts(id)
);
"""
SQL_DIRTY = """
CREATE TABLE Contacts (
contactId INT NOT NULL AUTO_INCREMENT,
ContactName VARCHAR(64),
PRIMARY KEY (contactId)
);
CREATE TABLE ex_sr_calls (
id INT NOT NULL AUTO_INCREMENT,
contact INT,
PRIMARY KEY (id),
FOREIGN KEY (contact) REFERENCES Contacts(contactId)
);
"""
TAGS_CLEAN = {"tags": [{"name": "Exchange", "tagType": "Folder", "tags": [
{"name": RESOURCE, "tagType": "Folder", "tags": [
{"name": "AlarmCount", "tagType": "AtomicTag", "valueSource": "memory"}]}]}]}
TAGS_DIRTY = {"tags": [{"name": "my_company", "tagType": "Folder", "tags": [
{"name": "alarm_count", "tagType": "AtomicTag", "valueSource": "memory"}]}]}
# --------------------------------------------------------------------- tests
class ExchangeLintTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="exlint_test_")
cls.clean = build_project(cls.tmp, "sample-resource", clean=True)
cls.dirty = build_project(cls.tmp, "SampleResource_v2", clean=False)
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def run_lint(self, proj, **kw):
kw.setdefault("tags", [])
kw.setdefault("sql", [])
kw.setdefault("resource", RESOURCE)
rep, _name, _res = el.lint_project(proj, **kw)
return rep
def rules(self, rep):
return {f[1] for f in rep.findings}
# -- the clean project must be silent ---------------------------------
def test_clean_project_has_no_findings(self):
rep = self.run_lint(self.clean)
self.assertEqual(rep.findings, [],
"conforming project produced findings:\n" +
"\n".join("%s %s %s: %s" % f for f in rep.findings))
def test_clean_project_exits_zero(self):
rc = el.main([self.clean, "--resource", RESOURCE, "--tags", "--sql"])
self.assertEqual(rc, 0)
# -- the dirty project must fire each rule ----------------------------
def test_dirty_project_fires_expected_rules(self):
got = self.rules(self.run_lint(self.dirty))
for rule in ("E-PROJ-NAME", "E-PROJ-TITLE", "E-PROJ-DESC",
"E-NS-ROOT", "E-COMP-NAME", "E-PROP-NAME",
"E-PAGE-ROOT", "E-STYLE-NAME", "E-MSG-NAME",
"E-STORE-SESS", "E-STORE-CTAG",
"E-PY-TABS", "E-PY-FUNC", "E-PY-VAR", "E-PY-DOC",
"E-PY-COMMENT", "E-UP-DOCS"):
self.assertIn(rule, got, "expected %s to fire" % rule)
def test_dirty_project_exits_nonzero(self):
rc = el.main([self.dirty, "--resource", RESOURCE, "--tags", "--sql"])
self.assertEqual(rc, 1)
# -- severity model ---------------------------------------------------
def test_namespace_and_storage_are_failures(self):
rep = self.run_lint(self.dirty)
fails = {f[1] for f in rep.fails}
for rule in ("E-NS-ROOT", "E-STORE-SESS", "E-STORE-CTAG", "E-PROJ-NAME"):
self.assertIn(rule, fails, "%s should be a FAIL, not a warning" % rule)
def test_naming_rules_are_warnings_by_default(self):
rep = self.run_lint(self.dirty)
warns = {f[1] for f in rep.warns}
for rule in ("E-COMP-NAME", "E-PY-FUNC", "E-PY-TABS"):
self.assertIn(rule, warns, "%s should warn by default" % rule)
def test_strict_promotes_warnings(self):
rep = self.run_lint(self.dirty, strict=True)
self.assertEqual(rep.warns, [], "--strict left warnings behind")
self.assertIn("E-COMP-NAME", {f[1] for f in rep.fails})
def test_ignore_suppresses_a_rule(self):
rep = self.run_lint(self.dirty, ignore={"E-COMP-NAME"})
self.assertNotIn("E-COMP-NAME", self.rules(rep))
# -- database and tag checks -----------------------------------------
def test_sql_clean_and_dirty(self):
clean_sql = os.path.join(self.tmp, "clean.sql")
dirty_sql = os.path.join(self.tmp, "dirty.sql")
write(clean_sql, SQL_CLEAN)
write(dirty_sql, SQL_DIRTY)
rep = el.Report()
el.check_database(rep, [clean_sql], RESOURCE)
self.assertEqual(rep.findings, [],
"conforming schema produced findings: %s" % rep.findings)
rep = el.Report()
el.check_database(rep, [dirty_sql], RESOURCE)
got = {f[1] for f in rep.findings}
for rule in ("E-DB-TABLE", "E-DB-COL", "E-DB-ID", "E-DB-FK"):
self.assertIn(rule, got, "expected %s to fire" % rule)
def test_tags_clean_and_dirty(self):
clean_tags = os.path.join(self.tmp, "clean-tags.json")
dirty_tags = os.path.join(self.tmp, "dirty-tags.json")
write_json(clean_tags, TAGS_CLEAN)
write_json(dirty_tags, TAGS_DIRTY)
rep = el.Report()
el.check_tags(rep, [clean_tags], RESOURCE)
self.assertEqual(rep.findings, [],
"conforming tag export produced findings: %s" % rep.findings)
rep = el.Report()
el.check_tags(rep, [dirty_tags], RESOURCE)
got = {f[1] for f in rep.findings}
self.assertIn("E-NS-ROOT", got)
self.assertIn("E-TAG-NAME", got)
# -- discovery --------------------------------------------------------
def test_discovers_both_projects_from_a_containing_folder(self):
found = el.discover_projects(self.tmp)
self.assertEqual(sorted(os.path.basename(p) for p in found),
["SampleResource_v2", "sample-resource"])
def test_project_folder_resolves_to_itself(self):
self.assertEqual(el.discover_projects(self.clean), [self.clean])
def test_zip_export_is_unpacked(self):
archive = shutil.make_archive(
os.path.join(self.tmp, "export"), "zip",
root_dir=os.path.dirname(self.clean),
base_dir=os.path.basename(self.clean))
found = el.discover_projects(archive)
self.assertEqual(len(found), 1)
self.assertTrue(os.path.exists(os.path.join(found[0], "project.json")))
def test_resource_name_inferred_from_namespace(self):
self.assertEqual(el.infer_resource(self.clean, "sample-resource"), RESOURCE)
# -- name predicates --------------------------------------------------
def test_name_predicates(self):
self.assertTrue(el.is_pascal("AreaCard"))
self.assertFalse(el.is_pascal("areaCard"))
self.assertTrue(el.is_camel("startMs"))
self.assertFalse(el.is_camel("start_ms"))
self.assertTrue(el.is_kebab("bad-actors"))
self.assertFalse(el.is_kebab("BadActors"))
self.assertTrue(el.is_title("Alarm Dashboard"))
self.assertTrue(el.is_title("Table of Contents"))
self.assertFalse(el.is_title("alarm dashboard"))
self.assertTrue(el.is_title_or_pascal("AlarmDashboard"))
def test_rule_table_ids_are_unique_and_referenced(self):
ids = [r[0] for r in el.RULES]
self.assertEqual(len(ids), len(set(ids)), "duplicate rule id in RULES")
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"exchange_lint.py")) as f:
source = f.read()
for rid in ids:
self.assertGreaterEqual(source.count('"%s"' % rid), 2,
"%s is declared but never raised" % rid)
if __name__ == "__main__":
unittest.main(verbosity=2)