Skip to content

build: never re-lock uv.lock from a hook or make target - #467

Merged
hellices merged 12 commits into
mainfrom
fix/466-frozen-local-gates
Oct 23, 2026
Merged

hellices merged 12 commits into
mainfrom
fix/466-frozen-local-gates

Conversation

@hellices

@hellices hellices commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #466

Summary

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 mypy uv --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:

Command Result
pytest -p no:tach tests/test_lockfile.py tests/test_docs_links.py tests/test_docs_build_config.py 64 passed
python scripts/check_source_size.py passed
ruff check src/ tests/ and ruff format --check src/ tests/ passed; 659 files already formatted
SKIP=mypy,source-size pre-commit run --files <changed files> passed
mypy no issues in 659 source files
tach check all modules validated
pytest -n 8 -q 13,698 passed, 25 skipped

Checklist

  • This closes an accepted issue in this repository that is assigned to me.
  • Tests cover the change, written to fail first where behavior changed.
  • make check passes locally.
  • User-facing docs and the README match the change, or nothing user-facing changed.
  • No security invariant is weakened: writes still need a keystroke approval
    and a fail-closed audit entry, and sensitive reads are still masked.

Copilot AI balanced review requested due to automatic review settings October 23, 2026 08:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused changes satisfy #466, include regression coverage, and have no identified blocking issues.

No open issues 🎉

What changed in this PR

Prevents local development gates from implicitly rewriting uv.lock behind a package mirror, addressing #466.

Changes:

  • Adds --frozen to the mypy pre-commit hook and remaining Makefile uv run commands.
  • Adds a regression test guarding both files against unfrozen invocations.
  • Documents frozen gate behavior and equivalent options for ad-hoc commands.
File Description
tests/​test_lockfile.py Adds regression coverage for frozen local gate commands.
Makefile Uses the committed lock for lint, format, typecheck, test, and architecture checks.
docs/​dev/​quality-gates.md Explains frozen gates and mirror-safe ad-hoc commands.
.pre-commit-config.yaml Prevents the mypy hook from implicitly re-locking dependencies.

Review effort: Balanced

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이슈 1건을 인라인 코멘트로 남겼습니다.

Comment thread tests/test_lockfile.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The narrow changes satisfy the linked issue, include focused regression coverage, and have no identified blocking issues.

No open issues 🎉

Review effort: Balanced

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이슈 1건을 인라인 코멘트로 남겼습니다.

Comment thread tests/test_lockfile.py

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The regression guard misses YAML-quoted bare hook commands, allowing lockfile rewrites to return undetected.

1 open issue

Review effort: Balanced

Comment thread tests/test_lockfile.py Outdated

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검토 범위에서 보고할 이슈가 없습니다.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity 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.

1 resolved since last review

Review effort: Balanced

@hellices

hellices commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner Author

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

No open issues 🎉

Review effort: Balanced

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이슈 1건을 인라인 코멘트로 남겼습니다.

Comment thread tests/test_lockfile.py

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The scoped commands consistently use --frozen, regression coverage addresses the prior quoting issue, and no blocking findings remain.

No open issues 🎉

Review effort: Balanced

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이슈 1건을 인라인 코멘트로 남겼습니다.

Comment thread tests/test_lockfile.py

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The targeted commands use frozen execution, regression coverage addresses the prior YAML-parsing issue, and no unresolved blocking findings remain.

No open issues 🎉

Review effort: Balanced

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검토 범위에서 보고할 이슈가 없습니다.

hellices and others added 8 commits October 23, 2026 21:15
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>
@hellices
hellices force-pushed the fix/466-frozen-local-gates branch from 41d85f1 to e433d9e Compare October 23, 2026 12:16

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이슈 1건을 인라인 코멘트로 남겼습니다.

Comment thread tests/test_lockfile.py
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The targeted commands use frozen execution, the regression checks address the prior YAML-quoting gap, and no blocking issues remain.

No open issues 🎉

Review effort: Balanced

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이슈 1건을 인라인 코멘트로 남겼습니다.

Comment thread tests/test_lockfile.py Outdated
_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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

No blocking code issues were identified, but completion of the required checks has not been verified.

No open issues 🎉

Review effort: Balanced

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이슈 1건을 인라인 코멘트로 남겼습니다.

Comment thread tests/test_lockfile.py Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The scoped changes address #466 and the prior YAML-quoting issue, with regression coverage and no unresolved findings.

No open issues 🎉

Review effort: Balanced

`$(...)` 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes satisfy the issue requirements, address the previous YAML-quoting gap, and leave no unresolved blocking findings.

No open issues 🎉

Review effort: Balanced

@my-reviewer-agent my-reviewer-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검토 범위에서 보고할 이슈가 없습니다.

@hellices
hellices merged commit 7919926 into main Oct 23, 2026
15 checks passed
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.

The mypy pre-commit hook and make targets re-lock uv.lock behind a package mirror

2 participants