Skip to content

ci: align ruff gates and fix claude hooks - #869

Merged
av1155 merged 13 commits into
mainfrom
ci/align-ruff-scope-with-ci
Nov 11, 2026
Merged

av1155 merged 13 commits into
mainfrom
ci/align-ruff-scope-with-ci

Conversation

@av1155

@av1155 av1155 commented Nov 11, 2026

Copy link
Copy Markdown
Owner

Summary

The local ruff gates checked less than CI, CI skipped its ruff and test jobs for files those jobs cover, and most of the Claude Code hooks in .claude/settings.json never ran. just lint, just fmt-check and just fix only covered src/ and tests/, so the two Markdown code blocks ruff 0.17 reformatted in #865 passed locally and failed the Format check. The edit hooks looped over $CLAUDE_FILE_PATHS, which Claude Code doesn't set, so nothing was formatted on edit, mypy never ran, and edits on main went through.

Closes #867
Closes #868

Changes

  • just lint, just fmt-check and just fix run ruff on ., the same files quality.yml checks. The /check and /bump skills and CONTRIBUTING.md list the same commands.
  • quality.yml and tests.yml run on every PR, and ci-skip.yml no longer posts stubs for their checks. Ruff checks every .py and .md file and the tests read docs/api/, scripts/marketing/ and .claude/settings.json, so a Markdown-only, marketing-only or hook-only PR used to merge without the checks that cover it. The security, dependency review, docker and smoke test workflows keep their path filters.
  • The edit hooks read the file path from the hook's stdin JSON (tool_input.file_path) and find the repo root with git rev-parse, so they also work from a subdirectory such as website/ and for a path that goes through a symlink.
  • The format-on-edit hook runs ruff check --fix --unfixable F401,F841 and ruff format on .py files and ruff format on .md files. Unused imports and except ... as e bindings are left for just fix, so code added before its first use survives the edit. Gitignored files and ruff's excludes are skipped; ruff's --force-exclude still formats a gitignored file passed by path, so the hook asks git check-ignore first.
  • The mypy hook exits 2 with mypy's output on stderr. Claude never sees stderr from a hook that exits 0, so the type errors used to go nowhere.
  • The branch hook now blocks edits on main. It asks git which checkout the edited file is in, after resolving .. the way the Write tool does, so an edit to a checkout on main is caught from another worktree or through a symlink too, and anything it can't place, such as a file under .git/, is blocked. Gitignored local files such as .claude/settings.local.json stay editable.
  • The commit hook matches git commit inside compound commands too, including cd <dir> && git commit and git -C <dir> commit, runs ruff format --check . from the repo root, and lists the files when it blocks. It used to block every commit made from a subdirectory. It checks the working tree, so its message says to re-add the fixed files.
  • The push guard treats any command with the word git followed later by the word push as a push, and only accepts one written as a plain command of its own: one line starting with git, with nothing chained, piped or redirected and no quotes, $, backslashes, braces, globs or comments. Anything else with git and then push in it is refused with a message that says how to run it. That covers pushes inside && chains, subshells, $(...) and multi-line commands, or with option values built from variables, where shell syntax can hide where the push goes, and commit or PR text with git and push in it, which goes in a file instead. In a plain push it reads git's global options (git -C <dir> push) and blocks force pushes, --all, --branches and --mirror (abbreviations too), : refspecs, and any target naming main or master, including HEAD:main and heads/main. On main or master, a push that names no branch is blocked too; tag pushes still go through. A push using -c, --git-dir or --work-tree, or a -C directory that isn't a repo, is refused. So git -C . push --force, git push -u origin main and, from main, git push no longer get through. It's there to catch mistakes, so a push through a git alias, a shell alias or a script isn't seen.
  • The Stop hook reads stdin once and checks last_assistant_message.
  • tests/test_gates/test_claude_hooks_gate.py runs each hook with the JSON Claude Code sends, inside throwaway repos, so a hook that stops reading its input fails the suite.
  • AGENTS.md's hook and CI notes say where each hook gets its input, which files it covers, and which workflows keep path filters. Setup now lists jq, which every hook needs to read its input. The release steps and the /bump skill show each push as its own command.

