Skip to content

ci: ratchet one-function-per-file to the default of 1 and drop the override #1053

Closed as not planned

Description

@thekevinbot

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. "belongs to its owner"
Module-level constants, dicts, @dataclass declarations, TS interface No. Not functions.
TS object-literal const with shorthand methods (export const handlers = { onA() {...} }) 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 root testing-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.

Per-file breakdown (30 files, 83 moves)

internals/checks/src -- 54:

Moves File Holder
9 checks/declared_deps/gate.py requirement_name
6 checks/platforms_mirror/decide.py unmirrored_fields
5 checks/changelog_gate/decide.py changed_packages
4 checks/artifact_completeness/gate.py declared_targets
4 checks/release_globs/decide.py carve_out
3 checks/platforms_mirror/python_table.py _dataclass_fields
3 checks/platforms_mirror/typescript_table.py _requoted
3 checks/release_globs/gate.py read_config
3 checks/wheel_extension_load/gate.py _require_zero
2 checks/changelog_gate/git_ops.py changed_files
2 checks/npm_binary_extension_load/gate.py config_for
2 checks/platforms_mirror/row.py _call_name
2 checks/preflight/invocation.py package_root
2 checks/pytest_gate/gate.py find_test_files
1 checks/platforms_mirror/gate.py read
1 checks/preflight/discovery.py discovered
1 checks/preflight/gate.py read_e2e
1 checks/preflight/matrix.py parse_gate_matrix

packages/ts/src -- 10: resolve-extension.ts 4 (holder defaultResolver), resolve-config-extensions.ts 3 (loadExtensionEntries), cli/main.ts 2 (resolveRunCli), platforms.ts 1 (libTriples).

packages/python/dirsql -- 8: resolve_config_extensions.py 5 (_load_toml_module), resolve_extension.py 3 (_platform_patterns).

plugins/dirsql-plugin-embeddings/src -- 6: search/sql.py 3 (quote), search/run.py 2 (no_rows_message), embedding/model.py 1 (load_model).

internals/distcheck/src -- 5: python_flow/gate.py 3 (_require_zero), node_flow/gate.py 1, node_flow/platforms.py 1 (to_node_arch).

Full function / file / line / length / holder output for all 83 is reproducible in one command:

git clone --depth 1 https://github.com/thekevinscott/dirsql.git /tmp/census && cd /tmp/census
sed -i 's/one_function_per_file = { max_lines = [0-9]* }/one_function_per_file = { max_lines = 1 }/' \
  testing-conventions.toml packages/{ts,python}/testing-conventions.toml \
  internals/checks/testing-conventions.toml plugins/dirsql-plugin-embeddings/testing-conventions.toml
npx -y testing-conventions@0.0.111 unit one-function-per-file --language python     --config internals/checks/testing-conventions.toml internals/checks/src
npx -y testing-conventions@0.0.111 unit one-function-per-file --language typescript --config packages/ts/testing-conventions.toml packages/ts/src
npx -y testing-conventions@0.0.111 unit one-function-per-file --language python     --config packages/python/testing-conventions.toml packages/python/dirsql
npx -y testing-conventions@0.0.111 unit one-function-per-file --language python     --config testing-conventions.toml internals/distcheck/src
npx -y testing-conventions@0.0.111 unit one-function-per-file --language python     --config plugins/dirsql-plugin-embeddings/testing-conventions.toml plugins/dirsql-plugin-embeddings/src

4. What "delete the override" means, per file

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:

File Delete Leaves behind
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/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:

Config Governs
internals/checks/testing-conventions.toml internals/checks/src
packages/ts/testing-conventions.toml packages/ts/src
packages/python/testing-conventions.toml packages/python/dirsql
plugins/dirsql-plugin-embeddings/testing-conventions.toml plugin src
root testing-conventions.toml [python] internals/distcheck/src only
root testing-conventions.toml [typescript] nothing

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:

  1. Every split PR is config-free. No PR touches a max_lines line and a source file in the same diff.
  2. 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.
  3. 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.
  4. 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.

