@onrails/eslint-plugin 0.1.3 → 0.2.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 +3 -0
- package/package.json +5 -4
- package/src/index.js +366 -0
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ export default [
|
|
|
21
21
|
"@onrails/no-promise-result": "warn",
|
|
22
22
|
"@onrails/no-unsafe-unwrap": "warn",
|
|
23
23
|
"@onrails/no-deprecated-synonyms": "warn",
|
|
24
|
+
"@onrails/fluent-stays-local": "warn",
|
|
24
25
|
},
|
|
25
26
|
},
|
|
26
27
|
];
|
|
@@ -48,6 +49,8 @@ export default [
|
|
|
48
49
|
| `@onrails/no-promise-result` | Flags `Promise<Result<…>>` in source — public APIs should return `ResultAsync` |
|
|
49
50
|
| `@onrails/no-unsafe-unwrap` | Flags `_unsafeUnwrap*` outside test files |
|
|
50
51
|
| `@onrails/no-deprecated-synonyms` | Flags deprecated synonyms (`chain`, `fold`, `getOrElse`, `of`, …) and suggests the canonical names |
|
|
52
|
+
| `@onrails/fluent-stays-local` | Flags `FluentResult`/`FluentMaybe` escaping as a return type, an exported binding, a stored field, or a serialize/postMessage/cache argument |
|
|
53
|
+
| `@onrails/carrier-sinks-need-from` | Flags `JSON.parse` / `structuredClone` / `.json()` output typed as `Result` / `Maybe` without `Result.from` / `Maybe.from` (plain data has no methods) |
|
|
51
54
|
|
|
52
55
|
## Caveats (v0)
|
|
53
56
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onrails/eslint-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "ESLint rules for @onrails/result boundaries — flags Promise<Result<…>> in public APIs and _unsafeUnwrap*. Replaces unmaintained eslint-plugin-neverthrow.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alan R. Soares",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"LICENSE"
|
|
42
42
|
],
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"eslint": "^10.
|
|
44
|
+
"eslint": "^10.8.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependenciesMeta": {
|
|
47
47
|
"@typescript-eslint/parser": {
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
"check": "bun run typecheck && bun test"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@typescript-eslint/parser": "^8.
|
|
58
|
-
"eslint": "^10.
|
|
57
|
+
"@typescript-eslint/parser": "^8.67.0",
|
|
58
|
+
"eslint": "^10.8.1",
|
|
59
|
+
"typescript": "^6.0.3"
|
|
59
60
|
}
|
|
60
61
|
}
|
package/src/index.js
CHANGED
|
@@ -200,6 +200,368 @@ const noDeprecatedSynonyms = {
|
|
|
200
200
|
},
|
|
201
201
|
};
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Fluent wrapper types and serialize-sink names. Hand-copied from
|
|
205
|
+
* `@onrails/codemod` `src/boundary-spec.ts` (cross-package imports are
|
|
206
|
+
* forbidden); `packages/codemod/test/boundary-conformance.spec.ts` keeps the
|
|
207
|
+
* copies in sync.
|
|
208
|
+
*/
|
|
209
|
+
const FLUENT_WRAPPER_TYPES = new Set(["FluentResult", "FluentMaybe"]);
|
|
210
|
+
const FLUENT_ESCAPE_SINKS = new Set(["postMessage", "structuredClone", "JSON.stringify"]);
|
|
211
|
+
/** Fluent methods that exit the wrapper — a chain ending here is already plain data. */
|
|
212
|
+
const FLUENT_TERMINAL_METHODS = new Set(["toResult", "toMaybe", "toString", "match", "unwrapOr"]);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Loose parent-chain node shape for structural classification — mirrors
|
|
216
|
+
* {@link TsTypeReference} for nodes outside estree's bundled types
|
|
217
|
+
* (`TSTypeAnnotation`, `PropertyDefinition`, `VariableDeclarator`, …).
|
|
218
|
+
*
|
|
219
|
+
* @typedef {object} LooseNode
|
|
220
|
+
* @property {string} type
|
|
221
|
+
* @property {string} [name]
|
|
222
|
+
* @property {LooseNode} [parent]
|
|
223
|
+
* @property {LooseNode} [returnType]
|
|
224
|
+
* @property {LooseNode} [object]
|
|
225
|
+
* @property {LooseNode} [property]
|
|
226
|
+
* @property {LooseNode} [callee]
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {unknown} node
|
|
231
|
+
* @returns {LooseNode}
|
|
232
|
+
*/
|
|
233
|
+
const asLoose = (node) => /** @type {LooseNode} */ (node);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {LooseNode} node
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
const calleeName = (node) => {
|
|
240
|
+
if (node.type === "Identifier") return node.name ?? "";
|
|
241
|
+
if (
|
|
242
|
+
node.type === "MemberExpression" &&
|
|
243
|
+
node.object?.type === "Identifier" &&
|
|
244
|
+
node.property?.type === "Identifier"
|
|
245
|
+
)
|
|
246
|
+
return `${node.object.name}.${node.property.name}`;
|
|
247
|
+
return "";
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* True when `node` still holds a fluent wrapper — a call chain rooted at
|
|
252
|
+
* `fluent(...)` that hasn't reached a terminal (`toResult`, `match`, …) yet.
|
|
253
|
+
* `fluent(r).toResult()` is plain data by the time it reaches a sink;
|
|
254
|
+
* `fluent(r)` and `fluent(r).map(f)` are not. Purely syntactic: a
|
|
255
|
+
* `fluent(...)` result stashed in a variable first is out of reach without
|
|
256
|
+
* type information.
|
|
257
|
+
*
|
|
258
|
+
* @param {unknown} node
|
|
259
|
+
* @returns {boolean}
|
|
260
|
+
*/
|
|
261
|
+
const isFluentChain = (node) => {
|
|
262
|
+
if (!node) return false;
|
|
263
|
+
const loose = asLoose(node);
|
|
264
|
+
if (loose.type !== "CallExpression" || !loose.callee) return false;
|
|
265
|
+
if (loose.callee.type === "Identifier") return loose.callee.name === "fluent";
|
|
266
|
+
if (loose.callee.type === "MemberExpression") {
|
|
267
|
+
const method = loose.callee.property?.name;
|
|
268
|
+
if (method && FLUENT_TERMINAL_METHODS.has(method)) return false;
|
|
269
|
+
return isFluentChain(loose.callee.object);
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
275
|
+
const fluentStaysLocal = {
|
|
276
|
+
meta: {
|
|
277
|
+
type: "problem",
|
|
278
|
+
docs: {
|
|
279
|
+
description:
|
|
280
|
+
"Disallow FluentResult/FluentMaybe escaping its function — as a return type, an exported binding, a stored field, or an argument to a serialize/postMessage/cache call.",
|
|
281
|
+
},
|
|
282
|
+
schema: [],
|
|
283
|
+
messages: {
|
|
284
|
+
returnType:
|
|
285
|
+
"{{name}} as a function return type lets the fluent wrapper escape — call toResult()/toMaybe()/toString() and return plain data instead.",
|
|
286
|
+
exported:
|
|
287
|
+
"{{name}} on an exported binding lets the fluent wrapper escape its module — call toResult()/toMaybe()/toString() before exporting.",
|
|
288
|
+
storedField:
|
|
289
|
+
"{{name}} on a stored field lets the fluent wrapper escape — call toResult()/toMaybe()/toString() before storing it.",
|
|
290
|
+
serializeArg:
|
|
291
|
+
"Passing a fluent(...) chain into {{sink}}() serializes/transfers a closure — call toResult()/toMaybe()/toString() first.",
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
create(context) {
|
|
295
|
+
return {
|
|
296
|
+
TSTypeReference(/** @type {import('eslint').Rule.Node} */ node) {
|
|
297
|
+
const typeRef = asTsTypeReference(node);
|
|
298
|
+
const typeName = typeRef.typeName;
|
|
299
|
+
if (typeName.type !== "Identifier" || !FLUENT_WRAPPER_TYPES.has(typeName.name ?? ""))
|
|
300
|
+
return;
|
|
301
|
+
const name = typeName.name;
|
|
302
|
+
|
|
303
|
+
const annotation = asLoose(node).parent;
|
|
304
|
+
if (annotation?.type !== "TSTypeAnnotation") return;
|
|
305
|
+
const owner = annotation.parent;
|
|
306
|
+
if (!owner) return;
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
[
|
|
310
|
+
"FunctionDeclaration",
|
|
311
|
+
"FunctionExpression",
|
|
312
|
+
"ArrowFunctionExpression",
|
|
313
|
+
"TSMethodSignature",
|
|
314
|
+
"TSDeclareFunction",
|
|
315
|
+
"TSFunctionType",
|
|
316
|
+
"TSCallSignatureDeclaration",
|
|
317
|
+
].includes(owner.type) &&
|
|
318
|
+
owner.returnType === annotation
|
|
319
|
+
) {
|
|
320
|
+
context.report({ node, messageId: "returnType", data: { name } });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A variable's type annotation hangs off its `id` pattern, e.g.
|
|
325
|
+
// `const chain: FluentResult<…>` attaches to the Identifier "chain",
|
|
326
|
+
// whose parent is the VariableDeclarator.
|
|
327
|
+
if (owner.type === "Identifier" && owner.parent?.type === "VariableDeclarator") {
|
|
328
|
+
if (owner.parent.parent?.parent?.type === "ExportNamedDeclaration") {
|
|
329
|
+
context.report({ node, messageId: "exported", data: { name } });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (owner.type === "PropertyDefinition" || owner.type === "TSPropertySignature") {
|
|
335
|
+
context.report({ node, messageId: "storedField", data: { name } });
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
CallExpression(node) {
|
|
339
|
+
const sink = calleeName(asLoose(node.callee));
|
|
340
|
+
if (!FLUENT_ESCAPE_SINKS.has(sink)) return;
|
|
341
|
+
for (const arg of node.arguments) {
|
|
342
|
+
if (isFluentChain(arg)) {
|
|
343
|
+
context.report({ node: arg, messageId: "serializeArg", data: { sink } });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Carrier types and deserialization sinks. Hand-copied from `@onrails/codemod`
|
|
353
|
+
* `src/boundary-spec.ts` (cross-package imports forbidden);
|
|
354
|
+
* `packages/codemod/test/boundary-conformance.spec.ts` keeps copies in sync.
|
|
355
|
+
*/
|
|
356
|
+
const CARRIER_TYPES = new Set(["Result", "Maybe"]);
|
|
357
|
+
const CARRIER_SINKS = new Set(["JSON.parse", "structuredClone"]);
|
|
358
|
+
const CARRIER_SINK_METHODS = new Set(["json"]);
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Loose expression shape for the sink walk (`await`, `as`, parens, calls).
|
|
362
|
+
*
|
|
363
|
+
* @typedef {object} LooseExpr
|
|
364
|
+
* @property {string} type
|
|
365
|
+
* @property {string} [name]
|
|
366
|
+
* @property {LooseExpr} [argument]
|
|
367
|
+
* @property {LooseExpr} [expression]
|
|
368
|
+
* @property {LooseExpr} [callee]
|
|
369
|
+
* @property {LooseExpr} [object]
|
|
370
|
+
* @property {LooseExpr} [property]
|
|
371
|
+
* @property {LooseExpr} [typeAnnotation]
|
|
372
|
+
* @property {LooseExpr} [typeName]
|
|
373
|
+
*/
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* True when the expression is a deserialization sink call, looking through
|
|
377
|
+
* `await` and `as` wrappers: `JSON.parse(x)`, `structuredClone(x)`, `res.json()`.
|
|
378
|
+
*
|
|
379
|
+
* @param {LooseExpr | null | undefined} node
|
|
380
|
+
* @returns {boolean}
|
|
381
|
+
*/
|
|
382
|
+
const isSinkCall = (node) => {
|
|
383
|
+
if (!node) return false;
|
|
384
|
+
if (node.type === "AwaitExpression") return isSinkCall(node.argument);
|
|
385
|
+
if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression") {
|
|
386
|
+
return isSinkCall(node.expression);
|
|
387
|
+
}
|
|
388
|
+
if (node.type !== "CallExpression" || !node.callee) return false;
|
|
389
|
+
const callee = node.callee;
|
|
390
|
+
if (callee.type === "MemberExpression" && callee.property?.name) {
|
|
391
|
+
if (CARRIER_SINK_METHODS.has(callee.property.name)) return true;
|
|
392
|
+
return CARRIER_SINKS.has(`${callee.object?.name}.${callee.property.name}`);
|
|
393
|
+
}
|
|
394
|
+
return callee.type === "Identifier" && CARRIER_SINKS.has(callee.name ?? "");
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @param {LooseExpr | null | undefined} typeNode
|
|
399
|
+
* @returns {boolean}
|
|
400
|
+
*/
|
|
401
|
+
const isCarrierType = (typeNode) =>
|
|
402
|
+
typeNode?.type === "TSTypeReference" &&
|
|
403
|
+
typeNode.typeName?.type === "Identifier" &&
|
|
404
|
+
CARRIER_TYPES.has(typeNode.typeName.name ?? "");
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* `Promise<Result<…>>` / `Promise<Maybe<…>>` or a bare carrier: what an `async` function annotated
|
|
408
|
+
* with a carrier return type looks like.
|
|
409
|
+
*
|
|
410
|
+
* @param {LooseExpr | null | undefined} typeNode
|
|
411
|
+
* @returns {boolean}
|
|
412
|
+
*/
|
|
413
|
+
const isCarrierReturn = (typeNode) => {
|
|
414
|
+
if (isCarrierType(typeNode)) return true;
|
|
415
|
+
if (typeNode?.type !== "TSTypeReference" || typeNode.typeName?.name !== "Promise") return false;
|
|
416
|
+
const args = /** @type {{ params?: LooseExpr[] }} */ (
|
|
417
|
+
/** @type {{ typeArguments?: unknown; typeParameters?: unknown }} */ (typeNode).typeArguments ??
|
|
418
|
+
/** @type {{ typeParameters?: unknown }} */ (typeNode).typeParameters
|
|
419
|
+
);
|
|
420
|
+
return isCarrierType(args?.params?.[0]);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The sink call, or an identifier that resolves to a `const` initialised from one:
|
|
425
|
+
* `const x = JSON.parse(s); return x;`.
|
|
426
|
+
*
|
|
427
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
428
|
+
* @param {LooseExpr | null | undefined} node
|
|
429
|
+
* @returns {boolean}
|
|
430
|
+
*/
|
|
431
|
+
const isSinkValue = (context, node) => {
|
|
432
|
+
if (isSinkCall(node)) return true;
|
|
433
|
+
if (node?.type !== "Identifier") return false;
|
|
434
|
+
for (
|
|
435
|
+
let scope = context.sourceCode.getScope(/** @type {never} */ (node));
|
|
436
|
+
scope;
|
|
437
|
+
scope = /** @type {typeof scope} */ (scope.upper)
|
|
438
|
+
) {
|
|
439
|
+
const variable = scope.set.get(node.name ?? "");
|
|
440
|
+
if (!variable) continue;
|
|
441
|
+
const def = variable.defs[0];
|
|
442
|
+
const declarator =
|
|
443
|
+
/** @type {{ id?: LooseExpr; init?: LooseExpr | null; parent?: { kind?: string } } | undefined} */ (
|
|
444
|
+
def?.node
|
|
445
|
+
);
|
|
446
|
+
return (
|
|
447
|
+
def?.type === "Variable" &&
|
|
448
|
+
declarator?.parent?.kind === "const" &&
|
|
449
|
+
declarator.id?.type === "Identifier" &&
|
|
450
|
+
isSinkCall(declarator.init)
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** `res.json<Result<…>>()`: the carrier is named through the call's type argument. */
|
|
457
|
+
const hasCarrierTypeArgument = (/** @type {LooseExpr} */ node) => {
|
|
458
|
+
const call =
|
|
459
|
+
/** @type {{ typeArguments?: { params?: LooseExpr[] }; typeParameters?: { params?: LooseExpr[] } }} */ (
|
|
460
|
+
/** @type {unknown} */ (node)
|
|
461
|
+
);
|
|
462
|
+
return isCarrierType((call.typeArguments ?? call.typeParameters)?.params?.[0]);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// an `as Result<…>` / `as Maybe<…>` expression is already reported by the TSAsExpression
|
|
466
|
+
// visitor; any other cast (`as any`) must not hide the carrier type around it
|
|
467
|
+
const isCarrierCast = (/** @type {LooseExpr | null | undefined} */ e) =>
|
|
468
|
+
e?.type === "TSAsExpression" && isCarrierType(e.typeAnnotation);
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Builds the check for a value flowing into a function's carrier-typed return.
|
|
472
|
+
*
|
|
473
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
474
|
+
* @param {(node: import('eslint').Rule.Node) => void} report
|
|
475
|
+
*/
|
|
476
|
+
const returnCheck =
|
|
477
|
+
(context, report) =>
|
|
478
|
+
/**
|
|
479
|
+
* @param {import('eslint').Rule.Node} fn
|
|
480
|
+
* @param {import('eslint').Rule.Node} at
|
|
481
|
+
* @param {LooseExpr | null | undefined} value
|
|
482
|
+
*/
|
|
483
|
+
(fn, at, value) => {
|
|
484
|
+
const returnType = /** @type {{ returnType?: { typeAnnotation?: LooseExpr } }} */ (
|
|
485
|
+
/** @type {unknown} */ (fn)
|
|
486
|
+
).returnType?.typeAnnotation;
|
|
487
|
+
if (isCarrierReturn(returnType) && !isCarrierCast(value) && isSinkValue(context, value)) {
|
|
488
|
+
report(at);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
493
|
+
const carrierSinksNeedFrom = {
|
|
494
|
+
meta: {
|
|
495
|
+
type: "problem",
|
|
496
|
+
docs: {
|
|
497
|
+
description:
|
|
498
|
+
"Disallow plain data from JSON.parse/structuredClone/.json() being typed as Result/Maybe without Result.from/Maybe.from.",
|
|
499
|
+
},
|
|
500
|
+
schema: [],
|
|
501
|
+
messages: {
|
|
502
|
+
needsFrom:
|
|
503
|
+
"Data from JSON.parse/structuredClone/.json() is typed as Result/Maybe but carries no methods — lift it with Result.from / Maybe.from.",
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
create(context) {
|
|
507
|
+
/** @param {import('eslint').Rule.Node} node */
|
|
508
|
+
const report = (node) => context.report({ node, messageId: "needsFrom" });
|
|
509
|
+
const checkReturn = returnCheck(context, report);
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
TSAsExpression(/** @type {import('eslint').Rule.Node} */ node) {
|
|
513
|
+
const expr = /** @type {LooseExpr} */ (/** @type {unknown} */ (node));
|
|
514
|
+
if (isCarrierType(expr.typeAnnotation) && isSinkValue(context, expr.expression)) {
|
|
515
|
+
report(node);
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
VariableDeclarator(/** @type {import('eslint').Rule.Node} */ node) {
|
|
519
|
+
const decl =
|
|
520
|
+
/** @type {{ id: { typeAnnotation?: { typeAnnotation: LooseExpr } }; init?: LooseExpr | null }} */ (
|
|
521
|
+
/** @type {unknown} */ (node)
|
|
522
|
+
);
|
|
523
|
+
if (isCarrierCast(decl.init)) return;
|
|
524
|
+
if (
|
|
525
|
+
isCarrierType(decl.id.typeAnnotation?.typeAnnotation) &&
|
|
526
|
+
isSinkValue(context, decl.init)
|
|
527
|
+
) {
|
|
528
|
+
report(node);
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
AssignmentPattern(/** @type {import('eslint').Rule.Node} */ node) {
|
|
532
|
+
const pattern =
|
|
533
|
+
/** @type {{ left: { typeAnnotation?: { typeAnnotation: LooseExpr } }; right: LooseExpr }} */ (
|
|
534
|
+
/** @type {unknown} */ (node)
|
|
535
|
+
);
|
|
536
|
+
if (isCarrierCast(pattern.right)) return;
|
|
537
|
+
if (
|
|
538
|
+
isCarrierType(pattern.left.typeAnnotation?.typeAnnotation) &&
|
|
539
|
+
isSinkValue(context, pattern.right)
|
|
540
|
+
) {
|
|
541
|
+
report(node);
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
ReturnStatement(/** @type {import('eslint').Rule.Node} */ node) {
|
|
545
|
+
const stmt = /** @type {{ argument?: LooseExpr | null }} */ (/** @type {unknown} */ (node));
|
|
546
|
+
const fn = context.sourceCode
|
|
547
|
+
.getAncestors(node)
|
|
548
|
+
.filter((a) => /Function/.test(a.type))
|
|
549
|
+
.pop();
|
|
550
|
+
// getAncestors is typed with estree nodes; every ancestor of a visited node is a rule node
|
|
551
|
+
if (fn) checkReturn(/** @type {import('eslint').Rule.Node} */ (fn), node, stmt.argument);
|
|
552
|
+
},
|
|
553
|
+
ArrowFunctionExpression(/** @type {import('eslint').Rule.Node} */ node) {
|
|
554
|
+
const arrow = /** @type {{ body: LooseExpr }} */ (/** @type {unknown} */ (node));
|
|
555
|
+
if (arrow.body.type !== "BlockStatement") checkReturn(node, node, arrow.body);
|
|
556
|
+
},
|
|
557
|
+
CallExpression(/** @type {import('eslint').Rule.Node} */ node) {
|
|
558
|
+
const call = /** @type {LooseExpr} */ (/** @type {unknown} */ (node));
|
|
559
|
+
if (isSinkCall(call) && hasCarrierTypeArgument(call)) report(node);
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
|
|
203
565
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
204
566
|
const plugin = {
|
|
205
567
|
meta: {
|
|
@@ -210,6 +572,8 @@ const plugin = {
|
|
|
210
572
|
"no-promise-result": noPromiseResult,
|
|
211
573
|
"no-unsafe-unwrap": noUnsafeUnwrap,
|
|
212
574
|
"no-deprecated-synonyms": noDeprecatedSynonyms,
|
|
575
|
+
"fluent-stays-local": fluentStaysLocal,
|
|
576
|
+
"carrier-sinks-need-from": carrierSinksNeedFrom,
|
|
213
577
|
},
|
|
214
578
|
};
|
|
215
579
|
|
|
@@ -224,6 +588,8 @@ export const configs = {
|
|
|
224
588
|
"@onrails/result/no-promise-result": "warn",
|
|
225
589
|
"@onrails/result/no-unsafe-unwrap": "warn",
|
|
226
590
|
"@onrails/result/no-deprecated-synonyms": "warn",
|
|
591
|
+
"@onrails/result/fluent-stays-local": "warn",
|
|
592
|
+
"@onrails/result/carrier-sinks-need-from": "warn",
|
|
227
593
|
},
|
|
228
594
|
},
|
|
229
595
|
};
|