Skip to content

ci: ratchet one-function-per-file from max_lines = 100 toward the default of 1 #1020

Closed

Description

@thekevinbot

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.

Target: upstream's shipped default, max_lines = 1.

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:

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

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).

Activity

  1. thekevinbot commented on Sep 5, 2026

    @thekevinbot
    CollaboratorAuthor

    Campaign status after #1042 closes.

    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):

    Config Table(s) Value
    testing-conventions.toml [python], [typescript] 12
    packages/python/testing-conventions.toml [python] 12
    packages/ts/testing-conventions.toml [typescript] 12
    internals/checks/testing-conventions.toml [python] 12
    plugins/dirsql-plugin-embeddings/testing-conventions.toml [python] 10

    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):

    File Function Lines
    packages/python/dirsql/resolve_config_extensions.py _resolve_entries 12
    internals/checks/src/checks/artifact_completeness/gate.py run 11
    packages/ts/src/resolve-config-extensions.ts resolveConfigsExtensionSpecs 11
    packages/ts/src/resolve-extension.ts resolveExtensionPath 10

    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.

  2. thekevinbot commented on Sep 5, 2026

    @thekevinbot
    CollaboratorAuthor

    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.

  3. thekevinbot commented on Sep 5, 2026

    @thekevinbot
    CollaboratorAuthor

    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:

    1. The per-file breakdown is stale. The rung-9 splits (refactor(ts): give the package glob and the config reader their own modules #1057/ci: split internals/checks so one_function_per_file is clean at 9 #1058/ci: split packages/python to clean one_function_per_file at 9 #1059) created modules that
      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 removal
      analysis do not go stale; the file tables do.
    2. 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. "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.

  4. thekevinbot commented on Sep 5, 2026

    @thekevinbot
    CollaboratorAuthor

    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.

  5. thekevinbot commented on Sep 5, 2026

    @thekevinbot
    CollaboratorAuthor

    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).

    File Flagged Holder
    resolve_extension.py is_bare_name, _resolve_package, resolve_extension_path _platform_patterns
    resolve_config_extensions.py _load_extension_entries, _has_bare_name, resolve_config_extension_specs _load_toml_module

    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.

  6. self-assigned this
    on Sep 5, 2026
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