@mui/internal-code-infra 0.1.1-canary.1 → 0.1.1-canary.10

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.
@@ -8,6 +8,14 @@ export type FetchCommitsOptions = {
8
8
  * - GitHub organization name, defaults to 'mui'
9
9
  */
10
10
  org?: string;
11
+ /**
12
+ * - Called as commits are listed and their pull request details are fetched
13
+ */
14
+ onProgress?: (progress: {
15
+ phase: string;
16
+ count: number;
17
+ total: number;
18
+ }) => void;
11
19
  };
12
20
  /**
13
21
  * @typedef {import('@octokit/rest').Octokit} OctokitType
@@ -21,17 +29,8 @@ export type FetchCommitsOptions = {
21
29
  * @property {string} lastRelease
22
30
  * @property {string} release
23
31
  * @property {string} [org='mui'] - GitHub organization name, defaults to 'mui'
32
+ * @property {(progress: { phase: string; count: number; total: number }) => void} [onProgress] - Called as commits are listed and their pull request details are fetched
24
33
  */
25
- /**
26
- * @param {Object} opts
27
- * @param {string} opts.cwd
28
- * @param {boolean} [opts.fetchAll=true] Whether to fetch all tags from all remotes before finding the latest tag.
29
- * @returns {Promise<string>}
30
- */
31
- export declare function findLatestTaggedVersion(opts: {
32
- cwd: string;
33
- fetchAll?: boolean;
34
- }): Promise<string>;
35
34
  /**
36
35
  * Fetches commits between two refs (lastRelease..release) including PR details.
37
36
  * Automatically handles GitHub OAuth authentication if none provided.
@@ -4,3 +4,4 @@
4
4
  export { generateChangelog } from './generateChangelog.mjs';
5
5
  export * from './loadChangelogConfig.mjs';
6
6
  export * from './fetchChangelogs.mjs';
7
+ export { findLatestTaggedVersion } from '../utils/git.mjs';
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ export type Args = {
3
+ /**
4
+ * - Branch pull requests fork from
5
+ */
6
+ baseBranch?: string;
7
+ /**
8
+ * - Remote the base branch is fetched from
9
+ */
10
+ remote?: string;
11
+ };
12
+ /**
13
+ * @typedef {Object} Args
14
+ * @property {string} [baseBranch] - Branch pull requests fork from
15
+ * @property {string} [remote] - Remote the base branch is fetched from
16
+ */
17
+ declare const _default: import('yargs').CommandModule<{}, Args>;
18
+ export default _default;
@@ -0,0 +1,50 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ export type PropertyDefinition = import('@typescript-eslint/utils').TSESTree.PropertyDefinition;
3
+ export type Node = import('@typescript-eslint/utils').TSESTree.Node;
4
+ /**
5
+ * ESLint rule that disallows assigning a class field in the constructor when it is also
6
+ * declared as a (non-`declare`) class field.
7
+ *
8
+ * Babel 8 only strips TypeScript fields marked `declare` (see mui/material-ui#49241), so a
9
+ * field without an initializer is emitted as a property definition that sets it to
10
+ * `undefined` before the constructor assigns it again. Either initialize the field at its
11
+ * declaration, or mark it `declare` so it only serves as a type annotation.
12
+ *
13
+ * `declare` fields are exempt from TypeScript's strict property initialization check
14
+ * (TS2564), so prefer initializing at the declaration where the value doesn't depend on
15
+ * constructor arguments. The `declare` suggestion is only offered when every path through the
16
+ * constructor assigns the field, i.e. for an unconditional top-level assignment.
17
+ *
18
+ * Only plain `=` assignments count: compound assignments read the field rather than
19
+ * re-initialize it. A field with an initializer is only reported for an unconditional
20
+ * top-level assignment, as a conditional one overrides a default. Private (`#`) and decorated
21
+ * fields are ignored: they are runtime declarations that `declare` can't replace.
22
+ *
23
+ * @example
24
+ * // Invalid
25
+ * class ModalManager {
26
+ * private modals: Modal[];
27
+ * constructor() {
28
+ * this.modals = [];
29
+ * }
30
+ * }
31
+ *
32
+ * @example
33
+ * // Valid - initialized at the declaration
34
+ * class ModalManager {
35
+ * private modals: Modal[] = [];
36
+ * }
37
+ *
38
+ * @example
39
+ * // Valid - declared only, assigned in the constructor
40
+ * class ModalManager {
41
+ * declare private modals: Modal[];
42
+ * constructor(modals: Modal[]) {
43
+ * this.modals = modals;
44
+ * }
45
+ * }
46
+ */
47
+ declare const _default: ESLintUtils.RuleModule<"addDeclare" | "assignedInConstructor", [], unknown, ESLintUtils.RuleListener> & {
48
+ name: string;
49
+ };
50
+ export default _default;
@@ -13,22 +13,18 @@ export type RepoInfo = {
13
13
  remoteName: string;
14
14
  };
15
15
  /**
16
- * @typedef {Object} RepoInfo
17
- * @property {string} owner - Repository owner
18
- * @property {string} repo - Repository name
19
- * @property {string} remoteName - Remote name
20
- */
21
- /**
22
- * Get current repository info from git remote
16
+ * The canonical repository's remote, as opposed to a fork's: `upstream`, else `origin`, each only if
17
+ * it points at a mui repository. Reads the configured URLs, which `insteadOf` does not rewrite.
23
18
  * @param {string} [cwd=process.cwd()]
24
- * @returns {Promise<RepoInfo>} Repository owner and name
19
+ * @returns {Promise<RepoInfo>}
25
20
  */
26
21
  export declare function getRepositoryInfo(cwd?: string): Promise<RepoInfo>;
