@@ -114,6 +114,35 @@ const ENGINE_SIBLINGS = new Set([
114114/** Parameter names that mean "this is the DRIVER's delete(object, id, options)". */
115115const ID_PARAM = / ^ _ * ( i d | r e c o r d I d | i d s | p k ) $ / i;
116116
117+ /**
118+ * Members present on `IDataDriver` and on NEITHER `IDataEngine` nor the ObjectQL
119+ * class — so declaring one is positive evidence of the DRIVER contract.
120+ *
121+ * Consulted only when the parameter test cannot answer (see `isEngineDeleteShape`).
122+ * Deliberately excludes every name both contracts share — `find`, `findOne`,
123+ * `update`, `count`, `delete` and `execute` (the engine declares `execute?` too,
124+ * `data-engine.ts`) — because a name on both sides separates nothing.
125+ */
126+ const DRIVER_ONLY_MEMBERS = new Set ( [
127+ 'connect' , 'disconnect' , 'checkHealth' , 'getPoolStats' , 'create' , 'upsert' ,
128+ 'bulkCreate' , 'bulkUpdate' , 'bulkDelete' , 'updateMany' , 'deleteMany' ,
129+ 'beginTransaction' , 'commit' , 'rollback' , 'syncSchema' , 'syncSchemasBatch' ,
130+ 'registerExternalObject' , 'getSchemaSyncStats' , 'dropTable' , 'reclaimSpace' ,
131+ 'explain' , 'temporalFilterValue' , 'temporalFilterColumnSql' ,
132+ ] ) ;
133+
134+ /**
135+ * The engine-side half of the same evidence: on `IDataEngine` (`insert`,
136+ * `aggregate`) or on the ObjectQL class itself (`getSchema`, `registry`,
137+ * `insertMany`), and absent from `IDataDriver`.
138+ *
139+ * A subset of ENGINE_SIBLINGS, and the distinction is the whole point: `find` /
140+ * `findOne` / `update` / `count` are engine siblings for DISCOVERY (they mark a
141+ * data-access object) while being useless for ATTRIBUTION (drivers speak all
142+ * four). Only the names here answer "engine, not driver".
143+ */
144+ const ENGINE_ONLY_MEMBERS = new Set ( [ 'insert' , 'insertMany' , 'aggregate' , 'getSchema' , 'registry' ] ) ;
145+
117146// ── Discovery ───────────────────────────────────────────────────────────────
118147
119148function walk ( dir , out = [ ] ) {
@@ -169,10 +198,43 @@ function memberName(member) {
169198 * The second parameter is the whole question: the engine takes an options bag
170199 * there, the driver takes a primary key. Judged on the name first (the repo
171200 * writes `id` when it means one) and on a scalar type annotation second.
201+ *
202+ * ## When there IS no second parameter (#5629)
203+ *
204+ * A fake omits the parameters it ignores — `async delete() { return false; }` —
205+ * and this function used to open with `if (params.length < 2) return false`,
206+ * which discarded the double before any other test ran. Not "declared out of
207+ * scope": unreachable. Those deletes reached neither PINNED nor the ledger and
208+ * produced no output at all, which is the #4868 shape this script's own
209+ * DISCOVERED invariant is written against. Measured on this branch: 92 such
210+ * deletes behind that one line, 0 of them pinned.
211+ *
212+ * So when arity cannot answer, the SIBLING SET answers instead — and it has to
213+ * be a real test, not a waved-through `return true`. #5629's premise for a
214+ * blanket admit ("a zero-parameter delete cannot be the driver's, since the
215+ * driver's signature has a primary-key position") does not survive measurement:
216+ * fake DRIVERS drop their unused parameters exactly like fake engines do, so 43
217+ * of those 92 are driver doubles — `spec/src/contracts/data-driver.test.ts`
218+ * itself, and `objectql/src/engine-aggregate-having.test.ts`'s self-described
219+ * "driver WITH native aggregate()". Admitting them unconditionally would have
220+ * pointed this gate at the wrong contract 43 times.
221+ *
222+ * The evidence that does separate them is which members the object declares
223+ * ALONGSIDE delete: it must show a member only the engine has, and none that
224+ * only the driver has. Both halves are load-bearing — `aggregate` alone admits
225+ * the native-aggregate driver above, and "no driver members" alone admits any
226+ * `{ find, findOne, update, delete }` store mock that is neither contract.
172227 */
173- function isEngineDeleteShape ( fn ) {
228+ function isEngineDeleteShape ( fn , memberNames = new Set ( ) ) {
174229 const params = fn . parameters ?? [ ] ;
175- if ( params . length < 2 ) return false ;
230+ if ( params . length < 2 ) {
231+ let engineEvidence = false ;
232+ for ( const n of memberNames ) {
233+ if ( DRIVER_ONLY_MEMBERS . has ( n ) ) return false ;
234+ if ( ENGINE_ONLY_MEMBERS . has ( n ) ) engineEvidence = true ;
235+ }
236+ return engineEvidence ;
237+ }
176238 const second = params [ 1 ] ;
177239 const name = ts . isIdentifier ( second . name ) ? second . name . text : '' ;
178240 if ( ID_PARAM . test ( name ) ) return false ;
@@ -279,7 +341,7 @@ function scanSource(fileName, text) {
279341 if ( ! del ) return ;
280342 const siblings = [ ...names ] . filter ( ( n ) => ENGINE_SIBLINGS . has ( n ) ) ;
281343 if ( siblings . length < 2 ) return ;
282- if ( ! isEngineDeleteShape ( del ) ) return ;
344+ if ( ! isEngineDeleteShape ( del , names ) ) return ;
283345 const line = sf . getLineAndCharacterOfPosition ( node . getStart ( sf ) ) . line + 1 ;
284346 doubles . push ( { line, siblings : siblings . sort ( ) , pinned : bodyIsPinned ( del ) } ) ;
285347 } ;
@@ -508,6 +570,82 @@ const engine = {
508570 d = scanSource ( 'p.test.ts' , arrowFake ) ;
509571 expect ( 'an arrow-property fake engine is in scope' , d . length === 1 && d [ 0 ] . pinned === false ) ;
510572
573+ // ── Arity: a fake omits the parameters it ignores (#5629).
574+ //
575+ // `async delete() { return false; }` is the commonest engine-double spelling
576+ // in this repo, and it used to leave the scan before any other test ran — 92
577+ // deletes, none of them pinned, none of them in the ledger, no output. These
578+ // cases drive both halves of the sibling evidence that admits them now,
579+ // because the obvious cheap fix (admit every short-arity delete) is WRONG:
580+ // fake drivers drop their unused parameters exactly like fake engines do.
581+ const zeroArityEngine = `
582+ const engine = {
583+ async find(o: string) { return []; },
584+ async findOne(o: string) { return null; },
585+ async insert(o: string, d: any) { return d; },
586+ async update(o: string, d: any) { return d; },
587+ async delete() { return false; },
588+ };
589+ ` ;
590+ d = scanSource ( 'z.test.ts' , zeroArityEngine ) ;
591+ expect ( 'a zero-parameter engine delete is in scope' , d . length === 1 && d [ 0 ] . pinned === false ) ;
592+
593+ // Same shape, one parameter — `action-body-identity.test.ts`'s scoped facade.
594+ const oneArityEngine = `
595+ const engine = {
596+ find: async (o: string) => [],
597+ insert: async (o: string, d: any) => d,
598+ update: async (o: string, d: any) => d,
599+ delete: async (opts?: any) => ({ ok: true }),
600+ };
601+ ` ;
602+ d = scanSource ( 'y.test.ts' , oneArityEngine ) ;
603+ expect ( 'a single-parameter engine delete is in scope' , d . length === 1 && d [ 0 ] . pinned === false ) ;
604+
605+ // A fake DRIVER with the same zero-parameter delete must stay out: driver-only
606+ // members veto. `spec/src/contracts/data-driver.test.ts` is this shape.
607+ const zeroArityDriver = `
608+ const driver = {
609+ async find(o: string) { return []; },
610+ async findOne(o: string) { return null; },
611+ async update(o: string, id: string, d: any) { return d; },
612+ async create(o: string, d: any) { return d; },
613+ async checkHealth() { return true; },
614+ async delete() { return true; },
615+ };
616+ ` ;
617+ expect ( 'a zero-parameter DRIVER delete stays out of scope' ,
618+ scanSource ( 'zd.test.ts' , zeroArityDriver ) . length === 0 ) ;
619+
620+ // The veto has to outrank engine-looking evidence, or `engine-aggregate-
621+ // having.test.ts`'s self-described "driver WITH native aggregate()" is read as
622+ // an engine: drivers may implement `aggregate` for pushdown.
623+ const nativeAggregateDriver = `
624+ const driver = {
625+ async find() { return []; },
626+ async count() { return 0; },
627+ async create(o: string, d: any) { return d; },
628+ async bulkCreate(o: string, rows: any[]) { return rows; },
629+ async aggregate(o: string, ast: any) { return []; },
630+ async delete() { return true; },
631+ };
632+ ` ;
633+ expect ( 'a zero-parameter driver that implements aggregate() stays out of scope' ,
634+ scanSource ( 'zn.test.ts' , nativeAggregateDriver ) . length === 0 ) ;
635+
636+ // And the positive half must be required too, or every `{ find, findOne,
637+ // update, delete }` store mock — neither contract — becomes a finding.
638+ const zeroArityStoreMock = `
639+ const store = {
640+ async find(k: string) { return []; },
641+ async findOne(k: string) { return null; },
642+ async update(k: string, v: any) { return v; },
643+ async delete() { return true; },
644+ };
645+ ` ;
646+ expect ( 'a zero-parameter mock with no engine-only member stays out of scope' ,
647+ scanSource ( 'zs.test.ts' , zeroArityStoreMock ) . length === 0 ) ;
648+
511649 // The import must come from the producer. A same-named local function is not
512650 // the contract — the whole point is that ONE predicate answers.
513651 d = scanSource ( 'q.test.ts' , engineFake ( 'assertEngineDeleteDispatch(opts); return 1;' ,
@@ -547,9 +685,10 @@ const engine = {
547685 process . exit ( 1 ) ;
548686 }
549687 console . log (
550- 'OK self-test: separates engine doubles from driver doubles, accepts only the producer\'s '
551- + 'predicate (direct or one helper deep), rejects unused imports, hand-mirrored guards and '
552- + 'look-alikes, and proves discovery reaches the real tree.' ,
688+ 'OK self-test: separates engine doubles from driver doubles, admits a delete that declares '
689+ + 'fewer than two parameters only on engine-vs-driver sibling evidence, accepts only the '
690+ + 'producer\'s predicate (direct or one helper deep), rejects unused imports, hand-mirrored '
691+ + 'guards and look-alikes, and proves discovery reaches the real tree.' ,
553692 ) ;
554693}
555694
0 commit comments