forked from b.peck/BAT
Data & simulation: - simulation_tags.json: re-prioritize 10 tags to Medium so all four priorities appear in live data (backfilled journal history to match) - SimHarness probe P2: ack-user/includeData ground truth + filtered-bundle verification against the live journal Data layer (PrimeControls.calc/alarms): - filters now drive every metric: queryStatus rows (Active Now/Unacked/ Standing) filtered via calc.apply_status_filters; event_count counts only filtered instances (per-instance n_events); dropdown options enumerate unfiltered data so choices never collapse - build_bundle refactored into _assemble(); when filters are active an unfiltered mirror is attached as bundle.overview - the Overview tab stays a plant-wide summary while all other consumers bind filtered sections - _fetch_journal now honors includeData (required for ackUserName; without it ack attribution is always None); enabled for journal rows + source detail so Top Ack Users and the Ack By column populate FilterBar/Dashboard: - root-cause fix: bidirectional bindings never wrote dropdown selections back to session props, so filters silently did nothing; Apply now pulls values directly from the sibling controls, Clear resets both - multi-select dropdowns cap at 280px and wrap chips; filter bar row is auto-height so wrapped chips push tabs down instead of overlapping - filter bar hidden on the Overview tab (not applicable there) UI scaling: - Header: Prime logo added; KpiCard compacted to fit the 71px KPI row (basis-95% overflow fix, chip hugs text, embeds clipped) - no scrollbars - Overview timeline: thinned x labels (minDistance 140), labels anchored below the axis, smaller legend markers - Bad Actors focus chip shows the full source string with the clear X - popup repeaters (Daily Activity, ScoreBars) no longer overflow (useDefaultViewWidth/Height off, taller strip); 5px table cell padding Tooling/tests: - lint_project.py: accept propConfig params.* as param declarations (Designer saves strip null params entries) - tests: 31 -> 34 (status-filter semantics, overview mirror, filtered event counts) - .gitignore: runtime churn (valueStore.idb, local-system-properties, migration logs, thumbnails, .schemas) New project: AlarmAnalysis - submission copy of PrimeBAT with client-visible Prime Controls branding removed (logo, subtitle, project title, shift report heading); PrimeControls organizational namespaces retained per spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
306 lines
13 KiB
Python
306 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)
|
|
# 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("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()
|