Files
BAT/tools/lint_project.py
b.peck c82a2fa6df Phase 1 UI foundation: 32 style classes, 9 shared components, tab shell, placeholders
Dashboard shell: bundle expr binding + script transform, onStartup range init,
hand-rolled tab bar (position.display exprs), CONTRACT.md frozen pending G1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:16:16 -05:00

300 lines
13 KiB
Python

#!/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"),
# 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")
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()