@tinvar/renderer 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/eslint.js ADDED
@@ -0,0 +1,469 @@
1
+ // The lint rule that keeps state out of server components.
2
+ //
3
+ // tinvar-spec.md, "Renderer hardening": "Per-render isolation. Each render
4
+ // runs in its own AsyncLocalStorage context. The renderer never caches query
5
+ // results across requests. A lint rule rejects mutable module-level state in
6
+ // server files."
7
+ //
8
+ // A server module is evaluated once per renderer process, and every render
9
+ // that process serves -- every user's -- runs against that one instance.
10
+ // What the framework holds for a render lives in the render's context, but a
11
+ // `let` at the top of a page, or a Map a component fills, sits outside it:
12
+ // what one user's render leaves there, the next user's render reads. The
13
+ // render context cannot stop that, so the source is held to it before it
14
+ // ships.
15
+ //
16
+ // A server file is a module of the rsc graph. The graph exists whole only
17
+ // while the bundler has it (vite.js), so the config names the directories
18
+ // whose modules are server components -- `configs.server` covers the app's
19
+ // pages and components -- and the rule leaves out what @vitejs/plugin-rsc
20
+ // leaves out of the graph: a module with a 'use client' directive in its
21
+ // prologue, which the rsc environment replaces with a client reference. A
22
+ // declaration file holds no code and is left out too.
23
+ //
24
+ // Mutable module state is:
25
+ // - a top-level `let` or `var`, reported where it is declared -- exported,
26
+ // with a message of its own, since every importer reads it live;
27
+ // - any other top-level binding -- a const, a function, a class, an import
28
+ // -- reassigned anywhere in the file;
29
+ // - a top-level binding whose value the file mutates anywhere: a property
30
+ // assigned, deleted or incremented; the value handed to Object.assign(),
31
+ // Object.defineProperty() or the like as the target; or a call of a
32
+ // method that changes an array, Map, Set or WeakMap: push, pop, shift,
33
+ // unshift, splice, sort, reverse, fill, copyWithin, set, add, delete,
34
+ // clear. A write through it to a nested value counts the same;
35
+ // - a variable a closure keeps from when the module loaded -- one declared
36
+ // in a function invoked at the top as it is written, or in a top-level
37
+ // block -- that a function made there reassigns or mutates;
38
+ // - a write to a global: `globalThis.x = ...`, `process.env.X = ...`.
39
+ //
40
+ // Building a constant at the top -- `const ROUTES = new Map()` and then
41
+ // `ROUTES.set(...)` -- is reported too, as the spec words it: the rule cannot
42
+ // tell set-up at load from a write a render makes, and a value built whole in
43
+ // its initializer needs no write.
44
+ //
45
+ // Frozen and `as const` values. A constant is only reported when the file
46
+ // writes it, so one it never writes is never reported, frozen or not.
47
+ // Object.freeze() of an object or array literal exempts writes to the frozen
48
+ // value's own properties: in a module, which is strict code, they throw
49
+ // rather than change anything. A write through it to a nested value is still
50
+ // reported, since freezing is shallow, and so is a write to a frozen Map, Set
51
+ // or instance, whose methods freezing does not stop. `as const` exempts
52
+ // nothing: TypeScript already refuses a write to one, so a write this rule
53
+ // sees went past the type through a cast, and it changes the value at
54
+ // runtime all the same.
55
+ //
56
+ // What it cannot see: state behind a function -- a factory called at the top
57
+ // that returns a closure, a memoizer from a dependency, a method that writes
58
+ // `this` -- and a binding reached through an alias (`const a = list;
59
+ // a.push()`). It reads one file's syntax, not what its code does when it
60
+ // runs.
61
+ //
62
+ // The plugin's other rule, for a query called inside a can() branch, is in
63
+ // can-branch.js.
64
+
65
+ import { authorizeQueriesInCanBranches } from './can-branch.js';
66
+
67
+ /** Methods that change the array, Map, Set or WeakMap they are called on. */
68
+ const MUTATORS = new Set([
69
+ 'push',
70
+ 'pop',
71
+ 'shift',
72
+ 'unshift',
73
+ 'splice',
74
+ 'sort',
75
+ 'reverse',
76
+ 'fill',
77
+ 'copyWithin',
78
+ 'set',
79
+ 'add',
80
+ 'delete',
81
+ 'clear',
82
+ ]);
83
+
84
+ /** Those an array has, which a frozen array refuses by throwing. */
85
+ const ARRAY_MUTATORS = new Set([
86
+ 'push',
87
+ 'pop',
88
+ 'shift',
89
+ 'unshift',
90
+ 'splice',
91
+ 'sort',
92
+ 'reverse',
93
+ 'fill',
94
+ 'copyWithin',
95
+ ]);
96
+
97
+ /** Built-ins that write onto the value they are handed first. */
98
+ const TARGET_WRITERS = new Map([
99
+ ['Object', new Set(['assign', 'defineProperty', 'defineProperties', 'setPrototypeOf'])],
100
+ ['Reflect', new Set(['set', 'defineProperty', 'deleteProperty', 'setPrototypeOf'])],
101
+ ]);
102
+
103
+ /** Nodes around an expression that leave its value as it is: TypeScript's assertions, an optional chain. */
104
+ const WRAPPERS = new Set([
105
+ 'ChainExpression',
106
+ 'TSAsExpression',
107
+ 'TSNonNullExpression',
108
+ 'TSSatisfiesExpression',
109
+ 'TSTypeAssertion',
110
+ 'TSInstantiationExpression',
111
+ ]);
112
+
113
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/;
114
+
115
+ /** The modules `configs.server` holds to the rule: the app's pages and components. */
116
+ export const SERVER_FILES = [
117
+ 'resources/pages/**/*.{js,jsx,mjs,ts,tsx,mts}',
118
+ 'resources/components/**/*.{js,jsx,mjs,ts,tsx,mts}',
119
+ ];
120
+
121
+ const WHY =
122
+ 'A server module is loaded once and every render shares it, so what one render leaves there the next one reads, and one user\'s data can reach another user\'s page (tinvar-spec.md, "Per-render isolation").';
123
+
124
+ /** @type {import('eslint').Rule.RuleModule} */
125
+ export const noMutableModuleState = {
126
+ meta: {
127
+ type: 'problem',
128
+ docs: {
129
+ description:
130
+ "Disallow mutable module-level state in server components: every render shares it, so one user's data can reach another's render.",
131
+ },
132
+ schema: [],
133
+ messages: {
134
+ topLevel: `'{{name}}' is a top-level \`{{kind}}\`. ${WHY} Keep a per-render value inside the component, or make this a \`const\` nothing writes; a module that only runs in the browser says so with 'use client'.`,
135
+ exported: `'{{name}}' is an exported top-level \`{{kind}}\`, which every importer reads live. ${WHY} Export a \`const\` nothing writes, or a function that builds the value for each render.`,
136
+ reassigned: `'{{name}}' is module state, and this reassigns it. ${WHY} Keep a per-render value inside the component.`,
137
+ mutated: `'{{name}}' is module state, and this mutates it. ${WHY} Build the value inside the render, or build it whole where it is declared and never write it.`,
138
+ global: `This writes to '{{name}}', a global. One renderer process serves every render, so what this render leaves there the next one reads, and one user's data can reach another user's page (tinvar-spec.md, "Per-render isolation").`,
139
+ },
140
+ },
141
+ create(context) {
142
+ if (DECLARATION_FILE.test(context.filename)) return {};
143
+ const sourceCode = context.sourceCode;
144
+
145
+ return {
146
+ // At the end, when every node has its parent and the scopes are whole.
147
+ 'Program:exit'(program) {
148
+ if (isClientModule(program)) return;
149
+ check(context, sourceCode, program);
150
+ },
151
+ };
152
+ },
153
+ };
154
+
155
+ /**
156
+ * Whether @vitejs/plugin-rsc takes the module for a client reference: a
157
+ * 'use client' directive anywhere in its prologue, as its hasDirective()
158
+ * reads it. A string later in the file is no directive, to it or here.
159
+ */
160
+ function isClientModule(program) {
161
+ return program.body.some(
162
+ (/** @type {any} */ statement) =>
163
+ statement.type === 'ExpressionStatement' && statement.directive === 'use client',
164
+ );
165
+ }
166
+
167
+ /**
168
+ * @param {import('eslint').Rule.RuleContext} context
169
+ * @param {import('eslint').SourceCode} sourceCode
170
+ * @param {any} program
171
+ */
172
+ function check(context, sourceCode, program) {
173
+ const globalScope = /** @type {any} */ (sourceCode.scopeManager).globalScope;
174
+ // The module's own scope: a module, or the function CommonJS wraps it in.
175
+ // A script has none, and its top-level bindings are the global scope's.
176
+ const top =
177
+ globalScope.childScopes.find(
178
+ (/** @type {any} */ scope) =>
179
+ scope.block === program && (scope.type === 'module' || scope.type === 'function'),
180
+ ) ?? globalScope;
181
+
182
+ const isGlobal = (/** @type {any} */ identifier) => {
183
+ for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
184
+ const variable = /** @type {any} */ (scope.set.get(identifier.name));
185
+ if (variable !== undefined && variable.isValueVariable !== false) return variable.defs.length === 0;
186
+ }
187
+ return true;
188
+ };
189
+
190
+ /** Reports a write through one reference to a binding that outlives the render. */
191
+ const reportWrite = (/** @type {any} */ reference, /** @type {string} */ name, frozen) => {
192
+ if (reference.isValueReference === false) return;
193
+ if (reference.isWrite()) {
194
+ if (reference.init) return;
195
+ context.report({ node: reference.identifier, messageId: 'reassigned', data: { name } });
196
+ return;
197
+ }
198
+ const mutation = mutationOf(reference.identifier, isGlobal);
199
+ if (mutation === null) return;
200
+ // A frozen literal's own properties throw when written, so nothing changes.
201
+ if (
202
+ frozen !== null &&
203
+ mutation.level === 0 &&
204
+ (mutation.method === null || (frozen === 'array' && ARRAY_MUTATORS.has(mutation.method)))
205
+ ) {
206
+ return;
207
+ }
208
+ context.report({ node: mutation.node, messageId: 'mutated', data: { name } });
209
+ };
210
+
211
+ // The module's top level: every binding, every write, wherever it is made.
212
+ for (const variable of top.variables) {
213
+ if (variable.isValueVariable === false || variable.defs.length === 0) continue;
214
+
215
+ const declared = variable.defs.find(
216
+ (/** @type {any} */ def) =>
217
+ def.type === 'Variable' &&
218
+ (def.parent.kind === 'let' || def.parent.kind === 'var') &&
219
+ !def.parent.declare,
220
+ );
221
+ if (declared !== undefined) {
222
+ // One report, where it is declared: its writes are what the `let` is for.
223
+ context.report({
224
+ node: declared.name,
225
+ messageId: isExported(variable, declared) ? 'exported' : 'topLevel',
226
+ data: { name: variable.name, kind: declared.parent.kind },
227
+ });
228
+ continue;
229
+ }
230
+
231
+ const frozen = frozenLiteral(variable, isGlobal);
232
+ for (const reference of variable.references) reportWrite(reference, variable.name, frozen);
233
+ }
234
+
235
+ // What a closure made at load keeps: written by a function made there, it
236
+ // lives, and changes, as long as the module.
237
+ for (const scope of /** @type {any} */ (sourceCode.scopeManager).scopes) {
238
+ if (scope === top || scope === globalScope || !runsAtLoad(scope, top)) continue;
239
+ for (const variable of /** @type {any} */ (scope).variables) {
240
+ if (variable.isValueVariable === false || variable.defs.length === 0) continue;
241
+ const frozen = frozenLiteral(variable, isGlobal);
242
+ for (const reference of variable.references) {
243
+ if (fromClosure(reference.from, scope)) reportWrite(reference, variable.name, frozen);
244
+ }
245
+ }
246
+ }
247
+
248
+ // Globals: what the parser could not resolve, and the built-ins and
249
+ // configured globals, which are declared nowhere in the file.
250
+ const globals = [
251
+ ...globalScope.through,
252
+ ...globalScope.variables
253
+ .filter((/** @type {any} */ variable) => variable.defs.length === 0)
254
+ .flatMap((/** @type {any} */ variable) => variable.references),
255
+ ];
256
+ for (const reference of globals) {
257
+ if (reference.isValueReference === false) continue;
258
+ const mutation = reference.isWrite()
259
+ ? { node: reference.identifier }
260
+ : mutationOf(reference.identifier, isGlobal);
261
+ if (mutation !== null) {
262
+ context.report({ node: mutation.node, messageId: 'global', data: { name: reference.identifier.name } });
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Whether a reference changes the value its binding holds, rather than the
269
+ * binding: a write to a property of it, or of anything reached through it,
270
+ * a mutating method called on either, or either handed to a built-in that
271
+ * writes onto it.
272
+ *
273
+ * `level` counts the properties between the binding and the value changed:
274
+ * 0 for `list.push(x)` and `config.mode = x`, 1 for `state.items.push(x)`.
275
+ * A frozen literal's exemption holds at 0 alone.
276
+ *
277
+ * @param {any} identifier
278
+ * @param {(identifier: any) => boolean} isGlobal
279
+ * @returns {{ node: any, level: number, method: string | null } | null}
280
+ */
281
+ function mutationOf(identifier, isGlobal) {
282
+ let node = identifier;
283
+ let member = null;
284
+ let depth = 0;
285
+ for (let parent = node.parent; parent; parent = node.parent) {
286
+ if (WRAPPERS.has(parent.type) && parent.expression === node) {
287
+ node = parent;
288
+ } else if (parent.type === 'MemberExpression' && parent.object === node) {
289
+ node = parent;
290
+ member = parent;
291
+ depth += 1;
292
+ } else {
293
+ break;
294
+ }
295
+ }
296
+
297
+ const parent = node.parent;
298
+ if (member !== null) {
299
+ if (isWriteTarget(node, parent)) return { node, level: depth - 1, method: null };
300
+ if (parent?.type === 'CallExpression' && parent.callee === node && !writesTarget(parent, isGlobal)) {
301
+ const method = propertyName(member);
302
+ if (method !== null && MUTATORS.has(method)) return { node: parent, level: depth - 1, method };
303
+ }
304
+ }
305
+ if (parent?.type === 'CallExpression' && parent.arguments[0] === node && writesTarget(parent, isGlobal)) {
306
+ return { node: parent, level: depth, method: null };
307
+ }
308
+ return null;
309
+ }
310
+
311
+ /** Whether `node`, a member expression, is what its parent assigns, deletes, increments or destructures into. */
312
+ function isWriteTarget(node, parent) {
313
+ switch (parent?.type) {
314
+ case 'AssignmentExpression':
315
+ case 'AssignmentPattern':
316
+ case 'ForInStatement':
317
+ case 'ForOfStatement':
318
+ return parent.left === node;
319
+ case 'UpdateExpression':
320
+ case 'RestElement':
321
+ return parent.argument === node;
322
+ case 'UnaryExpression':
323
+ return parent.operator === 'delete' && parent.argument === node;
324
+ case 'ArrayPattern':
325
+ return parent.elements.includes(node);
326
+ case 'Property':
327
+ return parent.value === node && parent.parent?.type === 'ObjectPattern';
328
+ default:
329
+ return false;
330
+ }
331
+ }
332
+
333
+ /** `Object.assign(target, ...)` and its like, with `Object` or `Reflect` the built-in, not a binding of the file's. */
334
+ function writesTarget(call, isGlobal) {
335
+ const callee = unwrap(call.callee);
336
+ if (callee?.type !== 'MemberExpression' || callee.object.type !== 'Identifier') return false;
337
+ const methods = TARGET_WRITERS.get(callee.object.name);
338
+ const method = propertyName(callee);
339
+ return methods !== undefined && method !== null && methods.has(method) && isGlobal(callee.object);
340
+ }
341
+
342
+ /** A member's property as written, or null when it is computed from something other than a literal. */
343
+ function propertyName(member) {
344
+ const property = member.property;
345
+ if (!member.computed) return property.type === 'Identifier' ? property.name : null;
346
+ if (property.type === 'Literal' && typeof property.value === 'string') return property.value;
347
+ if (property.type === 'TemplateLiteral' && property.expressions.length === 0) {
348
+ return property.quasis[0]?.value.cooked ?? null;
349
+ }
350
+ return null;
351
+ }
352
+
353
+ function unwrap(node) {
354
+ while (node && WRAPPERS.has(node.type)) node = node.expression;
355
+ return node;
356
+ }
357
+
358
+ /**
359
+ * 'object' or 'array' for a const bound to Object.freeze() of that literal,
360
+ * through any TypeScript assertion around either; null for anything else,
361
+ * a frozen Map or instance among them.
362
+ */
363
+ function frozenLiteral(variable, isGlobal) {
364
+ if (variable.defs.length !== 1) return null;
365
+ const def = variable.defs[0];
366
+ if (def.type !== 'Variable' || def.parent.kind !== 'const' || def.node.id.type !== 'Identifier')
367
+ return null;
368
+
369
+ const init = unwrap(def.node.init);
370
+ if (init?.type !== 'CallExpression') return null;
371
+ const callee = unwrap(init.callee);
372
+ if (
373
+ callee?.type !== 'MemberExpression' ||
374
+ callee.object.type !== 'Identifier' ||
375
+ callee.object.name !== 'Object' ||
376
+ propertyName(callee) !== 'freeze' ||
377
+ !isGlobal(callee.object)
378
+ ) {
379
+ return null;
380
+ }
381
+
382
+ const value = unwrap(init.arguments[0]);
383
+ if (value?.type === 'ObjectExpression') return 'object';
384
+ if (value?.type === 'ArrayExpression') return 'array';
385
+ return null;
386
+ }
387
+
388
+ /** Whether a top-level `let` or `var` is exported: in its declaration, or by an export list naming it. */
389
+ function isExported(variable, def) {
390
+ if (def.parent.parent?.type === 'ExportNamedDeclaration') return true;
391
+ return variable.references.some(
392
+ (/** @type {any} */ reference) =>
393
+ reference.identifier.parent?.type === 'ExportSpecifier' &&
394
+ reference.identifier.parent.local === reference.identifier,
395
+ );
396
+ }
397
+
398
+ /**
399
+ * Whether a scope's code runs once, as the module loads: the module's own,
400
+ * and a block, a class body, a static field or block, or a function invoked
401
+ * where it is written, inside it. Any other function runs when called -- a
402
+ * render's own variables are its own.
403
+ */
404
+ function runsAtLoad(scope, top) {
405
+ for (let current = scope; current !== top; current = current.upper) {
406
+ if (current === null || current.type === 'global') return false;
407
+ if (current.type === 'function' && !isInvokedInPlace(current.block)) return false;
408
+ if (current.type === 'class-field-initializer' && current.block.parent?.static !== true) return false;
409
+ }
410
+ return true;
411
+ }
412
+
413
+ /** Whether a reference is made from inside a function nested in `scope`, which may run long after it. */
414
+ function fromClosure(from, scope) {
415
+ for (let current = from; current !== null && current !== scope; current = current.upper) {
416
+ if (current.type === 'function' && !isInvokedInPlace(current.block)) return true;
417
+ if (current.type === 'class-field-initializer' && current.block.parent?.static !== true) return true;
418
+ }
419
+ return false;
420
+ }
421
+
422
+ /** `(() => { ... })()`, `(function () { ... }).call(this)`: a function run once, where it is written. */
423
+ function isInvokedInPlace(fn) {
424
+ if (fn.type !== 'FunctionExpression' && fn.type !== 'ArrowFunctionExpression') return false;
425
+ let node = fn;
426
+ while (node.parent && WRAPPERS.has(node.parent.type)) node = node.parent;
427
+ const parent = node.parent;
428
+ if ((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') && parent.callee === node) {
429
+ return true;
430
+ }
431
+ return (
432
+ parent?.type === 'MemberExpression' &&
433
+ parent.object === node &&
434
+ (propertyName(parent) === 'call' || propertyName(parent) === 'apply') &&
435
+ parent.parent?.type === 'CallExpression' &&
436
+ parent.parent.callee === parent
437
+ );
438
+ }
439
+
440
+ /**
441
+ * The plugin: `import tinvar from '@tinvar/renderer/eslint'`, then
442
+ * `tinvar.configs.server` in a flat config, or a rule by name --
443
+ * `'@tinvar/no-mutable-module-state'`,
444
+ * `'@tinvar/authorize-queries-in-can-branches'` -- in a block of the app's
445
+ * own for another layout. The second reads what `tinvar codegen` wrote
446
+ * (QUERY_MAP) from where ESLint runs, or from its `queryMap` option.
447
+ */
448
+ const plugin = {
449
+ meta: { name: '@tinvar/renderer/eslint', namespace: '@tinvar' },
450
+ rules: {
451
+ 'no-mutable-module-state': noMutableModuleState,
452
+ 'authorize-queries-in-can-branches': authorizeQueriesInCanBranches,
453
+ },
454
+ configs: /** @type {Record<string, object>} */ ({}),
455
+ };
456
+
457
+ plugin.configs.server = {
458
+ name: '@tinvar/server',
459
+ files: SERVER_FILES,
460
+ plugins: { '@tinvar': plugin },
461
+ rules: {
462
+ '@tinvar/no-mutable-module-state': 'error',
463
+ '@tinvar/authorize-queries-in-can-branches': 'error',
464
+ },
465
+ };
466
+
467
+ export { authorizeQueriesInCanBranches };
468
+ export const { rules, configs } = plugin;
469
+ export default plugin;