Skip to content

ci: adopt one-function-per-file at max_lines = 100 - #1023

Merged
thekevinscott merged 15 commits into
mainfrom
claude/1017-ofpf-threshold
Sep 4, 2026
Merged

thekevinscott merged 15 commits into
mainfrom
claude/1017-ofpf-threshold

Conversation

@thekevinbot

@thekevinbot thekevinbot commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1017
Fixes #1024

testing-conventions@v0 rolled to 78ab3452 and added two gates that both fail on main. This branch now carries the fix for both:

Neither gate fix could go green alone: with only the threshold change Lint integration tests still failed; with only the patch fix Check one function per file still failed. That is why they were stacked and are now one diff.


Part 1 -- one-function-per-file at max_lines = 100 (#1017)

unit one-function-per-file now runs as a step of the Static checks (<language>) job. The rule was unconfigured here, so it ran at the shipped default of 1 and main was already red on it: python-sdk / Static checks (python) and typescript-sdk / Static checks (typescript) both failed the Check one function per file step (runs 33655717589, 33655137333).

What the knob measures

max_lines is not a cap on function length. It is the threshold above which a module-scope function counts as substantial and must own its file; functions at or under it are trivial and share a file freely. A higher number is a looser gate, and upstream's default of 1 is the strictest setting available, not a one-line function limit.

100 is thekevinscott/willfire's value, described there as a "starting ceiling for one-function-per-file, to be ratcheted down over time". Ratcheting is tracked in #1020 and deliberately out of scope here -- at the default the TypeScript SDK alone owes 12 file splits, each with its own module, colocated test, and call-site updates.

Rust stays opted out, matching upstream's default: a Rust file is a module, and grouping a type with its impl blocks and free functions is how Rust is written. Opting it in is a design call, not a threshold.

Config lives in five files rather than one -- each lane that passes its own config: restates the tables its gates need -- so the threshold is set in all five.

Verified

$ npx testing-conventions@latest unit one-function-per-file --language python \
    --config packages/python/testing-conventions.toml packages/python/dirsql
testing-conventions 0.0.111
$ npx testing-conventions@latest unit one-function-per-file --language typescript \
    --config packages/ts/testing-conventions.toml packages/ts/src
testing-conventions 0.0.111

Both exit 0 (a green run prints only the version banner). The three Python configs that did not yet run the gate -- internals/checks, internals/distcheck via the root default, and the embeddings plugin -- were checked at 100 too and are green; #1019 (merged into this branch) turns the gate on for those lanes and teaches just preflight to derive it.


Part 2 -- no-first-party-patch in the Python hermetic tier (#1024, from #1026)

integration lint's no-first-party-patch rule now resolves the patch.object(...) object form, which made five previously-invisible patches in packages/python/tests/integration/hermetic/ visible. The five split two ways.

Re-rooted: the two stdlib patches

asyncio.to_thread and os.getcwd are effectful stdlib, which the rule permits. They only tripped it because the base was reached through dirsql._async:

patch.object(async_mod.asyncio, "to_thread", spy)          ->  patch.object(asyncio, "to_thread", spy)
patch.object(async_mod.os, "getcwd", return_value="/cwd")  ->  patch.object(os, "getcwd", return_value="/cwd")

Both modules were already imported in their test files, and async_mod.asyncio is the asyncio module object -- patch.object mutates that same object either way, so the patch is byte-for-byte the same target with the same scope. Nothing is broadened.

Waived: the three _RustDirSQL patches

Patching dirsql._async._RustDirSQL is not a shortcut around the integration tier -- it is the tier's definition. AGENTS.md states it under "Test Locations": tests/integration/hermetic/ mocks SQLite and the filesystem by patching the _RustDirSQL core boundary, which is precisely why that subdir needs no native build.

The rule's remedy is to redirect a first-party patch at a third-party target. There is none here. _RustDirSQL is from dirsql._dirsql import DirSQL -- first-party as well -- and rebinding it after _async has already bound the name would not take effect. Upstream's narrowest supported scoping for a lint rule is per-file, so: two entries, each naming one file and only the one rule, in packages/python/testing-conventions.toml.

AGENTS.md and agents/reference/testing-gates.md both claimed the exemption count was zero; both are updated to name the entry and why the rule does not apply to it.


E2E Verification

N/A - CI gate config, repo tooling, and test-file edits only.

The combined diff touches no SDK runtime source. Under packages/, it changes packages/python/testing-conventions.toml, packages/ts/testing-conventions.toml, and two files under packages/python/tests/integration/hermetic/ -- gate config plus test files, both explicitly "not substantial" per AGENTS.md. The rest is .github/workflows/, root/internals/plugins gate config, internals/checks/ repo tooling, and prose (AGENTS.md, agents/reference/). Nothing ships in a published artifact.

  • Ran e2e suites locally for every affected SDK -- N/A, no SDK source changed
  • Python SDK e2e: N/A
  • TypeScript SDK e2e: N/A
  • Rust core e2e: N/A
  • packages/python/e2e-attestations/<branch>.json receipt: not owed -- packages/python scoped source (src/) is untouched; the python-sdk / E2E attestation freshness gate passes on this diff
  • packages/ts/e2e-attestations/<branch>.json receipt: not owed -- packages/ts/src and packages/ts/napi untouched; the typescript-sdk e2e-verify gate passes

internals/checks/e2e-attestations/claude-1018-preflight-one-function-gate.json is present in the diff, written by #1019 for the internals/checks changes it brought in; internals-checks / E2E attestation freshness passes.

Local verification of the merged branch: just preflight --gate one-function-per-file --gate integration-lint reports 0 failing pairs across all 11 (source, gate) pairs at testing-conventions 0.0.111 -- both previously-red gates, both directions. Python suites from packages/python: uv run python -m pytest dirsql/ -q 108 passed, uv run python -m pytest tests/integration/ -q 131 passed, ruff check . / ruff format --check . clean.

Changelog / Migrations

  • Changelog fragment added under <root>/<pkg>/changelog.d/ for each changed package (or: skip-changelog trailer on a commit with reason)
  • Migration fragment added under <root>/<pkg>/migrations.d/ (or: not required -- additive/bugfix only)

No fragment is required and none was added. A package-root testing-conventions.toml is explicitly exempt in changelog_gate/decide.py ("gate config that ships in no published artifact"), and everything under packages/<pkg>/tests/ is exempt by name -- together that is the entire diff under packages/. The root, internals/, .github/, and prose files are outside the gate's packages/ + plugins/ roots entirely, except plugins/dirsql-embedding/testing-conventions.toml, which is exempt on the same grounds. Commits carry skip-changelog: lines regardless. Changelog Check is green.

No migration fragment: no public API changes, no runtime behavior changes.

PARITY.md is unchanged -- no SDK public API is touched.

Related

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.
@thekevinbot
thekevinbot force-pushed the claude/1017-ofpf-threshold branch from 7171065 to d5dd874 Compare September 2, 2026 16:55
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
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
`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, 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
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
`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
`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
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
thekevinscott and others added 3 commits September 2, 2026 14:57
…-function-gate

internals/checks: teach preflight the one-function-per-file gate
ci: fix the no-first-party-patch failures in the Python hermetic tier
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants