@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.
@@ -0,0 +1,1666 @@
1
+ // The lint rule that holds a query called inside a can() branch to an
2
+ // #[Authorize] of its own.
3
+ //
4
+ // tinvar-spec.md, "Defaults and build rules": "Server-component `can()`
5
+ // checks control what is displayed, not access. A lint flags queries
6
+ // rendered inside a `can()` branch that lack an equivalent `#[Authorize]`."
7
+ //
8
+ // A check in a server component decides what this render draws. It decides
9
+ // nothing about who may call the query the branch renders with: the query is
10
+ // answered over /_query to any caller whose route lists it, from a fragment
11
+ // rendered under the route's allowlist, and from any other page that calls
12
+ // it -- and in none of those does the branch run. So a query written inside
13
+ // `{order.can.cancel && ...}` reads as guarded, and is not. What guards it is
14
+ // the query's own #[Authorize], which PHP runs before the cache lookup on
15
+ // every path; this rule asks that it check the same ability.
16
+ //
17
+ // A can() check is either of what a server component holds one by:
18
+ // - a call of a function or method named `can` -- `can('cancel', order)`,
19
+ // `gate.can('cancel')` -- whose ability is its first argument, written out;
20
+ // - a read of a `can` map, the serializable form tinvar-spec.md names
21
+ // ("Authorization"): `order.can.cancel`, `can['cancel']`, whose ability is
22
+ // the key read.
23
+ // Or either of those read into a variable first -- `const { cancel } =
24
+ // order.can`, `const allowed = can('cancel', order)`, `const perms =
25
+ // order.can` -- or returned by a function the test calls, in this module or
26
+ // one it imports: the check is followed to where it is written.
27
+ //
28
+ // A branch is what runs only on the check's say: an `if`'s branches, a
29
+ // conditional's, what follows `&&`, `||` or `??`, and the statements after an
30
+ // `if` on a check that returns or throws -- `if (!can.view) return null;`
31
+ // guards the rest of the block. Functions are not a boundary: a callback
32
+ // written inside the branch is rendered from it.
33
+ //
34
+ // A query is a call of query() from @tinvar/react/server (or the root, which
35
+ // re-exports it), or of a function imported from a @queries/* module -- the
36
+ // calls the build puts on a route's allowlist (vite.js), named the way it
37
+ // names them, from what `tinvar codegen` wrote (QUERY_MAP). The query's
38
+ // abilities come from the same file. The guard is satisfied when the query
39
+ // declares an #[Authorize] for one of the abilities its check reads; the
40
+ // subject is not compared, since the check and the attribute name it in two
41
+ // languages.
42
+ //
43
+ // What the branch renders or calls is followed, across files: a component or
44
+ // function it names -- `<OrderShipments />`, `renderRow(row)`, one handed on
45
+ // as a prop -- is read where it is defined, in this module or the module it
46
+ // is imported from, and so is everything that one names in turn; a
47
+ // `<Deferred component="Name">` is read where the rsc entry's
48
+ // deferredRegistry() says Name comes from. Every query any of them calls is
49
+ // held to the branch's check. And a component that renders what it is handed
50
+ // -- `children`, a prop -- under a check of its own carries that check to
51
+ // whatever a module hands it: `<IfCan ...><Refunds /></IfCan>` holds Refunds
52
+ // to IfCan's check.
53
+ //
54
+ // It fails closed. A check whose ability is not written out, a query whose
55
+ // name is not, a query the map does not know and a map that lists no
56
+ // abilities are each reported. So is what the branch reaches and the rule
57
+ // cannot read: an application module it cannot resolve or parse, an export
58
+ // it cannot find, a module loaded at runtime, a <Deferred> naming no
59
+ // registered component, and a component or function the branch renders or
60
+ // calls that was handed in as a parameter, or computed, where it cannot see
61
+ // what it is. A package -- a module under node_modules -- is not read: its
62
+ // code is not the application's, and names no query of the application's.
63
+
64
+ import { readFileSync, statSync } from 'node:fs';
65
+ import { dirname, extname, relative, resolve } from 'node:path';
66
+ import { QUERIES_MODULE, QUERY_MAP, readQueryAuthorization, readQueryMap } from './query-map.js';
67
+
68
+ /** The specifiers query() is imported by in a server component: its module, and the root re-exporting it. */
69
+ const QUERY_SPECIFIERS = new Set(['@tinvar/react/server', '@tinvar/react']);
70
+
71
+ /** The specifiers <Deferred> is imported by. */
72
+ const DEFERRED_SPECIFIERS = new Set(['@tinvar/react/deferred', '@tinvar/react']);
73
+
74
+ /** Where an application's rsc entry declares its deferred components, under the root. */
75
+ const DEFERRED_REGISTRY = 'resources/entries/rsc.tsx';
76
+
77
+ /** The call the rsc entry declares them with (vite.js's REGISTRY_FUNCTION). */
78
+ const REGISTRY_FUNCTION = 'deferredRegistry';
79
+
80
+ /** What `tinvar codegen` types for actions: references to PHP, which call no query. */
81
+ const ACTION_MODULE = /^@actions(?:\/|$)/;
82
+
83
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/;
84
+
85
+ /** The extensions an import written without one is looked for under, in order. */
86
+ const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mts', '.mjs', '.cts', '.cjs'];
87
+
88
+ /** Nodes around an expression that leave its value as it is. */
89
+ const WRAPPERS = new Set([
90
+ 'ChainExpression',
91
+ 'TSAsExpression',
92
+ 'TSNonNullExpression',
93
+ 'TSSatisfiesExpression',
94
+ 'TSTypeAssertion',
95
+ 'TSInstantiationExpression',
96
+ 'ParenthesizedExpression',
97
+ ]);
98
+
99
+ /** Where nested code runs at another time, so a check inside it is not this test's. */
100
+ const FUNCTIONS = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'FunctionDeclaration']);
101
+
102
+ /** Keys under which a node holds only types, or what is no value. */
103
+ const SKIPPED_KEYS = new Set([
104
+ 'parent',
105
+ 'range',
106
+ 'loc',
107
+ 'tokens',
108
+ 'comments',
109
+ 'typeAnnotation',
110
+ 'typeParameters',
111
+ 'typeArguments',
112
+ 'returnType',
113
+ 'superTypeArguments',
114
+ 'implements',
115
+ ]);
116
+
117
+ const WHY =
118
+ 'A can() check decides what a render displays, not who may read: the query is answered over /_query, and from every page and fragment that lists it, where this branch never runs (tinvar-spec.md, "Defaults and build rules").';
119
+
120
+ /** @type {import('eslint').Rule.RuleModule} */
121
+ export const authorizeQueriesInCanBranches = {
122
+ meta: {
123
+ type: 'problem',
124
+ docs: {
125
+ description:
126
+ 'Require a query called inside a can() branch to declare an #[Authorize] for the same ability: the check only decides what is displayed.',
127
+ },
128
+ schema: [
129
+ {
130
+ type: 'object',
131
+ properties: {
132
+ queryMap: { type: 'string' },
133
+ deferredRegistry: { type: 'string' },
134
+ aliases: { type: 'object', additionalProperties: { type: 'string' } },
135
+ },
136
+ additionalProperties: false,
137
+ },
138
+ ],
139
+ messages: {
140
+ unauthorized: `'{{query}}' is called inside a branch shown only when the visitor can '{{abilities}}', and the query checks no such ability itself. ${WHY} Give the query an #[Authorize('{{ability}}', ...)] of its own.`,
141
+ unauthorizedVia: `'{{query}}' is called by {{via}}, rendered inside a branch shown only when the visitor can '{{abilities}}', and the query checks no such ability itself. ${WHY} Give the query an #[Authorize('{{ability}}', ...)] of its own.`,
142
+ unreadableAbility: `This query is called inside a branch on a can() check whose ability is not written out, so whether the query checks the same one cannot be read. ${WHY} Write the ability out: can('cancel', ...), or order.can.cancel.`,
143
+ unreadableQuery: `This calls query() inside a can() branch with a name that is not written out, so whether the query checks the ability cannot be read. ${WHY} Write the name out: query('orders.shipments', ...).`,
144
+ unknownQuery: `'{{query}}' is called inside a can() branch, and {{file}} does not list it, so whether it checks the ability cannot be read. Run tinvar codegen.`,
145
+ noMap: `This query is called inside a can() branch, and {{file}} {{why}}, so whether it checks the ability cannot be read. Run tinvar codegen.`,
146
+ unreadable: `{{via}} is rendered inside a can() branch, and {{why}}, so whether the queries it calls check the ability cannot be read. ${WHY}`,
147
+ },
148
+ },
149
+ create(context) {
150
+ if (DECLARATION_FILE.test(context.filename)) return {};
151
+ const option =
152
+ /** @type {{ queryMap?: string, deferredRegistry?: string, aliases?: Record<string, string> } | undefined} */ (
153
+ context.options[0]
154
+ );
155
+ const settings = {
156
+ mapFile: resolve(context.cwd, option?.queryMap ?? QUERY_MAP),
157
+ registryFile: resolve(context.cwd, option?.deferredRegistry ?? DEFERRED_REGISTRY),
158
+ aliases: Object.entries(option?.aliases ?? {}).map(([prefix, target]) => ({
159
+ prefix,
160
+ target: resolve(context.cwd, target),
161
+ })),
162
+ cwd: context.cwd,
163
+ };
164
+
165
+ return {
166
+ 'Program:exit'(program) {
167
+ if (isClientModule(program)) return;
168
+ check(context, program, settings);
169
+ },
170
+ };
171
+ },
172
+ };
173
+
174
+ /**
175
+ * @typedef {{ kind: 'query', name: string | null, function: string | null, via: string | null }
176
+ * | { kind: 'unreadable', why: string, via: string }} Finding
177
+ * a query some code calls -- its name, or null where it is not written out --
178
+ * or why what it reaches cannot be read; `via` names the component or
179
+ * function it was reached through, null for a call written in the branch
180
+ */
181
+
182
+ /**
183
+ * @typedef {{
184
+ * file: string,
185
+ * program: any,
186
+ * client: boolean,
187
+ * bindings: ReturnType<typeof queryBindings>,
188
+ * top: Map<string, { kind: 'local', node: any } | { kind: 'import', source: string, imported: string }>,
189
+ * exports: Map<string, { local: string } | { source: string, imported: string } | { node: any }>,
190
+ * stars: string[],
191
+ * }} Module
192
+ */
193
+
194
+ /**
195
+ * @param {import('eslint').Rule.RuleContext} context
196
+ * @param {any} program
197
+ * @param {{ mapFile: string, registryFile: string, aliases: Array<{ prefix: string, target: string }>, cwd: string }} settings
198
+ */
199
+ function check(context, program, settings) {
200
+ const sourceCode = context.sourceCode;
201
+ const current = moduleOf(context.filename, program);
202
+
203
+ /** @type {ReturnType<typeof readMaps> | null} */
204
+ let maps = null;
205
+ const reader = {
206
+ settings,
207
+ parser: context.languageOptions.parser,
208
+ parserOptions: context.languageOptions.parserOptions ?? {},
209
+ mapsOf: () => (maps ??= readMaps(settings.mapFile)),
210
+ current,
211
+ /** @type {Map<string, Finding[]>} */
212
+ reached: new Map(),
213
+ /** @type {Set<string>} */
214
+ reaching: new Set(),
215
+ /** @type {Map<any, Array<string | null>>} */
216
+ checks: new Map(),
217
+ /** @param {any} node */
218
+ scopeOf: (node) => sourceCode.getScope(node),
219
+ };
220
+
221
+ /** @type {Set<string>} */
222
+ const reported = new Set();
223
+ /** @param {any} node @param {string} messageId @param {Record<string, string>} [data] */
224
+ const report = (node, messageId, data = {}) => {
225
+ const key = `${node.range?.[0]}:${node.range?.[1]}:${messageId}:${JSON.stringify(data)}`;
226
+ if (reported.has(key)) return;
227
+ reported.add(key);
228
+ context.report({ node, messageId, data });
229
+ };
230
+
231
+ walk(program, (node) => {
232
+ // What runs here: a query called, or a component or function named.
233
+ /** @type {(() => Finding[]) | null} */
234
+ let findings = null;
235
+ if (node.type === 'CallExpression') {
236
+ const call = queryOf(node, current.bindings, reader.mapsOf);
237
+ if (call !== null) findings = () => [{ kind: 'query', ...call, via: null }];
238
+ }
239
+ if (findings === null && isReference(node) && !isQueryCallee(node, current, reader)) {
240
+ findings = () => reachOfReference(reader, node);
241
+ }
242
+ if (findings === null && node.type === 'JSXOpeningElement') {
243
+ const deferred = deferredOf(reader, current, node);
244
+ if (deferred !== null) findings = () => deferred;
245
+ }
246
+
247
+ if (findings !== null) {
248
+ const guards = guardsOf(node).map((guard) => checksIn(reader, guard));
249
+ if (guards.some((abilities) => abilities.length > 0)) {
250
+ const found = findings();
251
+ for (const abilities of guards) {
252
+ if (abilities.length > 0 && !holds(reader, node, found, abilities, report)) break;
253
+ }
254
+ }
255
+ }
256
+
257
+ // A component that renders what it is handed under a check of its own:
258
+ // what is handed to it here is held to that check.
259
+ if (node.type === 'JSXElement') {
260
+ for (const { abilities, nodes } of handedUnderCheck(reader, node)) {
261
+ for (const handed of nodes) {
262
+ const found = reachOfSubtree(reader, current, handed, null);
263
+ holds(reader, handed, found, abilities, report);
264
+ }
265
+ }
266
+ }
267
+ return undefined;
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Whether every query found checks one of the abilities; reports each that
273
+ * does not, and each that cannot be read.
274
+ *
275
+ * @param {any} reader
276
+ * @param {any} node where the findings were reached from, reported on
277
+ * @param {Finding[]} found
278
+ * @param {Array<string | null>} abilities
279
+ * @param {(node: any, messageId: string, data?: Record<string, string>) => void} report
280
+ */
281
+ function holds(reader, node, found, abilities, report) {
282
+ let held = true;
283
+ for (const finding of found) {
284
+ if (finding.kind === 'unreadable') {
285
+ report(node, 'unreadable', { via: finding.via, why: finding.why });
286
+ held = false;
287
+ continue;
288
+ }
289
+ const { authorize, why } = reader.mapsOf();
290
+ if (authorize === null) {
291
+ report(node, 'noMap', { file: reader.settings.mapFile, why });
292
+ return false;
293
+ }
294
+ if (finding.name === null) {
295
+ report(
296
+ node,
297
+ ...(finding.function === null
298
+ ? /** @type {const} */ (['unreadableQuery'])
299
+ : /** @type {const} */ ([
300
+ 'unknownQuery',
301
+ { query: finding.function, file: reader.settings.mapFile },
302
+ ])),
303
+ );
304
+ held = false;
305
+ continue;
306
+ }
307
+ if (!Object.hasOwn(authorize, finding.name)) {
308
+ report(node, 'unknownQuery', { query: finding.name, file: reader.settings.mapFile });
309
+ held = false;
310
+ continue;
311
+ }
312
+ const declared = authorize[finding.name];
313
+ if (abilities.some((ability) => ability !== null && declared.includes(ability))) continue;
314
+
315
+ const written = abilities.filter((ability) => ability !== null);
316
+ if (written.length === 0) {
317
+ report(node, 'unreadableAbility');
318
+ held = false;
319
+ continue;
320
+ }
321
+ const data = { query: finding.name, abilities: written.join("' or '"), ability: written[0] };
322
+ report(
323
+ node,
324
+ finding.via === null ? 'unauthorized' : 'unauthorizedVia',
325
+ finding.via === null ? data : { ...data, via: finding.via },
326
+ );
327
+ held = false;
328
+ }
329
+ return held;
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // What code reaches: the queries it calls, followed through what it names.
334
+
335
+ /**
336
+ * What a reference in the linted module reaches, by what its name is bound
337
+ * to here: an import, a function or class, a variable holding one -- or,
338
+ * where it is rendered or called, a parameter or a value it cannot see into.
339
+ *
340
+ * @param {any} reader
341
+ * @param {any} node an Identifier or JSXIdentifier
342
+ * @returns {Finding[]}
343
+ */
344
+ function reachOfReference(reader, node) {
345
+ const name = node.name;
346
+ // `<kit.Card />`, `orders.Shipments()`: the export read of a namespace import.
347
+ const parent = node.parent;
348
+ if (
349
+ (parent?.type === 'MemberExpression' || parent?.type === 'JSXMemberExpression') &&
350
+ parent.object === node
351
+ ) {
352
+ const binding = reader.current.top.get(name);
353
+ const property = parent.type === 'JSXMemberExpression' ? parent.property.name : propertyName(parent);
354
+ if (
355
+ binding?.kind === 'import' &&
356
+ binding.imported === '*' &&
357
+ property !== null &&
358
+ findVariable(reader.scopeOf(node), name)?.defs[0]?.type === 'ImportBinding'
359
+ ) {
360
+ return reachOfImport(reader, reader.current, binding.source, property, property);
361
+ }
362
+ }
363
+ const variable = findVariable(reader.scopeOf(node), name);
364
+ const used = usedAs(node);
365
+ if (variable === null) {
366
+ // A global: nothing of the application's, unless the module's own
367
+ // top-level binding was missed by the scope.
368
+ return reader.current.top.has(name) ? reachOfBinding(reader, reader.current, name) : [];
369
+ }
370
+ return reachOfVariable(reader, variable, name, used, new Set());
371
+ }
372
+
373
+ /**
374
+ * @param {any} reader
375
+ * @param {any} variable an eslint-scope variable
376
+ * @param {string} name
377
+ * @param {'rendered' | 'called' | 'value'} used
378
+ * @param {Set<any>} seen
379
+ * @returns {Finding[]}
380
+ */
381
+ function reachOfVariable(reader, variable, name, used, seen) {
382
+ if (seen.has(variable)) return [];
383
+ seen.add(variable);
384
+ /** @type {Finding[]} */
385
+ const found = [];
386
+ for (const def of variable.defs) {
387
+ if (def.type === 'ImportBinding') {
388
+ found.push(...reachOfBinding(reader, reader.current, name));
389
+ } else if (def.type === 'FunctionName' || def.type === 'ClassName') {
390
+ found.push(...reachOfSubtree(reader, reader.current, def.node, null));
391
+ } else if (def.type === 'Variable') {
392
+ const init = unwrap(def.node.init);
393
+ if (init === null || init === undefined) {
394
+ if (used !== 'value') found.push(cannotSee(name, used, 'is given no value where it is declared'));
395
+ } else if (def.node.id.type !== 'Identifier') {
396
+ if (used !== 'value')
397
+ found.push(cannotSee(name, used, 'is destructured from a value the lint cannot follow'));
398
+ } else if (isFunctionLike(init)) {
399
+ found.push(...reachOfSubtree(reader, reader.current, init, null));
400
+ } else if (isAlias(init)) {
401
+ // `const Shown = flag ? Shipments : Refunds`: each name it may be.
402
+ for (const alias of identifiersOf(init)) {
403
+ const target = findVariable(reader.scopeOf(alias), alias.name);
404
+ if (target === null) continue;
405
+ found.push(...reachOfVariable(reader, target, alias.name, used, seen));
406
+ }
407
+ } else if (used !== 'value') {
408
+ found.push(cannotSee(name, used, 'holds a value the lint cannot follow to a component or function'));
409
+ } else {
410
+ // Data: what a query answered, or a value made here. Its own calls
411
+ // are read where they are written.
412
+ }
413
+ } else if (def.type === 'Parameter') {
414
+ // Handed in: what the caller hands is written where it renders this,
415
+ // and read there -- inside the branch, or, for a component that
416
+ // renders what it is handed under a check, held to that check where
417
+ // it is handed over (handedUnderCheck()).
418
+ } else if (used !== 'value') {
419
+ found.push(cannotSee(name, used, 'is bound in a way the lint cannot follow'));
420
+ }
421
+ }
422
+ return found;
423
+ }
424
+
425
+ /**
426
+ * @param {string} name
427
+ * @param {'rendered' | 'called' | 'value'} used
428
+ * @param {string} why
429
+ * @returns {Finding}
430
+ */
431
+ function cannotSee(name, used, why) {
432
+ return { kind: 'unreadable', via: used === 'rendered' ? `<${name}>` : `${name}()`, why: `${name} ${why}` };
433
+ }
434
+
435
+ /**
436
+ * What a module's top-level binding reaches: its definition, or the export
437
+ * of the module it is imported from.
438
+ *
439
+ * @param {any} reader
440
+ * @param {Module} module
441
+ * @param {string} name
442
+ * @returns {Finding[]}
443
+ */
444
+ function reachOfBinding(reader, module, name) {
445
+ const key = `${module.file}#${name}`;
446
+ const known = reader.reached.get(key);
447
+ if (known !== undefined) return known;
448
+ if (reader.reaching.has(key)) return [];
449
+ reader.reaching.add(key);
450
+
451
+ /** @type {Finding[]} */
452
+ let found = [];
453
+ const binding = module.top.get(name);
454
+ if (binding?.kind === 'local') {
455
+ // Named for where the query is called: the component or function that
456
+ // calls it, in the module that defines it.
457
+ found = reachOfSubtree(reader, module, binding.node, null).map((finding) =>
458
+ module === reader.current ? finding : withVia(finding, name, module.file, reader),
459
+ );
460
+ } else if (binding?.kind === 'import') {
461
+ found = reachOfImport(reader, module, binding.source, binding.imported, name);
462
+ }
463
+
464
+ reader.reaching.delete(key);
465
+ reader.reached.set(key, found);
466
+ return found;
467
+ }
468
+
469
+ /**
470
+ * What an import reaches: nothing for a package, for @tinvar's own modules
471
+ * and codegen's action references; the export in the module it resolves to;
472
+ * and, refused, a module that cannot be resolved or read.
473
+ *
474
+ * @param {any} reader
475
+ * @param {Module} module the importer
476
+ * @param {string} source
477
+ * @param {string} imported the name it is imported by: '*', 'default' or an export's
478
+ * @param {string} local how the importer names it, for a message
479
+ * @returns {Finding[]}
480
+ */
481
+ function reachOfImport(reader, module, source, imported, local) {
482
+ if (QUERY_SPECIFIERS.has(source) || QUERIES_MODULE.test(source)) {
483
+ // A query function handed on rather than called is still its query.
484
+ if (QUERIES_MODULE.test(source) && imported !== '*') {
485
+ const functions = reader.mapsOf().modules[source];
486
+ const name = functions !== undefined && Object.hasOwn(functions, imported) ? functions[imported] : null;
487
+ return [{ kind: 'query', name, function: `${source}'s ${imported}()`, via: null }];
488
+ }
489
+ return [];
490
+ }
491
+ const target = resolveImport(reader, module.file, source);
492
+ if (target.kind === 'leaf') return [];
493
+ const via = `${local} (${display(reader, module.file)})`;
494
+ if (target.kind === 'unresolved') {
495
+ return [{ kind: 'unreadable', via, why: `'${source}' cannot be resolved to a module the lint can read` }];
496
+ }
497
+ const loaded = loadModule(reader, target.file);
498
+ if ('error' in loaded) return [{ kind: 'unreadable', via, why: loaded.error }];
499
+ if (loaded.client) return [];
500
+ return reachOfExport(reader, loaded, imported, new Set()).map((finding) =>
501
+ withVia(finding, local, loaded.file, reader),
502
+ );
503
+ }
504
+
505
+ /**
506
+ * What a module's export reaches: `*` the whole module.
507
+ *
508
+ * @param {any} reader
509
+ * @param {Module} module
510
+ * @param {string} name
511
+ * @param {Set<string>} seen
512
+ * @returns {Finding[]}
513
+ */
514
+ function reachOfExport(reader, module, name, seen) {
515
+ const key = `${module.file}#${name}`;
516
+ if (seen.has(key)) return [];
517
+ seen.add(key);
518
+
519
+ if (name === '*') {
520
+ // The whole module: its own code, and what it re-exports.
521
+ const found = reachOfSubtree(reader, module, module.program, null);
522
+ for (const [exported, target] of module.exports) {
523
+ if ('source' in target)
524
+ found.push(...reachOfImport(reader, module, target.source, target.imported, exported));
525
+ }
526
+ for (const source of module.stars) found.push(...reachOfImport(reader, module, source, '*', '*'));
527
+ return found;
528
+ }
529
+
530
+ const exported = module.exports.get(name);
531
+ if (exported !== undefined) {
532
+ if ('local' in exported) return reachOfBinding(reader, module, exported.local);
533
+ if ('node' in exported) return reachOfSubtree(reader, module, exported.node, null);
534
+ return reachOfImport(reader, module, exported.source, exported.imported, name);
535
+ }
536
+ for (const source of module.stars) {
537
+ const target = resolveImport(reader, module.file, source);
538
+ if (target.kind !== 'file') continue;
539
+ const loaded = loadModule(reader, target.file);
540
+ if ('error' in loaded) continue;
541
+ if (exportsName(reader, loaded, name, new Set())) return reachOfExport(reader, loaded, name, seen);
542
+ }
543
+ return [
544
+ {
545
+ kind: 'unreadable',
546
+ via: `${name} (${display(reader, module.file)})`,
547
+ why: `${display(reader, module.file)} exports no '${name}' the lint can find`,
548
+ },
549
+ ];
550
+ }
551
+
552
+ /**
553
+ * Whether a module exports a name, itself or through `export *`.
554
+ *
555
+ * @param {any} reader
556
+ * @param {Module} module
557
+ * @param {string} name
558
+ * @param {Set<string>} seen
559
+ */
560
+ function exportsName(reader, module, name, seen) {
561
+ if (seen.has(module.file)) return false;
562
+ seen.add(module.file);
563
+ if (module.exports.has(name)) return true;
564
+ for (const source of module.stars) {
565
+ const target = resolveImport(reader, module.file, source);
566
+ if (target.kind !== 'file') continue;
567
+ const loaded = loadModule(reader, target.file);
568
+ if (!('error' in loaded) && exportsName(reader, loaded, name, seen)) return true;
569
+ }
570
+ return false;
571
+ }
572
+
573
+ /**
574
+ * What a subtree of a module reaches: each query it calls, each <Deferred>
575
+ * it renders, and what each name it reads that is bound at the module's top
576
+ * reaches in turn. In another module than the linted one, a name is read by
577
+ * its spelling alone, which follows more than runs and never less.
578
+ *
579
+ * @param {any} reader
580
+ * @param {Module} module
581
+ * @param {any} root
582
+ * @param {string | null} via
583
+ * @returns {Finding[]}
584
+ */
585
+ function reachOfSubtree(reader, module, root, via) {
586
+ /** @type {Finding[]} */
587
+ const found = [];
588
+ const linted = module === reader.current;
589
+ walk(root, (node) => {
590
+ if (node.type === 'ImportDeclaration' || node.type === 'ExportAllDeclaration') return false;
591
+ if (node.type === 'ExportNamedDeclaration' && node.source) return false;
592
+
593
+ if (node.type === 'CallExpression') {
594
+ const call = queryOf(node, module.bindings, reader.mapsOf);
595
+ if (call !== null) found.push({ kind: 'query', ...call, via });
596
+ }
597
+ if (node.type === 'ImportExpression') {
598
+ found.push({
599
+ kind: 'unreadable',
600
+ via: via ?? display(reader, module.file),
601
+ why: 'it loads a module at runtime (import()), which the lint cannot follow',
602
+ });
603
+ }
604
+ if (node.type === 'JSXOpeningElement') {
605
+ const deferred = deferredOf(reader, module, node);
606
+ if (deferred !== null) found.push(...deferred);
607
+ }
608
+ if (isReference(node) && !isQueryCallee(node, module, reader)) {
609
+ if (linted) {
610
+ found.push(...reachOfReference(reader, node));
611
+ } else if (module.top.has(node.name)) {
612
+ found.push(...reachOfBinding(reader, module, node.name));
613
+ }
614
+ }
615
+ // `<kit.Card />`, `orders.Shipments()`: an export of a namespace import.
616
+ const member = node.type === 'MemberExpression' || node.type === 'JSXMemberExpression' ? node : null;
617
+ if (member !== null && member.object.type.endsWith('Identifier')) {
618
+ const binding = module.top.get(member.object.name);
619
+ if (binding?.kind === 'import' && binding.imported === '*' && !QUERY_SPECIFIERS.has(binding.source)) {
620
+ const property = member.type === 'JSXMemberExpression' ? member.property.name : propertyName(member);
621
+ found.push(...reachOfImport(reader, module, binding.source, property ?? '*', member.object.name));
622
+ }
623
+ }
624
+ return undefined;
625
+ });
626
+ return found;
627
+ }
628
+
629
+ /**
630
+ * A <Deferred> element's component, read where the rsc entry's registry
631
+ * says it comes from; null for an element that is no <Deferred>.
632
+ *
633
+ * @param {any} reader
634
+ * @param {Module} module
635
+ * @param {any} opening a JSXOpeningElement
636
+ * @returns {Finding[] | null}
637
+ */
638
+ function deferredOf(reader, module, opening) {
639
+ if (opening.name.type !== 'JSXIdentifier') return null;
640
+ const binding = module.top.get(opening.name.name);
641
+ if (
642
+ binding?.kind !== 'import' ||
643
+ !DEFERRED_SPECIFIERS.has(binding.source) ||
644
+ binding.imported !== 'Deferred'
645
+ ) {
646
+ return null;
647
+ }
648
+ const attribute = opening.attributes.find(
649
+ (/** @type {any} */ a) =>
650
+ a.type === 'JSXAttribute' && a.name.type === 'JSXIdentifier' && a.name.name === 'component',
651
+ );
652
+ const value =
653
+ attribute?.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : attribute?.value;
654
+ const name = literal(value);
655
+ if (name === null) {
656
+ return [{ kind: 'unreadable', via: '<Deferred>', why: 'the component it names is not written out' }];
657
+ }
658
+ const registry = loadModule(reader, reader.settings.registryFile);
659
+ if ('error' in registry) {
660
+ return [
661
+ {
662
+ kind: 'unreadable',
663
+ via: `<Deferred component="${name}">`,
664
+ why: `the registry it is read from ${registry.error}`,
665
+ },
666
+ ];
667
+ }
668
+ const registered = registeredComponents(registry);
669
+ const local = registered.get(name);
670
+ if (local === undefined) {
671
+ return [
672
+ {
673
+ kind: 'unreadable',
674
+ via: `<Deferred component="${name}">`,
675
+ why: `${display(reader, registry.file)} registers no component '${name}' with ${REGISTRY_FUNCTION}()`,
676
+ },
677
+ ];
678
+ }
679
+ return reachOfBinding(reader, registry, local).map((finding) =>
680
+ withVia(finding, `<Deferred component="${name}">`, registry.file, reader),
681
+ );
682
+ }
683
+
684
+ /**
685
+ * The components a module's deferredRegistry() call registers: each name,
686
+ * and the binding its value is.
687
+ *
688
+ * @param {Module} module
689
+ * @returns {Map<string, string>}
690
+ */
691
+ function registeredComponents(module) {
692
+ /** @type {Map<string, string>} */
693
+ const components = new Map();
694
+ walk(module.program, (node) => {
695
+ if (node.type !== 'CallExpression') return undefined;
696
+ const callee = unwrap(node.callee);
697
+ if (callee.type !== 'Identifier' || callee.name !== REGISTRY_FUNCTION) return undefined;
698
+ const object = unwrap(node.arguments[0]);
699
+ if (object?.type !== 'ObjectExpression') return undefined;
700
+ for (const property of object.properties) {
701
+ if (property.type !== 'Property') continue;
702
+ const key = property.computed
703
+ ? literal(property.key)
704
+ : property.key.type === 'Identifier'
705
+ ? property.key.name
706
+ : literal(property.key);
707
+ const value = unwrap(property.value);
708
+ if (key !== null && value?.type === 'Identifier') components.set(key, value.name);
709
+ }
710
+ return undefined;
711
+ });
712
+ return components;
713
+ }
714
+
715
+ /**
716
+ * What a JSX element hands a component that renders it under a check of
717
+ * its own: for each such check, its abilities and the children and
718
+ * attribute values that check holds.
719
+ *
720
+ * @param {any} reader
721
+ * @param {any} element a JSXElement
722
+ * @returns {Array<{ abilities: Array<string | null>, nodes: any[] }>}
723
+ */
724
+ function handedUnderCheck(reader, element) {
725
+ const tag = element.openingElement.name;
726
+ if (tag.type !== 'JSXIdentifier' || !/^[A-Z]/.test(tag.name)) return [];
727
+ const definition = definitionOf(reader, tag);
728
+ if (definition === null) return [];
729
+
730
+ const guarded = guardedProps(reader, definition.module, definition.node);
731
+ /** @type {Array<{ abilities: Array<string | null>, nodes: any[] }>} */
732
+ const held = [];
733
+ for (const { props, abilities } of guarded) {
734
+ /** @type {any[]} */
735
+ const nodes = [];
736
+ if (props === null || props.has('children')) nodes.push(...element.children);
737
+ for (const attribute of element.openingElement.attributes) {
738
+ if (attribute.type === 'JSXSpreadAttribute') {
739
+ if (props === null) nodes.push(attribute.argument);
740
+ } else if (
741
+ props === null ||
742
+ (attribute.name.type === 'JSXIdentifier' && props.has(attribute.name.name))
743
+ ) {
744
+ if (attribute.value) nodes.push(attribute.value);
745
+ }
746
+ }
747
+ if (nodes.length > 0) held.push({ abilities, nodes });
748
+ }
749
+ return held;
750
+ }
751
+
752
+ /**
753
+ * Where a component the linted module renders is defined: a function in
754
+ * this module, or the export of the module it is imported from.
755
+ *
756
+ * @param {any} reader
757
+ * @param {any} tag
758
+ * @returns {{ module: Module, node: any } | null}
759
+ */
760
+ function definitionOf(reader, tag) {
761
+ const variable = findVariable(reader.scopeOf(tag), tag.name);
762
+ if (variable === null) return null;
763
+ const def = variable.defs[0];
764
+ if (def?.type === 'FunctionName') return { module: reader.current, node: def.node };
765
+ if (def?.type === 'Variable' && isFunctionLike(unwrap(def.node.init))) {
766
+ return { module: reader.current, node: unwrap(def.node.init) };
767
+ }
768
+ if (def?.type !== 'ImportBinding') return null;
769
+ const binding = reader.current.top.get(tag.name);
770
+ if (binding?.kind !== 'import') return null;
771
+ return exportedFunction(reader, reader.current, binding.source, binding.imported, new Set());
772
+ }
773
+
774
+ /**
775
+ * The function an import names, through re-exports; null for one the lint
776
+ * cannot find, which holds nothing handed to it.
777
+ *
778
+ * @param {any} reader
779
+ * @param {Module} module
780
+ * @param {string} source
781
+ * @param {string} name
782
+ * @param {Set<string>} seen
783
+ * @returns {{ module: Module, node: any } | null}
784
+ */
785
+ function exportedFunction(reader, module, source, name, seen) {
786
+ const target = resolveImport(reader, module.file, source);
787
+ if (target.kind !== 'file') return null;
788
+ const loaded = loadModule(reader, target.file);
789
+ if ('error' in loaded || loaded.client) return null;
790
+ const key = `${loaded.file}#${name}`;
791
+ if (seen.has(key)) return null;
792
+ seen.add(key);
793
+
794
+ const exported = loaded.exports.get(name);
795
+ if (exported === undefined) {
796
+ for (const star of loaded.stars) {
797
+ const found = exportedFunction(reader, loaded, star, name, seen);
798
+ if (found !== null) return found;
799
+ }
800
+ return null;
801
+ }
802
+ if ('source' in exported) return exportedFunction(reader, loaded, exported.source, exported.imported, seen);
803
+ const node = 'node' in exported ? unwrap(exported.node) : localFunction(loaded, exported.local);
804
+ return node !== null && isFunctionLike(node) ? { module: loaded, node } : null;
805
+ }
806
+
807
+ /** @param {Module} module @param {string} name */
808
+ function localFunction(module, name) {
809
+ const binding = module.top.get(name);
810
+ if (binding?.kind !== 'local') return null;
811
+ const node = binding.node.type === 'VariableDeclarator' ? unwrap(binding.node.init) : binding.node;
812
+ return node ?? null;
813
+ }
814
+
815
+ /**
816
+ * The props a component renders under a check of its own, each check with
817
+ * the props it holds: `children`, a prop by name, or every prop (null) where
818
+ * the component renders `props` whole or a name it cannot tie to one.
819
+ *
820
+ * @param {any} reader
821
+ * @param {Module} module
822
+ * @param {any} component a function
823
+ * @returns {Array<{ props: Set<string> | null, abilities: Array<string | null> }>}
824
+ */
825
+ function guardedProps(reader, module, component) {
826
+ const first = component.params?.[0];
827
+ if (first === undefined) return [];
828
+ /** @type {Map<string, string | null>} local name => the prop it is (null: all of them) */
829
+ const names = new Map();
830
+ if (first.type === 'Identifier') names.set(first.name, null);
831
+ else if (first.type === 'ObjectPattern') {
832
+ for (const property of first.properties) {
833
+ if (property.type === 'RestElement' && property.argument.type === 'Identifier') {
834
+ names.set(property.argument.name, null);
835
+ } else if (property.type === 'Property') {
836
+ const key = property.key.type === 'Identifier' ? property.key.name : literal(property.key);
837
+ const value = property.value.type === 'AssignmentPattern' ? property.value.left : property.value;
838
+ if (value.type === 'Identifier') names.set(value.name, key);
839
+ }
840
+ }
841
+ }
842
+ if (names.size === 0) return [];
843
+
844
+ /** @type {Array<{ props: Set<string> | null, abilities: Array<string | null> }>} */
845
+ const guarded = [];
846
+ walk(component.body, (node) => {
847
+ if (node.type !== 'Identifier' || !names.has(node.name) || !isReference(node)) return undefined;
848
+ // `props.children`: the prop read; `props` alone: all of them.
849
+ let prop = names.get(node.name) ?? null;
850
+ if (prop === null && node.parent?.type === 'MemberExpression' && node.parent.object === node) {
851
+ prop = propertyName(node.parent);
852
+ }
853
+ for (const guard of guardsOf(node)) {
854
+ const abilities = module === reader.current ? checksIn(reader, guard) : canChecksIn(guard, null);
855
+ if (abilities.length === 0) continue;
856
+ guarded.push({ props: prop === null ? null : new Set([prop]), abilities });
857
+ }
858
+ return undefined;
859
+ });
860
+ return guarded;
861
+ }
862
+
863
+ /**
864
+ * The module's own name for a finding reached through `local`, from `file`.
865
+ *
866
+ * @param {Finding} finding
867
+ * @param {string} local
868
+ * @param {string} file
869
+ * @param {any} reader
870
+ * @returns {Finding}
871
+ */
872
+ function withVia(finding, local, file, reader) {
873
+ if (finding.via !== null) return finding;
874
+ return { ...finding, via: `${local} (${display(reader, file)})` };
875
+ }
876
+
877
+ // ---------------------------------------------------------------------------
878
+ // The caches below live as long as the process: an editor's lint server is
879
+ // shown every file it is ever opened on, so each is capped, the least
880
+ // recently used going first. A Map keeps insertion order; a use moves the
881
+ // key to the end, and the oldest is the first key.
882
+
883
+ /** How many parsed modules are held; a lint run reads each once a version. */
884
+ const MODULE_LIMIT = 1000;
885
+
886
+ /** How many query maps are held: one for each app the server lints. */
887
+ const MAP_LIMIT = 16;
888
+
889
+ /**
890
+ * The value held for `key`, counted as a use.
891
+ *
892
+ * @template K, V
893
+ * @param {Map<K, V>} cache
894
+ * @param {K} key
895
+ * @returns {V | undefined}
896
+ */
897
+ export function heldIn(cache, key) {
898
+ if (!cache.has(key)) return undefined;
899
+ const value = /** @type {V} */ (cache.get(key));
900
+ cache.delete(key);
901
+ cache.set(key, value);
902
+ return value;
903
+ }
904
+
905
+ /**
906
+ * Holds `value` for `key` as the newest, dropping the oldest past `limit`.
907
+ *
908
+ * @template K, V
909
+ * @param {Map<K, V>} cache
910
+ * @param {K} key
911
+ * @param {V} value
912
+ * @param {number} limit
913
+ */
914
+ export function hold(cache, key, value, limit) {
915
+ cache.delete(key);
916
+ cache.set(key, value);
917
+ while (cache.size > limit) {
918
+ const oldest = cache.keys().next();
919
+ if (oldest.done === true) break;
920
+ cache.delete(oldest.value);
921
+ }
922
+ }
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // Modules: resolved, parsed and read once for each version of the file.
926
+
927
+ /** @type {Map<string, { stamp: string, module: Module | { error: string } }>} */
928
+ const modules = new Map();
929
+
930
+ /**
931
+ * @param {any} reader
932
+ * @param {string} file
933
+ * @returns {Module | { error: string }}
934
+ */
935
+ function loadModule(reader, file) {
936
+ let stamp;
937
+ try {
938
+ const stat = statSync(file);
939
+ stamp = `${stat.mtimeMs}:${stat.size}`;
940
+ } catch {
941
+ return { error: `${display(reader, file)} cannot be read` };
942
+ }
943
+ const held = heldIn(modules, file);
944
+ if (held !== undefined && held.stamp === stamp) return held.module;
945
+
946
+ /** @type {Module | { error: string }} */
947
+ let module;
948
+ try {
949
+ const code = readFileSync(file, 'utf8');
950
+ const parser = reader.parser;
951
+ const options = {
952
+ ...reader.parserOptions,
953
+ ecmaVersion: 'latest',
954
+ sourceType: 'module',
955
+ range: true,
956
+ loc: true,
957
+ filePath: file,
958
+ ecmaFeatures: { ...(reader.parserOptions.ecmaFeatures ?? {}), jsx: true },
959
+ };
960
+ const parsed =
961
+ typeof parser?.parseForESLint === 'function'
962
+ ? parser.parseForESLint(code, options).ast
963
+ : typeof parser?.parse === 'function'
964
+ ? parser.parse(code, options)
965
+ : null;
966
+ if (parsed === null) throw new Error('no parser to read it with');
967
+ setParents(parsed, null);
968
+ module = moduleOf(file, parsed);
969
+ } catch (error) {
970
+ module = { error: `${display(reader, file)} cannot be parsed (${/** @type {Error} */ (error).message})` };
971
+ }
972
+ hold(modules, file, { stamp, module }, MODULE_LIMIT);
973
+ return module;
974
+ }
975
+
976
+ /**
977
+ * A module's top-level bindings and exports.
978
+ *
979
+ * @param {string} file
980
+ * @param {any} program
981
+ * @returns {Module}
982
+ */
983
+ function moduleOf(file, program) {
984
+ /** @type {Module['top']} */
985
+ const top = new Map();
986
+ /** @type {Module['exports']} */
987
+ const exports = new Map();
988
+ /** @type {string[]} */
989
+ const stars = [];
990
+
991
+ /** @param {any} declaration */
992
+ const declare = (declaration) => {
993
+ if (declaration === null || declaration === undefined) return [];
994
+ if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
995
+ if (declaration.id) top.set(declaration.id.name, { kind: 'local', node: declaration });
996
+ return declaration.id ? [declaration.id.name] : [];
997
+ }
998
+ if (declaration.type === 'VariableDeclaration') {
999
+ const names = [];
1000
+ for (const declarator of declaration.declarations) {
1001
+ for (const name of patternNames(declarator.id)) {
1002
+ top.set(name, { kind: 'local', node: declarator });
1003
+ names.push(name);
1004
+ }
1005
+ }
1006
+ return names;
1007
+ }
1008
+ return [];
1009
+ };
1010
+
1011
+ for (const node of program.body) {
1012
+ if (node.type === 'ImportDeclaration') {
1013
+ if (node.importKind === 'type') continue;
1014
+ for (const specifier of node.specifiers) {
1015
+ if (specifier.importKind === 'type') continue;
1016
+ const imported =
1017
+ specifier.type === 'ImportNamespaceSpecifier'
1018
+ ? '*'
1019
+ : specifier.type === 'ImportDefaultSpecifier'
1020
+ ? 'default'
1021
+ : importedName(specifier);
1022
+ top.set(specifier.local.name, { kind: 'import', source: node.source.value, imported });
1023
+ }
1024
+ } else if (node.type === 'ExportNamedDeclaration') {
1025
+ if (node.exportKind === 'type') continue;
1026
+ if (node.declaration) {
1027
+ for (const name of declare(node.declaration)) exports.set(name, { local: name });
1028
+ }
1029
+ for (const specifier of node.specifiers ?? []) {
1030
+ if (specifier.exportKind === 'type') continue;
1031
+ const exported =
1032
+ specifier.exported.type === 'Identifier' ? specifier.exported.name : specifier.exported.value;
1033
+ const local = specifier.local.type === 'Identifier' ? specifier.local.name : specifier.local.value;
1034
+ exports.set(exported, node.source ? { source: node.source.value, imported: local } : { local });
1035
+ }
1036
+ } else if (node.type === 'ExportDefaultDeclaration') {
1037
+ const declaration = node.declaration;
1038
+ const names = declare(declaration);
1039
+ if (names.length > 0) exports.set('default', { local: names[0] });
1040
+ else if (declaration.type === 'Identifier') exports.set('default', { local: declaration.name });
1041
+ else exports.set('default', { node: declaration });
1042
+ } else if (node.type === 'ExportAllDeclaration') {
1043
+ if (node.exportKind === 'type') continue;
1044
+ if (node.exported) {
1045
+ const name = node.exported.type === 'Identifier' ? node.exported.name : node.exported.value;
1046
+ exports.set(name, { source: node.source.value, imported: '*' });
1047
+ } else {
1048
+ stars.push(node.source.value);
1049
+ }
1050
+ } else {
1051
+ declare(node);
1052
+ }
1053
+ }
1054
+
1055
+ return {
1056
+ file,
1057
+ program,
1058
+ client: isClientModule(program),
1059
+ bindings: queryBindings(program),
1060
+ top,
1061
+ exports,
1062
+ stars,
1063
+ };
1064
+ }
1065
+
1066
+ /**
1067
+ * Where an import leads: a file of the application's, a leaf -- a package,
1068
+ * @tinvar's own modules, codegen's action references -- or nowhere the lint
1069
+ * can find, which it refuses.
1070
+ *
1071
+ * @param {any} reader
1072
+ * @param {string} from the importing file
1073
+ * @param {string} source
1074
+ * @returns {{ kind: 'file', file: string } | { kind: 'leaf' } | { kind: 'unresolved' }}
1075
+ */
1076
+ function resolveImport(reader, from, source) {
1077
+ if (source.startsWith('@tinvar/') || ACTION_MODULE.test(source) || source.startsWith('node:')) {
1078
+ return { kind: 'leaf' };
1079
+ }
1080
+ /** @type {string | null} */
1081
+ let base = null;
1082
+ if (source.startsWith('./') || source.startsWith('../') || source === '.' || source === '..') {
1083
+ base = resolve(dirname(from), source);
1084
+ } else if (source.startsWith('/')) {
1085
+ base = source;
1086
+ } else {
1087
+ for (const { prefix, target } of reader.settings.aliases) {
1088
+ if (source === prefix || source.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`)) {
1089
+ base = resolve(target, source.slice(prefix.length).replace(/^\//, ''));
1090
+ break;
1091
+ }
1092
+ }
1093
+ }
1094
+ if (base === null) return isPackage(from, source) ? { kind: 'leaf' } : { kind: 'unresolved' };
1095
+
1096
+ // As the bundler does: the path, a TypeScript file for a `.js` written,
1097
+ // with each extension, or a directory's index.
1098
+ const stem = /\.[cm]?jsx?$/.test(base) ? base.replace(/\.[cm]?jsx?$/, '') : null;
1099
+ const candidates = [
1100
+ base,
1101
+ ...(stem === null ? [] : EXTENSIONS.map((extension) => stem + extension)),
1102
+ ...EXTENSIONS.map((extension) => base + extension),
1103
+ ...EXTENSIONS.map((extension) => resolve(base, `index${extension}`)),
1104
+ ];
1105
+ for (const candidate of candidates) {
1106
+ try {
1107
+ if (statSync(candidate).isFile()) {
1108
+ return EXTENSIONS.includes(extname(candidate)) ? { kind: 'file', file: candidate } : { kind: 'leaf' };
1109
+ }
1110
+ } catch {
1111
+ // Not this one.
1112
+ }
1113
+ }
1114
+ return { kind: 'unresolved' };
1115
+ }
1116
+
1117
+ /** Whether a bare specifier names a package installed where the importer would find it. */
1118
+ function isPackage(from, source) {
1119
+ const name = source.startsWith('@') ? source.split('/').slice(0, 2).join('/') : source.split('/')[0];
1120
+ if (name === '' || (name.startsWith('@') && !name.includes('/'))) return false;
1121
+ for (let directory = dirname(from); ; directory = dirname(directory)) {
1122
+ try {
1123
+ if (statSync(resolve(directory, 'node_modules', name)).isDirectory()) return true;
1124
+ } catch {
1125
+ // Not installed here.
1126
+ }
1127
+ if (dirname(directory) === directory) return false;
1128
+ }
1129
+ }
1130
+
1131
+ /** @param {any} reader @param {string} file */
1132
+ function display(reader, file) {
1133
+ const path = relative(reader.settings.cwd, file);
1134
+ return path.startsWith('..') ? file : path;
1135
+ }
1136
+
1137
+ // ---------------------------------------------------------------------------
1138
+ // Checks: the abilities a test reads.
1139
+
1140
+ /**
1141
+ * The abilities the can() checks in a test of the linted module read --
1142
+ * null for one not written out -- following a variable the check was read
1143
+ * into and a function that returns one.
1144
+ *
1145
+ * @param {any} reader
1146
+ * @param {any} test
1147
+ * @returns {Array<string | null>}
1148
+ */
1149
+ function checksIn(reader, test) {
1150
+ const held = reader.checks.get(test);
1151
+ if (held !== undefined) return held;
1152
+ reader.checks.set(test, []);
1153
+ const found = canChecksIn(test, {
1154
+ /** @param {any} node */
1155
+ variable: (node) => {
1156
+ const variable = findVariable(reader.scopeOf(node), node.name);
1157
+ return variable === null ? null : (variable.defs[0] ?? null);
1158
+ },
1159
+ /** @param {any} def @param {string} name */
1160
+ function: (def, name) => {
1161
+ if (def.type === 'FunctionName') return { module: reader.current, node: def.node };
1162
+ if (def.type === 'Variable' && isFunctionLike(unwrap(def.node.init))) {
1163
+ return { module: reader.current, node: unwrap(def.node.init) };
1164
+ }
1165
+ if (def.type !== 'ImportBinding') return null;
1166
+ const binding = reader.current.top.get(name);
1167
+ return binding?.kind === 'import'
1168
+ ? exportedFunction(reader, reader.current, binding.source, binding.imported, new Set())
1169
+ : null;
1170
+ },
1171
+ reader,
1172
+ });
1173
+ reader.checks.set(test, found);
1174
+ return found;
1175
+ }
1176
+
1177
+ /**
1178
+ * The abilities the can() checks in a test read -- null for one not written
1179
+ * out -- and none when it holds no check. With a resolver, a name the test
1180
+ * reads is followed to the check it was given, and a function it calls to
1181
+ * the checks it makes.
1182
+ *
1183
+ * @param {any} test
1184
+ * @param {{ variable: (node: any) => any, function: (def: any, name: string) => { module: Module, node: any } | null, reader: any } | null} resolver
1185
+ * @param {Set<any>} [seen]
1186
+ * @returns {Array<string | null>}
1187
+ */
1188
+ function canChecksIn(test, resolver, seen = new Set()) {
1189
+ /** @type {Array<string | null>} */
1190
+ const found = [];
1191
+ if (seen.has(test)) return found;
1192
+ seen.add(test);
1193
+ walk(test, (node) => {
1194
+ if (FUNCTIONS.has(node.type) && node !== test) return false;
1195
+
1196
+ if (node.type === 'CallExpression') {
1197
+ const callee = unwrap(node.callee);
1198
+ const named =
1199
+ (callee.type === 'Identifier' && callee.name === 'can') ||
1200
+ (callee.type === 'MemberExpression' && propertyName(callee) === 'can');
1201
+ if (named) {
1202
+ found.push(literal(node.arguments[0]));
1203
+ } else if (callee.type === 'Identifier' && resolver !== null) {
1204
+ // canCancel(order): the checks the function makes.
1205
+ const def = resolver.variable(callee);
1206
+ const fn = def === null ? null : resolver.function(def, callee.name);
1207
+ if (fn !== null) found.push(...canChecksIn(fn.node.body ?? fn.node, null, seen));
1208
+ }
1209
+ return undefined;
1210
+ }
1211
+
1212
+ if (node.type === 'MemberExpression') {
1213
+ const object = unwrap(node.object);
1214
+ const isMap =
1215
+ (object.type === 'Identifier' && (object.name === 'can' || canMapVariable(object, resolver))) ||
1216
+ (object.type === 'MemberExpression' && propertyName(object) === 'can');
1217
+ // `x.can(...)` is a call, read above; `x.can.y` reads the map.
1218
+ if (isMap && !(node.parent?.type === 'CallExpression' && node.parent.callee === node)) {
1219
+ found.push(propertyName(node));
1220
+ }
1221
+ return undefined;
1222
+ }
1223
+
1224
+ // `cancel`, read from `const { cancel } = order.can` or `const cancel =
1225
+ // order.can.cancel`.
1226
+ if (
1227
+ node.type === 'Identifier' &&
1228
+ resolver !== null &&
1229
+ isReference(node) &&
1230
+ node.parent?.type !== 'MemberExpression'
1231
+ ) {
1232
+ const def = resolver.variable(node);
1233
+ if (def?.type !== 'Variable' || !def.node.init) return undefined;
1234
+ if (def.node.id.type === 'Identifier') {
1235
+ found.push(...canChecksIn(def.node.init, resolver, seen));
1236
+ } else if (def.node.id.type === 'ObjectPattern' && isCanMap(unwrap(def.node.init), resolver)) {
1237
+ const key = keyBinding(def.node.id, node.name);
1238
+ if (key !== undefined) found.push(key);
1239
+ }
1240
+ }
1241
+ return undefined;
1242
+ });
1243
+ return found;
1244
+ }
1245
+
1246
+ /** Whether an expression is a `can` map: `can`, `x.can`, or a name given one. */
1247
+ function isCanMap(node, resolver) {
1248
+ if (node?.type === 'Identifier') return node.name === 'can' || canMapVariable(node, resolver);
1249
+ return node?.type === 'MemberExpression' && propertyName(node) === 'can';
1250
+ }
1251
+
1252
+ /** Whether a name was given a `can` map: `const perms = order.can`, `const { can: perms } = order`. */
1253
+ function canMapVariable(node, resolver) {
1254
+ if (resolver === null || node.name === 'can') return false;
1255
+ const def = resolver.variable(node);
1256
+ if (def?.type !== 'Variable' || !def.node.init) return false;
1257
+ if (def.node.id.type === 'Identifier') return isCanMap(unwrap(def.node.init), null);
1258
+ return def.node.id.type === 'ObjectPattern' && keyBinding(def.node.id, node.name) === 'can';
1259
+ }
1260
+
1261
+ /** The key an object pattern binds a name from, or undefined; null for a computed one. */
1262
+ function keyBinding(pattern, name) {
1263
+ for (const property of pattern.properties) {
1264
+ if (property.type !== 'Property') continue;
1265
+ const value = property.value.type === 'AssignmentPattern' ? property.value.left : property.value;
1266
+ if (value.type === 'Identifier' && value.name === name) {
1267
+ return property.computed
1268
+ ? literal(property.key)
1269
+ : property.key.type === 'Identifier'
1270
+ ? property.key.name
1271
+ : literal(property.key);
1272
+ }
1273
+ }
1274
+ return undefined;
1275
+ }
1276
+
1277
+ // ---------------------------------------------------------------------------
1278
+ // Queries, guards and syntax.
1279
+
1280
+ /**
1281
+ * The names query() and the @queries/* functions are bound to in this
1282
+ * module, from its imports.
1283
+ *
1284
+ * @param {any} program
1285
+ */
1286
+ function queryBindings(program) {
1287
+ /** @type {Set<string>} local names of query() */
1288
+ const callees = new Set();
1289
+ /** @type {Set<string>} namespaces whose `.query` is query() */
1290
+ const namespaces = new Set();
1291
+ /** @type {Map<string, { module: string, name: string }>} a @queries/* function imported by name */
1292
+ const functions = new Map();
1293
+ /** @type {Map<string, string>} a @queries/* module imported whole => its specifier */
1294
+ const queryModules = new Map();
1295
+
1296
+ for (const node of program.body) {
1297
+ if (node.type !== 'ImportDeclaration' || node.importKind === 'type') continue;
1298
+ const from = node.source.value;
1299
+ for (const specifier of node.specifiers) {
1300
+ if (specifier.importKind === 'type') continue;
1301
+ if (QUERY_SPECIFIERS.has(from)) {
1302
+ if (specifier.type === 'ImportNamespaceSpecifier') namespaces.add(specifier.local.name);
1303
+ else if (specifier.type === 'ImportSpecifier' && importedName(specifier) === 'query') {
1304
+ callees.add(specifier.local.name);
1305
+ }
1306
+ } else if (typeof from === 'string' && QUERIES_MODULE.test(from)) {
1307
+ if (specifier.type === 'ImportSpecifier') {
1308
+ functions.set(specifier.local.name, { module: from, name: importedName(specifier) });
1309
+ } else if (specifier.type === 'ImportNamespaceSpecifier') {
1310
+ queryModules.set(specifier.local.name, from);
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ return { callees, namespaces, functions, queryModules };
1316
+ }
1317
+
1318
+ /**
1319
+ * The query a call makes -- its name, or null where it is not written out --
1320
+ * or null when the call is not one.
1321
+ *
1322
+ * @param {any} node
1323
+ * @param {ReturnType<typeof queryBindings>} bindings
1324
+ * @param {() => ReturnType<typeof readMaps>} mapsOf
1325
+ * @returns {{ name: string | null, function: string | null } | null}
1326
+ * `function` names the @queries/* function called, for one the map does not list
1327
+ */
1328
+ function queryOf(node, bindings, mapsOf) {
1329
+ const callee = unwrap(node.callee);
1330
+
1331
+ const isQuery =
1332
+ (callee.type === 'Identifier' && bindings.callees.has(callee.name)) ||
1333
+ (callee.type === 'MemberExpression' &&
1334
+ !callee.computed &&
1335
+ callee.object.type === 'Identifier' &&
1336
+ bindings.namespaces.has(callee.object.name) &&
1337
+ callee.property.type === 'Identifier' &&
1338
+ callee.property.name === 'query');
1339
+ if (isQuery) return { name: literal(node.arguments[0]), function: null };
1340
+
1341
+ /** @type {{ module: string, name: string } | undefined} */
1342
+ let imported;
1343
+ if (callee.type === 'Identifier') imported = bindings.functions.get(callee.name);
1344
+ else if (
1345
+ callee.type === 'MemberExpression' &&
1346
+ callee.object.type === 'Identifier' &&
1347
+ bindings.queryModules.has(callee.object.name)
1348
+ ) {
1349
+ const module = /** @type {string} */ (bindings.queryModules.get(callee.object.name));
1350
+ const name = propertyName(callee);
1351
+ imported = { module, name: name ?? '' };
1352
+ }
1353
+ if (imported === undefined) return null;
1354
+
1355
+ const functions = mapsOf().modules[imported.module];
1356
+ const name =
1357
+ functions !== undefined && Object.hasOwn(functions, imported.name) ? functions[imported.name] : null;
1358
+ return { name, function: `${imported.module}'s ${imported.name || '(computed)'}()` };
1359
+ }
1360
+
1361
+ /**
1362
+ * The tests whose say a node runs on, innermost first: each `if` or
1363
+ * conditional it is a branch of, each `&&`, `||` or `??` it is the right of,
1364
+ * and each `if` before it in a block that leaves the block when it holds.
1365
+ *
1366
+ * @param {any} node
1367
+ * @returns {any[]}
1368
+ */
1369
+ function guardsOf(node) {
1370
+ const guards = [];
1371
+ for (let child = node, parent = node.parent; parent; child = parent, parent = parent.parent) {
1372
+ if (parent.type === 'IfStatement' && (parent.consequent === child || parent.alternate === child)) {
1373
+ guards.push(parent.test);
1374
+ } else if (
1375
+ parent.type === 'ConditionalExpression' &&
1376
+ (parent.consequent === child || parent.alternate === child)
1377
+ ) {
1378
+ guards.push(parent.test);
1379
+ } else if (parent.type === 'LogicalExpression' && parent.right === child) {
1380
+ guards.push(parent.left);
1381
+ } else if (
1382
+ parent.type === 'BlockStatement' ||
1383
+ parent.type === 'Program' ||
1384
+ parent.type === 'SwitchCase'
1385
+ ) {
1386
+ const statements = parent.type === 'SwitchCase' ? parent.consequent : parent.body;
1387
+ const index = statements.indexOf(child);
1388
+ for (const before of statements.slice(0, Math.max(0, index)).reverse()) {
1389
+ if (before.type === 'IfStatement' && before.alternate === null && exits(before.consequent)) {
1390
+ guards.push(before.test);
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ return guards;
1396
+ }
1397
+
1398
+ /** Whether a statement always leaves its block: a return or throw, or a block ending in one. */
1399
+ function exits(statement) {
1400
+ if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') return true;
1401
+ if (statement.type === 'BlockStatement') {
1402
+ const last = statement.body.at(-1);
1403
+ return last !== undefined && exits(last);
1404
+ }
1405
+ return false;
1406
+ }
1407
+
1408
+ /**
1409
+ * Whether a node is a name read as a value -- not a declaration, a property
1410
+ * key, an attribute's name or a closing tag -- that may be bound by the
1411
+ * module: an Identifier, or a JSX tag that names a component.
1412
+ *
1413
+ * @param {any} node
1414
+ */
1415
+ function isReference(node) {
1416
+ const parent = node.parent;
1417
+ if (node.type === 'JSXIdentifier') {
1418
+ // `<Card>` names a component, `<div>` an element; `<kit.Card>` reads kit.
1419
+ return (
1420
+ (parent?.type === 'JSXOpeningElement' && parent.name === node && /^[A-Z]/.test(node.name)) ||
1421
+ (parent?.type === 'JSXMemberExpression' && parent.object === node)
1422
+ );
1423
+ }
1424
+ if (node.type !== 'Identifier' || parent === null || parent === undefined) return false;
1425
+ switch (parent.type) {
1426
+ case 'MemberExpression':
1427
+ return parent.object === node || parent.computed;
1428
+ case 'Property':
1429
+ return parent.value === node && parent.parent?.type !== 'ObjectPattern';
1430
+ case 'PropertyDefinition':
1431
+ case 'MethodDefinition':
1432
+ return parent.value === node || (parent.computed && parent.key === node);
1433
+ case 'VariableDeclarator':
1434
+ return parent.init === node;
1435
+ case 'FunctionDeclaration':
1436
+ case 'FunctionExpression':
1437
+ case 'ArrowFunctionExpression':
1438
+ case 'ClassDeclaration':
1439
+ case 'ClassExpression':
1440
+ return parent.body === node || parent.superClass === node;
1441
+ case 'ImportSpecifier':
1442
+ case 'ImportDefaultSpecifier':
1443
+ case 'ImportNamespaceSpecifier':
1444
+ case 'ExportSpecifier':
1445
+ case 'LabeledStatement':
1446
+ case 'BreakStatement':
1447
+ case 'ContinueStatement':
1448
+ case 'RestElement':
1449
+ case 'ArrayPattern':
1450
+ case 'ObjectPattern':
1451
+ case 'CatchClause':
1452
+ case 'JSXAttribute':
1453
+ return false;
1454
+ case 'AssignmentPattern':
1455
+ return parent.right === node;
1456
+ case 'AssignmentExpression':
1457
+ return parent.right === node;
1458
+ default:
1459
+ return !parent.type.startsWith('TS') || WRAPPERS.has(parent.type);
1460
+ }
1461
+ }
1462
+
1463
+ /**
1464
+ * Whether a name is what a query call calls -- `query`, `shipments` in
1465
+ * `shipments(...)` -- which the call itself is read as.
1466
+ *
1467
+ * @param {any} node
1468
+ * @param {Module} module
1469
+ * @param {any} reader
1470
+ */
1471
+ function isQueryCallee(node, module, reader) {
1472
+ let child = node;
1473
+ // `orders.refunds(...)`: the namespace read for the function called.
1474
+ if (child.parent?.type === 'MemberExpression' && child.parent.object === child) child = child.parent;
1475
+ while (child.parent && WRAPPERS.has(child.parent.type)) child = child.parent;
1476
+ const parent = child.parent;
1477
+ return (
1478
+ parent?.type === 'CallExpression' &&
1479
+ parent.callee === child &&
1480
+ queryOf(parent, module.bindings, reader.mapsOf) !== null
1481
+ );
1482
+ }
1483
+
1484
+ /** How a reference is used: rendered as a tag, called, or read as a value. */
1485
+ function usedAs(node) {
1486
+ if (node.type === 'JSXIdentifier') return 'rendered';
1487
+ let child = node;
1488
+ while (child.parent && WRAPPERS.has(child.parent.type)) child = child.parent;
1489
+ const parent = child.parent;
1490
+ if ((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') && parent.callee === child)
1491
+ return 'called';
1492
+ return 'value';
1493
+ }
1494
+
1495
+ /** A function or class written here, whose body is read. */
1496
+ function isFunctionLike(node) {
1497
+ return (
1498
+ node !== null &&
1499
+ node !== undefined &&
1500
+ (FUNCTIONS.has(node.type) || node.type === 'ClassExpression' || node.type === 'ClassDeclaration')
1501
+ );
1502
+ }
1503
+
1504
+ /** A value that is one of some names: `A`, `ns.A`, `flag ? A : B`, `a ?? B`. */
1505
+ function isAlias(node) {
1506
+ if (node === null || node === undefined) return false;
1507
+ if (WRAPPERS.has(node.type)) return isAlias(node.expression);
1508
+ if (node.type === 'Identifier') return true;
1509
+ if (node.type === 'ConditionalExpression') return isAlias(node.consequent) && isAlias(node.alternate);
1510
+ if (node.type === 'LogicalExpression') return isAlias(node.right);
1511
+ return false;
1512
+ }
1513
+
1514
+ /** The names an alias may be. */
1515
+ function identifiersOf(node) {
1516
+ if (WRAPPERS.has(node.type)) return identifiersOf(node.expression);
1517
+ if (node.type === 'Identifier') return [node];
1518
+ if (node.type === 'ConditionalExpression')
1519
+ return [...identifiersOf(node.consequent), ...identifiersOf(node.alternate)];
1520
+ if (node.type === 'LogicalExpression')
1521
+ return [...(isAlias(node.left) ? identifiersOf(node.left) : []), ...identifiersOf(node.right)];
1522
+ return [];
1523
+ }
1524
+
1525
+ /** @param {any} scope @param {string} name */
1526
+ function findVariable(scope, name) {
1527
+ for (let current = scope; current; current = current.upper) {
1528
+ const variable = current.set.get(name);
1529
+ if (variable !== undefined) return variable;
1530
+ }
1531
+ return null;
1532
+ }
1533
+
1534
+ /** The names a pattern binds. */
1535
+ function patternNames(pattern) {
1536
+ if (pattern === null || pattern === undefined) return [];
1537
+ switch (pattern.type) {
1538
+ case 'Identifier':
1539
+ return [pattern.name];
1540
+ case 'ObjectPattern':
1541
+ return pattern.properties.flatMap((/** @type {any} */ p) =>
1542
+ patternNames(p.type === 'RestElement' ? p.argument : p.value),
1543
+ );
1544
+ case 'ArrayPattern':
1545
+ return pattern.elements.flatMap((/** @type {any} */ e) => patternNames(e));
1546
+ case 'RestElement':
1547
+ return patternNames(pattern.argument);
1548
+ case 'AssignmentPattern':
1549
+ return patternNames(pattern.left);
1550
+ default:
1551
+ return [];
1552
+ }
1553
+ }
1554
+
1555
+ /** Whether a module's prologue says 'use client'. */
1556
+ function isClientModule(program) {
1557
+ return program.body.some(
1558
+ (/** @type {any} */ statement) =>
1559
+ statement.type === 'ExpressionStatement' && statement.directive === 'use client',
1560
+ );
1561
+ }
1562
+
1563
+ /** A string written out: a literal, or a template with nothing in it. Null for anything else. */
1564
+ function literal(node) {
1565
+ if (node?.type === 'Literal' && typeof node.value === 'string' && node.value !== '') return node.value;
1566
+ if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) {
1567
+ const cooked = node.quasis[0]?.value.cooked;
1568
+ if (typeof cooked === 'string' && cooked !== '') return cooked;
1569
+ }
1570
+ return null;
1571
+ }
1572
+
1573
+ /** A member's property as written, or null when it is computed from something other than a literal. */
1574
+ function propertyName(member) {
1575
+ const property = member.property;
1576
+ if (!member.computed) return property.type === 'Identifier' ? property.name : null;
1577
+ return literal(property);
1578
+ }
1579
+
1580
+ function importedName(specifier) {
1581
+ return specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value;
1582
+ }
1583
+
1584
+ function unwrap(node) {
1585
+ while (node && WRAPPERS.has(node.type)) node = node.expression;
1586
+ return node;
1587
+ }
1588
+
1589
+ /** Each node's parent, which a parser leaves to ESLint to set. */
1590
+ function setParents(node, parent) {
1591
+ if (node === null || typeof node !== 'object') return;
1592
+ if (Array.isArray(node)) {
1593
+ for (const child of node) setParents(child, parent);
1594
+ return;
1595
+ }
1596
+ if (typeof node.type !== 'string') return;
1597
+ node.parent = parent;
1598
+ for (const key of Object.keys(node)) {
1599
+ if (key === 'parent' || key === 'range' || key === 'loc' || key === 'tokens' || key === 'comments')
1600
+ continue;
1601
+ const child = node[key];
1602
+ if (child !== null && typeof child === 'object') setParents(child, node);
1603
+ }
1604
+ }
1605
+
1606
+ /**
1607
+ * Depth-first over every node that holds a value -- not TypeScript's types
1608
+ * -- the visitor returning false to skip a subtree.
1609
+ *
1610
+ * @param {any} node
1611
+ * @param {(node: any) => boolean | undefined} visit
1612
+ */
1613
+ function walk(node, visit) {
1614
+ if (node === null || typeof node !== 'object') return;
1615
+ if (Array.isArray(node)) {
1616
+ for (const child of node) walk(child, visit);
1617
+ return;
1618
+ }
1619
+ if (typeof node.type !== 'string') return;
1620
+ if (node.type.startsWith('TS') && !WRAPPERS.has(node.type) && node.type !== 'TSParameterProperty') return;
1621
+ if (visit(node) === false) return;
1622
+ for (const key of Object.keys(node)) {
1623
+ if (SKIPPED_KEYS.has(key)) continue;
1624
+ const child = node[key];
1625
+ if (child !== null && typeof child === 'object') walk(child, visit);
1626
+ }
1627
+ }
1628
+
1629
+ /** @type {Map<string, { stamp: string, maps: { modules: Record<string, Record<string, string>>, authorize: Record<string, string[]> | null, why: string } }>} */
1630
+ const cache = new Map();
1631
+
1632
+ /**
1633
+ * What codegen wrote, read once for each version of the file: a lint run
1634
+ * reads it for every server module, and an editor's for every keystroke.
1635
+ *
1636
+ * @param {string} file
1637
+ */
1638
+ function readMaps(file) {
1639
+ let stamp = 'missing';
1640
+ try {
1641
+ const stat = statSync(file);
1642
+ stamp = `${stat.mtimeMs}:${stat.size}`;
1643
+ } catch {
1644
+ // Missing: read as no map.
1645
+ }
1646
+ const held = heldIn(cache, file);
1647
+ if (held !== undefined && held.stamp === stamp) return held.maps;
1648
+
1649
+ let maps;
1650
+ try {
1651
+ const authorize = readQueryAuthorization(file);
1652
+ maps = {
1653
+ modules: readQueryMap(file),
1654
+ authorize,
1655
+ why: authorize === null ? 'is missing, or lists no query’s abilities' : '',
1656
+ };
1657
+ } catch (error) {
1658
+ maps = {
1659
+ modules: /** @type {Record<string, Record<string, string>>} */ ({}),
1660
+ authorize: null,
1661
+ why: `cannot be read (${/** @type {Error} */ (error).message})`,
1662
+ };
1663
+ }
1664
+ hold(cache, file, { stamp, maps }, MAP_LIMIT);
1665
+ return maps;
1666
+ }