-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy patheslint.config.mjs
More file actions
819 lines (785 loc) · 41.8 KB
/
Copy patheslint.config.mjs
File metadata and controls
819 lines (785 loc) · 41.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { readFileSync } from 'node:fs';
import tsParser from '@typescript-eslint/parser';
// Flat ESLint config — guards against memory-bloating import patterns.
//
// Background: `export * as Namespace from './sub'` is NOT tree-shakeable in
// Node ESM. The 16 namespace re-exports previously in
// `packages/spec/src/index.ts` force-evaluated ~400 Zod schema closures on the
// first `import { Data } from '@objectstack/spec'`, ballooning RSS by ~1.2GB
// in `@objectstack/objectos`. Those root barrels are gone — this rule prevents
// them coming back via consumer imports.
//
// Wired into CI via the root `lint` script (.github/workflows/lint.yml).
// Run locally with `pnpm lint`. The script passes `--no-inline-config`:
// source files carry orphaned `eslint-disable` directives for a richer rule
// set this config does not register (a fuller setup was stripped to this
// import guard), and the flag ignores them so the guard runs clean. The only
// active rule (no-restricted-imports) should never need a local opt-out — it
// prevents a ~1.2GB RSS regression.
//
// Lint ONLY from the root. Per-package `lint` scripts (`eslint src`) were
// removed in #4276: a standalone run resolves this same config but honors the
// inline directives `--no-inline-config` exists to ignore, so it fails on
// rules this config never registers ("Definition for rule … was not found").
// Don't add such a script back — scope a local run from the root instead:
// `pnpm exec eslint --no-inline-config packages/verify/src`.
const SUBPATH_NAMES = [
'Data', 'UI', 'System', 'AI', 'API', 'Automation',
'Security', 'Kernel', 'Cloud', 'QA', 'Identity',
'Integration', 'Contracts', 'Studio', 'Shared',
];
const SUBPATH_RULE_MESSAGE =
'Use subpath imports: `import * as Data from "@objectstack/spec/data"` ' +
'or `import { Field } from "@objectstack/spec/data"`. Root namespace ' +
're-exports were removed because Node ESM cannot tree-shake them — see ' +
'packages/spec/src/index.ts.';
// issue #2035 — the 16 writable domains that now have a `defineX` factory. In
// example/app metadata files these must be authored through the factory, never a
// bare `: DomainType` / `: DomainTypeInput` literal: the factory validates at
// `.parse()` time and is a *value* import that fails loudly on a broken import
// instead of silently degrading to `any` (the #2023 failure mode).
const DOMAIN_TYPES = [
'Datasource', 'Connector', 'Policy', 'SharingRule', 'Position', 'PermissionSet',
'EmailTemplateDefinition', 'Report', 'Webhook', 'ObjectExtension', 'Cube',
'Mapping', 'Theme', 'TranslationBundle', 'Page', 'Action',
].flatMap((t) => [t, t + 'Input']).join('|');
const DOMAIN_RULE_MESSAGE =
'Author this metadata through its defineX factory (e.g. `definePage({ ... })`) ' +
'instead of a bare `: Type` literal. The factory validates at parse time and a ' +
'broken value import fails loudly instead of degrading to `any` — see issue #2035.';
// The dispatcher's service-lookup methods whose result carries the slot's
// contract (#4127). `getObjectQL` is NOT here: it reaches ObjectQL's surface
// beyond IDataEngine (`registry`, `executeAction`), which has no contract, so
// its `any` is correct and permanent until someone writes one.
const SLOT_LOOKUPS = ['resolveService', 'getService', 'getRequestKernelService'].join('|');
// Slots with no written contract. A lookup naming one of these legitimately
// yields `any`, so the rule exempts it BY NAME rather than by an inline
// disable — this repo lints with `--no-inline-config`, which ignores
// eslint-disable comments on purpose: exceptions belong in one reviewable
// place, not sprinkled through the code. Deleting a name from this list is how
// the exemption ends once that contract gets written.
//
// Entries are spliced into a regex, so escape metacharacters if one returns.
//
// [#4251] `http.server` was here, on the stated ground that "no IHttpServer
// contract exists". That was FALSE when it was written: the contract is
// `packages/spec/src/contracts/http-server.ts`, and eight call sites were
// already resolving the slot as `getService<IHttpServer>(…)`. An exemption is a
// claim like any other, and this one rested on a premise nobody checked — the
// same shape as the gaps this rule exists to find. Revoked; the slot is
// accounted for like every other contracted slot.
const UNCONTRACTED_SLOTS = ['protocol', 'mcp', 'kernel-resolver', 'scope-manager'].join('|');
// Exported so `scripts/check-slot-lookup-ratchet.mjs` can identify THIS rule's
// reports among the other `no-restricted-syntax` rules, by exact message —
// the counter and the rule must never be able to disagree about what counts.
export const SLOT_LOOKUP_ANY_MESSAGE =
'Do not erase a service-lookup result to `any` (`: any`, `as any`, or a ' +
'`getService<any>(…)` type argument) — the lookup already returns the slot\'s ' +
'contract (#4168/#4176/#4202), and this switches that checking off ' +
'for the call site while looking identical to code that has it. Every such ' +
'annotation found so far was hiding a real gap, including a project-membership ' +
'gate that silently stopped gating and two datasource-registration branches ' +
'probing a method no metadata service has (#4251). Pass the slot\'s contract ' +
'type instead (`getService<IDataEngine>(\'data\')`). If the slot genuinely has ' +
'no contract, add its name to UNCONTRACTED_SLOTS in eslint.config.mjs with a ' +
'note, so the exemption is reviewed once and visible in one place — see ' +
'issues #4127 and #4251.';
// [#4251] The sweep ratchet, read from `scripts/slot-lookup-baseline.json`.
//
// Those files hold pre-existing lookup-erasure sites — `getService<any>(…)`,
// `: any`, or `as any` — that predate the rule reaching them: the rule's scope
// was packages/runtime only until #4251 widened it, and the type-argument
// selector did not exist. 171 sites in 40 files at the widening; they are
// grandfathered BY FILE for the same reason UNCONTRACTED_SLOTS is central —
// `--no-inline-config` means the escape must live in config, and one shrinking
// list is the ratchet made visible. Batches remove entries as they sweep (see
// #4214 for the batch pattern and its yield — these sites are where the erased
// contracts live).
//
// The baseline is the SINGLE SOURCE: its keys are these ignores and its values
// are the per-file counts `pnpm check:slot-lookup` enforces. That coupling is
// the point (#4320 was found the same way — a promise nothing checked). A bare
// file list made three moves invisible: adding a file to silence lint, adding
// NEW violations to an already-listed file (they rode the entry silently), and
// clearing a file without dropping its entry (the list stops meaning anything).
// The counted baseline fails all three, and `--update` is the only way to move
// it — downward.
const SLOT_LOOKUP_UNSWEPT = Object.keys(JSON.parse(
readFileSync(new URL('./scripts/slot-lookup-baseline.json', import.meta.url), 'utf8'),
));
// [#4251] The FOURTH erasure shape: the declaration and the lookup split apart.
//
// let ql: any;
// try { ql = ctx.getService('objectql'); } catch { /* optional */ }
//
// The contract is erased exactly as in `const ql: any = ctx.getService(…)`, and
// all three selectors below miss it: selector 1 needs the call inside the
// declarator (here the declarator has no init), selector 2 needs `as`, selector
// 3 needs a type argument. 23 sites repo-wide used it, 12 of them in files no
// longer grandfathered — i.e. lint covered them and said nothing. Worse, that
// number GREW with every batch: sweeping a file removes it from the baseline,
// and the baseline's monotonicity check means it can never be re-added, so each
// batch converted more of this shape from "grandfathered" into "silently clean".
// A ratchet that looks cleaner the more you use it is the #4342 failure again.
//
// This is a RULE and not a fourth selector because esquery cannot do it. A
// selector can match `AssignmentExpression:has(CallExpression[…])`, but it
// cannot tell which declaration the assigned identifier resolves to — so it
// would equally flag the correctly-typed form this whole work line is trying to
// produce (`let i18nService: II18nService | undefined; i18nService = …`, 8 such
// sites today, in runtime/app-plugin.ts and service-automation among others).
// Resolving the identifier to its declaration needs SCOPE analysis, which is
// cheap and needs no type information — so this stays out of the typed-lint
// pass that the KNOWN RESIDUAL below still waits on.
const slotLookupPlugin = {
rules: {
'no-any-assignment': {
meta: {
type: 'problem',
docs: { description: 'Ban assigning a service-lookup result to an `any`-declared variable.' },
schema: [],
messages: { erased: SLOT_LOOKUP_ANY_MESSAGE },
},
create(context) {
const lookupNames = new Set(SLOT_LOOKUPS.split('|'));
const uncontracted = new RegExp(`^(${UNCONTRACTED_SLOTS})$`);
/** The slot-lookup call inside `node`, or null. Mirrors the selectors' `:has`. */
const findLookupCall = (node) => {
let found = null;
const walk = (n) => {
if (found || !n || typeof n.type !== 'string') return;
if (
n.type === 'CallExpression' &&
n.callee?.type === 'MemberExpression' &&
lookupNames.has(n.callee.property?.name)
) {
// Same exemption channel as the selectors: the slot name is read
// off a literal argument, so an UNCONTRACTED_SLOTS lookup is
// legitimately `any` and must not be reported.
const exempt = n.arguments.some(
(a) => a?.type === 'Literal' && uncontracted.test(String(a.value)),
);
if (!exempt) { found = n; return; }
}
for (const key of Object.keys(n)) {
if (key === 'parent') continue;
const child = n[key];
if (Array.isArray(child)) child.forEach(walk);
else if (child && typeof child.type === 'string') walk(child);
}
};
walk(node);
return found;
};
/** True when `name` resolves, in scope, to a variable declared `: any`. */
const declaredAny = (name, node) => {
let scope = context.sourceCode.getScope(node);
for (; scope; scope = scope.upper) {
const variable = scope.variables.find((v) => v.name === name);
if (!variable) continue;
return variable.defs.some(
(d) => d.node?.id?.typeAnnotation?.typeAnnotation?.type === 'TSAnyKeyword',
);
}
return false;
};
return {
AssignmentExpression(node) {
if (node.left.type !== 'Identifier') return;
if (!findLookupCall(node.right)) return;
if (!declaredAny(node.left.name, node)) return;
context.report({ node, messageId: 'erased' });
},
};
},
},
},
};
// ---------------------------------------------------------------------------
// [#4918] Engine query-options `any`-erasure guard.
//
// The same failure as the slot-lookup rule above, one layer further in: the
// contract exists, `tsc` is willing to enforce it, and one annotation switches
// that off for the call site while looking identical to code that has it.
//
// `IDataEngine.find/findOne/count/aggregate` declare their options as
// `EngineQueryOptions` / `EngineCountOptions` / `EngineAggregateOptions`
// (`packages/spec/src/contracts/data-engine.ts`), and `IDataDriver` declares the
// same slots as `QueryAST` + `DriverOptions`. For an INTERNAL caller `tsc` is
// the ONLY enforced channel on that path: the protocol's ingress normalizer does
// not run on calls the protocol itself makes to `this.engine.find`, and the
// options schemas are not `.strict()`, so an unknown key is silently DROPPED
// rather than rejected. Erase the type and a wrong key becomes a no-op that
// nothing anywhere reports.
//
// #4674 is the bill: two internal queries spelled their sort
// `{ field, direction: 'desc' }` — `IReportService`'s vocabulary — where the
// QueryAST shape is `SortNodeSchema` = `{ field, order }`. Both drivers
// normalize off `.order` with no fallback, so both queries ran ASCENDING, and
// because both carried a `limit` the wrong direction changed WHICH ROWS came
// back: metadata audit history returned the oldest events (never an object's
// recent changes) and global search returned the stalest matches. `#4720`
// restored those two sites, `#4721` closed the external (REST/RPC) callers with
// a strict schema plus an ingress normalizer, and this rule is the third leg —
// it stops the erasure regrowing on the internal side.
const ENGINE_QUERY_READ_METHODS = ['find', 'findOne', 'count', 'aggregate'];
// Exported so `scripts/check-query-options-erasure-ratchet.mjs` measures the
// SAME surface this rule blocks. The ratchet lifts these to count the test-side
// residual; the rule itself never runs on them.
//
// The first cut is deliberately non-test only (the 08-03 triage on #4918). Test
// code holds the large majority of the erasures, and an unknown share of those
// are legitimate: a test whose SUBJECT is off-contract engine input (see
// `engine-unknown-option.test.ts`, `engine-wire-alias-reject.test.ts`) has to
// erase the type to construct input `tsc` would otherwise refuse. A blocking
// rule there would fight the tests that prove the contract is enforced, so the
// test surface is held by a COUNT instead — see the ratchet.
export const QUERY_OPTIONS_TEST_GLOBS = [
'**/*.test.{ts,tsx,mts,cts}',
'**/*.spec.{ts,tsx,mts,cts}',
];
// The rule's own id, exported so the ratchet identifies this rule's reports
// exactly rather than by message text. (The slot-lookup ratchet matches on
// message because that rule shares `no-restricted-syntax` with three others;
// this one is a dedicated rule, so the id is available and is stricter.)
export const QUERY_OPTIONS_RULE_ID = 'query-options/no-any-erasure';
export const QUERY_OPTIONS_ANY_MESSAGE =
'Do not erase an engine query-options value to `any` — not as `find(obj, { … } ' +
'as any)`, not as an `orderBy: … as any`, and not as a `const opts: any` that is ' +
'then passed as the options argument. `EngineQueryOptions` (and `QueryAST` on the ' +
'driver side) already declare every key these methods read, and for an internal ' +
'caller `tsc` is the ONLY channel that enforces them: the protocol\'s ingress ' +
'normalizer does not run on a direct engine call, and the options schemas are not ' +
'`.strict()`, so an unknown key is silently DROPPED, never rejected. That is #4674 ' +
'— two queries sorted by `direction` (IReportService\'s vocabulary) instead of ' +
'`order` (SortNodeSchema\'s), both with a `limit`, so both quietly returned the ' +
'OLDEST rows: audit history that never showed an object\'s recent changes, and a ' +
'global search that truncated away the freshly-edited records. The declared type ' +
'would have rejected `direction` at the call site; the erasure is the only reason ' +
'it compiled. Type the value instead (`const opts: EngineQueryOptions = { … }`, or ' +
'just drop the assertion — these signatures already infer). If the value is ' +
'DELIBERATELY off-contract — a test asserting the engine REJECTS an unknown option ' +
'— write `as unknown as EngineQueryOptions`: that names the contract being ' +
'bypassed, keeps the rest of the call type-checked, and greps as an intentional ' +
'act, none of which a bare `as any` does. See issues #4674, #4720, #4721, #4918.';
// [#4918] The unswept residual, grandfathered BY FILE from
// `scripts/query-options-erasure-baseline.json` — same mechanism, and same
// reasoning, as SLOT_LOOKUP_UNSWEPT above: `pnpm lint` runs with
// `--no-inline-config`, so the escape has to live in config, and one shrinking
// counted list is the ratchet made visible. An `ignores` entry silences the
// WHOLE file, which is exactly why the baseline carries per-file COUNTS and
// `pnpm check:query-options-erasure` enforces them.
//
// ⛔ Do NOT sweep these sites in the same PR that touches this rule. Part of the
// residual is a real type boundary (`hookContext.input.options`, the metadata
// loader's `Record<string, unknown>` query bag) and needs the boundary type
// written, not the assertion deleted — a separate batch.
const QUERY_OPTIONS_UNSWEPT = Object.keys(JSON.parse(
readFileSync(new URL('./scripts/query-options-erasure-baseline.json', import.meta.url), 'utf8'),
).nonTest);
const queryOptionsPlugin = {
rules: {
'no-any-erasure': {
meta: {
type: 'problem',
docs: { description: 'Ban erasing an engine query-options value to `any`.' },
schema: [],
messages: { erased: QUERY_OPTIONS_ANY_MESSAGE },
},
create(context) {
const methods = new Set(ENGINE_QUERY_READ_METHODS);
/**
* True when `node` is, or wraps, an `any` assertion.
*
* Walks the whole assertion chain rather than testing the outermost
* node, so `{ … } as any as EngineQueryOptions` is caught too: that
* spelling checks the literal against nothing and then re-labels the
* result with the contract, which erases the keys exactly as `as any`
* does while reading as if it were typed. `as unknown as X` is NOT
* matched, on purpose — see the message.
*/
const erasesToAny = (node) => {
for (let cur = node; cur; cur = cur.expression) {
if (cur.type === 'TSAsExpression' || cur.type === 'TSTypeAssertion') {
if (cur.typeAnnotation?.type === 'TSAnyKeyword') return true;
continue;
}
if (cur.type === 'TSNonNullExpression') continue;
return false;
}
return false;
};
/**
* True when `name` resolves, in scope, to a local VARIABLE declared
* `: any` — the split form (`const opts: any = { … }` … `find(o, opts)`)
* that #4674's global-search site actually used.
*
* Scope analysis, not a name heuristic: a rule keyed on the identifier's
* spelling would flag every `const options: any` in the repo whether or
* not it ever reaches a query, and miss the ones spelled anything else.
* Deliberately restricted to variable declarations — an `: any`
* PARAMETER forwarded into a query is a different (and much larger,
* mostly test-double) population, out of this cut's scope.
*/
const declaredAnyVariable = (name, node) => {
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
const variable = scope.variables.find((v) => v.name === name);
if (!variable) continue;
return variable.defs.some(
(d) =>
d.node?.type === 'VariableDeclarator' &&
d.node.id?.typeAnnotation?.typeAnnotation?.type === 'TSAnyKeyword',
);
}
return false;
};
return {
CallExpression(node) {
if (node.callee?.type !== 'MemberExpression') return;
const property = node.callee.property;
if (property?.type !== 'Identifier' || !methods.has(property.name)) return;
node.arguments.forEach((argument, index) => {
// Argument 0 is the object/table NAME on every one of these
// signatures; the options bags are 1 (the query) and 2
// (`BaseEngineOptions` / `DriverOptions`). Starting at 1 is also
// what keeps `Array.prototype.find(cb)` — same method name,
// callback at index 0 — out of the rule entirely.
if (index < 1 || !argument) return;
if (erasesToAny(argument)) {
context.report({ node: argument, messageId: 'erased' });
return;
}
if (argument.type === 'Identifier' && declaredAnyVariable(argument.name, node)) {
context.report({ node: argument, messageId: 'erased' });
}
});
},
// `orderBy` is scoped in by name because it is the key #4674 was
// actually wrong about, and it is erased one level below the argument
// — `...(ast.orderBy ? { orderBy: ast.orderBy as any } : {})` sits
// inside an otherwise-typed options literal, so the argument-position
// check above cannot see it. `SortNodeSchema` is the shape everywhere
// this key appears.
Property(node) {
if (node.computed) return;
const key = node.key;
const isOrderBy =
(key?.type === 'Identifier' && key.name === 'orderBy') ||
(key?.type === 'Literal' && key.value === 'orderBy');
if (!isOrderBy) return;
if (erasesToAny(node.value)) {
context.report({ node: node.value, messageId: 'erased' });
}
},
};
},
},
},
};
// ---------------------------------------------------------------------------
// [#6399] `@objectstack/verify` structural stand-in erasure guard.
//
// The third member of the family above, and the narrowest. `checkReadCoercion`
// and `checkDateBucketParity` take their driver STRUCTURALLY — `CoercibleDriver`
// / `BucketableDriver` — so an out-of-tree driver (cloud's `driver-turso` in
// remote mode) can run the identical contract without importing a concrete
// driver type. That parameter type is not decoration around the check: for the
// compile-time half of the conformance it IS the check. Assert the argument and
// the stand-in stops standing for anything, at that call site, while the code
// reads exactly like the checked kind.
//
// #6354/PR #6396 is the bill: TEN `as never` casts — every call site of both
// helpers — had switched that half off, long enough that nobody remembered
// writing them. They were provably dead (removing all ten left three packages'
// typecheck at exit=0) and the compile-time check they had been hiding is
// provably live (adding a member no real driver can have turned all ten sites
// red, 8+2 matching the cast count exactly). Nothing rang for either fact.
//
// ⚠️ The cost is highest on the FAKE-driver side. Six of the ten sites pass a
// hand-written literal; four pass a real driver. A real driver comes from
// production code and mostly satisfies the stand-in whether or not anyone
// checks — a hand-written fake is precisely the thing that drifts, and it is
// the arm an assertion silences most cheaply.
//
// Scope is argument 0 — the driver — and nothing else. The options bag is a
// different type with its own `unknown` slots, and an assertion there is
// #6394's subject, not this rule's.
//
// WHY A DEDICATED RULE, not a widened `check:query-options-erasure`: measured,
// that ratchet cannot reach these sites at all. `query-options/no-any-erasure`
// keys on a MEMBER-expression callee named `find|findOne|count|aggregate` and
// only inspects arguments at index >= 1; every site here is a bare-identifier
// callee with the driver at index 0. Teaching it the word `never` would have
// matched zero of the ten while pulling several hundred unrelated `as never`
// sites into its baseline and blurring what "query/options type erasure" means.
//
// WHY NOT a blanket ban on `as never` at call arguments in tests: 550 of the
// repo's 703 `as never` assertions sit at a call-argument position, 536 of them
// in test files across 33 packages, and the large majority are legitimate —
// a negative test constructing input `tsc` is supposed to refuse. That is the
// same trade-off QUERY_OPTIONS_TEST_GLOBS already resolved the same way: a
// blocking rule there fights the tests that prove the contract is enforced.
//
// The guarded set is reconciled against `packages/verify/src` in BOTH
// directions by `pnpm check:verify-stand-in`, so a third stand-in check cannot
// arrive unguarded and a renamed helper cannot leave this rule silently
// matching nothing. A guard whose covered set is a hand-list nobody re-checks
// is the dead-pin shape (#4984 / #5018), and this one is not allowed to become
// it.
export const VERIFY_STAND_IN_CHECKS = {
checkReadCoercion: 'CoercibleDriver',
checkDateBucketParity: 'BucketableDriver',
};
// The rule's own id, exported so `check:verify-stand-in` identifies this rule's
// reports exactly rather than by message text — same reasoning as
// QUERY_OPTIONS_RULE_ID.
export const VERIFY_STAND_IN_RULE_ID = 'verify-stand-in/no-asserted-driver-argument';
export const VERIFY_STAND_IN_MESSAGE =
'Do not type-assert the driver argument of a @objectstack/verify conformance check. ' +
'`checkReadCoercion(driver)` / `checkDateBucketParity(driver)` declare that parameter as a ' +
'structural stand-in (`CoercibleDriver` / `BucketableDriver`) so any driver — including an ' +
'out-of-tree one — can run the identical contract; that declaration is the compile-time half ' +
'of the conformance, and an assertion on the argument deletes it for this call site while ' +
'looking identical to a call that has it. Ten such casts (`as never`, every call site of both ' +
'helpers) lived in this repo long enough that nobody remembered writing them — all ten dead, ' +
'and the check underneath them alive (#6354 / PR #6396). Six of the ten passed a HAND-WRITTEN ' +
'fake driver, which is the arm that actually drifts. Pass the driver unasserted. If it does ' +
'not satisfy the stand-in, that is the finding — fix the driver or widen the stand-in ' +
'deliberately, in `packages/verify/src`, where the change is reviewed once instead of ' +
'silenced per call site. See issues #6354, #6394 and #6399.';
const verifyStandInPlugin = {
rules: {
'no-asserted-driver-argument': {
meta: {
type: 'problem',
docs: {
description:
'Ban type-asserting the driver argument of a @objectstack/verify structural conformance check.',
},
schema: [],
messages: { erased: VERIFY_STAND_IN_MESSAGE },
},
create(context) {
const guarded = new Set(Object.keys(VERIFY_STAND_IN_CHECKS));
/**
* True when `node` is, or wraps, ANY type assertion.
*
* Deliberately wider than `erasesToAny` above: on this argument there is
* no assertion worth allowing. `as unknown as BucketableDriver` is the
* sanctioned escape for engine query OPTIONS because a test may need
* off-contract input on purpose; here the parameter type is the contract
* UNDER TEST, so re-labelling the argument with it asserts exactly the
* thing the call was supposed to prove. Every one of the ten historical
* casts would be re-admitted by an `any`-only test.
*/
const isAsserted = (node) => {
for (let cur = node; cur; cur = cur.expression) {
if (cur.type === 'TSAsExpression' || cur.type === 'TSTypeAssertion') return true;
if (cur.type === 'TSNonNullExpression') continue;
return false;
}
return false;
};
/**
* True when `name` resolves, in scope, to a variable declared `: any` or
* `: never` — the split form (`const d: any = brokenDriver(); check(d)`),
* which erases the stand-in exactly as the inline assertion does and is
* the first shape someone reaches for once the inline one is blocked.
* Scope analysis, not a name heuristic — same mechanism, and the same
* reason, as `slot-lookup/no-any-assignment`.
*/
const declaredErasedVariable = (name, node) => {
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
const variable = scope.variables.find((v) => v.name === name);
if (!variable) continue;
return variable.defs.some((d) => {
const kind = d.node?.id?.typeAnnotation?.typeAnnotation?.type;
return (
d.node?.type === 'VariableDeclarator' &&
(kind === 'TSAnyKeyword' || kind === 'TSNeverKeyword')
);
});
}
return false;
};
return {
CallExpression(node) {
// Bare-identifier callee only. These names are distinctive enough to
// treat as reserved, and matching the name rather than the resolved
// import is what keeps a re-export or a test-local alias from
// quietly leaving the rule behind.
if (node.callee?.type !== 'Identifier' || !guarded.has(node.callee.name)) return;
const driver = node.arguments?.[0];
if (!driver) return;
if (isAsserted(driver)) {
context.report({ node: driver, messageId: 'erased' });
return;
}
if (driver.type === 'Identifier' && declaredErasedVariable(driver.name, node)) {
context.report({ node: driver, messageId: 'erased' });
}
},
};
},
},
},
};
export default [
{
files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'],
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/.next/**',
'**/.turbo/**',
'packages/spec/**',
// CLI/scaffold templates contain `@objectstack/spec` strings that are
// emitted to user projects, not actual imports in this repo.
'packages/cli/src/commands/init.ts',
'packages/cli/src/commands/generate.ts',
'packages/cli/src/commands/create.ts',
'packages/create-objectstack/src/index.ts',
],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
},
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: '@objectstack/spec',
importNames: SUBPATH_NAMES,
message: SUBPATH_RULE_MESSAGE,
}],
}],
},
},
// Machine output must not be written with `console.log`.
//
// `console.log(big)` followed by an exit hands a PIPE reader a payload cut
// off at one 64 KiB buffer: Node writes stdout asynchronously to a pipe and
// the exit tears the process down mid-drain. `os lint … --json` shipped that
// for months at exactly 65536 bytes, and it is invisible to whoever writes
// it — stdout to a TTY is synchronous, so every interactive run looks right
// while every scripted consumer, the only audience `--json` has, gets
// invalid JSON. The exit need not be explicit: oclif ends failing commands
// with `handle()` → `Exit.exit()` → `process.exit()` and flushes nothing on
// that path, so a plain `this.exit(1)` truncates the same way.
//
// `emitJson` / `emitText` (packages/cli/src/utils/format.ts) await the write
// callback first. The whole CLI was swept onto them; this keeps the pattern
// from growing back one command at a time. Note the root lint script runs
// with `--no-inline-config`, so there is no per-site opt-out — which is the
// point: every past instance of this was written by someone who had no
// reason to suspect it.
{
files: ['packages/cli/src/**/*.{ts,tsx,mts,cts}'],
ignores: ['**/node_modules/**', '**/dist/**', '**/*.test.ts'],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
},
rules: {
'no-restricted-syntax': ['error',
{
selector:
"CallExpression[callee.object.name='console'][callee.property.name='log']" +
" > CallExpression[callee.object.name='JSON'][callee.property.name='stringify']",
message:
'Write machine output with `await emitJson(payload)` from utils/format.js, not ' +
'console.log(JSON.stringify(…)). On a pipe, console.log followed by an exit ' +
'(including oclif\'s this.exit / any thrown error) truncates the payload at 64 KiB. ' +
'Pass `{ compact: true }` as the third argument to keep single-line output.',
},
{
// `formatOutput` became async for the same reason — its json and yaml
// branches go through emitText. An un-awaited call at statement
// position silently reopens the hole. (An awaited one nests under an
// AwaitExpression and does not match.)
selector: "ExpressionStatement > CallExpression[callee.name='formatOutput']",
message:
'`formatOutput` is async — await it. Its json/yaml branches drain stdout before ' +
'the command can exit; dropping the await reintroduces the 64 KiB pipe truncation.',
},
],
},
},
// issue #2035 — authoring-entry guard. Flags exported consts in metadata
// files that are annotated with a spec domain type (simple `Page` or qualified
// `UI.Page`) instead of being wrapped in the `defineX` factory. AST-only (no
// type info): matches the declaration shape, not local vars or function params.
// Scoped to the authoring surfaces — the example corpus AI learns from and the
// platform's own apps. NOT downstream-contract: its bare literals are a frozen
// backward-compat fixture (#2089) and are intentional.
{
files: ['examples/**/*.{ts,tsx,mts,cts}', 'packages/apps/**/*.{ts,tsx,mts,cts}'],
ignores: ['**/node_modules/**', '**/dist/**', 'packages/qa/downstream-contract/**'],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
},
rules: {
'no-restricted-syntax': ['error',
{
selector: `ExportNamedDeclaration VariableDeclarator[id.typeAnnotation.typeAnnotation.typeName.name=/^(${DOMAIN_TYPES})$/]`,
message: DOMAIN_RULE_MESSAGE,
},
{
selector: `ExportNamedDeclaration VariableDeclarator[id.typeAnnotation.typeAnnotation.typeName.right.name=/^(${DOMAIN_TYPES})$/]`,
message: DOMAIN_RULE_MESSAGE,
},
],
},
},
// issue #4127 — service-lookup `any` guard. #4168/#4176/#4202 made a slot
// lookup return the slot's contract, so a domain calling a method nobody
// declares is a compile error. An `any` annotation on the RESULT silently
// switches that back off for that call site: nothing fails, no test breaks,
// and the code looks exactly like the checked kind. Three such sites already
// existed and were found by grep, which is the sweep this work replaced —
// #4087 shipped for months because a sweep is not repeatable.
//
// The `any` is not always wrong, so the exemptions are declared above —
// by SLOT NAME, and centrally. That is deliberate: `pnpm lint` runs with
// `--no-inline-config`, so an `eslint-disable` comment would be ignored and
// the escape has to live in config anyway. The effect is the one worth
// having — a deliberate gap is a reviewed line in this file, a careless one
// is a build failure, and the two stop looking identical in the code.
//
// [#4251] Scope is all of packages/ — the rule shipped scoped to
// packages/runtime while the composition roots (rest, plugins/*, services/*)
// held 77 of the 80 known sites, an unlinted majority that looked covered.
// Per-package curation would recreate that gap one package at a time, so the
// scope is total and the not-yet-swept files are grandfathered individually
// in the counted baseline above — a shrinking list under `check:slot-lookup`,
// not a silent boundary.
//
// KNOWN RESIDUAL: a wrapper whose own return type is annotated
// (`const getEngine = async (): Promise<any> => …resolveService(…)`) erases
// the slot type just as effectively, and this selector cannot see it — the
// annotation is on the enclosing function, not on the call. One such site
// existed (share-links `getEngine`, fixed in batch 4). Catching that shape
// needs type information, so it belongs to a typed-lint pass, not here.
{
files: ['packages/**/*.{ts,tsx,mts,cts}'],
ignores: ['**/node_modules/**', '**/dist/**', ...SLOT_LOOKUP_UNSWEPT],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
},
plugins: { 'slot-lookup': slotLookupPlugin },
rules: {
// The split-declaration form (#4251) — see `slotLookupPlugin`. Reports the
// SAME message as the three selectors below, so `check:slot-lookup` counts
// all four shapes without knowing there are four.
'slot-lookup/no-any-assignment': 'error',
'no-restricted-syntax': ['error',
{
// `const svc: any = await deps.resolveService('auth', env)`
selector:
'VariableDeclarator[id.typeAnnotation.typeAnnotation.type="TSAnyKeyword"]' +
`:has(CallExpression[callee.property.name=/^(${SLOT_LOOKUPS})$/]` +
`:not(:has(Literal[value=/^(${UNCONTRACTED_SLOTS})$/])))`,
message: SLOT_LOOKUP_ANY_MESSAGE,
},
{
// `await deps.resolveService('security', env) as any`
selector:
'TSAsExpression[typeAnnotation.type="TSAnyKeyword"]' +
`:has(CallExpression[callee.property.name=/^(${SLOT_LOOKUPS})$/]` +
`:not(:has(Literal[value=/^(${UNCONTRACTED_SLOTS})$/])))`,
message: SLOT_LOOKUP_ANY_MESSAGE,
},
{
// `ctx.getService<any>('data')` — the type-argument form (#4251).
// No annotation, no `as`, and the contract is erased all the same;
// this is the shape 80 sites actually used while the two selectors
// above matched zero of them.
selector:
`CallExpression[callee.property.name=/^(${SLOT_LOOKUPS})$/]` +
'[typeArguments.params.0.type="TSAnyKeyword"]' +
`:not(:has(Literal[value=/^(${UNCONTRACTED_SLOTS})$/]))`,
message: SLOT_LOOKUP_ANY_MESSAGE,
},
],
},
},
// issue #4918 — engine query-options `any`-erasure guard. Rationale and the
// #4674 cost are on `QUERY_OPTIONS_ANY_MESSAGE` above.
//
// This is a dedicated PLUGIN rule and not three more `no-restricted-syntax`
// selectors, for two reasons that both matter:
//
// 1. 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 blocks match —
// silently deleting that rule. The two guards also need independent
// `ignores` (their unswept sets are different files), which one shared
// block cannot give them.
// 2. The split form needs SCOPE analysis to resolve an identifier to its
// declaration, which esquery cannot express — the same reason
// `slot-lookup/no-any-assignment` exists. Scope analysis needs no type
// information, so this still runs in the plain (untyped) lint pass.
//
// KNOWN RESIDUAL, stated rather than implied: an erasure that happens through
// a typed indirection — a helper declared `(o, q?: any) => engine.find(o, q)`,
// or a wrapper whose own return type is `Promise<any>` — erases the contract
// just as effectively and this rule cannot see it (an `: any` PARAMETER
// forwarded into a query is a real shape, ~50 sites, almost all of them test
// doubles; judging it needs the call graph, not one file's scopes). Same
// boundary as the slot-lookup rule's own KNOWN RESIDUAL, and the same answer:
// it belongs to a typed-lint pass, not here.
{
files: ['packages/**/*.{ts,tsx,mts,cts}'],
ignores: [
'**/node_modules/**',
'**/dist/**',
// First cut is non-test code (08-03 triage). The ratchet lifts this and
// holds the test residual to a count instead.
...QUERY_OPTIONS_TEST_GLOBS,
// Pre-existing sites, grandfathered by file and counted — see
// QUERY_OPTIONS_UNSWEPT and `pnpm check:query-options-erasure`.
...QUERY_OPTIONS_UNSWEPT,
],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
},
plugins: { 'query-options': queryOptionsPlugin },
rules: { 'query-options/no-any-erasure': 'error' },
},
// issue #6399 — @objectstack/verify stand-in erasure guard. Rationale and the
// #6354 / PR #6396 measurement are on `VERIFY_STAND_IN_MESSAGE` above.
//
// No `ignores` beyond the build dirs and NO baseline, which is the whole
// reason this is its own rule rather than a widening of one of the two above:
// the tree is clean TODAY (all ten casts removed by PR #6396), so the guard
// starts at zero and every future violation is a new one. Both siblings had
// to grandfather hundreds of pre-existing sites; there is nothing here to
// grandfather, and adding one later would mean the state stopped being locked.
//
// Scope is unrestricted on purpose. The four files holding call sites today
// are `packages/qa/dogfood/test/` and `packages/drivers/driver-turso/src/`,
// but `@objectstack/verify` is a PUBLISHED helper whose whole point is being
// callable from anywhere — a package-scoped rule would go quiet exactly when
// the eleventh call site lands somewhere new, which is the case this issue
// exists to cover.
//
// ⚠️ Test files are IN scope here, unlike the query-options rule. That rule
// lifts them because a test may legitimately need off-contract engine input;
// this argument has no legitimate off-contract form (see the message), and
// six of the ten historical casts were in test files passing hand-written
// fakes — the arm the guard is worth the most on.
{
files: ['**/*.{ts,tsx,mts,cts}'],
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/.turbo/**'],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
},
plugins: { 'verify-stand-in': verifyStandInPlugin },
rules: { 'verify-stand-in/no-asserted-driver-argument': 'error' },
},
];