"""One-command dispatcher for every registered security lint.
The registry lives in ../lints.toml — one [[lint]] block per lint, with
globs, a command, and a required flag. Adding a lint is a TOML edit, not
a code change. Each underlying script stays runnable standalone.
Files are matched against each lint's globs and a lint only runs when at
least one file matches — a lint whose file type didn't change never runs.
A lint whose tool isn't installed is skipped with a warning, never failed
(same pattern as prose_lint.py).
python3 lint.py FILE [FILE ...]
python3 lint.py --git-changed # changed + untracked files vs HEAD
Exit 0 = every required lint passed (advisory findings don't count).
Exit 1 = a required lint failed. Exit 2 = usage/file error.
from pathlib import Path, PurePosixPath
SKILL_DIR = Path(__file__).resolve().parent.parent
LINTS_TOML = SKILL_DIR / "lints.toml"
UV_TOOL_BIN = Path(os.path.expanduser("~/.local/bin"))
def find_tool(name: str) -> str | None:
"""Resolve a CLI tool via PATH, falling back to ~/.local/bin (where
`uv tool install` places binaries) — same pattern as prose_lint.py."""
found = shutil.which(name)
candidate = UV_TOOL_BIN / name
def git_changed_files() -> list[str]:
"""Changed + untracked files vs HEAD in the CWD's repo."""
def git(*args: str) -> list[str]:
result = subprocess.run(["git", *args], capture_output=True, text=True)
if result.returncode != 0:
print(f"ERROR: git {' '.join(args)} failed: {result.stderr.strip()}",
return [line for line in result.stdout.splitlines() if line.strip()]
top = git("rev-parse", "--show-toplevel")[0]
names = git("diff", "--name-only", "HEAD")
names += git("ls-files", "--others", "--exclude-standard")
for name in dict.fromkeys(names): # de-dupe, keep order
if path.is_file(): # deleted files can't be linted
_GLOBSTAR_CACHE: dict[str, re.Pattern] = {}
def _glob_to_regex(pattern: str) -> re.Pattern:
"""Translate a globstar pattern to a compiled regex, matching real
** semantics (zero or more path segments) rather than fnmatch's flat
'*' (which spans '/' unpredictably and breaks on two '**' in one
pattern, or on 'dir/**/*.ext' failing to match a file directly in
'dir/' with nothing nested under it — both were real bugs found in
hand-written lints.toml globs during the 2026-07-09 skill audit)."""
cached = _GLOBSTAR_CACHE.get(pattern)
if pattern[i:i + 3] == "**/":
elif pattern[i:i + 2] == "**":
compiled = re.compile("^" + "".join(out) + "$")
_GLOBSTAR_CACHE[pattern] = compiled
def matches(path: str, globs: list[str]) -> bool:
posix = PurePosixPath(path).as_posix()
if _glob_to_regex(pattern).match(posix):
# A pattern with no path separator at all is meant to match by
# bare filename regardless of directory depth (e.g. "*.md").
if "/" not in pattern and _glob_to_regex(pattern).match(PurePosixPath(posix).name):
def run_lint(lint: dict, files: list[str]) -> str:
"""Run one lint over its matched files. Returns pass | fail | skipped."""
command = lint["command"].replace("{skill}", str(SKILL_DIR))
command = command.replace("{files}", " ".join(shlex.quote(f) for f in files))
tokens = shlex.split(command)
resolved = find_tool(tokens[0])
print(f"WARNING ({lint['name']}): {tokens[0]} not installed — skipping.")
note = f" — {lint['note']}" if lint.get("note") else ""
print(f"\n━━ {lint['name']} ({len(files)} file(s)){note}", flush=True)
result = subprocess.run(tokens)
return "pass" if result.returncode == 0 else "fail"
ap = argparse.ArgumentParser(
description="Run every registered lint (lints.toml) over the given files.")
ap.add_argument("files", nargs="*", help="files to lint")
ap.add_argument("--git-changed", action="store_true",
help="lint the files changed or untracked vs HEAD in the CWD's repo")
files = git_changed_files() + args.files
print("No files to lint." if args.git_changed
else "Usage: lint.py FILE [FILE ...] or --git-changed", file=sys.stderr)
sys.exit(0 if args.git_changed else 2)
missing = [f for f in files if not Path(f).is_file()]
print(f"ERROR: file not found: {f}", file=sys.stderr)
lints = tomllib.loads(LINTS_TOML.read_text(encoding="utf-8")).get("lint", [])
matched = [f for f in files if matches(f, lint.get("globs", []))]
rows.append((lint["name"], 0, "skipped"))
outcome = run_lint(lint, matched)
if outcome == "fail" and lint.get("required", False):
if outcome == "fail" and not lint.get("required", False):
outcome = "fail (advisory)"
rows.append((lint["name"], len(matched), outcome))
print(f" {'lint':<14} {'files':>5} result")
for name, count, outcome in rows:
print(f" {name:<14} {count:>5} {outcome}")
sys.exit(1 if required_failed else 0)
if __name__ == "__main__":