Testing

The new gate test covers each hook. It runs the push guard under every shell it finds (bash, sh, zsh) and checks that pushes inside chains, subshells, command substitutions, pipes, redirects and multi-line commands are refused. The push guard was also checked against what bash actually runs, with a stand-in git that only logs its arguments, on macOS and on Ubuntu 24.04 with GNU grep, GNU sed, mawk and gawk, and no disallowed push got past it. The hooks ran against sample input under bash 5.3, macOS's bash 3.2, dash and zsh, and the edit, mypy, branch, commit and push hooks were exercised in a live Claude Code session. just fmt-check now fails on an unformatted Markdown code block the way CI does, and just fix formats it. All checks pass.

Type of Change

  • Bug fix (fix:)
  • New feature (feat:)
  • Refactoring (refactor:)
  • Documentation (docs:)
  • CI/CD (ci:)
  • Chore (chore:)

Checklist

  • Issue exists and is linked above with Closes #N
  • Linked issue has exactly one type:* and one priority:* label
  • Linked issue has at most one phase:* label (or none when not roadmap work)
  • Branch name matches issue scope (feat/<slug>, fix/<slug>, etc.)
  • Tests added/updated for all changes
  • Type check passes (mypy src/)
  • Lint passes (ruff check .)
  • Format passes (ruff format --check .)
  • Documentation updated (if applicable)
  • No secrets or sensitive data committed
  • Scope check: This change helps search for missing or cutoff-unmet media in a controlled way

Claude Code passes hook input as JSON on stdin and never sets
CLAUDE_FILE_PATHS, so the edit hooks (format, mypy, and the block on
editing main) never saw a file. They now read tool_input.file_path.

The format hook leaves unused imports alone, so an import added
before its first use survives the edit. The mypy hook exits 2 so its
errors reach Claude. The Stop hook read stdin twice and looked for a
field that doesn't exist; it now reads stdin once and checks
last_assistant_message. The commit hook only matched commands that
start with "git commit" and resolved .venv against the current
directory; it now matches compound and "git -C" commits and runs
from the repo root.
quality.yml runs `ruff check .` and `ruff format --check .`, but just
lint, fmt-check and fix, the pre-commit hook, the /check and /bump
skills and CONTRIBUTING.md only covered src/ and tests/. Anything else
CI checks, like scripts/ and Python code blocks in Markdown, could
pass locally and still fail CI, and just fix couldn't repair it.

All of them now run ruff on the whole repo. The format-on-edit hook
also formats Markdown files, and it skips gitignored files, which
ruff's --force-exclude still formats when they're passed by path.
The main-branch guard took the branch from Claude's current directory,
so an edit to a checkout on main went through from a worktree on a
feature branch, and gitignored local files such as
.claude/settings.local.json were blocked. It now checks the branch of
the checkout the edited file is in, limited to this repo's checkouts,
and skips gitignored files.

The commit hook also matches "/usr/bin/git commit" and a commit
followed by ";" or ")". Its message says to re-add the files after
just fix, since it checks the working tree while git commits the
index.

tests/test_gates/test_claude_hooks_gate.py runs each hook with the
JSON Claude Code sends, inside throwaway repos.
The main-branch guard placed the edited file by string prefix, so a
path spelled through a symlink or in another case slipped past it, and
it let edits under .git/ through on main. It now asks git where the
file sits (rev-parse --show-prefix) and blocks anything it can't
place: an edit outside a work tree, or a project dir git can't read.

The mypy hook keeps mypy's "Found N errors" line, so a cut-off list
still says how many errors there were. The commit hook only lets the
git options that take a value (-C, -c, --git-dir, --work-tree,
--namespace, --config-env) take one, so `git --no-pager log --grep
commit` isn't mistaken for a commit, and `git commit>file` is caught.

