#!/usr/bin/env python3

"""Check a module's tree against the ios-module-structure registry.

The registry (references/rules.yml) states each rule over ROLES. The module's overlay
(modules/<Module>.yml) binds those roles to this module's own paths and spellings. This script
resolves the bindings, runs each rule's predicate, and reports findings by stable ID.

Three things it will not do, by design:

  - guess a binding. An unbound role or slot DISABLES the rules that read it, and the run reports
    them as disabled coverage rather than defaulting to one shape and manufacturing findings.
  - carry vocabulary. Every literal path, suffix and prefix comes from the overlay. Nothing about
    any one codebase lives in this file or in the registry beside it.
  - edit anything. It reads and reports.

Requires PyYAML - the one dependency, because the registry and the overlay are YAML.
"""

import argparse
import fnmatch
import json
import re
import sys
from pathlib import Path

try:
    import yaml
except ImportError:
    sys.stderr.write(
        "ios-module-structure needs PyYAML to read the registry and the overlay.\n"
        "  python3 -m pip install --user pyyaml\n"
    )
    sys.exit(2)


EXCEPTION_RE = re.compile(r"//\s*standard:exception\(([A-Z]+-\d+)\)")

# The engine holds no language of its own: the registry declares which one it speaks for, and a
# second language is a second rules file passed to --rules, never a fork of this script.
SOURCE_SUFFIX = ".swift"


def source_suffix(registry):
    scope = registry.get("scope") or {}
    if scope.get("sourceExtension"):
        return scope["sourceExtension"]
    for pattern in scope.get("paths") or []:
        if pattern.startswith("**/*."):
            return pattern[4:]
    return SOURCE_SUFFIX


# ---------------------------------------------------------------------------
# Binding
# ---------------------------------------------------------------------------


def in_scope(path, registry):
    """The registry declares a scope; honour it. A generated file is not a design decision."""
    scope = registry.get("scope") or {}
    text = str(path)
    return not any(fnmatch.fnmatch(text, g) for g in (scope.get("excludePaths") or []))


class Bindings:
    """Roles and dialect slots resolved from the overlay, plus what could not be resolved."""

    def __init__(self, overlay, registry):
        self.roles = dict(overlay.get("roles") or {})
        self.dialect = {k: v for k, v in (overlay.get("dialect") or {}).items()
                        if v and not k.endswith("_evidence")}
        self.vocabulary = dict(overlay.get("vocabulary") or {})
        self.exemptions = dict(overlay.get("exemptions") or {})
        self.limits = dict(overlay.get("limits") or {})
        self.known_roles = set((registry.get("roles") or {}).keys())
        self.known_slots = {s["id"] for s in (registry.get("module_overlay_slots") or {}).get("slots", [])}

    def role(self, name):
        return self.roles.get(name)

    def slot(self, name):
        return self.dialect.get(name)

    def exempt(self, rule_id, path=None, screen=None, detail=None):
        """Carve-outs are declared per rule in the overlay, never guessed here."""
        rule = self.exemptions.get(rule_id)
        if not rule:
            return False
        if screen and screen in (rule.get("screens") or []):
            return True
        if path:
            text = str(path)
            if any(fnmatch.fnmatch(text, g) for g in (rule.get("paths") or [])):
                return True
        if detail is not None:
            for expr in (rule.get("patterns") or []):
                if re.search(expr, str(detail)):
                    return True
        return False

    def unbound_roles(self):
        return sorted(self.known_roles - set(self.roles))

    def unbound_slots(self):
        return sorted(self.known_slots - set(self.dialect))


def iter_glob(base, pattern):
    """A role binds to one glob or to several; both read the same here."""
    if not pattern:
        return []
    patterns = pattern if isinstance(pattern, list) else [pattern]
    seen, out = set(), []
    for one in patterns:
        for hit in base.glob(one):
            if hit not in seen:
                seen.add(hit)
                out.append(hit)
    return sorted(out)


def expand(pattern, **subs):
    """Substitute {dir}/{stem}/{screen}/{target} into a role pattern."""
    if isinstance(pattern, list):
        return [expand(one, **subs) for one in pattern]
    out = pattern
    for key, value in subs.items():
        out = out.replace("{" + key + "}", str(value))
    return out


def match_role(root, pattern):
    """Every path under root matching a role's glob."""
    if not pattern:
        return []
    return sorted(p for p in root.glob(pattern) if p.is_file() or p.is_dir())


# ---------------------------------------------------------------------------
# Screens
# ---------------------------------------------------------------------------


def screen_labels(screens, root):
    """A screen's identity is its path, not its name.

    In a multi-target package two targets may each carry a screen with the same folder name.
    Keying a report by the name merges them into one entry that belongs to neither, so the label
    only shortens to the bare name when that name is unique.
    """
    from collections import Counter
    repeated = {n for n, c in Counter(s.name for s in screens).items() if c > 1}
    labels = {}
    for screen in screens:
        if screen.name not in repeated:
            labels[screen] = screen.name
            continue
        parts = screen.relative_to(root).parts
        # Enough of the path to tell the two apart: the unit that owns the screen, then the screen.
        labels[screen] = "/".join(parts[-3:]) if len(parts) >= 3 else "/".join(parts)
    return labels


def discover_screens(root, bindings, only=None):
    """Every directory the overlay's screen.root pattern selects."""
    pattern = bindings.role("screen.root")
    if not pattern:
        return []
    screens = [p for p in iter_glob(root, pattern) if p.is_dir()]
    if only:
        screens = [s for s in screens if s.name == only]
    return sorted(screens, key=lambda p: (p.parent.name, p.name))


def read(path):
    try:
        return path.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return ""


def line_of(text, index):
    return text.count("\n", 0, index) + 1


def excepted(text, line, rule_id):
    """A finding is waived when its line, or the line above it, carries the marker for that rule."""
    lines = text.splitlines()
    for candidate in (line - 1, line - 2):
        if 0 <= candidate < len(lines):
            found = EXCEPTION_RE.search(lines[candidate])
            if found and found.group(1) == rule_id:
                return True
    return False


# ---------------------------------------------------------------------------
# Predicates
# ---------------------------------------------------------------------------
# Each takes (screen_dir, root, rule, params, bindings) and returns a list of
# {path, line, detail}. The message comes from the rule, never from the predicate.


def p_dir_required_in_dir(screen, root, rule, params, b):
    wanted = b.vocabulary.get(params["vocabulary_key"]) or []
    if isinstance(wanted, str):
        wanted = [wanted]
    return [{"path": str(screen), "line": 0, "detail": name}
            for name in wanted if not (screen / name).is_dir()]


def p_file_required_in_dir(screen, root, rule, params, b):
    pattern = b.role(params["role"])
    if not pattern:
        return []

    trigger_role = params.get("trigger_role")
    if trigger_role:
        # The requirement only bites when the screen actually has the thing the file would hold.
        trigger_pattern = b.role(trigger_role)
        trigger_re = re.compile(params["trigger_pattern"], re.M)
        if not any(trigger_re.search(read(p)) for p in iter_glob(screen, trigger_pattern)):
            return []

    wanted = expand(pattern, screen=screen.name)
    if iter_glob(screen, wanted):
        return []
    return [{"path": str(screen), "line": 0, "detail": wanted}]


def p_prefix_collision(screen, root, rule, params, b):
    """A file in the shared folder whose name opens with a screen's own name."""
    return []  # module-level; run once outside the screen walk


