From d5dd874ea0e7bcf8533d11d0b2391d59380dcd06 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:49:06 -0400 Subject: [PATCH 01/12] ci: adopt one-function-per-file at max_lines = 100 testing-conventions @v0 wires `unit one-function-per-file` into the static job, and it has been failing `Static checks (typescript)` on main since the tag rolled. The rule is unconfigured here, so it ran at the shipped default of 1. `max_lines` is the threshold above which a module-scope function counts as substantial and must own its file, not a cap on how long a function may be, so a higher number is a looser gate. 100 matches thekevinscott/willfire's starting ceiling: green across every scan root today with zero exemptions, and a number to walk down rather than an end state. Rust stays opted out, as upstream defaults it -- a Rust file is a module, and grouping a type with its impls and free functions is idiomatic. --- internals/checks/testing-conventions.toml | 3 +++ packages/python/testing-conventions.toml | 3 +++ packages/ts/testing-conventions.toml | 3 +++ plugins/dirsql-plugin-embeddings/testing-conventions.toml | 3 +++ testing-conventions.toml | 6 ++++++ 5 files changed, 18 insertions(+) diff --git a/internals/checks/testing-conventions.toml b/internals/checks/testing-conventions.toml index 6db553550..62a130eb8 100644 --- a/internals/checks/testing-conventions.toml +++ b/internals/checks/testing-conventions.toml @@ -14,6 +14,9 @@ # a receipt under internals/checks/e2e-attestations/ (#550). Scoping this package to # 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 = 100 } + [python.coverage] fail_under = 100 branch = true diff --git a/packages/python/testing-conventions.toml b/packages/python/testing-conventions.toml index 40bf0dd6b..ef8dcff95 100644 --- a/packages/python/testing-conventions.toml +++ b/packages/python/testing-conventions.toml @@ -9,6 +9,9 @@ [e2e] extra_scope = ["packages/rust/src", "packages/python/src"] +[python] +one_function_per_file = { max_lines = 100 } + # Same floor as the root file, which this lane no longer reads. [python.coverage] fail_under = 100 diff --git a/packages/ts/testing-conventions.toml b/packages/ts/testing-conventions.toml index b7c773bd1..a353a5191 100644 --- a/packages/ts/testing-conventions.toml +++ b/packages/ts/testing-conventions.toml @@ -10,6 +10,9 @@ [e2e] extra_scope = ["packages/rust/src", "packages/ts/napi"] +[typescript] +one_function_per_file = { max_lines = 100 } + # Same floors as the root file, which this lane no longer reads. [typescript.coverage] lines = 100 diff --git a/plugins/dirsql-plugin-embeddings/testing-conventions.toml b/plugins/dirsql-plugin-embeddings/testing-conventions.toml index 521b44f0b..fdf5dd969 100644 --- a/plugins/dirsql-plugin-embeddings/testing-conventions.toml +++ b/plugins/dirsql-plugin-embeddings/testing-conventions.toml @@ -2,6 +2,9 @@ # package. Mirrors internals/checks: python coverage floor 100 + branch, zero # exemptions, and deliberately no `[e2e] extra_scope` -- this package compiles # none of packages/rust/src, so a core change never stales its attestation. +[python] +one_function_per_file = { max_lines = 100 } + [python.coverage] fail_under = 100 branch = true diff --git a/testing-conventions.toml b/testing-conventions.toml index cb477a0be..7c4cea1a1 100644 --- a/testing-conventions.toml +++ b/testing-conventions.toml @@ -79,6 +79,9 @@ extra_scope = ["packages/rust/src"] # --- Python (scanned: packages/python/dirsql) ------------------------------ +[python] +one_function_per_file = { max_lines = 100 } + # Unit-only coverage floor (branch coverage on, matching the prior --cov-branch). # The default for a config-less python caller (internals/distcheck); the # packages/python lane holds the identical floor in its own config. @@ -95,6 +98,9 @@ branch = true # `packages/ts/napi` declares a napi build (auto-provisioning without a # build_command field), with `rust_toolchain: true` on the caller supplying cargo. +[typescript] +one_function_per_file = { max_lines = 100 } + # Unit-only coverage floor, per vitest metric. [typescript.coverage] lines = 100 From c7e22b21f3609143ba8e34251bc0151ed31fcda9 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:39:07 -0400 Subject: [PATCH 02/12] test: assert preflight derives the one-function-per-file gate RED. testing-conventions @v0 runs `unit one-function-per-file` in its static job, so a lane naming no `gates:` is held to it in CI. preflight's ROOT_GATES omits it, so `just preflight` skips it locally and reports green. skip-changelog: internals/ tooling tests, no published package touched --- internals/checks/tests/e2e/preflight_test.py | 7 +++++++ internals/checks/tests/integration/preflight_test.py | 9 +++++++++ 2 files changed, 16 insertions(+) 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..fee44bf79 100644 --- a/internals/checks/tests/integration/preflight_test.py +++ b/internals/checks/tests/integration/preflight_test.py @@ -76,6 +76,15 @@ 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_names_a_missing_workflow_and_the_fix_instead_of_raising(repo_root): result = dry_run("--conventions", ".github/workflows/conventions.yml") From 82391f1c3d79af8167d85157d3ab8862daf1a288 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:48:19 -0400 Subject: [PATCH 03/12] feat: run the one-function-per-file gate, on the release CI runs GREEN. Two changes, and the second is what makes the first observable. `one-function-per-file` joins the gate table and the default-gates list, so preflight derives it for the lanes that name no `gates:` and builds the right argv for a lane that names it. Resolving the CLI moves off `uvx` to `npx testing-conventions@latest`. The PyPI wheel is a laggier distribution of the same tool -- 0.0.104 against npm's 0.0.111 -- and has no `one-function-per-file` subcommand at all, so the gate could not run locally however the table was written. A bare `npx testing-conventions` is worse: npm resolves an unversioned name engine-aware, `engines.node` rose to `>=24` at 0.0.87, and a shim on an older node silently picks 0.0.86 and exits 0. Naming the tag makes the local ruleset the one CI enforces, which is the whole point of the recipe. skip-changelog: internals/ tooling, no published package touched --- internals/checks/src/checks/preflight/gate.py | 34 +++++++++++++------ .../checks/src/checks/preflight/gate_test.py | 18 +++++----- .../checks/src/checks/preflight/matrix.py | 4 ++- .../src/checks/preflight/matrix_test.py | 2 ++ 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/internals/checks/src/checks/preflight/gate.py b/internals/checks/src/checks/preflight/gate.py index e822316a2..ad93591ad 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` -- a throwaway environment supplies its + own interpreter, so the suite's `python3 -m pytest` resolves to one with no + pytest (#706 saw it as a cosmic-ray baseline failure naming no cause). Both + run via the package's own venv instead. * 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,27 @@ MANIFESTS = ("pyproject.toml", "package.json", "Cargo.toml") +# The CLI, resolved the way CI resolves it: npm, newest release. Two distinct +# drifts made the local run enforce a different ruleset than CI, and `@latest` +# closes both. The PyPI wheel `uvx` fetched is a laggier distribution of the same +# tool (0.0.104 against npm's 0.0.111 when this was written), and it has no +# `one-function-per-file` subcommand at all. A BARE `npx testing-conventions` is +# worse: npm resolves an unversioned name engine-aware, `engines.node` rose to +# `>=24` at 0.0.87, and a launcher shim running an older node therefore silently +# picks 0.0.86 -- dozens of releases of rule changes behind, and exiting 0 with +# no version banner to say so. CI dodges that by provisioning node 24 in every +# job; naming the tag dodges it wherever the local node lands. +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 `python3 -m pytest` resolves to that package's venv (which is +# why that one arm keeps the wheel, and stays subject to the PyPI lag above), +# 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 `CLI`. LAUNCHERS = { "python": ["uv", "run", "--with", "testing-conventions", "testing-conventions"], - "typescript": ["npx", "-y", "testing-conventions"], + "typescript": CLI, } @@ -95,7 +109,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 +121,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..20a119864 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"], @@ -154,10 +154,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 +228,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 +283,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" ) 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"), From 829b30ca864c981b46e544e95f0f9f1624c4a769 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:56:13 -0400 Subject: [PATCH 04/12] test: assert every explicit-gates lane derives one-function-per-file `gates:` is an allowlist, so internals-checks, internals-distcheck and plugins-embeddings each skip the gate until their array names it -- three lanes CI silently holds to a rule nothing local runs. skip-changelog: internals tooling test, no packaged behavior --- internals/checks/tests/integration/preflight_test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internals/checks/tests/integration/preflight_test.py b/internals/checks/tests/integration/preflight_test.py index fee44bf79..ed93385aa 100644 --- a/internals/checks/tests/integration/preflight_test.py +++ b/internals/checks/tests/integration/preflight_test.py @@ -85,6 +85,15 @@ def it_derives_the_one_function_per_file_gate_for_a_lane_naming_no_gates(repo_ro 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") From 9e91dc78f8ea24be2501f165a77fce98f40710ab Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:57:48 -0400 Subject: [PATCH 05/12] ci: name one-function-per-file in every explicit gates array internals-checks, internals-distcheck and plugins-embeddings each pass an explicit `gates:` allowlist, so the reusable workflow's default set does not reach them and the gate was not running on those three lanes. skip-changelog: CI config, no packaged behavior --- .github/workflows/internals-checks-ci.yml | 2 +- .github/workflows/internals-distcheck-ci.yml | 2 +- .github/workflows/plugin-dirsql-embedding-ci.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 From f113721228796244d8b6491bfa703aaa2f21dac3 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:58:05 -0400 Subject: [PATCH 06/12] e2e attestation for claude/1018-preflight-one-function-gate --- .../claude-1018-preflight-one-function-gate.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 internals/checks/e2e-attestations/claude-1018-preflight-one-function-gate.json 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..1cbf46082 --- /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": 1788368285, + "exit_code": 0, + "commit": "9e91dc78f8ea24be2501f165a77fce98f40710ab", + "branch": "claude/1018-preflight-one-function-gate" +} From 19c23b43465c567a5fa7162cb517de4d577e3855 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:00:19 -0400 Subject: [PATCH 07/12] test: expect the python launcher to run the npm CLI under uv run The wheel `uv run --with` fetches lags npm, and it rejects a config key the newer release added -- an unknown key fails the whole config, so the python coverage and mutation arms red locally on a config CI accepts. Running the npm CLI under `uv run` keeps the package venv's interpreter for the suite while resolving the same release CI does. skip-changelog: internals tooling test, no packaged behavior --- internals/checks/src/checks/preflight/gate_test.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internals/checks/src/checks/preflight/gate_test.py b/internals/checks/src/checks/preflight/gate_test.py index 20a119864..722c19a19 100644 --- a/internals/checks/src/checks/preflight/gate_test.py +++ b/internals/checks/src/checks/preflight/gate_test.py @@ -131,9 +131,7 @@ 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[:5] == ["uv", "run", "npx", "-y", "testing-conventions@latest"] def it_rewrites_the_config_and_source_paths_relative_to_that_cwd(): assert call(PY, "python", "mutation").argv[-3:] == [ @@ -291,8 +289,8 @@ def it_runs_only_the_gates_named_by_the_filter(): lines = [] 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" + "==> python-sdk [python] mutation: uv run npx -y testing-conventions@latest " + "unit mutation --language python --base origin/main dirsql" ] def it_prints_every_pair_without_running_any_when_dry_run(): From f032f0fa05bf1ba089f1a71197a4bca124c2ad36 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:02:17 -0400 Subject: [PATCH 08/12] fix: resolve the preflight python CLI from npm, not the PyPI wheel `uv run --with testing-conventions` resolved both the CLI and the mutation adapter from the wheel, which trails the npm release. A config key the newer release added is an unknown key to the older one, and an unknown key fails the whole config -- so `unit coverage` and `unit mutation` red locally against a config CI accepts, on three python lanes at once. Split the two roles. The npm tag resolves the CLI, matching CI; `--with` stays only to put `testing_conventions` on the interpreter the gate hands to `python3 -m`, the sole form the mutation adapter ships in. skip-changelog: internals tooling, no packaged behavior --- internals/checks/src/checks/preflight/gate.py | 38 +++++++++---------- .../checks/src/checks/preflight/gate_test.py | 10 +++-- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/internals/checks/src/checks/preflight/gate.py b/internals/checks/src/checks/preflight/gate.py index ad93591ad..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` -- a throwaway environment supplies its - own interpreter, so the suite's `python3 -m pytest` resolves to one 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,26 +36,24 @@ MANIFESTS = ("pyproject.toml", "package.json", "Cargo.toml") -# The CLI, resolved the way CI resolves it: npm, newest release. Two distinct -# drifts made the local run enforce a different ruleset than CI, and `@latest` -# closes both. The PyPI wheel `uvx` fetched is a laggier distribution of the same -# tool (0.0.104 against npm's 0.0.111 when this was written), and it has no -# `one-function-per-file` subcommand at all. A BARE `npx testing-conventions` is -# worse: npm resolves an unversioned name engine-aware, `engines.node` rose to -# `>=24` at 0.0.87, and a launcher shim running an older node therefore silently -# picks 0.0.86 -- dozens of releases of rule changes behind, and exiting 0 with -# no version banner to say so. CI dodges that by provisioning node 24 in every -# job; naming the tag dodges it wherever the local node lands. +# 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 (which is -# why that one arm keeps the wheel, and stays subject to the PyPI lag above), -# 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 `CLI`. +# 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"], + "python": ["uv", "run", "--with", "testing-conventions", *CLI], "typescript": CLI, } diff --git a/internals/checks/src/checks/preflight/gate_test.py b/internals/checks/src/checks/preflight/gate_test.py index 722c19a19..81b95323e 100644 --- a/internals/checks/src/checks/preflight/gate_test.py +++ b/internals/checks/src/checks/preflight/gate_test.py @@ -131,7 +131,10 @@ 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", "npx", "-y", "testing-conventions@latest"] + 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:] == [ @@ -289,8 +292,9 @@ def it_runs_only_the_gates_named_by_the_filter(): lines = [] 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 npx -y testing-conventions@latest " - "unit mutation --language python --base origin/main dirsql" + "==> python-sdk [python] mutation: uv run --with testing-conventions " + "npx -y testing-conventions@latest unit mutation --language python " + "--base origin/main dirsql" ] def it_prints_every_pair_without_running_any_when_dry_run(): From 13aaf51d7f2edd105c8e6d302f72959d97981e2d Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:02:26 -0400 Subject: [PATCH 09/12] e2e attestation for claude/1018-preflight-one-function-gate --- .../claude-1018-preflight-one-function-gate.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 1cbf46082..041c21b43 100644 --- a/internals/checks/e2e-attestations/claude-1018-preflight-one-function-gate.json +++ b/internals/checks/e2e-attestations/claude-1018-preflight-one-function-gate.json @@ -1,7 +1,7 @@ { "command": "uv run python -m pytest tests/e2e -x -q", - "ran_at": 1788368285, + "ran_at": 1788368546, "exit_code": 0, - "commit": "9e91dc78f8ea24be2501f165a77fce98f40710ab", + "commit": "f032f0fa05bf1ba089f1a71197a4bca124c2ad36", "branch": "claude/1018-preflight-one-function-gate" } From 642bb78a78ad3065993287c5b99dd809ea6a29b4 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:04:35 -0400 Subject: [PATCH 10/12] test: re-root the hermetic tier's two stdlib patches `asyncio.to_thread` and `os.getcwd` were reached through `dirsql._async`, so upstream's patch.object resolution reads them as first-party targets. Both modules are already imported in the test files; patching them at their own home is the same object with the same scope. skip-changelog: test-only change, no published artifact affected --- packages/python/tests/integration/hermetic/dirsql_test.py | 2 +- packages/python/tests/integration/hermetic/extensions_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/python/tests/integration/hermetic/dirsql_test.py b/packages/python/tests/integration/hermetic/dirsql_test.py index fd705e7d0..ae3c3602b 100644 --- a/packages/python/tests/integration/hermetic/dirsql_test.py +++ b/packages/python/tests/integration/hermetic/dirsql_test.py @@ -104,7 +104,7 @@ async def spy(func, *args, **kwargs): calls.append(getattr(func, "__name__", repr(func))) return await real_to_thread(func, *args, **kwargs) - with patch.object(async_mod.asyncio, "to_thread", spy): + with patch.object(asyncio, "to_thread", spy): yield calls diff --git a/packages/python/tests/integration/hermetic/extensions_test.py b/packages/python/tests/integration/hermetic/extensions_test.py index 401c06abf..128212685 100644 --- a/packages/python/tests/integration/hermetic/extensions_test.py +++ b/packages/python/tests/integration/hermetic/extensions_test.py @@ -59,7 +59,7 @@ def mock_core(): @pytest.fixture def mock_cwd(): """Pin the cwd the SDK resolves programmatic entries against.""" - with patch.object(async_mod.os, "getcwd", return_value="/cwd") as m: + with patch.object(os, "getcwd", return_value="/cwd") as m: yield m From 616b6d5056760ac5d6a922f82afc25a78bcf546c Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:04:42 -0400 Subject: [PATCH 11/12] ci: waive no-first-party-patch on the hermetic core boundary Patching `dirsql._async._RustDirSQL` is the hermetic tier's definition -- it is what lets that subdir run with no native build and no real SQLite. The rule redirects a first-party patch to a third-party target, and there is none here: `dirsql._dirsql` is first-party too, and rebinding it after `_async` has bound the name would not take effect. The rule does not describe this seam, so the two suites take a scoped waiver. The repo's exemption count moves from zero to one. AGENTS.md and the testing-gates reference said zero and stays there; they now say what the one entry is and why the rule does not apply to it. skip-changelog: gate config and docs, no published artifact affected --- AGENTS.md | 2 +- agents/reference/testing-gates.md | 2 +- packages/python/testing-conventions.toml | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 787033812..543db993b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,7 @@ A feature is not done until integration tests pass and cover the new functionali ### Enforcing Colocation (testing-conventions) -The colocation rule is a blocking CI gate ([`testing-conventions`](https://github.com/thekevinscott/testing-conventions), wired in the per-domain CI workflows + `testing-conventions.toml`): every source file needs a colocated unit test (Rust: inline `#[cfg(test)]`), a PR-only co-change check flags modified sources whose test didn't change, and `unit lint` enforces **isolation** -- a unit test must mock every collaborator (Python `patch(...)`, TS `vi.mock`, Rust trait double or relocate the effectful test), never weaken the test. The gates also cover the two binding crates and the repo-tooling `internals/checks` / `internals/distcheck` uv packages. **The exemption count is zero and stays there**: barrels get a colocated surface test, logic gets extracted, dead shells get deleted -- an exemption is never the escape hatch. Full wiring, per-package gate map, and history: `agents/reference/testing-gates.md`. +The colocation rule is a blocking CI gate ([`testing-conventions`](https://github.com/thekevinscott/testing-conventions), wired in the per-domain CI workflows + `testing-conventions.toml`): every source file needs a colocated unit test (Rust: inline `#[cfg(test)]`), a PR-only co-change check flags modified sources whose test didn't change, and `unit lint` enforces **isolation** -- a unit test must mock every collaborator (Python `patch(...)`, TS `vi.mock`, Rust trait double or relocate the effectful test), never weaken the test. The gates also cover the two binding crates and the repo-tooling `internals/checks` / `internals/distcheck` uv packages. **There is exactly one exemption**: `no-first-party-patch` on the two `packages/python/tests/integration/hermetic/` suites, waived because patching the first-party `_RustDirSQL` core boundary *is* what makes that tier hermetic -- there is no third-party target the rule could redirect it to, so the rule does not describe that seam. Everywhere else the count stays zero and an exemption is never the escape hatch: barrels get a colocated surface test, logic gets extracted, dead shells get deleted. Full wiring, per-package gate map, and history: `agents/reference/testing-gates.md`. **Run every gate CI declares before pushing** with `just preflight` (#781), which *derives* the (source, gate) pairs from the CI workflows instead of restating them -- 40 pairs across 8 scan roots, versus the 6 across 3 the four hand-written recipes it replaced covered. It reads every `.github/workflows/*.yml` and folds together each job that calls the reusable workflow, so a lane added in a new per-domain file is a pair it runs. It also encodes the invocations that differ from the naive one (python suites via the package venv, typescript via `npx`, per-gate option support), each of which used to pass locally while failing in CI. `--dry-run` prints the matrix; `--gate ` and `--conventions ` narrow it (both repeatable); `packaging` reports SKIP since it needs a built artifact (`just test-packaging`). diff --git a/agents/reference/testing-gates.md b/agents/reference/testing-gates.md index 767bd7ea1..324996899 100644 --- a/agents/reference/testing-gates.md +++ b/agents/reference/testing-gates.md @@ -38,7 +38,7 @@ flags. `packaging` is reported **SKIP**: it inspects a built artifact, so use **Exemptions.** The goal is **zero** exemptions, and barrels are no longer an excuse for one. A re-export barrel gets a **colocated test that asserts its public surface** (TS `src/index.ts` ↔ `index.test.ts`, python `dirsql/__init__.py` ↔ `__init___test.py`), exactly as any module would -- it is *tested*, not exempted, so a broken re-export is caught. An `__init__.py` carrying no executable logic is made **truly empty** (0 bytes), which the tool auto-skips with no config entry. A package shell left dead by a feature removal is **deleted**, not parked behind an exemption. When a "barrel" actually holds logic, the fix remains to **extract that logic into colocated-tested modules** (#239). The npm `bin` shim `src/cli/dirsql.ts` is likewise *not* exempt: its error-handling lives in the unit-tested `cli/run-cli.ts`, leaving a trivial `runCli()` shim covered by a mocked distcheck-test. -The exemption count is again **zero**. The three-tier conformance work (#517) restored it: the binding suites moved into the recognized tier `tests/integration/binding/` (#519) and the packaging distcheck flows moved to the `internals/distcheck` package (#520), so nothing trips `unknown-tier` anymore and the temporary `unknown-tier` waivers #518 added are all removed. Before that the last standing entry -- the python barrel-test isolation waiver (`[[python.exempt]] path = "__init___test.py" rules = ["unmocked-collaborator"]`) -- was removed once testing-conventions#382 / PR #384 brought the python `unmocked-collaborator` rule to parity with TS (a bare package-relative import `from . import ` in a barrel test now resolves to the SUT and passes). Exemption entries carry a `path` (relative to the scanned source dir for source-file rules, but the derived package root for the suite-tier `unknown-tier` rule), the `rules` waived, and a required `reason`; the CLI **rejects a stale entry whose `path` matches no file**, so it self-cleans. Adding a *new* untested source file fails the gate -- an exemption is never the escape hatch. +The exemption count is **one**: `no-first-party-patch` waived on `tests/integration/hermetic/dirsql_test.py` and `tests/integration/hermetic/extensions_test.py` in `packages/python/testing-conventions.toml`. Patching the first-party `_RustDirSQL` core boundary is the hermetic tier's definition -- it is why that subdir needs no native build -- and there is no third-party target to redirect the patch to, so the rule has nothing to say about that seam. It is not a way past a real failure: the two stdlib patches reported alongside it (`asyncio.to_thread`, `os.getcwd`, both reached through `dirsql._async`) were re-rooted at their own modules rather than waived, and everything else remains at zero. Before this entry the count was zero. The three-tier conformance work (#517) restored it then: the binding suites moved into the recognized tier `tests/integration/binding/` (#519) and the packaging distcheck flows moved to the `internals/distcheck` package (#520), so nothing trips `unknown-tier` anymore and the temporary `unknown-tier` waivers #518 added are all removed. Before that the last standing entry -- the python barrel-test isolation waiver (`[[python.exempt]] path = "__init___test.py" rules = ["unmocked-collaborator"]`) -- was removed once testing-conventions#382 / PR #384 brought the python `unmocked-collaborator` rule to parity with TS (a bare package-relative import `from . import ` in a barrel test now resolves to the SUT and passes). Exemption entries carry a `path` (relative to the scanned source dir for source-file rules, but the derived package root for `integration lint`'s suite subjects -- both `unknown-tier` and the mocking lints), the `rules` waived, and a required `reason`; the CLI **rejects a stale entry whose `path` matches no file**, so it self-cleans. Adding a *new* untested source file fails the gate -- an exemption is never the escape hatch. ## Mutation The rung above coverage is the **`unit mutation`** gate (#235 / epic #231): testing-conventions mutates the source and fails on any **surviving** mutant -- one no unit test caught. Engines: **cosmic-ray** (Python), **Stryker** (TypeScript), **cargo-mutants** (Rust). It is **PR-only and diff-scoped** (`--base ...HEAD`): only the lines a PR added/modified are mutated, so each PR's surface stays bounded. A PR that changes no SDK source has nothing to mutate and passes trivially. diff --git a/packages/python/testing-conventions.toml b/packages/python/testing-conventions.toml index 40bf0dd6b..034008073 100644 --- a/packages/python/testing-conventions.toml +++ b/packages/python/testing-conventions.toml @@ -13,3 +13,19 @@ extra_scope = ["packages/rust/src", "packages/python/src"] [python.coverage] fail_under = 100 branch = true + +# The hermetic integration tier is defined by patching the first-party core +# boundary: `dirsql._async._RustDirSQL` is the PyO3 class, and replacing it is +# what lets these tests run SQLite-free and native-build-free. There is no +# third-party target to re-root onto, so `no-first-party-patch` has nothing to +# say about this seam. Every other patch in both files targets stdlib and stays +# gated. +[[python.exempt]] +path = "tests/integration/hermetic/dirsql_test.py" +rules = ["no-first-party-patch"] +reason = "the hermetic tier IS the _RustDirSQL core-boundary patch; no third-party target exists to move it to" + +[[python.exempt]] +path = "tests/integration/hermetic/extensions_test.py" +rules = ["no-first-party-patch"] +reason = "the hermetic tier IS the _RustDirSQL core-boundary patch; no third-party target exists to move it to" From ab123f996932e65485da2a7ee2948df64cbfc696 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:06:04 -0400 Subject: [PATCH 12/12] docs: scope the mutation-audit's zero claim to mutation exemptions skip-changelog: docs only --- agents/reference/testing-gates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/reference/testing-gates.md b/agents/reference/testing-gates.md index 324996899..36d3c699b 100644 --- a/agents/reference/testing-gates.md +++ b/agents/reference/testing-gates.md @@ -91,7 +91,7 @@ Keep the job count low enough that the build dirs fit: each *unscoped* job dir r **What the audit found.** Unit scope: 330 caught, **61 survivors**, 75 unviable, 4 timeouts. Re-running those 61 under CI's scoping, **40 are killed by the integration tier** -- the #233 split (effectful filesystem / subprocess / HTTP / signal code lives in the integration tier by design) showing up as mutation survivors rather than gate escapes -- 3 time out, and **18 escape every tier**. Those 18 divide three ways, and the split is what matters for anyone repeating this: - **7 were real gaps and are now closed** -- six operator mutations in `matcher.rs`'s `placeholder_spans` (its own grammar counter-examples were untested) and `lib.rs`'s `prepare_persist` reading `meta_current` as `compatible && lengths match`, where `&&` -> `||` survived. The tests added for them kill 14 mutants at those sites. -- **7 are equivalent mutants, proved rather than argued.** `|` -> `^` over disjoint bit fields is always a no-op: `function_flags`' `SQLITE_UTF8|SQLITE_DETERMINISTIC` (bits `0x1` and `0x800`), `base64_encode`'s `(b0<<16)|(b1<<8)|b2` (exhaustive over all 16,777,216 inputs), and `base64_decode`'s `(triple<<6)|value_of(b)` (all 67,108,864). In `placeholder_spans`, `j += 1` -> `j *= 1` is re-consumed by the following `while`, and `i = j + 1` -> `i = j * 1` / `i = j - 1` land on the closing brace or a name byte, never a `{` -- zero differing results over all 335,923 strings up to length 7 on `{ } a 1 _ -`. **No exemptions were added**: the gate is diff-scoped, so these only surface if someone edits those lines, and the exemption count stays zero. +- **7 are equivalent mutants, proved rather than argued.** `|` -> `^` over disjoint bit fields is always a no-op: `function_flags`' `SQLITE_UTF8|SQLITE_DETERMINISTIC` (bits `0x1` and `0x800`), `base64_encode`'s `(b0<<16)|(b1<<8)|b2` (exhaustive over all 16,777,216 inputs), and `base64_decode`'s `(triple<<6)|value_of(b)` (all 67,108,864). In `placeholder_spans`, `j += 1` -> `j *= 1` is re-consumed by the following `while`, and `i = j + 1` -> `i = j * 1` / `i = j - 1` land on the closing brace or a name byte, never a `{` -- zero differing results over all 335,923 strings up to length 7 on `{ } a 1 _ -`. **No exemptions were added**: the gate is diff-scoped, so these only surface if someone edits those lines, and the `mutation` exemption count stays zero. - **4 sat in effectful code no tier reached** -- `RootedFs::hash` (two), `cli/run.rs`'s `load_state` guard and `wait_for_shutdown`. Tracked as #1012 rather than re-litigating #233 there, and closed by it at the integration and e2e tiers (below). The headline: nothing merged behind the blind gate turned out to be a silent behavior regression. What it hid was **test debt** -- two pure functions whose own documented edge cases had no assertions.