@mui/internal-code-infra 0.0.4-canary.98 → 0.1.1-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +4 -0
  2. package/build/babel-config.d.mts +4 -4
  3. package/build/babelPluginInlineEnvironmentVariables.d.mts +11 -0
  4. package/build/brokenLinksChecker/index.d.mts +147 -0
  5. package/build/changelog/fetchChangelogs.d.mts +3 -0
  6. package/build/cli/cmdArgosPush.d.mts +6 -0
  7. package/build/cli/cmdBuild.d.mts +45 -0
  8. package/build/cli/cmdCopyFiles.d.mts +12 -0
  9. package/build/cli/cmdGenerateChangelog.d.mts +15 -0
  10. package/build/cli/cmdListWorkspaces.d.mts +14 -2
  11. package/build/cli/cmdNetlifyIgnore.d.mts +9 -0
  12. package/build/cli/cmdPublish.d.mts +20 -2
  13. package/build/cli/cmdPublishCanary.d.mts +18 -0
  14. package/build/cli/cmdPublishNewPackage.d.mts +6 -0
  15. package/build/cli/cmdRenameScope.d.mts +18 -0
  16. package/build/cli/cmdSetVersionOverrides.d.mts +3 -0
  17. package/build/eslint/mui/rules/no-restricted-resolved-imports.d.mts +6 -0
  18. package/build/eslint/testConfig.d.mts +1 -7
  19. package/build/utils/babel.d.mts +6 -0
  20. package/build/utils/build.d.mts +3 -15
  21. package/build/utils/extractErrorCodes.d.mts +16 -0
  22. package/build/utils/git.d.mts +9 -0
  23. package/build/utils/github.d.mts +12 -0
  24. package/build/utils/pnpm.d.mts +147 -9
  25. package/build/utils/testUtils.d.mts +21 -0
  26. package/build/utils/typescript.d.mts +2 -2
  27. package/package.json +54 -56
  28. package/src/babel-config.mjs +21 -11
  29. package/src/babelPluginInlineEnvironmentVariables.mjs +45 -0
  30. package/src/brokenLinksChecker/index.mjs +3 -3
  31. package/src/cli/cmdArgosPush.mjs +2 -4
  32. package/src/cli/cmdBuild.mjs +8 -9
  33. package/src/cli/cmdCopyFiles.mjs +3 -3
  34. package/src/cli/cmdListWorkspaces.mjs +3 -31
  35. package/src/cli/cmdPublish.mjs +23 -29
  36. package/src/cli/cmdRenameScope.mjs +77 -0
  37. package/src/cli/cmdSetVersionOverrides.mjs +32 -25
  38. package/src/cli/cmdVale.mjs +3 -6
  39. package/src/cli/cmdValidateBuiltTypes.mjs +3 -4
  40. package/src/cli/index.mjs +2 -0
  41. package/src/eslint/testConfig.mjs +3 -31
  42. package/src/untyped-plugins.d.ts +11 -49
  43. package/src/utils/build.mjs +0 -39
  44. package/src/utils/extractErrorCodes.mjs +12 -7
  45. package/src/utils/pnpm.mjs +380 -16
  46. package/src/utils/testUtils.mjs +35 -0
  47. package/src/utils/typescript.mjs +60 -42
@@ -95,73 +95,35 @@ declare module '@next/eslint-plugin-next' {
95
95
  export default config;
96
96
  }
97
97
 
98
- declare module '@babel/plugin-transform-runtime' {
99
- import type { PluginItem } from '@babel/core';
100
-
101
- declare const plugin: PluginItem;
102
- export default plugin;
103
- }
104
-
105
- declare module '@babel/plugin-syntax-jsx' {
106
- import type { PluginItem } from '@babel/core';
107
-
108
- declare const plugin: PluginItem;
109
- export default plugin;
110
- }
111
-
112
- declare module '@babel/plugin-syntax-typescript' {
113
- import type { PluginItem } from '@babel/core';
114
-
115
- declare const plugin: PluginItem;
116
- export default plugin;
117
- }
118
-
119
98
  declare module 'babel-plugin-optimize-clsx' {
120
- import type { PluginItem } from '@babel/core';
99
+ import type { PluginTarget } from '@babel/core';
121
100
 
122
- declare const plugin: PluginItem;
101
+ declare const plugin: PluginTarget;
123
102
  export default plugin;
124
103
  }
