@mui/internal-code-infra 0.0.4-canary.7 → 0.0.4-canary.71

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 (120) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +19 -8
  3. package/build/babel-config.d.mts +11 -3
  4. package/build/brokenLinksChecker/crawlWorker.d.mts +1 -0
  5. package/build/brokenLinksChecker/index.d.mts +45 -2
  6. package/build/changelog/types.d.ts +1 -1
  7. package/build/cli/cmdArgosPush.d.mts +2 -2
  8. package/build/cli/cmdBuild.d.mts +3 -2
  9. package/build/cli/cmdCopyFiles.d.mts +2 -2
  10. package/build/cli/cmdExtractErrorCodes.d.mts +2 -2
  11. package/build/cli/cmdGenerateChangelog.d.mts +2 -2
  12. package/build/cli/cmdGithubAuth.d.mts +2 -2
  13. package/build/cli/cmdListWorkspaces.d.mts +6 -4
  14. package/build/cli/cmdNetlifyIgnore.d.mts +3 -2
  15. package/build/cli/cmdPublish.d.mts +4 -2
  16. package/build/cli/cmdPublishCanary.d.mts +3 -3
  17. package/build/cli/cmdPublishNewPackage.d.mts +4 -2
  18. package/build/cli/cmdSetVersionOverrides.d.mts +2 -2
  19. package/build/cli/cmdVale.d.mts +46 -0
  20. package/build/cli/cmdValidateBuiltTypes.d.mts +2 -2
  21. package/build/eslint/baseConfig.d.mts +3 -1
  22. package/build/eslint/mui/rules/disallow-react-api-in-server-components.d.mts +2 -2
  23. package/build/eslint/mui/rules/docgen-ignore-before-comment.d.mts +2 -2
  24. package/build/eslint/mui/rules/no-floating-cleanup.d.mts +5 -0
  25. package/build/eslint/mui/rules/no-guarded-throw.d.mts +31 -0
  26. package/build/eslint/mui/rules/no-presentation-role.d.mts +5 -0
  27. package/build/eslint/mui/rules/no-restricted-resolved-imports.d.mts +2 -2
  28. package/build/eslint/mui/rules/nodeEnvUtils.d.mts +18 -0
  29. package/build/remark/config.d.mts +43 -0
  30. package/build/remark/createLintTester.d.mts +10 -0
  31. package/build/remark/firstBlockHeading.d.mts +4 -0
  32. package/build/remark/gitDiff.d.mts +2 -0
  33. package/build/remark/noSpaceInLinks.d.mts +2 -0
  34. package/build/remark/straightQuotes.d.mts +2 -0
  35. package/build/remark/tableAlignment.d.mts +2 -0
  36. package/build/remark/terminalLanguage.d.mts +2 -0
  37. package/build/utils/babel.d.mts +1 -1
  38. package/build/utils/build.d.mts +56 -37
  39. package/build/utils/github.d.mts +1 -1
  40. package/build/utils/pnpm.d.mts +81 -2
  41. package/build/utils/testUtils.d.mts +7 -0
  42. package/build/utils/typescript.d.mts +6 -16
  43. package/package.json +60 -38
  44. package/src/babel-config.mjs +9 -3
  45. package/src/brokenLinksChecker/__fixtures__/static-site/index.html +1 -0
  46. package/src/brokenLinksChecker/__fixtures__/static-site/invalid-html.html +15 -0
  47. package/src/brokenLinksChecker/crawlWorker.mjs +217 -0
  48. package/src/brokenLinksChecker/index.mjs +263 -188
  49. package/src/brokenLinksChecker/index.test.ts +53 -13
  50. package/src/changelog/categorizeCommits.test.ts +5 -5
  51. package/src/changelog/fetchChangelogs.mjs +6 -2
  52. package/src/changelog/parseCommitLabels.test.ts +5 -5
  53. package/src/changelog/renderChangelog.mjs +1 -1
  54. package/src/changelog/types.ts +1 -1
  55. package/src/cli/cmdBuild.mjs +51 -85
  56. package/src/cli/cmdListWorkspaces.mjs +12 -27
  57. package/src/cli/cmdNetlifyIgnore.mjs +34 -93
  58. package/src/cli/cmdPublish.mjs +55 -15
  59. package/src/cli/cmdPublishCanary.mjs +128 -132
  60. package/src/cli/cmdPublishNewPackage.mjs +27 -6
  61. package/src/cli/cmdSetVersionOverrides.mjs +8 -9
  62. package/src/cli/cmdVale.mjs +513 -0
  63. package/src/cli/cmdVale.test.mjs +644 -0
  64. package/src/cli/index.mjs +2 -0
  65. package/src/cli/packageJson.d.ts +1 -1
  66. package/src/eslint/baseConfig.mjs +51 -19
  67. package/src/eslint/docsConfig.mjs +2 -1
  68. package/src/eslint/mui/config.mjs +24 -4
  69. package/src/eslint/mui/index.mjs +6 -0
  70. package/src/eslint/mui/rules/no-floating-cleanup.mjs +187 -0
  71. package/src/eslint/mui/rules/no-floating-cleanup.test.mjs +141 -0
  72. package/src/eslint/mui/rules/no-guarded-throw.mjs +115 -0
  73. package/src/eslint/mui/rules/no-guarded-throw.test.mjs +206 -0
  74. package/src/eslint/mui/rules/no-presentation-role.mjs +60 -0
  75. package/src/eslint/mui/rules/no-presentation-role.test.mjs +33 -0
  76. package/src/eslint/mui/rules/nodeEnvUtils.mjs +52 -0
  77. package/src/eslint/mui/rules/require-dev-wrapper.mjs +25 -40
  78. package/src/estree-typescript.d.ts +1 -1
  79. package/src/remark/config.mjs +157 -0
  80. package/src/remark/createLintTester.mjs +19 -0
  81. package/src/remark/firstBlockHeading.mjs +87 -0
  82. package/src/remark/firstBlockHeading.test.mjs +107 -0
  83. package/src/remark/gitDiff.mjs +43 -0
  84. package/src/remark/gitDiff.test.mjs +45 -0
  85. package/src/remark/noSpaceInLinks.mjs +42 -0
  86. package/src/remark/noSpaceInLinks.test.mjs +22 -0
  87. package/src/remark/straightQuotes.mjs +31 -0
  88. package/src/remark/straightQuotes.test.mjs +25 -0
  89. package/src/remark/tableAlignment.mjs +23 -0
  90. package/src/remark/tableAlignment.test.mjs +28 -0
  91. package/src/remark/terminalLanguage.mjs +19 -0
  92. package/src/remark/terminalLanguage.test.mjs +17 -0
  93. package/src/untyped-plugins.d.ts +11 -11
  94. package/src/utils/build.mjs +604 -270
  95. package/src/utils/build.test.mjs +1079 -521
  96. package/src/utils/git.mjs +16 -7
  97. package/src/utils/pnpm.mjs +255 -3
  98. package/src/utils/pnpm.test.mjs +731 -0
  99. package/src/utils/testUtils.mjs +18 -0
  100. package/src/utils/typescript.mjs +23 -42
  101. package/src/utils/typescript.test.mjs +234 -273
  102. package/vale/.vale.ini +1 -0
  103. package/vale/styles/MUI/CorrectReferenceAllCases.yml +43 -0
  104. package/vale/styles/MUI/CorrectRererenceCased.yml +14 -0
  105. package/vale/styles/MUI/GoogleLatin.yml +11 -0
  106. package/vale/styles/MUI/MuiBrandName.yml +22 -0
  107. package/vale/styles/MUI/NoBritish.yml +112 -0
  108. package/vale/styles/MUI/NoCompanyName.yml +17 -0
  109. package/build/markdownlint/duplicate-h1.d.mts +0 -56
  110. package/build/markdownlint/git-diff.d.mts +0 -11
  111. package/build/markdownlint/index.d.mts +0 -52
  112. package/build/markdownlint/straight-quotes.d.mts +0 -11
  113. package/build/markdownlint/table-alignment.d.mts +0 -11
  114. package/build/markdownlint/terminal-language.d.mts +0 -11
  115. package/src/markdownlint/duplicate-h1.mjs +0 -69
  116. package/src/markdownlint/git-diff.mjs +0 -31
  117. package/src/markdownlint/index.mjs +0 -62
  118. package/src/markdownlint/straight-quotes.mjs +0 -26
  119. package/src/markdownlint/table-alignment.mjs +0 -42
  120. package/src/markdownlint/terminal-language.mjs +0 -19
@@ -6,6 +6,7 @@ import compatPlugin from 'eslint-plugin-compat';
6
6
  import importPlugin from 'eslint-plugin-import';
7
7
  import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
8
8
  import reactPlugin from 'eslint-plugin-react';
9
+ import * as mdx from 'eslint-plugin-mdx';
9
10
  import { configs as reactCompilerPluginConfigs } from 'eslint-plugin-react-compiler';
10
11
  import reactHooks from 'eslint-plugin-react-hooks';
11
12
  import globals from 'globals';
@@ -34,19 +35,60 @@ function includeIgnoreIfExists(filePath, description) {
34
35
  * @param {boolean} [params.consistentTypeImports] - Whether to enforce consistent type imports.
35
36
  * @param {boolean} [params.materialUi] - Whether to enable Material UI specific rules (mui/material-ui-*).
36
37
  * @param {string} [params.baseDirectory] - The base directory for the configuration.
38
+ * @param {boolean} [params.markdown] - @deprecated Markdown/MDX linting is enabled by default; this option no longer needs to be passed. To skip markdown linting, use eslint ignore patterns for the relevant files.
37
39
  * @returns {import('eslint').Linter.Config[]}
38
40
  */
39
41
  export function createBaseConfig({
40
42
  enableReactCompiler = false,
41
43
  consistentTypeImports = false,
42
44
  materialUi = false,
45
+ markdown = true,
43
46
  baseDirectory = process.cwd(),
44
47
  } = {}) {
45
48
  return defineConfig([
49
+ {
50
+ name: 'settings',
51
+ languageOptions: {
52
+ ecmaVersion: 7,
53
+ globals: {
54
+ ...globals.es2020,
55
+ ...globals.browser,
56
+ ...globals.node,
57
+ },
58
+ },
59
+ plugins: {
60
+ mui: muiPlugin,
61
+ },
62
+ settings: {
63
+ react: {
64
+ version: 'detect',
65
+ },
66
+ browserslistOpts: {
67
+ config: path.join(baseDirectory, '.browserslistrc'),
68
+ env: 'stable',
69
+ ignoreUnknownVersions: true,
70
+ },
71
+ },
72
+ },
46
73
  includeIgnoreIfExists(path.join(baseDirectory, '.gitignore'), `Ignore rules from .gitignore`),
47
74
  includeIgnoreIfExists(path.join(baseDirectory, '.lintignore'), `Ignore rules from .lintignore`),
48
75
  createJsonConfig(),
49
76
  prettier,
77
+ // Markdown + MDX linting via eslint-plugin-mdx. Severities for markdown
78
+ // quality checks live in the project's `.remarkrc` (see
79
+ // `@mui/internal-code-infra/remark`), not here.
80
+ markdown
81
+ ? [
82
+ {
83
+ ...mdx.flat,
84
+ rules: {
85
+ ...mdx.flat.rules,
86
+ 'mdx/remark': 'error',
87
+ },
88
+ },
89
+ mdx.flatCodeBlocks,
90
+ ]
91
+ : [],
50
92
  {
51
93
  name: 'Base config',
52
94
  files: [`**/*${EXTENSION_TS}`],
@@ -62,25 +104,7 @@ export function createBaseConfig({
62
104
  enableReactCompiler ? reactCompilerPluginConfigs.recommended : {},
63
105
  compatPlugin.configs['flat/recommended'],
64
106
  {
65
- name: 'typescript-eslint-parser',
66
- languageOptions: {
67
- ecmaVersion: 7,
68
- globals: {
69
- ...globals.es2020,
70
- ...globals.browser,
71
- ...globals.node,
72
- },
73
- },
74
- plugins: {
75
- mui: muiPlugin,
76
- },
77
- settings: {
78
- browserslistOpts: {
79
- config: path.join(baseDirectory, '.browserslistrc'),
80
- env: 'stable',
81
- ignoreUnknownVersions: true,
82
- },
83
- },
107
+ name: 'core',
84
108
  extends: createCoreConfig({ enableReactCompiler, consistentTypeImports, materialUi }),
85
109
  },
86
110
  // Lint rule to disallow usage of typescript namespaces.We've seen at least two problems with them:
@@ -116,6 +140,14 @@ export function createBaseConfig({
116
140
  'no-unassigned-vars': 'off',
117
141
  },
118
142
  },
143
+ // @TODO: Remove this once @typescript-eslint/no-shadow supports wrapped functions
144
+ // See https://github.com/eslint/eslint/pull/20982 (once merged also needs port to `typescript-eslint`)
145
+ {
146
+ name: 'Disabled tseslint-plugins',
147
+ rules: {
148
+ '@typescript-eslint/no-shadow': 'off',
149
+ },
150
+ },
119
151
  ]),
120
152
  },
121
153
  {
@@ -1,5 +1,6 @@
1
1
  import nextjs from '@next/eslint-plugin-next';
2
2
  import { defineConfig } from 'eslint/config';
3
+ import { EXTENSION_TS } from './extensions.mjs';
3
4
 
4
5
  /**
5
6
  * @returns {import('eslint').Linter.Config[]}
@@ -24,7 +25,7 @@ export function createDocsConfig() {
24
25
  rootDir: 'docs',
25
26
  },
26
27
  },
27
- files: ['**/*.js', '**/*.mjs', '**/*.jsx', '**/*.ts', '**/*.tsx'],
28
+ files: [`**/*${EXTENSION_TS}`],
28
29
  rules: {
29
30
  'compat/compat': 'off',
30
31
  'jsx-a11y/anchor-is-valid': 'off',
@@ -1,4 +1,5 @@
1
1
  import { defineConfig } from 'eslint/config';
2
+ import { EXTENSION_DTS } from '../extensions.mjs';
2
3
 
3
4
  const restrictedMethods = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'];
4
5
 
@@ -414,10 +415,12 @@ export function createCoreConfig(options = {}) {
414
415
  'mui/material-ui-no-styled-box': 'error',
415
416
  }
416
417
  : {}),
418
+ 'mui/no-guarded-throw': 'error',
417
419
  'mui/straight-quotes': 'off',
418
420
  'mui/consistent-production-guard': 'error',
419
421
  'mui/add-undef-to-optional': 'off',
420
422
  'mui/flatten-parentheses': 'warn',
423
+ 'mui/no-presentation-role': 'off',
421
424
 
422
425
  'react-hooks/exhaustive-deps': [
423
426
  'error',
@@ -460,9 +463,9 @@ export function createCoreConfig(options = {}) {
460
463
  'react/state-in-constructor': 'off',
461
464
  // stylistic opinion. For conditional assignment we want it outside, otherwise as static
462
465
  'react/static-property-placement': 'off',
463
- // noopener is enough
464
- // https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-target-blank.md#rule-options
465
- 'react/jsx-no-target-blank': ['error', { allowReferrer: true }],
466
+ // This rule is outdated, rel are no longer needed for security on target="_blank" links.
467
+ // See https://github.com/mui/material-ui/pull/40447 for more details.
468
+ 'react/jsx-no-target-blank': 'off',
466
469
 
467
470
  'no-restricted-syntax': [
468
471
  'error',
@@ -499,6 +502,15 @@ export function createCoreConfig(options = {}) {
499
502
  message: 'Do not call `Error(...)` without `new`. Use `new Error(...)` instead.',
500
503
  selector: "CallExpression[callee.name='Error']",
501
504
  },
505
+ {
506
+ // xmlns="http://www.w3.org/2000/svg" is only needed on standalone .svg files so the
507
+ // browser treats them as SVG instead of generic XML. Inside HTML the <svg> element is
508
+ // already recognised by the browser, so the attribute is dead weight.
509
+ // https://github.com/mui/mui-public/pull/1321
510
+ message:
511
+ 'Remove xmlns from inline <svg>. The attribute is redundant in HTML and adds unnecessary bytes.',
512
+ selector: 'JSXOpeningElement[name.name="svg"] > JSXAttribute[name.name="xmlns"]',
513
+ },
502
514
  ...restrictedSyntaxRules,
503
515
  ],
504
516
 
@@ -515,9 +527,10 @@ export function createCoreConfig(options = {}) {
515
527
  '@typescript-eslint/consistent-type-imports': [
516
528
  'error',
517
529
  {
518
- fixStyle: 'inline-type-imports',
530
+ fixStyle: 'separate-type-imports',
519
531
  },
520
532
  ],
533
+ 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
521
534
  }
522
535
  : {}),
523
536
  // Prevent the use of `e` as a shorthand for `event`, `error`, etc.
@@ -525,5 +538,12 @@ export function createCoreConfig(options = {}) {
525
538
  '@typescript-eslint/return-await': 'off',
526
539
  },
527
540
  },
541
+ {
542
+ name: 'mui-base/dts',
543
+ files: [`**/*${EXTENSION_DTS}`],
544
+ rules: {
545
+ '@typescript-eslint/consistent-type-imports': 'off',
546
+ },
547
+ },
528
548
  ]);
529
549
  }
@@ -4,6 +4,7 @@ import disallowReactApiInServerComponents from './rules/disallow-react-api-in-se
4
4
  import docgenIgnoreBeforeComment from './rules/docgen-ignore-before-comment.mjs';
5
5
  import muiNameMatchesComponentName from './rules/mui-name-matches-component-name.mjs';
6
6
  import noEmptyBox from './rules/no-empty-box.mjs';
7
+ import noGuardedThrow from './rules/no-guarded-throw.mjs';
7
8
  import noRestrictedResolvedImports from './rules/no-restricted-resolved-imports.mjs';
8
9
  import noStyledBox from './rules/no-styled-box.mjs';
9
10
  import requireDevWrapper from './rules/require-dev-wrapper.mjs';
@@ -11,6 +12,8 @@ import rulesOfUseThemeVariants from './rules/rules-of-use-theme-variants.mjs';
11
12
  import straightQuotes from './rules/straight-quotes.mjs';
12
13
  import addUndefToOptional from './rules/add-undef-to-optional.mjs';
13
14
  import flattenParentheses from './rules/flatten-parentheses.mjs';
15
+ import noPresentationRole from './rules/no-presentation-role.mjs';
16
+ import noFloatingCleanup from './rules/no-floating-cleanup.mjs';
14
17
 
15
18
  /** @type {import('eslint').ESLint.Plugin} */
16
19
  const muiPlugin = {
@@ -22,6 +25,7 @@ const muiPlugin = {
22
25
  'consistent-production-guard': consistentProductionGuard,
23
26
  'disallow-active-element-as-key-event-target': disallowActiveElementAsKeyEventTarget,
24
27
  'docgen-ignore-before-comment': docgenIgnoreBeforeComment,
28
+ 'no-guarded-throw': noGuardedThrow,
25
29
  'material-ui-name-matches-component-name': muiNameMatchesComponentName,
26
30
  'material-ui-rules-of-use-theme-variants': rulesOfUseThemeVariants,
27
31
  'material-ui-no-empty-box': noEmptyBox,
@@ -33,6 +37,8 @@ const muiPlugin = {
33
37
  // Some discrepancies between TypeScript and ESLint types - casting to any
34
38
  'add-undef-to-optional': /** @type {any} */ (addUndefToOptional),
35
39
  'flatten-parentheses': /** @type {any} */ (flattenParentheses),
40
+ 'no-presentation-role': noPresentationRole,
41
+ 'no-floating-cleanup': /** @type {any} */ (noFloatingCleanup),
36
42
  },
37
43
  };
38
44
 
@@ -0,0 +1,187 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ import ts from 'typescript';
3
+
4
+ /**
5
+ * no-floating-cleanup
6
+ *
7
+ * Like `@typescript-eslint/no-floating-promises`, but for functions: reports
8
+ * call expressions used as statements whose return value is a function
9
+ * (e.g. an `unsubscribe` / cleanup callback). Ignoring such a value usually
10
+ * means the subscription can never be torn down — a leak.
11
+ *
12
+ * Requires type information (parserOptions.project / projectService).
13
+ *
14
+ * Calls whose resolved signature declares a `this` return type are never
15
+ * reported: fluent / builder APIs (e.g. d3 scales) chain by returning `this`,
16
+ * so the discarded result is callable but not a leaked cleanup function.
17
+ *
18
+ * Opt-out at a call site, same convention as no-floating-promises:
19
+ * void subscribe(cb);
20
+ */
21
+
22
+ const RULE_NAME = 'no-floating-cleanup';
23
+
24
+ const createRule = ESLintUtils.RuleCreator(
25
+ (name) =>
26
+ `https://github.com/mui/mui-public/blob/master/packages/code-infra/src/eslint/mui/rules/${name}.mjs`,
27
+ );
28
+
29
+ export default createRule({
30
+ meta: {
31
+ type: 'problem',
32
+ docs: {
33
+ description:
34
+ 'Disallow ignoring returned cleanup/unsubscribe functions, which leaks subscriptions.',
35
+ },
36
+ messages: {
37
+ floatingCleanup:
38
+ "Return value of type '{{typeName}}' is ignored. This likely leaks a subscription — " +
39
+ 'store the cleanup function, or discard it explicitly with the `void` operator.',
40
+ },
41
+ schema: [],
42
+ },
43
+ name: RULE_NAME,
44
+ defaultOptions: [],
45
+ create(context) {
46
+ const services = ESLintUtils.getParserServices(context);
47
+ const checker = services.program.getTypeChecker();
48
+
49
+ /**
50
+ * Does this type (or any union member) look like a cleanup function?
51
+ * @param {import('typescript').Type} type
52
+ * @returns {boolean}
53
+ */
54
+ function isCleanupType(type) {
55
+ if (type.isUnion()) {
56
+ return type.types.some(isCleanupType);
57
+ }
58
+ // Never fire on any/unknown/never — too noisy and not provable.
59
+ // eslint-disable-next-line no-bitwise -- TypeScript type flags are a bitmask
60
+ if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) {
61
+ return false;
62
+ }
63
+
64
+ return checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0;
65
+ }
66
+
67
+ /**
68
+ * @param {import('typescript').Type} type
69
+ * @returns {string}
70
+ */
71
+ function describeType(type) {
72
+ return (type.aliasSymbol && type.aliasSymbol.getName()) || checker.typeToString(type);
73
+ }
74
+
75
+ /**
76
+ * Does this call resolve to a signature that returns `this`? Fluent /
77
+ * builder APIs (e.g. d3 scales) chain by returning `this`, so the
78
+ * discarded result is callable but never a leaked cleanup function —
79
+ * those calls should not be reported. Covers both an explicit `: this`
80
+ * annotation and an inferred `this` return (no annotation).
81
+ * @param {import('@typescript-eslint/utils').TSESTree.Node} estreeCall
82
+ * @returns {boolean}
83
+ */
84
+ function returnsThis(estreeCall) {
85
+ /** @type {import('typescript').Node | undefined} */
86
+ let tsNode = services.esTreeNodeToTSNodeMap.get(estreeCall);
87
+ // Unwrap `await (...)` / parens down to the underlying call.
88
+ while (tsNode && (ts.isAwaitExpression(tsNode) || ts.isParenthesizedExpression(tsNode))) {
89
+ tsNode = tsNode.expression;
90
+ }
91
+ if (!tsNode || !(ts.isCallExpression(tsNode) || ts.isNewExpression(tsNode))) {
92
+ return false;
93
+ }
94
+ const declaration = checker.getResolvedSignature(tsNode)?.getDeclaration();
95
+ if (!declaration) {
96
+ return false;
97
+ }
98
+ // Explicit `: this` annotation.
99
+ if (declaration.type?.kind === ts.SyntaxKind.ThisType) {
100
+ return true;
101
+ }
102
+ // Inferred `this` return: the *declared* (uninstantiated) signature's
103
+ // return type is the polymorphic `this` type, which prints as `this`.
104
+ // (The resolved signature would have it bound to the receiver type.)
105
+ const declaredSignature = checker.getSignatureFromDeclaration(declaration);
106
+ return (
107
+ !!declaredSignature &&
108
+ checker.typeToString(checker.getReturnTypeOfSignature(declaredSignature)) === 'this'
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Unwrap an expression-statement expression down to the calls whose
114
+ * results are being discarded. Mirrors the cases no-floating-promises
115
+ * handles: plain calls, optional chains, `await`, comma sequences,
116
+ * and the operand(s) of unary / logical / conditional expressions.
117
+ * A top-level `void` operator is the explicit opt-out, so we stop there.
118
+ * @param {import('@typescript-eslint/utils').TSESTree.Node} expr
119
+ * @param {import('@typescript-eslint/utils').TSESTree.Node[]} [out]
120
+ * @returns {import('@typescript-eslint/utils').TSESTree.Node[]}
121
+ */
122
+ function findDiscardedCalls(expr, out = []) {
123
+ switch (expr.type) {
124
+ case 'CallExpression':
125
+ case 'NewExpression':
126
+ out.push(expr);
127
+ break;
128
+ case 'ChainExpression':
129
+ findDiscardedCalls(expr.expression, out);
130
+ break;
131
+ case 'AwaitExpression':
132
+ // type of the AwaitExpression node is already the awaited type
133
+ if (expr.argument.type === 'CallExpression' || expr.argument.type === 'ChainExpression') {
134
+ out.push(expr);
135
+ }
136
+ break;
137
+ case 'UnaryExpression':
138
+ // `void expr` is the explicit opt-out; any other unary operator
139
+ // (e.g. `!subscribe()`) still discards the operand's result.
140
+ if (expr.operator !== 'void') {
141
+ findDiscardedCalls(expr.argument, out);
142
+ }
143
+ break;
144
+ case 'LogicalExpression':
145
+ // `a && subscribe()`, `a || subscribe()`, `a ?? subscribe()`:
146
+ // either side may be the discarded value.
147
+ findDiscardedCalls(expr.left, out);
148
+ findDiscardedCalls(expr.right, out);
149
+ break;
150
+ case 'ConditionalExpression':
151
+ // `cond ? subscribe() : other()`: the branches are discarded,
152
+ // not the test.
153
+ findDiscardedCalls(expr.consequent, out);
154
+ findDiscardedCalls(expr.alternate, out);
155
+ break;
156
+ case 'SequenceExpression':
157
+ // every value except the last is discarded; the last one is the
158
+ // statement's value, which is also discarded here
159
+ expr.expressions.forEach((subExpr) => findDiscardedCalls(subExpr, out));
160
+ break;
161
+ default:
162
+ break;
163
+ }
164
+ return out;
165
+ }
166
+
167
+ return {
168
+ /** @param {import('@typescript-eslint/utils').TSESTree.ExpressionStatement} node */
169
+ ExpressionStatement(node) {
170
+ findDiscardedCalls(node.expression).forEach((call) => {
171
+ // Fluent/builder calls return `this` for chaining — not a leak.
172
+ if (returnsThis(call)) {
173
+ return;
174
+ }
175
+ const type = services.getTypeAtLocation(call);
176
+ if (isCleanupType(type)) {
177
+ context.report({
178
+ node: call,
179
+ messageId: 'floatingCleanup',
180
+ data: { typeName: describeType(type) },
181
+ });
182
+ }
183
+ });
184
+ },
185
+ };
186
+ },
187
+ });
@@ -0,0 +1,141 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import TSESlintParser from '@typescript-eslint/parser';
3
+ import rule from './no-floating-cleanup.mjs';
4
+
5
+ const ruleTester = new RuleTester({
6
+ languageOptions: {
7
+ parser: TSESlintParser,
8
+ parserOptions: {
9
+ tsconfigRootDir: import.meta.dirname,
10
+ projectService: {
11
+ allowDefaultProject: ['*.ts'],
12
+ },
13
+ },
14
+ },
15
+ });
16
+
17
+ ruleTester.run('no-floating-cleanup', rule, {
18
+ valid: [
19
+ {
20
+ name: 'stored cleanup function',
21
+ code: `
22
+ type Unsubscribe = () => void;
23
+ declare function subscribe(cb: () => void): Unsubscribe;
24
+ function f() {
25
+ const un = subscribe(() => {});
26
+ un();
27
+ }
28
+ `,
29
+ },
30
+ {
31
+ name: 'explicit void opt-out',
32
+ code: `
33
+ type Unsubscribe = () => void;
34
+ declare function subscribe(cb: () => void): Unsubscribe;
35
+ function f() {
36
+ void subscribe(() => {});
37
+ }
38
+ `,
39
+ },
40
+ {
41
+ name: 'call returning void',
42
+ code: `
43
+ declare function noop(): void;
44
+ function f() {
45
+ noop();
46
+ }
47
+ `,
48
+ },
49
+ {
50
+ name: 'fluent builder methods returning `this`',
51
+ code: `
52
+ interface Scale {
53
+ (value: number): number;
54
+ domain(d: number[]): this;
55
+ range(r: number[]): this;
56
+ }
57
+ declare const scale: Scale;
58
+ function f() {
59
+ scale.domain([0, 1]);
60
+ scale.range([0, 100]);
61
+ }
62
+ `,
63
+ },
64
+ {
65
+ name: 'fluent builder method with an inferred `this` return',
66
+ code: `
67
+ class Builder {
68
+ configure() {
69
+ return this;
70
+ }
71
+ }
72
+ declare const builder: Builder;
73
+ function f() {
74
+ builder.configure();
75
+ }
76
+ `,
77
+ },
78
+ ],
79
+ invalid: [
80
+ {
81
+ name: 'discarded unsubscribe function',
82
+ code: `
83
+ type Unsubscribe = () => void;
84
+ declare function subscribe(cb: () => void): Unsubscribe;
85
+ function f() {
86
+ subscribe(() => {});
87
+ }
88
+ `,
89
+ errors: [{ messageId: 'floatingCleanup', line: 5 }],
90
+ },
91
+ {
92
+ name: 'call returning a fresh callable (not `this`)',
93
+ code: `
94
+ interface Scale {
95
+ (value: number): number;
96
+ copy(): Scale;
97
+ }
98
+ declare const scale: Scale;
99
+ function f() {
100
+ scale.copy();
101
+ }
102
+ `,
103
+ errors: [{ messageId: 'floatingCleanup', line: 8 }],
104
+ },
105
+ {
106
+ name: 'discarded cleanup behind a unary operator',
107
+ code: `
108
+ type Unsubscribe = () => void;
109
+ declare function subscribe(cb: () => void): Unsubscribe;
110
+ function f() {
111
+ !subscribe(() => {});
112
+ }
113
+ `,
114
+ errors: [{ messageId: 'floatingCleanup', line: 5 }],
115
+ },
116
+ {
117
+ name: 'discarded cleanup in a logical expression',
118
+ code: `
119
+ type Unsubscribe = () => void;
120
+ declare function subscribe(cb: () => void): Unsubscribe;
121
+ declare const enabled: boolean;
122
+ function f() {
123
+ enabled && subscribe(() => {});
124
+ }
125
+ `,
126
+ errors: [{ messageId: 'floatingCleanup', line: 6 }],
127
+ },
128
+ {
129
+ name: 'discarded cleanup in a conditional expression',
130
+ code: `
131
+ type Unsubscribe = () => void;
132
+ declare function subscribe(cb: () => void): Unsubscribe;
133
+ declare const enabled: boolean;
134
+ function f() {
135
+ enabled ? subscribe(() => {}) : undefined;
136
+ }
137
+ `,
138
+ errors: [{ messageId: 'floatingCleanup', line: 6 }],
139
+ },
140
+ ],
141
+ });
@@ -0,0 +1,115 @@
1
+ import { isProcessEnvNodeEnv } from './nodeEnvUtils.mjs';
2
+
3
+ /**
4
+ * Recursively checks if process.env.NODE_ENV appears anywhere in the node tree
5
+ * @param {import('estree').Node | null | undefined} node
6
+ * @returns {boolean}
7
+ */
8
+ function containsProcessEnvNodeEnv(node) {
9
+ if (!node || typeof node !== 'object') {
10
+ return false;
11
+ }
12
+
13
+ if (isProcessEnvNodeEnv(node)) {
14
+ return true;
15
+ }
16
+
17
+ // Traverse all child nodes, skipping parent references to avoid circular traversal
18
+ for (const key of Object.keys(node)) {
19
+ if (key === 'parent') {
20
+ continue;
21
+ }
22
+ const child = /** @type {unknown} */ (/** @type {any} */ (node)[key]);
23
+ if (Array.isArray(child)) {
24
+ if (child.some(containsProcessEnvNodeEnv)) {
25
+ return true;
26
+ }
27
+ } else if (
28
+ child &&
29
+ typeof child === 'object' &&
30
+ /** @type {import('estree').Node} */ (child).type
31
+ ) {
32
+ if (containsProcessEnvNodeEnv(/** @type {import('estree').Node} */ (child))) {
33
+ return true;
34
+ }
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ /**
41
+ * ESLint rule that disallows throw statements guarded by process.env.NODE_ENV checks.
42
+ *
43
+ * NODE_ENV guards cause throw statements to only execute in certain environments,
44
+ * leading to inconsistent control flow between development and production. Whether
45
+ * the guard excludes production (tree-shaking the throw away) or targets production
46
+ * specifically, the result is environment-dependent behavior that should be avoided.
47
+ *
48
+ * The rule stops at function boundaries, so throws inside functions defined within
49
+ * a NODE_ENV guard are not flagged, as the function may be called from other contexts.
50
+ *
51
+ * @example
52
+ * // Invalid - throw only in development, removed in production
53
+ * if (process.env.NODE_ENV !== 'production') {
54
+ * throw new Error('Missing required prop');
55
+ * }
56
+ *
57
+ * @example
58
+ * // Invalid - throw only in production
59
+ * if (process.env.NODE_ENV === 'production') {
60
+ * throw new Error('Production-only error');
61
+ * }
62
+ *
63
+ * @example
64
+ * // Valid - unconditional throw
65
+ * throw new Error('Something went wrong');
66
+ *
67
+ * @type {import('eslint').Rule.RuleModule}
68
+ */
69
+ const rule = {
70
+ meta: {
71
+ type: 'problem',
72
+ docs: {
73
+ description:
74
+ 'Disallow throw statements guarded by process.env.NODE_ENV checks, as they cause environment-dependent control flow',
75
+ },
76
+ messages: {
77
+ guardedThrow:
78
+ 'Do not guard `throw` statements with `process.env.NODE_ENV` checks. Guarded throws execute only in certain environments, causing inconsistent control flow between development and production.',
79
+ },
80
+ schema: [],
81
+ },
82
+ create(context) {
83
+ return {
84
+ ThrowStatement(node) {
85
+ /** @type {import('eslint').Rule.Node | null} */
86
+ let current = node.parent;
87
+ /** @type {import('eslint').Rule.Node} */
88
+ let currentChild = node;
89
+
90
+ while (current) {
91
+ if (
92
+ current.type === 'FunctionDeclaration' ||
93
+ current.type === 'FunctionExpression' ||
94
+ current.type === 'ArrowFunctionExpression'
95
+ ) {
96
+ break;
97
+ }
98
+ if (current.type === 'IfStatement') {
99
+ const isInConsequent = current.consequent === currentChild;
100
+ const isInAlternate = current.alternate === currentChild;
101
+
102
+ if ((isInConsequent || isInAlternate) && containsProcessEnvNodeEnv(current.test)) {
103
+ context.report({ node, messageId: 'guardedThrow' });
104
+ return;
105
+ }
106
+ }
107
+ currentChild = current;
108
+ current = current.parent;
109
+ }
110
+ },
111
+ };
112
+ },
113
+ };
114
+
115
+ export default rule;