Skip to content

Commit 0cd475e

Browse files
author
os-dev
committed
fix(devx): check:engine-double-contract 看不见零/单形参的假引擎 delete
`isEngineDeleteShape()` 的首行 `params.length < 2 → return false` 把「不声明 形参」的假引擎 delete 在任何其他判据之前就丢掉了。这些 double 既不进 PINNED 也不进台账,不产生任何输出 —— 正是本脚本 DISCOVERED 不变量针对的 #4868 形状 (检查在跑、是绿的、结构上够不到它的对象)。 形参数少于 2 时改为回落到 sibling 证据,而不是无条件放行:立单时设想的 「零形参不可能是驱动(驱动签名必有主键位)」经实测不成立 —— 假驱动同样会省掉 不用的形参,本分支上 92 个短形参 delete 里有 43 个是驱动 double(其中包括 spec/src/contracts/data-driver.test.ts 本身,以及 objectql 里自述 「driver WITH native aggregate()」的那个)。所以判据要求 object 同时满足: 声明了只有引擎才有的成员,且没有只有驱动才有的成员。两半都是承重的。 本 PR 只做「放宽判据 + 记债」,不做任何收编:49 个新可见 double(36 个文件) 全量进 MEASURED 台账,pinned 计数不变(27),门禁落地后全绿,台账对这批 double 从此 shrink-only。逐条 why 都实测过:注入 stderr 标记后 49 个 delete 全部未被所在测试调用(休眠宽松),同一次运行里 run-summary.test.ts 已 pinned 的那个 delete 标记正常打印,作为「静默是证据而非探针坏了」的对照。 Fixes #5629
1 parent 414395b commit 0cd475e

2 files changed

Lines changed: 387 additions & 10 deletions

File tree

scripts/check-engine-double-contract.mjs

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,35 @@ const ENGINE_SIBLINGS = new Set([
114114
/** Parameter names that mean "this is the DRIVER's delete(object, id, options)". */
115115
const ID_PARAM = /^_*(id|recordId|ids|pk)$/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

119148
function 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

Comments
 (0)