You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
build: never re-lock uv.lock from a hook or make target - #467
On a machine configured for a package mirror, the repository's own gates rewrote uv.lock. The mypy pre-commit hook ran a bare uv run mypy, which re-locks when the lock looks out of date. Behind a mirror the configured index is not the one the lock records, so every URL was rewritten to the mirror: 2,256 lines on 2026-10-23, after which no-private-index-in-lock rejected the file. make lint, make format, make typecheck, make test and the tach step of make check used the same bare uv run, on the path the pull request template asks contributors to take.
The fix: every uv run in .pre-commit-config.yaml and the Makefile now passes --frozen, as source-size, docs-build and docs-serve already did.
The pin:tests/test_lockfile.py::test_local_gates_never_relock_the_lockfile fails when either file gains a uv run without it.
The docs: the mirror section of docs/dev/quality-gates.md now says the gates pass --frozen, and points ad-hoc commands at --frozen or UV_FROZEN=1.
--frozen runs against the committed lock without checking it against pyproject.toml. A contributor who changes dependencies runs uv lock explicitly, which is already how CONTRIBUTING.md frames it ("do not commit a re-locked uv.lock unless the change is about dependencies").
Verification
New test:test_local_gates_never_relock_the_lockfile is parametrized over both files. It failed first, listing entry: uv run mypy and the five Makefile lines, and passes now.
How the pin reads each file: each file is read the way the tool that runs it reads it, after nine review rounds. For .pre-commit-config.yaml, the check parses the YAML and splits each command hook's decoded entry with shlex, as pre-commit does. For the Makefile, it strips Make's @, - and + recipe prefixes and then splits each recipe line into words and operators as a POSIX shell does, so blanks, quotes, escapes, and ;, &&, ||, |, $( and backticks with or without spaces are all handled. It skips only whole-line comments and reads any other # as code, failing closed: a comment boundary guessed wrong would hide the command after it, so a trailing comment that mentions uv is reported instead. It catches the ways a bare uv run gets written by accident, not a deliberately obscured one: it does not expand Make variables, look inside a nested shell (sh -c '...') or reassemble a split word (u""v), and its docstring says so. Both checks are an allowlist that fails closed: any uv word, matched by its last path component so /usr/local/bin/uv counts, not followed by exactly run --frozen is reported, so a global option before the subcommand (uv --quiet run), another subcommand (uv sync), and anything the check does not model are reported for review. Respaced, tabbed, quoted, prefixed and chained bare forms, YAML-quoted and folded entries, and a --frozen-lockfile lookalike are all caught. Frozen forms, comments and pygrep patterns are not reported. Writing the real hook as uv run mypy, "uv" run mypy or entry: "uv run mypy", or the typecheck recipe as @uv run mypy, true;uv run mypyuv --quiet run mypy, /usr/local/bin/uv run mypy, echo foo#bar; uv run mypy, echo foo\ #bar; uv run mypy, echo $(date)#tag; uv run mypy or echo `uv run mypy` , fails the real-file pin.
The bug, before and after: on this mirror-configured machine, running the mypy hook rewrote the 2,256 lines before the change. After the change, uv.lock is unchanged. Here the hook stops trying to build a fresh environment from the lock's PyPI URLs, because this machine cannot reach files.pythonhosted.org. With an existing environment, or with PyPI reachable, it runs mypy.
Run locally on macOS with Python 3.13 and the frozen lock. Each step ran directly with the project's environment rather than through make, because this machine cannot sync from PyPI:
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
The regression guard misses standard Make recipe prefixes, allowing unfrozen commands to pass undetected.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Strip Make recipe prefixes before detecting unfrozen uv runs
tests/test_lockfile.py:604
Standard Make recipe prefixes bypass this regression guard: _bare_uv_runs("\t@uv run mypy") returns [], as do the -uv and +uv forms. Make removes these prefixes before executing the recipe, but shlex.split preserves them, so _runs_uv_unfrozen never sees a uv token. A new unfrozen target can therefore pass the test and reintroduce lockfile rewrites. Strip leading recipe prefixes before splitting, and add regression cases for individual and combined prefixes on both bare and frozen commands.
Re the latest review overview ("misses standard Make recipe prefixes"): fixed in 4b3ed20. _bare_uv_runs now strips any leading run of blanks and Make's @, - and + recipe prefixes before the shell split. @uv run mypy and -uv run mypy are reported, and @-uv run --frozen mypy is not. As a mutation check, rewriting the typecheck recipe as @uv run mypy fails test_local_gates_never_relock_the_lockfile[make]. With this, the pin reads each file the way its consumer does: pre-commit's YAML-decoded entries split with shlex, and Make's recipes with prefixes stripped, then split as the shell does.
The reason will be displayed to describe this comment to others. Learn more.
🟢 Approval recommended
The focused changes address the reported lockfile rewrites, with regression coverage and consistent documentation, and no unresolved blocking findings.
The mypy pre-commit hook ran a bare `uv run mypy`. Behind a package mirror
that re-locks: the configured index is not the one the lock records, so
every URL in uv.lock was rewritten to the mirror (2,256 lines), and
no-private-index-in-lock rejected the result (#466). `make lint`,
`format`, `typecheck`, `test` and the tach step of `check` used the same
bare `uv run`, on the path the pull request template asks contributors to
take.
Every `uv run` in .pre-commit-config.yaml and the Makefile now passes
--frozen, as source-size and the docs targets already did, and
test_local_gates_never_relock_the_lockfile fails when one does not. The
mirror section of quality-gates.md says so and points ad-hoc commands at
--frozen or UV_FROZEN=1.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The pin matched `uv run` with exactly one space and accepted any word
starting with --frozen, so a recipe written `uv run mypy` or
`uv<TAB>run mypy` would re-lock while the test stayed green, and
`--frozen-lockfile` would pass as frozen. The check now matches any run of
blanks between the words, counts --frozen only as a whole word, and lives
in _bare_uv_runs with nine cases that pin both directions. Reverting the
mypy hook to `uv run mypy` fails the real-file pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The regex matched blanks between `uv` and `run` but not quotes, so a
recipe written `"uv" run mypy` or `uv "run" mypy` would run unfrozen
once the shell removed the quotes, while the pin stayed green. It also
flagged `uv run` inside a comment. _bare_uv_runs now splits each line
with shlex.split(comments=True), as a POSIX shell splits words, and
reports a line whose next word after `uv run` is not `--frozen`; a line
that does not split is reported rather than skipped. Five new cases cover
quoted words, a quoted frozen command, an unsplittable line and a
comment, and quoting the mypy hook as `"uv" run mypy` fails the
real-file pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Splitting .pre-commit-config.yaml line by line with shlex treated a
YAML-quoted entry as one word, so `entry: "uv run mypy"` or
`entry: 'uv run mypy'` would pass the pin while pre-commit decoded and
ran the bare `uv run mypy`. The pre-commit file now has its own check,
_bare_hook_entries: it parses the YAML and splits each hook's decoded
entry with shlex, as pre-commit does, skipping pygrep and fail hooks,
whose entries are a pattern and a message. An entry that does not split
is reported. Makefile recipes keep the shell split, and both checks share
_runs_uv_unfrozen. Seven cases pin double-quoted, single-quoted, folded,
frozen and pygrep entries, and quoting the real hook as
`entry: "uv run mypy"` fails the pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make removes a recipe line's leading `@`, `-` and `+` before the shell
sees it, so `@uv run mypy` runs a bare `uv run` while the shell split saw
the word `@uv` and passed it. _bare_uv_runs now strips any leading run of
blanks and those prefixes before splitting. Three cases pin it: `@` and
`-` prefixed bare recipes are reported, and `@-uv run --frozen mypy` is
not. Rewriting the typecheck recipe as `@uv run mypy` fails the pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
shlex.split keeps `;`, `&&`, `||` and `|` inside a word when there is no
space around them, so the recipe `true;uv run mypy` split into
['true;uv', 'run', 'mypy'] and passed the pin while the shell ran a bare
`uv run`. _bare_uv_runs now tokenizes recipes with shlex's
punctuation_chars, which separates those operators. Four cases pin it:
`;`, `&&` and `||` without spaces are reported, and a frozen command
piped without spaces is not. Rewriting the typecheck recipe as
`true;uv run mypy` fails the pin.
The docstring now states the check's scope: it reads each recipe as a
simple command list, and does not expand Make variables or look inside a
nested shell such as `sh -c '...'`.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The check looked for `uv` and `run` side by side, so a global option
between them, `uv --quiet run mypy`, ran unfrozen and passed the pin.
Rather than model uv's global options as well, _runs_uv_unfrozen is now
an allowlist and fails closed: any `uv` word not followed by exactly
`run --frozen` is reported. That covers a global option before the
subcommand, another subcommand such as `uv sync` (which also re-locks by
default), and every bare `uv run`, and it reports forms it does not
model for review instead of interpreting them. Every hook and target is
already written `uv run --frozen`. Five cases pin it across both
scanners, and rewriting the typecheck recipe as `uv --quiet run mypy`
fails the pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The allowlist matched only the word `uv`, so a recipe calling the binary
by path, `/usr/local/bin/uv run mypy` or `$(HOME)/.local/bin/uv run mypy`,
would run unfrozen and pass. uv is now matched by the word's last path
component. Two cases pin it: a path-qualified bare call is reported and a
path-qualified `run --frozen` is not. Rewriting the typecheck recipe as
`/usr/local/bin/uv run mypy` fails the pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
shlex ends a word at any `#` and drops the rest of the line, but a POSIX
shell starts a comment only where a word could start, so the recipe
`echo foo#bar; uv run mypy` runs a bare `uv run` while the check saw
only `echo foo`. _without_shell_comment now cuts a line at a `#` that
starts a word outside quotes, and the lexer runs with its own comment
handling off. Three cases pin it: a `#` inside a word and a quoted `#`
leave the next command visible, and a real trailing comment is still
dropped. Rewriting the typecheck recipe as `echo foo#bar; uv run mypy`
fails the pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_without_shell_comment remembered only the previous character, so the
escaped blank in `echo foo\ #bar; uv run mypy` looked like a word
boundary and the `#` after it started a comment, hiding the bare `uv
run` the shell would run. It now tracks whether a word can start: only
an unescaped blank or operator outside quotes allows one, while an
escaped character or quoted text always continues the word. Nine cases
pin the comment boundary directly (whole line, after a blank or an
operator, inside a word, after an escaped blank, double quote or
backslash, single quotes taking no escape, after a closing quote), and
two more pin it through the recipe check. Rewriting the typecheck recipe
as `echo foo\ #bar; uv run mypy` fails the pin.
The docstring now states the check's purpose: it catches the ways a bare
`uv run` gets written by accident, not a deliberately obscured one such
as a Make variable, a nested shell or a split word (`u""v`).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Where a shell comment starts depends on substitutions and quoting: in
`echo $(date)#tag; uv run mypy` the `#` continues the word after `$(...)`,
so the shell runs the bare `uv run`, but _without_shell_comment cut the
line there. Every comment-boundary rule the check guessed wrong hid the
command after it, so the check now fails closed instead. It skips only a
line that is a whole comment and reads any other `#` as code. A trailing
comment that mentions uv is therefore reported, the safe direction; the
real Makefile has no `#` at all. _without_shell_comment and its direct
cases are gone. The recipe cases now expect a trailing comment and a
comment after an operator to be reported, and add the substitution case.
Rewriting the typecheck recipe as `echo $(date)#tag; uv run mypy` fails
the pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`$(...)` already split into its own tokens, but a backtick stayed inside
the word, so ``echo `uv run mypy` `` produced the word `` `uv `` and the
bare command passed the pin. The recipe lexer now treats the backtick as
punctuation alongside `();<>|&`. Two cases pin it: a bare command in
backticks is reported and a frozen one is not. Rewriting the typecheck
recipe as ``echo `uv run mypy` `` fails the pin.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #466
Summary
On a machine configured for a package mirror, the repository's own gates rewrote
uv.lock. Themypypre-commit hook ran a bareuv run mypy, which re-locks when the lock looks out of date. Behind a mirror the configured index is not the one the lock records, so every URL was rewritten to the mirror: 2,256 lines on 2026-10-23, after whichno-private-index-in-lockrejected the file.make lint,make format,make typecheck,make testand thetachstep ofmake checkused the same bareuv run, on the path the pull request template asks contributors to take.uv runin.pre-commit-config.yamland theMakefilenow passes--frozen, assource-size,docs-buildanddocs-servealready did.tests/test_lockfile.py::test_local_gates_never_relock_the_lockfilefails when either file gains auv runwithout it.docs/dev/quality-gates.mdnow says the gates pass--frozen, and points ad-hoc commands at--frozenorUV_FROZEN=1.--frozenruns against the committed lock without checking it againstpyproject.toml. A contributor who changes dependencies runsuv lockexplicitly, which is already how CONTRIBUTING.md frames it ("do not commit a re-lockeduv.lockunless the change is about dependencies").Verification
test_local_gates_never_relock_the_lockfileis parametrized over both files. It failed first, listingentry: uv run mypyand the five Makefile lines, and passes now..pre-commit-config.yaml, the check parses the YAML and splits each command hook's decodedentrywithshlex, as pre-commit does. For theMakefile, it strips Make's@,-and+recipe prefixes and then splits each recipe line into words and operators as a POSIX shell does, so blanks, quotes, escapes, and;,&&,||,|,$(and backticks with or without spaces are all handled. It skips only whole-line comments and reads any other#as code, failing closed: a comment boundary guessed wrong would hide the command after it, so a trailing comment that mentions uv is reported instead. It catches the ways a bareuv rungets written by accident, not a deliberately obscured one: it does not expand Make variables, look inside a nested shell (sh -c '...') or reassemble a split word (u""v), and its docstring says so. Both checks are an allowlist that fails closed: anyuvword, matched by its last path component so/usr/local/bin/uvcounts, not followed by exactlyrun --frozenis reported, so a global option before the subcommand (uv --quiet run), another subcommand (uv sync), and anything the check does not model are reported for review. Respaced, tabbed, quoted, prefixed and chained bare forms, YAML-quoted and folded entries, and a--frozen-lockfilelookalike are all caught. Frozen forms, comments and pygrep patterns are not reported. Writing the real hook asuv run mypy,"uv" run mypyorentry: "uv run mypy", or the typecheck recipe as@uv run mypy,true;uv run mypyuv --quiet run mypy,/usr/local/bin/uv run mypy,echo foo#bar; uv run mypy,echo foo\ #bar; uv run mypy,echo $(date)#tag; uv run mypyorecho `uv run mypy`, fails the real-file pin.mypyhook rewrote the 2,256 lines before the change. After the change,uv.lockis unchanged. Here the hook stops trying to build a fresh environment from the lock's PyPI URLs, because this machine cannot reachfiles.pythonhosted.org. With an existing environment, or with PyPI reachable, it runs mypy.Run locally on macOS with Python 3.13 and the frozen lock. Each step ran directly with the project's environment rather than through
make, because this machine cannot sync from PyPI:pytest -p no:tach tests/test_lockfile.py tests/test_docs_links.py tests/test_docs_build_config.pypython scripts/check_source_size.pyruff check src/ tests/andruff format --check src/ tests/SKIP=mypy,source-size pre-commit run --files <changed files>mypytach checkpytest -n 8 -qChecklist
make checkpasses locally.and a fail-closed audit entry, and sensitive reads are still masked.