27
22
  /**
28
23
  * Get current git SHA
24
+ * @param {string} [cwd=process.cwd()]
29
25
  * @returns {Promise<string>} Current git commit SHA
30
26
  */
31
- export declare function getCurrentGitSha(): Promise<string>;
27
+ export declare function getCurrentGitSha(cwd?: string): Promise<string>;
32
28
  /**
33
29
  * Check whether a tag already exists on the origin remote
34
30
  * @param {string} tagName - Tag name to check (e.g. `v1.2.3`)
@@ -36,3 +32,28 @@ export declare function getCurrentGitSha(): Promise<string>;
36
32
  * @returns {Promise<boolean>} True if the tag exists on origin
37
33
  */
38
34
  export declare function remoteGitTagExists(tagName: string, cwd?: string): Promise<boolean>;
35
+ /**
36
+ * The commit HEAD should be compared against: where it forked from the base branch, or its parent
37
+ * when HEAD is already on the base branch.
38
+ * @param {Object} [options]
39
+ * @param {string} [options.cwd]
40
+ * @param {string} [options.remote] - Defaults to the canonical remote.
41
+ * @param {string} [options.baseBranch] - Defaults to the remote's default branch.
42
+ * @returns {Promise<string>}
43
+ */
44
+ export declare function resolveBaseline(options?: {
45
+ cwd?: string;
46
+ remote?: string;
47
+ baseBranch?: string;
48
+ }): Promise<string>;
49
+ /**
50
+ * The most recent version tag reachable from HEAD — where a changelog's range starts.
51
+ * @param {Object} opts
52
+ * @param {string} opts.cwd
53
+ * @param {boolean} [opts.fetchAll=true] Whether to fetch all tags from all remotes before finding the latest tag.
54
+ * @returns {Promise<string>}
55
+ */
56
+ export declare function findLatestTaggedVersion(opts: {
57
+ cwd: string;
58
+ fetchAll?: boolean;
59
+ }): Promise<string>;
@@ -47,3 +47,11 @@ export declare function endToEndGhAuthGetToken({ log }?: {
47
47
  * @returns {Promise<void>}
48
48
  */
49
49
  export declare function clearGitHubAuth(): Promise<void>;
50
+ /**
51
+ * Returns when a rate-limited request may be retried, or `null` for other errors.
52
+ * https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit
53
+ *
54
+ * @param {unknown} error
55
+ * @returns {Date | null}
56
+ */
57
+ export declare function getRateLimitRetryTime(error: unknown): Date | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-code-infra",
3
- "version": "0.1.1-canary.1",
3
+ "version": "0.1.1-canary.10",
4
4
  "author": "MUI Team",
5
5
  "description": "Infra scripts and configs to be used across MUI repos.",
6
6
  "license": "MIT",
@@ -53,14 +53,14 @@
53
53
  "code-infra": "./bin/code-infra.mjs"
54
54
  },
55
55
  "dependencies": {
56
- "@argos-ci/core": "6.8.4",
57
- "@babel/cli": "^8.0.5",
58
- "@babel/core": "^8.0.5",
56
+ "@argos-ci/core": "6.8.6",
57
+ "@babel/cli": "^8.0.6",
58
+ "@babel/core": "^8.0.6",
59
59
  "@babel/plugin-syntax-jsx": "^8.0.1",
60
- "@babel/plugin-syntax-typescript": "^8.0.1",
60
+ "@babel/plugin-syntax-typescript": "^8.0.3",
61
61
  "@babel/plugin-transform-react-pure-annotations": "^8.0.1",
62
- "@babel/plugin-transform-runtime": "^8.0.1",
63
- "@babel/preset-env": "^8.0.5",
62
+ "@babel/plugin-transform-runtime": "^8.0.6",
63
+ "@babel/preset-env": "^8.0.6",
64
64
  "@babel/preset-react": "^8.0.1",
65
65
  "@babel/preset-typescript": "^8.0.1",
66
66
  "@eslint/compat": "^2.1.1",
@@ -68,27 +68,29 @@
68
68
  "@eslint/json": "^2.1.0",
69
69
  "@inquirer/confirm": "^6.3.2",
70
70
  "@inquirer/select": "^5.2.5",
71
- "@mui/internal-babel-plugin-display-name": "2.0.1-canary.0",
72
- "@mui/internal-babel-plugin-minify-errors": "3.0.1-canary.0",
73
- "@mui/internal-babel-plugin-resolve-imports": "3.0.1-canary.0",
74
- "@napi-rs/keyring": "^2.0.0",
71
+ "@mui/internal-babel-plugin-display-name": "2.0.1-canary.1",
72
+ "@mui/internal-babel-plugin-minify-errors": "3.0.1-canary.1",
73
+ "@mui/internal-babel-plugin-resolve-imports": "3.0.1-canary.2",
74
+ "@napi-rs/keyring": "^2.1.0",
75
75
  "@octokit/auth-action": "^6.0.3",
76
76
  "@octokit/oauth-methods": "^6.0.5",
77
+ "@octokit/plugin-retry": "^8.1.1",
78
+ "@octokit/plugin-throttling": "^11.0.5",
77
79
  "@octokit/rest": "^22.0.1",
78
- "@pnpm/config.version-policy": "^1100.2.3",
80
+ "@pnpm/config.version-policy": "^1100.2.5",
79
81
  "@pnpm/find-workspace-dir": "^1000.1.5",
80
82
  "@pnpm/parse-wanted-dependency": "^1001.0.0",
81
- "@typescript-eslint/types": "^8.70.0",
82
- "@typescript-eslint/utils": "^8.70.0",
83
+ "@typescript-eslint/types": "^8.71.0",
84
+ "@typescript-eslint/utils": "^8.71.0",
83
85
  "@vitest/eslint-plugin": "^1.6.27",
84
86
  "babel-plugin-optimize-clsx": "^2.6.2",
85
87
  "babel-plugin-react-compiler": "^1.0.0",
86
88
  "babel-plugin-transform-import-meta": "^3.0.0",
87
89
  "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
88
90
  "babel-plugin-transform-remove-imports": "^1.8.1",
89
- "chalk": "^6.0.0",
91
+ "chalk": "^6.0.1",
90
92
  "clipboardy": "^5.3.2",
91
- "content-type": "^3.1.0",
93
+ "content-type": "^3.1.1",
92
94
  "env-ci": "^11.2.0",
93
95
  "es-toolkit": "^1.52.0",
94
96
  "eslint-config-prettier": "^10.1.8",
@@ -104,13 +106,13 @@
104
106
  "eslint-plugin-testing-library": "^7.16.2",
105
107
  "execa": "^10.0.1",
106
108
  "git-url-parse": "^16.1.0",
107
- "globals": "^17.12.0",
109
+ "globals": "^17.13.0",
108
110
  "globby": "^16.2.4",
109
- "html-validate": "^11.15.0",
111
+ "html-validate": "^11.16.1",
110
112
  "minimatch": "^10.2.6",
111
113
  "node-html-parser": "^9.0.4",
112
- "open": "^11.0.3",
113
- "postcss-styled-syntax": "^0.7.2",
114
+ "open": "^11.0.4",
115
+ "postcss-styled-syntax": "^0.7.3",
114
116
  "regexp.escape": "^2.0.1",
115
117
  "rehype-slug": "^6.0.0",
116
118
  "rehype-stringify": "^10.0.1",
@@ -133,12 +135,12 @@
133
135
  "resolve-pkg-maps": "^1.0.0",
134
136
  "semver": "^7.8.5",
135
137
  "stylelint-config-standard": "^40.0.0",
136
- "typescript-eslint": "^8.70.0",
138
+ "typescript-eslint": "^8.71.0",
137
139
  "unified": "^11.0.5",
138
140
  "unified-lint-rule": "^3.0.1",
139
141
  "unist-util-visit": "^5.1.0",
140
142
  "yaml": "^2.9.1",
141
- "yargs": "^18.1.0"
143
+ "yargs": "^18.2.0"
142
144
  },
143
145
  "peerDependencies": {
144
146
  "@next/eslint-plugin-next": "*",
@@ -162,17 +164,17 @@
162
164
  "@types/mdast": "4.0.4",
163
165
  "@types/regexp.escape": "2.0.0",
164
166
  "@types/yargs": "17.0.35",
165
- "@typescript-eslint/parser": "8.70.0",
166
- "@typescript-eslint/rule-tester": "8.70.0",
167
+ "@typescript-eslint/parser": "8.71.0",
168
+ "@typescript-eslint/rule-tester": "8.71.0",
167
169
  "@typescript/native": "npm:typescript@7.0.2",
168
- "eslint": "10.10.0",
170
+ "eslint": "10.11.0",
169
171
  "get-port": "7.2.0",
170
- "prettier": "3.9.6",
172
+ "prettier": "3.9.9",
171
173
  "remark": "15.0.1",
172
174
  "remark-mdx": "3.1.1",
173
175
  "serve": "14.2.6",
174
176
  "typescript": "npm:@typescript/typescript6@6.0.2",
175
- "typescript-eslint": "8.70.0"
177
+ "typescript-eslint": "8.71.0"
176
178
  },
177
179
  "files": [
178
180
  "bin",
@@ -187,7 +189,7 @@
187
189
  "publishConfig": {
188
190
  "access": "public"
189
191
  },
190
- "gitSha": "294ab9be74ab24116c64cd734568b8d0a9b30f18",
192
+ "gitSha": "46d7844970a13df535908a1f3f0d353d992feb87",
191
193
  "scripts": {
192
194
  "build": "tsc -p tsconfig.build.json",
193
195
  "typescript": "tsc -noEmit",
@@ -1,9 +1,25 @@
1
+ import { retry } from '@octokit/plugin-retry';
2
+ import { throttling } from '@octokit/plugin-throttling';
1
3
  import { Octokit } from '@octokit/rest';
2
- import { $ } from 'execa';
4
+ import { mapAsync } from 'es-toolkit/array';
3
5
 
4
- import { getRepositoryInfo } from '../utils/git.mjs';
5
6
  import { persistentAuthStrategy } from '../utils/github.mjs';
6
7
 
8
+ const ThrottledOctokit = Octokit.plugin(throttling, retry);
9
+
10
+ /**
11
+ * Waits out short rate limits. Longer ones fail with the original error.
12
+ *
13
+ * @param {number} retryAfter - Seconds until the request may be retried
14
+ * @param {unknown} options
15
+ * @param {unknown} octokit
16
+ * @param {number} retryCount
17
+ * @returns {boolean}
18
+ */
19
+ function shouldRetryRateLimit(retryAfter, options, octokit, retryCount) {
20
+ return retryAfter <= 60 && retryCount < 3;
21
+ }
22
+
7
23
  /**
8
24
  * @typedef {import('@octokit/rest').Octokit} OctokitType
9
25
  */
@@ -18,27 +34,9 @@ import { persistentAuthStrategy } from '../utils/github.mjs';
18
34
  * @property {string} lastRelease
19
35
  * @property {string} release
20
36
  * @property {string} [org='mui'] - GitHub organization name, defaults to 'mui'
37
+ * @property {(progress: { phase: string; count: number; total: number }) => void} [onProgress] - Called as commits are listed and their pull request details are fetched
21
38
  */
22
39
 
23
- /**
24
- * @param {Object} opts
25
- * @param {string} opts.cwd
26
- * @param {boolean} [opts.fetchAll=true] Whether to fetch all tags from all remotes before finding the latest tag.
27
- * @returns {Promise<string>}
28
- */
29
- export async function findLatestTaggedVersion(opts) {
30
- const $$ = $({ cwd: opts.cwd });
31
- const fetchAll = opts.fetchAll ?? true;
32
- if (fetchAll) {
33
- const { remoteName } = await getRepositoryInfo();
34
- // Fetch all tags from the mui remote to ensure we have the latest tags.
35
- // --force to update any existing tags that may have changed to avoid the clobering error.
36
- await $$`git fetch --tags --force ${remoteName}`;
37
- }
38
- const { stdout } = await $$`git describe --tags --abbrev=0 --match ${'v*'}`; // only include "version-tags"
39
- return stdout.trim();
40
- }
41
-
42
40
  /**
43
41
  * Fetches commits between two refs (lastRelease..release) including PR details.
44
42
  * Automatically handles GitHub OAuth authentication if none provided.
@@ -50,7 +48,13 @@ export async function fetchCommitsBetweenRefs(opts) {
50
48
  const octokit =
51
49
  'octokit' in opts && opts.octokit
52
50
  ? opts.octokit
53
- : new Octokit({ authStrategy: persistentAuthStrategy });
51
+ : new ThrottledOctokit({
52
+ authStrategy: persistentAuthStrategy,
53
+ throttle: {
54
+ onRateLimit: shouldRetryRateLimit,
55
+ onSecondaryRateLimit: shouldRetryRateLimit,
56
+ },
57
+ });
54
58
 
55
59
  return fetchCommitsRest({
56
60
  octokit,
@@ -58,6 +62,7 @@ export async function fetchCommitsBetweenRefs(opts) {
58
62
  lastRelease: opts.lastRelease,
59
63
  release: opts.release,
60
64
  org: opts.org ?? 'mui',
65
+ onProgress: opts.onProgress,
61
66
  });
62
67
  }
63
68
 
@@ -70,7 +75,7 @@ export async function fetchCommitsBetweenRefs(opts) {
70
75
  *
71
76
  * @returns {Promise<FetchedCommitDetails[]>}
72
77
  */
73
- async function fetchCommitsRest({ octokit, repo, lastRelease, release, org = 'mui' }) {
78
+ async function fetchCommitsRest({ octokit, repo, lastRelease, release, org = 'mui', onProgress }) {
74
79
  /**
75
80
  * @typedef {Awaited<ReturnType<Octokit['repos']['compareCommits']>>['data']['commits']} Commits
76
81
  */
@@ -90,9 +95,19 @@ async function fetchCommitsRest({ octokit, repo, lastRelease, release, org = 'mu
90
95
  );
91
96
  for await (const response of timeline) {
92
97
  results.push(...response.data.commits);
98
+ onProgress?.({
99
+ phase: 'Listing commits',
100
+ count: results.length,
101
+ total: response.data.total_commits,
102
+ });
93
103
  }
94
104
 
95
- const promises = results.map(async (commit) => {
105
+ /**
106
+ * @param {Commits[number]} commit
107
+ * @param {AbortSignal} signal
108
+ * @returns {Promise<FetchedCommitDetails | null>}
109
+ */
110
+ const fetchCommitDetails = async (commit, signal) => {
96
111
  const matches = [...commit.commit.message.matchAll(/#(\d+)/g)];
97
112
  // The PR number is always the last match.
98
113
  // Sometimes the PR titles include an issue number like this:
@@ -111,6 +126,7 @@ async function fetchCommitsRest({ octokit, repo, lastRelease, release, org = 'mu
111
126
  headers: {
112
127
  Accept: 'application/vnd.github.text+json',
113
128
  },
129
+ request: { signal },
114
130
  });
115
131
 
116
132
  const labels = pr.data.labels.map((label) => label.name);
@@ -132,9 +148,33 @@ async function fetchCommitsRest({ octokit, repo, lastRelease, release, org = 'mu
132
148
  prTitle: pr.data.title,
133
149
  prBody: pr.data.body,
134
150
  });
135
- });
151
+ };
152
+
153
+ // `mapAsync` rejects on the first error but still runs every queued callback.
154
+ // Aborted once it settles, which cancels requests in flight and skips the ones not started yet.
155
+ const controller = new AbortController();
136
156
 
137
- return (await Promise.all(promises)).filter((entry) => entry !== null);
157
+ let fetched = 0;
158
+ try {
159
+ const commits = await mapAsync(
160
+ results,
161
+ async (commit) => {
162
+ if (controller.signal.aborted) {
163
+ return null;
164
+ }
165
+ const details = await fetchCommitDetails(commit, controller.signal);
166
+ if (!controller.signal.aborted) {
167
+ fetched += 1;
168
+ onProgress?.({ phase: 'Fetching pull requests', count: fetched, total: results.length });
169
+ }
170
+ return details;
171
+ },
172
+ { concurrency: 10 },
173
+ );
174
+ return commits.filter((entry) => entry !== null);
175
+ } finally {
176
+ controller.abort();
177
+ }
138
178
  }
139
179
 
140
180
  /**
@@ -5,3 +5,6 @@
5
5
  export { generateChangelog } from './generateChangelog.mjs';
6
6
  export * from './loadChangelogConfig.mjs';
7
7
  export * from './fetchChangelogs.mjs';
8
+ // Moved to the git utilities, where the rest of the local git plumbing lives. Re-exported because
9
+ // `@mui/internal-code-infra/changelog` is a published entry point and this was part of it.
10
+ export { findLatestTaggedVersion } from '../utils/git.mjs';
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ /* eslint-disable no-console */
4
+
5
+ import { resolveBaseline } from '../utils/git.mjs';
6
+
7
+ /**
8
+ * @typedef {Object} Args
9
+ * @property {string} [baseBranch] - Branch pull requests fork from
10
+ * @property {string} [remote] - Remote the base branch is fetched from
11
+ */
12
+
13
+ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
14
+ command: 'baseline',
15
+ describe: 'Print the commit HEAD should be compared against',
16
+ builder: (yargs) => {
17
+ return yargs
18
+ .option('base-branch', {
19
+ type: 'string',
20
+ description: "Branch pull requests fork from. Default: the remote's default branch",
21
+ })
22
+ .option('remote', {
23
+ type: 'string',
24
+ description:
25
+ 'Remote the base branch is fetched from. Default: upstream, else origin, whichever points at a mui repository',
26
+ });
27
+ },
28
+ handler: async (argv) => {
29
+ console.log(await resolveBaseline({ baseBranch: argv.baseBranch, remote: argv.remote }));
30
+ },
31
+ });
@@ -30,7 +30,13 @@ import { getCurrentGitSha, getRepositoryInfo, remoteGitTagExists } from '../util
30
30
  const isCI = envCI().isCi;
31
31
 
32
32
  function getOctokit() {
33
- return new Octokit({ authStrategy: isCI ? createActionAuth : persistentAuthStrategy });
33
+ const octokit = new Octokit({
34
+ authStrategy: isCI ? createActionAuth : persistentAuthStrategy,
35
+ });
36
+ octokit.hook.before('request', (requestOptions) => {
37
+ requestOptions.headers['x-github-api-version'] = '2026-03-10';
38
+ });
39
+ return octokit;
34
40
  }
35
41
 
36
42
  /**
package/src/cli/index.mjs CHANGED
@@ -4,6 +4,7 @@ import yargs from 'yargs';
4
4
  import { hideBin } from 'yargs/helpers';
5
5
 
6
6
  import cmdArgosPush from './cmdArgosPush.mjs';
7
+ import cmdBaseline from './cmdBaseline.mjs';
7
8
  import cmdBuild from './cmdBuild.mjs';
8
9
  import cmdCopyFiles from './cmdCopyFiles.mjs';
9
10
  import cmdExtractErrorCodes from './cmdExtractErrorCodes.mjs';
@@ -38,6 +39,7 @@ await yargs(hideBin(process.argv))
38
39
  globalArgv = argv;
39
40
  }, true)
40
41
  .command(cmdArgosPush)
42
+ .command(cmdBaseline)
41
43
  .command(cmdBuild)
42
44
  .command(cmdCopyFiles)
43
45
  .command(cmdExtractErrorCodes)
@@ -1,5 +1,5 @@
1
1
  import { defineConfig } from 'eslint/config';
2
- import { EXTENSION_DTS } from '../extensions.mjs';
2
+ import { EXTENSION_DTS, EXTENSION_TS_ONLY } from '../extensions.mjs';
3
3
 
4
4
  const restrictedMethods = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'];
5
5
 
@@ -184,6 +184,14 @@ const typescriptOverrides = {
184
184
  // TypeScript equivalents of ESLint rules
185
185
  'default-param-last': 'off',
186
186
  '@typescript-eslint/default-param-last': 'error',
187
+ '@typescript-eslint/ban-ts-comment': [
188
+ 'error',
189
+ {
190
+ 'ts-expect-error': 'allow-with-description',
191
+ 'ts-ignore': true,
192
+ minimumDescriptionLength: 10,
193
+ },
194
+ ],
187
195
  'no-array-constructor': 'off',
188
196
  '@typescript-eslint/no-array-constructor': 'error',
189
197
  '@typescript-eslint/triple-slash-reference': 'off',
@@ -538,6 +546,13 @@ export function createCoreConfig(options = {}) {
538
546
  '@typescript-eslint/return-await': 'off',
539
547
  },
540
548
  },
549
+ {
550
+ name: 'mui-base/ts',
551
+ files: [`**/*${EXTENSION_TS_ONLY}`],
552
+ rules: {
553
+ 'mui/no-constructor-assigned-field': 'error',
554
+ },
555
+ },
541
556
  {
542
557
  name: 'mui-base/dts',
543
558
  files: [`**/*${EXTENSION_DTS}`],
@@ -14,6 +14,7 @@ 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
16
  import noFloatingCleanup from './rules/no-floating-cleanup.mjs';
17
+ import noConstructorAssignedField from './rules/no-constructor-assigned-field.mjs';
17
18
 
18
19
  /** @type {import('eslint').ESLint.Plugin} */
19
20
  const muiPlugin = {
@@ -39,6 +40,7 @@ const muiPlugin = {
39
40
  'flatten-parentheses': /** @type {any} */ (flattenParentheses),
40
41
  'no-presentation-role': noPresentationRole,
41
42
  'no-floating-cleanup': /** @type {any} */ (noFloatingCleanup),
43
+ 'no-constructor-assigned-field': /** @type {any} */ (noConstructorAssignedField),
42
44
  },
43
45
  };
44
46
 
@@ -0,0 +1,183 @@
1
+ import { ASTUtils, ESLintUtils } from '@typescript-eslint/utils';
2
+
3
+ /**
4
+ * @typedef {import('@typescript-eslint/utils').TSESTree.PropertyDefinition} PropertyDefinition
5
+ * @typedef {import('@typescript-eslint/utils').TSESTree.Node} Node
6
+ */
7
+
8
+ const createRule = ESLintUtils.RuleCreator(
9
+ (name) =>
10
+ `https://github.com/mui/mui-public/blob/master/packages/code-infra/src/eslint/mui/rules/${name}.mjs`,
11
+ );
12
+
13
+ /**
14
+ * Returns the constructor whose own code contains `node`, or `null` when `node` is outside a
15
+ * constructor or inside a nested function or class.
16
+ * @param {Node} node
17
+ */
18
+ function getEnclosingConstructor(node) {
19
+ let current = node.parent;
20
+ while (current && !ASTUtils.isFunction(current)) {
21
+ if (current.type === 'ClassBody') {
22
+ return null;
23
+ }
24
+ current = current.parent;
25
+ }
26
+ const method = current?.parent;
27
+ return method && ASTUtils.isConstructor(method) ? method : null;
28
+ }
29
+
30
+ /**
31
+ * Returns the extra edits needed before `field` can be marked `declare`.
32
+ * @param {PropertyDefinition} field
33
+ */
34
+ function getDeclareHint(field) {
35
+ const edits = [];
36
+ if (field.value) {
37
+ edits.push('drop its initializer');
38
+ }
39
+ if (field.definite) {
40
+ edits.push('drop the `!`');
41
+ }
42
+ if (field.override) {
43
+ edits.push('drop `override`');
44
+ }
45
+ return edits.length > 0 ? ` (${edits.join(', ')})` : '';
46
+ }
47
+
48
+ /**
49
+ * ESLint rule that disallows assigning a class field in the constructor when it is also
50
+ * declared as a (non-`declare`) class field.
51
+ *
52
+ * Babel 8 only strips TypeScript fields marked `declare` (see mui/material-ui#49241), so a
53
+ * field without an initializer is emitted as a property definition that sets it to
54
+ * `undefined` before the constructor assigns it again. Either initialize the field at its
55
+ * declaration, or mark it `declare` so it only serves as a type annotation.
56
+ *
57
+ * `declare` fields are exempt from TypeScript's strict property initialization check
58
+ * (TS2564), so prefer initializing at the declaration where the value doesn't depend on
59
+ * constructor arguments. The `declare` suggestion is only offered when every path through the
60
+ * constructor assigns the field, i.e. for an unconditional top-level assignment.
61
+ *
62
+ * Only plain `=` assignments count: compound assignments read the field rather than
63
+ * re-initialize it. A field with an initializer is only reported for an unconditional
64
+ * top-level assignment, as a conditional one overrides a default. Private (`#`) and decorated
65
+ * fields are ignored: they are runtime declarations that `declare` can't replace.
66
+ *
67
+ * @example
68
+ * // Invalid
69
+ * class ModalManager {
70
+ * private modals: Modal[];
71
+ * constructor() {
72
+ * this.modals = [];
73
+ * }
74
+ * }
75
+ *
76
+ * @example
77
+ * // Valid - initialized at the declaration
78
+ * class ModalManager {
79
+ * private modals: Modal[] = [];
80
+ * }
81
+ *
82
+ * @example
83
+ * // Valid - declared only, assigned in the constructor
84
+ * class ModalManager {
85
+ * declare private modals: Modal[];
86
+ * constructor(modals: Modal[]) {
87
+ * this.modals = modals;
88
+ * }
89
+ * }
90
+ */
91
+ export default createRule({
92
+ name: 'no-constructor-assigned-field',
93
+ meta: {
94
+ type: 'suggestion',
95
+ docs: {
96
+ description:
97
+ 'Disallow assigning class fields in the constructor when they are also declared as class fields',
98
+ },
99
+ hasSuggestions: true,
100
+ messages: {
101
+ assignedInConstructor:
102
+ "Field '{{name}}' is declared as a class field and also assigned in the constructor. " +
103
+ 'Either initialize it at the declaration and remove the constructor assignment, ' +
104
+ 'or mark it `declare`{{declareHint}}.',
105
+ addDeclare: 'Mark the field `declare`.',
106
+ },
107
+ schema: [],
108
+ },
109
+ defaultOptions: [],
110
+ create(context) {
111
+ /**
112
+ * Fields assigned in a constructor, mapped to whether any of those assignments is an
113
+ * unconditional top-level statement.
114
+ * @type {Map<PropertyDefinition, boolean>}
115
+ */
116
+ const assignedFields = new Map();
117
+
118
+ return {
119
+ AssignmentExpression(node) {
120
+ if (
121
+ node.operator !== '=' ||
122
+ node.left.type !== 'MemberExpression' ||
123
+ node.left.object.type !== 'ThisExpression'
124
+ ) {
125
+ return;
126
+ }
127
+ const name = ASTUtils.getPropertyName(node.left);
128
+ if (name === null) {
129
+ return;
130
+ }
131
+ const constructor = getEnclosingConstructor(node);
132
+ if (!constructor) {
133
+ return;
134
+ }
135
+
136
+ const field = constructor.parent.body.find(
137
+ /** @returns {member is PropertyDefinition} */
138
+ (member) =>
139
+ member.type === 'PropertyDefinition' &&
140
+ !member.static &&
141
+ !member.declare &&
142
+ member.decorators.length === 0 &&
143
+ ASTUtils.getPropertyName(member) === name,
144
+ );
145
+ if (!field) {
146
+ return;
147
+ }
148
+
149
+ const statement = node.parent;
150
+ const unconditional =
151
+ statement.type === 'ExpressionStatement' && statement.parent === constructor.value.body;
152
+ assignedFields.set(field, Boolean(assignedFields.get(field)) || unconditional);
153
+ },
154
+ 'ClassBody:exit'(classBody) {
155
+ for (const member of classBody.body) {
156
+ if (member.type !== 'PropertyDefinition' || !assignedFields.has(member)) {
157
+ continue;
158
+ }
159
+ const unconditional = Boolean(assignedFields.get(member));
160
+ if (member.value && !unconditional) {
161
+ continue;
162
+ }
163
+
164
+ const declareHint = getDeclareHint(member);
165
+ context.report({
166
+ node: member,
167
+ messageId: 'assignedInConstructor',
168
+ data: { name: ASTUtils.getPropertyName(member), declareHint },
169
+ suggest:
170
+ unconditional && declareHint === ''
171
+ ? [
172
+ {
173
+ messageId: 'addDeclare',
174
+ fix: (fixer) => fixer.insertTextBefore(member, 'declare '),
175
+ },
176
+ ]
177
+ : [],
178
+ });
179
+ }
180
+ },
181
+ };
182
+ },
183
+ });
@@ -21,7 +21,6 @@ export const baseSpecRules = {
21
21
  '@typescript-eslint/no-unused-expressions': 'off',
22
22
  '@typescript-eslint/no-unused-vars': 'off',
23
23
  '@typescript-eslint/no-use-before-define': 'off',
24
- '@typescript-eslint/ban-ts-comment': 'off',
25
24
 
26
25
  'import/prefer-default-export': 'off',
27
26
 
@@ -63,6 +62,7 @@ export function createTestConfig() {
63
62
  'import/named': 'off',
64
63
  'mui/disallow-active-element-as-key-event-target': 'error',
65
64
  'mui/consistent-production-guard': 'off',
65
+ 'mui/no-constructor-assigned-field': 'off',
66
66
 
67
67
  // disable eslint-plugin-jsx-a11y
68
68
  // tests are not driven by assistive technology
@@ -87,7 +87,6 @@ export function createTestConfig() {
87
87
  'react/no-unused-prop-types': 'off',
88
88
  // Part of the migration away from airbnb config. Turned of initially.
89
89
  '@typescript-eslint/no-empty-function': 'off',
90
- '@typescript-eslint/ban-ts-comment': 'off',
91
90
  'testing-library/no-node-access': 'off',
92
91
  '@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
93
92
  // end migration
package/src/utils/git.mjs CHANGED
@@ -9,68 +9,63 @@ import gitUrlParse from 'git-url-parse';
9
9
  */
10
10
 
11
11
  /**
12
- * Get current repository info from git remote
12
+ * The repository a remote URL points at, if it is a GitHub repository under `mui`.
13
+ * @param {string} url
14
+ * @returns {{ owner: string, repo: string } | null}
15
+ */
16
+ function muiRepositoryOf(url) {
17
+ try {
18
+ const parsed = gitUrlParse(url);
19
+ return parsed.source === 'github.com' && parsed.owner === 'mui'
20
+ ? { owner: parsed.owner, repo: parsed.name }
21
+ : null;
22
+ } catch {
23
+ // Not a URL git-url-parse understands, such as a local path: not a mui repository either.
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * The canonical repository's remote, as opposed to a fork's: `upstream`, else `origin`, each only if
30
+ * it points at a mui repository. Reads the configured URLs, which `insteadOf` does not rewrite.
13
31
  * @param {string} [cwd=process.cwd()]
14
- * @returns {Promise<RepoInfo>} Repository owner and name
32
+ * @returns {Promise<RepoInfo>}
15
33
  */
16
34
  export async function getRepositoryInfo(cwd = process.cwd()) {
17
- /**
18
- * @type {Record<string, string>}
19
- */
20
- const cause = {};
21
- const { stdout } = await $({ cwd })`git remote -v`;
22
- const lines = stdout.trim().split('\n');
23
- /**
24
- * @type {Set<string>}
25
- */
26
- const repoRemotes = new Set();
27
- /**
28
- * @type {Map<string, { owner: string, repo: string }>}
29
- */
30
- const validRemotes = new Map();
31
-
32
- for (const line of lines) {
33
- // Match pattern: "remoteName url (fetch|push)"
34
- const [remoteName, url, type] = line.trim().split(/\s+/, 3);
35
- repoRemotes.add(remoteName);
36
- if (type === '(fetch)') {
37
- try {
38
- const parsed = gitUrlParse(url);
39
- if (parsed.source !== 'github.com' || parsed.owner !== 'mui') {
40
- cause[remoteName] = `Remote is not a GitHub repository under 'mui' organization: ${url}`;
41
- continue;
42
- }
43
- if (!validRemotes.has(remoteName)) {
44
- validRemotes.set(remoteName, { owner: parsed.owner, repo: parsed.name });
45
- }
46
- } catch (error) {
47
- cause[remoteName] = `Failed to parse URL for remote ${remoteName}: ${url}`;
48
- }
49
- } else if (type !== '(push)') {
50
- throw new Error(`Unexpected line format for "git remote -v": "${line}"`);
51
- }
35
+ // Exits 1 when neither remote is configured; the error below reports that.
36
+ const { stdout } = await $({
37
+ cwd,
38
+ reject: false,
39
+ })`git config --get-regexp ${'^remote\\.(upstream|origin)\\.url$'}`;
40
+ /** @type {Map<string, string>} */
41
+ const urls = new Map();
42
+ for (const line of stdout.split('\n').filter(Boolean)) {
43
+ const [key, url] = line.split(' ', 2);
44
+ urls.set(key.slice('remote.'.length, -'.url'.length), url);
52
45
  }
53
46
 
54
- const preferredOrder = ['upstream', 'origin', ...validRemotes.keys()];
55
- for (const name of preferredOrder) {
56
- const match = validRemotes.get(name);
57
- if (match) {
58
- return { ...match, remoteName: name };
47
+ for (const remoteName of ['upstream', 'origin']) {
48
+ const url = urls.get(remoteName);
49
+ const repository = url ? muiRepositoryOf(url) : null;
50
+ if (repository) {
51
+ return { ...repository, remoteName };
59
52
  }
60
53
  }
61
54
 
55
+ const found = [...urls].map(([name, url]) => `${name}: ${url}`).join(', ') || 'none';
62
56
  throw new Error(
63
- `Failed to find correct remote(s) in : ${Array.from(repoRemotes.keys()).join(', ')}`,
64
- { cause },
57
+ `No canonical remote: neither \`upstream\` nor \`origin\` points at a mui repository (${found}). ` +
58
+ 'Add an `upstream` remote for it, or name the remote explicitly where the command allows it.',
65
59
  );
66
60
  }
67
61
 
68
62
  /**
69
63
  * Get current git SHA
64
+ * @param {string} [cwd=process.cwd()]
70
65
  * @returns {Promise<string>} Current git commit SHA
71
66
  */
72
- export async function getCurrentGitSha() {
73
- const result = await $`git rev-parse HEAD`;
67
+ export async function getCurrentGitSha(cwd = process.cwd()) {
68
+ const result = await $({ cwd })`git rev-parse HEAD`;
74
69
  return result.stdout.trim();
75
70
  }
76
71
 
@@ -84,3 +79,69 @@ export async function remoteGitTagExists(tagName, cwd = process.cwd()) {
84
79
  const { stdout } = await $({ cwd })`git ls-remote --tags origin refs/tags/${tagName}`;
85
80
  return stdout.trim().length > 0;
86
81
  }
82
+
83
+ /**
84
+ * The default branch of `remote`, as the remote itself reports it.
85
+ * @param {string} remote
86
+ * @param {string} cwd
87
+ * @returns {Promise<string>}
88
+ */
89
+ async function getDefaultBranchOf(remote, cwd) {
90
+ const { stdout } = await $({ cwd })`git ls-remote --symref ${remote} HEAD`;
91
+ const match = /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(stdout);
92
+ if (!match) {
93
+ throw new Error(`Remote "${remote}" does not report a default branch.`);
94
+ }
95
+ return match[1];
96
+ }
97
+
98
+ /**
99
+ * The commit HEAD should be compared against: where it forked from the base branch, or its parent
100
+ * when HEAD is already on the base branch.
101
+ * @param {Object} [options]
102
+ * @param {string} [options.cwd]
103
+ * @param {string} [options.remote] - Defaults to the canonical remote.
104
+ * @param {string} [options.baseBranch] - Defaults to the remote's default branch.
105
+ * @returns {Promise<string>}
106
+ */
107
+ export async function resolveBaseline(options = {}) {
108
+ const cwd = options.cwd ?? process.cwd();
109
+ const remote = options.remote ?? (await getRepositoryInfo(cwd)).remoteName;
110
+ const baseBranch = options.baseBranch ?? (await getDefaultBranchOf(remote, cwd));
111
+
112
+ await $({ cwd })`git fetch --no-tags ${remote} ${baseBranch}`;
113
+ const [forkPoint, head] = await Promise.all([
114
+ $({ cwd })`git merge-base HEAD FETCH_HEAD`.then((result) => result.stdout.trim()),
115
+ getCurrentGitSha(cwd),
116
+ ]);
117
+ if (forkPoint !== head) {
118
+ return forkPoint;
119
+ }
120
+
121
+ const parent = await $({ cwd, reject: false })`git rev-parse --verify HEAD~1`;
122
+ if (parent.exitCode !== 0) {
123
+ throw new Error(
124
+ 'HEAD has no parent commit to compare against. A shallow clone is the usual cause.',
125
+ );
126
+ }
127
+ return parent.stdout.trim();
128
+ }
129
+
130
+ /**
131
+ * The most recent version tag reachable from HEAD — where a changelog's range starts.
132
+ * @param {Object} opts
133
+ * @param {string} opts.cwd
134
+ * @param {boolean} [opts.fetchAll=true] Whether to fetch all tags from all remotes before finding the latest tag.
135
+ * @returns {Promise<string>}
136
+ */
137
+ export async function findLatestTaggedVersion(opts) {
138
+ const $$ = $({ cwd: opts.cwd });
139
+ const fetchAll = opts.fetchAll ?? true;
140
+ if (fetchAll) {
141
+ // --force to update any existing tags that may have changed to avoid the clobering error.
142
+ const { remoteName } = await getRepositoryInfo(opts.cwd);
143
+ await $$`git fetch --tags --force ${remoteName}`;
144
+ }
145
+ const { stdout } = await $$`git describe --tags --abbrev=0 --match ${'v*'}`; // only include "version-tags"
146
+ return stdout.trim();
147
+ }
@@ -261,3 +261,30 @@ export async function endToEndGhAuthGetToken({ log = false } = {}) {
261
261
  export async function clearGitHubAuth() {
262
262
  await credentials.deleteKey(GITHUB_APP_CREDENTIAL_KEY);
263
263
  }
264
+
265
+ /**
266
+ * Returns when a rate-limited request may be retried, or `null` for other errors.
267
+ * https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit
268
+ *
269
+ * @param {unknown} error
270
+ * @returns {Date | null}
271
+ */
272
+ export function getRateLimitRetryTime(error) {
273
+ const { status, response, message } = /** @type {any} */ (error) ?? {};
274
+ if (status !== 403 && status !== 429) {
275
+ return null;
276
+ }
277
+ const headers = response?.headers ?? {};
278
+ const now = Date.now();
279
+ let retryTime;
280
+ if (headers['retry-after']) {
281
+ retryTime = now + Number(headers['retry-after']) * 1000;
282
+ } else if (headers['x-ratelimit-remaining'] === '0' && headers['x-ratelimit-reset']) {
283
+ retryTime = Number(headers['x-ratelimit-reset']) * 1000;
284
+ } else if (/rate limit/i.test(message ?? '')) {
285
+ retryTime = now;
286
+ } else {
287
+ return null;
288
+ }
289
+ return new Date(Math.max(retryTime, now + 60_000));
290
+ }