Sweep stale Python modules, and ask TS and Ruby whether their clients reach what was generated
Two gate holes the service-inventory work exposed. One is live — a stale Python module reaches main green today. The other is a missing guard: nothing is unreachable right now, and this PR does not fix a bug so much as make the next one fail.
#757 — the Python generator was the only one of six that never swept
python/scripts/generate_services.py wrote modules, rewrote __init__.py, and stopped. It already built generated_files and appended to it — and never read it. That list was the vestige of the sweep TS and Ruby perform under the same variable name; Python built the set and discarded it. So dropping or renaming a service in the mapping left the old module on disk forever: the barrel stopped importing it, but nothing deleted it.
Fixed by making the generator sweep what it no longer emits. The mechanism went through one revision under review, and the second design is the one that shipped — see "The sweep works from a record, not from a fingerprint" below.
The CI framing, which is not the one the issue proposes
The issue proposes pointing CI at scripts/check-python-service-drift.sh. This PR does that, in both Python workflows, and the reason is sharper than "consistency".
.github/workflows/test.yml and release-python.yml both regenerated in place and tested git status --porcelain -- src/basecamp/generated/, never invoking the drift script (a grep for it across the repo returned only Makefile and SPEC.md — zero workflow references).
An in-place porcelain check reports what regenerating changed. That is sound exactly where the generator wipes its output directory first, because then every stale file becomes a deletion — which is why release-kotlin.yml can keep doing it (Main.kt wipes) and Python cannot. Python's sweep removes modules the previous barrel named; a module already committed without ever being named there is changed by nothing, so porcelain has nothing to report.
Measured at this PR's head, with a stale fanfares.py committed and absent from the barrel:
| check | result |
|---|---|
regenerate in place + git status --porcelain | 0 lines, exit 0 — blind |
scripts/check-python-service-drift.sh (out-of-tree diff) | exit 2 — Only in .../services_committed: fanfares.py |
So the two halves do different jobs and both are needed: the sweep prevents a corpse from being created — the run that drops a service deletes its module in the same run, so it can never be committed — and the drift script is what sees one that already exists, because it compares directory contents rather than what a run rewrote. Pointing both workflows at it makes CI and make run the same check.
The sweep works from a record, not from a fingerprint
The first design recognised past output by its preamble: the @generated marker plus the BaseService import line. Two reviewers independently found the hole — Codex as a thread, Copilot as a suppressed comment on the same line. Remove a service in the same change that touches either string, and the stranded module carries the old preamble: unrecognised, unswept, and unfixable by rerunning the generator.
The remedy is not a third clause. __init__.py is the generator's own record of its own last output — rewritten whole every run, importing exactly the modules it wrote. main now reads it before overwriting, and the sweep set is that record − this run's output. No file's contents are inspected at any point.
What dissolved rather than moved: the content predicate, the character-bounded UTF-8 head read (which existed only because the marker holds an em-dash), and main's emitted-preamble self-check (which existed only because a content predicate fails open). The types.py hazard that motivated the second clause is answered by the mechanism — point --output one directory too high and the barrel there is the empty package marker, so the drop list is empty and nothing is deleted.
SERVICE_PACKAGE is now the one string that must survive between two runs, and the warning lives at the constant. It is read with ast, so wrapping, ordering and parenthesised imports are beneath notice; renaming the package rewrites every generated module's and every consumer's imports, which is not the drive-by preamble edit the old predicate broke on.
The residual, stated plainly at remove_stale_files and in the commit: a module the barrel never named is never swept — reachable via a hand-deleted barrel, or a module dropped while the sweep was absent (that is, any corpse predating this code). Both want one manual rm, and the drift gate is what reports them. The trade is deliberate: a drop list can only be too short, where a content predicate can be wrong in the other direction.
Red proof. Against a copy of the real tree, dropping Gauges while rewording both the marker and the base import: the pre-fix generator strands gauges.py (exit 1 — the finding, reproduced); the post-fix generator sweeps it (exit 0, before − after == {gauges.py}, base modules surviving). Plus per-case kills: grafting a content predicate back onto the drop list → 2 failures naming the obsolete-preamble module; moving the barrel read after the barrel write → 1; removing the sweep call → 1; forcing the drop list empty → the non-vacuity floor fires first.
#755 — accessor-roster tests for TypeScript and Ruby
No service is unreachable today. All rosters agree, verified in both directions. This is a missing guard, not a save.
Replicates TestGroupedClientAccessorInventory (#748/#732) along its axes: a non-vacuity floor, presence, identity (resolve the accessor and check the class it actually yields), and the reverse direction (no accessor outliving its service). The roster is derived from the generated directory, never a literal — a literal would be edited by the same person who forgot the accessor.
TypeScript has four hand-maintained renderings, not two: the import block, the BasecampClient interface properties, the defineService calls, and index.ts's exports. So two instruments:
typescript/tests/accessor-inventory.test.tsresolves accessors on a constructed client, covering the imports and thedefineServicecalls at once, and givesindex.tsits own assertion — a missing export is invisible at runtime to an in-repo importer and only bites a consumer, since package.json exports only the root and./oauth.typescript/tests/types/accessor-inventory.test-d.tsasserts the interface type-level, because the factory returnsclient as BasecampClientand no runtime check can see that rendering.
Resolution goes through the modules, not their source text: two defineService calls span lines and one index.ts export is a single line, so a line-oriented regex over either file misses all three.
Ruby is easier — Zeitwerk on_load hooks prepend the five extension modules onto the generated classes rather than subclassing, so every accessor's class is the generated constant exactly, with no subclass allowance. (Basecamp::AccountClient is declared beside Basecamp::Client in client.rb, so no autoload is registered for it and naming it directly raises NameError under some minitest seeds; the test reads the class off an instance.)
The interface rendering is checked on all three axes, after review found the first version checked only one. It now asserts presence, the reverse direction (an interface property the runtime client does not define — invisible to Object.keys, so no runtime test can see it), and identity: which service each accessor is actually declared as. Retyping readonly clientApprovals: ClientRepliesService leaves both name-set assertions empty while consumers get the wrong static API, and the runtime test still passes because the getter returns the right class.
The identity rule is derived rather than rostered, and its limit was measured rather than assumed — all 53×52 ordered pairs were probed:
| rule | wrong pairings admitted |
|---|---|
| plain assignability | 45 |
| shipped rule (agreement in both directions over the generated service's own members) | 11 |
| any type-level rule | ≥1, irreducibly |
Zero is not reachable: CardTablesService and MessageBoardsService are a single get(bucketId, id) each over mutually assignable schema types — literally the same type, so nothing in the type system can tell those two accessors apart. The shipped rule is what lets the six §18 composite subclasses through without a roster; exempting them by name would tighten the residual to 2 at the cost of a list a seventh composite must be added to, and this file's whole argument is derive-don't-list. The measured number is written next to the rule.
Red proof — nine mutations, each applied alone
| mutation | result |
|---|---|
removed the multi-line defineService("googleDocuments") | exit 1 — 2 failed, client-wiring block only |
removed the single-line export { UploadsService } from index.ts | exit 1 — 2 failed, index.ts block only |
orphan client.fanfares accessor | exit 1 — reverse direction only |
client.gauges → ProjectsService | exit 1 — identity only |
removed readonly gauges from the BasecampClient interface | tsc exit 2 — TS2344: Type '"gauges"' does not satisfy the constraint 'true' |
forced the type-level roster to never | tsc exit 2 — non-vacuity floor fires |
removed AccountClient#gauges (Ruby) | exit 1 — 2 failures, message names the file |
orphan AccountClient#fanfares (Ruby) | exit 1 — reverse direction only |
Ruby gauges → ProjectsService | exit 1 — identity only |
The two TypeScript surfaces fail independently, so neither guard passes on the other's behalf — the vacuity this pair exists to avoid. The specific shapes a line-oriented matcher misses were each mutated deliberately.
SPEC.md
SPEC §5 recorded Ruby's and TypeScript's counts as hand-verified and dated (2026-08-13). A dated number is a constant that rots; these tests are what retire it. No row rests on a hand-verification any more — all six counts are now restated by something that recomputes them.
The sweep also falsified prose in three places that said Python's generator does not sweep (SPEC §5's parenthetical, check-service-inventory-parity's Python comment, and its self-test case 11). Corrected, with the reason the barrel reading stays anyway: it is the one reader a regression in the sweep cannot fool, and it names exactly the modules the mapping produced, excluding the two hand-written base files without a drop-list. No behavior change; self-test cases 11 and 12 still pin it.
Verification
All under LC_ALL=C, exit codes written to a log and read back:
py-check 0 · py-check-drift 0 · ts-check 0 · rb-check 0 (1451 runs) · check-service-inventory-parity 0 · its self-test 0 · doc-constants-check 0 · lint-actions 0 (zizmor: no findings).
No file under */generated/ was edited.