def module_prefix_collision(root, rule, params, b, screens):
    """A shared file named after a screen - compared only against screens it actually shares with.

    In a multi-target package a file in one target's shared folder has nothing to do with a
    same-named screen in another target, so the comparison is scoped to the unit that owns both:
    the directory holding the screen's container.
    """
    shared = b.role(params["subject_role"])
    if not shared:
        return []
    out = []
    for path in iter_glob(root, shared):
        if path.suffix != SOURCE_SUFFIX:
            continue
        for screen in screens:
            unit = screen.parent.parent  # .../<unit>/<container>/<screen>
            if unit not in path.parents:
                continue
            if path.stem.startswith(screen.name) and path.stem != screen.name:
                out.append({"path": str(path), "line": 0, "detail": screen.name})
                break
    return out


def p_pair_required_in_dir(screen, root, rule, params, b):
    container = b.role(params["container_role"])
    left = b.role(params["left_role"])
    right = b.role(params["right_role"])
    if not (container and left and right):
        return []
    wanted = (left, right)
    if params.get("pairing_slot") and b.slot(params["pairing_slot"]) == params.get("right_only_value"):
        # Under response-only the response alone is the whole operation; only a lone request is odd.
        wanted = (right,)

    out = []
    for directory in iter_glob(screen, container):
        if not directory.is_dir():
            continue
        names = [f.name for f in directory.iterdir() if f.is_file()]
        for role_pattern in wanted:
            if not any(fnmatch.fnmatch(n, role_pattern) for n in names):
                out.append({"path": str(directory), "line": 0, "detail": role_pattern})
    return out


def p_sibling_required(screen, root, rule, params, b):
    """Every file playing the subject role has the sibling role's file beside it.

    The sibling's name is the subject's with one suffix swapped for another; both suffixes come
    from the overlay, so a module that spells them differently rebinds rather than forks.
    """
    subject = b.role(params["subject_role"])
    if not subject:
        return []
    strip = b.vocabulary.get(params.get("strip_suffix_from_vocabulary", ""))
    append = b.vocabulary.get(params.get("append_suffix_from_vocabulary", ""))
    if not append:
        return []

    out = []
    for path in iter_glob(screen, subject):
        stem = path.stem
        if strip and stem.endswith(strip):
            stem = stem[: -len(strip)]
        sibling = path.parent / (stem + append + path.suffix)
        if not sibling.exists():
            out.append({"path": str(path), "line": 0, "detail": sibling.name})
    return out


def pattern_for(params, b):
    """A rule whose pattern depends on the module's dialect reads it from the overlay, by slot value.

    Both halves of a slot are defensible, so the thing that counts as a finding flips with the
    binding: under one value the copy layer is the finding, under the other a raw key at a render
    site is. Neither regex belongs in the shared registry - the module names them.
    """
    by_slot = params.get("pattern_from_vocabulary_by_slot")
    if not by_slot:
        return params.get("pattern")
    value = b.slot(params["slot"])
    key = by_slot.get(value)
    return b.vocabulary.get(key) if key else None


def p_forbidden_pattern(screen, root, rule, params, b):
    subject = params.get("glob")
    if params.get("subject_role"):
        subject = b.role(params["subject_role"])
    elif params.get("glob_from_vocabulary"):
        dirs = b.vocabulary.get(params["glob_from_vocabulary"]) or []
        dirs = [dirs] if isinstance(dirs, str) else dirs
        subject = [f"{d}/**/*.swift" for d in dirs] or None
    expression = pattern_for(params, b)
    if not subject or not expression:
        return []
    regex = re.compile(expression, re.M)
    out = []
    for path in iter_glob(screen, subject):
        if path.suffix != SOURCE_SUFFIX:
            continue
        text = read(path)
        for found in regex.finditer(text):
            line = line_of(text, found.start())
            if excepted(text, line, rule["id"]):
                continue
            out.append({"path": str(path), "line": line, "detail": found.group(0).strip()[:70]})
    return out


def p_required_pattern(screen, root, rule, params, b):
    subject = b.role(params["subject_role"]) if params.get("subject_role") else params.get("glob")
    if not subject:
        return []
    trigger = re.compile(params["when_pattern"], re.M)
    wanted = re.compile(params["pattern"], re.M)
    out = []
    for path in iter_glob(screen, subject):
        if path.suffix != SOURCE_SUFFIX:
            continue
        text = read(path)
        if trigger.search(text) and not wanted.search(text):
            out.append({"path": str(path), "line": 0, "detail": params.get("label", "")})
    return out


def p_forbidden_member(screen, root, rule, params, b):
    subject = b.role(params["subject_role"])
    if not subject:
        return []
    regex = re.compile(params["pattern"], re.M)
    allowed = set(params.get("allow") or [])
    out = []
    for path in iter_glob(screen, subject):
        text = read(path)
        for found in regex.finditer(text):
            name = found.group(1)
            if name in allowed:
                continue
            line = line_of(text, found.start())
            if excepted(text, line, rule["id"]):
                continue
            out.append({"path": str(path), "line": line, "detail": name})
    return out


def p_naming_pattern(screen, root, rule, params, b):
    subject = b.role(params["subject_role"]) if params.get("subject_role") else params.get("glob")
    if not subject:
        return []
    declaration = re.compile(params["declaration"], re.M)
    accepted = params.get("accept")
    vocab_key = params.get("accept_from_vocabulary")
    if vocab_key:
        values = b.vocabulary.get(vocab_key)
        if not values:
            return []
        accepted = "|".join(re.escape(v) for v in values)
    accept_re = re.compile(accepted) if accepted else None

    rejected = params.get("reject")
    reject_key = params.get("reject_from_vocabulary")
    if reject_key:
        values = b.vocabulary.get(reject_key)
        if not values:
            return []
        rejected = "(" + "|".join(re.escape(v) for v in values) + ")$"
    reject_re = re.compile(rejected) if rejected else None
    match_stem = params.get("match_file_stem")

    out = []
    for path in iter_glob(screen, subject):
        if path.suffix != SOURCE_SUFFIX:
            continue
        text = read(path)
        names = [m.group(1) for m in declaration.finditer(text)]
        if match_stem:
            # The file is named after its one top-level declaration; extensions of it are fine.
            strangers = [n for n in names if n != path.stem]
            if strangers and path.stem not in names:
                out.append({"path": str(path), "line": 0, "detail": ", ".join(strangers[:3])})
            continue
        for found in declaration.finditer(text):
            name = found.group(1)
            bad = (accept_re and not accept_re.match(name)) or (reject_re and reject_re.search(name))
            if not bad:
                continue
            line = line_of(text, found.start())
            if excepted(text, line, rule["id"]):
                continue
            out.append({"path": str(path), "line": line, "detail": name})
    return out


def p_vocabulary(screen, root, rule, params, b):
    allowed = b.vocabulary.get(params["vocabulary_key"])
    if not allowed:
        return []
    allowed = set(allowed)
    exempt = [re.compile(x) for x in (params.get("exempt_patterns") or [])]
    declaration = re.compile(params["declaration"], re.M)
    out = []
    for path in screen.rglob("*" + SOURCE_SUFFIX):
        text = read(path)
        for found in declaration.finditer(text):
            name = found.group(1).strip()
            if name in allowed or any(rx.search(name) for rx in exempt):
                continue
            line = line_of(text, found.start())
            if excepted(text, line, rule["id"]):
                continue
            out.append({"path": str(path), "line": line, "detail": name})
    return out


def p_file_size(screen, root, rule, params, b):
    limits = b.limits or {}
    out = []
    for path in screen.rglob("*" + SOURCE_SUFFIX):
        kind = "test" if params.get("test_marker", "/Tests/") in str(path) else "source"
        ceiling = limits.get(kind + "_ceiling")
        target = limits.get(kind + "_target")
        if not ceiling:
            continue
        count = read(path).count("\n") + 1
        if count > ceiling:
            out.append({"path": str(path), "line": 0, "detail": f"{count} > {ceiling}"})
        elif target and count > target:
            out.append({"path": str(path), "line": 0, "detail": f"{count} > {target}", "note": True})
    return out


