#!/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] [--namespace PrimeControls] --namespace names the single folder every view / style class / script package must live under. It defaults to PrimeControls (PrimeBAT); the shippable, de-branded copy passes --project AlarmDashboard --namespace AlarmDashboard. """ 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"), # f-string prefix: f/F immediately followed by a quote, not itself preceded by a # quote (excludes string literals like 'F') or word char (excludes identifiers). (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") ap.add_argument("--namespace", default="PrimeControls") args = ap.parse_args() ns = args.namespace 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 != ns: fail(rel(sp_path), "session custom key %r outside %s" % (key, ns)) # ---------- 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") != "%s/Dashboard" % ns: fail(rel(pc_path), "page '/' must map to %s/Dashboard" % ns) 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(ns + "/"): fail("views/%s" % p, "view outside %s/ folder" % ns) for p in sorted(style_paths): if not p.startswith(ns + "/"): fail("style-classes/%s" % p, "style class outside %s/ folder" % ns) # ---------- 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) # Params are declared in "params" (nulls) and/or "propConfig" ("params.X"). # Designer saves strip null-valued "params" entries, so propConfig is the # durable declaration. declared_params = set((view.get("params") or {}).keys()) for pc_key in (view.get("propConfig") or {}): if pc_key.startswith("params."): declared_params.add(pc_key.split(".")[1]) 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(ns + "/"): fail(rv, "%s embeds view %r outside %s/" % (cpath, path_prop, ns)) 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 %s — journal API only)" % (owner, key, args.project)) 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 != ns: fail("script-python/%s" % entry, "script package outside %s" % ns) 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()