7777 * inherit this chokepoint (`driver-sqlite-wasm`, `driver-turso`). Scanning the
7878 * subclasses is what stops a new door being added one layer down.
7979 *
80+ * And every `sql-driver-*.ts` module beside `sql-driver.ts` (tests excepted),
81+ * listed from the directory (#29520, ruling 6591753301). Splitting the class
82+ * moves method bodies into those modules as functions whose `this` is a host
83+ * the method hands them, so a door can sit one FILE over as well as one layer
84+ * down. A module-level function is read as a member is, under its own name;
85+ * the readings line (`getBuilder` bindings, read doors, tenant predicates, D8
86+ * arms) is what a split compares before and after, and it must not move.
87+ *
8088 * `driver-memory` and `driver-mongodb` are outside it, and NOT because #5499
8189 * froze them: they do not use this mechanism at all. Neither file contains
8290 * `getBuilder` or `applyTenantScope` (mongodb enforces its own wall in
157165 * (no class found, a builder it cannot classify, or a DISCOVERED floor missed).
158166 */
159167
160- import { readFileSync , existsSync } from 'node:fs' ;
168+ import { readFileSync , existsSync , readdirSync } from 'node:fs' ;
161169import { join , relative , sep } from 'node:path' ;
162170import { fileURLToPath } from 'node:url' ;
163171import { requireDefaultExport } from './import-prerequisite.mjs' ;
@@ -167,11 +175,35 @@ import { isEntrypoint } from './invoked-as.mjs';
167175
168176const ROOT = join ( fileURLToPath ( new URL ( '.' , import . meta. url ) ) , '..' ) ;
169177
170- /** The `SqlDriver` family — every class that inherits this chokepoint. */
178+ /** The directory whose `sql-driver-*.ts` seam modules carry `SqlDriver`'s moved bodies. */
179+ const SEAM_DIR = join ( 'packages' , 'drivers' , 'driver-sql' , 'src' ) ;
180+
181+ /**
182+ * Which basenames of {@link SEAM_DIR} are seam modules this gate reads:
183+ * `sql-driver-<anything>.ts`, never a test or spec file. A pure function of
184+ * the listing, so the self-test can drive it without the repo.
185+ */
186+ export function seamModulesOf ( basenames ) {
187+ return basenames
188+ . filter ( ( n ) => / ^ s q l - d r i v e r - .+ \. t s $ / . test ( n ) && ! / \. ( t e s t | s p e c ) \. t s $ / . test ( n ) && ! n . endsWith ( '.d.ts' ) )
189+ . sort ( ) ;
190+ }
191+
192+ /**
193+ * The `SqlDriver` family — every class that inherits this chokepoint — and
194+ * every `sql-driver-*.ts` module its methods' bodies have moved into (#29520,
195+ * ruling 6591753301). A moved body keeps its `this.getBuilder(…)` and
196+ * `this.applyTenantScope(…)` calls on the host its method hands it, so the
197+ * door it builds is still a door, one file over; a gate that read only
198+ * `sql-driver.ts` would count fewer doors after every cut while reporting
199+ * clean. The population is LISTED from the directory, not enumerated by hand,
200+ * so a new seam module is read the day it lands.
201+ */
171202const SCAN_FILES = [
172203 join ( 'packages' , 'drivers' , 'driver-sql' , 'src' , 'sql-driver.ts' ) ,
173204 join ( 'packages' , 'drivers' , 'driver-sqlite-wasm' , 'src' , 'sqlite-wasm-driver.ts' ) ,
174205 join ( 'packages' , 'drivers' , 'driver-turso' , 'src' , 'turso-driver.ts' ) ,
206+ ...seamModulesOf ( existsSync ( join ( ROOT , SEAM_DIR ) ) ? readdirSync ( join ( ROOT , SEAM_DIR ) ) : [ ] ) . map ( ( n ) => join ( SEAM_DIR , n ) ) ,
175207] ;
176208
177209/**
@@ -181,9 +213,9 @@ const SCAN_FILES = [
181213 * derivation — which reads SOURCE TEXT — saw a handful of bare words and no path
182214 * at all. This gate was scored `undetermined` for EVERY card: absent from every
183215 * dispatch brief and every `--commands` harvest, while CI ran it on each pull
184- * request. Worse than the usual cost, because this gate's population is THREE
185- * FILES: the cards that actually implicate it are rare and precisely nameable,
186- * and it was reaching none of them.
216+ * request. Worse than the usual cost, because this gate's population is a
217+ * handful of FILES: the cards that actually implicate it are rare and
218+ * precisely nameable, and it was reaching none of them.
187219 *
188220 * The spelling is `DECLARED_WATCH_HINTS` rather than the directory idiom: this
189221 * is an enumerated population, not a walked subtree, and each hint is a whole
@@ -192,7 +224,13 @@ const SCAN_FILES = [
192224 *
193225 * ⛔ Not `packages/drivers/**`, and not a whole-tree marker. Both would put this
194226 * gate on cards it does not read a byte for; a lead that is wrong on thousands
195- * of files is worse than the silence it replaces.
227+ * of files is worse than the silence it replaces. ⛔ And not
228+ * `sql-driver-*.ts` either, although that is the population's own shape: the
229+ * same directory holds a few hundred `sql-driver-*.test.ts` files this gate
230+ * never reads, and a glob would name it on every one of them. So the seam
231+ * modules are declared one by one, and the self-test holds the list to the
232+ * directory's listing in both directions: a seam module added, renamed or
233+ * removed reds the self-test until it is declared here (#29520).
196234 *
197235 * The self-test derives the coupling from `SCAN_FILES` on both sides rather than
198236 * re-spelling it, so a driver added to or removed from the family cannot leave
@@ -204,6 +242,36 @@ const DECLARED_WATCH_HINTS = [
204242 'packages/drivers/driver-sql/src/sql-driver.ts' ,
205243 'packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts' ,
206244 'packages/drivers/driver-turso/src/turso-driver.ts' ,
245+ 'packages/drivers/driver-sql/src/sql-driver-aggregation.ts' ,
246+ 'packages/drivers/driver-sql/src/sql-driver-autonumber.ts' ,
247+ 'packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts' ,
248+ 'packages/drivers/driver-sql/src/sql-driver-column-creation.ts' ,
249+ 'packages/drivers/driver-sql/src/sql-driver-cross-field-comparison.ts' ,
250+ 'packages/drivers/driver-sql/src/sql-driver-crud.ts' ,
251+ 'packages/drivers/driver-sql/src/sql-driver-database-helpers.ts' ,
252+ 'packages/drivers/driver-sql/src/sql-driver-date-bucket.ts' ,
253+ 'packages/drivers/driver-sql/src/sql-driver-distinct.ts' ,
254+ 'packages/drivers/driver-sql/src/sql-driver-field-mapping.ts' ,
255+ 'packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts' ,
256+ 'packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts' ,
257+ 'packages/drivers/driver-sql/src/sql-driver-introspection.ts' ,
258+ 'packages/drivers/driver-sql/src/sql-driver-lifecycle.ts' ,
259+ 'packages/drivers/driver-sql/src/sql-driver-managed-drift.ts' ,
260+ 'packages/drivers/driver-sql/src/sql-driver-predicate-emitters.ts' ,
261+ 'packages/drivers/driver-sql/src/sql-driver-presumed-timestamp.ts' ,
262+ 'packages/drivers/driver-sql/src/sql-driver-query-plan.ts' ,
263+ 'packages/drivers/driver-sql/src/sql-driver-raw-execution.ts' ,
264+ 'packages/drivers/driver-sql/src/sql-driver-read-exit-fault.ts' ,
265+ 'packages/drivers/driver-sql/src/sql-driver-rotation.ts' ,
266+ 'packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts' ,
267+ 'packages/drivers/driver-sql/src/sql-driver-schema-sync.ts' ,
268+ 'packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts' ,
269+ 'packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts' ,
270+ 'packages/drivers/driver-sql/src/sql-driver-top-level.ts' ,
271+ 'packages/drivers/driver-sql/src/sql-driver-transactions.ts' ,
272+ 'packages/drivers/driver-sql/src/sql-driver-upsert-conflict-target.ts' ,
273+ 'packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts' ,
274+ 'packages/drivers/driver-sql/src/sql-driver-window-functions.ts' ,
207275] ;
208276
209277/**
@@ -579,6 +647,13 @@ export function analyzeSource(fileName, text) {
579647 }
580648 }
581649 }
650+ // A module-level function is a moved method body (#29520): its `this` is
651+ // the host its method hands it, so `this.getBuilder(…)` there builds the
652+ // same door. Read under the function's name, which is the method's.
653+ // Top-level only: a function nested in a member is walked with the member.
654+ if ( ts . isFunctionDeclaration ( node ) && node . name && ts . isSourceFile ( node . parent ) ) {
655+ visitMethod ( node . name . text , node . body ) ;
656+ }
582657 ts . forEachChild ( node , walkTop ) ;
583658 } ;
584659 walkTop ( sf ) ;
@@ -638,6 +713,9 @@ const SELF_TEST_VERDICT = 'check-tenant-chokepoint self-test reached its verdict
638713// does not make. The hoisted single battery is the shape PR #14896 and PR #15003
639714// landed for exactly this case.
640715//
716+ // #29520 adds a third, `moved bodies (#29520)`, opened by its own `battery()`
717+ // call ahead of the population declaration: the widened scan's own cases.
718+ //
641719// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3
642720// keeps a total "right" the moment a sibling grows.
643721//
@@ -647,11 +725,12 @@ const SELF_TEST_VERDICT = 'check-tenant-chokepoint self-test reached its verdict
647725const SELF_TEST_BATTERIES = Object . freeze ( {
648726 'check-tenant-chokepoint self-test' : 17 ,
649727 'D8 null-arm rule' : 24 ,
728+ 'moved bodies (#29520)' : 9 ,
650729} ) ;
651730
652731// DELETING an entry silences that battery's floor exactly as effectively as
653732// zeroing it, so the roster's own size is pinned too.
654- const SELF_TEST_BATTERY_FLOOR = 2 ;
733+ const SELF_TEST_BATTERY_FLOOR = 3 ;
655734
656735// The key an assertion is filed under when no battery is open. It is not a
657736// declared battery, so it reds by the same set difference rather than silently
@@ -972,6 +1051,50 @@ function selfTest() {
9721051 ) ;
9731052 }
9741053
1054+ // ── #29520: a body moved to a sql-driver-*.ts module is still a door ──────
1055+ battery ( 'moved bodies (#29520)' ) ;
1056+ {
1057+ // The shape the codemod writes: the body as a module function whose `this`
1058+ // is the host its method hands it.
1059+ const moved = ( body ) => analyzeSource ( 'm.ts' , `export async function door(this: Pick<H, 'getBuilder'>, object: string, options?: unknown) {\n${ body } \n}\n` ) ;
1060+ const unscoped = moved ( ` const builder = this.getBuilder(object, options);\n return await builder;` ) ;
1061+ assert ( unscoped . builders . length === 1 && violationsOf ( 'm.ts' , unscoped ) . length === 1 ,
1062+ 'a moved body that builds and never scopes must REPORT — the module is no hiding place' ) ;
1063+ const scoped = moved ( ` const builder = this.getBuilder(object, options);\n this.applyTenantScope(builder, object, options);\n return await builder;` ) ;
1064+ assert ( scoped . builders . length === 1 && violationsOf ( 'm.ts' , scoped ) . length === 0 ,
1065+ 'a moved body that scopes its builder is counted and silent' ) ;
1066+ assert ( scoped . builders [ 0 ] ?. method === 'door' , 'a moved body is read under its function name, which is its method\'s' ) ;
1067+ const inline = moved ( ` return await this.getBuilder(object, options).select('*');` ) ;
1068+ assert ( violationsOf ( 'm.ts' , inline ) . some ( ( p ) => p . fatal ) , 'an inline getBuilder() in a moved body is fatal, as on the class' ) ;
1069+ // A function nested in a class member is walked with the member, once.
1070+ const nested = analyzeSource ( 't.ts' , wrap ( `async outer(object, options) {
1071+ function inner() { return 1; }
1072+ const builder = this.getBuilder(object, options);
1073+ this.applyTenantScope(builder, object, options);
1074+ return await builder;
1075+ }` ) ) ;
1076+ assert ( nested . builders . length === 1 , 'a member is not counted twice' ) ;
1077+ // The stub's host hands the factory as a bound reference: not a call, so
1078+ // neither a binding nor an inline read — the call is the moved body's.
1079+ const stub = analyzeSource ( 't.ts' , wrap ( `async door(object, options) {
1080+ const host = { getBuilder: this.getBuilder.bind(this), applyTenantScope: (...a) => this.applyTenantScope(...a) };
1081+ return door.call(host, object, options);
1082+ }` ) ) ;
1083+ assert ( stub . builders . length === 0 && stub . unclassifiable . length === 0 ,
1084+ 'a bound getBuilder reference in a stub is neither a door nor an unclassifiable read' ) ;
1085+ // The D8 rule already walks module functions; a moved arm is still refused.
1086+ assert ( nullArmsOf ( 'm.ts' , `export function applyTenantScope(this: H, builder: any, object: string) {
1087+ const field = this.resolveTenantField(object);
1088+ builder.where(field, 'org').orWhereNull(field);
1089+ }` ) . arms . length === 1 , 'D8: an IS NULL arm in a moved body reports' ) ;
1090+ // The population: every sql-driver-*.ts module, never a test.
1091+ const listed = seamModulesOf ( [ 'sql-driver.ts' , 'sql-driver-crud.ts' , 'sql-driver-crud.test.ts' ,
1092+ 'sql-driver-x.spec.ts' , 'sql-driver-types.d.ts' , 'schema-drift.ts' , 'sql-driver-top-level.ts' ] ) ;
1093+ assert ( JSON . stringify ( listed ) === JSON . stringify ( [ 'sql-driver-crud.ts' , 'sql-driver-top-level.ts' ] ) ,
1094+ `the seam population is sql-driver-*.ts minus tests, specs and declaration files (got ${ listed . join ( ', ' ) } )` ) ;
1095+ assert ( SCAN_FILES . length > 3 , 'the live scan reads seam modules beside the three driver files' ) ;
1096+ }
1097+
9751098 // ── the dispatch-gates population declaration ────────────────────────────
9761099 battery ( 'check-tenant-chokepoint self-test' ) ;
9771100 const scannedPosix = SCAN_FILES . map ( ( f ) => f . split ( sep ) . join ( '/' ) ) ;
@@ -1056,7 +1179,9 @@ function main() {
10561179 const problems = [ ] ;
10571180 const nullArms = [ ] ;
10581181 let tenantPredicates = 0 ;
1182+ let armsSeen = 0 ;
10591183 let discovered = 0 ;
1184+ let readDoors = 0 ;
10601185 let scannedFiles = 0 ;
10611186
10621187 for ( const rel of SCAN_FILES ) {
@@ -1073,10 +1198,12 @@ function main() {
10731198 const text = readFileSync ( abs , 'utf8' ) ;
10741199 const analysis = analyzeSource ( rel , text ) ;
10751200 discovered += analysis . builders . length ;
1201+ readDoors += analysis . builders . filter ( ( b ) => ! b . write ) . length ;
10761202 const posix = relative ( ROOT , abs ) . replace ( / \\ / g, '/' ) ;
10771203 problems . push ( ...violationsOf ( posix , analysis ) ) ;
10781204 const d8 = nullArmsOf ( rel , text ) ;
10791205 tenantPredicates += d8 . predicates ;
1206+ armsSeen += d8 . arms . length ;
10801207 nullArms . push ( ...nullArmViolationsOf ( posix , d8 . arms ) ) ;
10811208 }
10821209
@@ -1157,6 +1284,14 @@ NULL_ARM_EXEMPT in scripts/check-tenant-chokepoint.mjs — which reverses an ADR
11571284decision and needs a superseding ADR first.` ) ;
11581285 }
11591286
1287+ // The readings, on one line a base and a head can be compared by (#29520):
1288+ // a split that moves a door out of the files read changes one of them.
1289+ console . log (
1290+ `check:tenant-chokepoint readings: ${ discovered } getBuilder() binding(s), ${ readDoors } read door(s), ` +
1291+ `${ discovered - readDoors } insert builder(s), ${ tenantPredicates } tenant predicate(s), ` +
1292+ `${ armsSeen } D8 arm(s) across ${ scannedFiles } file(s)` ,
1293+ ) ;
1294+
11601295 if ( problems . length > 0 || nullArms . length > 0 ) process . exit ( 1 ) ;
11611296
11621297 console . log (
0 commit comments