CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/hubitat-dev

Context for developing and debugging Hubitat Elevation apps, drivers, and hub environment — sandbox constraints, lifecycle idioms, capability contracts, plus grounded deploy/log-tail/lint mechanisms.

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

High

Do not use without reviewing

Overview
Quality
Evals
Security
Files

hub_lint.pyskills/_scripts/

#!/usr/bin/env python3
"""Lint a Hubitat app or driver Groovy source for sandbox violations and silent-failure traps.

Offline and deterministic: given the same source it always emits the same findings.
It flags CANDIDATES for the `lint-review` skill to judge — the checks are heuristic
because Groovy is not fully regex-parseable (see coding-policy rules/script-delegation.md
"The Regex Trap"). Comments and string literals are stripped before scanning to cut
false positives, but a finding is a signal, not a verdict.

Checks:
  disallowed-import   an `import` whose class is not on the sandbox allow-list
  forbidden-construct sleep()/println/new Thread/.getClass() — blocked by the sandbox
  unresolved-handler  a subscribe/runIn/runOnce/runInMillis/schedule handler string
                      with no matching method definition (compiles clean, fails silently)
  missing-command     a declared `capability "X"` whose required command has no method
  install-trap        updated() wires subscriptions/schedules but installed() neither
                      calls updated() nor wires them itself (first-run does nothing)

Reference data (paths resolved relative to this script by default):
  ../_reference/allowed-imports.txt   one FQ class per line, '#' comments ignored
  ../_reference/capabilities.json     capability -> required commands

Usage:
    hub_lint.py SOURCE.groovy [--allowed-imports PATH] [--capabilities PATH]
    hub_lint.py --source-stdin < SOURCE.groovy

Output: JSON {source, kind, finding_count, findings:[{check, severity, line, symbol, message}]}
Exit 0 always on a successful lint (findings are data, not failure); non-zero only on
bad invocation or unreadable input.
"""
import argparse
import json
import re
import sys
from pathlib import Path

_REF = Path(__file__).resolve().parent.parent / "_reference"

# Command names that are lifecycle/util methods, never a capability-required command gap.
_LIFECYCLE = {"installed", "updated", "uninstalled", "initialize", "configure",
              "refresh", "poll", "parse", "installed", "updated"}


def strip_comments_and_strings(src: str, blank_strings: bool = True) -> str:
    """Blank out // and /* */ comments (always) and, when blank_strings is True, the
    contents of string literals too. Character positions and newlines are preserved
    exactly, so a position found in one rendering indexes the same spot in another and
    line numbers stay correct. Two renderings are used: the fully-blanked one to locate
    code (so `sleep` in a string is not a construct), and the comments-only one to read
    the string literals that ARE data (handler names, capability names)."""
    out = []
    i, n = 0, len(src)
    while i < n:
        c = src[i]
        two = src[i:i + 2]
        if two == "//":
            j = src.find("\n", i)
            j = n if j == -1 else j
            out.append(" " * (j - i))
            i = j
        elif two == "/*":
            j = src.find("*/", i + 2)
            j = n if j == -1 else j + 2
            chunk = src[i:j]
            out.append("".join(ch if ch == "\n" else " " for ch in chunk))
            i = j
        elif c in "\"'":
            triple = src[i:i + 3] in ('"""', "'''")
            delim = src[i:i + 3] if triple else c
            k = i + len(delim)
            end = src.find(delim, k)
            end = n if end == -1 else end
            body = src[k:end]
            if blank_strings:
                body = "".join(ch if ch == "\n" else " " for ch in body)
            out.append(delim + body)
            out.append("" if end == n else delim)
            i = (end + len(delim)) if end != n else n
        else:
            out.append(c)
            i += 1
    return "".join(out)


def _lineno(src: str, pos: int) -> int:
    return src.count("\n", 0, pos) + 1


def _split_top_level(s: str) -> list:
    """Split an argument list on commas that are not nested inside (), [], or {}."""
    parts, depth, cur = [], 0, []
    for ch in s:
        if ch in "([{":
            depth += 1
        elif ch in ")]}":
            depth -= 1
        if ch == "," and depth == 0:
            parts.append("".join(cur).strip())
            cur = []
        else:
            cur.append(ch)
    parts.append("".join(cur).strip())
    return parts


def _defined_methods(clean: str) -> set:
    # def name(  |  void name(  |  Type name(  — capture the method name
    names = set()
    for m in re.finditer(r'\b(?:def|void|private|public|static|[A-Za-z_][\w.<>]*)\s+([a-zA-Z_]\w*)\s*\(', clean):
        names.add(m.group(1))
    return names


def _load_allowed_imports(path: Path) -> set:
    allowed = set()
    for line in path.read_text().splitlines():
        line = line.strip()
        if line and not line.startswith("#"):
            allowed.add(line)
    return allowed


def _load_required_commands(path: Path) -> dict:
    data = json.loads(path.read_text())
    caps = data.get("capabilities", data)
    return {name: [c["name"] for c in cap.get("commands", [])] for name, cap in caps.items()}


def lint_source(source: str, allowed_imports: set, required_commands: dict) -> list:
    """Pure core. Returns a list of finding dicts sorted by line."""
    clean = strip_comments_and_strings(source)                       # comments + strings blanked
    literals = strip_comments_and_strings(source, blank_strings=False)  # comments blanked, strings intact
    findings = []

    def add(check, severity, pos, symbol, message):
        findings.append({"check": check, "severity": severity,
                         "line": _lineno(source, pos), "symbol": symbol, "message": message})

    # disallowed-import. Platform namespaces are always available; the documented
    # allow-list is known incomplete (real drivers import hubitat.* classes absent
    # from it), so a non-listed import is a WARN candidate for the skill to judge,
    # never a hard error. ([ \t]* not \s* — \s would slide the anchor across newlines.)
    for m in re.finditer(r'^[ \t]*import\s+(static\s+)?([\w.$*]+)', clean, re.M):
        fq = m.group(2)
        if fq.startswith(("hubitat.", "com.hubitat.")):
            continue  # platform-internal classes, always importable
        if fq.endswith(".*"):
            add("disallowed-import", "warn", m.start(), fq,
                f"Wildcard import '{fq}' cannot be verified against the allow-list; import the exact classes.")
        elif fq not in allowed_imports:
            add("disallowed-import", "warn", m.start(), fq,
                f"'{fq}' is not on the documented sandbox import allow-list (which is incomplete) — verify it compiles on the hub.")

    # forbidden-construct
    forbidden = [
        (r'\bsleep\s*\(', "sleep()", "use pauseExecution(ms)"),
        (r'\bprintln\b', "println", "use log.debug"),
        (r'\bnew\s+Thread\b', "new Thread", "threads are not allowed in the sandbox"),
        (r'\bExecutors\s*\.', "Executors", "thread pools are not allowed in the sandbox"),
        (r'\.getClass\s*\(', ".getClass()", "use getObjectClassName(obj)"),
    ]
    for pat, sym, fix in forbidden:
        for m in re.finditer(pat, clean):
            add("forbidden-construct", "error", m.start(), sym,
                f"{sym} is blocked by the sandbox — {fix}.")

    methods = _defined_methods(clean)

    # unresolved-handler — the handler is the rightmost POSITIONAL string argument,
    # skipping any trailing options map (e.g. [overwrite: true, misfire: "ignore"]) or
    # named argument. A bare (unquoted) method reference in that position is left to the
    # compiler — only a quoted handler name is checkable here.
    handler_calls = r'\b(subscribe|runIn|runInMillis|runOnce|schedule)\s*\('
    for m in re.finditer(handler_calls, clean):
        start = m.end()
        depth, j = 1, start
        while j < len(clean) and depth:
            if clean[j] == "(":
                depth += 1
            elif clean[j] == ")":
                depth -= 1
            j += 1
        args = literals[start:j - 1]  # strings intact — the handler name is a literal
        handler = None
        for part in reversed(_split_top_level(args)):
            if not part or part.startswith("[") or ":" in part:
                continue  # options map or named arg
            hm = re.fullmatch(r'["\']([A-Za-z_]\w*)["\']', part)
            if hm:
                handler = hm.group(1)
            break  # first non-map positional from the right decides
        if handler and handler not in methods:
            add("unresolved-handler", "error", m.start(), handler,
                f"{m.group(1)}(...) references handler \"{handler}\" but no method by that name is defined.")

    # missing-command (capability name is a string literal — read from `literals`)
    for m in re.finditer(r'\bcapability\s+["\']([A-Za-z0-9]+)["\']', literals):
        cap = m.group(1)
        for cmd in required_commands.get(cap, []):
            if cmd and cmd not in methods and cmd not in _LIFECYCLE:
                add("missing-command", "error", m.start(), f"{cap}.{cmd}",
                    f"capability \"{cap}\" requires command {cmd}() but no such method is defined.")

    # install-trap (apps)
    upd = re.search(r'\bdef\s+updated\s*\(', clean)
    inst = re.search(r'\bdef\s+installed\s*\(', clean)
    if upd and inst:
        def _body(match):
            start = clean.find("{", match.end())
            if start == -1:
                return ""
            depth, j = 1, start + 1
            while j < len(clean) and depth:
                if clean[j] == "{":
                    depth += 1
                elif clean[j] == "}":
                    depth -= 1
                j += 1
            return clean[start:j]
        wires = re.compile(r'\b(subscribe|runIn|runInMillis|runOnce|schedule|initialize)\s*\(')
        upd_body, inst_body = _body(upd), _body(inst)
        if wires.search(upd_body) and "updated" not in inst_body and not wires.search(inst_body):
            add("install-trap", "warn", inst.start(), "installed",
                "installed() neither calls updated() nor wires subscriptions/schedules — on first install the app does nothing until the second Done.")

    findings.sort(key=lambda f: (f["line"], f["check"]))
    return findings


