368 lines
13 KiB
Python
368 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""checklint -- lint the CHECK line of a Wizard Troupe standup entry.
|
|
|
|
The troupe's lane contract is one line:
|
|
|
|
CHECK: <single bare shell command>
|
|
|
|
whose own exit code IS the verdict: 0 = correct, nonzero = wrong. Two defects
|
|
keep recurring in this loop, and both are statically detectable before any
|
|
command runs:
|
|
|
|
* the CHECK line carries a fallback arm (`cmd || true`, `cmd; exit 0`,
|
|
`cmd || echo ...`) or is a constant-success builtin (`true`, `exit 0`).
|
|
The exit code is then 0 whatever the command did: a gate that cannot fail.
|
|
* the CHECK line is not one bare command (`a && b`, `a | b`, a redirect, a
|
|
line continuation), so the harness cannot run it as written and the
|
|
multi-command line resolves to whichever arm last ran.
|
|
|
|
checklint reads an entry, extracts the top-level CHECK line, and reports one
|
|
finding per defect. `--run` adds the only non-static leg: it executes the
|
|
extracted command with a timeout and folds the command's REAL exit code into
|
|
the verdict.
|
|
|
|
Exit code IS the verdict:
|
|
0 the CHECK line is clean (and, with --run, the command exited 0)
|
|
1 a graded fault (one or more findings)
|
|
2 COULD NOT GRADE (entry unreadable, command could not be executed,
|
|
timed out) -- every 2 names its cause, and a 2 is never a fault
|
|
|
|
Usage:
|
|
checklint entry.md
|
|
checklint --run entry.md
|
|
checklint --scan . # every file under DIR carrying a CHECK line
|
|
checklint --json entry.md
|
|
|
|
`--scan` is a gate over a corpus: it exits 1 when any scanned file has
|
|
findings, 0 when every CHECK line in the tree is clean, 2 when the tree could
|
|
not be read. It skips .git, __pycache__ and files whose first 8 KiB contain a
|
|
NUL byte.
|
|
|
|
Deliberately NOT checked: whether the command's assertions are strong enough.
|
|
A CHECK line can be one bare command and still assert nothing (`test -f f`); a
|
|
static linter cannot see that. It is documented here because a reader who
|
|
assumes checklint grades the quality of the gate would be wrong.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
|
|
PROG = "checklint"
|
|
DEFAULT_TIMEOUT = 300.0
|
|
|
|
# Shell control operators and substitution that turn a CHECK line into more
|
|
# than one command, into a substitution, or into a redirected command. The
|
|
# contract is one bare command whose exit code is its own, so all of these are
|
|
# faults -- including `2>/dev/null`, which is still a redirect.
|
|
METACHARS = "`$<>&|;(){}"
|
|
|
|
# A fallback arm forces the exit code to 0 (or to the arm's own code) whatever
|
|
# the command did. Reported by name, because "it contains |" does not tell a
|
|
# reader why the gate cannot fail.
|
|
FALLBACK_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
("cmd || true", re.compile(r"\|\|\s*(?:true|:)\s*$")),
|
|
("cmd || exit 0", re.compile(r"\|\|\s*exit\s+0\s*$")),
|
|
("cmd || echo/printf ...", re.compile(r"\|\|\s*(?:echo|printf)\b")),
|
|
("cmd ; exit 0", re.compile(r";\s*exit\s+0\s*$")),
|
|
("cmd ; true", re.compile(r";\s*(?:true|:)\s*$")),
|
|
("cmd ; echo/printf ...", re.compile(r";\s*(?:echo|printf)\b")),
|
|
)
|
|
|
|
# Commands that always succeed. Exact match on the stripped command.
|
|
TAUTOLOGIES = frozenset(
|
|
{
|
|
"true",
|
|
":",
|
|
"exit 0",
|
|
"echo",
|
|
"echo ok",
|
|
"echo pass",
|
|
"echo PASS",
|
|
"echo 0",
|
|
"printf ok",
|
|
"test 1 -eq 1",
|
|
"test 1 = 1",
|
|
"test -f /dev/null",
|
|
"test -e /",
|
|
}
|
|
)
|
|
|
|
FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
|
|
CHECK_RE = re.compile(r"^\s*CHECK\s*:\s*(.*)$")
|
|
SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", ".ruff_cache"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
"""One defect. kind is 'error' (a graded fault) or 'cng' (could not grade)."""
|
|
|
|
code: str
|
|
line: int | None
|
|
message: str
|
|
kind: str = "error"
|
|
|
|
def as_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
def iter_prose_lines(text: str):
|
|
"""Yield (lineno, raw_line) for lines outside fenced code blocks.
|
|
|
|
A CHECK line inside a ``` fence is a replay transcript, not the contract.
|
|
"""
|
|
in_fence = False
|
|
for lineno, raw in enumerate(text.split("\n"), start=1):
|
|
if FENCE_RE.match(raw):
|
|
in_fence = not in_fence
|
|
continue
|
|
if not in_fence:
|
|
yield lineno, raw
|
|
|
|
|
|
def find_check_lines(text: str) -> list[tuple[int, str]]:
|
|
"""Top-level CHECK lines as (lineno, command-stripped) pairs."""
|
|
hits = []
|
|
for lineno, raw in iter_prose_lines(text):
|
|
m = CHECK_RE.match(raw)
|
|
if m:
|
|
hits.append((lineno, m.group(1).strip()))
|
|
return hits
|
|
|
|
|
|
def lint_command(cmd: str, lineno: int) -> list[Finding]:
|
|
"""Static findings for one extracted command."""
|
|
out: list[Finding] = []
|
|
if cmd.endswith("\\"):
|
|
out.append(
|
|
Finding("METACHAR", lineno, f"CHECK line ends a line continuation: {cmd!r}")
|
|
)
|
|
return out
|
|
for label, rx in FALLBACK_PATTERNS:
|
|
if rx.search(cmd):
|
|
out.append(
|
|
Finding(
|
|
"FALLBACK_ARM",
|
|
lineno,
|
|
f"CHECK line carries a fallback arm ({label}): {cmd!r} -- "
|
|
"the exit code is forced, so the gate cannot fail",
|
|
)
|
|
)
|
|
break
|
|
bad = "".join(sorted({c for c in cmd if c in METACHARS}))
|
|
if bad:
|
|
out.append(
|
|
Finding(
|
|
"METACHAR",
|
|
lineno,
|
|
f"CHECK line contains shell metacharacters {bad!r}: {cmd!r} -- "
|
|
"the contract is ONE bare command with no shell metacharacters",
|
|
)
|
|
)
|
|
if cmd in TAUTOLOGIES:
|
|
out.append(
|
|
Finding(
|
|
"TAUTOLOGY",
|
|
lineno,
|
|
f"CHECK line is the constant-success command {cmd!r} -- "
|
|
"the gate cannot fail",
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def lint_text(text: str) -> list[Finding]:
|
|
"""Every finding for one entry's text."""
|
|
hits = find_check_lines(text)
|
|
if not hits:
|
|
return [
|
|
Finding("MISSING", None, "no top-level 'CHECK: <command>' line in the entry")
|
|
]
|
|
if len(hits) > 1:
|
|
where = ", ".join(str(n) for n, _ in hits)
|
|
return [
|
|
Finding(
|
|
"MULTIPLE",
|
|
hits[1][0],
|
|
f"{len(hits)} top-level CHECK lines (lines {where}); the contract is exactly one",
|
|
)
|
|
]
|
|
lineno, cmd = hits[0]
|
|
if not cmd:
|
|
return [Finding("EMPTY", lineno, "CHECK line carries no command")]
|
|
return lint_command(cmd, lineno)
|
|
|
|
|
|
def run_check_command(cmd: str, timeout: float) -> tuple[int | None, str]:
|
|
"""Execute the extracted command. Returns (exit_code, detail)."""
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return None, f"timed out after {timeout:g}s"
|
|
except OSError as exc:
|
|
return None, f"could not execute: {exc}"
|
|
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
detail = f"exit {proc.returncode}"
|
|
if tail:
|
|
detail += f" | last output line: {tail[-1][:160]}"
|
|
return proc.returncode, detail
|
|
|
|
|
|
def grade(
|
|
text: str, *, run: bool = False, timeout: float = DEFAULT_TIMEOUT
|
|
) -> tuple[list[Finding], int]:
|
|
"""Lint, and optionally execute. Returns (findings, verdict).
|
|
|
|
verdict: 0 clean, 1 fault, 2 could-not-grade.
|
|
"""
|
|
findings = lint_text(text)
|
|
blocking = {"MISSING", "EMPTY", "MULTIPLE", "FALLBACK_ARM", "METACHAR", "TAUTOLOGY"}
|
|
if run and not any(f.code in blocking for f in findings):
|
|
cmd = find_check_lines(text)[0][1]
|
|
code, detail = run_check_command(cmd, timeout)
|
|
if code is None:
|
|
if detail.startswith("timed out"):
|
|
findings.append(Finding("TIMEOUT", find_check_lines(text)[0][0], detail, "cng"))
|
|
else:
|
|
findings.append(Finding("UNREADABLE", find_check_lines(text)[0][0], detail, "cng"))
|
|
elif code != 0:
|
|
findings.append(
|
|
Finding(
|
|
"RUN_FAILURE",
|
|
find_check_lines(text)[0][0],
|
|
f"the CHECK command exited {code} ({detail})",
|
|
)
|
|
)
|
|
if any(f.kind == "cng" for f in findings):
|
|
return findings, 2
|
|
if findings:
|
|
return findings, 1
|
|
return findings, 0
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
"""Read a file as text; binary content is reported, never guessed at."""
|
|
raw = path.read_bytes()
|
|
if b"\0" in raw[:8192]:
|
|
raise ValueError(f"{path}: binary (NUL byte in first 8 KiB)")
|
|
return raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
def report(path: Path, findings: list[Finding], verdict: int, as_json: bool) -> None:
|
|
if as_json:
|
|
print(
|
|
json.dumps(
|
|
{"entry": str(path), "verdict": verdict, "findings": [f.as_dict() for f in findings]},
|
|
indent=2,
|
|
)
|
|
)
|
|
return
|
|
if not findings:
|
|
print(f"OK {path}")
|
|
return
|
|
print(f"{'FAULT' if verdict == 1 else 'CNG '} {path}")
|
|
for f in findings:
|
|
where = f"line {f.line}" if f.line else "entry"
|
|
print(f" [{f.code}] ({where}) {f.message}")
|
|
|
|
|
|
def scan(root: Path, *, run: bool, timeout: float, as_json: bool) -> int:
|
|
"""Gate over a corpus: every file under root carrying a top-level CHECK line."""
|
|
if not root.is_dir():
|
|
print(f"{PROG}: --scan: not a directory: {root}", file=sys.stderr)
|
|
return 2
|
|
checked = faulty = clean = skipped = 0
|
|
results: list[dict] = []
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for name in sorted(filenames):
|
|
p = Path(dirpath) / name
|
|
try:
|
|
text = read_text(p)
|
|
except (OSError, ValueError):
|
|
skipped += 1
|
|
continue
|
|
if not find_check_lines(text):
|
|
continue
|
|
checked += 1
|
|
findings, verdict = grade(text, run=run, timeout=timeout)
|
|
if verdict == 0:
|
|
clean += 1
|
|
else:
|
|
faulty += 1
|
|
results.append(
|
|
{"entry": str(p), "verdict": verdict, "findings": [f.as_dict() for f in findings]}
|
|
)
|
|
report(p, findings, verdict, as_json)
|
|
if as_json:
|
|
print(json.dumps({"root": str(root), "checked": checked, "clean": clean,
|
|
"faulty": faulty, "skipped": skipped, "results": results}, indent=2))
|
|
else:
|
|
print(
|
|
f"\nscan {root}: {checked} file(s) with a CHECK line, "
|
|
f"{clean} clean, {faulty} faulty, {skipped} unreadable/binary skipped"
|
|
)
|
|
if faulty:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser(
|
|
prog=PROG,
|
|
description="Lint the CHECK line of a standup entry; its exit code is the verdict.",
|
|
)
|
|
ap.add_argument("entries", nargs="*", help="entry files to lint")
|
|
ap.add_argument("--run", action="store_true",
|
|
help="execute the extracted command and fold its real exit code in")
|
|
ap.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, metavar="SECONDS",
|
|
help=f"timeout for --run (default {DEFAULT_TIMEOUT:g}s)")
|
|
ap.add_argument("--json", action="store_true", help="emit findings as JSON")
|
|
ap.add_argument("--scan", metavar="DIR", help="lint every file under DIR with a CHECK line")
|
|
args = ap.parse_args(argv)
|
|
|
|
if args.scan:
|
|
return scan(Path(args.scan), run=args.run, timeout=args.timeout, as_json=args.json)
|
|
if not args.entries:
|
|
ap.print_usage(sys.stderr)
|
|
print(f"{PROG}: no entry given (or use --scan DIR)", file=sys.stderr)
|
|
return 2
|
|
|
|
verdict = 0
|
|
for entry in args.entries:
|
|
path = Path(entry)
|
|
try:
|
|
text = read_text(path)
|
|
except OSError as exc:
|
|
print(f"CNG {path}\n [UNREADABLE] {exc}", file=sys.stderr)
|
|
verdict = 2
|
|
continue
|
|
except ValueError as exc:
|
|
print(f"CNG {path}\n [UNREADABLE] {exc}", file=sys.stderr)
|
|
verdict = 2
|
|
continue
|
|
findings, v = grade(text, run=args.run, timeout=args.timeout)
|
|
report(path, findings, v, args.json)
|
|
if v == 2:
|
|
verdict = 2
|
|
elif v == 1 and verdict != 2:
|
|
verdict = 1
|
|
return verdict
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|