def p_mirror_required(screen, root, rule, params, b):
    return []  # module-level; run once outside the screen walk


def module_mirror(root, rule, params, b):
    """Every directory under the test root has a counterpart under the source root.

    Only directories: test FILE names carry a suffix the source does not, so matching them would
    measure a naming convention rather than the shape. A test folder with no source counterpart is
    either testing something that moved or grouping by a concept the source does not have.
    """
    test_root = b.role(params["test_role"])
    source_root = b.role(params["source_role"])
    if not (test_root and source_root):
        return []
    tests, sources = root / test_root, root / source_root
    if not tests.is_dir() or not sources.is_dir():
        return []

    out = []
    for directory in sorted(p for p in tests.rglob("*") if p.is_dir()):
        if not any(f.suffix == SOURCE_SUFFIX for f in directory.iterdir() if f.is_file()):
            continue
        if not (sources / directory.relative_to(tests)).is_dir():
            out.append({"path": str(directory), "line": 0, "detail": "no source counterpart"})
    return out


def p_none(screen, root, rule, params, b):
    return []


# What each predicate cannot run without. A rule missing one of these is reported as disabled,
# never silently passed: a rule that quietly checks nothing is worse than one that is switched off,
# because the run then claims coverage it does not have.
PREDICATE_REQUIRES = {
    "dir_required_in_dir": ["vocabulary_key"],
    "file_required_in_dir": ["role"],
    "pair_required_in_dir": ["container_role", "left_role", "right_role"],
    "sibling_required": ["subject_role", "append_suffix_from_vocabulary"],
    "forbidden_pattern": [],  # pattern may arrive via the slot-keyed vocabulary instead
    "required_pattern": ["when_pattern", "pattern"],
    "forbidden_member": ["subject_role", "pattern"],
    "naming_pattern": ["declaration"],
    "vocabulary": ["vocabulary_key", "declaration"],
    "prefix_collision": ["subject_role"],
    "file_size": [],
    "none": [],
}

UNIMPLEMENTED = set()


PREDICATES = {
    "prefix_collision": p_prefix_collision,
    "dir_required_in_dir": p_dir_required_in_dir,
    "file_required_in_dir": p_file_required_in_dir,
    "pair_required_in_dir": p_pair_required_in_dir,
    "sibling_required": p_sibling_required,
    "forbidden_pattern": p_forbidden_pattern,
    "required_pattern": p_required_pattern,
    "forbidden_member": p_forbidden_member,
    "naming_pattern": p_naming_pattern,
    "vocabulary": p_vocabulary,
    "file_size": p_file_size,
    "mirror_required": p_mirror_required,
    "none": p_none,
}


# ---------------------------------------------------------------------------
# Rule selection
# ---------------------------------------------------------------------------


def rule_status(rule, bindings):
    """(enabled, reason). A rule whose slot is unbound is disabled, and says which slot."""
    params = rule.get("params") or {}
    slot = params.get("slot")
    if slot:
        bound = bindings.slot(slot)
        if not bound:
            return False, f"slot {slot} unbound"
        wanted = params.get("slot_value")
        if wanted and bound != wanted:
            return False, f"slot {slot} is {bound}"
    for key in ("role", "subject_role", "sibling_role", "container_role", "left_role", "right_role"):
        name = params.get(key)
        if name and not bindings.role(name):
            return False, f"role {name} unbound"
    if params.get("vocabulary_key") and not bindings.vocabulary.get(params["vocabulary_key"]):
        return False, f"vocabulary {params['vocabulary_key']} unbound"
    if params.get("accept_from_vocabulary") and not bindings.vocabulary.get(params["accept_from_vocabulary"]):
        return False, f"vocabulary {params['accept_from_vocabulary']} unbound"
    if rule.get("predicate") == "file_size" and not bindings.limits:
        return False, "limits unset"
    if rule.get("enforcement") == "judgement":
        return False, "judgement - surfaced, not checked"

    predicate = rule.get("predicate")
    if predicate in UNIMPLEMENTED:
        return False, "predicate not implemented yet"
    missing = [k for k in PREDICATE_REQUIRES.get(predicate, []) if k not in params]
    if missing:
        return False, "params incomplete: " + ", ".join(missing)

    return True, ""


# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------


def run(root, registry, bindings, only_rule=None, only_screen=None):
    screens = discover_screens(root, bindings, only_screen)
    labels = screen_labels(screens, root)
    findings, notes, disabled = [], [], []

    for rule in registry.get("rules") or []:
        if only_rule and rule["id"] != only_rule:
            continue
        enabled, reason = rule_status(rule, bindings)
        if not enabled:
            disabled.append({"rule_id": rule["id"], "reason": reason, "title": rule["title"]})
            continue
        predicate = PREDICATES.get(rule.get("predicate"))
        if not predicate:
            disabled.append({"rule_id": rule["id"], "reason": "no predicate", "title": rule["title"]})
            continue
        params = rule.get("params") or {}

        if rule.get("predicate") == "mirror_required":
            for hit in module_mirror(root, rule, params, bindings):
                findings.append({
                    "rule_id": rule["id"], "severity": rule["severity"], "screen": "(tests)",
                    "path": str(Path(hit["path"]).relative_to(root)), "line": 0,
                    "message": rule["title"], "detail": hit["detail"],
                })
            continue

        if rule.get("predicate") == "prefix_collision":
            for hit in module_prefix_collision(root, rule, params, bindings, screens):
                findings.append({
                    "rule_id": rule["id"], "severity": rule["severity"], "screen": "(shared)",
                    "path": str(Path(hit["path"]).relative_to(root)), "line": 0,
                    "message": rule["title"], "detail": hit["detail"],
                })
            continue

        for screen in screens:
            if bindings.exempt(rule["id"], screen=screen.name):  # carve-outs name the screen
                continue
            for hit in predicate(screen, root, rule, params, bindings):
                if not in_scope(hit["path"], registry):
                    continue
                if bindings.exempt(rule["id"], path=hit["path"], detail=hit.get("detail")):
                    continue
                record = {
                    "rule_id": rule["id"],
                    "severity": rule["severity"],
                    "screen": labels[screen],
                    "path": str(Path(hit["path"]).relative_to(root)) if str(hit["path"]).startswith(str(root)) else hit["path"],
                    "line": hit.get("line", 0),
                    "message": rule["title"],
                    "detail": hit.get("detail", ""),
                }
                (notes if hit.get("note") else findings).append(record)

    return {
        "screens": [labels[s] for s in screens],
        "findings": findings,
        "notes": notes,
        "disabled": disabled,
        "unbound_roles": bindings.unbound_roles(),
        "unbound_slots": bindings.unbound_slots(),
    }