def _classify(source: str) -> str:
    if re.search(r'\bmetadata\s*\{', source):
        return "driver"
    if re.search(r'\bpreferences\s*\{', source) or re.search(r'\bdefinition\s*\(', source):
        return "app"
    return "unknown"


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description="Lint Hubitat Groovy for sandbox violations.")
    p.add_argument("source", nargs="?", help="path to .groovy source (omit with --source-stdin)")
    p.add_argument("--source-stdin", action="store_true", help="read source from stdin")
    p.add_argument("--allowed-imports", default=str(_REF / "allowed-imports.txt"))
    p.add_argument("--capabilities", default=str(_REF / "capabilities.json"))
    args = p.parse_args(argv)

    if args.source_stdin:
        source = sys.stdin.read()
        name = "<stdin>"
    elif args.source:
        try:
            source = Path(args.source).read_text()
        except OSError as e:
            print(f"cannot read {args.source}: {e}", file=sys.stderr)
            return 2
        name = args.source
    else:
        print("provide a source path or --source-stdin", file=sys.stderr)
        return 2

    try:
        allowed = _load_allowed_imports(Path(args.allowed_imports))
        required = _load_required_commands(Path(args.capabilities))
    except (OSError, json.JSONDecodeError) as e:
        print(f"cannot read reference data: {e}", file=sys.stderr)
        return 2

    findings = lint_source(source, allowed, required)
    result = {"source": name, "kind": _classify(source),
              "finding_count": len(findings), "findings": findings}
    json.dump(result, sys.stdout, indent=2)
    sys.stdout.write("\n")
    return 0


if __name__ == "__main__":
    sys.exit(main())

README.md

tile.json