forked from b.peck/BAT
Phase 0: wipe PrimeBAT template, SimHarness (sim+probe), lint + schema harvest tools
Gate G0 green: scan clean, lint 0 failures, clients 200, probe P0 facts captured, simulator journaling. Probe locked: uuid row grouping, compound AlarmState strings, java-exception bare-except gotcha, millis dates OK, ackUserName field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
297
tools/lint_project.py
Normal file
297
tools/lint_project.py
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""lint_project.py — deterministic contest/structure linter for the PrimeBAT project.
|
||||
|
||||
Checks (stdlib only, exit non-zero on any FAIL):
|
||||
structure every .json parses; resource.json integrity (declared files exist,
|
||||
no undeclared payloads, scope correct); no .resources authored
|
||||
refs embedded view paths + flex-repeater paths resolve to real views
|
||||
under PrimeControls/; style-class references resolve
|
||||
bindings binding types from the known set; property-binding paths well-formed;
|
||||
session.custom.* references resolve against session-props schema;
|
||||
view.params.* references resolve against the view's declared params;
|
||||
script transforms/events are tab-indented
|
||||
contest page-config: exactly one page "/" -> PrimeControls/Dashboard, empty
|
||||
sharedDocks; all view/style resources under PrimeControls/;
|
||||
NO tag bindings anywhere in PrimeBAT; string bans (hardcoded local
|
||||
paths/datasource/simulator/SQL/f-strings/journalName literal);
|
||||
queryJournal calls bounded by start+end
|
||||
--strict (Phase 4) additionally: no ignition/* resources besides
|
||||
script-python/PrimeControls/* and global-props; session custom keys
|
||||
only PrimeControls; dead view params reported as failures
|
||||
|
||||
Usage: python3 tools/lint_project.py [--strict] [--project PrimeBAT]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PERSPECTIVE = "com.inductiveautomation.perspective"
|
||||
|
||||
KNOWN_BINDING_TYPES = {"property", "expr", "expression", "tag", "query", "expr-struct", "http"}
|
||||
EMBED_TYPES = {"ia.display.view", "ia.display.flex-repeater"}
|
||||
|
||||
# (regex, message) — scanned over view.json raw text and PrimeControls *.py
|
||||
STRING_BANS = [
|
||||
(re.compile(r"\[default\]"), "hardcoded tag provider path '[default]'"),
|
||||
(re.compile(r"BuildathonSim"), "hardcoded simulator tag path 'BuildathonSim'"),
|
||||
(re.compile(r"Buildathon_DB"), "hardcoded local datasource 'Buildathon_DB'"),
|
||||
(re.compile(r"\balarmsim\b"), "reference to dev simulator 'alarmsim'"),
|
||||
(re.compile(r"journalName\s*="), "hardcoded journalName= (judges' journal name unknown)"),
|
||||
(re.compile(r"\bsystem\.db\."), "system.db.* SQL access (journal API only)"),
|
||||
(re.compile(r"\bSELECT\s+", re.IGNORECASE), "raw SQL SELECT"),
|
||||
(re.compile(r"""(^|[^A-Za-z0-9_])[fF]["']"""), "f-string (Jython 2.7 cannot parse)"),
|
||||
(re.compile(r"system\.util\.sendRequest\("), "sendRequest dev bridge leftover"),
|
||||
]
|
||||
|
||||
FAILS = []
|
||||
WARNS = []
|
||||
|
||||
|
||||
def fail(path, msg):
|
||||
FAILS.append("FAIL %s: %s" % (path, msg))
|
||||
|
||||
|
||||
def warn(path, msg):
|
||||
WARNS.append("warn %s: %s" % (path, msg))
|
||||
|
||||
|
||||
def rel(p):
|
||||
return os.path.relpath(p, ROOT)
|
||||
|
||||
|
||||
def load_json(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
fail(rel(path), "invalid JSON: %s" % e)
|
||||
return None
|
||||
|
||||
|
||||
def walk_components(node, visit, path="root"):
|
||||
"""Depth-first over a view component tree; visit(node, path)."""
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
visit(node, path)
|
||||
for i, child in enumerate(node.get("children") or []):
|
||||
name = "?"
|
||||
if isinstance(child, dict):
|
||||
name = (child.get("meta") or {}).get("name", "?")
|
||||
walk_components(child, visit, "%s/%s[%d]" % (path, name, i))
|
||||
|
||||
|
||||
def iter_prop_configs(view):
|
||||
"""Yield (owner_path, prop_key, config_dict) for view-level and component-level propConfig."""
|
||||
for k, v in (view.get("propConfig") or {}).items():
|
||||
yield ("view", k, v)
|
||||
collected = []
|
||||
|
||||
def visit(node, path):
|
||||
for k, v in (node.get("propConfig") or {}).items():
|
||||
collected.append((path, k, v))
|
||||
walk_components(view.get("root") or {}, visit)
|
||||
for item in collected:
|
||||
yield item
|
||||
|
||||
|
||||
def resolve_in_schema(schema, dotted):
|
||||
"""Walk a dict tree by dotted path; list-typed nodes accept any index; return True if resolvable."""
|
||||
cur = schema
|
||||
for part in dotted.split("."):
|
||||
part = part.split("[")[0]
|
||||
if isinstance(cur, list):
|
||||
return True # element access into arrays: shape unknown, accept
|
||||
if not isinstance(cur, dict) or part not in cur:
|
||||
return False
|
||||
cur = cur[part]
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--strict", action="store_true")
|
||||
ap.add_argument("--project", default="PrimeBAT")
|
||||
args = ap.parse_args()
|
||||
|
||||
proj = os.path.join(ROOT, "ignition", "gateway", "projects", args.project)
|
||||
if not os.path.isdir(proj):
|
||||
print("project dir not found: %s" % proj)
|
||||
sys.exit(2)
|
||||
|
||||
views_root = os.path.join(proj, PERSPECTIVE, "views")
|
||||
styles_root = os.path.join(proj, PERSPECTIVE, "style-classes")
|
||||
|
||||
# ---------- collect resource inventory ----------
|
||||
view_paths = set() # e.g. "PrimeControls/Dashboard"
|
||||
style_paths = set() # e.g. "PrimeControls/Card"
|
||||
for base, target, payload in ((views_root, view_paths, "view.json"),
|
||||
(styles_root, style_paths, "style.json")):
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for dirpath, _dirs, files in os.walk(base):
|
||||
if payload in files:
|
||||
target.add(os.path.relpath(dirpath, base).replace(os.sep, "/"))
|
||||
|
||||
# ---------- structure: parse every json + resource.json integrity ----------
|
||||
session_schema = {}
|
||||
for dirpath, dirs, files in os.walk(proj):
|
||||
if ".resources" in dirs:
|
||||
fail(rel(dirpath), "authored .resources directory (gateway-owned)")
|
||||
dirs.remove(".resources")
|
||||
for fn in files:
|
||||
if fn.endswith(".json"):
|
||||
load_json(os.path.join(dirpath, fn))
|
||||
if "resource.json" in files:
|
||||
res = load_json(os.path.join(dirpath, "resource.json"))
|
||||
if res is None:
|
||||
continue
|
||||
declared = res.get("files") or []
|
||||
payloads = [f for f in files if f != "resource.json"]
|
||||
for f in declared:
|
||||
if f not in payloads:
|
||||
fail(rel(dirpath), "resource.json declares missing file %r" % f)
|
||||
for f in payloads:
|
||||
if f not in declared:
|
||||
fail(rel(dirpath), "undeclared payload file %r" % f)
|
||||
rp = rel(dirpath)
|
||||
want_a = ("script-python" in rp) or ("global-props" in rp)
|
||||
want_scope = "A" if want_a else "G"
|
||||
if res.get("scope") != want_scope:
|
||||
fail(rp, "scope %r, expected %r" % (res.get("scope"), want_scope))
|
||||
|
||||
# ---------- session props schema ----------
|
||||
sp_path = os.path.join(proj, PERSPECTIVE, "session-props", "props.json")
|
||||
sp = load_json(sp_path) if os.path.exists(sp_path) else None
|
||||
if sp:
|
||||
session_schema = sp.get("custom") or {}
|
||||
if args.strict:
|
||||
for key in session_schema:
|
||||
if key != "PrimeControls":
|
||||
fail(rel(sp_path), "session custom key %r outside PrimeControls" % key)
|
||||
|
||||
# ---------- page-config contest rules ----------
|
||||
pc_path = os.path.join(proj, PERSPECTIVE, "page-config", "config.json")
|
||||
pc = load_json(pc_path) if os.path.exists(pc_path) else None
|
||||
if pc is not None:
|
||||
pages = pc.get("pages") or {}
|
||||
if list(pages.keys()) != ["/"]:
|
||||
fail(rel(pc_path), "pages must be exactly ['/'], got %s" % list(pages.keys()))
|
||||
elif (pages["/"] or {}).get("viewPath") != "PrimeControls/Dashboard":
|
||||
fail(rel(pc_path), "page '/' must map to PrimeControls/Dashboard")
|
||||
if pc.get("sharedDocks"):
|
||||
fail(rel(pc_path), "sharedDocks must be empty (docked views prohibited)")
|
||||
|
||||
# ---------- namespace rule: everything under PrimeControls/ ----------
|
||||
for p in sorted(view_paths):
|
||||
if not p.startswith("PrimeControls/"):
|
||||
fail("views/%s" % p, "view outside PrimeControls/ folder")
|
||||
for p in sorted(style_paths):
|
||||
if not p.startswith("PrimeControls/"):
|
||||
fail("style-classes/%s" % p, "style class outside PrimeControls/ folder")
|
||||
|
||||
# ---------- per-view checks ----------
|
||||
for vp in sorted(view_paths):
|
||||
vfile = os.path.join(views_root, vp.replace("/", os.sep), "view.json")
|
||||
view = load_json(vfile)
|
||||
if view is None:
|
||||
continue
|
||||
rv = rel(vfile)
|
||||
declared_params = set((view.get("params") or {}).keys())
|
||||
raw = open(vfile).read()
|
||||
|
||||
for rx, msg in STRING_BANS:
|
||||
if rx.search(raw):
|
||||
fail(rv, msg)
|
||||
|
||||
def visit(node, cpath):
|
||||
t = node.get("type")
|
||||
if t in EMBED_TYPES:
|
||||
path_prop = (node.get("props") or {}).get("path")
|
||||
bound = "props.path" in (node.get("propConfig") or {})
|
||||
if isinstance(path_prop, str) and path_prop:
|
||||
if path_prop not in view_paths:
|
||||
fail(rv, "%s embeds missing view %r" % (cpath, path_prop))
|
||||
elif not path_prop.startswith("PrimeControls/"):
|
||||
fail(rv, "%s embeds non-PrimeControls view %r" % (cpath, path_prop))
|
||||
elif not bound:
|
||||
warn(rv, "%s embed with no static path and no binding" % cpath)
|
||||
classes = ((node.get("props") or {}).get("style") or {}).get("classes")
|
||||
if isinstance(classes, str):
|
||||
for cls in classes.split():
|
||||
if cls not in style_paths:
|
||||
fail(rv, "%s references missing style class %r" % (cpath, cls))
|
||||
walk_components(view.get("root") or {}, visit)
|
||||
|
||||
for owner, key, cfg in iter_prop_configs(view):
|
||||
binding = (cfg or {}).get("binding")
|
||||
if not binding:
|
||||
continue
|
||||
btype = binding.get("type")
|
||||
if btype not in KNOWN_BINDING_TYPES:
|
||||
fail(rv, "%s %s: unknown binding type %r" % (owner, key, btype))
|
||||
if btype == "tag":
|
||||
fail(rv, "%s %s: tag binding (prohibited in PrimeBAT — journal API only)" % (owner, key))
|
||||
text_blobs = [json.dumps(binding.get("config") or {})]
|
||||
for tr in binding.get("transforms") or []:
|
||||
code = tr.get("code", "")
|
||||
text_blobs.append(code)
|
||||
if tr.get("type") == "script" and code and not code.startswith(("\t", "\n")):
|
||||
fail(rv, "%s %s: script transform not tab-indented" % (owner, key))
|
||||
blob = " ".join(text_blobs)
|
||||
for m in re.finditer(r"session\.custom\.([A-Za-z0-9_.\[\]]+)", blob):
|
||||
if not resolve_in_schema(session_schema, m.group(1)):
|
||||
fail(rv, "%s %s: unresolvable session.custom.%s" % (owner, key, m.group(1)))
|
||||
for m in re.finditer(r"view\.params\.([A-Za-z0-9_]+)", blob):
|
||||
if m.group(1) not in declared_params:
|
||||
fail(rv, "%s %s: undeclared view param %r" % (owner, key, m.group(1)))
|
||||
|
||||
if args.strict:
|
||||
for p in declared_params:
|
||||
if not re.search(r"(view\.params\.|params\.)%s" % re.escape(p), raw.replace('"%s"' % p, "", 1)):
|
||||
warn(rv, "param %r possibly unused" % p)
|
||||
|
||||
# ---------- scripts: string bans + bounded queries ----------
|
||||
sp_root = os.path.join(proj, "ignition", "script-python")
|
||||
if os.path.isdir(sp_root):
|
||||
for dirpath, _dirs, files in os.walk(sp_root):
|
||||
for fn in files:
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
fpath = os.path.join(dirpath, fn)
|
||||
src = open(fpath).read()
|
||||
for rx, msg in STRING_BANS:
|
||||
if rx.search(src):
|
||||
fail(rel(fpath), msg)
|
||||
for m in re.finditer(r"queryJournal\s*\(", src):
|
||||
fstart = src.rfind("\ndef ", 0, m.start())
|
||||
fend = src.find("\ndef ", m.start())
|
||||
body = src[max(fstart, 0):fend if fend != -1 else len(src)]
|
||||
if not (re.search(r"start", body) and re.search(r"end", body)):
|
||||
fail(rel(fpath), "queryJournal call not visibly bounded by start/end")
|
||||
|
||||
# ---------- strict: no stray ignition resources ----------
|
||||
if args.strict:
|
||||
ig_root = os.path.join(proj, "ignition")
|
||||
for entry in sorted(os.listdir(ig_root)) if os.path.isdir(ig_root) else []:
|
||||
if entry not in ("script-python", "global-props"):
|
||||
fail("ignition/%s" % entry, "unexpected project resource type for submission")
|
||||
if os.path.isdir(sp_root):
|
||||
for entry in sorted(os.listdir(sp_root)):
|
||||
if entry != "PrimeControls":
|
||||
fail("script-python/%s" % entry, "script package outside PrimeControls")
|
||||
|
||||
for w in WARNS:
|
||||
print(w)
|
||||
for f in FAILS:
|
||||
print(f)
|
||||
print("lint: %d view(s), %d style class(es), %d failure(s), %d warning(s)%s"
|
||||
% (len(view_paths), len(style_paths), len(FAILS), len(WARNS),
|
||||
" [strict]" if args.strict else ""))
|
||||
sys.exit(1 if FAILS else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
55
tools/schema_harvest.sh
Executable file
55
tools/schema_harvest.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# schema_harvest.sh — extract Perspective component prop schemas from the running
|
||||
# gateway's Perspective module into per-component JSON Schema files.
|
||||
#
|
||||
# Sources (verified on 8.3.7): perspective-common-<ver>.jar inside
|
||||
# Perspective-module.modl carries ia.components.json (70 core components),
|
||||
# perspective-amcharts.components.json (ia.chart.xy/pie/gauge/simple-gauge),
|
||||
# perspective-timeseries.components.json, plus schemas/*.json (binding,
|
||||
# transform, style, view-props, session-props schemas).
|
||||
#
|
||||
# Output layout:
|
||||
# <out>/components/<component-id>.schema.json (the component's props JSON Schema)
|
||||
# <out>/schemas/<name>.json (binding/transform/etc. schemas)
|
||||
#
|
||||
# Usage: tools/schema_harvest.sh [output_dir] (default: .schemas/)
|
||||
set -euo pipefail
|
||||
OUT="${1:-.schemas}"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
docker cp buildathon-ignition:"/usr/local/bin/ignition/user-lib/modules/Perspective-module.modl" "$TMP/perspective.modl"
|
||||
|
||||
python3 - "$TMP" "$OUT" <<'EOF'
|
||||
import json, os, sys, zipfile
|
||||
tmp, out = sys.argv[1], sys.argv[2]
|
||||
comp_dir = os.path.join(out, "components")
|
||||
misc_dir = os.path.join(out, "schemas")
|
||||
os.makedirs(comp_dir, exist_ok=True)
|
||||
os.makedirs(misc_dir, exist_ok=True)
|
||||
|
||||
modl = zipfile.ZipFile(os.path.join(tmp, "perspective.modl"))
|
||||
total = 0
|
||||
for jar in [n for n in modl.namelist() if n.endswith(".jar")]:
|
||||
jp = os.path.join(tmp, os.path.basename(jar))
|
||||
open(jp, "wb").write(modl.read(jar))
|
||||
try:
|
||||
z = zipfile.ZipFile(jp)
|
||||
except zipfile.BadZipFile:
|
||||
continue
|
||||
for entry in z.namelist():
|
||||
if entry.endswith(".components.json") or entry == "ia.components.json":
|
||||
data = json.loads(z.read(entry))
|
||||
for comp in data.get("components", []):
|
||||
cid = comp.get("id")
|
||||
if not cid:
|
||||
continue
|
||||
with open(os.path.join(comp_dir, cid + ".schema.json"), "w") as f:
|
||||
json.dump(comp.get("schema", {}), f, indent=1)
|
||||
total += 1
|
||||
elif entry.startswith("schemas/") and entry.endswith(".json"):
|
||||
with open(os.path.join(misc_dir, os.path.basename(entry)), "wb") as f:
|
||||
f.write(z.read(entry))
|
||||
print("extracted %d component schemas -> %s" % (total, comp_dir))
|
||||
EOF
|
||||
ls "$OUT/components" | wc -l
|
||||
Reference in New Issue
Block a user