def render_text(result, root):
    lines = []
    by_screen = {}
    for f in result["findings"]:
        by_screen.setdefault(f["screen"], []).append(f)
    for note in result["notes"]:
        by_screen.setdefault(note["screen"], [])

    for screen in result["screens"]:
        hits = by_screen.get(screen, [])
        lines.append(f"{screen}: {len(hits)}")
        for f in sorted(hits, key=lambda x: (x["rule_id"], x["path"])):
            where = f"{f['path']}:{f['line']}" if f["line"] else f["path"]
            lines.append(f"   - [{f['rule_id']}] {f['message']} :: {where}"
                         + (f"  ({f['detail']})" if f["detail"] else ""))
        for n in sorted([x for x in result["notes"] if x["screen"] == screen], key=lambda x: x["path"]):
            lines.append(f"   ~ [{n['rule_id']}] {n['path']}  ({n['detail']})")

    for bucket in ("(tests)",):
        rows = [f for f in result["findings"] if f["screen"] == bucket]
        if rows:
            lines.append(f"{bucket}: {len(rows)}")
            for f in sorted(rows, key=lambda x: x["path"]):
                lines.append(f"   - [{f['rule_id']}] {f['message']} :: {f['path']}"
                             + (f"  ({f['detail']})" if f["detail"] else ""))

    shared = [f for f in result["findings"] if f["screen"] == "(shared)"]
    if shared:
        lines.append(f"(shared): {len(shared)}")
        for f in sorted(shared, key=lambda x: (x["rule_id"], x["path"])):
            lines.append(f"   - [{f['rule_id']}] {f['message']} :: {f['path']}"
                         + (f"  ({f['detail']})" if f["detail"] else ""))

    lines.append("")
    lines.append(f"TOTAL: {len(result['findings'])} finding(s), {len(result['notes'])} note(s)"
                 f" across {len(result['screens'])} screen(s)")
    if result["disabled"]:
        lines.append("")
        lines.append(f"DISABLED ({len(result['disabled'])}) - coverage this run did not have:")
        for d in result["disabled"]:
            lines.append(f"   {d['rule_id']}: {d['reason']}")
    if result["unbound_slots"]:
        lines.append("")
        lines.append("UNBOUND SLOTS: " + ", ".join(result["unbound_slots"]))
    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Check a module's tree against the structure registry.")
    parser.add_argument("--rules", required=True, help="path to references/rules.yml")
    parser.add_argument("--overlay", required=True, help="path to the module's overlay yml")
    parser.add_argument("--root", required=True, help="module root the overlay's globs are relative to")
    parser.add_argument("--only", help="run one rule id")
    parser.add_argument("--screen", help="run one screen")
    parser.add_argument("--format", choices=["text", "json"], default="text")
    args = parser.parse_args()

    root = Path(args.root).resolve()
    if not root.is_dir():
        sys.stderr.write(f"no such module root: {root}\n")
        return 2

    for label, given in (("rules", args.rules), ("overlay", args.overlay)):
        if not Path(given).is_file():
            sys.stderr.write(f"no such {label} file: {given}\n")
            return 2
    try:
        registry = yaml.safe_load(Path(args.rules).read_text(encoding="utf-8")) or {}
        overlay = yaml.safe_load(Path(args.overlay).read_text(encoding="utf-8")) or {}
    except yaml.YAMLError as error:
        sys.stderr.write(f"could not parse YAML: {error}\n")
        return 2

    if not overlay.get("roles", {}).get("screen.root"):
        sys.stderr.write(
            "the overlay binds no screen.root, so there is nothing to walk.\n"
            "Copy modules/_TEMPLATE.yml and bind it from your own tree.\n"
        )
        return 2

    global SOURCE_SUFFIX
    SOURCE_SUFFIX = source_suffix(registry)

    # A run that inspected nothing must not read as a run that found nothing. Both print "0", and
    # only one of them means the module is clean.
    if not any(root.rglob("*" + SOURCE_SUFFIX)):
        languages = ", ".join((registry.get("scope") or {}).get("languages") or ["?"])
        sys.stderr.write(
            f"not applicable: this registry speaks for {languages}, and {root} holds no "
            f"*{SOURCE_SUFFIX} file.\n"
        )
        return 2

    bindings = Bindings(overlay, registry)

    result = run(root, registry, bindings, args.only, args.screen)

    if not result["screens"]:
        sys.stderr.write(
            f"the overlay's screen.root matched no directory under {root}, so nothing was "
            f"checked. Rebind it before reading this as a clean run.\n"
        )
        return 2

    result["module"] = overlay.get("module", root.name)
    result["registry_version"] = registry.get("version")

    if args.format == "json":
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        print(render_text(result, root))

    return 1 if result["findings"] else 0


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