openlatch-client 0.6.21

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
#!/usr/bin/env python3
"""Fail the build on POSIX-only assumptions living in cross-platform code.

The Windows cross-compile gate (`cargo xwin clippy --target
x86_64-pc-windows-msvc`) proves the tree *compiles* for Windows. It cannot
prove it *behaves*: `"/tmp/x"`, `.join("a/b")` and a bare `HOME` read all
compile perfectly for MSVC and then resolve to nothing on a real Windows box.
Those are exactly the defects that used to reach users, because the only
Windows execution in this repo happened after publishing to npm.

This scanner is the behavioural half of that gate. It reads `src/**/*.rs` and
`build.rs`, drops everything that is legitimately POSIX-only, and fails on what
is left.

Dropped, in order of precedence:

  * `#[cfg(test)]` items — fixture paths never run on a user's machine.
  * items under a POSIX-only `cfg` (`unix`, `not(windows)`, `target_os =
    "linux"` / `"macos"`, `target_family = "unix"`) — code that Windows never
    compiles, let alone runs.
  * anything carrying an explicit `portability-ok:` waiver, per line
    (trailing comment) or per file (a `//! portability-ok:` inner doc comment
    in the first 40 lines). The waiver must state a reason: it is a documented
    exception, not a mute button.

Exit codes: 0 clean, 1 violations found, 2 bad invocation.
"""

from __future__ import annotations

import re
import sys
from dataclasses import dataclass
from pathlib import Path

# ── What counts as a violation ───────────────────────────────────────

# `platform_scoped` rules describe code that is *correct* inside a platform
# `cfg` and only suspicious outside one: `%APPDATA%` read under `cfg(windows)`
# is the right Windows idiom, the same read in portable code is not. Rules
# without the flag fire everywhere — a `/tmp` literal under `cfg(windows)` is a
# bug no matter who wrote it.
@dataclass(frozen=True)
class Rule:
    name: str
    pattern: re.Pattern[str]
    hint: str
    platform_scoped: bool = False


RULES: list[Rule] = [
    Rule(
        "abs-posix-path",
        re.compile(r'"(?:/(?:tmp|etc|usr|var|opt|home|Users|Library|proc|dev)\b|~/)'),
        "absolute POSIX path literal — resolve through core::config paths or std::env::temp_dir()",
    ),
    Rule(
        "embedded-separator",
        re.compile(r'\.join\(\s*"[^"]*/[^"]*"\s*\)'),
        "path separator inside a join() segment — chain .join() per segment instead",
    ),
    Rule(
        "home-env-read",
        re.compile(r'env::var(?:_os)?\(\s*"(?:HOME|USERPROFILE|APPDATA|XDG_[A-Z_]+)"'),
        "raw home/config env read — go through the single resolver (dirs::* behind core::config)",
        platform_scoped=True,
    ),
    Rule(
        "gui-subsystem",
        re.compile(r'windows_subsystem\s*=\s*"windows"'),
        "GUI subsystem on a CLI binary — cmd and PowerShell do not WAIT for one, "
        "so every exit code becomes unobservable to scripts, and it is never "
        "ATTACHED to the parent console, so no keystroke and no Ctrl-C can reach "
        "it and every interactive prompt hangs unanswerable. Suppress a console "
        "window per-process instead (release_unattended_console in "
        "src/app/openlatch/main.rs)",
    ),
]

# `cfg(...)` expressions that mean "Windows never sees this". No trailing `\b`
# after the closing quote — `"` to `)` is not a word boundary, and requiring one
# silently matched nothing for every `target_os = "..."` form.
POSIX_CFG = re.compile(
    r'(?:\bunix\b|target_family\s*=\s*"unix"|target_os\s*=\s*"(?:linux|macos|ios|android|freebsd)")'
)
NOT_WINDOWS_CFG = re.compile(r'not\s*\(\s*(?:windows|target_os\s*=\s*"windows")\s*\)')
WINDOWS_CFG = re.compile(r'(?:\bwindows\b|target_os\s*=\s*"windows")')

ATTR = re.compile(r"^\s*#!?\[")
CFG_ATTR = re.compile(r"^\s*#!?\[\s*cfg\s*\(")
WAIVER = re.compile(r"portability-ok:\s*\S")

# String/char literals are stripped before brace counting, so a `"{"` in a
# format string cannot desynchronise the block scanner.
LITERALS = re.compile(
    r'r#+"(?:.|\n)*?"#+'  # raw strings
    r'|"(?:\\.|[^"\\])*"'  # normal strings
    r"|'(?:\\.|[^'\\])'"  # chars
)
LINE_COMMENT = re.compile(r"//.*$")


@dataclass
class Violation:
    path: Path
    line: int
    rule: str
    text: str
    hint: str


def strip_noise(line: str) -> str:
    """Remove string literals then line comments — brace counting only."""
    return LINE_COMMENT.sub("", LITERALS.sub('""', line))


def cfg_is_posix_only(expr: str) -> bool:
    return bool(NOT_WINDOWS_CFG.search(expr) or POSIX_CFG.search(expr))


