@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/LICENSE +21 -0
- package/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|