@onrails/eslint-plugin 0.1.4 → 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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/package.json +5 -4
  3. package/src/index.js +216 -0
package/README.md CHANGED
@@ -50,6 +50,7 @@ export default [
50
50
  | `@onrails/no-unsafe-unwrap` | Flags `_unsafeUnwrap*` outside test files |
51
51
  | `@onrails/no-deprecated-synonyms` | Flags deprecated synonyms (`chain`, `fold`, `getOrElse`, `of`, …) and suggests the canonical names |
52
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) |
53
54
 
54
55
  ## Caveats (v0)
55
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onrails/eslint-plugin",
3
- "version": "0.1.4",
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.4.0"
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.60.0",
58
- "eslint": "^10.4.0"
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
@@ -348,6 +348,220 @@ const fluentStaysLocal = {
348
348
  },
349
349
  };
350
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
+
351
565
  /** @type {import('eslint').ESLint.Plugin} */
352
566
  const plugin = {
353
567
  meta: {
@@ -359,6 +573,7 @@ const plugin = {
359
573
  "no-unsafe-unwrap": noUnsafeUnwrap,
360
574
  "no-deprecated-synonyms": noDeprecatedSynonyms,
361
575
  "fluent-stays-local": fluentStaysLocal,
576
+ "carrier-sinks-need-from": carrierSinksNeedFrom,
362
577
  },
363
578
  };
364
579
 
@@ -374,6 +589,7 @@ export const configs = {
374
589
  "@onrails/result/no-unsafe-unwrap": "warn",
375
590
  "@onrails/result/no-deprecated-synonyms": "warn",
376
591
  "@onrails/result/fluent-stays-local": "warn",
592
+ "@onrails/result/carrier-sinks-need-from": "warn",
377
593
  },
378
594
  },
379
595
  };