125
104
 
126
105
  declare module 'babel-plugin-transform-react-remove-prop-types' {
127
- import type { PluginItem } from '@babel/core';
106
+ import type { PluginTarget } from '@babel/core';
128
107
 
129
- declare const plugin: PluginItem;
108
+ declare const plugin: PluginTarget;
130
109
  export default plugin;
131
110
  }
132
111
 
133
- declare module 'babel-plugin-transform-import-meta' {
134
- import type { PluginItem } from '@babel/core';
112
+ declare module 'babel-plugin-transform-remove-imports' {
113
+ // The package's own bundled types mislabel the export's signature.
114
+ import type { PluginTarget } from '@babel/core';
135
115
 
136
- declare const plugin: PluginItem;
116
+ declare const plugin: PluginTarget;
137
117
  export default plugin;
138
118
  }
139
119
 
140
- declare module 'babel-plugin-transform-inline-environment-variables' {
141
- import type { PluginItem } from '@babel/core';
120
+ declare module 'babel-plugin-transform-import-meta' {
121
+ import type { PluginTarget } from '@babel/core';
142
122
 
143
- declare const plugin: PluginItem;
123
+ declare const plugin: PluginTarget;
144
124
  export default plugin;
145
125
  }
146
126
 
147
- declare module '@babel/preset-react' {
148
- import type { PluginItem } from '@babel/core';
149
-
150
- export type Options = {
151
- runtime: 'string';
152
- };
153
-
154
- declare const preset: PluginItem;
155
- export default preset;
156
- }
157
-
158
- declare module '@babel/preset-typescript' {
159
- import type { PluginItem } from '@babel/core';
160
-
161
- declare const preset: PluginItem;
162
- export default preset;
163
- }
164
-
165
127
  declare module 'stylelint-config-standard' {
166
128
  import type { Config } from 'stylelint';
167
129
 
@@ -894,42 +894,3 @@ export function measureFn(label) {
894
894
  const endMark = `${label}-end`;
895
895
  return performance.measure(label, startMark, endMark);
896
896
  }
897
-
898
- /**
899
- * A utility to map a function over an array of items in a worker pool.
900
- *
901
- * This function will create a pool of workers and distribute the items to be processed among them.
902
- * Each worker will process items sequentially, but multiple workers will run in parallel.
903
- *
904
- * @function
905
- * @template T
906
- * @template R
907
- * @param {T[]} items
908
- * @param {(item: T) => Promise<R>} mapper
909
- * @param {number} concurrency
910
- * @returns {Promise<(R|Error)[]>}
911
- */
912
- export async function mapConcurrently(items, mapper, concurrency) {
913
- if (!items.length) {
914
- return Promise.resolve([]); // nothing to do
915
- }
916
- const itemIterator = items.entries();
917
- const count = Math.min(concurrency, items.length);
918
- const workers = [];
919
- /**
920
- * @type {(R|Error)[]}
921
- */
922
- const results = new Array(items.length);
923
- for (let i = 0; i < count; i += 1) {
924
- const worker = Promise.resolve().then(async () => {
925
- for (const [index, item] of itemIterator) {
926
- // eslint-disable-next-line no-await-in-loop
927
- const res = await mapper(item);
928
- results[index] = res;
929
- }
930
- });
931
- workers.push(worker);
932
- }
933
- await Promise.all(workers);
934
- return results;
935
- }
@@ -6,9 +6,12 @@ import minifyErrorsPlugin from '@mui/internal-babel-plugin-minify-errors';
6
6
  import { globby } from 'globby';
7
7
  import * as fs from 'node:fs/promises';
8
8
  import * as path from 'node:path';
9
+ import { mapAsync } from 'es-toolkit/array';
9
10
 
10
11
  import { getWorkspacePackages } from './pnpm.mjs';
11
- import { BASE_IGNORES, mapConcurrently } from './build.mjs';
12
+ import { BASE_IGNORES } from './build.mjs';
13
+
14
+ const PLAIN_TS_EXTENSION = /\.[cm]?ts$/;
12
15
 
13
16
  /**
14
17
  * @typedef {Object} Args
@@ -51,18 +54,20 @@ async function getFilesForPackage(pkg) {
51
54
  * @param {Set<string | Error>} errors
52
55
  * @param {import('@mui/internal-babel-plugin-minify-errors').Options['detection']} [detection='opt-in']
53
56
  */
54
- async function extractErrorCodesForWorkspace(files, errors, detection = 'opt-in') {
55
- await mapConcurrently(
57
+ export async function extractErrorCodesForFiles(files, errors, detection = 'opt-in') {
58
+ await mapAsync(
56
59
  files,
57
60
  async (fullPath) => {
58
61
  const code = await fs.readFile(fullPath, 'utf8');
62
+ // Plain .ts files must not get the JSX parser: `<T = unknown>(x) => x` would be read as JSX.
63
+ const isPlainTs = PLAIN_TS_EXTENSION.test(fullPath);
59
64
  // Replicate how Babel is run when preparing the source to be published on npm, so we can extract the same messages.
60
65
  await transformAsync(code, {
61
66
  filename: fullPath,
62
67
  sourceType: 'module',
63
68
  plugins: [
64
- [babelSyntaxTypescript, { isTSX: true }],
65
- [babelSyntaxJsx],
69
+ [babelSyntaxTypescript, { isTSX: !isPlainTs }],
70
+ ...(isPlainTs ? [] : [babelSyntaxJsx]),
66
71
  [minifyErrorsPlugin, { collectErrors: errors, detection }],
67
72
  ],
68
73
  configFile: false,
@@ -71,7 +76,7 @@ async function extractErrorCodesForWorkspace(files, errors, detection = 'opt-in'
71
76
  code: false,
72
77
  });
73
78
  },
74
- 30,
79
+ { concurrency: 30 },
75
80
  );
76
81
  }
77
82
 
@@ -108,7 +113,7 @@ export default async function extractErrorCodes(args) {
108
113
  // Extract error codes from said files.
109
114
  const filesToProcess = files.flat();
110
115
  console.log(`🔍 Extracting error codes from ${filesToProcess.length} files...`);
111
- await extractErrorCodesForWorkspace(filesToProcess, errors, detection);
116
+ await extractErrorCodesForFiles(filesToProcess, errors, detection);
112
117
 
113
118
  // Partition collected entries into valid messages and unminifyable errors.
114
119
  /** @type {string[]} */
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { getPublishedByPolicy } from '@pnpm/config.version-policy';
4
+ import { parseWantedDependency } from '@pnpm/parse-wanted-dependency';
3
5
  import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
4
6
  import { $ } from 'execa';
5
7
  import * as fs from 'node:fs/promises';
@@ -160,6 +162,21 @@ export async function getPublishRegistry(packagePath) {
160
162
  return registryUrl.href;
161
163
  }
162
164
 
165
+ /**
166
+ * Get the version to release from the workspace-root package.json.
167
+ *
168
+ * Resolves the workspace root so the version is the monorepo's regardless of
169
+ * which directory publish runs from.
170
+ *
171
+ * @param {string} [cwd] - Directory to resolve the workspace root from
172
+ * @returns {Promise<string | null>} Version string, or null when absent/invalid
173
+ */
174
+ export async function getReleaseVersion(cwd = process.cwd()) {
175
+ const workspaceDir = (await findWorkspaceDir(cwd)) ?? cwd;
176
+ const { version } = await readPackageJson(workspaceDir);
177
+ return version ? semver.valid(version) : null;
178
+ }
179
+
163
180
  /**
164
181
  * Whether a registry requires a package to exist before CI can publish to it.
165
182
  * @param {string} registry - Normalized registry URL
@@ -303,11 +320,29 @@ export async function publishPackages(packages, options = {}) {
303
320
  * @property {boolean} [includeDev=true] - Whether to include devDependencies in the traversal
304
321
  */
305
322
 
323
+ /**
324
+ * The package a `workspace:` alias spec resolves to, if it is one.
325
+ *
326
+ * @param {string} spec - Dependency spec
327
+ * @returns {string | null} The aliased package name, or null for a plain spec
328
+ */
329
+ function aliasTarget(spec) {
330
+ if (!spec.startsWith('workspace:')) {
331
+ return null;
332
+ }
333
+ const { alias, bareSpecifier } = parseWantedDependency(spec.slice('workspace:'.length));
334
+ // A plain range parses as one half or the other (`*` as an alias, `^1.0.0` as a
335
+ // specifier); only an aliased spec carries both.
336
+ return bareSpecifier === undefined ? null : (alias ?? null);
337
+ }
338
+
306
339
  /**
307
340
  * Get all transitive workspace dependencies for a set of packages.
308
341
  *
309
342
  * Only follows deps whose version spec starts with `workspace:` (e.g. `workspace:*`
310
- * or `workspace:^`), meaning they are sourced directly from the monorepo. Pinned
343
+ * or `workspace:^`), meaning they are sourced directly from the monorepo. An
344
+ * alias spec (`workspace:@scope/name@range`) is followed to the package it
345
+ * targets, which need not match the dependency key. Pinned
311
346
  * external versions (e.g. `^1.0.0`) are ignored even when the package name exists
312
347
  * in the workspace. Traverses `dependencies` and optionally `devDependencies`.
313
348
  * Results are cached per package so each package is read from disk at most once
@@ -344,14 +379,15 @@ export async function getTransitiveDependencies(packageNames, options = {}) {
344
379
  ...Object.entries(pkgJson.dependencies ?? {}),
345
380
  ...(includeDev ? Object.entries(pkgJson.devDependencies ?? {}) : []),
346
381
  ];
347
- const workspaceDeps = allDepEntries
348
- .filter(
349
- ([dep, spec]) =>
350
- workspacePathByName.has(dep) &&
351
- typeof spec === 'string' &&
352
- spec.startsWith('workspace:'),
353
- )
354
- .map(([dep]) => dep);
382
+ const workspaceDeps = allDepEntries.flatMap(([dep, spec]) => {
383
+ if (typeof spec !== 'string' || !spec.startsWith('workspace:')) {
384
+ return [];
385
+ }
386
+ // An aliased spec (`workspace:@scope/name@range`) names the workspace
387
+ // package it resolves to, which need not match the dependency key.
388
+ const name = aliasTarget(spec) ?? dep;
389
+ return workspacePathByName.has(name) ? [name] : [];
390
+ });
355
391
 
356
392
  const recursiveResults = await Promise.all(workspaceDeps.map(collectDeps));
357
393
  return new Set([...workspaceDeps, ...recursiveResults.flatMap((s) => [...s])]);
@@ -541,26 +577,354 @@ export async function writeOverridesToWorkspace(workspaceDir, overrides) {
541
577
  }
542
578
 
543
579
  /**
544
- * Resolve a package@version specifier to an exact version
580
+ * Map a package name onto a different npm scope.
581
+ * @param {string} name - Package name, e.g. `@base-ui/mosaic`
582
+ * @param {string} fromScope - Scope to replace, e.g. `@base-ui`
583
+ * @param {string} toScope - Replacement scope, e.g. `@base-ui-private`
584
+ * @returns {string | null} The renamed package, or null when the scope doesn't match
585
+ */
586
+ function renameScope(name, fromScope, toScope) {
587
+ const prefix = `${fromScope}/`;
588
+ return name.startsWith(prefix) ? `${toScope}/${name.slice(prefix.length)}` : null;
589
+ }
590
+
591
+ /**
592
+ * Move the publishable workspace packages in one scope to another.
593
+ *
594
+ * Only packages that are part of the workspace are touched, so dependencies
595
+ * that merely share the scope but come from the registry (say `@base-ui/react`
596
+ * alongside a workspace `@base-ui/mosaic`) are left alone. Dependents keep the
597
+ * original dependency name and gain a `workspace:` alias, so imports in the
598
+ * repo resolve exactly as before.
599
+ *
600
+ * @param {(PublicPackage | PrivatePackage)[]} packages - All workspace packages
601
+ * @param {string} fromScope - Scope to move away from
602
+ * @param {string} toScope - Scope to move to
603
+ * @returns {Promise<Map<string, string>>} Old package name to new package name
604
+ */
605
+ export async function renameWorkspaceScope(packages, fromScope, toScope) {
606
+ // Renaming a scope onto itself only rewrites every manifest to itself. The
607
+ // CLI rejects this earlier, but the guard belongs with the operation so a
608
+ // direct caller cannot trip the silent churn either.
609
+ if (fromScope === toScope) {
610
+ throw new Error(`Cannot rename ${fromScope} to itself.`);
611
+ }
612
+
613
+ /** @type {Map<string, string>} */
614
+ const renamed = new Map();
615
+
616
+ for (const pkg of packages) {
617
+ if (pkg.isPrivate) {
618
+ continue;
619
+ }
620
+ const newName = renameScope(pkg.name, fromScope, toScope);
621
+ if (newName) {
622
+ renamed.set(pkg.name, newName);
623
+ }
624
+ }
625
+
626
+ // A rename that lands on a name another package keeps would leave the
627
+ // workspace with two packages sharing a name. Catch it up front, before any
628
+ // manifest is written.
629
+ /** @type {Map<string, string>} */
630
+ const finalNames = new Map();
631
+ for (const pkg of packages) {
632
+ if (!pkg.name) {
633
+ continue;
634
+ }
635
+ const finalName = renamed.get(pkg.name) ?? pkg.name;
636
+ const owner = finalNames.get(finalName);
637
+ if (owner) {
638
+ throw new Error(
639
+ `Cannot rename ${fromScope} to ${toScope}: ${pkg.name} and ${owner} would both be named ${finalName}.`,
640
+ );
641
+ }
642
+ finalNames.set(finalName, pkg.name);
643
+ }
644
+
645
+ // Rewrite in memory first. A dependency that cannot be pointed at its renamed
646
+ // package has to fail before anything is written, or the workspace is left
647
+ // half renamed with nothing to restore it.
648
+ const rewritten = await Promise.all(
649
+ packages.map(async (pkg) => {
650
+ const packageJson = await readPackageJson(pkg.path);
651
+ const label = pkg.name ?? pkg.path;
652
+ /** @type {string[]} */
653
+ const problems = [];
654
+ let changed = false;
655
+
656
+ const newName = pkg.name ? renamed.get(pkg.name) : undefined;
657
+ if (newName) {
658
+ packageJson.name = newName;
659
+ changed = true;
660
+ }
661
+
662
+ // peerDependencies are deliberately absent: a peer is supplied by the
663
+ // consumer, who installs the package under its original name. An alias
664
+ // range would be unsatisfiable for them.
665
+ for (const deps of [
666
+ packageJson.dependencies,
667
+ packageJson.devDependencies,
668
+ packageJson.optionalDependencies,
669
+ ]) {
670
+ if (!deps) {
671
+ continue;
672
+ }
673
+ for (const [depName, spec] of Object.entries(deps)) {
674
+ if (!spec) {
675
+ continue;
676
+ }
677
+
678
+ const existingTarget = aliasTarget(spec);
679
+ if (existingTarget) {
680
+ if (renamed.has(existingTarget)) {
681
+ problems.push(
682
+ `"${depName}" in ${label} already aliases ${existingTarget}, which is being renamed. Point it at the package directly so it can be rewritten.`,
683
+ );
684
+ }
685
+ // Otherwise it already aliases what it should, as a re-run does.
686
+ continue;
687
+ }
688
+
689
+ const target = renamed.get(depName);
690
+ if (!target) {
691
+ continue;
692
+ }
693
+ if (!spec.startsWith('workspace:')) {
694
+ // Only `workspace:` specs can be aliased. Anything else would keep
695
+ // resolving the original name from the registry after the rename.
696
+ problems.push(
697
+ `"${depName}" in ${label} is required as "${spec}" rather than a workspace: dependency, so it cannot be pointed at ${target}.`,
698
+ );
699
+ continue;
700
+ }
701
+ deps[depName] = `workspace:${target}@${spec.slice('workspace:'.length)}`;
702
+ changed = true;
703
+ }
704
+ }
705
+
706
+ return { path: pkg.path, packageJson, changed, problems };
707
+ }),
708
+ );
709
+
710
+ const problems = rewritten.flatMap((entry) => entry.problems);
711
+ if (problems.length > 0) {
712
+ throw new Error(`Cannot rename ${fromScope} to ${toScope}:\n ${problems.join('\n ')}`);
713
+ }
714
+
715
+ await Promise.all(
716
+ rewritten
717
+ .filter((entry) => entry.changed)
718
+ .map((entry) => writePackageJson(entry.path, entry.packageJson)),
719
+ );
720
+
721
+ return renamed;
722
+ }
723
+
724
+ /**
725
+ * The versions a `minimumReleaseAgeExclude` policy exempts from the cooldown. A
726
+ * policy answers `true` for a package that is exempt at any version, or the list
727
+ * of versions exempted individually.
728
+ *
729
+ * @param {import('@pnpm/config.version-policy').PublishedByPolicy} policy - Policy from {@link getMinimumReleaseAgePolicy}
730
+ * @param {string} name - Package name
731
+ * @returns {true | string[]} `true` when every version is exempt, otherwise the exempt versions
732
+ */
733
+ function cooldownExemptions(policy, name) {
734
+ const exemption = policy.publishedByExclude?.(name);
735
+ if (exemption === true) {
736
+ return true;
737
+ }
738
+ return Array.isArray(exemption) ? exemption : [];
739
+ }
740
+
741
+ /**
742
+ * Pick the version to pin for a specifier whose newest match is too recent to
743
+ * satisfy a `minimumReleaseAge` cooldown.
744
+ *
745
+ * Only the three specifier shapes this tool is given are handled. An exact
746
+ * version is not a choice, so it stands and the install reports it. A range
747
+ * resolves within itself. A dist-tag repoints to the highest aged-in version
748
+ * sharing the major and prerelease-ness — except `latest`, which pnpm allows to
749
+ * cross majors. Unlike pnpm, a deprecated version is not passed over: `pnpm info`
750
+ * lists bare version strings, so that flag would cost a request per candidate.
751
+ *
752
+ * @param {string} requested - The specifier as given: a dist-tag, range or exact version
753
+ * @param {string} resolvedVersion - Version the specifier resolves to today
754
+ * @param {string[]} versions - Every published version, from `pnpm info <pkg> versions`
755
+ * @param {Record<string, string>} publishTimes - Version to ISO publish date, from `pnpm info <pkg> time`
756
+ * @param {number} cutoff - Epoch ms; versions published after this are too recent
757
+ * @param {string[]} [exemptVersions] - Versions `minimumReleaseAgeExclude` installs regardless of age
758
+ * @returns {string | null} Version to pin, or null when nothing qualifies
759
+ * @internal exported for unit tests
760
+ */
761
+ export function selectAgedVersion(
762
+ requested,
763
+ resolvedVersion,
764
+ versions,
765
+ publishTimes,
766
+ cutoff,
767
+ exemptVersions = [],
768
+ ) {
769
+ const exempt = new Set(exemptVersions);
770
+
771
+ /**
772
+ * Whether the install would accept this version: old enough, or excluded from
773
+ * the cooldown altogether.
774
+ *
775
+ * @param {string} version
776
+ * @returns {boolean}
777
+ */
778
+ const isInstallable = (version) => {
779
+ if (exempt.has(version)) {
780
+ return true;
781
+ }
782
+ const published = Date.parse(publishTimes[version]);
783
+ return Number.isFinite(published) && published <= cutoff;
784
+ };
785
+
786
+ // An unknown publish date leaves nothing to compare against, so the
787
+ // resolution stands rather than being swapped for another build.
788
+ if (!Object.hasOwn(publishTimes, resolvedVersion) || isInstallable(resolvedVersion)) {
789
+ return resolvedVersion;
790
+ }
791
+
792
+ if (semver.valid(requested)) {
793
+ return resolvedVersion;
794
+ }
795
+
796
+ // `time` keeps an entry for a version after it is unpublished, so candidates
797
+ // come from the version list rather than from the dates.
798
+ const candidates = versions.filter(isInstallable);
799
+
800
+ if (semver.validRange(requested)) {
801
+ return semver.maxSatisfying(candidates, requested, true);
802
+ }
803
+
804
+ const resolved = semver.parse(resolvedVersion, true);
805
+ if (!resolved) {
806
+ return resolvedVersion;
807
+ }
808
+ const resolvedIsPrerelease = resolved.prerelease.length > 0;
809
+
810
+ let best = null;
811
+ for (const version of candidates) {
812
+ const parsed = semver.parse(version, true);
813
+ if (
814
+ !parsed ||
815
+ (requested !== 'latest' && parsed.major !== resolved.major) ||
816
+ parsed.prerelease.length > 0 !== resolvedIsPrerelease
817
+ ) {
818
+ continue;
819
+ }
820
+ if (!best || semver.gt(version, best, true)) {
821
+ best = version;
822
+ }
823
+ }
824
+ return best;
825
+ }
826
+
827
+ /**
828
+ * Read the effective pnpm configuration, including everything resolved from
829
+ * pnpm-workspace.yaml. `config list` types the values and omits unset keys,
830
+ * where `config get` stringifies everything and prints `undefined`.
831
+ *
832
+ * @returns {Promise<Record<string, any>>} Parsed configuration
833
+ */
834
+ export async function readPnpmConfig() {
835
+ const result = await $`pnpm config list --json`;
836
+ return JSON.parse(result.stdout);
837
+ }
838
+
839
+ /**
840
+ * Read the registry cooldown from pnpm config, so resolution stays in step with
841
+ * what the subsequent install will enforce.
842
+ *
843
+ * @returns {Promise<import('@pnpm/config.version-policy').PublishedByPolicy>} Cutoff date and exemption policy, both undefined when no cooldown is configured
844
+ */
845
+ export async function getMinimumReleaseAgePolicy() {
846
+ const config = await readPnpmConfig();
847
+ // pnpm's own parser, so exclude entries keep their full grammar — wildcards,
848
+ // version unions (`pkg@1.0.0 || 2.0.0`) and `!` negation.
849
+ return getPublishedByPolicy({
850
+ minimumReleaseAge: config.minimumReleaseAge,
851
+ minimumReleaseAgeExclude: config.minimumReleaseAgeExclude,
852
+ });
853
+ }
854
+
855
+ /**
856
+ * Resolve a package@version specifier to an exact version.
857
+ *
858
+ * Given a cooldown policy, the result is the newest version the install will
859
+ * actually accept — a dist-tag on a daily channel always points at a build too
860
+ * recent to install under one. See {@link selectAgedVersion}.
861
+ *
545
862
  * @param {string} packageSpec - Package specifier in format "package@version"
863
+ * @param {import('@pnpm/config.version-policy').PublishedByPolicy} policy - Cooldown from {@link getMinimumReleaseAgePolicy}
546
864
  * @returns {Promise<string>} Exact version string
547
865
  */
548
- export async function resolveVersion(packageSpec) {
549
- const result = await $`pnpm info ${packageSpec} version --json`;
550
- const versions = JSON.parse(result.stdout);
551
- return typeof versions === 'string' ? versions : versions[versions.length - 1];
866
+ export async function resolveVersion(packageSpec, policy) {
867
+ // The unprojected document carries both the resolved version and every
868
+ // publish date, so honouring the cooldown costs no extra round-trip.
869
+ const info = JSON.parse((await $`pnpm info ${packageSpec} --json`).stdout);
870
+ const manifest = Array.isArray(info) ? info[info.length - 1] : info;
871
+ const { name, version: exactVersion, time: publishTimes = {}, versions = [] } = manifest;
872
+
873
+ if (!policy.publishedBy) {
874
+ return exactVersion;
875
+ }
876
+
877
+ const exemptVersions = cooldownExemptions(policy, name);
878
+ if (exemptVersions === true || exemptVersions.includes(exactVersion)) {
879
+ return exactVersion;
880
+ }
881
+
882
+ const cutoff = policy.publishedBy.toISOString();
883
+ const { bareSpecifier: requested } = parseWantedDependency(packageSpec);
884
+ const agedVersion = selectAgedVersion(
885
+ requested ?? '',
886
+ exactVersion,
887
+ versions,
888
+ publishTimes,
889
+ policy.publishedBy.getTime(),
890
+ exemptVersions,
891
+ );
892
+
893
+ if (!agedVersion) {
894
+ throw new Error(
895
+ `No version matching ${packageSpec} was published before the minimumReleaseAge cutoff ` +
896
+ `(${cutoff}). The newest match, ${exactVersion}, was published at ` +
897
+ `${publishTimes[exactVersion]}, and no earlier match qualifies. Lower ` +
898
+ `minimumReleaseAge, add ${name} to minimumReleaseAgeExclude, or wait for ${exactVersion} ` +
899
+ `to age in.`,
900
+ );
901
+ }
902
+
903
+ if (agedVersion !== exactVersion) {
904
+ // eslint-disable-next-line no-console
905
+ console.log(
906
+ `Resolved ${packageSpec} to ${agedVersion} rather than ${exactVersion}, which was published after the minimumReleaseAge cutoff (${cutoff}).`,
907
+ );
908
+ }
909
+
910
+ return agedVersion;
552
911
  }
553
912
 
554
913
  /**
555
914
  * Find the version of a dependency for a specific package@version
915
+ *
916
+ * The parent's spec is often a range, so the cooldown applies here too. Stepping
917
+ * back stays within that range and so never contradicts the parent.
918
+ *
556
919
  * @param {string} packageSpec - Package specifier in format "package@version"
557
920
  * @param {string} dependency - Dependency name to look up
921
+ * @param {import('@pnpm/config.version-policy').PublishedByPolicy} policy - Registry cooldown to resolve within
558
922
  * @returns {Promise<string>} Exact version string of the dependency
559
923
  */
560
- export async function findDependencyVersionFromSpec(packageSpec, dependency) {
924
+ export async function findDependencyVersionFromSpec(packageSpec, dependency, policy) {
561
925
  const result = await $`pnpm info ${packageSpec} dependencies.${dependency}`;
562
926
  const spec = result.stdout.trim();
563
- return resolveVersion(`${dependency}@${spec}`);
927
+ return resolveVersion(`${dependency}@${spec}`, policy);
564
928
  }
565
929
 
566
930
  /**
@@ -3,6 +3,8 @@ import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { onTestFinished } from 'vitest';
5
5
 
6
+ import { writePackageJson } from './pnpm.mjs';
7
+
6
8
  /**
7
9
  * Creates a temporary directory and registers an `onTestFinished` hook to
8
10
  * remove it automatically when the current test ends — even if the test throws.
@@ -16,3 +18,36 @@ export async function makeTempDir() {
16
18
  });
17
19
  return tmpDir;
18
20
  }
21
+
22
+ /**
23
+ * Write a package.json into a subdirectory of a temp workspace.
24
+ *
25
+ * @param {string} root - Workspace root
26
+ * @param {string} dir - Subdirectory to create
27
+ * @param {object} pkgJson - Manifest contents
28
+ * @returns {Promise<string>} The package directory
29
+ */
30
+ export async function writePackage(root, dir, pkgJson) {
31
+ const pkgDir = path.join(root, dir);
32
+ await fs.mkdir(pkgDir, { recursive: true });
33
+ await writePackageJson(pkgDir, pkgJson);
34
+ return pkgDir;
35
+ }
36
+
37
+ /**
38
+ * @param {string} name
39
+ * @param {string} pkgPath
40
+ * @returns {import('./pnpm.mjs').PublicPackage}
41
+ */
42
+ export function publicPkg(name, pkgPath) {
43
+ return { name, version: '1.0.0', path: pkgPath, isPrivate: false };
44
+ }
45
+
46
+ /**
47
+ * @param {string} name
48
+ * @param {string} pkgPath
49
+ * @returns {import('./pnpm.mjs').PrivatePackage}
50
+ */
51
+ export function privatePkg(name, pkgPath) {
52
+ return { name, version: '1.0.0', path: pkgPath, isPrivate: true };
53
+ }