You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ci: ratchet one-function-per-file from max_lines = 100 toward the default of 1 #1020
Follow-up to #1017, which adopted unit one-function-per-file at a starting ceiling rather than at the gate's real intent.
Current value:one_function_per_file = { max_lines = 100 } in the [python] / [typescript] tables of testing-conventions.toml, packages/python/testing-conventions.toml, packages/ts/testing-conventions.toml, internals/checks/testing-conventions.toml, and plugins/dirsql-plugin-embeddings/testing-conventions.toml. Rust is not opted in.
What the number means.max_lines is the threshold above which a module-scope function counts as substantial and must own its file; anything at or under it is trivial and shares a file freely. A higher number is a looser gate. At 100 the rule is close to inert for this codebase -- it only fires when one file holds two functions of over a hundred code lines each -- so today it buys the colocation guarantee it exists for almost nowhere.
Why staged rather than done at once. At the default of 1 the TypeScript SDK alone reports 12 violations, in packages/ts/src/cli/main.ts, cli/resolve-config-extensions.ts, platforms.ts, resolve-config-extensions.ts, and resolve-extension.ts. Each fix is a real file split -- a new module, its colocated unit test, imports updated at every call site -- and it drags coverage, mutation, and co-change along with it. Bundled into the adoption PR that would have been one large unreviewable change on top of a CI outage; upstream's own guidance is to adopt at a threshold that passes and walk it down. Splitting files is also the kind of change worth reviewing on its merits, not as collateral of a config bump.
Suggested shape. One PR per step, each dropping the ceiling to the next number that is green after that step's splits, TypeScript first since that is where the violations are. Enumerate the surface at any threshold with:
Rust stays out of scope. Upstream defaults the rule off for Rust because a Rust file is a module, and grouping a type with its impl blocks and free functions is idiomatic. Opting Rust in is a separate design call, not a step on this ratchet.
Pin the tag. A bare npx testing-conventions resolves engine-aware, so a launcher shim on a node older than the package's engines.node silently picks an older release and exits 0 with no banner (see #1018).
Where it landed. All five opted-in configs are at max_lines = 12, except plugins/dirsql-plugin-embeddings/testing-conventions.toml, which sits at 10 (it was already clean there, so no reason to loosen it):
The ladder so far: 100 -> 24 (#1033/#1035), 24 -> 16 (#1039/#1040), 16 -> 15 (#1048, free), then the splits — #1045 (distcheck node_flow), #1046 (checks B1, four gate.py files), #1047 (python), #1049 (ts), #1050 (checks B2, parse.py + matrix.py) — and #1051 dropped the ceiling to 12.
Rust remains out of scope, unchanged.
Why it stopped at 12 rather than 10. Kevin's call, and it matches #1042's own recommendation. 12 was where the cost curve turned: the remaining moves stop being "this file holds two substantial functions" and start being the gate rearranging cohesive modules.
What the next step costs. Four moves to reach 10, per #1042's census (measured with testing-conventions@0.0.111 pinned):
Three roots (packages/python, packages/ts, internals/checks), so it is at least two PRs under the per-lane split convention, each owing a new module, a colocated unit test, relocated test cases, updated call sites, and the coverage / mutation / co-change fallout. internals/distcheck and the plugin are clean at 10 already.
Two of the four are marginal private helpers in resolver modules (_resolve_entries, resolveConfigsExtensionSpecs); one is a run orchestrator flagged only because a 12-line sibling holds its file. None is a legibility win on its own merits — that is the argument the next step has to answer.
Below 10 the surface has not been measured; every threshold from 100 down to 10 was swept in #1042's harness, so 9 and lower needs a fresh census run:
npx -y testing-conventions@0.0.111 unit one-function-per-file \
--language <lang> --config <config><source>
Exemptions for this gate remain at zero across every step, and no function was golfed under a limit.
Endgame step filed as #1053: census at max_lines = 1 (83 violations across 30 files in 5 roots), rule mechanics verified empirically, five-lane concurrent wave plan (~29 PRs), and what deleting the override means per config file.
#1053 was filed as a separate tracker for the ratchet endgame, but its scope is this issue's
remaining work restated, so it is closed as a duplicate and its content lives here.
Two caveats on the material below, learned since it was written:
Spillover is ~15%. At rung 9, 9 flagged violations cost 11 actual moves — three cases had to
drag a companion (a caller, a constant, a type alias) into the new module to avoid a two-way
import cycle. Budget ~95 moves to reach 1, not 83.
Step under #1020. Predecessors: #1033/#1035 (100 -> 24), #1037 (24 -> 16), #1042 (16 -> 12, PR F = #1051 in flight). This is the endgame: ratchet to the upstream default of 1 and then delete the one_function_per_file override from all five configs so the tool's own default applies.
This issue is the census, the rule mechanics, and the wave plan. It files no per-batch issues; the orchestrator files those as each wave starts.
1. How the rule behaves at max_lines = 1
Verified empirically against testing-conventions@0.0.111 with a synthetic fixture tree, not reasoned about. The end state is achievable -- nothing in the repo is unfixable.
Subjects are module-scope functions only. At max_lines = 1, every module-scope function whose body runs 2 or more code lines and is not the first such function in its file is a violation.
Confirmed not counted (fixture returned 0 violations for each):
Construct
Counted?
Methods on a class -- incl. @property, @staticmethod, @classmethod
No. A class with five 3-line methods is invisible to the rule.
Nested / inner def, nested arrow, nested function declaration
No. The const is not bound to an arrow/function expression, so it is not a subject and its methods are invisible.
Re-export barrels (export { a } from "./x.js", export * from, export type {})
No. 0 violations.
TS overload signatures
No. Only the implementation counts.
A function whose body is 1 code line
No. Any number of 1-line functions share a file freely.
Blank lines, comments, Python docstrings, the signature
Not counted toward length. A function with a 4-line docstring, a comment and one statement runs 1 line.
Counted: Python def / async def at module level; TypeScript top-level function declarations and top-level const/let/var bound to an arrow function or function expression, exported or not.
Consequence worth stating: the gate is trivially side-steppable. A fixture with two 2-line @staticmethods on a private class _Ops plus module-level aliases (two_lines = _Ops.two_lines) reports 0 violations while presenting an identical import surface. So does folding helpers into an object literal in TypeScript. We are not going to do that -- but it means the rule enforces "one module-scope def per file", not "one function per file", and no gate can tell the difference.
Which function holds the file is a free choice. The holder is the first over-threshold function in source order, so keeping the module's named export means moving everything else out (or reordering so the keeper is first). Same freedom prior waves used.
2. Tool version -- npm 0.0.111 is what CI runs; PyPI 0.0.104 is a different thing
All six per-domain workflows call thekevinscott/testing-conventions/.github/workflows/testing-conventions.yml@v0 (tag resolves to 78ab345) and pass no version: input. The reusable workflow's gate step is:
${CLI_COMMAND:-npm --prefix "$RUNNER_TEMP" exec --yes -- "testing-conventions${VERSION:+@$VERSION}"} unit one-function-per-file ...
With VERSION empty that is npm exec testing-conventions -> npm latest, today 0.0.111. CI is unpinned and floats.
Version discrepancy, flagged as asked: npm latest = 0.0.111, PyPI latest = 0.0.104. The two distributions are versioned separately. The npm package is what the gate runs; the PyPI package is what just e2e-attest-* uses locally (and is the one carrying the #1041 prune-and-auto-commit hazard). A local pip-installed testing-conventions can therefore never reproduce the gate exactly -- always census with npx -y testing-conventions@0.0.111, never the python CLI.
3. Census at max_lines = 1
Clean throwaway clone of origin/main at b2c4651, all five configs rewritten to max_lines = 1, one run per scan root with testing-conventions@0.0.111. Nothing committed.
Scan root
Language
Config it reads
Violations at 1
Files affected
internals/checks/src
python
internals/checks/testing-conventions.toml
54
20
packages/ts/src
typescript
packages/ts/testing-conventions.toml
10
4
packages/python/dirsql
python
packages/python/testing-conventions.toml
8
2
plugins/dirsql-plugin-embeddings/src
python
plugins/.../testing-conventions.toml
6
3
internals/distcheck/src
python
roottesting-conventions.toml (passes no config:)
5
3
Total
83
30
(The earlier rough census -- python 9, ts 12, checks 63, distcheck 6, plugin 6 -- is superseded; the splits landed under #1037/#1042 moved every number.)
The ladder, 12 down to 1
Same harness, every threshold swept.
Threshold
checks
ts
python
distcheck
plugin
Total
12
0
0
0
0
0
0
11
1
0
0
0
0
1
10
1
2
1
0
0
4
9
5
2
2
0
0
9
8
5
2
2
0
0
9
7
10
3
3
0
0
16
6
18
4
4
0
0
26
5
21
4
5
0
1
31
4
23
7
6
2
1
39
3
35
9
6
3
2
55
2
42
9
8
3
4
66
1
54
10
8
5
6
83
12 is confirmed clean, so #1051 will be green. There is one free step left after it (11 costs 1 move, internals/checks only) and then the curve turns: 9 is the last two-digit stop, and everything below 7 is a cliff.
Size distribution of the 83 -- this is the finding
Body length
Count
Share
2 lines
10
12%
3 lines
7
4 lines
11
5 lines
7
35 moves (42%) are <= 5 lines
6-9 lines
20
10-19 lines
16
20+ lines
12
14%
Forty-two percent of the remaining work extracts a function of five lines or fewer into its own module with its own colocated test file, its own 100%-coverage obligation and its own mutation survivors. Seventeen of them are three lines or fewer.
Verified: one_function_per_file is the only key directly inside the [python] / [typescript] table in all five configs. Everything else lives in a sibling sub-table ([python.coverage], [typescript.coverage]) or a different table ([e2e], [[python.exempt]], [rust]).
So deleting the key leaves an empty [python] / [typescript] header. Both forms were tested and behave identically to max_lines = 1:
an empty [python] table -> default 1
no [python] header at all, keeping [python.coverage] -> default 1 (TOML allows the dotted sub-table without its parent, and the coverage gate still reads it)
an entirely empty config file -> default 1
Upstream's default is genuinely 1 for Python and TypeScript -- verified against the @v0 source tree, not assumed (docs/reference/config.md: "1 -- a one-line function is trivial and shares freely"; docs/reference/checks/one-function-per-file.md default table; MIGRATIONS.md line 43). Rust has no default: the rule is off until [rust].one_function_per_file exists. Rust stays out of scope, unchanged from #1020/#1033/#1037/#1042.
Recommended end state per file -- delete the key and the now-empty parent header:
Note the root [typescript] table is read by no scanned root today -- packages/ts carries its own config and there is no other TS tree -- so its ceiling is already vestigial.
5. Config topology -- the ceilings are already independent
Each scan root reads exactly one config, and only internals/distcheck reads the root file:
Prior waves moved all five in lockstep by convention, not necessity. Stop doing that. Decoupling the ceilings removes the single worst concurrency hazard from the 16->12 campaign: five lanes can then walk down independently and no two PRs ever contend for the same max_lines line. (The plugin is already out of lockstep at 10 vs 15, which is fine and proves the point.)
6. Wave plan
Invariants, non-negotiable:
Every split PR is config-free. No PR touches a max_lines line and a source file in the same diff.
Exactly one PR per root ever touches that root's config -- the last one in that root's lane, which deletes the key outright rather than stepping to a number.
Cap a split PR at ~5 moves. A move is a new source module + its colocated test + test-case surgery in the original test file + call-site updates, so 5 moves is already ~15 files -- the top of M.
Exemptions stay at zero. Every violation is fixed by moving a function. Never by an exempt entry, never by golfing under the limit.
Lane C -- internals/checks (54 moves)
Nine check packages are separate directories, so the lane parallelises well. Ordering constraints called out inline.
delete [python]and the vestigial [typescript] table from the roottesting-conventions.toml
0
XS
D1, D2
Concurrency groups
Lanes C / T / P / E / D touch disjoint source trees and disjoint config files. All five lanes run concurrently. Within lanes, these fire in parallel:
Wave 1 (9 concurrent PRs): C1, C2, C3, C4, C6, C8, C10, C12, C14 | T1, T2, T3 | P1 | E1 | D1, D2
-- that is 16 concurrent PRs, which is too many agents at once in practice; throttle to ~6 in flight and drain.
Wave 3: the five *-final config PRs, each independent of the others.
Collision surfaces, explicitly
Config files -- eliminated by invariant 2. Only one PR per root touches a config, and it lands last in that lane.
wheel_extension_load -> npm_binary_extension_load -- the only cross-package import inside internals/checks. C9 must follow C8.
Same-file pairs -- C10/C11 (declared_deps/gate.py) and C14/C15 (platforms_mirror/decide.py) split one file across two PRs and cannot be concurrent. Stack them (base = the parent branch) rather than serialising.
Cross-root data coupling:checks/platforms_mirrorparsesinternals/distcheck/src/distcheck/node_flow/platforms.py and packages/ts/src/platforms.ts by hard-coded path (PYTHON_FILE, TYPESCRIPT_FILE), reading class Platform and PLATFORMS = [...] out of the former. D2 and T2 both edit a parsed file. Verified low-risk -- the parser reads the dataclass and the table, not find_host_platform or librarySlug (which appear only in comments and error text) -- but D2 and T2 must each run dirsql-checks platforms-mirror locally before pushing, and D2 must leave class Platform and PLATFORMS at module scope in platforms.py.
unit lint isolation -- every extraction creates the exact shape the gate rejects: a unit test importing a sibling source module counts as an unmocked collaborator. Patch the symbol by string at the consuming module's namespace, or import it from the module under test if that module re-exports it.
Import cycles / shared vocabulary -- extracting a function whose exception, constant or interface lives in the original file forces that vocabulary into a leaf module too. Precedent: platforms_mirror/parse.py needed 3 moves where the gate demanded 2; node_flow/gate.py needed a separate errors.py. platforms_mirror/decide.py (C14) is the worst case here -- SHARED, LIB_PREFIX, DERIVE, _slug and key are interleaved with the six functions being moved. Budget ~10-15% more moves than 83; call it ~95.
Mutation survivors from relocated constants -- moving a constant alongside its function survives mutation unless a test pins the literal. Applies to PATH_PREFIXES (E1), SHARED/LIB_PREFIX (C14), PROBE_SQL/ENTRYPOINT/BIN_NAME (C9), CLASS_NAME/TABLE_NAME (C12).
e2e receipts under e2e attest silently deletes other branches' attestation receipts #1041 -- receipts are per-branch so the paths never collide, but the PyPI testing-conventions CLI prunes stale receipts and auto-commits the deletions. With ~10 branches in flight at once every attest run will see the other lanes' receipts as stale. Every implementation PR must git show --stat its attest commit and drop any receipt deletion it did not intend.
Obligations per lane (verified against the repo, not assumed)
Lane
e2e receipt
Changelog fragment
Migration
PARITY.md
C (internals/checks)
Yes -- internals/checks/e2e-attestations/<branch>.json; the workflow's gates: list does include e2e-verify
No -- changelog_gate/decide.py has _ROOTS = ("packages", "plugins")
No
No
T (packages/ts)
Yes (just e2e-attest-ts)
Yes -- packages/ts/changelog.d/
No -- behaviour-preserving, moved symbols not re-exported
Verify per PR; none of the 10 appears in PARITY.md
No source change -> no receipt; _is_exempt waives a package-root testing-conventions.toml by name -> no fragment
T and P reorganise mirrored files, so say so in the PR body even though there is no drift to record.
Gates that judge every split: 100% unit-coverage floor, changed-lines coverage, unit mutation (fix a survivor with a new assertion, never by weakening a test), unit lint isolation, colocated-test and its PR-only co-change check. Run just preflight before pushing.
7. Cost, end state, and a recommended stopping point
Cost: ~95 moves (83 measured + cycle spillover) across ~24 split PRs plus 5 config PRs = ~29 PRs, in five concurrent lanes.
End state, counting non-test source files:
Root
Today
At max_lines = 1
internals/checks/src
45
~99
packages/ts/src
15
25
packages/python/dirsql
14
22
plugins/.../src
15
21
internals/distcheck/src
12
17
Total source
101
~184
Plus ~83 new colocated test files (internals/checks goes from 35 to ~89). Roughly +170 files repo-wide, an 80% increase in source-file count, for zero behaviour change.
Nothing is impossible. Classes, methods, nested functions and barrels are all invisible to the rule, so there is no structure in this repo the ratchet cannot reach. The blocker is not feasibility.
The honest assessment: the last stretch is not worth what it costs.
42% of the remaining moves extract a function of five lines or fewer.plugins/.../search/sql.py is the archetype: a 41-line module that builds one SQL string, made of quote (2), normalize_glob (3), embed_call (3), build_search_sql (8) and a 1-line count_corpus_sql, sharing one constant. At 1 it becomes four source files and four test files, each importing the others, and sql.py keeps quote. Nothing about that is easier to read, and the colocated-test guarantee the rule exists to buy was already true -- the module had one subject.
internals/checks/src/checks/declared_deps/gate.py is 158 lines, thirteen module functions, one check. At 1 it is ten files. The gate is not finding a problem there; it is rearranging a file that is already the right size.
The rule is side-steppable in one edit (section 1). A convention that a class of static methods satisfies but a module of free functions does not is enforcing file layout, not cohesion -- and at max_lines = 1 that is all it is enforcing.
The rule's stated purpose is that a coverage or mutation result reported against a file is a result about one function. At 12 that is already true for every substantial function in the repo. Below ~7 the marginal claim is about 2-to-6-line helpers, where per-file attribution buys nothing a reader could not get from the function name.
Recommended stopping point: 9.
Stop
Moves from 12
What it buys
11
1
free-ish; one internals/checks move
9
9
one small PR per lane; every function of 10+ lines owns its file
~8 PRs; the plugin's SQL builder starts fragmenting
3
55
~14 PRs
1
83 (~95)
~29 PRs, ~+170 files
At 9 the guarantee reads cleanly: every function of ten lines or more owns its file. It is nine moves -- roughly one M-sized PR per lane, four of the five lanes are one PR each -- and it stops before the first genuinely cohesive module has to be broken up.
Cost of going the rest of the way anyway: ~86 more moves, ~25 more PRs, ~+160 more files, and five concurrent agent lanes running for as long as it takes to drain waves 1 and 2. Every one of those PRs still has to clear 100% coverage, mutation, isolation and co-change. It is all mechanical, it is all reviewable, and none of it is blocked -- it is simply expensive, and the last 40% of it is spent on functions under six lines.
Kevin has committed to 1, so the plan above goes to 1. This section is the evidence, not a veto. If the answer is still 1, execute lanes C/T/P/E/D as written and the endgame is section 4's five config deletions.
Rust stays out of scope
Unchanged from #1020, #1033, #1037 and #1042. Upstream defaults the rule off for Rust because a Rust file is a module. Opting Rust in is a separate design call, not a step on this ratchet.
Lane P (packages/python/dirsql) — measured and in flight
Re-measured against main at f575c06 with the pinned npm CLI, since the folded-in census predates #1056 (which created resolve_entries.py and resolve_configs_extension_specs.py).
6 violations across 2 files, not the 8 the census table shows, and both holders differ from its prediction:
File
Function
Lines
resolve_config_extensions.py
_load_extension_entries
11
resolve_config_extensions.py
_has_bare_name
6
resolve_config_extensions.py
resolve_config_extension_specs
7
resolve_extension.py
is_bare_name
3
resolve_extension.py
_resolve_package
35
resolve_extension.py
resolve_extension_path
8
Two stacked PRs, one file each, each keeping the module’s named export as the holder:
Spillover was zero here, against the ~15% budgeted. Both constants that had to move (_LOADABLE_SUFFIXES, the _toml binding) have exactly one reader, so each rode along inside its function’s new module — no leaf vocabulary module was needed and no import cycle appeared. 6 flagged, 6 moves.
With #1087 the whole packages/python/dirsql tree reports 0 violations at max_lines = 1. Neither PR touches a testing-conventions.toml; packages/python’s ceiling stays at 12 for the final config PR.
Mutation came out clean without weakening anything: 38 mutants / 0 survivors on #1077, 20 / 0 on #1087. Two assertions were added to hold it there on newly-moved lines — an OSError-on-open case for the unreadable-config arm, and a traversal-segment case pinning that the base directory is normalised. Exemptions still zero.
packages/python/dirsql lane — measured and split, one PR.
Measured against origin/main at f575c06 with npx -y testing-conventions@0.0.111 and max_lines = 1: 6 violations across 2 files, not the 8 the census table predicted, and with different holders (the table predates the rung-9 splits).
In both files the holder was reassigned to the module's named export, so the private helpers moved: 3 + 3 = 6 moves, zero spillover against the budgeted ~15%. Both constants that had to travel (_LOADABLE_SUFFIXES, the _toml binding) have exactly one reader each, so they rode along inside their function's new module — no leaf vocabulary module, no import cycle.
Landed as #1077 (originally stacked as #1077 + #1087; #1087 merged into the parent branch, so #1077 now carries the lane and closes both #1066 and #1079).
packages/python/dirsql now reports 0 violations at max_lines = 1, verified by temporarily lowering the config and restoring it — packages/python/testing-conventions.toml is untouched in the diff, still 12.
Activity
thekevinbot commentedon Sep 5, 2026
Campaign status after #1042 closes.
Where it landed. All five opted-in configs are at
max_lines = 12, exceptplugins/dirsql-plugin-embeddings/testing-conventions.toml, which sits at 10 (it was already clean there, so no reason to loosen it):testing-conventions.toml[python],[typescript]packages/python/testing-conventions.toml[python]packages/ts/testing-conventions.toml[typescript]internals/checks/testing-conventions.toml[python]plugins/dirsql-plugin-embeddings/testing-conventions.toml[python]The ladder so far: 100 -> 24 (#1033/#1035), 24 -> 16 (#1039/#1040), 16 -> 15 (#1048, free), then the splits — #1045 (distcheck
node_flow), #1046 (checks B1, fourgate.pyfiles), #1047 (python), #1049 (ts), #1050 (checks B2,parse.py+matrix.py) — and #1051 dropped the ceiling to 12.Rust remains out of scope, unchanged.
Why it stopped at 12 rather than 10. Kevin's call, and it matches #1042's own recommendation. 12 was where the cost curve turned: the remaining moves stop being "this file holds two substantial functions" and start being the gate rearranging cohesive modules.
What the next step costs. Four moves to reach 10, per #1042's census (measured with
testing-conventions@0.0.111pinned):packages/python/dirsql/resolve_config_extensions.py_resolve_entriesinternals/checks/src/checks/artifact_completeness/gate.pyrunpackages/ts/src/resolve-config-extensions.tsresolveConfigsExtensionSpecspackages/ts/src/resolve-extension.tsresolveExtensionPathThree roots (
packages/python,packages/ts,internals/checks), so it is at least two PRs under the per-lane split convention, each owing a new module, a colocated unit test, relocated test cases, updated call sites, and the coverage / mutation / co-change fallout.internals/distcheckand the plugin are clean at 10 already.Two of the four are marginal private helpers in resolver modules (
_resolve_entries,resolveConfigsExtensionSpecs); one is arunorchestrator flagged only because a 12-line sibling holds its file. None is a legibility win on its own merits — that is the argument the next step has to answer.Below 10 the surface has not been measured; every threshold from 100 down to 10 was swept in #1042's harness, so 9 and lower needs a fresh census run:
Exemptions for this gate remain at zero across every step, and no function was golfed under a limit.
thekevinbot commentedon Sep 5, 2026
Endgame step filed as #1053: census at
max_lines = 1(83 violations across 30 files in 5 roots), rule mechanics verified empirically, five-lane concurrent wave plan (~29 PRs), and what deleting the override means per config file.thekevinbot commentedon Sep 5, 2026
Census and endgame plan (folded in from #1053)
#1053 was filed as a separate tracker for the ratchet endgame, but its scope is this issue's
remaining work restated, so it is closed as a duplicate and its content lives here.
Two caveats on the material below, learned since it was written:
did not exist when this census ran, and it mispredicted the holder in all three lanes because
it measured holders at threshold 1 rather than at the rung being worked. Every future rung
re-measures against current
main. The rule mechanics, the ladder shape and the removalanalysis do not go stale; the file tables do.
drag a companion (a caller, a constant, a type alias) into the new module to avoid a two-way
import cycle. Budget ~95 moves to reach 1, not 83.
Step under #1020. Predecessors: #1033/#1035 (
100->24), #1037 (24->16), #1042 (16->12, PR F = #1051 in flight). This is the endgame: ratchet to the upstream default of1and then delete theone_function_per_fileoverride from all five configs so the tool's own default applies.This issue is the census, the rule mechanics, and the wave plan. It files no per-batch issues; the orchestrator files those as each wave starts.
1. How the rule behaves at
max_lines = 1Verified empirically against
testing-conventions@0.0.111with a synthetic fixture tree, not reasoned about. The end state is achievable -- nothing in the repo is unfixable.Subjects are module-scope functions only. At
max_lines = 1, every module-scope function whose body runs 2 or more code lines and is not the first such function in its file is a violation.Confirmed not counted (fixture returned 0 violations for each):
@property,@staticmethod,@classmethoddef, nested arrow, nestedfunctiondeclaration@dataclassdeclarations, TSinterfaceconstwith shorthand methods (export const handlers = { onA() {...} })export { a } from "./x.js",export * from,export type {})Counted: Python
def/async defat module level; TypeScript top-levelfunctiondeclarations and top-levelconst/let/varbound to an arrow function or function expression, exported or not.Consequence worth stating: the gate is trivially side-steppable. A fixture with two 2-line
@staticmethods on a privateclass _Opsplus module-level aliases (two_lines = _Ops.two_lines) reports 0 violations while presenting an identical import surface. So does folding helpers into an object literal in TypeScript. We are not going to do that -- but it means the rule enforces "one module-scopedefper file", not "one function per file", and no gate can tell the difference.Which function holds the file is a free choice. The holder is the first over-threshold function in source order, so keeping the module's named export means moving everything else out (or reordering so the keeper is first). Same freedom prior waves used.
2. Tool version -- npm 0.0.111 is what CI runs; PyPI 0.0.104 is a different thing
All six per-domain workflows call
thekevinscott/testing-conventions/.github/workflows/testing-conventions.yml@v0(tag resolves to78ab345) and pass noversion:input. The reusable workflow's gate step is:With
VERSIONempty that isnpm exec testing-conventions-> npmlatest, today0.0.111. CI is unpinned and floats.Version discrepancy, flagged as asked: npm
latest= 0.0.111, PyPIlatest= 0.0.104. The two distributions are versioned separately. The npm package is what the gate runs; the PyPI package is whatjust e2e-attest-*uses locally (and is the one carrying the #1041 prune-and-auto-commit hazard). A localpip-installedtesting-conventionscan therefore never reproduce the gate exactly -- always census withnpx -y testing-conventions@0.0.111, never the python CLI.3. Census at
max_lines = 1Clean throwaway clone of
origin/mainatb2c4651, all five configs rewritten tomax_lines = 1, one run per scan root withtesting-conventions@0.0.111. Nothing committed.internals/checks/srcinternals/checks/testing-conventions.tomlpackages/ts/srcpackages/ts/testing-conventions.tomlpackages/python/dirsqlpackages/python/testing-conventions.tomlplugins/dirsql-plugin-embeddings/srcplugins/.../testing-conventions.tomlinternals/distcheck/srctesting-conventions.toml(passes noconfig:)(The earlier rough census -- python 9, ts 12, checks 63, distcheck 6, plugin 6 -- is superseded; the splits landed under #1037/#1042 moved every number.)
The ladder, 12 down to 1
Same harness, every threshold swept.
12is confirmed clean, so #1051 will be green. There is one free step left after it (11 costs 1 move,internals/checksonly) and then the curve turns: 9 is the last two-digit stop, and everything below 7 is a cliff.Size distribution of the 83 -- this is the finding
Forty-two percent of the remaining work extracts a function of five lines or fewer into its own module with its own colocated test file, its own 100%-coverage obligation and its own mutation survivors. Seventeen of them are three lines or fewer.
Per-file breakdown (30 files, 83 moves)
internals/checks/src-- 54:checks/declared_deps/gate.pyrequirement_namechecks/platforms_mirror/decide.pyunmirrored_fieldschecks/changelog_gate/decide.pychanged_packageschecks/artifact_completeness/gate.pydeclared_targetschecks/release_globs/decide.pycarve_outchecks/platforms_mirror/python_table.py_dataclass_fieldschecks/platforms_mirror/typescript_table.py_requotedchecks/release_globs/gate.pyread_configchecks/wheel_extension_load/gate.py_require_zerochecks/changelog_gate/git_ops.pychanged_fileschecks/npm_binary_extension_load/gate.pyconfig_forchecks/platforms_mirror/row.py_call_namechecks/preflight/invocation.pypackage_rootchecks/pytest_gate/gate.pyfind_test_fileschecks/platforms_mirror/gate.pyreadchecks/preflight/discovery.pydiscoveredchecks/preflight/gate.pyread_e2echecks/preflight/matrix.pyparse_gate_matrixpackages/ts/src-- 10:resolve-extension.ts4 (holderdefaultResolver),resolve-config-extensions.ts3 (loadExtensionEntries),cli/main.ts2 (resolveRunCli),platforms.ts1 (libTriples).packages/python/dirsql-- 8:resolve_config_extensions.py5 (_load_toml_module),resolve_extension.py3 (_platform_patterns).plugins/dirsql-plugin-embeddings/src-- 6:search/sql.py3 (quote),search/run.py2 (no_rows_message),embedding/model.py1 (load_model).internals/distcheck/src-- 5:python_flow/gate.py3 (_require_zero),node_flow/gate.py1,node_flow/platforms.py1 (to_node_arch).Full
function / file / line / length / holderoutput for all 83 is reproducible in one command:4. What "delete the override" means, per file
Verified:
one_function_per_fileis the only key directly inside the[python]/[typescript]table in all five configs. Everything else lives in a sibling sub-table ([python.coverage],[typescript.coverage]) or a different table ([e2e],[[python.exempt]],[rust]).So deleting the key leaves an empty
[python]/[typescript]header. Both forms were tested and behave identically tomax_lines = 1:[python]table -> default1[python]header at all, keeping[python.coverage]-> default1(TOML allows the dotted sub-table without its parent, and the coverage gate still reads it)1Upstream's default is genuinely 1 for Python and TypeScript -- verified against the
@v0source tree, not assumed (docs/reference/config.md: "1-- a one-line function is trivial and shares freely";docs/reference/checks/one-function-per-file.mddefault table;MIGRATIONS.mdline 43). Rust has no default: the rule is off until[rust].one_function_per_fileexists. Rust stays out of scope, unchanged from #1020/#1033/#1037/#1042.Recommended end state per file -- delete the key and the now-empty parent header:
testing-conventions.toml[python]+ key,[typescript]+ key[e2e],[python.coverage],[typescript.coverage],[rust],[rust.coverage]packages/python/testing-conventions.toml[python]+ key[e2e],[python.coverage], two[[python.exempt]]packages/ts/testing-conventions.toml[typescript]+ key[e2e],[typescript.coverage]internals/checks/testing-conventions.toml[python]+ key[python.coverage]plugins/dirsql-plugin-embeddings/testing-conventions.toml[python]+ key[python.coverage]Note the root
[typescript]table is read by no scanned root today --packages/tscarries its own config and there is no other TS tree -- so its ceiling is already vestigial.5. Config topology -- the ceilings are already independent
Each scan root reads exactly one config, and only
internals/distcheckreads the root file:internals/checks/testing-conventions.tomlinternals/checks/srcpackages/ts/testing-conventions.tomlpackages/ts/srcpackages/python/testing-conventions.tomlpackages/python/dirsqlplugins/dirsql-plugin-embeddings/testing-conventions.tomltesting-conventions.toml[python]internals/distcheck/srconlytesting-conventions.toml[typescript]Prior waves moved all five in lockstep by convention, not necessity. Stop doing that. Decoupling the ceilings removes the single worst concurrency hazard from the 16->12 campaign: five lanes can then walk down independently and no two PRs ever contend for the same
max_linesline. (The plugin is already out of lockstep at 10 vs 15, which is fine and proves the point.)6. Wave plan
Invariants, non-negotiable:
max_linesline and a source file in the same diff.exemptentry, never by golfing under the limit.Lane C --
internals/checks(54 moves)Nine check packages are separate directories, so the lane parallelises well. Ordering constraints called out inline.
pytest_gate/gate.pypreflight/(discovery1,gate1,invocation2,matrix1)artifact_completeness/gate.pychangelog_gate/git_ops.pychangelog_gate/decide.pygate.pyimports both)release_globs/decide.pyrelease_globs/gate.pygate.pyimportsdecide)wheel_extension_load/gate.pynpm_binary_extension_load/gate.pyProbeError,_require_zero,bin_subdir,write_textfromwheel_extension_load.gate, and_require_zero/write_textare among C8's movesdeclared_deps/gate.py, first halfdeclared_deps/gate.py, second halfplatforms_mirror/row.py(2) +python_table.py(3)platforms_mirror/typescript_table.py(3) +gate.py(1)gate.pyimports the tables)platforms_mirror/decide.py, first half + extract the shared vocabulary (SHARED,LIB_PREFIX,DERIVE,_slug,key) to a leaf moduleplatforms_mirror/decide.py, second halfinternals/checks/testing-conventions.tomlLane T --
packages/ts(10 moves)cli/main.ts(keepSignalsFatal2,main15)platforms.ts(librarySlug5)resolve-extension.ts(platformSuffixes,isBareName,resolvePackage,resolveExtensionPath)resolve-config-extensions.ts(resolveEntries,resolveConfigExtensionSpecs,resolveConfigsExtensionSpecs)isBareName,resolveExtensionPath[typescript]+ key frompackages/ts/testing-conventions.tomlCall sites to fix:
dirsql.ts,load-native-core.ts,cli/main.ts,cli/resolve-config-extensions.ts(dynamicimport("../resolve-config-extensions.js")).Lane P --
packages/python(8 moves)resolve_extension.py(is_bare_name3,_resolve_package35,resolve_extension_path8)resolve_config_extensions.py(5)is_bare_name,resolve_extension_path[python]+ key frompackages/python/testing-conventions.tomlCall sites:
_async.py,cli/main.py,cli/resolve_config_extensions.py.Lane E -- plugin (6 moves)
search/sql.py(normalize_glob3,embed_call3,build_search_sql8) + relocatePATH_PREFIXESsearch/run.py(2) +embedding/model.py(1)run.pyimports the moved SQL builders -- verify[python]+ key from the plugin configLane D --
internals/distcheck(5 moves)python_flow/gate.py(sole_wheel5,check_wheel_tag5,run61)node_flow/gate.py(run89) +node_flow/platforms.py(find_host_platform7)[python]and the vestigial[typescript]table from the roottesting-conventions.tomlConcurrency groups
Lanes C / T / P / E / D touch disjoint source trees and disjoint config files. All five lanes run concurrently. Within lanes, these fire in parallel:
-- that is 16 concurrent PRs, which is too many agents at once in practice; throttle to ~6 in flight and drain.
*-finalconfig PRs, each independent of the others.Collision surfaces, explicitly
wheel_extension_load->npm_binary_extension_load-- the only cross-package import insideinternals/checks. C9 must follow C8.declared_deps/gate.py) and C14/C15 (platforms_mirror/decide.py) split one file across two PRs and cannot be concurrent. Stack them (base= the parent branch) rather than serialising.checks/platforms_mirrorparsesinternals/distcheck/src/distcheck/node_flow/platforms.pyandpackages/ts/src/platforms.tsby hard-coded path (PYTHON_FILE,TYPESCRIPT_FILE), readingclass PlatformandPLATFORMS = [...]out of the former. D2 and T2 both edit a parsed file. Verified low-risk -- the parser reads the dataclass and the table, notfind_host_platformorlibrarySlug(which appear only in comments and error text) -- but D2 and T2 must each rundirsql-checks platforms-mirrorlocally before pushing, and D2 must leaveclass PlatformandPLATFORMSat module scope inplatforms.py.unit lintisolation -- every extraction creates the exact shape the gate rejects: a unit test importing a sibling source module counts as an unmocked collaborator. Patch the symbol by string at the consuming module's namespace, or import it from the module under test if that module re-exports it.platforms_mirror/parse.pyneeded 3 moves where the gate demanded 2;node_flow/gate.pyneeded a separateerrors.py.platforms_mirror/decide.py(C14) is the worst case here --SHARED,LIB_PREFIX,DERIVE,_slugandkeyare interleaved with the six functions being moved. Budget ~10-15% more moves than 83; call it ~95.PATH_PREFIXES(E1),SHARED/LIB_PREFIX(C14),PROBE_SQL/ENTRYPOINT/BIN_NAME(C9),CLASS_NAME/TABLE_NAME(C12).testing-conventionsCLI prunes stale receipts and auto-commits the deletions. With ~10 branches in flight at once every attest run will see the other lanes' receipts as stale. Every implementation PR mustgit show --statits attest commit and drop any receipt deletion it did not intend.Obligations per lane (verified against the repo, not assumed)
internals/checks)internals/checks/e2e-attestations/<branch>.json; the workflow'sgates:list does includee2e-verifychangelog_gate/decide.pyhas_ROOTS = ("packages", "plugins")packages/ts)just e2e-attest-ts)packages/ts/changelog.d/PARITY.mdpackages/python)just e2e-attest-python)packages/python/changelog.d/e2e-verifyplugins/dirsql-plugin-embeddings/changelog.d/internals/distcheck)gates:list omitse2e-verify*-finalconfig PR_is_exemptwaives a package-roottesting-conventions.tomlby name -> no fragmentT and P reorganise mirrored files, so say so in the PR body even though there is no drift to record.
Gates that judge every split: 100% unit-coverage floor, changed-lines coverage,
unit mutation(fix a survivor with a new assertion, never by weakening a test),unit lintisolation,colocated-testand its PR-only co-change check. Runjust preflightbefore pushing.7. Cost, end state, and a recommended stopping point
Cost: ~95 moves (83 measured + cycle spillover) across ~24 split PRs plus 5 config PRs = ~29 PRs, in five concurrent lanes.
End state, counting non-test source files:
max_lines = 1internals/checks/srcpackages/ts/srcpackages/python/dirsqlplugins/.../srcinternals/distcheck/srcPlus ~83 new colocated test files (
internals/checksgoes from 35 to ~89). Roughly +170 files repo-wide, an 80% increase in source-file count, for zero behaviour change.Nothing is impossible. Classes, methods, nested functions and barrels are all invisible to the rule, so there is no structure in this repo the ratchet cannot reach. The blocker is not feasibility.
The honest assessment: the last stretch is not worth what it costs.
plugins/.../search/sql.pyis the archetype: a 41-line module that builds one SQL string, made ofquote(2),normalize_glob(3),embed_call(3),build_search_sql(8) and a 1-linecount_corpus_sql, sharing one constant. At 1 it becomes four source files and four test files, each importing the others, andsql.pykeepsquote. Nothing about that is easier to read, and the colocated-test guarantee the rule exists to buy was already true -- the module had one subject.internals/checks/src/checks/declared_deps/gate.pyis 158 lines, thirteen module functions, one check. At 1 it is ten files. The gate is not finding a problem there; it is rearranging a file that is already the right size.max_lines = 1that is all it is enforcing.Recommended stopping point: 9.
internals/checksmoveAt 9 the guarantee reads cleanly: every function of ten lines or more owns its file. It is nine moves -- roughly one M-sized PR per lane, four of the five lanes are one PR each -- and it stops before the first genuinely cohesive module has to be broken up.
Cost of going the rest of the way anyway: ~86 more moves, ~25 more PRs, ~+160 more files, and five concurrent agent lanes running for as long as it takes to drain waves 1 and 2. Every one of those PRs still has to clear 100% coverage, mutation, isolation and co-change. It is all mechanical, it is all reviewable, and none of it is blocked -- it is simply expensive, and the last 40% of it is spent on functions under six lines.
Kevin has committed to 1, so the plan above goes to 1. This section is the evidence, not a veto. If the answer is still 1, execute lanes C/T/P/E/D as written and the endgame is section 4's five config deletions.
Rust stays out of scope
Unchanged from #1020, #1033, #1037 and #1042. Upstream defaults the rule off for Rust because a Rust file is a module. Opting Rust in is a separate design call, not a step on this ratchet.
Exemptions
Zero today, zero at the end.
thekevinbot commentedon Sep 5, 2026
Lane P (
packages/python/dirsql) — measured and in flightRe-measured against
mainatf575c06with the pinned npm CLI, since the folded-in census predates #1056 (which createdresolve_entries.pyandresolve_configs_extension_specs.py).6 violations across 2 files, not the 8 the census table shows, and both holders differ from its prediction:
resolve_config_extensions.py_load_extension_entriesresolve_config_extensions.py_has_bare_nameresolve_config_extensions.pyresolve_config_extension_specsresolve_extension.pyis_bare_nameresolve_extension.py_resolve_packageresolve_extension.pyresolve_extension_pathTwo stacked PRs, one file each, each keeping the module’s named export as the holder:
main) —resolve_extension.py→platform_patterns.py,is_bare_name.py,resolve_package.py.claude/1066-p1-resolve-extension) —resolve_config_extensions.py→toml_module.py,load_extension_entries.py,has_bare_name.py.Spillover was zero here, against the ~15% budgeted. Both constants that had to move (
_LOADABLE_SUFFIXES, the_tomlbinding) have exactly one reader, so each rode along inside its function’s new module — no leaf vocabulary module was needed and no import cycle appeared. 6 flagged, 6 moves.With #1087 the whole
packages/python/dirsqltree reports 0 violations atmax_lines = 1. Neither PR touches atesting-conventions.toml;packages/python’s ceiling stays at 12 for the final config PR.Mutation came out clean without weakening anything: 38 mutants / 0 survivors on #1077, 20 / 0 on #1087. Two assertions were added to hold it there on newly-moved lines — an
OSError-on-opencase for the unreadable-config arm, and a traversal-segment case pinning that the base directory is normalised. Exemptions still zero.thekevinbot commentedon Sep 5, 2026
packages/python/dirsqllane — measured and split, one PR.Measured against
origin/mainatf575c06withnpx -y testing-conventions@0.0.111andmax_lines = 1: 6 violations across 2 files, not the 8 the census table predicted, and with different holders (the table predates the rung-9 splits).resolve_extension.pyis_bare_name,_resolve_package,resolve_extension_path_platform_patternsresolve_config_extensions.py_load_extension_entries,_has_bare_name,resolve_config_extension_specs_load_toml_moduleIn both files the holder was reassigned to the module's named export, so the private helpers moved: 3 + 3 = 6 moves, zero spillover against the budgeted ~15%. Both constants that had to travel (
_LOADABLE_SUFFIXES, the_tomlbinding) have exactly one reader each, so they rode along inside their function's new module — no leaf vocabulary module, no import cycle.Landed as #1077 (originally stacked as #1077 + #1087; #1087 merged into the parent branch, so #1077 now carries the lane and closes both #1066 and #1079).
packages/python/dirsqlnow reports 0 violations atmax_lines = 1, verified by temporarily lowering the config and restoring it —packages/python/testing-conventions.tomlis untouched in the diff, still 12.refactor(ts): give each extension-resolution helper its own module
refactor(ts): give the CLI launcher and platform helpers their own mo…