Skip to content

Commit 6b95c5d

Browse files
committed
feat(devx): 给 @objectstack/verify 结构替身的驱动实参加护栏 (#6399)
#6354 / PR #6396 一次删掉 10 个 `as never` —— `checkReadCoercion` / `checkDateBucketParity` 的全部调用点 —— 实测全部是死 cast,而它们盖住的 编译期检查是活的。两件事都没有任何门禁会响:明天原样加回来,或在新调用 点写第 11 个,仍然没有一道门会响。本次把这个状态锁住。 实测先于选型。全仓 703 处 `as never`(`git grep 'as never'` 的 1122 是 把 `has never` 一并数了进去),其中 550 处在调用实参位、536 处在测试文件、 分布在 33 个包。据此排除 sweep 暗示的方向 1 与宽口径的方向 2: - 方向 1(扩 `check:query-options-erasure` 词表)实测**根本够不着**本单的 调用点。该规则只匹配 callee 为成员表达式且名为 find/findOne/count/ aggregate、且实参下标 >= 1 的位置;本单 10 处全是裸标识符 callee、驱动 在下标 0。只把 `any` 扩成 `never` 会命中其中 0 处,却要把几百个无关站点 拖进它的 baseline,并让那道门的语义变糊。 - 方向 2 的宽口径版本会一次性命中 536 处既有站点,绝大多数是正当的 —— 负向测试要构造 `tsc` 本就该拒绝的输入。#4918 当初对 `as any` 已经 按同一理由做过同样的取舍(QUERY_OPTIONS_TEST_GLOBS)。 落点因此是方向 2 的窄口径版本:一条专用 ESLint 插件规则,只禁止对 `@objectstack/verify` 结构替身检查的**驱动实参**(下标 0)写类型断言。 那个参数类型不是装饰,它就是这次一致性检查的编译期一半。今天 10 处调用点 全干净,所以**零 baseline** —— 这正是它不该并进上面两道门的原因。 规则之外另加一道对账门 `check:verify-stand-in`,因为规则本身锁不住: `pnpm lint` 在「树干净」「helper 被改名后规则匹配不到任何东西」「新增了 第三个替身检查、生来无人守」三种情况下同样是绿的。该门把受守集合与 `packages/verify` 实际导出的东西**双向对账**,并对调用点**计数**(实测 10 处 = 8 + 2,与 PR #6396 的测量独立吻合),于是「没有违规」永远不等于 「什么都没看」。 ⚠️ 护栏缺失的代价在假驱动那一侧最高:10 处里 6 处传手写假驱动,4 处传真 实驱动。真实驱动来自生产代码、本来就大概率满足替身;手写字面量才是会漂移 的那一类。规则因此覆盖测试文件,与 query-options 规则的取舍相反,理由写在 规则注释里。 未采纳方向 3(在替身侧加 `satisfies` 钉子)的原因:它把一致性钉在真实驱动 那一侧,恰好是代价较低的一侧,对 6 处手写假驱动无效;且要在 `packages/verify` 里点名一个具体驱动,与 `read-coercion.ts` 注释写明的 「不导入具体驱动类型」的设计意图相悖。 不并 #6394:那是 `driver.create` 的 options 门(下标 1),本规则的 self-test 显式钉住它在范围之外。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
1 parent 6ce10bd commit 6b95c5d

4 files changed

Lines changed: 764 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ jobs:
9494
- name: Engine query-options erasure ratchet
9595
run: pnpm check:query-options-erasure
9696

97+
# @objectstack/verify stand-in erasure guard (#6399). The third and
98+
# narrowest member of the two above. `checkReadCoercion` /
99+
# `checkDateBucketParity` take their driver STRUCTURALLY
100+
# (`CoercibleDriver` / `BucketableDriver`) so an out-of-tree driver —
101+
# cloud's driver-turso in remote mode — runs the identical contract
102+
# without importing a concrete driver type; that parameter type IS the
103+
# compile-time half of the conformance, and an assertion on the argument
104+
# deletes it for that call site while looking like a call that has it.
105+
# #6354 / PR #6396 is the bill: ten `as never` casts, every call site of
106+
# both helpers, all of them dead, sitting over a check that was provably
107+
# alive — and no gate rang for either fact.
108+
#
109+
# The ESLint rule (`verify-stand-in/no-asserted-driver-argument`) blocks
110+
# the assertion. This step is the half that stops the rule going dead: it
111+
# reconciles the guarded set against what packages/verify actually exports
112+
# in BOTH directions — so a third stand-in check cannot arrive unguarded
113+
# and a stale entry cannot rot — and COUNTS the call sites it reached, so
114+
# a rename or a moved import surfaces as a census that fell instead of as
115+
# a green run over nothing. `pnpm lint` alone cannot tell "clean tree"
116+
# from "matched nothing". Runs its own --self-test first.
117+
- name: "@objectstack/verify stand-in erasure guard"
118+
run: pnpm check:verify-stand-in
119+
97120
# Raw control-byte guard (#3127 / #4890 / #5157 / #5460). Scans every
98121
# tracked TEXT file for a raw ASCII control byte and fails on any hit.
99122
# WHICH bytes are in the set and WHY each is rejected are stated and argued

eslint.config.mjs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,161 @@ const queryOptionsPlugin = {
404404
},
405405
};
406406

407+
// ---------------------------------------------------------------------------
408+
// [#6399] `@objectstack/verify` structural stand-in erasure guard.
409+
//
410+
// The third member of the family above, and the narrowest. `checkReadCoercion`
411+
// and `checkDateBucketParity` take their driver STRUCTURALLY — `CoercibleDriver`
412+
// / `BucketableDriver` — so an out-of-tree driver (cloud's `driver-turso` in
413+
// remote mode) can run the identical contract without importing a concrete
414+
// driver type. That parameter type is not decoration around the check: for the
415+
// compile-time half of the conformance it IS the check. Assert the argument and
416+
// the stand-in stops standing for anything, at that call site, while the code
417+
// reads exactly like the checked kind.
418+
//
419+
// #6354/PR #6396 is the bill: TEN `as never` casts — every call site of both
420+
// helpers — had switched that half off, long enough that nobody remembered
421+
// writing them. They were provably dead (removing all ten left three packages'
422+
// typecheck at exit=0) and the compile-time check they had been hiding is
423+
// provably live (adding a member no real driver can have turned all ten sites
424+
// red, 8+2 matching the cast count exactly). Nothing rang for either fact.
425+
//
426+
// ⚠️ The cost is highest on the FAKE-driver side. Six of the ten sites pass a
427+
// hand-written literal; four pass a real driver. A real driver comes from
428+
// production code and mostly satisfies the stand-in whether or not anyone
429+
// checks — a hand-written fake is precisely the thing that drifts, and it is
430+
// the arm an assertion silences most cheaply.
431+
//
432+
// Scope is argument 0 — the driver — and nothing else. The options bag is a
433+
// different type with its own `unknown` slots, and an assertion there is
434+
// #6394's subject, not this rule's.
435+
//
436+
// WHY A DEDICATED RULE, not a widened `check:query-options-erasure`: measured,
437+
// that ratchet cannot reach these sites at all. `query-options/no-any-erasure`
438+
// keys on a MEMBER-expression callee named `find|findOne|count|aggregate` and
439+
// only inspects arguments at index >= 1; every site here is a bare-identifier
440+
// callee with the driver at index 0. Teaching it the word `never` would have
441+
// matched zero of the ten while pulling several hundred unrelated `as never`
442+
// sites into its baseline and blurring what "query/options type erasure" means.
443+
//
444+
// WHY NOT a blanket ban on `as never` at call arguments in tests: 550 of the
445+
// repo's 703 `as never` assertions sit at a call-argument position, 536 of them
446+
// in test files across 33 packages, and the large majority are legitimate —
447+
// a negative test constructing input `tsc` is supposed to refuse. That is the
448+
// same trade-off QUERY_OPTIONS_TEST_GLOBS already resolved the same way: a
449+
// blocking rule there fights the tests that prove the contract is enforced.
450+
//
451+
// The guarded set is reconciled against `packages/verify/src` in BOTH
452+
// directions by `pnpm check:verify-stand-in`, so a third stand-in check cannot
453+
// arrive unguarded and a renamed helper cannot leave this rule silently
454+
// matching nothing. A guard whose covered set is a hand-list nobody re-checks
455+
// is the dead-pin shape (#4984 / #5018), and this one is not allowed to become
456+
// it.
457+
export const VERIFY_STAND_IN_CHECKS = {
458+
checkReadCoercion: 'CoercibleDriver',
459+
checkDateBucketParity: 'BucketableDriver',
460+
};
461+
462+
// The rule's own id, exported so `check:verify-stand-in` identifies this rule's
463+
// reports exactly rather than by message text — same reasoning as
464+
// QUERY_OPTIONS_RULE_ID.
465+
export const VERIFY_STAND_IN_RULE_ID = 'verify-stand-in/no-asserted-driver-argument';
466+
467+
export const VERIFY_STAND_IN_MESSAGE =
468+
'Do not type-assert the driver argument of a @objectstack/verify conformance check. ' +
469+
'`checkReadCoercion(driver)` / `checkDateBucketParity(driver)` declare that parameter as a ' +
470+
'structural stand-in (`CoercibleDriver` / `BucketableDriver`) so any driver — including an ' +
471+
'out-of-tree one — can run the identical contract; that declaration is the compile-time half ' +
472+
'of the conformance, and an assertion on the argument deletes it for this call site while ' +
473+
'looking identical to a call that has it. Ten such casts (`as never`, every call site of both ' +
474+
'helpers) lived in this repo long enough that nobody remembered writing them — all ten dead, ' +
475+
'and the check underneath them alive (#6354 / PR #6396). Six of the ten passed a HAND-WRITTEN ' +
476+
'fake driver, which is the arm that actually drifts. Pass the driver unasserted. If it does ' +
477+
'not satisfy the stand-in, that is the finding — fix the driver or widen the stand-in ' +
478+
'deliberately, in `packages/verify/src`, where the change is reviewed once instead of ' +
479+
'silenced per call site. See issues #6354, #6394 and #6399.';
480+
481+
const verifyStandInPlugin = {
482+
rules: {
483+
'no-asserted-driver-argument': {
484+
meta: {
485+
type: 'problem',
486+
docs: {
487+
description:
488+
'Ban type-asserting the driver argument of a @objectstack/verify structural conformance check.',
489+
},
490+
schema: [],
491+
messages: { erased: VERIFY_STAND_IN_MESSAGE },
492+
},
493+
create(context) {
494+
const guarded = new Set(Object.keys(VERIFY_STAND_IN_CHECKS));
495+
496+
/**
497+
* True when `node` is, or wraps, ANY type assertion.
498+
*
499+
* Deliberately wider than `erasesToAny` above: on this argument there is
500+
* no assertion worth allowing. `as unknown as BucketableDriver` is the
501+
* sanctioned escape for engine query OPTIONS because a test may need
502+
* off-contract input on purpose; here the parameter type is the contract
503+
* UNDER TEST, so re-labelling the argument with it asserts exactly the
504+
* thing the call was supposed to prove. Every one of the ten historical
505+
* casts would be re-admitted by an `any`-only test.
506+
*/
507+
const isAsserted = (node) => {
508+
for (let cur = node; cur; cur = cur.expression) {
509+
if (cur.type === 'TSAsExpression' || cur.type === 'TSTypeAssertion') return true;
510+
if (cur.type === 'TSNonNullExpression') continue;
511+
return false;
512+
}
513+
return false;
514+
};
515+
516+
/**
517+
* True when `name` resolves, in scope, to a variable declared `: any` or
518+
* `: never` — the split form (`const d: any = brokenDriver(); check(d)`),
519+
* which erases the stand-in exactly as the inline assertion does and is
520+
* the first shape someone reaches for once the inline one is blocked.
521+
* Scope analysis, not a name heuristic — same mechanism, and the same
522+
* reason, as `slot-lookup/no-any-assignment`.
523+
*/
524+
const declaredErasedVariable = (name, node) => {
525+
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
526+
const variable = scope.variables.find((v) => v.name === name);
527+
if (!variable) continue;
528+
return variable.defs.some((d) => {
529+
const kind = d.node?.id?.typeAnnotation?.typeAnnotation?.type;
530+
return (
531+
d.node?.type === 'VariableDeclarator' &&
532+
(kind === 'TSAnyKeyword' || kind === 'TSNeverKeyword')
533+
);
534+
});
535+
}
536+
return false;
537+
};
538+
539+
return {
540+
CallExpression(node) {
541+
// Bare-identifier callee only. These names are distinctive enough to
542+
// treat as reserved, and matching the name rather than the resolved
543+
// import is what keeps a re-export or a test-local alias from
544+
// quietly leaving the rule behind.
545+
if (node.callee?.type !== 'Identifier' || !guarded.has(node.callee.name)) return;
546+
const driver = node.arguments?.[0];
547+
if (!driver) return;
548+
if (isAsserted(driver)) {
549+
context.report({ node: driver, messageId: 'erased' });
550+
return;
551+
}
552+
if (driver.type === 'Identifier' && declaredErasedVariable(driver.name, node)) {
553+
context.report({ node: driver, messageId: 'erased' });
554+
}
555+
},
556+
};
557+
},
558+
},
559+
},
560+
};
561+
407562
export default [
408563
{
409564
files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'],
@@ -629,4 +784,36 @@ export default [
629784
plugins: { 'query-options': queryOptionsPlugin },
630785
rules: { 'query-options/no-any-erasure': 'error' },
631786
},
787+
// issue #6399 — @objectstack/verify stand-in erasure guard. Rationale and the
788+
// #6354 / PR #6396 measurement are on `VERIFY_STAND_IN_MESSAGE` above.
789+
//
790+
// No `ignores` beyond the build dirs and NO baseline, which is the whole
791+
// reason this is its own rule rather than a widening of one of the two above:
792+
// the tree is clean TODAY (all ten casts removed by PR #6396), so the guard
793+
// starts at zero and every future violation is a new one. Both siblings had
794+
// to grandfather hundreds of pre-existing sites; there is nothing here to
795+
// grandfather, and adding one later would mean the state stopped being locked.
796+
//
797+
// Scope is unrestricted on purpose. The four files holding call sites today
798+
// are `packages/qa/dogfood/test/` and `packages/drivers/driver-turso/src/`,
799+
// but `@objectstack/verify` is a PUBLISHED helper whose whole point is being
800+
// callable from anywhere — a package-scoped rule would go quiet exactly when
801+
// the eleventh call site lands somewhere new, which is the case this issue
802+
// exists to cover.
803+
//
804+
// ⚠️ Test files are IN scope here, unlike the query-options rule. That rule
805+
// lifts them because a test may legitimately need off-contract engine input;
806+
// this argument has no legitimate off-contract form (see the message), and
807+
// six of the ten historical casts were in test files passing hand-written
808+
// fakes — the arm the guard is worth the most on.
809+
{
810+
files: ['**/*.{ts,tsx,mts,cts}'],
811+
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/.turbo/**'],
812+
languageOptions: {
813+
parser: tsParser,
814+
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
815+
},
816+
plugins: { 'verify-stand-in': verifyStandInPlugin },
817+
rules: { 'verify-stand-in/no-asserted-driver-argument': 'error' },
818+
},
632819
];

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs --self-test && node scripts/check-single-authz-resolver.mjs",
4646
"check:slot-lookup": "node scripts/check-slot-lookup-ratchet.mjs",
4747
"check:query-options-erasure": "node scripts/check-query-options-erasure-ratchet.mjs --self-test && node scripts/check-query-options-erasure-ratchet.mjs",
48+
"check:verify-stand-in": "node scripts/check-verify-stand-in-erasure.mjs --self-test && node scripts/check-verify-stand-in-erasure.mjs",
4849
"check:service-providers": "node scripts/check-service-providers.mjs",
4950
"check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs",
5051
"check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs",

0 commit comments

Comments
 (0)