@mui/internal-code-infra 0.0.4-canary.7 → 0.0.4-canary.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +19 -8
- package/build/babel-config.d.mts +11 -3
- package/build/brokenLinksChecker/crawlWorker.d.mts +1 -0
- package/build/brokenLinksChecker/index.d.mts +45 -2
- package/build/changelog/types.d.ts +1 -1
- package/build/cli/cmdArgosPush.d.mts +2 -2
- package/build/cli/cmdBuild.d.mts +3 -2
- package/build/cli/cmdCopyFiles.d.mts +2 -2
- package/build/cli/cmdExtractErrorCodes.d.mts +2 -2
- package/build/cli/cmdGenerateChangelog.d.mts +2 -2
- package/build/cli/cmdGithubAuth.d.mts +2 -2
- package/build/cli/cmdListWorkspaces.d.mts +6 -4
- package/build/cli/cmdNetlifyIgnore.d.mts +3 -2
- package/build/cli/cmdPublish.d.mts +4 -2
- package/build/cli/cmdPublishCanary.d.mts +3 -3
- package/build/cli/cmdPublishNewPackage.d.mts +4 -2
- package/build/cli/cmdSetVersionOverrides.d.mts +2 -2
- package/build/cli/cmdVale.d.mts +46 -0
- package/build/cli/cmdValidateBuiltTypes.d.mts +2 -2
- package/build/eslint/baseConfig.d.mts +3 -1
- package/build/eslint/mui/rules/disallow-react-api-in-server-components.d.mts +2 -2
- package/build/eslint/mui/rules/docgen-ignore-before-comment.d.mts +2 -2
- package/build/eslint/mui/rules/no-floating-cleanup.d.mts +5 -0
- package/build/eslint/mui/rules/no-guarded-throw.d.mts +31 -0
- package/build/eslint/mui/rules/no-presentation-role.d.mts +5 -0
- package/build/eslint/mui/rules/no-restricted-resolved-imports.d.mts +2 -2
- package/build/eslint/mui/rules/nodeEnvUtils.d.mts +18 -0
- package/build/remark/config.d.mts +43 -0
- package/build/remark/createLintTester.d.mts +10 -0
- package/build/remark/firstBlockHeading.d.mts +4 -0
- package/build/remark/gitDiff.d.mts +2 -0
- package/build/remark/noSpaceInLinks.d.mts +2 -0
- package/build/remark/straightQuotes.d.mts +2 -0
- package/build/remark/tableAlignment.d.mts +2 -0
- package/build/remark/terminalLanguage.d.mts +2 -0
- package/build/utils/babel.d.mts +1 -1
- package/build/utils/build.d.mts +56 -37
- package/build/utils/github.d.mts +1 -1
- package/build/utils/pnpm.d.mts +81 -2
- package/build/utils/testUtils.d.mts +7 -0
- package/build/utils/typescript.d.mts +6 -16
- package/package.json +60 -38
- package/src/babel-config.mjs +9 -3
- package/src/brokenLinksChecker/__fixtures__/static-site/index.html +1 -0
- package/src/brokenLinksChecker/__fixtures__/static-site/invalid-html.html +15 -0
- package/src/brokenLinksChecker/crawlWorker.mjs +217 -0
- package/src/brokenLinksChecker/index.mjs +263 -188
- package/src/brokenLinksChecker/index.test.ts +50 -13
- package/src/changelog/categorizeCommits.test.ts +5 -5
- package/src/changelog/fetchChangelogs.mjs +6 -2
- package/src/changelog/parseCommitLabels.test.ts +5 -5
- package/src/changelog/renderChangelog.mjs +1 -1
- package/src/changelog/types.ts +1 -1
- package/src/cli/cmdBuild.mjs +51 -85
- package/src/cli/cmdListWorkspaces.mjs +12 -27
- package/src/cli/cmdNetlifyIgnore.mjs +34 -93
- package/src/cli/cmdPublish.mjs +55 -15
- package/src/cli/cmdPublishCanary.mjs +128 -132
- package/src/cli/cmdPublishNewPackage.mjs +27 -6
- package/src/cli/cmdSetVersionOverrides.mjs +8 -9
- package/src/cli/cmdVale.mjs +513 -0
- package/src/cli/cmdVale.test.mjs +644 -0
- package/src/cli/index.mjs +2 -0
- package/src/cli/packageJson.d.ts +1 -1
- package/src/eslint/baseConfig.mjs +51 -19
- package/src/eslint/docsConfig.mjs +2 -1
- package/src/eslint/mui/config.mjs +22 -3
- package/src/eslint/mui/index.mjs +6 -0
- package/src/eslint/mui/rules/no-floating-cleanup.mjs +187 -0
- package/src/eslint/mui/rules/no-floating-cleanup.test.mjs +141 -0
- package/src/eslint/mui/rules/no-guarded-throw.mjs +115 -0
- package/src/eslint/mui/rules/no-guarded-throw.test.mjs +206 -0
- package/src/eslint/mui/rules/no-presentation-role.mjs +60 -0
- package/src/eslint/mui/rules/no-presentation-role.test.mjs +33 -0
- package/src/eslint/mui/rules/nodeEnvUtils.mjs +52 -0
- package/src/eslint/mui/rules/require-dev-wrapper.mjs +25 -40
- package/src/estree-typescript.d.ts +1 -1
- package/src/remark/config.mjs +157 -0
- package/src/remark/createLintTester.mjs +19 -0
- package/src/remark/firstBlockHeading.mjs +87 -0
- package/src/remark/firstBlockHeading.test.mjs +107 -0
- package/src/remark/gitDiff.mjs +43 -0
- package/src/remark/gitDiff.test.mjs +45 -0
- package/src/remark/noSpaceInLinks.mjs +42 -0
- package/src/remark/noSpaceInLinks.test.mjs +22 -0
- package/src/remark/straightQuotes.mjs +31 -0
- package/src/remark/straightQuotes.test.mjs +25 -0
- package/src/remark/tableAlignment.mjs +23 -0
- package/src/remark/tableAlignment.test.mjs +28 -0
- package/src/remark/terminalLanguage.mjs +19 -0
- package/src/remark/terminalLanguage.test.mjs +17 -0
- package/src/untyped-plugins.d.ts +11 -11
- package/src/utils/build.mjs +604 -270
- package/src/utils/build.test.mjs +1079 -521
- package/src/utils/git.mjs +16 -7
- package/src/utils/pnpm.mjs +255 -3
- package/src/utils/pnpm.test.mjs +731 -0
- package/src/utils/testUtils.mjs +18 -0
- package/src/utils/typescript.mjs +23 -42
- package/src/utils/typescript.test.mjs +234 -273
- package/vale/.vale.ini +1 -0
- package/vale/styles/MUI/CorrectReferenceAllCases.yml +43 -0
- package/vale/styles/MUI/CorrectRererenceCased.yml +14 -0
- package/vale/styles/MUI/GoogleLatin.yml +11 -0
- package/vale/styles/MUI/MuiBrandName.yml +22 -0
- package/vale/styles/MUI/NoBritish.yml +112 -0
- package/vale/styles/MUI/NoCompanyName.yml +17 -0
- package/build/markdownlint/duplicate-h1.d.mts +0 -56
- package/build/markdownlint/git-diff.d.mts +0 -11
- package/build/markdownlint/index.d.mts +0 -52
- package/build/markdownlint/straight-quotes.d.mts +0 -11
- package/build/markdownlint/table-alignment.d.mts +0 -11
- package/build/markdownlint/terminal-language.d.mts +0 -11
- package/src/markdownlint/duplicate-h1.mjs +0 -69
- package/src/markdownlint/git-diff.mjs +0 -31
- package/src/markdownlint/index.mjs +0 -62
- package/src/markdownlint/straight-quotes.mjs +0 -26
- package/src/markdownlint/table-alignment.mjs +0 -42
- 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: '
|
|
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: [
|
|
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
|
-
//
|
|
464
|
-
// https://github.com/
|
|
465
|
-
'react/jsx-no-target-blank':
|
|
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
|
|
|
@@ -525,5 +537,12 @@ export function createCoreConfig(options = {}) {
|
|
|
525
537
|
'@typescript-eslint/return-await': 'off',
|
|
526
538
|
},
|
|
527
539
|
},
|
|
540
|
+
{
|
|
541
|
+
name: 'mui-base/dts',
|
|
542
|
+
files: [`**/*${EXTENSION_DTS}`],
|
|
543
|
+
rules: {
|
|
544
|
+
'@typescript-eslint/consistent-type-imports': 'off',
|
|
545
|
+
},
|
|
546
|
+
},
|
|
528
547
|
]);
|
|
529
548
|
}
|
package/src/eslint/mui/index.mjs
CHANGED
|
@@ -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;
|