From 9d5641fe6585a61894f7b7427a68c794e5efe76d Mon Sep 17 00:00:00 2001 From: godosa Date: Wed, 7 Oct 2026 07:44:16 +0200 Subject: Denylist guard at commit time (pre-commit + commit-msg hooks); drop publish_snapshot Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JEAjUkQRrCYX5MZhWxdtj2 --- scripts/denylist_check.py | 158 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100755 scripts/denylist_check.py (limited to 'scripts/denylist_check.py') diff --git a/scripts/denylist_check.py b/scripts/denylist_check.py new file mode 100755 index 0000000..08cf985 --- /dev/null +++ b/scripts/denylist_check.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Denylist guard for a public repo: refuse private words in commits (git hooks in .githooks/). + +Usage: denylist_check.py staged | msg FILE | tree [REF] + staged added lines + paths of the index (pre-commit hook) + msg FILE commit message, '#' lines skipped (commit-msg hook) + tree every tracked file of REF (default HEAD): manual audit + +Lists (merged): publish-denylist.local in the worktree top and in the main tree (git-ignored), +plus every `git config denylist.file ` (e.g. a global list). Lines: `word` = case-insensitive +substring; `!token` = exemption (a hit inside an occurrence of token does not count); `#` comments. +Output names the place and `word #` only, never the word. Exit 0 clean, 1 hits. +No list at all → warning, exit 0. Bypass once: `git commit --no-verify`. +Enable in a clone: `git config core.hooksPath .githooks`. +""" +import re +import subprocess +import sys +from pathlib import Path + +NAME = "publish-denylist.local" + + +def git(*args): + r = subprocess.run(["git", "-c", "core.quotePath=false", *args], capture_output=True) + if r.returncode: + raise SystemExit(f"denylist-check: git {args[0]} failed: {r.stderr.decode(errors='replace').strip()}") + return r.stdout.decode("utf-8", errors="replace") + + +def parse(text): + """([(lineno, word lowercased)], [exempt token lowercased]).""" + words, exempt = [], [] + for n, line in enumerate(text.splitlines(), 1): + w = line.strip() + if not w or w.startswith("#"): + continue + if w.startswith("!"): + if w[1:]: + exempt.append(w[1:].lower()) + else: + words.append((n, w.lower())) + return words, exempt + + +def _exempt_spans(low, exempt): + spans = [] + for t in exempt: + spans += [(m.start(), m.end()) for m in re.finditer(re.escape(t), low)] + return spans + + +def hits(text, words, exempt): + """Line numbers (in the list) of words found in text outside exempted tokens.""" + low = text.lower() + spans = None + out = [] + for n, w in words: + for m in re.finditer(re.escape(w), low): + if spans is None: + spans = _exempt_spans(low, exempt) + if not any(a <= m.start() and m.end() <= b for a, b in spans): + out.append(n) + break + return out + + +def load_lists(): + """[(label, words, exempt)] of every existing list; label '' for the repo list.""" + top = Path(git("rev-parse", "--show-toplevel").strip()) + common = Path(git("rev-parse", "--path-format=absolute", "--git-common-dir").strip()) + cands = [("", top / NAME), ("", common.parent / NAME)] + r = subprocess.run(["git", "config", "--get-all", "denylist.file"], capture_output=True, text=True) + cands += [(Path(p).name + " ", Path(p).expanduser()) for p in r.stdout.splitlines() if p.strip()] + seen, lists = set(), [] + for label, p in cands: + if not p.is_file() or p.resolve() in seen: + continue + seen.add(p.resolve()) + lists.append((label, *parse(p.read_text(errors="replace")))) + return lists + + +def scan(items, lists): + """items = [(place, text)] → ['place: