Skip to content

Commit 79b7fa1

Browse files
os-zhuangclaude
andauthored
feat(lint): ban erasing engine query options to any, with a counted shrink-only baseline (#4918) (#5600)
`IDataEngine.find/findOne/count/aggregate` declare their options as `EngineQueryOptions` / `EngineCountOptions` / `EngineAggregateOptions`, and `IDataDriver` declares the same slots as `QueryAST` + `DriverOptions`. For an INTERNAL caller `tsc` is the only channel enforcing those keys: the protocol's ingress normalizer never runs on a direct engine call, and the options schemas are not `.strict()`, so an unknown key is silently DROPPED rather than rejected. One `as any` on the options argument switches that off for the call site while looking identical to code that has it. That is #4674: two internal queries spelled their sort `{ field, direction: 'desc' }` where the QueryAST shape is `SortNodeSchema` = `{ field, order }`. Both drivers normalize off `.order` with no fallback, so both ran ASCENDING, and because both carried a `limit` the wrong direction changed WHICH ROWS came back — audit history returned the oldest events and global search the stalest matches. #4720 restored the two sites, #4721 closed the external (REST/RPC) callers with a strict schema plus an ingress normalizer; this is the third leg, and it stops the shape regrowing internally. New rule `query-options/no-any-erasure` (eslint.config.mjs), three shapes: - an `any` assertion at argument 1 or 2 of a query method — argument 0 is the object NAME on every one of these signatures, which is also what keeps `Array.prototype.find(cb)` out of the rule entirely; - `orderBy: … as any`, which sits one level below the argument and so is invisible to the argument-position check; - the split form (`const opts: any = { … }` … `find(o, opts)`) — the shape #4674's global-search site actually used. Scope analysis, not a name heuristic; needs no type information, so it stays in the untyped lint pass. The assertion chain is walked, so `{ … } as any as EngineQueryOptions` — which checks the literal against nothing and then re-labels it with the contract — is caught too. `as unknown as EngineQueryOptions` is deliberately NOT matched: it names the contract being bypassed, keeps the rest of the call checked, and greps as an intentional act, so it is the sanctioned spelling for input that is deliberately off-contract (a test asserting the engine REJECTS an option). It is a dedicated plugin rule rather than three more `no-restricted-syntax` selectors because flat config does not MERGE rule options: a second block setting `no-restricted-syntax` over `packages/**` would REPLACE the slot-lookup block's selector list for every file both match, silently deleting that rule. The two guards also need independent `ignores`. Residual re-measured on the branch point (the issue's numbers were taken at 89d2a4e, two days and ~40 merges earlier): 84 non-test sites in 19 files and 267 in test code, none of them a false positive. Both go into `scripts/query-options-erasure-baseline.json` and neither is swept here — part of the residual is a real type boundary (objectql's `hookContext.input.options`, the metadata loader's query bag) that needs the boundary type written, not the assertion deleted. `scripts/check-query-options-erasure-ratchet.mjs` (`pnpm check:query-options-erasure`, wired into lint.yml next to the slot-lookup ratchet) is what makes the baseline mean something. Non-test files are grandfathered by path, and an `ignores` entry silences the WHOLE file, so the baseline carries per-file counts measured with the grandfathering lifted — a new erasure in a listed file cannot ride the old entry. Test code is held by one aggregate decrease-only number instead of per-file counts, because an unknown share of those sites are legitimate and a per-file ratchet would go red on a new rejection test with no honest remedy. Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73580e7 commit 79b7fa1

5 files changed

Lines changed: 737 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,24 @@ jobs:
7676
- name: Slot-lookup ratchet
7777
run: pnpm check:slot-lookup
7878

79+
# Engine query-options erasure ratchet (#4918). The slot-lookup rule above
80+
# protects the service LOOKUP; this one protects what you pass to the
81+
# service you looked up. `IDataEngine.find/findOne/count/aggregate` declare
82+
# their options as `EngineQueryOptions` & co., and for an internal caller
83+
# `tsc` is the ONLY channel enforcing them — the protocol's ingress
84+
# normalizer never sees a direct engine call, and the options schemas are
85+
# not `.strict()`, so an unknown key is silently DROPPED. #4674 is the
86+
# bill: two queries sorted by `direction` instead of `order`, both with a
87+
# `limit`, so both returned the OLDEST rows — audit history that never
88+
# showed recent changes, and a search that truncated away fresh records.
89+
# #4720 restored those two sites and #4721 closed the external callers;
90+
# this stops the shape regrowing internally. Same ratchet mechanism as
91+
# slot-lookup for the non-test residual, plus one aggregate decrease-only
92+
# number for test code (a test whose subject IS off-contract engine input
93+
# must be able to build it). Runs its own --self-test first.
94+
- name: Engine query-options erasure ratchet
95+
run: pnpm check:query-options-erasure
96+
7997
# Raw control-byte guard (#3127 / #4890 / #5157 / #5460). Scans every
8098
# tracked TEXT file for a raw ASCII control byte — 0x00-0x08, 0x0b, 0x0c,
8199
# 0x0e-0x1f and 0x7f, i.e. everything except tab/LF/CR. Two distinct

eslint.config.mjs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,198 @@ const slotLookupPlugin = {
212212
},
213213
};
214214

215+
// ---------------------------------------------------------------------------
216+
// [#4918] Engine query-options `any`-erasure guard.
217+
//
218+
// The same failure as the slot-lookup rule above, one layer further in: the
219+
// contract exists, `tsc` is willing to enforce it, and one annotation switches
220+
// that off for the call site while looking identical to code that has it.
221+
//
222+
// `IDataEngine.find/findOne/count/aggregate` declare their options as
223+
// `EngineQueryOptions` / `EngineCountOptions` / `EngineAggregateOptions`
224+
// (`packages/spec/src/contracts/data-engine.ts`), and `IDataDriver` declares the
225+
// same slots as `QueryAST` + `DriverOptions`. For an INTERNAL caller `tsc` is
226+
// the ONLY enforced channel on that path: the protocol's ingress normalizer does
227+
// not run on calls the protocol itself makes to `this.engine.find`, and the
228+
// options schemas are not `.strict()`, so an unknown key is silently DROPPED
229+
// rather than rejected. Erase the type and a wrong key becomes a no-op that
230+
// nothing anywhere reports.
231+
//
232+
// #4674 is the bill: two internal queries spelled their sort
233+
// `{ field, direction: 'desc' }` — `IReportService`'s vocabulary — where the
234+
// QueryAST shape is `SortNodeSchema` = `{ field, order }`. Both drivers
235+
// normalize off `.order` with no fallback, so both queries ran ASCENDING, and
236+
// because both carried a `limit` the wrong direction changed WHICH ROWS came
237+
// back: metadata audit history returned the oldest events (never an object's
238+
// recent changes) and global search returned the stalest matches. `#4720`
239+
// restored those two sites, `#4721` closed the external (REST/RPC) callers with
240+
// a strict schema plus an ingress normalizer, and this rule is the third leg —
241+
// it stops the erasure regrowing on the internal side.
242+
const ENGINE_QUERY_READ_METHODS = ['find', 'findOne', 'count', 'aggregate'];
243+
244+
// Exported so `scripts/check-query-options-erasure-ratchet.mjs` measures the
245+
// SAME surface this rule blocks. The ratchet lifts these to count the test-side
246+
// residual; the rule itself never runs on them.
247+
//
248+
// The first cut is deliberately non-test only (the 08-03 triage on #4918). Test
249+
// code holds the large majority of the erasures, and an unknown share of those
250+
// are legitimate: a test whose SUBJECT is off-contract engine input (see
251+
// `engine-unknown-option.test.ts`, `engine-wire-alias-reject.test.ts`) has to
252+
// erase the type to construct input `tsc` would otherwise refuse. A blocking
253+
// rule there would fight the tests that prove the contract is enforced, so the
254+
// test surface is held by a COUNT instead — see the ratchet.
255+
export const QUERY_OPTIONS_TEST_GLOBS = [
256+
'**/*.test.{ts,tsx,mts,cts}',
257+
'**/*.spec.{ts,tsx,mts,cts}',
258+
];
259+
260+
// The rule's own id, exported so the ratchet identifies this rule's reports
261+
// exactly rather than by message text. (The slot-lookup ratchet matches on
262+
// message because that rule shares `no-restricted-syntax` with three others;
263+
// this one is a dedicated rule, so the id is available and is stricter.)
264+
export const QUERY_OPTIONS_RULE_ID = 'query-options/no-any-erasure';
265+
266+
export const QUERY_OPTIONS_ANY_MESSAGE =
267+
'Do not erase an engine query-options value to `any` — not as `find(obj, { … } ' +
268+
'as any)`, not as an `orderBy: … as any`, and not as a `const opts: any` that is ' +
269+
'then passed as the options argument. `EngineQueryOptions` (and `QueryAST` on the ' +
270+
'driver side) already declare every key these methods read, and for an internal ' +
271+
'caller `tsc` is the ONLY channel that enforces them: the protocol\'s ingress ' +
272+
'normalizer does not run on a direct engine call, and the options schemas are not ' +
273+
'`.strict()`, so an unknown key is silently DROPPED, never rejected. That is #4674 ' +
274+
'— two queries sorted by `direction` (IReportService\'s vocabulary) instead of ' +
275+
'`order` (SortNodeSchema\'s), both with a `limit`, so both quietly returned the ' +
276+
'OLDEST rows: audit history that never showed an object\'s recent changes, and a ' +
277+
'global search that truncated away the freshly-edited records. The declared type ' +
278+
'would have rejected `direction` at the call site; the erasure is the only reason ' +
279+
'it compiled. Type the value instead (`const opts: EngineQueryOptions = { … }`, or ' +
280+
'just drop the assertion — these signatures already infer). If the value is ' +
281+
'DELIBERATELY off-contract — a test asserting the engine REJECTS an unknown option ' +
282+
'— write `as unknown as EngineQueryOptions`: that names the contract being ' +
283+
'bypassed, keeps the rest of the call type-checked, and greps as an intentional ' +
284+
'act, none of which a bare `as any` does. See issues #4674, #4720, #4721, #4918.';
285+
286+
// [#4918] The unswept residual, grandfathered BY FILE from
287+
// `scripts/query-options-erasure-baseline.json` — same mechanism, and same
288+
// reasoning, as SLOT_LOOKUP_UNSWEPT above: `pnpm lint` runs with
289+
// `--no-inline-config`, so the escape has to live in config, and one shrinking
290+
// counted list is the ratchet made visible. An `ignores` entry silences the
291+
// WHOLE file, which is exactly why the baseline carries per-file COUNTS and
292+
// `pnpm check:query-options-erasure` enforces them.
293+
//
294+
// ⛔ Do NOT sweep these sites in the same PR that touches this rule. Part of the
295+
// residual is a real type boundary (`hookContext.input.options`, the metadata
296+
// loader's `Record<string, unknown>` query bag) and needs the boundary type
297+
// written, not the assertion deleted — a separate batch.
298+
const QUERY_OPTIONS_UNSWEPT = Object.keys(JSON.parse(
299+
readFileSync(new URL('./scripts/query-options-erasure-baseline.json', import.meta.url), 'utf8'),
300+
).nonTest);
301+
302+
const queryOptionsPlugin = {
303+
rules: {
304+
'no-any-erasure': {
305+
meta: {
306+
type: 'problem',
307+
docs: { description: 'Ban erasing an engine query-options value to `any`.' },
308+
schema: [],
309+
messages: { erased: QUERY_OPTIONS_ANY_MESSAGE },
310+
},
311+
create(context) {
312+
const methods = new Set(ENGINE_QUERY_READ_METHODS);
313+
314+
/**
315+
* True when `node` is, or wraps, an `any` assertion.
316+
*
317+
* Walks the whole assertion chain rather than testing the outermost
318+
* node, so `{ … } as any as EngineQueryOptions` is caught too: that
319+
* spelling checks the literal against nothing and then re-labels the
320+
* result with the contract, which erases the keys exactly as `as any`
321+
* does while reading as if it were typed. `as unknown as X` is NOT
322+
* matched, on purpose — see the message.
323+
*/
324+
const erasesToAny = (node) => {
325+
for (let cur = node; cur; cur = cur.expression) {
326+
if (cur.type === 'TSAsExpression' || cur.type === 'TSTypeAssertion') {
327+
if (cur.typeAnnotation?.type === 'TSAnyKeyword') return true;
328+
continue;
329+
}
330+
if (cur.type === 'TSNonNullExpression') continue;
331+
return false;
332+
}
333+
return false;
334+
};
335+
336+
/**
337+
* True when `name` resolves, in scope, to a local VARIABLE declared
338+
* `: any` — the split form (`const opts: any = { … }` … `find(o, opts)`)
339+
* that #4674's global-search site actually used.
340+
*
341+
* Scope analysis, not a name heuristic: a rule keyed on the identifier's
342+
* spelling would flag every `const options: any` in the repo whether or
343+
* not it ever reaches a query, and miss the ones spelled anything else.
344+
* Deliberately restricted to variable declarations — an `: any`
345+
* PARAMETER forwarded into a query is a different (and much larger,
346+
* mostly test-double) population, out of this cut's scope.
347+
*/
348+
const declaredAnyVariable = (name, node) => {
349+
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
350+
const variable = scope.variables.find((v) => v.name === name);
351+
if (!variable) continue;
352+
return variable.defs.some(
353+
(d) =>
354+
d.node?.type === 'VariableDeclarator' &&
355+
d.node.id?.typeAnnotation?.typeAnnotation?.type === 'TSAnyKeyword',
356+
);
357+
}
358+
return false;
359+
};
360+
361+
return {
362+
CallExpression(node) {
363+
if (node.callee?.type !== 'MemberExpression') return;
364+
const property = node.callee.property;
365+
if (property?.type !== 'Identifier' || !methods.has(property.name)) return;
366+
367+
node.arguments.forEach((argument, index) => {
368+
// Argument 0 is the object/table NAME on every one of these
369+
// signatures; the options bags are 1 (the query) and 2
370+
// (`BaseEngineOptions` / `DriverOptions`). Starting at 1 is also
371+
// what keeps `Array.prototype.find(cb)` — same method name,
372+
// callback at index 0 — out of the rule entirely.
373+
if (index < 1 || !argument) return;
374+
if (erasesToAny(argument)) {
375+
context.report({ node: argument, messageId: 'erased' });
376+
return;
377+
}
378+
if (argument.type === 'Identifier' && declaredAnyVariable(argument.name, node)) {
379+
context.report({ node: argument, messageId: 'erased' });
380+
}
381+
});
382+
},
383+
384+
// `orderBy` is scoped in by name because it is the key #4674 was
385+
// actually wrong about, and it is erased one level below the argument
386+
// — `...(ast.orderBy ? { orderBy: ast.orderBy as any } : {})` sits
387+
// inside an otherwise-typed options literal, so the argument-position
388+
// check above cannot see it. `SortNodeSchema` is the shape everywhere
389+
// this key appears.
390+
Property(node) {
391+
if (node.computed) return;
392+
const key = node.key;
393+
const isOrderBy =
394+
(key?.type === 'Identifier' && key.name === 'orderBy') ||
395+
(key?.type === 'Literal' && key.value === 'orderBy');
396+
if (!isOrderBy) return;
397+
if (erasesToAny(node.value)) {
398+
context.report({ node: node.value, messageId: 'erased' });
399+
}
400+
},
401+
};
402+
},
403+
},
404+
},
405+
};
406+
215407
export default [
216408
{
217409
files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'],
@@ -393,4 +585,48 @@ export default [
393585
],
394586
},
395587
},
588+
// issue #4918 — engine query-options `any`-erasure guard. Rationale and the
589+
// #4674 cost are on `QUERY_OPTIONS_ANY_MESSAGE` above.
590+
//
591+
// This is a dedicated PLUGIN rule and not three more `no-restricted-syntax`
592+
// selectors, for two reasons that both matter:
593+
//
594+
// 1. Flat config does not MERGE rule options. A second block setting
595+
// `no-restricted-syntax` over `packages/**` would REPLACE the
596+
// slot-lookup block's selector list for every file both blocks match —
597+
// silently deleting that rule. The two guards also need independent
598+
// `ignores` (their unswept sets are different files), which one shared
599+
// block cannot give them.
600+
// 2. The split form needs SCOPE analysis to resolve an identifier to its
601+
// declaration, which esquery cannot express — the same reason
602+
// `slot-lookup/no-any-assignment` exists. Scope analysis needs no type
603+
// information, so this still runs in the plain (untyped) lint pass.
604+
//
605+
// KNOWN RESIDUAL, stated rather than implied: an erasure that happens through
606+
// a typed indirection — a helper declared `(o, q?: any) => engine.find(o, q)`,
607+
// or a wrapper whose own return type is `Promise<any>` — erases the contract
608+
// just as effectively and this rule cannot see it (an `: any` PARAMETER
609+
// forwarded into a query is a real shape, ~50 sites, almost all of them test
610+
// doubles; judging it needs the call graph, not one file's scopes). Same
611+
// boundary as the slot-lookup rule's own KNOWN RESIDUAL, and the same answer:
612+
// it belongs to a typed-lint pass, not here.
613+
{
614+
files: ['packages/**/*.{ts,tsx,mts,cts}'],
615+
ignores: [
616+
'**/node_modules/**',
617+
'**/dist/**',
618+
// First cut is non-test code (08-03 triage). The ratchet lifts this and
619+
// holds the test residual to a count instead.
620+
...QUERY_OPTIONS_TEST_GLOBS,
621+
// Pre-existing sites, grandfathered by file and counted — see
622+
// QUERY_OPTIONS_UNSWEPT and `pnpm check:query-options-erasure`.
623+
...QUERY_OPTIONS_UNSWEPT,
624+
],
625+
languageOptions: {
626+
parser: tsParser,
627+
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
628+
},
629+
plugins: { 'query-options': queryOptionsPlugin },
630+
rules: { 'query-options/no-any-erasure': 'error' },
631+
},
396632
];

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"check:org-identifier": "node scripts/check-org-identifier.mjs",
4141
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs --self-test && node scripts/check-single-authz-resolver.mjs",
4242
"check:slot-lookup": "node scripts/check-slot-lookup-ratchet.mjs",
43+
"check:query-options-erasure": "node scripts/check-query-options-erasure-ratchet.mjs --self-test && node scripts/check-query-options-erasure-ratchet.mjs",
4344
"check:service-providers": "node scripts/check-service-providers.mjs",
4445
"check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs",
4546
"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)