AGENTS.md notes that the hooks need jq and that CI skips .claude/**.
The hook tests cover the new cases.
The commit hook also lets --attr-source take a separate value, so
`git --attr-source HEAD commit` is checked. The hook tests now cover
a nested AGENTS.md on main, which stays blocked, and a _build/ file,
which only ruff's own excludes keep away from the format hook.

AGENTS.md says CI's path filters also skip Markdown-only and
scripts/marketing/-only PRs, so those get no ruff check in CI.
quality.yml and tests.yml only ran when a PR touched something outside
docs/, Markdown, website/, .claude/ and scripts/marketing/, and
ci-skip.yml stubbed their checks otherwise. Ruff checks every .py and
.md file and the tests read docs/api/, scripts/marketing/ and
.claude/settings.json, so a PR touching only those merged without the
checks that cover them. Both workflows now run on every PR, and
ci-skip.yml only stubs the four workflows that keep their filters.

The push guard only matched --force, -f or "origin main" right after
"git push". It now finds git push after git's global options and
blocks force pushes (--force, -f, --force-with-lease, --mirror, --all,
+refspecs) and any main or master target. On main, a push that names
no branch, or names a ref that resolves to main, is blocked too; the
branch is read in the checkout -C points at.

The format hook also leaves unused except bindings alone (F841).
The push guard matched raw command text, so a quoted or expanded
branch or flag slipped a push to main or a force push past it, and
it only followed -C to find the checkout, so --git-dir, --work-tree
or GIT_DIR pointed the push at a branch it never read.

It now reads the push with quotes removed and across line breaks,
and refuses one it cannot read: $, backticks, backslashes, quoted
text with spaces, the repo overrides above, or a -C directory that
is not a repo.
Some spellings still slipped a push to main or a force push past the
guard: --branches and abbreviated options, heads/main targets, ':'
and '*' refspecs, braces, quotes or backslashes inside the git or
push words, xargs, -c and GIT_CONFIG overrides, and a second push
glued on with ';' and no space.

The guard now reads each push the way the shell would and refuses
the forms it still cannot read. Its loop runs in an explicit
subshell, so an exit inside it means the same under zsh, where the
old code exited 3 with no message and let the push through.
The push guard matched shell text with sed and grep, so an operator
glued to a word could hide a flag or a target: git push --force; git
log, (git push origin HEAD) from main, and git push origin 2>&1 from
main all went through.

It now splits each line into simple commands the way the shell does,
at operators, parentheses and substitutions with or without spaces
around them, drops redirections and comments, and checks every push on
its own. A command substitution is checked as a command of its own,
and quoted text stays one word, so a commit message that mentions a
push is no longer read as one.

The guard also stops reading -ofoo as -f, stops refusing a push because
another command on the line ends a pattern in $, sees GIT and
--shallow-file, and refuses a push with a quoted or escaped shell
operator in it.
Quoted text on a line is never read as a push, but the guard reads one
line at a time, so the later lines of a multi-line quoted message can
still be.
The guard read shell syntax line by line, so a push that followed a
multi-line quoted string got through, such as a heredoc commit message
chained with && git push. A checkout earlier in the same command also
made the on-main check read the wrong branch. Patching the parser kept
opening gaps like these.

Any command that mentions git push is now refused unless the whole
command is one plain git push line, which keeps the existing checks.
The refusal message says how to run the push, or to pass text that
spells out git push from a file. The release steps and /bump show the
tag push as its own command.
The push guard looked for git, its global options and then push, so an
option value built from a variable, such as git -C ${HOME}/repo push,
hid the push and it ran without any check. Any command with the word
git followed later by the word push is now treated as a push, and
unless it is one plain git push line it is refused.

The format and mypy hooks now resolve the edited path before matching
it against the repo root, so an edit through a symlink is checked. The
branch hook resolves .. the way the Write tool does, so a path through
a missing directory can no longer land in a checkout on main unseen.
A push word built from a shell expansion, such as pu$(:)sh, or a space
held in a variable gets past the push guard, and so do push settings
stored with git config. The hook notes now list them and no longer say
the guard reads through $ and braces.

The format hook test used fixtures that only ruff format changes, so it
passed with --force-exclude dropped from the ruff check step. They now
need a lint fix as well.
@av1155
av1155 merged commit e6a4208 into main Nov 11, 2026
30 of 36 checks passed
@av1155
av1155 deleted the ci/align-ruff-scope-with-ci branch November 11, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: fix the claude code hooks that never fire ci: run local ruff gates over the same files as ci

1 participant