PR Target Moves ~Files Size Depends on
C1 pytest_gate/gate.py 2 6 S --
C2 preflight/ (discovery 1, gate 1, invocation 2, matrix 1) 5 16 M --
C3 artifact_completeness/gate.py 4 13 M --
C4 changelog_gate/git_ops.py 2 6 S --
C5 changelog_gate/decide.py 5 16 M C4 (gate.py imports both)
C6 release_globs/decide.py 4 13 M --
C7 release_globs/gate.py 3 10 M C6 (gate.py imports decide)
C8 wheel_extension_load/gate.py 3 10 M --
C9 npm_binary_extension_load/gate.py 2 8 S C8 -- it imports ProbeError, _require_zero, bin_subdir, write_text from wheel_extension_load.gate, and _require_zero/write_text are among C8's moves
C10 declared_deps/gate.py, first half 5 15 M --
C11 declared_deps/gate.py, second half 4 12 M C10 -- same file
C12 platforms_mirror/row.py (2) + python_table.py (3) 5 16 M --
C13 platforms_mirror/typescript_table.py (3) + gate.py (1) 4 13 M C12 (same package, gate.py imports the tables)
C14 platforms_mirror/decide.py, first half + extract the shared vocabulary (SHARED, LIB_PREFIX, DERIVE, _slug, key) to a leaf module 3 + 1 12 M --
C15 platforms_mirror/decide.py, second half 3 10 M C14 -- same file
C-final delete the key from internals/checks/testing-conventions.toml 0 1 XS all of C1-C15

Lane T -- packages/ts (10 moves)

PR Target Moves Size Depends on
T1 cli/main.ts (keepSignalsFatal 2, main 15) 2 S --
T2 platforms.ts (librarySlug 5) 1 XS --
T3 resolve-extension.ts (platformSuffixes, isBareName, resolvePackage, resolveExtensionPath) 4 M --
T4 resolve-config-extensions.ts (resolveEntries, resolveConfigExtensionSpecs, resolveConfigsExtensionSpecs) 3 M T3 -- imports isBareName, resolveExtensionPath
T-final delete [typescript] + key from packages/ts/testing-conventions.toml 0 XS T1-T4

Call sites to fix: dirsql.ts, load-native-core.ts, cli/main.ts, cli/resolve-config-extensions.ts (dynamic import("../resolve-config-extensions.js")).

Lane P -- packages/python (8 moves)

PR Target Moves Size Depends on
P1 resolve_extension.py (is_bare_name 3, _resolve_package 35, resolve_extension_path 8) 3 M --
P2 resolve_config_extensions.py (5) 5 M P1 -- imports is_bare_name, resolve_extension_path
P-final delete [python] + key from packages/python/testing-conventions.toml 0 XS P1, P2

Call sites: _async.py, cli/main.py, cli/resolve_config_extensions.py.

Lane E -- plugin (6 moves)

PR Target Moves Size Depends on
E1 search/sql.py (normalize_glob 3, embed_call 3, build_search_sql 8) + relocate PATH_PREFIXES 3 M --
E2 search/run.py (2) + embedding/model.py (1) 3 M E1 if run.py imports the moved SQL builders -- verify
E-final delete [python] + key from the plugin config 0 XS E1, E2

Lane D -- internals/distcheck (5 moves)

PR Target Moves Size Depends on
D1 python_flow/gate.py (sole_wheel 5, check_wheel_tag 5, run 61) 3 M --
D2 node_flow/gate.py (run 89) + node_flow/platforms.py (find_host_platform 7) 2 M --
D-final delete [python] and the vestigial [typescript] table from the root testing-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 2 (blocked on wave 1): C5, C7, C9, C11, C13, C15 | T4 | P2 | E2
  • Wave 3: the five *-final config PRs, each independent of the others.

Collision surfaces, explicitly

  1. Config files -- eliminated by invariant 2. Only one PR per root touches a config, and it lands last in that lane.
  2. wheel_extension_load -> npm_binary_extension_load -- the only cross-package import inside internals/checks. C9 must follow C8.
  3. 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.
  4. Cross-root data coupling: checks/platforms_mirror parses internals/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.
  5. 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.
  6. 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.
  7. 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).
  8. 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
P (packages/python) Yes (just e2e-attest-python) Yes -- packages/python/changelog.d/ No Same
E (plugin) Yes -- the plugin workflow declares e2e-verify Yes -- plugins/dirsql-plugin-embeddings/changelog.d/ No n/a
D (internals/distcheck) No -- its gates: list omits e2e-verify No No No
any *-final config PR 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
7 16 ~4 PRs; starts splitting genuinely cohesive parsers
5 31 ~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.

Exemptions

Zero today, zero at the end.

Activity

  1. self-assigned this
    on Sep 5, 2026
  2. thekevinbot commented on Sep 5, 2026

    @thekevinbot
    CollaboratorAuthor

    Duplicate of #1020. This was filed as a tracker for the ratchet endgame, but "ratchet to 1 and drop the override" is #1020's remaining scope restated rather than a step under it. The census, rule mechanics, ladder and removal analysis are folded into #1020 (#1020 (comment)) with the two corrections learned at rung 9: the per-file tables are stale after each rung, and spillover runs ~15% over the flagged count.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions