forked from b.peck/BAT
exchange work
This commit is contained in:
@@ -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`.
|
||||
1243
exchangeResources/claude-skills/ignition-exchange-conformance/exchange_lint.py
Executable file
1243
exchangeResources/claude-skills/ignition-exchange-conformance/exchange_lint.py
Executable file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
Reference in New Issue
Block a user