@mui/internal-code-infra 0.0.4-canary.63 → 0.0.4-canary.65

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.
@@ -0,0 +1,5 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ declare const _default: ESLintUtils.RuleModule<"floatingCleanup", [], unknown, ESLintUtils.RuleListener> & {
3
+ name: string;
4
+ };
5
+ export default _default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-code-infra",
3
- "version": "0.0.4-canary.63",
3
+ "version": "0.0.4-canary.65",
4
4
  "author": "MUI Team",
5
5
  "description": "Infra scripts and configs to be used across MUI repos.",
6
6
  "license": "MIT",
@@ -192,7 +192,7 @@
192
192
  "publishConfig": {
193
193
  "access": "public"
194
194
  },
195
- "gitSha": "e08081fbb9ce12311a1acb555f69108dc1231951",
195
+ "gitSha": "308d63cc49db1419769226ebad3e2ceec1dd49d2",
196
196
  "scripts": {
197
197
  "build": "tsgo -p tsconfig.build.json",
198
198
  "typescript": "tsgo -noEmit",
@@ -9,6 +9,7 @@ import { Transform } from 'node:stream';
9
9
  import { Worker } from 'node:worker_threads';
10
10
 
11
11
  const DEFAULT_CONCURRENCY = 4;
12
+ const SERVER_START_TIMEOUT = 10000;
12
13
 
13
14
  const crawlWorkerUrl = new URL('./crawlWorker.mjs', import.meta.url);
14
15
 
@@ -620,30 +621,56 @@ export async function crawl(rawOptions) {
620
621
 
621
622
  /** @type {AbortController | null} */
622
623
  let controller = null;
623
- if (options.startCommand) {
624
- console.log(chalk.blue(`Starting server with "${options.startCommand}"...`));
625
- controller = new AbortController();
626
- const appProcess = execaCommand(options.startCommand, {
627
- stdout: 'pipe',
628
- stderr: 'pipe',
629
- cancelSignal: controller.signal,
630
- env: {
631
- FORCE_COLOR: '1',
632
- ...process.env,
633
- },
634
- });
635
624
 
636
- // Prefix server logs
637
- const serverPrefix = chalk.gray('server: ');
638
- appProcess.stdout.pipe(prefixLines(serverPrefix)).pipe(process.stdout);
639
- appProcess.stderr.pipe(prefixLines(serverPrefix)).pipe(process.stderr);
640
- appProcess.catch(() => {});
625
+ try {
626
+ if (options.startCommand) {
627
+ console.log(chalk.blue(`Starting server with "${options.startCommand}"...`));
628
+ controller = new AbortController();
629
+ const appProcess = execaCommand(options.startCommand, {
630
+ stdout: 'pipe',
631
+ stderr: 'pipe',
632
+ cancelSignal: controller.signal,
633
+ env: {
634
+ FORCE_COLOR: '1',
635
+ ...process.env,
636
+ },
637
+ });
638
+
639
+ // Prefix server logs
640
+ const serverPrefix = chalk.gray('server: ');
641
+ appProcess.stdout.pipe(prefixLines(serverPrefix)).pipe(process.stdout);
642
+ appProcess.stderr.pipe(prefixLines(serverPrefix)).pipe(process.stderr);
643
+ appProcess.catch(() => {});
641
644
 
642
- await pollUrl(options.host, 10000);
645
+ // Poll the first page we are about to crawl (resolved against host) so we
646
+ // wait for the actual entry point to be serveable rather than the
647
+ // homepage, which may be a different (slower) page.
648
+ const healthcheckUrl = new URL(options.seedUrls[0] ?? '/', options.host).href;
649
+ await pollUrl(healthcheckUrl, SERVER_START_TIMEOUT);
643
650
 
644
- console.log(`Server started on ${chalk.underline(options.host)}`);
651
+ console.log(`Server started on ${chalk.underline(options.host)}`);
652
+ }
653
+
654
+ return await runCrawl(options, startTime);
655
+ } finally {
656
+ // Always stop the server, even when startup or crawling throws. Without
657
+ // this, a failed healthcheck (or any error) would leave the dev server
658
+ // running, which on slow environments (e.g. Netlify) leads to orphaned
659
+ // servers piling up across retries.
660
+ if (controller) {
661
+ console.log(chalk.blue('Stopping server...'));
662
+ controller.abort();
663
+ }
645
664
  }
665
+ }
646
666
 
667
+ /**
668
+ * Runs the crawl against an already-running server.
669
+ * @param {ResolvedCrawlOptions} options - Fully resolved crawl options
670
+ * @param {number} startTime - Timestamp (ms) when the crawl began, for duration reporting
671
+ * @returns {Promise<CrawlResult>} Crawl results including all links, pages, and issues found
672
+ */
673
+ async function runCrawl(options, startTime) {
647
674
  const knownTargets = await resolveKnownTargets(options);
648
675
 
649
676
  /** @type {Map<string, Promise<PageData>>} */
@@ -733,11 +760,6 @@ export async function crawl(rawOptions) {
733
760
 
734
761
  await queue.waitAll();
735
762
 
736
- if (controller) {
737
- console.log(chalk.blue('Stopping server...'));
738
- controller.abort();
739
- }
740
-
741
763
  const results = new Map(
742
764
  await Promise.all(
743
765
  Array.from(crawledPages.entries(), async ([a, b]) => /** @type {const} */ ([a, await b])),
@@ -13,6 +13,7 @@ import straightQuotes from './rules/straight-quotes.mjs';
13
13
  import addUndefToOptional from './rules/add-undef-to-optional.mjs';
14
14
  import flattenParentheses from './rules/flatten-parentheses.mjs';
15
15
  import noPresentationRole from './rules/no-presentation-role.mjs';
16
+ import noFloatingCleanup from './rules/no-floating-cleanup.mjs';
16
17
 
17
18
  /** @type {import('eslint').ESLint.Plugin} */
18
19
  const muiPlugin = {
@@ -37,6 +38,7 @@ const muiPlugin = {
37
38
  'add-undef-to-optional': /** @type {any} */ (addUndefToOptional),
38
39
  'flatten-parentheses': /** @type {any} */ (flattenParentheses),
39
40
  'no-presentation-role': noPresentationRole,
41
+ 'no-floating-cleanup': /** @type {any} */ (noFloatingCleanup),
40
42
  },
41
43
  };
42
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
+ });