From ddc580c9c4544fdc810222cc1fccf5c91bfe6066 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 17:40:05 +0900 Subject: [PATCH 01/12] build: never re-lock uv.lock from a hook or make target 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> --- .pre-commit-config.yaml | 2 +- Makefile | 10 +++++----- docs/dev/quality-gates.md | 5 ++++- tests/test_lockfile.py | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e3b3c2ad8..73ccd58cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: always_run: true - id: mypy name: mypy - entry: uv run mypy + entry: uv run --frozen mypy language: system types: [python] pass_filenames: false diff --git a/Makefile b/Makefile index 5a8a14b71..f6bbc3af6 100644 --- a/Makefile +++ b/Makefile @@ -4,19 +4,19 @@ source-size: uv run --frozen python scripts/check_source_size.py lint: - uv run ruff check src/ tests/ + uv run --frozen ruff check src/ tests/ format: - uv run ruff check --fix src/ tests/ && uv run ruff format src/ tests/ + uv run --frozen ruff check --fix src/ tests/ && uv run --frozen ruff format src/ tests/ typecheck: - uv run mypy + uv run --frozen mypy test: - uv run pytest -x -q + uv run --frozen pytest -x -q check: source-size lint typecheck test - uv run tach check + uv run --frozen tach check docs-build: uv run --frozen --group docs mkdocs build --strict diff --git a/docs/dev/quality-gates.md b/docs/dev/quality-gates.md index abf7128e3..be65968fd 100644 --- a/docs/dev/quality-gates.md +++ b/docs/dev/quality-gates.md @@ -47,7 +47,10 @@ are guarded: | a repository-level `uv.toml` | `test_repository_declares_no_uv_configuration_file` | Behind a mirror, work with `uv sync --frozen --dev --all-extras` and do not -re-lock. If a lock genuinely must change: +re-lock. A bare `uv run` re-locks too, so every hook and `make` target passes +`--frozen` (`test_local_gates_never_relock_the_lockfile` keeps it that way); +give an ad-hoc `uv run` the same flag, or set `UV_FROZEN=1`. If a lock +genuinely must change: ```sh git checkout uv.lock # discard the rewritten lock diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 5ec213383..879253ff4 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -578,3 +578,17 @@ def test_repository_declares_no_uv_configuration_file() -> None: "uv.toml overrides [tool.uv] in pyproject.toml; if it is introduced," " assert here that it pins no alternate index" ) + + +@pytest.mark.parametrize("name", [".pre-commit-config.yaml", "Makefile"]) +def test_local_gates_never_relock_the_lockfile(name: str) -> None: + """A bare `uv run` re-locks when the lock looks stale to it, and behind a + package mirror it does: the configured index is not the one the lock + records, so every URL is rewritten to the mirror. The hooks and the + `make` targets are what a contributor runs before every commit (#466). + """ + text = (_ROOT / name).read_text(encoding="utf-8") + bare = [ + line.strip() for line in text.splitlines() if re.search(r"\buv run\b(?! --frozen\b)", line) + ] + assert bare == [], f"{name} runs uv without --frozen, which can re-lock uv.lock" From 4c26a900855442c43757f73043ad9034a9fbb031 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 18:10:17 +0900 Subject: [PATCH 02/12] test: read uv run the way the shell splits it The pin matched `uv run` with exactly one space and accepted any word starting with --frozen, so a recipe written `uv run mypy` or `uvrun 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> --- tests/test_lockfile.py | 46 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 879253ff4..fcfb3aa85 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -580,6 +580,16 @@ def test_repository_declares_no_uv_configuration_file() -> None: ) +def _bare_uv_runs(text: str) -> list[str]: + """The lines of `text` that run `uv run` without `--frozen`. + + Blanks are matched as the shell splits words - any run of spaces or tabs + - and `--frozen` only counts as a whole word. + """ + bare_run = re.compile(r"\buv\s+run\b(?!\s+--frozen(?:\s|$))") + return [line.strip() for line in text.splitlines() if bare_run.search(line)] + + @pytest.mark.parametrize("name", [".pre-commit-config.yaml", "Makefile"]) def test_local_gates_never_relock_the_lockfile(name: str) -> None: """A bare `uv run` re-locks when the lock looks stale to it, and behind a @@ -587,8 +597,36 @@ def test_local_gates_never_relock_the_lockfile(name: str) -> None: records, so every URL is rewritten to the mirror. The hooks and the `make` targets are what a contributor runs before every commit (#466). """ - text = (_ROOT / name).read_text(encoding="utf-8") - bare = [ - line.strip() for line in text.splitlines() if re.search(r"\buv run\b(?! --frozen\b)", line) - ] + bare = _bare_uv_runs((_ROOT / name).read_text(encoding="utf-8")) assert bare == [], f"{name} runs uv without --frozen, which can re-lock uv.lock" + + +@pytest.mark.parametrize( + ("line", "bare"), + [ + ("\tuv run --frozen mypy", False), + ("\tuv run --frozen --group docs mkdocs serve", False), + ("\tuv run --frozen\tmypy", False), + ("\tuv run mypy", True), + ("\tuv run mypy", True), + ("\tuv\trun mypy", True), + ("\tuv run mypy --frozen", True), + ("\tuv run --frozen-lockfile mypy", True), + ("\tuv run --frozen ruff check && uv run ruff format", True), + ], + ids=[ + "frozen", + "frozen-before-group", + "frozen-then-tab", + "bare", + "two-spaces", + "tab", + "flag-after-command", + "lookalike-flag", + "second-command", + ], +) +def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: + """The shell splits words on any run of blanks, so the check must too, or a + re-spaced recipe would re-lock while the pin above stays green.""" + assert bool(_bare_uv_runs(line)) is bare From a9240f3d0a952be03dcb6b966b4575195e290a80 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 18:56:26 +0900 Subject: [PATCH 03/12] test: split gate lines with shlex to find a bare uv run 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> --- tests/test_lockfile.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index fcfb3aa85..942511b67 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -18,6 +18,7 @@ import importlib.util import re +import shlex import tempfile import tomllib from pathlib import Path @@ -581,13 +582,27 @@ def test_repository_declares_no_uv_configuration_file() -> None: def _bare_uv_runs(text: str) -> list[str]: - """The lines of `text` that run `uv run` without `--frozen`. + """The lines of `text` that run `uv run` without `--frozen` right after it. - Blanks are matched as the shell splits words - any run of spaces or tabs - - and `--frozen` only counts as a whole word. + Each line is split into words the way a POSIX shell splits them - blanks, + quotes, escapes and `#` comments - so a respaced or quoted `uv run` is + still found, a comment is not, and `--frozen` only counts as a word of its + own. A line that does not split is reported rather than skipped. """ - bare_run = re.compile(r"\buv\s+run\b(?!\s+--frozen(?:\s|$))") - return [line.strip() for line in text.splitlines() if bare_run.search(line)] + bare: list[str] = [] + for line in text.splitlines(): + try: + words = shlex.split(line, comments=True) + except ValueError: + bare.append(line.strip()) + continue + if any( + words[index : index + 2] == ["uv", "run"] + and words[index + 2 : index + 3] != ["--frozen"] + for index in range(len(words)) + ): + bare.append(line.strip()) + return bare @pytest.mark.parametrize("name", [".pre-commit-config.yaml", "Makefile"]) @@ -613,6 +628,11 @@ def test_local_gates_never_relock_the_lockfile(name: str) -> None: ("\tuv run mypy --frozen", True), ("\tuv run --frozen-lockfile mypy", True), ("\tuv run --frozen ruff check && uv run ruff format", True), + ('\t"uv" run mypy', True), + ('\tuv "run" mypy', True), + ("\t'uv' run --frozen mypy", False), + ('\tuv run "mypy', True), + ("\t# uv run mypy is what a bare hook would do", False), ], ids=[ "frozen", @@ -624,6 +644,11 @@ def test_local_gates_never_relock_the_lockfile(name: str) -> None: "flag-after-command", "lookalike-flag", "second-command", + "quoted-uv", + "quoted-run", + "quoted-and-frozen", + "unsplittable", + "comment", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: From 902de549c0a057879b9f599741d29c92b3b6df02 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 19:27:32 +0900 Subject: [PATCH 04/12] test: read hook entries the way pre-commit does 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> --- tests/test_lockfile.py | 94 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 942511b67..ff9765e71 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -21,6 +21,7 @@ import shlex import tempfile import tomllib +from collections.abc import Callable from pathlib import Path from urllib.parse import urlsplit @@ -581,8 +582,16 @@ def test_repository_declares_no_uv_configuration_file() -> None: ) +def _runs_uv_unfrozen(words: list[str]) -> bool: + """Whether a split command runs `uv run` without `--frozen` right after it.""" + return any( + words[index : index + 2] == ["uv", "run"] and words[index + 2 : index + 3] != ["--frozen"] + for index in range(len(words)) + ) + + def _bare_uv_runs(text: str) -> list[str]: - """The lines of `text` that run `uv run` without `--frozen` right after it. + """The Makefile recipe lines that run `uv run` without `--frozen`. Each line is split into words the way a POSIX shell splits them - blanks, quotes, escapes and `#` comments - so a respaced or quoted `uv run` is @@ -596,26 +605,93 @@ def _bare_uv_runs(text: str) -> list[str]: except ValueError: bare.append(line.strip()) continue - if any( - words[index : index + 2] == ["uv", "run"] - and words[index + 2 : index + 3] != ["--frozen"] - for index in range(len(words)) - ): + if _runs_uv_unfrozen(words): bare.append(line.strip()) return bare -@pytest.mark.parametrize("name", [".pre-commit-config.yaml", "Makefile"]) -def test_local_gates_never_relock_the_lockfile(name: str) -> None: +def _bare_hook_entries(text: str) -> list[str]: + """The pre-commit hook entries that run `uv run` without `--frozen`. + + pre-commit decodes `entry` as YAML, so quotes there belong to YAML, and + runs the decoded string split with shlex. A `pygrep` entry is a pattern + and a `fail` entry a message, so neither is a command. An entry that does + not split is reported rather than skipped. + """ + bare: list[str] = [] + for repo in yaml.safe_load(text)["repos"]: + for hook in repo["hooks"]: + entry = hook.get("entry") + if entry is None or hook.get("language") in {"pygrep", "fail"}: + continue + try: + words = shlex.split(entry) + except ValueError: + bare.append(entry) + continue + if _runs_uv_unfrozen(words): + bare.append(entry) + return bare + + +@pytest.mark.parametrize( + ("name", "bare_commands"), + [(".pre-commit-config.yaml", _bare_hook_entries), ("Makefile", _bare_uv_runs)], + ids=["pre-commit", "make"], +) +def test_local_gates_never_relock_the_lockfile( + name: str, bare_commands: Callable[[str], list[str]] +) -> None: """A bare `uv run` re-locks when the lock looks stale to it, and behind a package mirror it does: the configured index is not the one the lock records, so every URL is rewritten to the mirror. The hooks and the `make` targets are what a contributor runs before every commit (#466). """ - bare = _bare_uv_runs((_ROOT / name).read_text(encoding="utf-8")) + bare = bare_commands((_ROOT / name).read_text(encoding="utf-8")) assert bare == [], f"{name} runs uv without --frozen, which can re-lock uv.lock" +def _hook_config(entry: str, language: str = "system") -> str: + return ( + "repos:\n" + " - repo: local\n" + " hooks:\n" + " - id: probe\n" + " name: probe\n" + f" language: {language}\n" + f" entry: {entry}\n" + ) + + +@pytest.mark.parametrize( + ("entry", "language", "bare"), + [ + ("uv run --frozen mypy", "system", False), + ('"uv run --frozen mypy"', "system", False), + ("uv run mypy", "system", True), + ('"uv run mypy"', "system", True), + ("'uv run mypy'", "system", True), + (">-\n uv run\n mypy", "system", True), + ("'(url|registry) = \"(?!https://)'", "pygrep", False), + ], + ids=[ + "frozen", + "quoted-frozen", + "bare", + "double-quoted", + "single-quoted", + "folded", + "pygrep-pattern", + ], +) +def test_the_hook_check_reads_entries_as_pre_commit_does( + entry: str, language: str, bare: bool +) -> None: + """pre-commit decodes `entry` as YAML, so its quotes are YAML's, and then + splits the string with shlex; a pygrep entry is a pattern, not a command.""" + assert bool(_bare_hook_entries(_hook_config(entry, language))) is bare + + @pytest.mark.parametrize( ("line", "bare"), [ From e07addeb47d36d7f1b4f6c0a4383964cf4cdcf05 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 19:42:11 +0900 Subject: [PATCH 05/12] test: strip Make's recipe prefixes before splitting a recipe 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> --- tests/test_lockfile.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index ff9765e71..d76af6ba7 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -593,15 +593,16 @@ def _runs_uv_unfrozen(words: list[str]) -> bool: def _bare_uv_runs(text: str) -> list[str]: """The Makefile recipe lines that run `uv run` without `--frozen`. - Each line is split into words the way a POSIX shell splits them - blanks, - quotes, escapes and `#` comments - so a respaced or quoted `uv run` is - still found, a comment is not, and `--frozen` only counts as a word of its - own. A line that does not split is reported rather than skipped. + Make first strips a recipe's `@`, `-` and `+` prefixes; the shell then + splits the rest into words - blanks, quotes, escapes and `#` comments. + So a prefixed, respaced or quoted `uv run` is still found, a comment is + not, and `--frozen` only counts as a word of its own. A line that does + not split is reported rather than skipped. """ bare: list[str] = [] for line in text.splitlines(): try: - words = shlex.split(line, comments=True) + words = shlex.split(re.sub(r"^[\s@+-]*", "", line), comments=True) except ValueError: bare.append(line.strip()) continue @@ -709,6 +710,9 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( ("\t'uv' run --frozen mypy", False), ('\tuv run "mypy', True), ("\t# uv run mypy is what a bare hook would do", False), + ("\t@uv run mypy", True), + ("\t-uv run mypy", True), + ("\t@-uv run --frozen mypy", False), ], ids=[ "frozen", @@ -725,6 +729,9 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( "quoted-and-frozen", "unsplittable", "comment", + "silent-prefix", + "ignore-errors-prefix", + "prefixes-and-frozen", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: From 47b34b918f90281f9402365a803d2d5fb8498e81 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 19:59:58 +0900 Subject: [PATCH 06/12] test: split recipe operators the way the shell does 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> --- tests/test_lockfile.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index d76af6ba7..ed4d8e798 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -594,15 +594,20 @@ def _bare_uv_runs(text: str) -> list[str]: """The Makefile recipe lines that run `uv run` without `--frozen`. Make first strips a recipe's `@`, `-` and `+` prefixes; the shell then - splits the rest into words - blanks, quotes, escapes and `#` comments. - So a prefixed, respaced or quoted `uv run` is still found, a comment is - not, and `--frozen` only counts as a word of its own. A line that does - not split is reported rather than skipped. + splits the rest into words and operators - blanks, quotes, escapes, `#` + comments, and `;`, `&&`, `||` and `|` with or without spaces. So a + prefixed, respaced, quoted or chained `uv run` is still found, a comment + is not, and `--frozen` only counts as a word of its own. A line that does + not split is reported rather than skipped. The check reads each recipe as + a simple command list: it does not expand Make variables or look inside a + nested shell (`sh -c '...'`), so a recipe written that way needs review. """ bare: list[str] = [] for line in text.splitlines(): + lexer = shlex.shlex(re.sub(r"^[\s@+-]*", "", line), posix=True, punctuation_chars=True) + lexer.whitespace_split = True try: - words = shlex.split(re.sub(r"^[\s@+-]*", "", line), comments=True) + words = list(lexer) except ValueError: bare.append(line.strip()) continue @@ -713,6 +718,10 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( ("\t@uv run mypy", True), ("\t-uv run mypy", True), ("\t@-uv run --frozen mypy", False), + ("\ttrue;uv run mypy", True), + ("\ttrue&&uv run mypy", True), + ("\tfalse||uv run mypy", True), + ("\tuv run --frozen mypy|cat", False), ], ids=[ "frozen", @@ -732,6 +741,10 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( "silent-prefix", "ignore-errors-prefix", "prefixes-and-frozen", + "after-semicolon", + "after-and", + "after-or", + "frozen-into-pipe", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: From cc8d3b3a3312a213e8cea37bcfd266fe7ad39894 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 20:17:22 +0900 Subject: [PATCH 07/12] test: allow only `uv run --frozen` in the gates 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> --- tests/test_lockfile.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index ed4d8e798..96e631d2b 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -583,15 +583,21 @@ def test_repository_declares_no_uv_configuration_file() -> None: def _runs_uv_unfrozen(words: list[str]) -> bool: - """Whether a split command runs `uv run` without `--frozen` right after it.""" + """Whether a split command runs uv as anything but `uv run --frozen ...`. + + An allowlist, so it fails closed: a bare `uv run`, a global option before + the subcommand (`uv --quiet run`), or another subcommand (`uv sync`) is + reported for review instead of being interpreted. Every hook and target + is written `uv run --frozen`, so nothing the gates run needs more. + """ return any( - words[index : index + 2] == ["uv", "run"] and words[index + 2 : index + 3] != ["--frozen"] - for index in range(len(words)) + word == "uv" and words[index + 1 : index + 3] != ["run", "--frozen"] + for index, word in enumerate(words) ) def _bare_uv_runs(text: str) -> list[str]: - """The Makefile recipe lines that run `uv run` without `--frozen`. + """The Makefile recipe lines that run uv as anything but `uv run --frozen`. Make first strips a recipe's `@`, `-` and `+` prefixes; the shell then splits the rest into words and operators - blanks, quotes, escapes, `#` @@ -617,7 +623,7 @@ def _bare_uv_runs(text: str) -> list[str]: def _bare_hook_entries(text: str) -> list[str]: - """The pre-commit hook entries that run `uv run` without `--frozen`. + """The pre-commit hook entries that run uv as anything but `uv run --frozen`. pre-commit decodes `entry` as YAML, so quotes there belong to YAML, and runs the decoded string split with shlex. A `pygrep` entry is a pattern @@ -679,6 +685,8 @@ def _hook_config(entry: str, language: str = "system") -> str: ("'uv run mypy'", "system", True), (">-\n uv run\n mypy", "system", True), ("'(url|registry) = \"(?!https://)'", "pygrep", False), + ("uv --quiet run mypy", "system", True), + ("uv --quiet run --frozen mypy", "system", True), ], ids=[ "frozen", @@ -688,6 +696,8 @@ def _hook_config(entry: str, language: str = "system") -> str: "single-quoted", "folded", "pygrep-pattern", + "global-option", + "global-option-then-frozen", ], ) def test_the_hook_check_reads_entries_as_pre_commit_does( @@ -722,6 +732,9 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( ("\ttrue&&uv run mypy", True), ("\tfalse||uv run mypy", True), ("\tuv run --frozen mypy|cat", False), + ("\tuv --quiet run mypy", True), + ("\tuv --quiet run --frozen mypy", True), + ("\tuv sync", True), ], ids=[ "frozen", @@ -745,6 +758,9 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( "after-and", "after-or", "frozen-into-pipe", + "global-option", + "global-option-then-frozen", + "other-subcommand", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: From e433d9ee58b69812376b21820b4a2447f21b2da0 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 20:22:16 +0900 Subject: [PATCH 08/12] test: recognise a path-qualified uv in the gate allowlist 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> --- tests/test_lockfile.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 96e631d2b..2fbabe791 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -587,11 +587,12 @@ def _runs_uv_unfrozen(words: list[str]) -> bool: An allowlist, so it fails closed: a bare `uv run`, a global option before the subcommand (`uv --quiet run`), or another subcommand (`uv sync`) is - reported for review instead of being interpreted. Every hook and target - is written `uv run --frozen`, so nothing the gates run needs more. + reported for review instead of being interpreted. `uv` is matched by its + last path component, so `/usr/local/bin/uv` counts too. Every hook and + target is written `uv run --frozen`, so nothing the gates run needs more. """ return any( - word == "uv" and words[index + 1 : index + 3] != ["run", "--frozen"] + word.rsplit("/", 1)[-1] == "uv" and words[index + 1 : index + 3] != ["run", "--frozen"] for index, word in enumerate(words) ) @@ -735,6 +736,8 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( ("\tuv --quiet run mypy", True), ("\tuv --quiet run --frozen mypy", True), ("\tuv sync", True), + ("\t/usr/local/bin/uv run mypy", True), + ("\t$(HOME)/.local/bin/uv run --frozen mypy", False), ], ids=[ "frozen", @@ -761,6 +764,8 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( "global-option", "global-option-then-frozen", "other-subcommand", + "path-qualified", + "path-qualified-and-frozen", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: From f0c486a83089c357518bf567582814c4d198311f Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 22:10:49 +0900 Subject: [PATCH 09/12] test: start a recipe comment only where a word starts 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> --- tests/test_lockfile.py | 49 +++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 2fbabe791..5a5b44bcb 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -597,22 +597,49 @@ def _runs_uv_unfrozen(words: list[str]) -> bool: ) +def _without_shell_comment(line: str) -> str: + """`line` up to its shell comment: a `#` that starts a word, outside quotes. + + shlex ends a word at any `#`, but a POSIX shell starts a comment only where + a word could start, so `foo#bar; uv run mypy` runs both commands. + """ + quote = "" + escaped = False + previous = " " + for index, char in enumerate(line): + if escaped: + escaped = False + elif char == "\\" and quote != "'": + escaped = True + elif quote: + quote = "" if char == quote else quote + elif char in "'\"": + quote = char + elif char == "#" and (previous.isspace() or previous in ";&|()<>"): + return line[:index] + previous = char + return line + + def _bare_uv_runs(text: str) -> list[str]: """The Makefile recipe lines that run uv as anything but `uv run --frozen`. Make first strips a recipe's `@`, `-` and `+` prefixes; the shell then - splits the rest into words and operators - blanks, quotes, escapes, `#` - comments, and `;`, `&&`, `||` and `|` with or without spaces. So a - prefixed, respaced, quoted or chained `uv run` is still found, a comment - is not, and `--frozen` only counts as a word of its own. A line that does - not split is reported rather than skipped. The check reads each recipe as - a simple command list: it does not expand Make variables or look inside a - nested shell (`sh -c '...'`), so a recipe written that way needs review. + splits the rest into words and operators - blanks, quotes, escapes, a `#` + comment where a word starts, and `;`, `&&`, `||` and `|` with or without + spaces. So a prefixed, respaced, quoted or chained `uv run` is still + found, a comment is not, and `--frozen` only counts as a word of its own. + A line that does not split is reported rather than skipped. The check + reads each recipe as a simple command list: it does not expand Make + variables or look inside a nested shell (`sh -c '...'`), so a recipe + written that way needs review. """ bare: list[str] = [] for line in text.splitlines(): - lexer = shlex.shlex(re.sub(r"^[\s@+-]*", "", line), posix=True, punctuation_chars=True) + recipe = _without_shell_comment(re.sub(r"^[\s@+-]*", "", line)) + lexer = shlex.shlex(recipe, posix=True, punctuation_chars=True) lexer.whitespace_split = True + lexer.commenters = "" try: words = list(lexer) except ValueError: @@ -738,6 +765,9 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( ("\tuv sync", True), ("\t/usr/local/bin/uv run mypy", True), ("\t$(HOME)/.local/bin/uv run --frozen mypy", False), + ("\techo foo#bar; uv run mypy", True), + ('\techo "#x"; uv run mypy', True), + ("\tuv run --frozen mypy # uv run mypy", False), ], ids=[ "frozen", @@ -766,6 +796,9 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( "other-subcommand", "path-qualified", "path-qualified-and-frozen", + "hash-inside-word", + "quoted-hash", + "trailing-comment", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: From 983056eb79baf34a09ffb45c3bce792d89dfe8f1 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 22:40:49 +0900 Subject: [PATCH 10/12] test: let an escaped blank continue the word before a # _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> --- tests/test_lockfile.py | 67 ++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 5a5b44bcb..a287fed6b 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -601,23 +601,25 @@ def _without_shell_comment(line: str) -> str: """`line` up to its shell comment: a `#` that starts a word, outside quotes. shlex ends a word at any `#`, but a POSIX shell starts a comment only where - a word could start, so `foo#bar; uv run mypy` runs both commands. + a word could start - after an unescaped blank or operator - so + `foo#bar; uv run mypy` and `foo\\ #bar; uv run mypy` both run `uv run`. """ quote = "" escaped = False - previous = " " + word_can_start = True for index, char in enumerate(line): - if escaped: - escaped = False - elif char == "\\" and quote != "'": - escaped = True - elif quote: - quote = "" if char == quote else quote - elif char in "'\"": - quote = char - elif char == "#" and (previous.isspace() or previous in ";&|()<>"): + if escaped or quote: + # An escaped character, or one inside quotes, continues the word. + if quote and not escaped and char == quote: + quote = "" + escaped = char == "\\" and quote == '"' and not escaped + word_can_start = False + elif char == "#" and word_can_start: return line[:index] - previous = char + else: + escaped = char == "\\" + quote = char if char in "'\"" else "" + word_can_start = char.isspace() or char in ";&|()<>" return line @@ -630,9 +632,11 @@ def _bare_uv_runs(text: str) -> list[str]: spaces. So a prefixed, respaced, quoted or chained `uv run` is still found, a comment is not, and `--frozen` only counts as a word of its own. A line that does not split is reported rather than skipped. The check - reads each recipe as a simple command list: it does not expand Make - variables or look inside a nested shell (`sh -c '...'`), so a recipe - written that way needs review. + reads each recipe as a simple command list: it catches the ways a bare + `uv run` gets written by accident, not a deliberately obscured one, so it + does not expand Make variables, look inside a nested shell (`sh -c + '...'`), or reassemble a split word (`u""v`). A recipe written that way + needs review. """ bare: list[str] = [] for line in text.splitlines(): @@ -691,6 +695,35 @@ def test_local_gates_never_relock_the_lockfile( assert bare == [], f"{name} runs uv without --frozen, which can re-lock uv.lock" +@pytest.mark.parametrize( + ("line", "kept"), + [ + ("# all of it", ""), + ("uv run --frozen mypy # note", "uv run --frozen mypy "), + ("true;# note", "true;"), + ("echo foo#bar", "echo foo#bar"), + ("echo foo\\ #bar", "echo foo\\ #bar"), + ('echo "a\\"#b" # note', 'echo "a\\"#b" '), + ("echo 'a\\'#b", "echo 'a\\'#b"), + ('echo "a"#b', 'echo "a"#b'), + ("echo a\\\\#b", "echo a\\\\#b"), + ], + ids=[ + "whole-line", + "after-blank", + "after-operator", + "inside-word", + "after-escaped-blank", + "after-escaped-double-quote", + "single-quotes-take-no-escape", + "after-closing-quote", + "after-escaped-backslash", + ], +) +def test_a_recipe_comment_starts_only_where_a_word_can(line: str, kept: str) -> None: + assert _without_shell_comment(line) == kept + + def _hook_config(entry: str, language: str = "system") -> str: return ( "repos:\n" @@ -768,6 +801,8 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( ("\techo foo#bar; uv run mypy", True), ('\techo "#x"; uv run mypy', True), ("\tuv run --frozen mypy # uv run mypy", False), + ("\techo foo\\ #bar; uv run mypy", True), + ("\ttrue;# uv run mypy", False), ], ids=[ "frozen", @@ -799,6 +834,8 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( "hash-inside-word", "quoted-hash", "trailing-comment", + "hash-after-escaped-blank", + "comment-after-operator", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: From 787fffa5c7e84d26e986caf018882d0c402f8114 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 23:04:53 +0900 Subject: [PATCH 11/12] test: skip only whole-line comments in the recipe check 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> --- tests/test_lockfile.py | 92 ++++++++++-------------------------------- 1 file changed, 22 insertions(+), 70 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index a287fed6b..2bf4025ea 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -597,50 +597,29 @@ def _runs_uv_unfrozen(words: list[str]) -> bool: ) -def _without_shell_comment(line: str) -> str: - """`line` up to its shell comment: a `#` that starts a word, outside quotes. - - shlex ends a word at any `#`, but a POSIX shell starts a comment only where - a word could start - after an unescaped blank or operator - so - `foo#bar; uv run mypy` and `foo\\ #bar; uv run mypy` both run `uv run`. - """ - quote = "" - escaped = False - word_can_start = True - for index, char in enumerate(line): - if escaped or quote: - # An escaped character, or one inside quotes, continues the word. - if quote and not escaped and char == quote: - quote = "" - escaped = char == "\\" and quote == '"' and not escaped - word_can_start = False - elif char == "#" and word_can_start: - return line[:index] - else: - escaped = char == "\\" - quote = char if char in "'\"" else "" - word_can_start = char.isspace() or char in ";&|()<>" - return line - - def _bare_uv_runs(text: str) -> list[str]: """The Makefile recipe lines that run uv as anything but `uv run --frozen`. Make first strips a recipe's `@`, `-` and `+` prefixes; the shell then - splits the rest into words and operators - blanks, quotes, escapes, a `#` - comment where a word starts, and `;`, `&&`, `||` and `|` with or without - spaces. So a prefixed, respaced, quoted or chained `uv run` is still - found, a comment is not, and `--frozen` only counts as a word of its own. - A line that does not split is reported rather than skipped. The check - reads each recipe as a simple command list: it catches the ways a bare - `uv run` gets written by accident, not a deliberately obscured one, so it - does not expand Make variables, look inside a nested shell (`sh -c - '...'`), or reassemble a split word (`u""v`). A recipe written that way - needs review. + splits the rest into words and operators - blanks, quotes, escapes, and + `;`, `&&`, `||` and `|` with or without spaces. So a prefixed, respaced, + quoted or chained `uv run` is still found, and `--frozen` only counts as + a word of its own. Only a whole-line comment is skipped: anywhere else a + `#` is read as code, because where a shell comment starts depends on + substitutions and quoting this check does not model, and guessing wrong + would hide the command after it. A trailing comment that mentions uv is + therefore reported; keep such comments on their own line. A line that + does not split is reported rather than skipped. The check catches the + ways a bare `uv run` gets written by accident, not a deliberately + obscured one, so it does not expand Make variables, look inside a nested + shell (`sh -c '...'`), or reassemble a split word (`u""v`). A recipe + written that way needs review. """ bare: list[str] = [] for line in text.splitlines(): - recipe = _without_shell_comment(re.sub(r"^[\s@+-]*", "", line)) + recipe = re.sub(r"^[\s@+-]*", "", line) + if recipe.startswith("#"): + continue lexer = shlex.shlex(recipe, posix=True, punctuation_chars=True) lexer.whitespace_split = True lexer.commenters = "" @@ -695,35 +674,6 @@ def test_local_gates_never_relock_the_lockfile( assert bare == [], f"{name} runs uv without --frozen, which can re-lock uv.lock" -@pytest.mark.parametrize( - ("line", "kept"), - [ - ("# all of it", ""), - ("uv run --frozen mypy # note", "uv run --frozen mypy "), - ("true;# note", "true;"), - ("echo foo#bar", "echo foo#bar"), - ("echo foo\\ #bar", "echo foo\\ #bar"), - ('echo "a\\"#b" # note', 'echo "a\\"#b" '), - ("echo 'a\\'#b", "echo 'a\\'#b"), - ('echo "a"#b', 'echo "a"#b'), - ("echo a\\\\#b", "echo a\\\\#b"), - ], - ids=[ - "whole-line", - "after-blank", - "after-operator", - "inside-word", - "after-escaped-blank", - "after-escaped-double-quote", - "single-quotes-take-no-escape", - "after-closing-quote", - "after-escaped-backslash", - ], -) -def test_a_recipe_comment_starts_only_where_a_word_can(line: str, kept: str) -> None: - assert _without_shell_comment(line) == kept - - def _hook_config(entry: str, language: str = "system") -> str: return ( "repos:\n" @@ -800,9 +750,10 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( ("\t$(HOME)/.local/bin/uv run --frozen mypy", False), ("\techo foo#bar; uv run mypy", True), ('\techo "#x"; uv run mypy', True), - ("\tuv run --frozen mypy # uv run mypy", False), + ("\tuv run --frozen mypy # uv run mypy", True), ("\techo foo\\ #bar; uv run mypy", True), - ("\ttrue;# uv run mypy", False), + ("\ttrue;# uv run mypy", True), + ("\techo $(date)#tag; uv run mypy", True), ], ids=[ "frozen", @@ -833,9 +784,10 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( "path-qualified-and-frozen", "hash-inside-word", "quoted-hash", - "trailing-comment", + "trailing-comment-is-read", "hash-after-escaped-blank", - "comment-after-operator", + "comment-after-operator-is-read", + "substitution-then-hash", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: From 5c66b7c2625b82cef3729f4f9941805e035564a7 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 23:14:20 +0900 Subject: [PATCH 12/12] test: split backtick substitution in the recipe check `$(...)` 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> --- tests/test_lockfile.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 2bf4025ea..98ea7dda0 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -602,9 +602,9 @@ def _bare_uv_runs(text: str) -> list[str]: Make first strips a recipe's `@`, `-` and `+` prefixes; the shell then splits the rest into words and operators - blanks, quotes, escapes, and - `;`, `&&`, `||` and `|` with or without spaces. So a prefixed, respaced, - quoted or chained `uv run` is still found, and `--frozen` only counts as - a word of its own. Only a whole-line comment is skipped: anywhere else a + `;`, `&&`, `||`, `|`, `$(` and backticks with or without spaces. So a + prefixed, respaced, quoted, chained or substituted `uv run` is still + found, and `--frozen` only counts as a word of its own. Only a whole-line comment is skipped: anywhere else a `#` is read as code, because where a shell comment starts depends on substitutions and quoting this check does not model, and guessing wrong would hide the command after it. A trailing comment that mentions uv is @@ -620,7 +620,7 @@ def _bare_uv_runs(text: str) -> list[str]: recipe = re.sub(r"^[\s@+-]*", "", line) if recipe.startswith("#"): continue - lexer = shlex.shlex(recipe, posix=True, punctuation_chars=True) + lexer = shlex.shlex(recipe, posix=True, punctuation_chars="();<>|&`") lexer.whitespace_split = True lexer.commenters = "" try: @@ -754,6 +754,8 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( ("\techo foo\\ #bar; uv run mypy", True), ("\ttrue;# uv run mypy", True), ("\techo $(date)#tag; uv run mypy", True), + ("\techo `uv run mypy`", True), + ("\techo `uv run --frozen mypy`", False), ], ids=[ "frozen", @@ -788,6 +790,8 @@ def test_the_hook_check_reads_entries_as_pre_commit_does( "hash-after-escaped-blank", "comment-after-operator-is-read", "substitution-then-hash", + "backtick-substitution", + "backtick-substitution-frozen", ], ) def test_the_bare_uv_run_check_reads_commands_as_the_shell_does(line: str, bare: bool) -> None: