@fjall/eslint-plugin 29.0.0 → 31.0.0

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/README.md CHANGED
@@ -28,4 +28,7 @@ export default [
28
28
  ```
29
29
 
30
30
  Every rule lives in its own file (`<rule-name>.js`); `index.js` is the barrel
31
- exposing them all under the `rules` map.
31
+ exposing them all under the `rules` map. AST helpers that more than two rules
32
+ would otherwise re-derive (the scope-chain variable lookup, the TS type-wrapper
33
+ unwrap) live in `astHelpers.js` and are imported, never copied — a rule still
34
+ carrying a private copy migrates to the shared helper as it is next touched.
package/astHelpers.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @fileoverview AST helpers shared across the plugin's rules.
3
+ *
4
+ * Rules stay one-per-file (`<rule-name>.js`); the helpers every flow-tracking
5
+ * rule otherwise re-derives — the scope-chain variable lookup and the
6
+ * type-wrapper unwrap — live here so one copy's membership cannot silently
7
+ * drift from another's. A sibling still carrying a private copy migrates to
8
+ * these as it is next touched.
9
+ */
10
+
11
+ /** Type-level wrappers with no runtime value of their own; `expression` is the value. */
12
+ export const TS_WRAPPER_TYPES = new Set([
13
+ "TSAsExpression",
14
+ "TSNonNullExpression",
15
+ "TSSatisfiesExpression",
16
+ "TSTypeAssertion"
17
+ ]);
18
+
19
+ /**
20
+ * The expression as written, minus type-level wrappers and the optional-chain
21
+ * envelope (`a?.b` parses as ChainExpression → MemberExpression).
22
+ */
23
+ export function unwrapExpression(node) {
24
+ while (
25
+ node &&
26
+ (node.type === "ChainExpression" || TS_WRAPPER_TYPES.has(node.type))
27
+ ) {
28
+ node = node.expression;
29
+ }
30
+ return node;
31
+ }
32
+
33
+ /** The variable `name` resolves to from `scope` outward, or null when unbound. */
34
+ export function findVariableInScopeChain(scope, name) {
35
+ for (let current = scope; current; current = current.upper) {
36
+ const variable = current.set.get(name);
37
+ if (variable) return variable;
38
+ }
39
+ return null;
40
+ }
package/index.js CHANGED
@@ -26,6 +26,7 @@ import requireAbortPrecheckInSdkLoop from "./require-abort-precheck-in-sdk-loop.
26
26
  import requireAbortCompositionOnSdkSend from "./require-abort-composition-on-sdk-send.js";
27
27
  import noClassicConnectedAccountAssume from "./no-classic-connected-account-assume.js";
28
28
  import noRawDbTransaction from "./no-raw-db-transaction.js";
29
+ import noRawRequestUrlInRedirect from "./no-raw-request-url-in-redirect.js";
29
30
  import noRawExitCode from "./no-raw-exit-code.js";
30
31
  import noReplacementStringExpansion from "./no-replacement-string-expansion.js";
31
32
  import noSilentResultDiscard from "./no-silent-result-discard.js";
@@ -66,6 +67,7 @@ export default {
66
67
  "require-abort-composition-on-sdk-send": requireAbortCompositionOnSdkSend,
67
68
  "no-classic-connected-account-assume": noClassicConnectedAccountAssume,
68
69
  "no-raw-db-transaction": noRawDbTransaction,
70
+ "no-raw-request-url-in-redirect": noRawRequestUrlInRedirect,
69
71
  "no-raw-exit-code": noRawExitCode,
70
72
  "no-replacement-string-expansion": noReplacementStringExpansion,
71
73
  "no-silent-result-discard": noSilentResultDiscard,
@@ -0,0 +1,854 @@
1
+ /**
2
+ * @fileoverview A redirect Location must not echo the request's own URL raw.
3
+ *
4
+ * React Router serves a client-side navigation as a single-fetch data request
5
+ * — `/{path}.data?_routes=…` (`/_.data` at the root) — and hands middleware
6
+ * and loaders that request UNMODIFIED: only route matching sees the normalised
7
+ * path (`getNormalizedPath` in react-router/lib/server-runtime/urls.ts, not
8
+ * exported). A redirect whose Location is built from `request.url` therefore
9
+ * lands a live tab on `/acme/overview.data?_routes=…` — the client follows the
10
+ * Location it copies out of the data response verbatim. The first, document
11
+ * navigation works; every in-app navigation after it breaks.
12
+ *
13
+ * Flagged — an ECHO of the request's own URL reaching a redirect sink:
14
+ * - `request.url` itself (binding names `request` / `req`, any
15
+ * `<expr>.request.url`, or `url` destructured from the request), except
16
+ * as an argument of `new URL(…)` / `URL.parse(…)`, where the URL object
17
+ * is tracked instead
18
+ * - the URL object built from it — `new URL(request.url)` /
19
+ * `URL.parse(request.url)` inline, a binding initialised or assigned from
20
+ * it, an alias of that binding, or a destructure (`const { pathname,
21
+ * search } = new URL(request.url)`) — read as `.pathname` / `.search` /
22
+ * `.href` / `.toString()` / `.toJSON()` / `String(url)` / `${url}` /
23
+ * `url + ""` / `url.searchParams.toString()` / `${url.searchParams}`, or
24
+ * passed whole to `Response.redirect(url)` (the one sink that accepts the
25
+ * object)
26
+ * - a URL resolved AGAINST the request with a static relative reference —
27
+ * `new URL("?page=2", request.url)`, `new URL("", request.url)`,
28
+ * `new URL("#top", request.url)` — which keeps the `.data` path
29
+ * - a `URLSearchParams` built from one of those
30
+ * (`new URLSearchParams(url.search)`), coerced the same ways
31
+ * …flowing directly, through a same-function binding, destructure or
32
+ * assignment (`=`, `+=`, `||=`, `??=`, `&&=`), through string transforms
33
+ * (`.replace`, `.slice`, `.split(…)[i]`, `.match(…)?.[i]`, `.join`, …),
34
+ * template literals, `+` concatenation, `??` / `||` / ternary branches, a
35
+ * second `new URL(echo, base)`, and the transparent wrappers `String` /
36
+ * `encodeURIComponent` / `encodeURI` / `decodeURIComponent` / `decodeURI`,
37
+ * into:
38
+ * - the first argument of `redirect(…)` / `redirectDocument(…)` (by name,
39
+ * or under any alias imported from react-router) and of `replace(…)`
40
+ * (only under an import binding from react-router — the bare name
41
+ * collides with string helpers)
42
+ * - the first argument of `Response.redirect(…)`
43
+ * - a `Location` header value (any case): a property of the `headers`
44
+ * object literal, of a `new Headers(…)` argument, or of a literal bound
45
+ * to a `*headers` name; a `["Location", …]` tuple in such an array; or
46
+ * `headers.set("Location", …)` / `.append(…)` on a receiver named
47
+ * `*headers` or bound to `new Headers(…)`
48
+ *
49
+ * NOT flagged — VALUE reads, structural checks, and the caller's own path:
50
+ * - `url.searchParams.get(…)` / `.has(…)` / `.getAll(…)` (a value, not the
51
+ * URL), and `url.searchParams` handed to a helper
52
+ * - `url.pathname.startsWith(…)` / `.includes(…)` / `=== …` (boolean; the
53
+ * Location comes from elsewhere)
54
+ * - `new URL("/path", request.url)` / `new URL("https://…", request.url)` —
55
+ * a static path- or scheme-absolute reference takes only the origin from
56
+ * the base — and `new URL(dynamic, request.url)`, where the reference is
57
+ * the caller's and must be path-absolute (cf. `safeRedirectSuffix`)
58
+ * - `requestPath(request)` destructures (the cure)
59
+ * - `url.origin` / `.host` / `.protocol` (no path or query in them)
60
+ * - a `location` key on a non-header object (a log payload, a Prisma
61
+ * `data`), or `.set("location", …)` on a non-header receiver (a Map)
62
+ *
63
+ * Known limitations:
64
+ * - Interprocedural flow: the rule is AST-local within a function scope,
65
+ * like `mask-error-message-at-boundary`. An echo returned from a helper
66
+ * and redirected by its caller, or passed through a user-defined helper
67
+ * (`redirect(normalise(url.pathname))`), is not connected. Manual
68
+ * discipline applies there: the only sanctioned normaliser is
69
+ * `requestPath(request)` at `webapp/app/.server/utils/http/requestPath.ts`,
70
+ * which takes the request, not its URL.
71
+ * - Flow-insensitive within the scope: every read of a binding the echo is
72
+ * assigned to counts, whatever the statement order.
73
+ * - A binding chain deeper than MAX_TRACE_DEPTH hops is abandoned, not
74
+ * walked — never seen in real code; it keeps the lint run alive.
75
+ *
76
+ * Escape hatch:
77
+ * `// eslint-disable-next-line fjall/no-raw-request-url-in-redirect -- <why>`
78
+ * above the ECHO line (the report anchors on the leaf, not the sink) for a
79
+ * proven document-only path. Expect zero uses.
80
+ *
81
+ * Why this rule exists: webapp-standards.md § "Redirect Locations echoed from
82
+ * `request.url` must normalise the single-fetch URL" landed on 2026-09-02 and
83
+ * recurred in new code ~90 minutes later (the legacy `/assets` 301 built its
84
+ * Location from a destructured `search`), with a pre-existing sibling one file
85
+ * over (the pre-split table shim composing `url.pathname.replace(…)` and
86
+ * `url.search`). Both were fixed in webapp `45beb2b3`; documented-not-
87
+ * mechanised rules recur, and this shape is lintable.
88
+ */
89
+
90
+ import {
91
+ TS_WRAPPER_TYPES,
92
+ findVariableInScopeChain,
93
+ unwrapExpression
94
+ } from "./astHelpers.js";
95
+
96
+ const REQUEST_BINDING_NAMES = new Set(["request", "req"]);
97
+ const URL_ECHO_PROPERTIES = new Set(["pathname", "search", "href"]);
98
+ const URL_SERIALISERS = new Set(["toString", "toJSON"]);
99
+ // A method on the flowing string whose RESULT carries none of it — the walk
100
+ // ends. `match` is absent on purpose: a capture is a substring of the echo.
101
+ const TERMINATOR_METHODS = new Set([
102
+ "includes",
103
+ "startsWith",
104
+ "endsWith",
105
+ "test",
106
+ "indexOf",
107
+ "lastIndexOf",
108
+ "localeCompare",
109
+ "search",
110
+ "some",
111
+ "every",
112
+ "has"
113
+ ]);
114
+ const TRANSPARENT_CALLEES = new Set([
115
+ "String",
116
+ "encodeURIComponent",
117
+ "encodeURI",
118
+ "decodeURIComponent",
119
+ "decodeURI"
120
+ ]);
121
+ const REACT_ROUTER_REDIRECTORS = new Set([
122
+ "redirect",
123
+ "redirectDocument",
124
+ "replace"
125
+ ]);
126
+ // Matched by bare name whatever the import; `replace` needs the import binding.
127
+ const NAME_MATCHED_REDIRECTORS = ["redirect", "redirectDocument"];
128
+ const FLOW_ASSIGNMENT_OPERATORS = new Set(["=", "+=", "||=", "??=", "&&="]);
129
+ const HEADER_WRITE_METHODS = new Set(["set", "append"]);
130
+ const LOCATION_HEADER_RE = /^location$/i;
131
+ const HEADERS_NAME_RE = /headers$/i;
132
+ // A static reference that takes only the origin from its base.
133
+ const ABSOLUTE_REFERENCE_RE = /^(?:\/|[a-z][a-z0-9+.-]*:)/i;
134
+ const MAX_TRACE_DEPTH = 200;
135
+
136
+ /** @type {import('eslint').Rule.RuleModule} */
137
+ const noRawRequestUrlInRedirect = {
138
+ meta: {
139
+ type: "problem",
140
+ docs: {
141
+ description:
142
+ "A redirect Location must derive an echoed path from requestPath(request), never from request.url / new URL(request.url) — a single-fetch data request would be echoed back as /{path}.data?_routes=…"
143
+ },
144
+ messages: {
145
+ rawRequestUrl:
146
+ '`{{leaf}}` echoes the request\'s own URL into `{{sink}}`. A client-side navigation arrives as `/{path}.data?_routes=…` and the client follows the Location verbatim, so the tab lands on the data URL. Derive the echoed path from `requestPath(request)` (`~/.server/utils/http/requestPath`) instead; value reads (`url.searchParams.get(…)`) are fine. See webapp-standards.md § "Redirect Locations echoed from `request.url` must normalise the single-fetch URL".'
147
+ },
148
+ schema: []
149
+ },
150
+
151
+ create(context) {
152
+ const sourceCode = context.sourceCode;
153
+ const redirectorNames = new Set(NAME_MATCHED_REDIRECTORS);
154
+
155
+ function report(leafNode, leafLabel, sinkLabel) {
156
+ context.report({
157
+ node: leafNode,
158
+ messageId: "rawRequestUrl",
159
+ data: { leaf: leafLabel, sink: sinkLabel }
160
+ });
161
+ }
162
+
163
+ function check(leafNode, leafLabel) {
164
+ const sink = findSink(leafNode, new Set(), 0);
165
+ if (sink !== null) report(leafNode, leafLabel, sink);
166
+ }
167
+
168
+ // ── Binding resolution ────────────────────────────────────────────
169
+
170
+ function findVariable(identifier) {
171
+ return findVariableInScopeChain(
172
+ sourceCode.getScope(identifier),
173
+ identifier.name
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Every `{ id, init }` that gives the binding a value: its declarator
179
+ * (when initialised) and each plain `=` assignment to it.
180
+ */
181
+ function initialisersOf(identifier, visited) {
182
+ const variable = findVariable(identifier);
183
+ if (!variable || visited.has(variable)) return [];
184
+ visited.add(variable);
185
+ const entries = [];
186
+ for (const def of variable.defs) {
187
+ if (
188
+ def.type === "Variable" &&
189
+ def.node.type === "VariableDeclarator" &&
190
+ def.node.init !== null
191
+ ) {
192
+ entries.push({ id: def.node.id, init: def.node.init });
193
+ }
194
+ }
195
+ for (const ref of variable.references) {
196
+ const parent = ref.identifier.parent;
197
+ if (
198
+ ref.isWrite() &&
199
+ parent &&
200
+ parent.type === "AssignmentExpression" &&
201
+ parent.left === ref.identifier &&
202
+ parent.operator === "="
203
+ ) {
204
+ entries.push({ id: ref.identifier, init: parent.right });
205
+ }
206
+ }
207
+ return entries;
208
+ }
209
+
210
+ /** `request` / `req` / `<expr>.request` — the request object itself. */
211
+ function isRequestBinding(node) {
212
+ node = unwrapExpression(node);
213
+ if (!node) return false;
214
+ if (node.type === "Identifier")
215
+ return REQUEST_BINDING_NAMES.has(node.name);
216
+ return (
217
+ node.type === "MemberExpression" &&
218
+ !node.computed &&
219
+ node.property.type === "Identifier" &&
220
+ REQUEST_BINDING_NAMES.has(node.property.name)
221
+ );
222
+ }
223
+
224
+ /** `request.url` / `req.url` / `<expr>.request.url` (chains unwrapped). */
225
+ function isRequestUrlMember(node) {
226
+ node = unwrapExpression(node);
227
+ return (
228
+ node.type === "MemberExpression" &&
229
+ !node.computed &&
230
+ node.property.type === "Identifier" &&
231
+ node.property.name === "url" &&
232
+ isRequestBinding(node.object)
233
+ );
234
+ }
235
+
236
+ /**
237
+ * `request.url` directly, a binding initialised or assigned from it, or
238
+ * `url` destructured from the request.
239
+ */
240
+ function isRequestUrlValue(node, visited) {
241
+ node = unwrapExpression(node);
242
+ if (!node) return false;
243
+ if (isRequestUrlMember(node)) return true;
244
+ if (node.type !== "Identifier") return false;
245
+ return initialisersOf(node, visited).some((entry) => {
246
+ if (entry.id.type === "Identifier") {
247
+ return isRequestUrlValue(entry.init, visited);
248
+ }
249
+ return (
250
+ entry.id.type === "ObjectPattern" &&
251
+ isRequestBinding(entry.init) &&
252
+ patternPropertyKey(entry.id, node.name) === "url"
253
+ );
254
+ });
255
+ }
256
+
257
+ /** `new URL(…)` or `URL.parse(…)` — a URL object under construction. */
258
+ function isUrlConstruction(node) {
259
+ node = unwrapExpression(node);
260
+ if (!node) return false;
261
+ if (node.type === "NewExpression") {
262
+ return calleeIdentifierName(node.callee) === "URL";
263
+ }
264
+ return (
265
+ node.type === "CallExpression" && urlStaticMethod(node) === "parse"
266
+ );
267
+ }
268
+
269
+ /**
270
+ * "url" when the construction parses the request's URL, or resolves a
271
+ * static RELATIVE reference against it (the `.data` path survives). A
272
+ * static absolute reference takes only the origin; a dynamic one is the
273
+ * caller's.
274
+ */
275
+ function urlConstructionKind(node, visited) {
276
+ const first = node.arguments[0];
277
+ const base = node.arguments[1];
278
+ if (first === undefined) return null;
279
+ if (isRequestUrlValue(first, visited)) return "url";
280
+ return base !== undefined &&
281
+ isRequestUrlValue(base, visited) &&
282
+ isStaticRelativeReference(first)
283
+ ? "url"
284
+ : null;
285
+ }
286
+
287
+ /**
288
+ * "url" — a URL object built from the request's own URL
289
+ * "params" — a URLSearchParams carrying that URL's query
290
+ * null — anything else (a fixed URL, a value read, unknown)
291
+ */
292
+ function resolveUrlKind(node, visited, depth) {
293
+ if (!node || depth > MAX_TRACE_DEPTH) return null;
294
+ node = unwrapExpression(node);
295
+ if (isUrlConstruction(node)) return urlConstructionKind(node, visited);
296
+ if (node.type === "NewExpression") {
297
+ const first = node.arguments[0];
298
+ if (
299
+ calleeIdentifierName(node.callee) === "URLSearchParams" &&
300
+ node.arguments.length === 1
301
+ ) {
302
+ if (resolveUrlKind(first, visited, depth + 1) === "params") {
303
+ return "params";
304
+ }
305
+ const member = unwrapExpression(first);
306
+ if (
307
+ member.type === "MemberExpression" &&
308
+ !member.computed &&
309
+ member.property.type === "Identifier" &&
310
+ (member.property.name === "search" ||
311
+ member.property.name === "searchParams") &&
312
+ resolveUrlKind(member.object, visited, depth + 1) === "url"
313
+ ) {
314
+ return "params";
315
+ }
316
+ }
317
+ return null;
318
+ }
319
+ if (
320
+ node.type === "MemberExpression" &&
321
+ !node.computed &&
322
+ node.property.type === "Identifier" &&
323
+ node.property.name === "searchParams"
324
+ ) {
325
+ return resolveUrlKind(node.object, visited, depth + 1) === "url"
326
+ ? "params"
327
+ : null;
328
+ }
329
+ if (node.type !== "Identifier") return null;
330
+ for (const entry of initialisersOf(node, visited)) {
331
+ if (entry.id.type === "Identifier") {
332
+ const kind = resolveUrlKind(entry.init, visited, depth + 1);
333
+ if (kind !== null) return kind;
334
+ continue;
335
+ }
336
+ if (
337
+ entry.id.type === "ObjectPattern" &&
338
+ resolveUrlKind(entry.init, visited, depth + 1) === "url" &&
339
+ patternPropertyKey(entry.id, node.name) === "searchParams"
340
+ ) {
341
+ return "params";
342
+ }
343
+ }
344
+ return null;
345
+ }
346
+
347
+ // ── Flow: leaf → sink ─────────────────────────────────────────────
348
+
349
+ /**
350
+ * Walks up from an echo leaf. Returns a sink label when the string
351
+ * reaches a redirect / Location sink in the same function scope, or null
352
+ * when it terminates first (boolean check, comparison, unknown helper,
353
+ * statement boundary).
354
+ */
355
+ function findSink(start, visited, depth) {
356
+ if (depth > MAX_TRACE_DEPTH) return null;
357
+ let previous = start;
358
+ let current = start.parent;
359
+ while (current) {
360
+ switch (current.type) {
361
+ case "TemplateLiteral":
362
+ case "ChainExpression":
363
+ case "AwaitExpression":
364
+ case "SpreadElement":
365
+ case "LogicalExpression":
366
+ break;
367
+ case "ArrayExpression":
368
+ if (isLocationTuple(current, previous)) {
369
+ return isHeadersLiteral(current) ? "Location header" : null;
370
+ }
371
+ break;
372
+ case "BinaryExpression":
373
+ if (current.operator !== "+") return null;
374
+ break;
375
+ case "ConditionalExpression":
376
+ if (current.test === previous) return null;
377
+ break;
378
+ case "SequenceExpression":
379
+ if (
380
+ current.expressions[current.expressions.length - 1] !== previous
381
+ ) {
382
+ return null;
383
+ }
384
+ break;
385
+ case "MemberExpression": {
386
+ if (current.object !== previous) return null;
387
+ if (current.computed) break;
388
+ if (current.property.type !== "Identifier") return null;
389
+ const call = current.parent;
390
+ if (
391
+ call &&
392
+ call.type === "CallExpression" &&
393
+ call.callee === current
394
+ ) {
395
+ if (TERMINATOR_METHODS.has(current.property.name)) return null;
396
+ previous = call;
397
+ current = call.parent;
398
+ continue;
399
+ }
400
+ // A URL laundered through `new URL(echo, base)` read back out.
401
+ if (URL_ECHO_PROPERTIES.has(current.property.name)) break;
402
+ return null;
403
+ }
404
+ case "CallExpression": {
405
+ if (current.callee === previous) return null;
406
+ const sink = classifySinkCall(current, previous);
407
+ if (sink !== null) return sink;
408
+ if (isTransparentCall(current)) break;
409
+ return null;
410
+ }
411
+ case "NewExpression": {
412
+ const ctor = calleeIdentifierName(current.callee);
413
+ if (
414
+ (ctor !== "URLSearchParams" && ctor !== "URL") ||
415
+ current.arguments[0] !== previous
416
+ ) {
417
+ return null;
418
+ }
419
+ // A construction the leaf visitors already track (its own reads
420
+ // are the leaves) ends this walk; an untracked one — a URL
421
+ // laundered from echo fragments — carries the echo onward.
422
+ if (resolveUrlKind(current, new Set(), depth) !== null) return null;
423
+ break;
424
+ }
425
+ case "Property": {
426
+ if (current.value !== previous) return null;
427
+ return propertyKeyMatches(current, LOCATION_HEADER_RE) &&
428
+ isHeadersLiteral(current.parent)
429
+ ? "Location header"
430
+ : null;
431
+ }
432
+ case "VariableDeclarator": {
433
+ if (current.init !== previous) return null;
434
+ return tracePattern(current.id, visited, depth);
435
+ }
436
+ case "AssignmentExpression": {
437
+ if (
438
+ current.right !== previous ||
439
+ !FLOW_ASSIGNMENT_OPERATORS.has(current.operator) ||
440
+ current.left.type !== "Identifier"
441
+ ) {
442
+ return null;
443
+ }
444
+ return traceBinding(current.left, visited, depth);
445
+ }
446
+ default:
447
+ if (TS_WRAPPER_TYPES.has(current.type)) break;
448
+ return null;
449
+ }
450
+ previous = current;
451
+ current = current.parent;
452
+ }
453
+ return null;
454
+ }
455
+
456
+ function tracePattern(pattern, visited, depth) {
457
+ const identifiers = [];
458
+ collectPatternIdentifiers(pattern, identifiers);
459
+ for (const identifier of identifiers) {
460
+ const sink = traceBinding(identifier, visited, depth);
461
+ if (sink !== null) return sink;
462
+ }
463
+ return null;
464
+ }
465
+
466
+ function traceBinding(identifier, visited, depth) {
467
+ const variable = findVariable(identifier);
468
+ if (!variable || visited.has(variable)) return null;
469
+ visited.add(variable);
470
+ for (const ref of variable.references) {
471
+ if (!ref.isRead() || ref.identifier === identifier) continue;
472
+ const sink = findSink(ref.identifier, visited, depth + 1);
473
+ if (sink !== null) return sink;
474
+ }
475
+ return null;
476
+ }
477
+
478
+ function classifySinkCall(call, argument) {
479
+ const callee = unwrapExpression(call.callee);
480
+ if (callee.type === "Identifier") {
481
+ return redirectorNames.has(callee.name) &&
482
+ call.arguments[0] === argument
483
+ ? `${callee.name}(…)`
484
+ : null;
485
+ }
486
+ if (
487
+ callee.type !== "MemberExpression" ||
488
+ callee.computed ||
489
+ callee.property.type !== "Identifier"
490
+ ) {
491
+ return null;
492
+ }
493
+ const method = callee.property.name;
494
+ if (
495
+ method === "redirect" &&
496
+ callee.object.type === "Identifier" &&
497
+ callee.object.name === "Response"
498
+ ) {
499
+ return call.arguments[0] === argument ? "Response.redirect(…)" : null;
500
+ }
501
+ if (
502
+ HEADER_WRITE_METHODS.has(method) &&
503
+ call.arguments[1] === argument &&
504
+ isStringLiteralMatching(call.arguments[0], LOCATION_HEADER_RE) &&
505
+ isHeadersReceiver(callee.object)
506
+ ) {
507
+ return `${method}("Location", …)`;
508
+ }
509
+ return null;
510
+ }
511
+
512
+ function isTransparentCall(call) {
513
+ const name = calleeIdentifierName(call.callee);
514
+ return name !== null && TRANSPARENT_CALLEES.has(name);
515
+ }
516
+
517
+ /**
518
+ * The object or array literal is a headers value: the `headers` property,
519
+ * a `new Headers(…)` argument, bound to a `*headers` name, or nested in
520
+ * such an array.
521
+ */
522
+ function isHeadersLiteral(literal) {
523
+ let child = literal;
524
+ let parent = literal.parent;
525
+ while (parent && TS_WRAPPER_TYPES.has(parent.type)) {
526
+ child = parent;
527
+ parent = parent.parent;
528
+ }
529
+ if (!parent) return false;
530
+ switch (parent.type) {
531
+ case "Property":
532
+ return (
533
+ parent.value === child && propertyKeyName(parent.key) === "headers"
534
+ );
535
+ case "NewExpression":
536
+ return (
537
+ calleeIdentifierName(parent.callee) === "Headers" &&
538
+ parent.arguments[0] === child
539
+ );
540
+ case "VariableDeclarator":
541
+ return (
542
+ parent.init === child &&
543
+ parent.id.type === "Identifier" &&
544
+ HEADERS_NAME_RE.test(parent.id.name)
545
+ );
546
+ case "AssignmentExpression":
547
+ return parent.right === child && isHeadersReceiver(parent.left);
548
+ case "ArrayExpression":
549
+ return isHeadersLiteral(parent);
550
+ default:
551
+ return false;
552
+ }
553
+ }
554
+
555
+ /** `headers`, `response.headers`, `new Headers()` or a binding to one. */
556
+ function isHeadersReceiver(node, visited = new Set()) {
557
+ node = unwrapExpression(node);
558
+ if (!node) return false;
559
+ if (node.type === "NewExpression") {
560
+ return calleeIdentifierName(node.callee) === "Headers";
561
+ }
562
+ if (node.type === "MemberExpression") {
563
+ return (
564
+ !node.computed &&
565
+ node.property.type === "Identifier" &&
566
+ HEADERS_NAME_RE.test(node.property.name)
567
+ );
568
+ }
569
+ if (node.type !== "Identifier") return false;
570
+ if (HEADERS_NAME_RE.test(node.name)) return true;
571
+ return initialisersOf(node, visited).some(
572
+ (entry) =>
573
+ entry.id.type === "Identifier" &&
574
+ isHeadersReceiver(entry.init, visited)
575
+ );
576
+ }
577
+
578
+ // ── Leaves ────────────────────────────────────────────────────────
579
+
580
+ /** `${x}` / `x + ""` / `"" + x` — the URL object coerced to a string. */
581
+ function isStringCoercionContext(node) {
582
+ const parent = node.parent;
583
+ if (!parent) return false;
584
+ if (parent.type === "TemplateLiteral") return true;
585
+ return parent.type === "BinaryExpression" && parent.operator === "+";
586
+ }
587
+
588
+ function objectLabel(object) {
589
+ object = unwrapExpression(object);
590
+ if (object.type === "Identifier") return object.name;
591
+ if (isUrlConstruction(object)) return constructionLabel(object);
592
+ if (
593
+ object.type === "MemberExpression" &&
594
+ !object.computed &&
595
+ object.property.type === "Identifier"
596
+ ) {
597
+ return `${objectLabel(object.object)}.${object.property.name}`;
598
+ }
599
+ return "url";
600
+ }
601
+
602
+ function constructionLabel(node) {
603
+ const callee = node.type === "NewExpression" ? "new URL" : "URL.parse";
604
+ const viaBase =
605
+ node.arguments[1] !== undefined &&
606
+ !isRequestUrlValue(node.arguments[0], new Set());
607
+ return viaBase ? `${callee}(…, request.url)` : `${callee}(request.url)`;
608
+ }
609
+
610
+ /** A bare URL / URLSearchParams object (a binding or an inline construction). */
611
+ function urlObjectKind(node) {
612
+ const inner = unwrapExpression(node);
613
+ if (inner.type !== "Identifier" && !isUrlConstruction(inner)) return null;
614
+ return resolveUrlKind(inner, new Set(), 0);
615
+ }
616
+
617
+ return {
618
+ ImportDeclaration(node) {
619
+ if (node.source.value !== "react-router") return;
620
+ for (const specifier of node.specifiers) {
621
+ if (
622
+ specifier.type === "ImportSpecifier" &&
623
+ specifier.imported.type === "Identifier" &&
624
+ REACT_ROUTER_REDIRECTORS.has(specifier.imported.name)
625
+ ) {
626
+ redirectorNames.add(specifier.local.name);
627
+ }
628
+ }
629
+ },
630
+
631
+ MemberExpression(node) {
632
+ if (isRequestUrlMember(node)) {
633
+ check(node, "request.url");
634
+ return;
635
+ }
636
+ if (node.computed || node.property.type !== "Identifier") return;
637
+ const property = node.property.name;
638
+
639
+ if (URL_ECHO_PROPERTIES.has(property)) {
640
+ if (resolveUrlKind(node.object, new Set(), 0) === "url") {
641
+ check(node, `${objectLabel(node.object)}.${property}`);
642
+ }
643
+ return;
644
+ }
645
+
646
+ if (URL_SERIALISERS.has(property)) {
647
+ const call = node.parent;
648
+ if (
649
+ call &&
650
+ call.type === "CallExpression" &&
651
+ call.callee === node &&
652
+ call.arguments.length === 0 &&
653
+ resolveUrlKind(node.object, new Set(), 0) !== null
654
+ ) {
655
+ check(call, `${objectLabel(node.object)}.${property}()`);
656
+ }
657
+ return;
658
+ }
659
+
660
+ if (
661
+ property === "searchParams" &&
662
+ isStringCoercionContext(node) &&
663
+ resolveUrlKind(node.object, new Set(), 0) === "url"
664
+ ) {
665
+ check(node, `${objectLabel(node.object)}.searchParams`);
666
+ }
667
+ },
668
+
669
+ CallExpression(node) {
670
+ // `String(url)` / `String(url.searchParams)`
671
+ if (
672
+ calleeIdentifierName(node.callee) === "String" &&
673
+ node.arguments.length === 1 &&
674
+ resolveUrlKind(node.arguments[0], new Set(), 0) !== null
675
+ ) {
676
+ check(node, `String(${objectLabel(node.arguments[0])})`);
677
+ return;
678
+ }
679
+ // The object itself handed to a sink: `Response.redirect(url)`.
680
+ for (const argument of node.arguments.slice(0, 2)) {
681
+ const sink = classifySinkCall(node, argument);
682
+ if (sink === null || urlObjectKind(argument) === null) continue;
683
+ const inner = unwrapExpression(argument);
684
+ report(inner, objectLabel(inner), sink);
685
+ }
686
+ },
687
+
688
+ TemplateLiteral(node) {
689
+ for (const expression of node.expressions) {
690
+ if (urlObjectKind(expression) === null) continue;
691
+ const inner = unwrapExpression(expression);
692
+ check(inner, `\${${objectLabel(inner)}}`);
693
+ }
694
+ },
695
+
696
+ BinaryExpression(node) {
697
+ if (node.operator !== "+") return;
698
+ for (const side of [node.left, node.right]) {
699
+ if (urlObjectKind(side) === null) continue;
700
+ const inner = unwrapExpression(side);
701
+ check(inner, `${objectLabel(inner)} + ""`);
702
+ }
703
+ },
704
+
705
+ VariableDeclarator(node) {
706
+ if (node.id.type !== "ObjectPattern" || node.init === null) return;
707
+ // `const { url } = request` — the request's URL bound to a local name.
708
+ if (isRequestBinding(node.init)) {
709
+ const property = patternProperty(node.id, "url");
710
+ if (property === null) return;
711
+ const sink = tracePattern(property.value, new Set(), 0);
712
+ if (sink !== null) report(property, "{ url } of request", sink);
713
+ return;
714
+ }
715
+ // `const { pathname, search } = new URL(request.url)` — each
716
+ // destructured echo property is a leaf bound to a local name.
717
+ if (resolveUrlKind(node.init, new Set(), 0) !== "url") return;
718
+ for (const property of node.id.properties) {
719
+ if (property.type !== "Property") continue;
720
+ const key = propertyKeyName(property.key);
721
+ if (key === null || !URL_ECHO_PROPERTIES.has(key)) continue;
722
+ const sink = tracePattern(property.value, new Set(), 0);
723
+ if (sink !== null) {
724
+ report(property, `{ ${key} } of new URL(request.url)`, sink);
725
+ }
726
+ }
727
+ }
728
+ };
729
+ }
730
+ };
731
+
732
+ function calleeIdentifierName(callee) {
733
+ callee = unwrapExpression(callee);
734
+ return callee && callee.type === "Identifier" ? callee.name : null;
735
+ }
736
+
737
+ /** The method name of a `URL.<method>(…)` call, or null. */
738
+ function urlStaticMethod(call) {
739
+ const callee = unwrapExpression(call.callee);
740
+ return callee &&
741
+ callee.type === "MemberExpression" &&
742
+ !callee.computed &&
743
+ callee.object.type === "Identifier" &&
744
+ callee.object.name === "URL" &&
745
+ callee.property.type === "Identifier"
746
+ ? callee.property.name
747
+ : null;
748
+ }
749
+
750
+ /**
751
+ * A string literal (or expressionless template) that is a relative reference
752
+ * — no leading `/`, no scheme. A dynamic value is not static and returns
753
+ * false: the caller owns it.
754
+ */
755
+ function isStaticRelativeReference(node) {
756
+ node = unwrapExpression(node);
757
+ let text = null;
758
+ if (node.type === "Literal" && typeof node.value === "string") {
759
+ text = node.value;
760
+ } else if (node.type === "TemplateLiteral") {
761
+ const head = node.quasis[0].value.cooked ?? "";
762
+ if (head === "" && node.expressions.length > 0) return false;
763
+ text = head;
764
+ }
765
+ return text !== null && !ABSOLUTE_REFERENCE_RE.test(text);
766
+ }
767
+
768
+ function isLocationTuple(array, valueNode) {
769
+ return (
770
+ array.elements.length === 2 &&
771
+ array.elements[1] === valueNode &&
772
+ isStringLiteralMatching(array.elements[0], LOCATION_HEADER_RE)
773
+ );
774
+ }
775
+
776
+ function propertyKeyName(key) {
777
+ if (key.type === "Identifier") return key.name;
778
+ if (key.type === "Literal" && typeof key.value === "string") {
779
+ return key.value;
780
+ }
781
+ return null;
782
+ }
783
+
784
+ function propertyKeyMatches(property, pattern) {
785
+ if (property.computed && property.key.type !== "Literal") return false;
786
+ const name = propertyKeyName(property.key);
787
+ return name !== null && pattern.test(name);
788
+ }
789
+
790
+ /** The pattern's property whose (possibly renamed) value binds `localName`. */
791
+ function patternPropertyKey(pattern, localName) {
792
+ const property = pattern.properties.find(
793
+ (p) =>
794
+ p.type === "Property" &&
795
+ p.value.type === "Identifier" &&
796
+ p.value.name === localName
797
+ );
798
+ return property ? propertyKeyName(property.key) : null;
799
+ }
800
+
801
+ /** The pattern's property destructuring `key`, or null. */
802
+ function patternProperty(pattern, key) {
803
+ return (
804
+ pattern.properties.find(
805
+ (p) => p.type === "Property" && propertyKeyName(p.key) === key
806
+ ) ?? null
807
+ );
808
+ }
809
+
810
+ function isStringLiteralMatching(node, pattern) {
811
+ if (!node) return false;
812
+ node = unwrapExpression(node);
813
+ if (node.type === "Literal" && typeof node.value === "string") {
814
+ return pattern.test(node.value);
815
+ }
816
+ return (
817
+ node.type === "TemplateLiteral" &&
818
+ node.expressions.length === 0 &&
819
+ node.quasis.length === 1 &&
820
+ pattern.test(node.quasis[0].value.cooked ?? "")
821
+ );
822
+ }
823
+
824
+ function collectPatternIdentifiers(pattern, out) {
825
+ if (!pattern) return;
826
+ switch (pattern.type) {
827
+ case "Identifier":
828
+ out.push(pattern);
829
+ return;
830
+ case "ObjectPattern":
831
+ for (const property of pattern.properties) {
832
+ collectPatternIdentifiers(
833
+ property.type === "Property" ? property.value : property.argument,
834
+ out
835
+ );
836
+ }
837
+ return;
838
+ case "ArrayPattern":
839
+ for (const element of pattern.elements) {
840
+ collectPatternIdentifiers(element, out);
841
+ }
842
+ return;
843
+ case "AssignmentPattern":
844
+ collectPatternIdentifiers(pattern.left, out);
845
+ return;
846
+ case "RestElement":
847
+ collectPatternIdentifiers(pattern.argument, out);
848
+ return;
849
+ default:
850
+ return;
851
+ }
852
+ }
853
+
854
+ export default noRawRequestUrlInRedirect;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/eslint-plugin",
3
- "version": "29.0.0",
3
+ "version": "31.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",