diff --git a/.github/workflows/internals-checks-ci.yml b/.github/workflows/internals-checks-ci.yml index d3ed8bc0e..de0691c9b 100644 --- a/.github/workflows/internals-checks-ci.yml +++ b/.github/workflows/internals-checks-ci.yml @@ -31,7 +31,7 @@ jobs: source: internals/checks/src config: internals/checks/testing-conventions.toml base: ${{ github.event.pull_request.base.sha || 'origin/main' }} - gates: '["colocated-test", "unit-lint", "integration-lint", "unit-coverage", "mutation", "e2e-verify"]' + gates: '["colocated-test", "one-function-per-file", "unit-lint", "integration-lint", "unit-coverage", "mutation", "e2e-verify"]' internals-checks-integration: # The conventions job runs this package's colocated unit tier and checks its diff --git a/.github/workflows/internals-distcheck-ci.yml b/.github/workflows/internals-distcheck-ci.yml index db64e3de5..558026f4b 100644 --- a/.github/workflows/internals-distcheck-ci.yml +++ b/.github/workflows/internals-distcheck-ci.yml @@ -29,4 +29,4 @@ jobs: languages: '["python"]' source: internals/distcheck/src base: ${{ github.event.pull_request.base.sha || 'origin/main' }} - gates: '["colocated-test", "unit-lint", "integration-lint", "unit-coverage", "mutation"]' + gates: '["colocated-test", "one-function-per-file", "unit-lint", "integration-lint", "unit-coverage", "mutation"]' diff --git a/.github/workflows/plugin-dirsql-embedding-ci.yml b/.github/workflows/plugin-dirsql-embedding-ci.yml index 64fa7efb5..e96140f8a 100644 --- a/.github/workflows/plugin-dirsql-embedding-ci.yml +++ b/.github/workflows/plugin-dirsql-embedding-ci.yml @@ -38,7 +38,7 @@ jobs: source: plugins/dirsql-plugin-embeddings/src config: plugins/dirsql-plugin-embeddings/testing-conventions.toml base: ${{ github.event.pull_request.base.sha || 'origin/main' }} - gates: '["colocated-test", "unit-lint", "integration-lint", "unit-coverage", "mutation", "e2e-verify"]' + gates: '["colocated-test", "one-function-per-file", "unit-lint", "integration-lint", "unit-coverage", "mutation", "e2e-verify"]' embeddings-integration: # The conventions job runs the plugin's colocated unit tier and checks its diff --git a/internals/checks/e2e-attestations/claude-1018-preflight-one-function-gate.json b/internals/checks/e2e-attestations/claude-1018-preflight-one-function-gate.json new file mode 100644 index 000000000..041c21b43 --- /dev/null +++ b/internals/checks/e2e-attestations/claude-1018-preflight-one-function-gate.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e -x -q", + "ran_at": 1788368546, + "exit_code": 0, + "commit": "f032f0fa05bf1ba089f1a71197a4bca124c2ad36", + "branch": "claude/1018-preflight-one-function-gate" +} diff --git a/internals/checks/src/checks/preflight/gate.py b/internals/checks/src/checks/preflight/gate.py index e822316a2..5b9e12c47 100644 --- a/internals/checks/src/checks/preflight/gate.py +++ b/internals/checks/src/checks/preflight/gate.py @@ -12,10 +12,10 @@ Three pairs cannot be run the naive way, and each is encoded rather than skipped: - * python `mutation` / `unit-coverage` -- `uvx` supplies its own environment, so - the suite's `python3 -m pytest` resolves to an interpreter with no pytest - (#706 saw it as a cosmic-ray baseline failure naming no cause). Both run via - the package's own venv instead. + * 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. @@ -36,13 +36,25 @@ 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 `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, so it stays on `uvx`. +# 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", "testing-conventions"], - "typescript": ["npx", "-y", "testing-conventions"], + "python": ["uv", "run", "--with", "testing-conventions", *CLI], + "typescript": CLI, } @@ -95,7 +107,7 @@ def invocation( home = package_root(root.source, exists) return Invocation( [ - *["uvx", "testing-conventions", *gate.command, *options], + *[*CLI, *gate.command, *options], *["--scope", root.source, *e2e_flags(e2e), home], ], ".", @@ -107,7 +119,7 @@ def invocation( 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(["uvx", "testing-conventions", *gate.command, *options, root.source], ".") + return Invocation([*CLI, *gate.command, *options, root.source], ".") def default_runner(argv: Sequence[str], cwd: str) -> int: diff --git a/internals/checks/src/checks/preflight/gate_test.py b/internals/checks/src/checks/preflight/gate_test.py index 22f702fb4..81b95323e 100644 --- a/internals/checks/src/checks/preflight/gate_test.py +++ b/internals/checks/src/checks/preflight/gate_test.py @@ -78,10 +78,10 @@ def it_returns_nothing_for_an_absent_table(): def describe_invocation(): - def it_runs_an_ordinary_gate_through_uvx_from_the_repo_root(): + def it_runs_an_ordinary_gate_through_the_npm_cli_from_the_repo_root(): assert call(PY, "python", "unit-lint") == Invocation( [ - *["uvx", "testing-conventions", "unit", "lint"], + *["npx", "-y", "testing-conventions@latest", "unit", "lint"], *["--language", "python"], *["--config", "testing-conventions.toml", "packages/python/dirsql"], ], @@ -89,10 +89,10 @@ def it_runs_an_ordinary_gate_through_uvx_from_the_repo_root(): ) def it_takes_the_ordinary_path_for_a_gate_name_sorting_below_e2e_verify(): - # Both paths start `uvx testing-conventions`, so `--scope` is what tells + # 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 == [ - *["uvx", "testing-conventions", "unit", "colocated-test"], + *["npx", "-y", "testing-conventions@latest", "unit", "colocated-test"], *["--language", "python", "--base", "origin/main"], *["--config", "testing-conventions.toml", "packages/python/dirsql"], ] @@ -117,7 +117,7 @@ def it_omits_config_for_a_root_that_declares_none(): 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( [ - *["uvx", "testing-conventions", "e2e", "verify"], + *["npx", "-y", "testing-conventions@latest", "e2e", "verify"], *["--base", "origin/main"], *["--scope", "packages/python/dirsql"], *["--extra-scope", "packages/rust/src", "packages/python"], @@ -131,8 +131,9 @@ def it_omits_language_for_e2e_verify_which_does_not_accept_it(): def it_runs_python_mutation_through_the_packages_own_venv(): mutation = call(PY, "python", "mutation") assert mutation.cwd == "packages/python" - assert mutation.argv[:5] == [ - *["uv", "run", "--with", "testing-conventions", "testing-conventions"] + 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(): @@ -154,10 +155,10 @@ def it_runs_python_unit_coverage_through_that_venv_too(): 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"] + 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_uvx(): + def it_keeps_a_rust_mutation_gate_on_the_default_cli(): assert call(RUST, "rust", "mutation").cwd == "." @@ -228,7 +229,7 @@ def it_runs_the_drift_guards_first_then_every_pair(): assert drive(runner=lambda argv, cwd: calls.append((argv, cwd)) or 0) == 0 assert [argv[:2] for argv, _cwd in calls] == [ *[["uv", "sync"], ["uv", "run"]], - *[["uvx", "testing-conventions"], ["uv", "run"]], + *[["npx", "-y"], ["uv", "run"]], ] assert [cwd for _argv, cwd in calls] == [".", ".", ".", "packages/python"] @@ -283,7 +284,7 @@ def it_echoes_the_argv_it_is_about_to_run(): lines = [] drive(only=["unit-lint"], echo=lines.append) assert lines[0] == ( - "==> python-sdk [python] unit-lint: uvx testing-conventions unit lint " + "==> python-sdk [python] unit-lint: npx -y testing-conventions@latest unit lint " "--language python packages/python/dirsql" ) @@ -292,7 +293,8 @@ def it_runs_only_the_gates_named_by_the_filter(): drive(only=["mutation"], runner=lambda _argv, _cwd: 0, echo=lines.append) assert [line for line in lines if line.startswith("==>")] == [ "==> python-sdk [python] mutation: uv run --with testing-conventions " - "testing-conventions unit mutation --language python --base origin/main dirsql" + "npx -y testing-conventions@latest unit mutation --language python " + "--base origin/main dirsql" ] def it_prints_every_pair_without_running_any_when_dry_run(): diff --git a/internals/checks/src/checks/preflight/matrix.py b/internals/checks/src/checks/preflight/matrix.py index a26aaa656..f311da3da 100644 --- a/internals/checks/src/checks/preflight/matrix.py +++ b/internals/checks/src/checks/preflight/matrix.py @@ -29,6 +29,7 @@ # omits `gates:` gets all of them. ROOT_GATES = [ "colocated-test", + "one-function-per-file", "unit-lint", "integration-lint", "unit-coverage", @@ -58,7 +59,7 @@ class Gate: config: bool = True base: bool = False # Executes the package's test suite, so it needs that package's own venv on - # `python3`, not the throwaway one `uvx` supplies. + # `python3`, not the throwaway one a standalone launcher supplies. runs_suite: bool = False # Takes the root of a BUILT artifact, not a source dir. CI builds it from # the manifest first; preflight has no equivalent and reports these skipped. @@ -67,6 +68,7 @@ class Gate: GATES = { "colocated-test": Gate(("unit", "colocated-test"), base=True), + "one-function-per-file": Gate(("unit", "one-function-per-file")), "unit-lint": Gate(("unit", "lint")), "integration-lint": Gate(("integration", "lint")), "unit-coverage": Gate(("unit", "coverage"), base=True, runs_suite=True), diff --git a/internals/checks/src/checks/preflight/matrix_test.py b/internals/checks/src/checks/preflight/matrix_test.py index 2c2250c8b..843398f0c 100644 --- a/internals/checks/src/checks/preflight/matrix_test.py +++ b/internals/checks/src/checks/preflight/matrix_test.py @@ -90,6 +90,7 @@ def it_records_the_options_and_needs_of_every_gate(): } assert shape == { "colocated-test": (True, True, True, False, False), + "one-function-per-file": (True, True, False, False, False), "unit-lint": (True, True, False, False, False), "integration-lint": (True, True, False, False, False), "unit-coverage": (True, True, True, True, False), @@ -101,6 +102,7 @@ def it_records_the_options_and_needs_of_every_gate(): def it_maps_each_gate_to_its_cli_subcommand(): assert {name: g.command for name, g in GATES.items()} == { "colocated-test": ("unit", "colocated-test"), + "one-function-per-file": ("unit", "one-function-per-file"), "unit-lint": ("unit", "lint"), "integration-lint": ("integration", "lint"), "unit-coverage": ("unit", "coverage"), diff --git a/internals/checks/tests/e2e/preflight_test.py b/internals/checks/tests/e2e/preflight_test.py index 4e8ee2720..1a5bccdce 100644 --- a/internals/checks/tests/e2e/preflight_test.py +++ b/internals/checks/tests/e2e/preflight_test.py @@ -44,6 +44,13 @@ def it_prints_the_matrix_from_the_repo_root_with_no_arguments(): assert "==> rust [rust] unit-lint" in proc.stdout assert "==> internals-checks [python] unit-lint" in proc.stdout + def it_prints_the_one_function_per_file_gate_for_a_lane_naming_no_gates(): + proc = preflight() + + assert proc.returncode == 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}" + assert "==> python-sdk [python] one-function-per-file" in proc.stdout + assert "==> typescript-sdk [typescript] one-function-per-file" in proc.stdout + def it_exits_with_an_actionable_message_when_a_named_workflow_is_missing(): proc = preflight("--conventions", ".github/workflows/conventions.yml") diff --git a/internals/checks/tests/integration/preflight_test.py b/internals/checks/tests/integration/preflight_test.py index fd096f99e..ed93385aa 100644 --- a/internals/checks/tests/integration/preflight_test.py +++ b/internals/checks/tests/integration/preflight_test.py @@ -76,6 +76,24 @@ def it_spans_every_workflow_holding_a_caller_not_just_one(repo_root): assert {file for job, file in jobs.items() if f"==> {job} [" in output} == set(jobs.values()) + def it_derives_the_one_function_per_file_gate_for_a_lane_naming_no_gates(repo_root): + # `@v0` runs `unit one-function-per-file` in the static job, so a lane + # that names no `gates:` is held to it in CI. A matrix omitting it is a + # locally-green run that says nothing about the gate. + output = dry_run().output + + assert "==> python-sdk [python] one-function-per-file" in output + assert "==> typescript-sdk [typescript] one-function-per-file" in output + + def it_derives_the_one_function_per_file_gate_for_every_lane_naming_it(repo_root): + # An explicit `gates` array is an allowlist: a lane that omits the gate + # does not run it, however the reusable workflow defaults. + output = dry_run().output + + assert "==> internals-checks [python] one-function-per-file" in output + assert "==> internals-distcheck [python] one-function-per-file" in output + assert "==> plugins-embeddings [python] one-function-per-file" in output + def it_names_a_missing_workflow_and_the_fix_instead_of_raising(repo_root): result = dry_run("--conventions", ".github/workflows/conventions.yml")