From 77031ba9b30869ea06c0496094c4e8e94d8258d4 Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Tue, 11 Aug 2026 19:20:44 -0700 Subject: [PATCH 1/4] feat: add a ruff-config hook that enforces the shared standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pyproject.toml claims 'see offworldlabs/ops for the canonical copy'. ops has never held one. Same false claim as 86cb417ty's dead-code script. ruff's extend takes only a local path, so there is no rev:-style sharing for pyproject.toml. But pre-commit clones the hook repo locally before running it, so a checker shipped from ops can compare against a canonical file sitting beside it — one rev pins the standard and the checker together. Comparison is semantic: select, ignore and per-file-ignores compare as sets, so comment whitespace and ordering do not matter. target-version is ignored because it tracks requires-python and is legitimately per-repo. Verified: all six repos pass today. --- check-ruff-config.py | 105 +++++++++++++++++ ruff-shared.toml | 51 +++++++++ tests/test-check-ruff-config.sh | 192 ++++++++++++++++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100755 check-ruff-config.py create mode 100644 ruff-shared.toml create mode 100644 tests/test-check-ruff-config.sh diff --git a/check-ruff-config.py b/check-ruff-config.py new file mode 100755 index 0000000..2a05c04 --- /dev/null +++ b/check-ruff-config.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Fail if a repo's shared ruff keys have drifted from the canonical set. + +The canonical values live in ruff-shared.toml beside this script. pre-commit +clones this hook repo locally before running it, so that file is always a local +path at run time — which is what makes sharing possible at all: ruff's `extend` +accepts only local paths, and there is no rev:-style sharing for pyproject.toml. + +Comparison is SEMANTIC, not textual. `select`, `ignore` and the values inside +`per-file-ignores` are compared as sets, so comment whitespace and ordering are +irrelevant. This matters concretely: Tower-Finder's config is semantically +identical to the baseline but pads its comments differently, and a text diff +would report false drift. + +`target-version` is never compared. It tracks each package's requires-python and +is legitimately per-repo. + +Permissive by design: the canonical keys must be present and equal, but a repo +may add extra ignores or per-file-ignores entries. The accepted cost is that a +repo could ignore a rule from the shared select list without being flagged. + + check-ruff-config.py # check ./pyproject.toml + check-ruff-config.py backend # check backend/pyproject.toml + +Exit codes: 0 compliant, 1 drift, 2 usage error or missing pyproject.toml. +""" + +import sys +import tomllib +from pathlib import Path + +HERE = Path(__file__).resolve().parent +CANONICAL = HERE / "ruff-shared.toml" + + +def load(path: Path) -> dict: + with open(path, "rb") as handle: + return tomllib.load(handle) + + +def ruff_of(doc: dict) -> dict: + return doc.get("tool", {}).get("ruff", {}) + + +def main(argv: list[str]) -> int: + if len(argv) > 2: + print(f"check-ruff-config: expected at most one target directory, got {len(argv) - 1}", file=sys.stderr) + return 2 + + target = Path(argv[1]) if len(argv) == 2 else Path(".") + pyproject = target if target.is_file() else target / "pyproject.toml" + + if not pyproject.is_file(): + print(f"check-ruff-config: no pyproject.toml at {pyproject}", file=sys.stderr) + return 2 + if not CANONICAL.is_file(): + print(f"check-ruff-config: canonical config missing at {CANONICAL}", file=sys.stderr) + return 2 + + canon = ruff_of(load(CANONICAL)) + repo = ruff_of(load(pyproject)) + + problems: list[str] = [] + + if not repo: + problems.append("no [tool.ruff] section at all") + + if repo.get("line-length") != canon.get("line-length"): + problems.append( + f"line-length is {repo.get('line-length')!r}, canonical is {canon.get('line-length')!r}" + ) + + canon_lint = canon.get("lint", {}) + repo_lint = repo.get("lint", {}) + + for key in ("select", "ignore"): + missing = sorted(set(canon_lint.get(key, [])) - set(repo_lint.get(key, []))) + if missing: + problems.append(f"lint.{key} is missing {missing}") + + canon_pfi = canon_lint.get("per-file-ignores", {}) + repo_pfi = repo_lint.get("per-file-ignores", {}) + for pattern, rules in canon_pfi.items(): + if pattern not in repo_pfi: + problems.append(f"lint.per-file-ignores is missing {pattern!r}") + elif set(repo_pfi[pattern]) != set(rules): + problems.append( + f"lint.per-file-ignores[{pattern!r}] is {sorted(repo_pfi[pattern])}, " + f"canonical is {sorted(rules)}" + ) + + if problems: + print(f"check-ruff-config: {pyproject} has drifted from the shared ruff standard", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + print(file=sys.stderr) + print("Edit offworldlabs/ops:ruff-shared.toml if the standard should change,", file=sys.stderr) + print("then publish a new hooks-v*.* tag. Do not diverge locally.", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/ruff-shared.toml b/ruff-shared.toml new file mode 100644 index 0000000..cd0491d --- /dev/null +++ b/ruff-shared.toml @@ -0,0 +1,51 @@ +# Canonical shared ruff configuration for offworldlabs Python repos. +# +# Consumers copy these keys into their own pyproject.toml and the `ruff-config` +# pre-commit hook checks they have not drifted. Copying rather than referencing +# is forced: ruff's `extend` accepts only a local path, so there is no way for +# one repo to point at another's config. +# +# target-version is deliberately absent. It tracks each package's +# requires-python and is legitimately per-repo, so the checker ignores it. + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "S", # flake8-bandit (security) + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long — handled by formatter + "E402", # module-level import not at top — env setup before imports is intentional + "S101", # assert in tests is fine + "S104", # binding to 0.0.0.0 is intentional (Docker) + "S105", # hardcoded password false positives on dev defaults + "S106", # hardcoded password false positives + "S110", # try-except-pass is used intentionally + "S112", # try-except-continue is intentional in iteration + "S310", # URL open audit — URLs are constructed internally + "S311", # pseudo-random is fine for non-crypto uses + "S501", # requests without verify — internal calls + "S603", # subprocess calls are in controlled scripts + "S607", # partial executable path is fine for scripts + "B008", # function call in default arg — Depends() is FastAPI pattern + "B905", # zip strict — not needed everywhere + "SIM102", # nested if — readability preference + "SIM105", # contextlib.suppress — try/except is more explicit + "SIM108", # ternary operator — readability preference + "SIM117", # combine with statements — readability preference + "UP017", # datetime.UTC — cosmetic, timezone.utc is fine + "UP028", # yield from — explicit loop is clearer +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S", "B"] +"scripts/*" = ["S", "E"] diff --git a/tests/test-check-ruff-config.sh b/tests/test-check-ruff-config.sh new file mode 100644 index 0000000..176caf1 --- /dev/null +++ b/tests/test-check-ruff-config.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# +# Tests for check-ruff-config.py. Plain bash asserts, matching the style of +# tests/test-check-dead-code.sh. +# +# bash tests/test-check-ruff-config.sh + +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$HERE/../check-ruff-config.py" +CANON="$HERE/../ruff-shared.toml" + +passed=0 +failed=0 +ok() { printf ' ok %s\n' "$1"; passed=$((passed + 1)); } +bad() { printf ' FAIL %s\n %s\n' "$1" "$2"; failed=$((failed + 1)); } + +# A pyproject carrying exactly the canonical keys, plus a per-repo +# target-version, which is what a compliant consumer looks like. +mkgood() { + local dir; dir="$(mktemp -d)" + { + printf '[project]\nname = "x"\nversion = "0.1.0"\n\n' + # reuse the canonical file verbatim, then add target-version + cat "$CANON" + printf '\n' + } >"$dir/pyproject.toml" + python3 - "$dir/pyproject.toml" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); s = p.read_text() +s = s.replace("[tool.ruff]\nline-length = 120", + '[tool.ruff]\nline-length = 120\ntarget-version = "py310"') +p.write_text(s) +PY + printf '%s' "$dir" +} + +t_matching_config_passes() { + local dir out rc + dir="$(mkgood)" + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 0 ]; then ok "canonical config passes" + else bad "canonical config passes" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +t_missing_select_fails() { + local dir out rc + dir="$(mkgood)" + python3 - "$dir/pyproject.toml" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); s = p.read_text() +s = s.replace(' "SIM", # flake8-simplify\n', '') +p.write_text(s) +PY + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 1 ] && [[ "$out" == *"SIM"* ]]; then + ok "a missing select entry fails and names it" + else bad "missing select fails" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +t_missing_ignore_fails() { + local dir out rc + dir="$(mkgood)" + python3 - "$dir/pyproject.toml" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); s = p.read_text() +s = s.replace(' "B905", # zip strict — not needed everywhere\n', '') +p.write_text(s) +PY + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 1 ] && [[ "$out" == *"B905"* ]]; then + ok "a missing ignore entry fails and names it" + else bad "missing ignore fails" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +# The negative control. A check that fires on legitimate variation gets +# disabled within a month, so this matters as much as detecting real drift. +t_target_version_is_ignored() { + local dir out rc + dir="$(mkgood)" + python3 - "$dir/pyproject.toml" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); s = p.read_text() +p.write_text(s.replace('target-version = "py310"', 'target-version = "py312"')) +PY + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 0 ]; then ok "a differing target-version still passes" + else bad "target-version ignored" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +# Permissive by design: extra local rules are additions, not drift. +t_extra_entries_permitted() { + local dir out rc + dir="$(mkgood)" + python3 - "$dir/pyproject.toml" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); s = p.read_text() +s = s.replace(' "UP028", # yield from — explicit loop is clearer\n', + ' "UP028", # yield from — explicit loop is clearer\n "C901", # local addition\n') +s = s.replace('"scripts/*" = ["S", "E"]', '"scripts/*" = ["S", "E"]\n"simulation/*" = ["S"]') +p.write_text(s) +PY + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 0 ]; then ok "extra ignores and per-file-ignores are permitted" + else bad "extra entries permitted" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +t_missing_per_file_ignore_fails() { + local dir out rc + dir="$(mkgood)" + python3 - "$dir/pyproject.toml" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); s = p.read_text() +p.write_text(s.replace('"scripts/*" = ["S", "E"]\n', '')) +PY + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 1 ] && [[ "$out" == *"scripts/*"* ]]; then + ok "a missing per-file-ignores entry fails and names it" + else bad "missing per-file-ignores fails" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +# Whitespace and ordering must not matter — Tower-Finder is semantically +# identical to the baseline but formats its comments differently. +t_whitespace_and_order_ignored() { + local dir out rc + dir="$(mkgood)" + python3 - "$dir/pyproject.toml" <<'PY' +import sys, pathlib, re +p = pathlib.Path(sys.argv[1]); s = p.read_text() +s = re.sub(r'",\s+#', '", #', s) # collapse comment padding +s = s.replace(' "E", # pycodestyle errors\n', '') +s = s.replace('select = [\n', 'select = [\n "E", # moved to the end later\n') +p.write_text(s) +PY + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 0 ]; then ok "comment whitespace and ordering are ignored" + else bad "whitespace/order ignored" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +# Tower-Finder's shape: config lives in a subdirectory. +t_subdirectory_argument() { + local root dir out rc + root="$(mktemp -d)"; mkdir -p "$root/backend" + dir="$(mkgood)" + mv "$dir/pyproject.toml" "$root/backend/pyproject.toml"; rmdir "$dir" + out="$(cd "$root" && python3 "$SCRIPT" backend 2>&1)"; rc=$? + if [ "$rc" -eq 0 ]; then ok "positional target dir finds backend/pyproject.toml" + else bad "subdirectory argument" "rc=$rc out=$out"; fi + rm -rf "$root" +} + +t_missing_pyproject_exits_2() { + local dir out rc + dir="$(mktemp -d)" + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 2 ]; then ok "no pyproject.toml exits 2" + else bad "no pyproject exits 2" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +t_no_ruff_section_fails() { + local dir out rc + dir="$(mktemp -d)" + printf '[project]\nname = "x"\nversion = "0.1.0"\n' >"$dir/pyproject.toml" + out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? + if [ "$rc" -eq 1 ]; then ok "a pyproject with no [tool.ruff] fails" + else bad "no ruff section fails" "rc=$rc out=$out"; fi + rm -rf "$dir" +} + +echo "checker:" +t_matching_config_passes +t_missing_select_fails +t_missing_ignore_fails +t_target_version_is_ignored +t_extra_entries_permitted +t_missing_per_file_ignore_fails +t_whitespace_and_order_ignored +t_subdirectory_argument +t_missing_pyproject_exits_2 +t_no_ruff_section_fails + +printf '\n%d passed, %d failed\n' "$passed" "$failed" +[ "$failed" -eq 0 ] From 6ba5ee08f843c2308b02bfadf07e0ae0f10bb9e6 Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Tue, 11 Aug 2026 19:22:48 -0700 Subject: [PATCH 2/4] test: assert the missing-pyproject message, not just the exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python exits 2 when it cannot open the script, the same code the checker uses for a missing pyproject.toml, so the bare rc check passed even with check-ruff-config.py absent — it would have kept passing if the script were later renamed or deleted. --- tests/test-check-ruff-config.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test-check-ruff-config.sh b/tests/test-check-ruff-config.sh index 176caf1..a6483d0 100644 --- a/tests/test-check-ruff-config.sh +++ b/tests/test-check-ruff-config.sh @@ -161,8 +161,14 @@ t_missing_pyproject_exits_2() { local dir out rc dir="$(mktemp -d)" out="$(python3 "$SCRIPT" "$dir" 2>&1)"; rc=$? - if [ "$rc" -eq 2 ]; then ok "no pyproject.toml exits 2" - else bad "no pyproject exits 2" "rc=$rc out=$out"; fi + # Assert the message, not just the code: python itself exits 2 when it + # cannot open the script, so a bare rc check passes even when + # check-ruff-config.py does not exist. + if [ "$rc" -eq 2 ] && [[ "$out" == *"no pyproject.toml at"* ]]; then + ok "no pyproject.toml exits 2 and says so" + else + bad "no pyproject exits 2 and says so" "rc=$rc out=$out" + fi rm -rf "$dir" } From 368af7355de695377b3c7d85884e3dda5ef70253 Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Tue, 11 Aug 2026 19:29:14 -0700 Subject: [PATCH 3/4] feat: publish ops as a two-hook repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ruff-config hook to the manifest, renames the workflow now that it covers more than one hook, and gives both hooks a try-repo positive control. Switches to repo-level hooks-v. tags: pre-commit pins the whole repo at one rev, so per-hook tags were incoherent — pinning dead-code-v1.1 also silently decided which ruff-config you got. Also reworks the drift message printed by check-ruff-config.py: it now leads with "copy the entries above into this repo's pyproject.toml" (the common case) and lists editing ruff-shared.toml as the secondary path, since the prior wording read as though the standard itself should usually change. --- .github/workflows/dead-code-hook.yml | 66 -------------------------- .github/workflows/hooks.yml | 70 ++++++++++++++++++++++++++++ .pre-commit-hooks.yaml | 7 +++ README.md | 22 ++++++--- check-ruff-config.py | 5 +- 5 files changed, 95 insertions(+), 75 deletions(-) delete mode 100644 .github/workflows/dead-code-hook.yml create mode 100644 .github/workflows/hooks.yml diff --git a/.github/workflows/dead-code-hook.yml b/.github/workflows/dead-code-hook.yml deleted file mode 100644 index 8befa24..0000000 --- a/.github/workflows/dead-code-hook.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: dead-code hook - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - # Matches the pin every consumer uses. - - run: pip install vulture==2.14 - - - name: Test check-dead-code.sh - env: - REQUIRE_VULTURE: 1 - run: bash tests/test-check-dead-code.sh - - # tests/test-check-dead-code.sh invokes the script directly and never - # goes through pre-commit, so it can't catch a broken entry:/language: - # in .pre-commit-hooks.yaml — that would only surface in a consumer - # repo, after the tag is published. try-repo runs the hook exactly as - # a consumer's pre-commit would, without needing a published tag. - # - # `ops` itself has zero .py files, so pointing try-repo at it proves - # nothing — it can't tell "ran and found nothing" from "no-opped". Both - # steps below build a throwaway git repo (try-repo needs a real repo - # with a resolvable rev) with planted content and check the hook's - # actual verdict on it. - - run: pip install pre-commit==4.6.2 - - - name: Exercise the hook manifest through pre-commit (positive control) - run: | - set -euo pipefail - tmp="$(mktemp -d)" - cd "$tmp" - git init -q - git config user.email test@example.com - git config user.name test - printf 'def used():\n return 1\n\n\ndef planted_orphan():\n return 2\n\n\nprint(used())\n' > main.py - git add -A - if pre-commit try-repo "$GITHUB_WORKSPACE" dead-code --all-files 2>&1 | tee out.txt; then - echo "FAIL: hook did not catch planted dead code" >&2 - exit 1 - fi - grep -q planted_orphan out.txt || { echo "FAIL: hook ran but did not name the finding" >&2; exit 1; } - - - name: Exercise the hook manifest through pre-commit (negative control) - run: | - set -euo pipefail - tmp="$(mktemp -d)" - cd "$tmp" - git init -q - git config user.email test@example.com - git config user.name test - printf 'def used():\n return 1\n\n\nprint(used())\n' > main.py - git add -A - pre-commit try-repo "$GITHUB_WORKSPACE" dead-code --all-files diff --git a/.github/workflows/hooks.yml b/.github/workflows/hooks.yml new file mode 100644 index 0000000..a349adc --- /dev/null +++ b/.github/workflows/hooks.yml @@ -0,0 +1,70 @@ +name: hooks + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # Matches the pin every consumer uses. + - run: pip install vulture==2.14 pre-commit==4.6.2 + + - name: Test check-dead-code.sh + env: + REQUIRE_VULTURE: 1 + run: bash tests/test-check-dead-code.sh + + - name: Test check-ruff-config.py + run: bash tests/test-check-ruff-config.sh + + # A try-repo run that can only ever pass proves nothing. Plant a finding, + # require the hook to catch it, then require it to pass once removed. + - name: Exercise the dead-code hook through pre-commit + run: | + set -euo pipefail + tmp="$(mktemp -d)"; cd "$tmp"; git init -q + printf 'def used():\n return 1\n\n\ndef planted_orphan():\n return 2\n\n\nprint(used())\n' > main.py + git add -A + if pre-commit try-repo "$GITHUB_WORKSPACE" dead-code --all-files 2>&1 | tee out.txt; then + echo "FAIL: hook did not catch planted dead code" >&2; exit 1 + fi + grep -q planted_orphan out.txt || { echo "FAIL: hook ran but did not name the finding" >&2; exit 1; } + printf 'def used():\n return 1\n\n\nprint(used())\n' > main.py + git add -A + pre-commit try-repo "$GITHUB_WORKSPACE" dead-code --all-files + + - name: Exercise the ruff-config hook through pre-commit + run: | + set -euo pipefail + tmp="$(mktemp -d)"; cd "$tmp"; git init -q + # A deliberately drifted config: the canonical select list minus SIM. + cat > pyproject.toml <<'TOML' + [project] + name = "drifted" + version = "0.1.0" + + [tool.ruff] + line-length = 120 + + [tool.ruff.lint] + select = ["E", "W", "F", "I", "B", "UP", "S"] + TOML + git add -A + if pre-commit try-repo "$GITHUB_WORKSPACE" ruff-config --all-files 2>&1 | tee out.txt; then + echo "FAIL: hook did not catch the drifted config" >&2; exit 1 + fi + grep -q "SIM" out.txt || { echo "FAIL: hook ran but did not name the missing rule" >&2; exit 1; } + # Now the compliant version: canonical keys verbatim plus a target-version. + { printf '[project]\nname = "compliant"\nversion = "0.1.0"\n\n'; cat "$GITHUB_WORKSPACE/ruff-shared.toml"; } > pyproject.toml + git add -A + pre-commit try-repo "$GITHUB_WORKSPACE" ruff-config --all-files diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 7133b11..f44146d 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -5,3 +5,10 @@ language: script pass_filenames: false always_run: true +- id: ruff-config + name: ruff config matches the shared standard + description: Fail if this repo's shared ruff keys have drifted from ops/ruff-shared.toml. + entry: check-ruff-config.py + language: script + pass_filenames: false + always_run: true diff --git a/README.md b/README.md index 7f24a72..22f390c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Small operational scripts and scheduled chores for Offworld Labs. | --- | --- | --- | | [`standup-nudge/`](standup-nudge/) | Posts a fixed standup prompt to the "Offworld Labs" ClickUp chat channel | 09:00 Europe/London, Mon–Fri | | [`check-dead-code.sh`](check-dead-code.sh) | Dead-code gate (vulture) consumed by the Python repos as a pre-commit hook | On every commit / CI run in consumer repos | +| [`ruff-shared.toml`](ruff-shared.toml) | Canonical ruff configuration, enforced in consumer repos by the `ruff-config` hook | On every commit / CI run in consumer repos | ## Shared pre-commit hooks @@ -31,14 +32,21 @@ and vendored drift cannot happen: | Hook | Script | Requires | | --- | --- | --- | | `dead-code` | [`check-dead-code.sh`](check-dead-code.sh) | `vulture==2.14` on `PATH` | +| `ruff-config` | [`check-ruff-config.py`](check-ruff-config.py) | Python 3.11+ (`tomllib`) | -Hook versions are published as tags named `-v.`. **The dot is -required, not cosmetic.** pre-commit warns "appears to be a mutable reference" -for any `rev` containing neither a `.` nor pure hex, so a tag like -`dead-code-v1` makes every consumer print a spurious warning on every run -(`clientlib.py`, `WarnMutableRev`). To change a hook: edit -it here, run `bash tests/test-check-dead-code.sh`, merge, tag, then bump `rev` -in the consumers (`pre-commit autoupdate` does the bump for you). +Hook versions are published as repo-level tags named `hooks-v.`. +**The dot is required, not cosmetic.** pre-commit warns "appears to be a mutable +reference" for any `rev` containing neither a `.` nor pure hex +(`clientlib.py`, `WarnMutableRev`), so `hooks-v1` would make every consumer +print a spurious warning on every run. + +Tags are repo-level rather than per-hook because pre-commit pins the whole +repository at one `rev` — two hooks cannot be versioned independently from a +single repo. The older `dead-code-v1.0` and `dead-code-v1.1` tags remain valid +for consumers that have not moved. + +To change a hook: edit it here, run both suites in `tests/`, merge, tag, then +bump `rev` in the consumers (`pre-commit autoupdate` does the bump for you). The "Adding a script" conventions below are about scheduled chores on the VPS and do not apply to hooks — a hook takes no env config and nothing schedules it. diff --git a/check-ruff-config.py b/check-ruff-config.py index 2a05c04..03bf8f5 100755 --- a/check-ruff-config.py +++ b/check-ruff-config.py @@ -94,8 +94,9 @@ def main(argv: list[str]) -> int: for problem in problems: print(f" - {problem}", file=sys.stderr) print(file=sys.stderr) - print("Edit offworldlabs/ops:ruff-shared.toml if the standard should change,", file=sys.stderr) - print("then publish a new hooks-v*.* tag. Do not diverge locally.", file=sys.stderr) + print("Copy the entries above into this repo's pyproject.toml to match the", file=sys.stderr) + print("shared standard. If the standard itself should change instead, edit", file=sys.stderr) + print("offworldlabs/ops:ruff-shared.toml and publish a new hooks-v*.* tag.", file=sys.stderr) return 1 return 0 From 6c476d69fc13825de1387af1074eeafcc97f84de Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Tue, 11 Aug 2026 19:35:28 -0700 Subject: [PATCH 4/4] docs: show the current tag scheme in the README example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quickstart still showed rev: dead-code-v1.1 with a single hook, sitting above the paragraph that explains per-hook tags are superseded. A reader skimming top-to-bottom would copy the old scheme and get only one of the two hooks. Also updates check-dead-code.sh's header comment, which pointed the same old dead-code-v*.* scheme, to hooks-v*.* — kept version-agnostic (a placeholder, not a literal MAJOR.MINOR), since this file ships frozen inside whatever tag a consumer pins. --- README.md | 8 ++++++-- check-dead-code.sh | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 22f390c..e8f58ef 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,13 @@ and vendored drift cannot happen: repos: - repo: https://github.com/offworldlabs/ops - rev: dead-code-v1.1 + rev: hooks-v1.0 hooks: - - id: dead-code + - id: dead-code # requires vulture==2.14 on PATH + - id: ruff-config + +If a consumer's Python lives in a subdirectory, add `args: []` to both +hooks — Tower-Finder, whose backend is not at repo root, needs exactly that. | Hook | Script | Requires | | --- | --- | --- | diff --git a/check-dead-code.sh b/check-dead-code.sh index a2a71a6..789e5cb 100755 --- a/check-dead-code.sh +++ b/check-dead-code.sh @@ -5,7 +5,7 @@ # Consumed by other repos as a pre-commit hook, pinned by rev: # # - repo: https://github.com/offworldlabs/ops -# rev: +# rev: # hooks: # - id: dead-code # @@ -13,7 +13,7 @@ # a consumer pinned, so any version written here is guaranteed wrong for every # release after it. The README on main is the current reference. # -# Do not vendor this file. Change it here, publish a dead-code-v. +# Do not vendor this file. Change it here, publish a hooks-v. # tag (the dot matters — pre-commit warns "mutable reference" without one), then # bump rev in the consumers (`pre-commit autoupdate` does that for you). #