Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 0 additions & 66 deletions .github/workflows/dead-code-hook.yml

This file was deleted.

70 changes: 70 additions & 0 deletions .github/workflows/hooks.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -24,21 +25,32 @@ 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: [<dir>]` to both
hooks — Tower-Finder, whose backend is not at repo root, needs exactly that.

| 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 repo-level tags named `hooks-v<MAJOR>.<MINOR>`.
**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.

Hook versions are published as tags named `<hook>-v<MAJOR>.<MINOR>`. **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).
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.
Expand Down
4 changes: 2 additions & 2 deletions check-dead-code.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
# Consumed by other repos as a pre-commit hook, pinned by rev:
#
# - repo: https://github.com/offworldlabs/ops
# rev: <latest dead-code-v*.* tag — see the repo's tag list or README>
# rev: <latest hooks-v*.* tag — see the repo's tag list or README>
# hooks:
# - id: dead-code
#
# No version is named here on purpose. This file is frozen inside whatever tag
# 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<MAJOR>.<MINOR>
# Do not vendor this file. Change it here, publish a hooks-v<MAJOR>.<MINOR>
# tag (the dot matters — pre-commit warns "mutable reference" without one), then
# bump rev in the consumers (`pre-commit autoupdate` does that for you).
#
Expand Down
106 changes: 106 additions & 0 deletions check-ruff-config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/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("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


if __name__ == "__main__":
sys.exit(main(sys.argv))
51 changes: 51 additions & 0 deletions ruff-shared.toml
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading