diff --git a/internals/checks/e2e-attestations/claude-tackle-1037-checks16.json b/internals/checks/e2e-attestations/claude-tackle-1037-checks16.json new file mode 100644 index 000000000..17117697a --- /dev/null +++ b/internals/checks/e2e-attestations/claude-tackle-1037-checks16.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e -x -q", + "ran_at": 1788558615, + "exit_code": 0, + "commit": "7ec541e56dcc16b9d569c7d0f34dbe0e1d2211cc", + "branch": "claude/tackle-1037-checks16" +} diff --git a/internals/checks/src/checks/preflight/cli.py b/internals/checks/src/checks/preflight/cli.py index 4d0a8a330..4d2f2e1e8 100644 --- a/internals/checks/src/checks/preflight/cli.py +++ b/internals/checks/src/checks/preflight/cli.py @@ -11,8 +11,9 @@ import click +from .discovery import sources from .gate import default_runner, read_e2e, run -from .matrix import NoGateMatrix, WORKFLOWS, sources +from .matrix import NoGateMatrix, WORKFLOWS @click.command() diff --git a/internals/checks/src/checks/preflight/cli_test.py b/internals/checks/src/checks/preflight/cli_test.py index 4103282bc..8ff174c42 100644 --- a/internals/checks/src/checks/preflight/cli_test.py +++ b/internals/checks/src/checks/preflight/cli_test.py @@ -1,6 +1,7 @@ """Colocated unit tests for the preflight command (isolation -- no `CliRunner`). -Driven through `.callback`; `sources` and `run` are mocked at their import site. +Driven through `.callback`; `sources` (from `.discovery`) and `run` (from `.gate`) +are mocked at their import site. """ from unittest import mock diff --git a/internals/checks/src/checks/preflight/discovery.py b/internals/checks/src/checks/preflight/discovery.py new file mode 100644 index 000000000..70e8f4a95 --- /dev/null +++ b/internals/checks/src/checks/preflight/discovery.py @@ -0,0 +1,61 @@ +"""Find the workflows the gate matrix is derived from (#781). + +Which workflows hold the reusable-workflow callers is itself discovered rather +than named. The matrix used to come from `conventions.yml` alone; #834 split it +into six per-domain workflows and deleted it, so the named default resolved to +nothing and `just preflight` died on a bare `FileNotFoundError` (#973). A list of +six names would only move the same failure one rename away. +""" + +from __future__ import annotations + +import os +from collections.abc import Callable, Sequence + +from .matrix import NoGateMatrix, REUSABLE, WORKFLOWS, named, parse_gate_matrix, read_text + + +def discovered( + directory: str, + listdir: Callable[[str], list[str]], + read: Callable[[str], str], +) -> list[tuple[str, str]]: + """(path, text) for every workflow in `directory` holding a caller.""" + try: + names = sorted(listdir(directory)) + except OSError as err: + raise NoGateMatrix( + f"no {directory} directory here. Run preflight from the repo root, or " + "name a workflow with --conventions." + ) from err + found = [] + for name in names: + if not name.endswith((".yml", ".yaml")): + continue + path = f"{directory}/{name}" + text = read(path) + if parse_gate_matrix(text): + found.append((path, text)) + if not found: + raise NoGateMatrix( + f"no workflow in {directory} calls {REUSABLE}, so there is no gate matrix " + "to run. If the reusable workflow moved, update REUSABLE in " + "internals/checks/src/checks/preflight/matrix.py; otherwise name the " + "workflow with --conventions." + ) + return found + + +def sources( + conventions: Sequence[str], + *, + directory: str = WORKFLOWS, + listdir: Callable[[str], list[str]] = os.listdir, + read: Callable[[str], str] = read_text, +) -> list[tuple[str, str]]: + """(path, text) for the workflows the gate matrix is derived from.""" + if conventions: + return [(path, named(path, read)) for path in conventions] + return discovered(directory, listdir, read) + + diff --git a/internals/checks/src/checks/preflight/discovery_test.py b/internals/checks/src/checks/preflight/discovery_test.py new file mode 100644 index 000000000..c15953841 --- /dev/null +++ b/internals/checks/src/checks/preflight/discovery_test.py @@ -0,0 +1,110 @@ +"""Colocated unit tests for workflow discovery (#781).""" + +import pytest + +from checks.preflight.discovery import NoGateMatrix, discovered, sources + +CONVENTIONS = """ +jobs: + python-sdk: + uses: x/y/.github/workflows/testing-conventions.yml@v0 + with: + languages: '["python"]' + source: packages/python/dirsql + config: testing-conventions.toml +""" +NOT_A_CALLER = "jobs:\n build:\n runs-on: ubuntu-latest\n" + + +def reader(files): + """A `read` that only knows the files it was given -- anything else raises.""" + return lambda path: files[path] + + +def describe_discovered(): + FILES = { + ".github/workflows/b-ci.yml": CONVENTIONS, + ".github/workflows/a-ci.yaml": CONVENTIONS, + ".github/workflows/docs.yml": NOT_A_CALLER, + } + + def it_returns_every_caller_it_finds_in_sorted_order(): + found = discovered( + ".github/workflows", + lambda _directory: ["b-ci.yml", "docs.yml", "a-ci.yaml"], + reader(FILES), + ) + + assert found == [ + (".github/workflows/a-ci.yaml", CONVENTIONS), + (".github/workflows/b-ci.yml", CONVENTIONS), + ] + + def it_never_reads_a_file_that_is_not_a_workflow(): + # `reader` raises for anything it was not given, so a README that got + # opened fails here rather than passing quietly. + found = discovered( + ".github/workflows", + lambda _directory: ["README.md", "b-ci.yml"], + reader(FILES), + ) + + assert [path for path, _text in found] == [".github/workflows/b-ci.yml"] + + def it_names_the_directory_and_the_fix_when_it_does_not_exist(): + def listdir(directory): + raise FileNotFoundError(2, "No such file or directory", directory) + + with pytest.raises(NoGateMatrix) as caught: + discovered(".github/workflows", listdir, reader(FILES)) + + assert "no .github/workflows directory here" in str(caught.value) + assert "--conventions" in str(caught.value) + + def it_fails_when_no_workflow_in_the_directory_calls_the_reusable_workflow(): + with pytest.raises(NoGateMatrix) as caught: + discovered(".github/workflows", lambda _directory: ["docs.yml"], reader(FILES)) + + message = str(caught.value) + assert "no workflow in .github/workflows calls" in message + # The fix names where REUSABLE lives, which is matrix.py, not this module. + assert "preflight/matrix.py" in message + + +def describe_sources(): + def it_reads_each_named_workflow_in_the_order_given(): + files = {"b.yml": CONVENTIONS, "a.yml": CONVENTIONS} + + assert sources(["b.yml", "a.yml"], read=reader(files)) == [ + ("b.yml", CONVENTIONS), + ("a.yml", CONVENTIONS), + ] + + def it_discovers_the_callers_when_no_workflow_is_named(): + found = sources( + (), + directory="wf", + listdir=lambda _directory: ["ci.yml"], + read=reader({"wf/ci.yml": CONVENTIONS}), + ) + + assert found == [("wf/ci.yml", CONVENTIONS)] + + def it_takes_its_seams_by_keyword_only(): + # `*` (not `/`) before the injected seams: a third positional argument + # would otherwise land silently on `listdir`. + with pytest.raises(TypeError): + sources((), "wf", lambda _directory: ["ci.yml"], reader({"wf/ci.yml": CONVENTIONS})) + + def it_discovers_from_the_workflows_directory_by_default(): + # The default is the whole point: #834 deleted the workflow the command + # used to name, and nothing pinned where it looks instead. + looked = [] + + sources( + (), + listdir=lambda directory: looked.append(directory) or ["ci.yml"], + read=reader({".github/workflows/ci.yml": CONVENTIONS}), + ) + + assert looked == [".github/workflows"] diff --git a/internals/checks/src/checks/preflight/gate.py b/internals/checks/src/checks/preflight/gate.py index 5b9e12c47..7f0c89091 100644 --- a/internals/checks/src/checks/preflight/gate.py +++ b/internals/checks/src/checks/preflight/gate.py @@ -7,21 +7,7 @@ Before the gates, each python root gets two drift guards (#782): `uv sync`, which reconciles the venv with the manifest, and `declared-deps`, which asserts every -import is declared. A `uv pip install` leaves the local venv strictly more capable -than any real install, and no amount of running more gates catches that. - -Three pairs cannot be run the naive way, and each is encoded rather than skipped: - - * python `mutation` / `unit-coverage` -- these execute the suite, whose - `python3 -m pytest` must resolve to the package's own venv rather than an - ambient interpreter with no pytest (#706 saw it as a cosmic-ray baseline - failure naming no cause). Both run under `uv run` from the package root. - * rust `colocated-test` -- the co-change variant rides only the - python/typescript language set (rust units are inline, so no sibling can go - stale), and the CLI rejects `--base` for it. - * `e2e-verify` -- its PATH is the package root holding `e2e-attestations/`, - with the source dir as `--scope`; the `[e2e]` config table it needs is - passed as `--extra-scope` / `--exclude` flags, since it takes no `--config`. +import is declared. """ from __future__ import annotations @@ -30,96 +16,10 @@ import subprocess import tomllib from collections.abc import Callable, Sequence -from dataclasses import dataclass - -from .matrix import GATES, Root, pairs, parse_gate_matrix - -MANIFESTS = ("pyproject.toml", "package.json", "Cargo.toml") - -# The CLI, resolved the way CI resolves it. Two silent drifts made the local run -# enforce a different ruleset, and naming the tag closes both: the PyPI wheel -# `uvx` fetches lags npm by several releases, and a BARE `npx testing-conventions` -# resolves engine-aware, so a shim on node older than `engines.node` picks the -# last release below that floor -- and exits 0 with no banner to say so. -CLI = ["npx", "-y", "testing-conventions@latest"] - -# How to launch a suite-executing gate, per language. Both run from the package -# root: python so the suite's `python3 -m pytest` resolves to that package's -# venv, typescript because only the npm CLI appends `--ts-mutation-adapter`, -# which the rule needs. Rust is absent -- it has no suite environment to enter. -# -# The python arm needs BOTH distributions. `--with` is not how the CLI is -# resolved; it puts the wheel's `testing_conventions` package on the interpreter -# the gate hands to `python3 -m`, which is the only form the mutation adapter -# ships in. -LAUNCHERS = { - "python": ["uv", "run", "--with", "testing-conventions", *CLI], - "typescript": CLI, -} - - -@dataclass -class Invocation: - argv: list[str] - # "." rather than None for the repo root: `subprocess.run(cwd=".")` is the - # same call, and it keeps every path in this module a plain str. - cwd: str - - -def package_root(source: str, exists: Callable[[str], bool]) -> str: - """Nearest ancestor of `source` (inclusive) holding a package manifest.""" - parts = source.split("/") - while parts: - candidate = "/".join(parts) - if any(exists(f"{candidate}/{name}") for name in MANIFESTS): - return candidate - parts.pop() - return "." - -def e2e_flags(e2e: dict) -> list[str]: - flags = [] - for scope in e2e.get("extra_scope", []): - flags += ["--extra-scope", scope] - for path in e2e.get("exclude", []): - flags += ["--exclude", path] - return flags - - -def invocation( - root: Root, - language: str, - gate_name: str, - base: str, - exists: Callable[[str], bool], - e2e: dict, -) -> Invocation: - gate = GATES[gate_name] - options = [] - if gate.language: - options += ["--language", language] - if gate.base and (gate_name, language) != ("colocated-test", "rust"): - options += ["--base", base] - if gate.config and root.config: - options += ["--config", root.config] - - if gate_name == "e2e-verify": - home = package_root(root.source, exists) - return Invocation( - [ - *[*CLI, *gate.command, *options], - *["--scope", root.source, *e2e_flags(e2e), home], - ], - ".", - ) - if gate.runs_suite and language in LAUNCHERS: - # Paths become relative to the package root we run from, not the repo. - cwd = package_root(root.source, exists) - if gate.config and root.config: - options[-1] = os.path.relpath(root.config, cwd) - source = os.path.relpath(root.source, cwd) - return Invocation([*LAUNCHERS[language], *gate.command, *options, source], cwd) - return Invocation([*CLI, *gate.command, *options, root.source], ".") +from .invocation import Invocation, invocation +from .matrix import GATES, pairs, parse_gate_matrix +from .prepare import prepare def default_runner(argv: Sequence[str], cwd: str) -> int: @@ -134,36 +34,6 @@ def read_e2e(config: str) -> dict: return tomllib.load(handle).get("e2e", {}) -def prepare(roots: list[Root], exists: Callable[[str], bool]) -> list[tuple[str, str, Invocation]]: - """Per-python-root steps that guard against venv drift (#782). - - `uv sync` reconciles the venv with the manifest, *removing* anything a - `uv pip install` left behind, so an undeclared dependency stops resolving - locally the way it never resolved in CI. `declared-deps` is the direct - assertion, independent of whatever the venv happens to hold. - """ - steps = [] - for root in roots: - if "python" not in root.languages: - continue - home = package_root(root.source, exists) - steps.append((root.job, "uv-sync", Invocation(["uv", "sync", "--project", home], "."))) - steps.append( - ( - root.job, - "declared-deps", - Invocation( - [ - *["uv", "run", "--project", "internals/checks", "dirsql-checks"], - *["declared-deps", root.source], - ], - ".", - ), - ) - ) - return steps - - def run( workflows: Sequence[str], base: str, diff --git a/internals/checks/src/checks/preflight/gate_test.py b/internals/checks/src/checks/preflight/gate_test.py index 81b95323e..62e828879 100644 --- a/internals/checks/src/checks/preflight/gate_test.py +++ b/internals/checks/src/checks/preflight/gate_test.py @@ -2,37 +2,8 @@ from unittest import mock -from checks.preflight.gate import ( - Invocation, - prepare, - default_runner, - e2e_flags, - invocation, - package_root, - read_e2e, - run, -) +from checks.preflight.gate import default_runner, read_e2e, run - -class Root: - """Stand-in for `matrix.Root` -- a value record, faked rather than imported.""" - - def __init__(self, job, source, languages, gates, config=None): - self.job = job - self.source = source - self.languages = languages - self.gates = gates - self.config = config - - -PY = Root( - job="python-sdk", - source="packages/python/dirsql", - languages=["python"], - gates=["unit-lint", "mutation"], - config="testing-conventions.toml", -) -RUST = Root(job="rust", source="packages/rust", languages=["rust"], gates=["packaging"]) CONVENTIONS = """ jobs: python-sdk: @@ -51,117 +22,6 @@ def has_manifest(path: str) -> bool: return path == "packages/python/pyproject.toml" -def call(root, language, gate, e2e=None): - return invocation(root, language, gate, "origin/main", has_manifest, e2e or {}) - - -def describe_package_root(): - def it_walks_up_to_the_nearest_manifest(): - assert package_root("packages/python/dirsql", has_manifest) == "packages/python" - - def it_returns_the_source_itself_when_it_holds_the_manifest(): - assert package_root("packages/python", has_manifest) == "packages/python" - - def it_falls_back_to_the_repo_root_when_no_ancestor_has_one(): - assert package_root("packages/rust", lambda _path: False) == "." - - -def describe_e2e_flags(): - def it_maps_extra_scope_and_exclude_onto_repeatable_flags(): - assert e2e_flags({"extra_scope": ["a", "b"], "exclude": ["a/cli"]}) == [ - *["--extra-scope", "a", "--extra-scope", "b"], - *["--exclude", "a/cli"], - ] - - def it_returns_nothing_for_an_absent_table(): - assert e2e_flags({}) == [] - - -def describe_invocation(): - def it_runs_an_ordinary_gate_through_the_npm_cli_from_the_repo_root(): - assert call(PY, "python", "unit-lint") == Invocation( - [ - *["npx", "-y", "testing-conventions@latest", "unit", "lint"], - *["--language", "python"], - *["--config", "testing-conventions.toml", "packages/python/dirsql"], - ], - ".", - ) - - def it_takes_the_ordinary_path_for_a_gate_name_sorting_below_e2e_verify(): - # Both paths start with the same CLI prefix, so `--scope` is what tells - # them apart -- an `<=` here would route colocated-test into e2e's branch. - assert call(PY, "python", "colocated-test").argv == [ - *["npx", "-y", "testing-conventions@latest", "unit", "colocated-test"], - *["--language", "python", "--base", "origin/main"], - *["--config", "testing-conventions.toml", "packages/python/dirsql"], - ] - - def it_omits_base_for_a_whole_tree_gate_that_does_not_accept_it(): - assert "--base" not in call(PY, "python", "unit-lint").argv - - def it_passes_base_to_a_diff_scoped_gate(): - assert "--base" in call(PY, "python", "colocated-test").argv - - def it_omits_base_for_rust_colocated_test_which_the_cli_rejects(): - # Rust units are inline, so the co-change variant has no sibling test - # that could go stale and the CLI errors on `--base --language rust`. - assert "--base" not in call(RUST, "rust", "colocated-test").argv - - def it_still_passes_base_for_rust_mutation(): - assert "--base" in call(RUST, "rust", "mutation").argv - - def it_omits_config_for_a_root_that_declares_none(): - assert "--config" not in call(RUST, "rust", "unit-lint").argv - - def it_targets_the_package_root_for_e2e_verify_scoped_to_the_source(): - assert call(PY, "python", "e2e-verify", {"extra_scope": ["packages/rust/src"]}) == Invocation( - [ - *["npx", "-y", "testing-conventions@latest", "e2e", "verify"], - *["--base", "origin/main"], - *["--scope", "packages/python/dirsql"], - *["--extra-scope", "packages/rust/src", "packages/python"], - ], - ".", - ) - - def it_omits_language_for_e2e_verify_which_does_not_accept_it(): - assert "--language" not in call(PY, "python", "e2e-verify").argv - - def it_runs_python_mutation_through_the_packages_own_venv(): - mutation = call(PY, "python", "mutation") - assert mutation.cwd == "packages/python" - assert mutation.argv[:7] == [ - *["uv", "run", "--with", "testing-conventions"], - *["npx", "-y", "testing-conventions@latest"], - ] - - def it_rewrites_the_config_and_source_paths_relative_to_that_cwd(): - assert call(PY, "python", "mutation").argv[-3:] == [ - *["--config", "../../testing-conventions.toml", "dirsql"] - ] - - def it_passes_dot_as_the_source_when_the_package_root_is_the_source(): - root = Root(job="p", source="packages/python", languages=["python"], gates=["mutation"]) - assert call(root, "python", "mutation").argv[-1] == "." - - def it_leaves_the_config_alone_when_a_mutation_root_declares_none(): - root = Root(job="p", source="packages/python", languages=["python"], gates=["mutation"]) - assert "--config" not in call(root, "python", "mutation").argv - - def it_runs_python_unit_coverage_through_that_venv_too(): - assert call(PY, "python", "unit-coverage").cwd == "packages/python" - - def it_runs_a_typescript_suite_gate_through_npx_from_the_package_root(): - root = Root(job="ts", source="packages/ts/src", languages=["typescript"], gates=["mutation"]) - mutation = call(root, "typescript", "mutation") - assert mutation.argv[:3] == ["npx", "-y", "testing-conventions@latest"] - assert (mutation.cwd, mutation.argv[-1]) == (".", "packages/ts/src") - - def it_keeps_a_rust_mutation_gate_on_the_default_cli(): - assert call(RUST, "rust", "mutation").cwd == "." - - def describe_read_e2e(): def it_returns_the_e2e_table_of_the_roots_config(): with mock.patch("checks.preflight.gate.os.path.exists", return_value=True): @@ -190,29 +50,6 @@ def it_returns_the_subprocess_return_code(): subprocess_run.assert_called_once_with(["x"], cwd="dir", check=False) -def describe_prepare(): - def it_syncs_and_checks_declared_deps_for_each_python_root(): - assert [(job, step, call.argv) for job, step, call in prepare([PY], has_manifest)] == [ - ("python-sdk", "uv-sync", ["uv", "sync", "--project", "packages/python"]), - ( - "python-sdk", - "declared-deps", - [ - *["uv", "run", "--project", "internals/checks", "dirsql-checks"], - *["declared-deps", "packages/python/dirsql"], - ], - ), - ] - - def it_skips_a_root_with_no_python(): - assert prepare([RUST], has_manifest) == [] - - def it_keeps_going_past_a_non_python_root_to_the_ones_after_it(): - assert [job for job, _step, _call in prepare([RUST, PY], has_manifest)] == [ - *["python-sdk", "python-sdk"] - ] - - def drive(workflows=None, **kwargs): defaults = { "runner": lambda _argv, _cwd: 0, diff --git a/internals/checks/src/checks/preflight/invocation.py b/internals/checks/src/checks/preflight/invocation.py new file mode 100644 index 000000000..f154b4a57 --- /dev/null +++ b/internals/checks/src/checks/preflight/invocation.py @@ -0,0 +1,110 @@ +"""Build the argv and cwd for one (root, language, gate) pair (#781). + +Three pairs cannot be run the naive way, and each is encoded rather than skipped: + + * python `mutation` / `unit-coverage` -- these execute the suite, whose + `python3 -m pytest` must resolve to the package's own venv rather than an + ambient interpreter with no pytest (#706 saw it as a cosmic-ray baseline + failure naming no cause). Both run under `uv run` from the package root. + * rust `colocated-test` -- the co-change variant rides only the + python/typescript language set (rust units are inline, so no sibling can go + stale), and the CLI rejects `--base` for it. + * `e2e-verify` -- its PATH is the package root holding `e2e-attestations/`, + with the source dir as `--scope`; the `[e2e]` config table it needs is + passed as `--extra-scope` / `--exclude` flags, since it takes no `--config`. +""" + +from __future__ import annotations + +import os.path +from collections.abc import Callable +from dataclasses import dataclass + +from .matrix import GATES, Root + +MANIFESTS = ("pyproject.toml", "package.json", "Cargo.toml") + +# The CLI, resolved the way CI resolves it. Two silent drifts made the local run +# enforce a different ruleset, and naming the tag closes both: the PyPI wheel +# `uvx` fetches lags npm by several releases, and a BARE `npx testing-conventions` +# resolves engine-aware, so a shim on node older than `engines.node` picks the +# last release below that floor -- and exits 0 with no banner to say so. +CLI = ["npx", "-y", "testing-conventions@latest"] + +# How to launch a suite-executing gate, per language. Both run from the package +# root: python so the suite's `python3 -m pytest` resolves to that package's +# venv, typescript because only the npm CLI appends `--ts-mutation-adapter`, +# which the rule needs. Rust is absent -- it has no suite environment to enter. +# +# The python arm needs BOTH distributions. `--with` is not how the CLI is +# resolved; it puts the wheel's `testing_conventions` package on the interpreter +# the gate hands to `python3 -m`, which is the only form the mutation adapter +# ships in. +LAUNCHERS = { + "python": ["uv", "run", "--with", "testing-conventions", *CLI], + "typescript": CLI, +} + + +@dataclass +class Invocation: + argv: list[str] + # "." rather than None for the repo root: `subprocess.run(cwd=".")` is the + # same call, and it keeps every path in this module a plain str. + cwd: str + + +def package_root(source: str, exists: Callable[[str], bool]) -> str: + """Nearest ancestor of `source` (inclusive) holding a package manifest.""" + parts = source.split("/") + while parts: + candidate = "/".join(parts) + if any(exists(f"{candidate}/{name}") for name in MANIFESTS): + return candidate + parts.pop() + return "." + + +def e2e_flags(e2e: dict) -> list[str]: + flags = [] + for scope in e2e.get("extra_scope", []): + flags += ["--extra-scope", scope] + for path in e2e.get("exclude", []): + flags += ["--exclude", path] + return flags + + +def invocation( + root: Root, + language: str, + gate_name: str, + base: str, + exists: Callable[[str], bool], + e2e: dict, +) -> Invocation: + gate = GATES[gate_name] + options = [] + if gate.language: + options += ["--language", language] + if gate.base and (gate_name, language) != ("colocated-test", "rust"): + options += ["--base", base] + if gate.config and root.config: + options += ["--config", root.config] + + if gate_name == "e2e-verify": + home = package_root(root.source, exists) + return Invocation( + [ + *[*CLI, *gate.command, *options], + *["--scope", root.source, *e2e_flags(e2e), home], + ], + ".", + ) + if gate.runs_suite and language in LAUNCHERS: + # Paths become relative to the package root we run from, not the repo. + cwd = package_root(root.source, exists) + if gate.config and root.config: + options[-1] = os.path.relpath(root.config, cwd) + source = os.path.relpath(root.source, cwd) + return Invocation([*LAUNCHERS[language], *gate.command, *options, source], cwd) + return Invocation([*CLI, *gate.command, *options, root.source], ".") diff --git a/internals/checks/src/checks/preflight/invocation_test.py b/internals/checks/src/checks/preflight/invocation_test.py new file mode 100644 index 000000000..02bfdc904 --- /dev/null +++ b/internals/checks/src/checks/preflight/invocation_test.py @@ -0,0 +1,144 @@ +"""Colocated unit tests for the preflight argv builder (#781).""" + +from checks.preflight.invocation import ( + Invocation, + e2e_flags, + invocation, + package_root, +) + + +class Root: + """Stand-in for `matrix.Root` -- a value record, faked rather than imported.""" + + def __init__(self, job, source, languages, gates, config=None): + self.job = job + self.source = source + self.languages = languages + self.gates = gates + self.config = config + + +PY = Root( + job="python-sdk", + source="packages/python/dirsql", + languages=["python"], + gates=["unit-lint", "mutation"], + config="testing-conventions.toml", +) +RUST = Root(job="rust", source="packages/rust", languages=["rust"], gates=["packaging"]) + + +def has_manifest(path: str) -> bool: + return path == "packages/python/pyproject.toml" + + +def call(root, language, gate, e2e=None): + return invocation(root, language, gate, "origin/main", has_manifest, e2e or {}) + + +def describe_package_root(): + def it_walks_up_to_the_nearest_manifest(): + assert package_root("packages/python/dirsql", has_manifest) == "packages/python" + + def it_returns_the_source_itself_when_it_holds_the_manifest(): + assert package_root("packages/python", has_manifest) == "packages/python" + + def it_falls_back_to_the_repo_root_when_no_ancestor_has_one(): + assert package_root("packages/rust", lambda _path: False) == "." + + +def describe_e2e_flags(): + def it_maps_extra_scope_and_exclude_onto_repeatable_flags(): + assert e2e_flags({"extra_scope": ["a", "b"], "exclude": ["a/cli"]}) == [ + *["--extra-scope", "a", "--extra-scope", "b"], + *["--exclude", "a/cli"], + ] + + def it_returns_nothing_for_an_absent_table(): + assert e2e_flags({}) == [] + + +def describe_invocation(): + def it_runs_an_ordinary_gate_through_the_npm_cli_from_the_repo_root(): + assert call(PY, "python", "unit-lint") == Invocation( + [ + *["npx", "-y", "testing-conventions@latest", "unit", "lint"], + *["--language", "python"], + *["--config", "testing-conventions.toml", "packages/python/dirsql"], + ], + ".", + ) + + def it_takes_the_ordinary_path_for_a_gate_name_sorting_below_e2e_verify(): + # Both paths start with the same CLI prefix, so `--scope` is what tells + # them apart -- an `<=` here would route colocated-test into e2e's branch. + assert call(PY, "python", "colocated-test").argv == [ + *["npx", "-y", "testing-conventions@latest", "unit", "colocated-test"], + *["--language", "python", "--base", "origin/main"], + *["--config", "testing-conventions.toml", "packages/python/dirsql"], + ] + + def it_omits_base_for_a_whole_tree_gate_that_does_not_accept_it(): + assert "--base" not in call(PY, "python", "unit-lint").argv + + def it_passes_base_to_a_diff_scoped_gate(): + assert "--base" in call(PY, "python", "colocated-test").argv + + def it_omits_base_for_rust_colocated_test_which_the_cli_rejects(): + # Rust units are inline, so the co-change variant has no sibling test + # that could go stale and the CLI errors on `--base --language rust`. + assert "--base" not in call(RUST, "rust", "colocated-test").argv + + def it_still_passes_base_for_rust_mutation(): + assert "--base" in call(RUST, "rust", "mutation").argv + + def it_omits_config_for_a_root_that_declares_none(): + assert "--config" not in call(RUST, "rust", "unit-lint").argv + + def it_targets_the_package_root_for_e2e_verify_scoped_to_the_source(): + assert call(PY, "python", "e2e-verify", {"extra_scope": ["packages/rust/src"]}) == Invocation( + [ + *["npx", "-y", "testing-conventions@latest", "e2e", "verify"], + *["--base", "origin/main"], + *["--scope", "packages/python/dirsql"], + *["--extra-scope", "packages/rust/src", "packages/python"], + ], + ".", + ) + + def it_omits_language_for_e2e_verify_which_does_not_accept_it(): + assert "--language" not in call(PY, "python", "e2e-verify").argv + + def it_runs_python_mutation_through_the_packages_own_venv(): + mutation = call(PY, "python", "mutation") + assert mutation.cwd == "packages/python" + assert mutation.argv[:7] == [ + *["uv", "run", "--with", "testing-conventions"], + *["npx", "-y", "testing-conventions@latest"], + ] + + def it_rewrites_the_config_and_source_paths_relative_to_that_cwd(): + assert call(PY, "python", "mutation").argv[-3:] == [ + *["--config", "../../testing-conventions.toml", "dirsql"] + ] + + def it_passes_dot_as_the_source_when_the_package_root_is_the_source(): + root = Root(job="p", source="packages/python", languages=["python"], gates=["mutation"]) + assert call(root, "python", "mutation").argv[-1] == "." + + def it_leaves_the_config_alone_when_a_mutation_root_declares_none(): + root = Root(job="p", source="packages/python", languages=["python"], gates=["mutation"]) + assert "--config" not in call(root, "python", "mutation").argv + + def it_runs_python_unit_coverage_through_that_venv_too(): + assert call(PY, "python", "unit-coverage").cwd == "packages/python" + + def it_runs_a_typescript_suite_gate_through_npx_from_the_package_root(): + root = Root(job="ts", source="packages/ts/src", languages=["typescript"], gates=["mutation"]) + mutation = call(root, "typescript", "mutation") + assert mutation.argv[:3] == ["npx", "-y", "testing-conventions@latest"] + assert (mutation.cwd, mutation.argv[-1]) == (".", "packages/ts/src") + + def it_keeps_a_rust_mutation_gate_on_the_default_cli(): + assert call(RUST, "rust", "mutation").cwd == "." diff --git a/internals/checks/src/checks/preflight/matrix.py b/internals/checks/src/checks/preflight/matrix.py index f311da3da..7c3042ec5 100644 --- a/internals/checks/src/checks/preflight/matrix.py +++ b/internals/checks/src/checks/preflight/matrix.py @@ -5,18 +5,13 @@ nothing about most of the pairs. Reading the workflows makes that drift impossible: a caller added to `.github/workflows/` is a pair `preflight` runs. -Which workflows hold those callers is itself discovered rather than named. The -matrix used to come from `conventions.yml` alone; #834 split it into six -per-domain workflows and deleted it, so the named default resolved to nothing -and `just preflight` died on a bare `FileNotFoundError` (#973). A list of six -names would only move the same failure one rename away. +`discovery.py` finds those callers; this module models and parses them. """ from __future__ import annotations import json -import os -from collections.abc import Callable, Sequence +from collections.abc import Callable from dataclasses import dataclass import yaml @@ -138,50 +133,6 @@ def named(path: str, read: Callable[[str], str]) -> str: return text -def discovered( - directory: str, - listdir: Callable[[str], list[str]], - read: Callable[[str], str], -) -> list[tuple[str, str]]: - """(path, text) for every workflow in `directory` holding a caller.""" - try: - names = sorted(listdir(directory)) - except OSError as err: - raise NoGateMatrix( - f"no {directory} directory here. Run preflight from the repo root, or " - "name a workflow with --conventions." - ) from err - found = [] - for name in names: - if not name.endswith((".yml", ".yaml")): - continue - path = f"{directory}/{name}" - text = read(path) - if parse_gate_matrix(text): - found.append((path, text)) - if not found: - raise NoGateMatrix( - f"no workflow in {directory} calls {REUSABLE}, so there is no gate matrix " - "to run. If the reusable workflow moved, update REUSABLE in " - "internals/checks/src/checks/preflight/matrix.py; otherwise name the " - "workflow with --conventions." - ) - return found - - -def sources( - conventions: Sequence[str], - *, - directory: str = WORKFLOWS, - listdir: Callable[[str], list[str]] = os.listdir, - read: Callable[[str], str] = read_text, -) -> list[tuple[str, str]]: - """(path, text) for the workflows the gate matrix is derived from.""" - if conventions: - return [(path, named(path, read)) for path in conventions] - return discovered(directory, listdir, read) - - def pairs(roots: list[Root]) -> list[tuple[Root, str, str]]: """Flatten the matrix into the (root, language, gate) triples to run.""" return [(root, lang, gate) for root in roots for lang in root.languages for gate in root.gates] diff --git a/internals/checks/src/checks/preflight/matrix_test.py b/internals/checks/src/checks/preflight/matrix_test.py index 843398f0c..ecd308295 100644 --- a/internals/checks/src/checks/preflight/matrix_test.py +++ b/internals/checks/src/checks/preflight/matrix_test.py @@ -10,12 +10,10 @@ REUSABLE, ROOT_GATES, WORKFLOWS, - discovered, named, pairs, parse_gate_matrix, read_text, - sources, ) CONVENTIONS = """ @@ -165,91 +163,3 @@ def it_rejects_a_workflow_that_declares_no_caller(): named("docs.yml", reader({"docs.yml": NOT_A_CALLER})) assert "--conventions docs.yml: no job in it calls" in str(caught.value) - - -def describe_discovered(): - FILES = { - ".github/workflows/b-ci.yml": CONVENTIONS, - ".github/workflows/a-ci.yaml": CONVENTIONS, - ".github/workflows/docs.yml": NOT_A_CALLER, - } - - def it_returns_every_caller_it_finds_in_sorted_order(): - found = discovered( - ".github/workflows", - lambda _directory: ["b-ci.yml", "docs.yml", "a-ci.yaml"], - reader(FILES), - ) - - assert found == [ - (".github/workflows/a-ci.yaml", CONVENTIONS), - (".github/workflows/b-ci.yml", CONVENTIONS), - ] - - def it_never_reads_a_file_that_is_not_a_workflow(): - # `reader` raises for anything it was not given, so a README that got - # opened fails here rather than passing quietly. - found = discovered( - ".github/workflows", - lambda _directory: ["README.md", "b-ci.yml"], - reader(FILES), - ) - - assert [path for path, _text in found] == [".github/workflows/b-ci.yml"] - - def it_names_the_directory_and_the_fix_when_it_does_not_exist(): - def listdir(directory): - raise FileNotFoundError(2, "No such file or directory", directory) - - with pytest.raises(NoGateMatrix) as caught: - discovered(".github/workflows", listdir, reader(FILES)) - - assert "no .github/workflows directory here" in str(caught.value) - assert "--conventions" in str(caught.value) - - def it_fails_when_no_workflow_in_the_directory_calls_the_reusable_workflow(): - with pytest.raises(NoGateMatrix) as caught: - discovered(".github/workflows", lambda _directory: ["docs.yml"], reader(FILES)) - - message = str(caught.value) - assert f"no workflow in .github/workflows calls {REUSABLE}" in message - assert "matrix.py" in message - - -def describe_sources(): - def it_reads_each_named_workflow_in_the_order_given(): - files = {"b.yml": CONVENTIONS, "a.yml": CONVENTIONS} - - assert sources(["b.yml", "a.yml"], read=reader(files)) == [ - ("b.yml", CONVENTIONS), - ("a.yml", CONVENTIONS), - ] - - def it_discovers_the_callers_when_no_workflow_is_named(): - found = sources( - (), - directory="wf", - listdir=lambda _directory: ["ci.yml"], - read=reader({"wf/ci.yml": CONVENTIONS}), - ) - - assert found == [("wf/ci.yml", CONVENTIONS)] - - def it_takes_its_seams_by_keyword_only(): - # `*` (not `/`) before the injected seams: a third positional argument - # would otherwise land silently on `listdir`. - with pytest.raises(TypeError): - sources((), "wf", lambda _directory: ["ci.yml"], reader({"wf/ci.yml": CONVENTIONS})) - - def it_discovers_from_the_workflows_directory_by_default(): - # The default is the whole point: #834 deleted the workflow the command - # used to name, and nothing pinned where it looks instead. - looked = [] - - sources( - (), - listdir=lambda directory: looked.append(directory) or ["ci.yml"], - read=reader({".github/workflows/ci.yml": CONVENTIONS}), - ) - - assert looked == [WORKFLOWS] == [".github/workflows"] diff --git a/internals/checks/src/checks/preflight/prepare.py b/internals/checks/src/checks/preflight/prepare.py new file mode 100644 index 000000000..20753c54c --- /dev/null +++ b/internals/checks/src/checks/preflight/prepare.py @@ -0,0 +1,42 @@ +"""The per-python-root drift guards that run before any gate (#782). + +A `uv pip install` leaves the local venv strictly more capable than any real +install, and no amount of running more gates catches that. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from .invocation import Invocation, package_root +from .matrix import Root + + +def prepare(roots: list[Root], exists: Callable[[str], bool]) -> list[tuple[str, str, Invocation]]: + """Per-python-root steps that guard against venv drift (#782). + + `uv sync` reconciles the venv with the manifest, *removing* anything a + `uv pip install` left behind, so an undeclared dependency stops resolving + locally the way it never resolved in CI. `declared-deps` is the direct + assertion, independent of whatever the venv happens to hold. + """ + steps = [] + for root in roots: + if "python" not in root.languages: + continue + home = package_root(root.source, exists) + steps.append((root.job, "uv-sync", Invocation(["uv", "sync", "--project", home], "."))) + steps.append( + ( + root.job, + "declared-deps", + Invocation( + [ + *["uv", "run", "--project", "internals/checks", "dirsql-checks"], + *["declared-deps", root.source], + ], + ".", + ), + ) + ) + return steps diff --git a/internals/checks/src/checks/preflight/prepare_test.py b/internals/checks/src/checks/preflight/prepare_test.py new file mode 100644 index 000000000..c9917019f --- /dev/null +++ b/internals/checks/src/checks/preflight/prepare_test.py @@ -0,0 +1,54 @@ +"""Colocated unit tests for the preflight drift guards (#782).""" + +from checks.preflight.prepare import prepare + + +class Root: + """Stand-in for `matrix.Root` -- a value record, faked rather than imported.""" + + def __init__(self, job, source, languages, gates, config=None): + self.job = job + self.source = source + self.languages = languages + self.gates = gates + self.config = config + + +PY = Root( + job="python-sdk", + source="packages/python/dirsql", + languages=["python"], + gates=["unit-lint", "mutation"], + config="testing-conventions.toml", +) +RUST = Root(job="rust", source="packages/rust", languages=["rust"], gates=["packaging"]) + + +def has_manifest(path: str) -> bool: + return path == "packages/python/pyproject.toml" + + +def describe_prepare(): + def it_syncs_and_checks_declared_deps_for_each_python_root(): + assert [(job, step, call.argv) for job, step, call in prepare([PY], has_manifest)] == [ + ("python-sdk", "uv-sync", ["uv", "sync", "--project", "packages/python"]), + ( + "python-sdk", + "declared-deps", + [ + *["uv", "run", "--project", "internals/checks", "dirsql-checks"], + *["declared-deps", "packages/python/dirsql"], + ], + ), + ] + + def it_runs_both_guards_from_the_repo_root(): + assert [call.cwd for _job, _step, call in prepare([PY], has_manifest)] == [".", "."] + + def it_skips_a_root_with_no_python(): + assert prepare([RUST], has_manifest) == [] + + def it_keeps_going_past_a_non_python_root_to_the_ones_after_it(): + assert [job for job, _step, _call in prepare([RUST, PY], has_manifest)] == [ + *["python-sdk", "python-sdk"] + ] diff --git a/internals/checks/src/checks/release_globs/decide.py b/internals/checks/src/checks/release_globs/decide.py index fb63c0448..c7e88e61d 100644 --- a/internals/checks/src/checks/release_globs/decide.py +++ b/internals/checks/src/checks/release_globs/decide.py @@ -120,34 +120,3 @@ def glob_problems(packages) -> list[str]: f"artifact. Replace the entries above in putitoutthere.toml." ) return problems - - -def unprechecked(exclusions) -> list[str]: - """One message per precheck exclusion the publish globs would still match. - - `release-ci.yml` is the PR-time build precheck; every path it skips must be - a path publishing also skips, or a merge cuts a release the matrix never - built. The comparison is by name rather than by matching, so it needs no - second copy of putitoutthere's glob semantics. - """ - problems = [] - for entry in exclusions: - path = entry.removeprefix("!") - root = subtree_root(path) - if root is None: - continue - rest = path[len(root) + 1 :] - if rest.endswith("/**") and rest.removesuffix("/**") in NON_SHIPPING_DIRS: - continue - if rest in NON_SHIPPING_FILES: - continue - problems.append( - f'release-ci.yml excludes "{entry}", but publishing does not: ' - f"{rest.removesuffix('/**')} is not one of " - f"{', '.join((*NON_SHIPPING_DIRS, *NON_SHIPPING_FILES))}, so " - f"putitoutthere still cascades {root} on a change there and a merge " - f"releases what the precheck never built. Either drop the exclusion " - f"from release-ci.yml or add the name to NON_SHIPPING_DIRS / " - f"NON_SHIPPING_FILES and re-run this check." - ) - return problems diff --git a/internals/checks/src/checks/release_globs/decide_test.py b/internals/checks/src/checks/release_globs/decide_test.py index 927712a6b..e959b3520 100644 --- a/internals/checks/src/checks/release_globs/decide_test.py +++ b/internals/checks/src/checks/release_globs/decide_test.py @@ -10,7 +10,6 @@ subtree_globs, subtree_root, subtree_roots, - unprechecked, ) RUST = carve_out("packages/rust") @@ -145,46 +144,3 @@ def it_accepts_a_package_declaring_no_globs(): def it_accepts_an_empty_package_list(): assert glob_problems([]) == [] - - -def describe_unprechecked(): - def it_accepts_an_exclusion_naming_a_non_shipping_directory(): - assert unprechecked(["!packages/rust/changelog.d/**"]) == [] - - def it_accepts_an_exclusion_naming_a_non_shipping_root_file(): - assert unprechecked(["!packages/python/testing-conventions.toml"]) == [] - - def it_ignores_an_exclusion_outside_every_published_package(): - assert unprechecked(["!docs/**"]) == [] - - def it_rejects_an_exclusion_for_a_directory_that_still_publishes(): - problems = unprechecked(["!plugins/dirsql-plugin-embeddings/tests/**"]) - assert len(problems) == 1 - assert "tests is not one of" in problems[0] - assert "plugins/dirsql-plugin-embeddings" in problems[0] - - def it_rejects_an_exclusion_for_a_root_file_that_still_publishes(): - assert len(unprechecked(["!packages/ts/biome.json"])) == 1 - - def it_rejects_a_non_shipping_name_used_at_the_wrong_depth(): - # `changelog.d` without the `/**` tail is a file exclusion, and no file - # by that name is carved out of the publish globs. - assert len(unprechecked(["!packages/rust/changelog.d"])) == 1 - - def it_reports_every_offending_exclusion(): - assert len(unprechecked(["!packages/ts/tools/**", "!packages/ts/biome.json"])) == 2 - - def it_keeps_scanning_past_an_exclusion_outside_every_package(): - assert len(unprechecked(["!docs/**", "!packages/ts/biome.json"])) == 1 - - def it_keeps_scanning_past_an_accepted_directory_exclusion(): - assert ( - len(unprechecked(["!packages/rust/changelog.d/**", "!packages/ts/biome.json"])) == 1 - ) - - def it_keeps_scanning_past_an_accepted_root_file_exclusion(): - exclusions = ["!packages/python/testing-conventions.toml", "!packages/ts/biome.json"] - assert len(unprechecked(exclusions)) == 1 - - def it_accepts_an_empty_exclusion_list(): - assert unprechecked([]) == [] diff --git a/internals/checks/src/checks/release_globs/gate.py b/internals/checks/src/checks/release_globs/gate.py index 5a9e20c2f..f54e2ed50 100644 --- a/internals/checks/src/checks/release_globs/gate.py +++ b/internals/checks/src/checks/release_globs/gate.py @@ -16,7 +16,8 @@ import yaml -from checks.release_globs.decide import glob_problems, unprechecked +from .decide import glob_problems +from .precheck import unprechecked def read_config(path: str) -> dict: diff --git a/internals/checks/src/checks/release_globs/gate_test.py b/internals/checks/src/checks/release_globs/gate_test.py index 34ef24dd2..0bbc25746 100644 --- a/internals/checks/src/checks/release_globs/gate_test.py +++ b/internals/checks/src/checks/release_globs/gate_test.py @@ -100,6 +100,7 @@ def it_also_annotates_a_precheck_exclusion_publishing_does_not_share(): workflow = {True: {"pull_request": {"paths": ["packages/rust/**", "!packages/rust/tests/**"]}}} assert drive(CLEAN, workflow, lines) == 1 assert "release-ci.yml excludes" in lines[0] + assert lines[1].startswith("release-globs: 1 problem(s).") def it_accepts_a_config_declaring_no_packages(): lines: list[str] = [] diff --git a/internals/checks/src/checks/release_globs/precheck.py b/internals/checks/src/checks/release_globs/precheck.py new file mode 100644 index 000000000..a81a96ce3 --- /dev/null +++ b/internals/checks/src/checks/release_globs/precheck.py @@ -0,0 +1,36 @@ +"""Whether `release-ci.yml`'s path filter skips only what publishing also skips (#944).""" + +from __future__ import annotations + +from .decide import NON_SHIPPING_DIRS, NON_SHIPPING_FILES, subtree_root + + +def unprechecked(exclusions) -> list[str]: + """One message per precheck exclusion the publish globs would still match. + + `release-ci.yml` is the PR-time build precheck; every path it skips must be + a path publishing also skips, or a merge cuts a release the matrix never + built. The comparison is by name rather than by matching, so it needs no + second copy of putitoutthere's glob semantics. + """ + problems = [] + for entry in exclusions: + path = entry.removeprefix("!") + root = subtree_root(path) + if root is None: + continue + rest = path[len(root) + 1 :] + if rest.endswith("/**") and rest.removesuffix("/**") in NON_SHIPPING_DIRS: + continue + if rest in NON_SHIPPING_FILES: + continue + problems.append( + f'release-ci.yml excludes "{entry}", but publishing does not: ' + f"{rest.removesuffix('/**')} is not one of " + f"{', '.join((*NON_SHIPPING_DIRS, *NON_SHIPPING_FILES))}, so " + f"putitoutthere still cascades {root} on a change there and a merge " + f"releases what the precheck never built. Either drop the exclusion " + f"from release-ci.yml or add the name to NON_SHIPPING_DIRS / " + f"NON_SHIPPING_FILES and re-run this check." + ) + return problems diff --git a/internals/checks/src/checks/release_globs/precheck_test.py b/internals/checks/src/checks/release_globs/precheck_test.py new file mode 100644 index 000000000..3324f2875 --- /dev/null +++ b/internals/checks/src/checks/release_globs/precheck_test.py @@ -0,0 +1,46 @@ +"""Colocated unit tests for the precheck half of the release-globs invariant (#944).""" + +from checks.release_globs.precheck import unprechecked + + +def describe_unprechecked(): + def it_accepts_an_exclusion_naming_a_non_shipping_directory(): + assert unprechecked(["!packages/rust/changelog.d/**"]) == [] + + def it_accepts_an_exclusion_naming_a_non_shipping_root_file(): + assert unprechecked(["!packages/python/testing-conventions.toml"]) == [] + + def it_ignores_an_exclusion_outside_every_published_package(): + assert unprechecked(["!docs/**"]) == [] + + def it_rejects_an_exclusion_for_a_directory_that_still_publishes(): + problems = unprechecked(["!plugins/dirsql-plugin-embeddings/tests/**"]) + assert len(problems) == 1 + assert "tests is not one of" in problems[0] + assert "plugins/dirsql-plugin-embeddings" in problems[0] + + def it_rejects_an_exclusion_for_a_root_file_that_still_publishes(): + assert len(unprechecked(["!packages/ts/biome.json"])) == 1 + + def it_rejects_a_non_shipping_name_used_at_the_wrong_depth(): + # `changelog.d` without the `/**` tail is a file exclusion, and no file + # by that name is carved out of the publish globs. + assert len(unprechecked(["!packages/rust/changelog.d"])) == 1 + + def it_reports_every_offending_exclusion(): + assert len(unprechecked(["!packages/ts/tools/**", "!packages/ts/biome.json"])) == 2 + + def it_keeps_scanning_past_an_exclusion_outside_every_package(): + assert len(unprechecked(["!docs/**", "!packages/ts/biome.json"])) == 1 + + def it_keeps_scanning_past_an_accepted_directory_exclusion(): + assert ( + len(unprechecked(["!packages/rust/changelog.d/**", "!packages/ts/biome.json"])) == 1 + ) + + def it_keeps_scanning_past_an_accepted_root_file_exclusion(): + exclusions = ["!packages/python/testing-conventions.toml", "!packages/ts/biome.json"] + assert len(unprechecked(exclusions)) == 1 + + def it_accepts_an_empty_exclusion_list(): + assert unprechecked([]) == [] diff --git a/internals/checks/testing-conventions.toml b/internals/checks/testing-conventions.toml index 783fe2d2b..ad1536649 100644 --- a/internals/checks/testing-conventions.toml +++ b/internals/checks/testing-conventions.toml @@ -15,7 +15,7 @@ # its own config drops that phantom root; a change to decide.py's path list still # demands a receipt through the normal --scope internals/checks/src walk. [python] -one_function_per_file = { max_lines = 24 } +one_function_per_file = { max_lines = 16 } [python.coverage] fail_under = 100 diff --git a/packages/python/testing-conventions.toml b/packages/python/testing-conventions.toml index af5dc0049..c85cc6cbd 100644 --- a/packages/python/testing-conventions.toml +++ b/packages/python/testing-conventions.toml @@ -10,7 +10,7 @@ extra_scope = ["packages/rust/src", "packages/python/src"] [python] -one_function_per_file = { max_lines = 24 } +one_function_per_file = { max_lines = 16 } # Same floor as the root file, which this lane no longer reads. [python.coverage] diff --git a/testing-conventions.toml b/testing-conventions.toml index 84defd620..3bbd3f94a 100644 --- a/testing-conventions.toml +++ b/testing-conventions.toml @@ -80,7 +80,7 @@ extra_scope = ["packages/rust/src"] # --- Python (scanned: packages/python/dirsql) ------------------------------ [python] -one_function_per_file = { max_lines = 24 } +one_function_per_file = { max_lines = 16 } # Unit-only coverage floor (branch coverage on, matching the prior --cov-branch). # The default for a config-less python caller (internals/distcheck); the