def skipped_lines(lines: list[str]) -> tuple[set[int], set[int]]:
    """Two 0-based index sets: always-skipped, and platform-cfg-skipped.

    The first covers `cfg(test)` items, POSIX-only items and multi-line raw
    strings (config templates, unit/plist/XML bodies — documentation, not code
    that resolves a path). The second adds Windows-gated items, which only
    `platform_scoped` rules ignore.
    """
    always: set[int] = set()
    platform: set[int] = set()
    code = without_raw_string_bodies(lines)
    for i, raw in enumerate(lines):
        if not CFG_ATTR.match(raw):
            continue
        # An attribute may wrap across lines; accumulate until parens balance.
        expr, end = raw, i
        while expr.count("(") > expr.count(")") and end + 1 < len(lines):
            end += 1
            expr += lines[end]
        is_test = re.search(r"\bcfg\s*\(\s*test\s*\)", expr) is not None
        posix_only = is_test or cfg_is_posix_only(expr)
        windows_only = not posix_only and WINDOWS_CFG.search(expr) is not None
        if not (posix_only or windows_only):
            continue
        target = always if posix_only else platform
        # Inner attribute (`#![cfg(...)]`) gates the whole remaining file.
        if raw.lstrip().startswith("#!["):
            target.update(range(i, len(lines)))
            continue
        target.update(range(i, end + 1))
        target.update(item_span(code, end + 1))
    always |= raw_string_lines(lines)
    return always, always | platform


def raw_string_lines(lines: list[str]) -> set[int]:
    """Interior lines of multi-line raw strings (`r#"` … `"#`).

    These hold generated artifacts and the commented config template shipped by
    `openlatch init` — prose about POSIX systems, not path resolution.
    """
    inside: set[int] = set()
    open_at: int | None = None
    for i, line in enumerate(lines):
        if open_at is None:
            # Ignore raw strings that open and close on their own line.
            if re.search(r'r#+"', LITERALS.sub('""', line)):
                open_at = i
        elif re.search(r'"#+', line):
            inside.update(range(open_at, i + 1))
            open_at = None
    return inside


def without_raw_string_bodies(lines: list[str]) -> list[str]:
    """`lines` with the contents of every multi-line raw string blanked out.

    A JSON fixture in `r#"{"a":{` … `}}"#` is not code, but per-line literal
    stripping sees `"{"` as a string and the `{` and `}}` around it as braces —
    so the block scanner closed a `#[cfg(test)]` module halfway through and
    reported the fixtures after it as shipping code. Only the code before the
    opening `r#"` and after the closing `"#` is kept.
    """
    out = list(lines)
    closing: re.Pattern[str] | None = None
    for i, line in enumerate(lines):
        if closing is None:
            if not re.search(r'r#+"', LITERALS.sub('""', line)):
                continue
            opener = list(re.finditer(r'r(#+)"', line))[-1]
            out[i] = line[: opener.start()]
            closing = re.compile('"' + opener.group(1))
        else:
            end = closing.search(line)
            if end is None:
                out[i] = ""
            else:
                out[i] = line[end.end() :]
                closing = None
    return out


def item_span(lines: list[str], start: int) -> range:
    """Lines covered by the item beginning at `start` (attributes included)."""
    j = start
    while j < len(lines) and (ATTR.match(lines[j]) or lines[j].lstrip().startswith("///")):
        j += 1
    depth = 0
    opened = False
    for k in range(j, len(lines)):
        clean = strip_noise(lines[k])
        depth += clean.count("{") - clean.count("}")
        if "{" in clean:
            opened = True
        if opened and depth <= 0:
            return range(start, k + 1)
        # A braceless item (`#[cfg(unix)] use ...;`) ends at its semicolon.
        if not opened and clean.rstrip().endswith(";"):
            return range(start, k + 1)
    return range(start, len(lines))


def scan(path: Path) -> list[Violation]:
    lines = path.read_text(encoding="utf-8").splitlines()
    if any(WAIVER.search(ln) for ln in lines[:40] if ln.lstrip().startswith("//!")):
        return []
    skip_all, skip_platform = skipped_lines(lines)
    out: list[Violation] = []
    for i, raw in enumerate(lines):
        if i in skip_all:
            continue
        stripped = raw.lstrip()
        if stripped.startswith("//") or WAIVER.search(raw):
            continue
        for rule in RULES:
            if rule.platform_scoped and i in skip_platform:
                continue
            if rule.pattern.search(raw):
                out.append(Violation(path, i + 1, rule.name, raw.strip(), rule.hint))
                break
    return out


def main(argv: list[str]) -> int:
    # Windows gives stdout the ANSI code page (cp1252) whenever it is a pipe —
    # CI, the pre-commit hook that runs this, `| head`. The diagnostics below
    # carry an em dash and an arrow, so without this the script raises
    # UnicodeEncodeError instead of printing the violations it found.
    for stream in (sys.stdout, sys.stderr):
        reconfigure = getattr(stream, "reconfigure", None)
        if reconfigure is not None:
            reconfigure(encoding="utf-8", errors="replace")

    root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
    if not root.is_dir():
        print(f"error: {root} is not a directory", file=sys.stderr)
        return 2

    # `src/generated/` is typify output from schemas/ — type definitions with no
    # path resolution in them, and nothing a `portability-ok:` waiver could
    # survive, since the next codegen run would drop it. build.rs stays in
    # scope: it runs on the host, so a POSIX assumption there breaks
    # `cargo install openlatch-client` on Windows.
    targets = sorted(
        p for p in (root / "src").rglob("*.rs") if "generated" not in p.relative_to(root).parts
    )
    if (root / "build.rs").exists():
        targets.append(root / "build.rs")

    violations = [v for path in targets for v in scan(path)]
    if not violations:
        print(f"portability: clean ({len(targets)} files scanned)")
        return 0

    print("::error::POSIX-only assumptions in cross-platform code:\n")
    for v in violations:
        rel = v.path.relative_to(root)
        print(f"  {rel}:{v.line}  [{v.rule}]")
        print(f"      {v.text}")
        print(f"      → {v.hint}\n")
    print(f"{len(violations)} violation(s).")
    print(
        "Fix them, move the code under a POSIX-only cfg, or append "
        "`// portability-ok: <reason>` when the path really is POSIX-only."
    )
    return 1


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