From d88076a6b56bea2b645362197f5298bc5921574d Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 24 Sep 2026 14:13:09 -0400 Subject: [PATCH 1/8] feat(gamut-codemods): add codemods package with a scope-swap preset Adds @skillsoft/gamut-codemods, a jscodeshift-based CLI for moving consumers from @codecademy/gamut* to @skillsoft/gamut*: npx @skillsoft/gamut-codemods scope-swap . The scope-swap preset rewrites imports, mocks, `declare module`, and package-name strings; moves Video/VideoProps to @skillsoft/gamut/Video; maps /dist/ deep imports to public entry points; replaces gamut-kit in package.json and Module Federation shared config; and updates the ESLint plugin prefix. It refuses to run on a dirty tree, and ends with a leftovers report plus a checklist built from what fired. Structure: presets (one per upgrade) hold a data manifest and an ordered list of reusable migrations. String swaps are splice edits on the original text rather than recast prints, since recast reprinted neighbouring code in semicolon-less files and changed JSX whitespace. Also adds gamut-codemods, gamut-illustrations, and gamut-tests to the changesets fixed group so every consumer-facing package shares a version, which the preset uses for its target versions. Co-Authored-By: Claude Opus 5.5 --- .changeset/config.json | 3 + .changeset/gamut-codemods-scope-swap.md | 5 + .eslintignore | 1 + .prettierignore | 2 + packages/gamut-codemods/.gitignore | 1 + packages/gamut-codemods/README.md | 109 +++++ packages/gamut-codemods/jest.config.ts | 19 + packages/gamut-codemods/package.json | 38 ++ packages/gamut-codemods/project.json | 46 ++ .../__testfixtures__/end-to-end.input.tsx | 7 + .../__testfixtures__/end-to-end.output.tsx | 8 + .../src/__tests__/fixtures.test.ts | 112 +++++ .../gamut-codemods/src/__tests__/lib.test.ts | 116 +++++ .../src/__tests__/manifest.test.ts | 111 +++++ packages/gamut-codemods/src/bin.ts | 13 + packages/gamut-codemods/src/cli.ts | 187 ++++++++ packages/gamut-codemods/src/lib/git.ts | 27 ++ packages/gamut-codemods/src/lib/leftovers.ts | 81 ++++ packages/gamut-codemods/src/lib/manifest.ts | 72 +++ .../gamut-codemods/src/lib/module-strings.ts | 81 ++++ packages/gamut-codemods/src/lib/types.ts | 85 ++++ .../__testfixtures__/rewrites.input.tsx | 7 + .../__testfixtures__/rewrites.output.tsx | 7 + .../__testfixtures__/rewrites.warnings.json | 1 + .../src/migrations/deep-imports/index.ts | 127 ++++++ .../__testfixtures__/directives.input.tsx | 6 + .../__testfixtures__/directives.output.tsx | 6 + .../src/migrations/eslint-comments/index.ts | 49 +++ .../__testfixtures__/legacy.input.json | 9 + .../__testfixtures__/legacy.output.json | 9 + .../src/migrations/eslint-config/index.ts | 28 ++ .../__testfixtures__/already-shared.input.ts | 11 + .../__testfixtures__/already-shared.output.ts | 19 + .../mf-shared/__testfixtures__/kit.input.ts | 11 + .../mf-shared/__testfixtures__/kit.output.ts | 16 + .../__testfixtures__/kit.warnings.json | 1 + .../src/migrations/mf-shared/index.ts | 112 +++++ .../__testfixtures__/merge-existing.input.tsx | 5 + .../merge-existing.output.tsx | 5 + .../__testfixtures__/mixed-import.input.tsx | 7 + .../__testfixtures__/mixed-import.output.tsx | 8 + .../__testfixtures__/re-export.input.ts | 1 + .../__testfixtures__/re-export.output.ts | 2 + .../__testfixtures__/unsafe-shapes.input.tsx | 10 + .../__testfixtures__/unsafe-shapes.output.tsx | 10 + .../unsafe-shapes.warnings.json | 1 + .../__testfixtures__/video-only.input.tsx | 6 + .../__testfixtures__/video-only.output.tsx | 6 + .../src/migrations/moved-exports/index.ts | 246 +++++++++++ .../__testfixtures__/kit.input.json | 11 + .../__testfixtures__/kit.output.json | 16 + .../__testfixtures__/kit.warnings.json | 1 + .../src/migrations/package-json/index.ts | 72 +++ .../__testfixtures__/everywhere.input.tsx | 20 + .../__testfixtures__/everywhere.output.tsx | 20 + .../__testfixtures__/everywhere.warnings.json | 1 + .../__testfixtures__/no-semicolons.input.tsx | 13 + .../__testfixtures__/no-semicolons.output.tsx | 13 + .../src/migrations/scope-rename/index.ts | 69 +++ packages/gamut-codemods/src/presets/index.ts | 43 ++ .../src/presets/scope-swap/index.ts | 36 ++ .../src/presets/scope-swap/manifest.ts | 154 +++++++ packages/gamut-codemods/src/transform.ts | 128 ++++++ packages/gamut-codemods/tsconfig.json | 13 + packages/gamut-codemods/tsconfig.lib.json | 13 + packages/gamut-codemods/tsconfig.spec.json | 11 + yarn.lock | 414 ++++++++++++++---- 67 files changed, 2805 insertions(+), 93 deletions(-) create mode 100644 .changeset/gamut-codemods-scope-swap.md create mode 100644 packages/gamut-codemods/.gitignore create mode 100644 packages/gamut-codemods/README.md create mode 100644 packages/gamut-codemods/jest.config.ts create mode 100644 packages/gamut-codemods/package.json create mode 100644 packages/gamut-codemods/project.json create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx create mode 100644 packages/gamut-codemods/src/__tests__/fixtures.test.ts create mode 100644 packages/gamut-codemods/src/__tests__/lib.test.ts create mode 100644 packages/gamut-codemods/src/__tests__/manifest.test.ts create mode 100644 packages/gamut-codemods/src/bin.ts create mode 100644 packages/gamut-codemods/src/cli.ts create mode 100644 packages/gamut-codemods/src/lib/git.ts create mode 100644 packages/gamut-codemods/src/lib/leftovers.ts create mode 100644 packages/gamut-codemods/src/lib/manifest.ts create mode 100644 packages/gamut-codemods/src/lib/module-strings.ts create mode 100644 packages/gamut-codemods/src/lib/types.ts create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/index.ts create mode 100644 packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/eslint-comments/index.ts create mode 100644 packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.input.json create mode 100644 packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.output.json create mode 100644 packages/gamut-codemods/src/migrations/eslint-config/index.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/index.ts create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.input.ts create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.output.ts create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/moved-exports/index.ts create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/index.ts create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/index.ts create mode 100644 packages/gamut-codemods/src/presets/index.ts create mode 100644 packages/gamut-codemods/src/presets/scope-swap/index.ts create mode 100644 packages/gamut-codemods/src/presets/scope-swap/manifest.ts create mode 100644 packages/gamut-codemods/src/transform.ts create mode 100644 packages/gamut-codemods/tsconfig.json create mode 100644 packages/gamut-codemods/tsconfig.lib.json create mode 100644 packages/gamut-codemods/tsconfig.spec.json diff --git a/.changeset/config.json b/.changeset/config.json index 67eba4b98a..60a8f22d4f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,9 +8,12 @@ "fixed": [ [ "@skillsoft/gamut-agent-tools", + "@skillsoft/gamut-codemods", "@skillsoft/gamut-icons", + "@skillsoft/gamut-illustrations", "@skillsoft/gamut-patterns", "@skillsoft/gamut-styles", + "@skillsoft/gamut-tests", "@skillsoft/gamut", "@skillsoft/variance" ] diff --git a/.changeset/gamut-codemods-scope-swap.md b/.changeset/gamut-codemods-scope-swap.md new file mode 100644 index 0000000000..f7d48bf20d --- /dev/null +++ b/.changeset/gamut-codemods-scope-swap.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut-codemods': minor +--- + +Add `@skillsoft/gamut-codemods` with a `scope-swap` preset for moving a repo from `@codecademy/gamut*` to `@skillsoft/gamut*`. Run `npx @skillsoft/gamut-codemods scope-swap .` from a clean working tree. It renames imports, mocks, and dependencies, moves `Video` imports to `@skillsoft/gamut/Video`, rewrites `/dist/` deep imports, replaces `@codecademy/gamut-kit`, and finishes with a list of what's left to fix by hand. diff --git a/.eslintignore b/.eslintignore index 790b3f75f1..3b8050a6ca 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,3 +12,4 @@ packages/gamut-patterns/src/patterns packages/code-connect .nx packages/gamut-agent-tools/skills/*-workspace +packages/gamut-codemods/**/__testfixtures__ diff --git a/.prettierignore b/.prettierignore index 104cb1cfe7..17524c399a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,8 @@ packages/gamut-patterns/src/patterns packages/gamut-styles/**/*.d.ts packages/styleguide/stories/Core/Atoms/Markdown/*.md packages/code-connect/**/* +# Codemod fixtures are exact expected output, formatting included. +packages/gamut-codemods/**/__testfixtures__ /.nx/cache /.nx/workspace-data diff --git a/packages/gamut-codemods/.gitignore b/packages/gamut-codemods/.gitignore new file mode 100644 index 0000000000..1521c8b765 --- /dev/null +++ b/packages/gamut-codemods/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md new file mode 100644 index 0000000000..c5511ea61e --- /dev/null +++ b/packages/gamut-codemods/README.md @@ -0,0 +1,109 @@ +# @skillsoft/gamut-codemods + +Codemods for moving a repo onto `@skillsoft/gamut` and between its releases. + +## Presets + +Each preset is one upgrade. Run them in order if you're more than one behind. + +| Preset | What it's for | +| ------------ | ------------------------------------------------------- | +| `scope-swap` | Moving from `@codecademy/gamut*` to `@skillsoft/gamut*` | + +`npx @skillsoft/gamut-codemods list` shows every preset and the migrations inside it. + +## Running scope-swap + +Start from a clean working tree. The codemod won't write into uncommitted changes (pass `--force` to override), so `git checkout .` always undoes a run. + +```sh +# Preview without writing anything +npx @skillsoft/gamut-codemods scope-swap . --dry + +# Run it +npx @skillsoft/gamut-codemods scope-swap . + +# Run only some migrations +npx @skillsoft/gamut-codemods scope-swap . --only=moved-exports,scope-rename +``` + +A run ends with three things: + +1. **Warnings**, prefixed with the migration name, e.g. `[moved-exports] src/Player.test.tsx:12 mock of '@codecademy/gamut' stubs Video/VideoProps`. Each one says what to do. +2. **Leftovers**: every line that still names an old package. These are for you to fix: jest `moduleNameMapper` regexes, tsconfig `paths`, flat ESLint configs, comments, and docs. +3. **Next steps**, built from what actually happened in the run. + +### What scope-swap changes + +| Migration | What it does | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deep-imports` | `@codecademy/gamut/dist/PopoverContainer/types` → `@codecademy/gamut`, and the rest of the promoted deep imports. Warns on deep imports with no public replacement (`Menu/elements`, `Form/SelectDropdown/elements`, `ButtonBase/ButtonBase`, `Form/styles`) and on ones it doesn't know. | +| `moved-exports` | Moves `Video` and `VideoProps` off the root import onto `/Video`. Warns on namespace imports, `export *`, and mocks that stub `Video`. | +| `mf-shared` | Replaces `@codecademy/gamut-kit` in Module Federation `shared` config with the packages it bundled. Gamut becomes a real singleton, so test the host and remotes together. | +| `eslint-comments` | `// eslint-disable-next-line gamut/x` → `@skillsoft/gamut/x`. | +| `scope-rename` | Every `@codecademy/gamut*` module string becomes `@skillsoft/gamut*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, and `import('x').T`. Also exact package-name strings elsewhere, like `transpilePackages`, with one warning per file. | +| `package-json` | Renames dependencies and sets their versions. Replaces `@codecademy/gamut-kit` with the individual packages. | +| `eslint-config` | `.eslintrc` / `.eslintrc.json`: plugin name, `plugin:` extends, and rule keys. | + +### Known limits + +- Every file is parsed with the `tsx` parser, so Flow-typed JS won't parse. +- Flat ESLint configs (`eslint.config.*`) and `.eslintrc.js` aren't rewritten. In a flat config the rule prefix is whatever key you register the plugin under, so renaming rule keys blindly would break it. They show up in the leftovers. +- The leftovers report uses regex, not an AST, so expect some false positives. + +## Contributing + +### How it's put together + +``` +src/ + presets/ + index.ts # every preset the CLI can run + / + index.ts # ordered migrations + next-steps checklist + manifest.ts # the data: what changed in this release + migrations// + index.ts # a reusable migration + __testfixtures__/ # .input., .output., optional .warnings.json + lib/ # shared types, module-string finder, leftovers, git guard + transform.ts # jscodeshift entry point + cli.ts, bin.ts +``` + +Two rules hold it together: + +- **Manifests hold the data; migrations hold the code.** Most Gamut changes need a manifest row and a fixture, not a new migration. +- **Manifests use the old package names.** In `scope-swap`, `scope-rename` runs last and swaps the scope, so nothing before it needs to know the new one. + +### Three kinds of migration + +- **`splice`** (the default choice): uses the AST to find ranges, then edits the original text by offset. Use it for anything that swaps one string for another. recast never prints the file, so nothing else in it moves. +- **`ast`**: mutates the AST, and recast prints the file. Only for structural rewrites like splitting an import. recast can reprint more than you changed: mutating one string literal in a semicolon-less file added semicolons and changed JSX text whitespace in neighbouring statements. Presets must list every `ast` migration before any `splice` one, and `validatePreset` enforces it. +- **`file`**: gets raw text for files its `match()` accepts (package.json, .eslintrc). + +### Changing a preset's manifest + +1. Add the row to `src/presets//manifest.ts`. +2. Add a fixture pair under the migration that handles it, e.g. `src/migrations/moved-exports/__testfixtures__/my-case.input.tsx` and `.output.tsx`. +3. If it should warn, add `my-case.warnings.json`: a list of substrings that must appear in the warnings. +4. `yarn nx test gamut-codemods`. + +`manifest.test.ts` type-checks every export a manifest points at against the built packages, so a row can't promise an export that doesn't exist. + +### Adding a migration + +1. Create `src/migrations//index.ts` exporting an `AstMigration`, `SpliceMigration`, or `FileMigration` (see `src/lib/types.ts`). +2. Add it to the presets that need it. In `scope-swap`, put it before `scope-rename` if it matches on old names. +3. Add fixtures. They're picked up automatically, and every source migration is also checked for idempotence. +4. If you find a new place a module name can appear, teach `lib/module-strings.ts` rather than special-casing it. +5. If it leaves follow-up work, call `note('')` and add the key to the preset's `conditionalChecklist`. + +### Adding a preset + +Create `src/presets//` with a manifest and an `index.ts`, register it in `src/presets/index.ts`, and reuse migrations from `src/migrations`. + +### recast gotchas already hit + +- Rebuild specifiers instead of mutating them. Changing `imported` on `import { List }` drops the alias, because `imported` and `local` share one source range. +- Clone nodes before moving them into a new declaration, or recast's patcher can crash. +- `insertAfter` and `replace(a, b)` copy the gap after the original node, which can leave a stray blank line. `moved-exports.postPrint` closes that specific gap. diff --git a/packages/gamut-codemods/jest.config.ts b/packages/gamut-codemods/jest.config.ts new file mode 100644 index 0000000000..dc5f1ad9f1 --- /dev/null +++ b/packages/gamut-codemods/jest.config.ts @@ -0,0 +1,19 @@ +/** @jest-config-loader ts-node */ +/* eslint-disable */ +import base from '../../jest.config.base'; + +export default base('gamut-codemods', { + testEnvironment: 'node', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + /* Fixtures are inputs, not tests, and may be deliberately odd code. */ + testPathIgnorePatterns: ['node_modules', 'dist', '__testfixtures__'], + setupFiles: [], + setupFilesAfterEnv: [], +}); diff --git a/packages/gamut-codemods/package.json b/packages/gamut-codemods/package.json new file mode 100644 index 0000000000..6acbecb748 --- /dev/null +++ b/packages/gamut-codemods/package.json @@ -0,0 +1,38 @@ +{ + "name": "@skillsoft/gamut-codemods", + "description": "Codemods for upgrading to and between @skillsoft/gamut releases", + "version": "0.0.1", + "author": "Skillsoft Engineering ", + "bin": "./dist/bin.js", + "dependencies": { + "jscodeshift": "^17.3.0" + }, + "devDependencies": { + "@skillsoft/gamut": "workspace:*", + "@types/jscodeshift": "^17.3.0" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "dist" + ], + "keywords": [ + "codemod", + "gamut", + "jscodeshift", + "migration" + ], + "license": "MIT", + "main": "./dist/cli.js", + "publishConfig": { + "access": "public" + }, + "repository": "Codecademy/skillsoft-gamut.git", + "scripts": { + "build": "nx build gamut-codemods", + "verify": "tsc --noEmit -p tsconfig.lib.json && tsc --noEmit -p tsconfig.spec.json" + }, + "types": "./dist/cli.d.ts", + "type": "commonjs" +} diff --git a/packages/gamut-codemods/project.json b/packages/gamut-codemods/project.json new file mode 100644 index 0000000000..91d962dcc2 --- /dev/null +++ b/packages/gamut-codemods/project.json @@ -0,0 +1,46 @@ +{ + "name": "gamut-codemods", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/gamut-codemods/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/gamut-codemods", + "commands": [ + "rm -rf ./dist", + "tsc -p tsconfig.lib.json", + "chmod +x ./dist/bin.js" + ], + "parallel": false + } + }, + "test": { + "executor": "@nx/jest:jest", + "dependsOn": ["^build"], + "outputs": ["{workspaceRoot}/coverage/packages/gamut-codemods"], + "options": { + "jestConfig": "packages/gamut-codemods/jest.config.ts" + } + }, + "verify-package": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "cwd": "{projectRoot}", + "commands": ["publint ."], + "parallel": false + } + }, + "publish-build": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "commands": [], + "parallel": false + } + } + } +} diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx new file mode 100644 index 0000000000..32a4d06d2e --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx @@ -0,0 +1,7 @@ +import { Box, Video } from '@codecademy/gamut'; +import { PopoverContainerProps } from '@codecademy/gamut/dist/PopoverContainer/types'; +import { theme } from '@codecademy/gamut-styles'; + +// eslint-disable-next-line gamut/no-inline-style +export const Thing = (p: PopoverContainerProps) => ; +export const shared = { '@codecademy/gamut-kit': { singleton: true } }; diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx new file mode 100644 index 0000000000..2b457b6ceb --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx @@ -0,0 +1,8 @@ +import { Box } from '@skillsoft/gamut'; +import { Video } from '@skillsoft/gamut/Video'; +import { PopoverContainerProps } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; + +// eslint-disable-next-line @skillsoft/gamut/no-inline-style +export const Thing = (p: PopoverContainerProps) => ; +export const shared = { '@skillsoft/gamut': { singleton: true }, '@skillsoft/gamut-icons': { singleton: true }, '@skillsoft/gamut-illustrations': { singleton: true }, '@skillsoft/gamut-patterns': { singleton: true }, '@skillsoft/gamut-styles': { singleton: true }, '@skillsoft/variance': { singleton: true } }; diff --git a/packages/gamut-codemods/src/__tests__/fixtures.test.ts b/packages/gamut-codemods/src/__tests__/fixtures.test.ts new file mode 100644 index 0000000000..5a001f24c9 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/fixtures.test.ts @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { applyTransform } from 'jscodeshift/src/testUtils'; + +import { presets } from '../presets'; +import * as transform from '../transform'; + +/* + Fixture-driven. Every `.input.` in a migration's + __testfixtures__ runs through that migration alone and must match + `.output.`. An optional `.warnings.json` lists substrings + that must each appear in some warning. `{{version}}` in an output stands + for this package's version. Adding a case means adding files, not code. +*/ + +const { version } = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8') +) as { version: string }; + +interface Case { + name: string; + ext: string; + input: string; + output: string; + warnings: string[]; +} + +const readCases = (dir: string): Case[] => { + if (!fs.existsSync(dir)) return []; + const read = (f: string) => fs.readFileSync(path.join(dir, f), 'utf8'); + return fs + .readdirSync(dir) + .filter((f) => f.includes('.input.')) + .map((input) => { + const [name, ext] = input.split('.input.'); + const warnings = `${name}.warnings.json`; + return { + name, + ext, + input: read(input), + output: read(`${name}.output.${ext}`).replace(/{{version}}/g, version), + warnings: fs.existsSync(path.join(dir, warnings)) + ? (JSON.parse(read(warnings)) as string[]) + : [], + }; + }); +}; + +const fixtureDir = (migration: string) => + path.join(__dirname, '../migrations', migration, '__testfixtures__'); + +const expectWarnings = (actual: string[], expected: string[]) => { + for (const needle of expected) { + expect(actual.join('\n')).toContain(needle); + } +}; + +const runSource = (preset: string, source: string, only?: string) => { + const warnings: string[] = []; + const out = applyTransform( + transform, + { preset, only, onWarn: (w: string) => warnings.push(w) }, + { source, path: 'fixture.tsx' }, + { parser: 'tsx' } + ); + return { out: out || source, warnings }; +}; + +describe.each(Object.values(presets))('preset $name', (preset) => { + for (const migration of preset.migrations) { + const cases = readCases(fixtureDir(migration.name)); + if (cases.length === 0) continue; + + describe(migration.name, () => { + it.each(cases)('$name', (c) => { + if (migration.kind === 'file') { + const warnings: string[] = []; + const out = migration.run({ + source: c.input, + manifest: preset.manifest, + warn: (w) => warnings.push(w), + note: () => {}, + }); + expect((out ?? c.input).trim()).toBe(c.output.trim()); + expectWarnings(warnings, c.warnings); + return; + } + + const { out, warnings } = runSource( + preset.name, + c.input, + migration.name + ); + expect(out.trim()).toBe(c.output.trim()); + expectWarnings(warnings, c.warnings); + + /* Idempotent: a second run over the output changes nothing. */ + expect( + runSource(preset.name, c.output, migration.name).out.trim() + ).toBe(c.output.trim()); + }); + }); + } + + const endToEnd = readCases(path.join(__dirname, '__testfixtures__')); + if (endToEnd.length > 0) { + it.each(endToEnd)('every migration: $name', (c) => { + expect(runSource(preset.name, c.input).out.trim()).toBe(c.output.trim()); + }); + } +}); diff --git a/packages/gamut-codemods/src/__tests__/lib.test.ts b/packages/gamut-codemods/src/__tests__/lib.test.ts new file mode 100644 index 0000000000..fa5953e11a --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/lib.test.ts @@ -0,0 +1,116 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import jscodeshift from 'jscodeshift'; + +import { parseArgs } from '../cli'; +import { dirtyTreeReason } from '../lib/git'; +import { findLeftovers } from '../lib/leftovers'; +import type { Manifest } from '../lib/manifest'; +import { deepImports } from '../migrations/deep-imports'; +import { validatePreset } from '../presets'; +import { scopeSwap } from '../presets/scope-swap'; +import { manifest } from '../presets/scope-swap/manifest'; + +const tmp = () => + fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-codemods-test-')); + +describe('deep-imports renames', () => { + /* scope-swap has no rename rows today, so this uses its own manifest. */ + it('renames the export and keeps the local binding', () => { + const j = jscodeshift.withParser('tsx'); + const root = j( + "import { List, ListItem as Item } from '@codecademy/gamut/dist/Menu/elements';" + ); + const withRenames: Manifest = { + ...manifest, + deepImports: [ + { + from: '@codecademy/gamut/dist/Menu/elements', + to: '@codecademy/gamut', + renames: { List: 'MenuList', ListItem: 'MenuListItem' }, + }, + ], + }; + deepImports.run({ + j, + root, + source: '', + manifest: withRenames, + warn: () => {}, + note: () => {}, + }); + expect(root.toSource({ quote: 'single' })).toBe( + "import { MenuList as List, MenuListItem as Item } from '@codecademy/gamut';" + ); + }); +}); + +describe('validatePreset', () => { + it('accepts scope-swap', () => { + expect(() => validatePreset(scopeSwap)).not.toThrow(); + }); + + it('rejects an ast migration listed after a splice one', () => { + const [deep, moved, mf] = scopeSwap.migrations; + expect(() => + validatePreset({ ...scopeSwap, migrations: [deep, mf, moved] }) + ).toThrow(/moved-exports/); + }); +}); + +describe('parseArgs', () => { + it('reads a preset, paths, and flags', () => { + expect( + parseArgs(['scope-swap', 'a', '--dry', '--only=scope-rename']) + ).toEqual( + expect.objectContaining({ + preset: 'scope-swap', + paths: [path.resolve('a')], + dry: true, + force: false, + only: 'scope-rename', + }) + ); + }); + + it('rejects unknown flags', () => { + expect(() => parseArgs(['scope-swap', 'a', '--drry'])).toThrow(/--drry/); + }); +}); + +describe('dirtyTreeReason', () => { + const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, stdio: 'ignore' }); + + it('refuses outside a git repo, and on uncommitted changes', () => { + const dir = tmp(); + expect(dirtyTreeReason(dir)).toMatch(/not inside a git repository/); + + git(dir, 'init', '-q'); + fs.writeFileSync(path.join(dir, 'a.ts'), ''); + expect(dirtyTreeReason(dir)).toMatch(/uncommitted changes/); + + git(dir, 'add', '-A'); + git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'x'); + expect(dirtyTreeReason(dir)).toBeNull(); + }); +}); + +describe('findLeftovers', () => { + it('finds old names but not new names that contain them', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'a.js'), + [ + "moduleNameMapper: { '^@codecademy/gamut(.*)$': 'x' }", + "'@skillsoft/eslint-plugin-gamut'", + "'@skillsoft/gamut-styles'", + "'@codecademy/gamut-kit'", + ].join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1, 4]); + }); +}); diff --git a/packages/gamut-codemods/src/__tests__/manifest.test.ts b/packages/gamut-codemods/src/__tests__/manifest.test.ts new file mode 100644 index 0000000000..4265860ae3 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/manifest.test.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import ts from 'typescript'; + +import { renameSpecifier } from '../lib/manifest'; +import { presets } from '../presets'; +import { manifest } from '../presets/scope-swap/manifest'; + +/* + Guards against a manifest that promises something the packages don't + deliver. Runs against the built dist, so `^build` has to run first + (project.json wires that up). +*/ + +const REPO = path.join(__dirname, '../../../..'); +const readJson = (file: string) => + JSON.parse(fs.readFileSync(path.join(REPO, file), 'utf8')); + +/* + Every name a manifest claims is importable from a new entry point: + moved exports at their destination, and renamed deep-import exports at + theirs. Written as one virtual file so tsc resolves it the way a + consumer's bundler would, through the exports map. +*/ +const importsToCheck = () => { + const lines: string[] = []; + for (const preset of Object.values(presets)) { + const m = preset.manifest; + for (const rule of m.movedExports) { + lines.push( + `import type { ${rule.names.join(', ')} } from '${renameSpecifier( + m, + rule.to + )}';` + ); + } + for (const row of m.deepImports) { + if (!row.to || !row.renames) continue; + lines.push( + `import type { ${Object.values(row.renames).join( + ', ' + )} } from '${renameSpecifier(m, row.to)}';` + ); + } + } + return lines.join('\n'); +}; + +describe('manifests', () => { + it('only point at exports that exist in the built packages', () => { + const file = path.join( + REPO, + 'packages/gamut-codemods/__manifest-check__.ts' + ); + const host = ts.createCompilerHost({}); + const source = importsToCheck(); + const readFile = host.readFile.bind(host); + host.readFile = (f) => (f === file ? source : readFile(f)); + const fileExists = host.fileExists.bind(host); + host.fileExists = (f) => f === file || fileExists(f); + + const program = ts.createProgram( + [file], + { + noEmit: true, + skipLibCheck: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + }, + host + ); + const errors = ts + .getPreEmitDiagnostics(program) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')); + + expect(errors).toEqual([]); + }); + + it('rename every package to one that exists in this repo', () => { + const published = new Set( + fs + .readdirSync(path.join(REPO, 'packages')) + .map((dir) => path.join(REPO, 'packages', dir, 'package.json')) + .filter((file) => fs.existsSync(file)) + .map((file) => JSON.parse(fs.readFileSync(file, 'utf8')).name) + ); + for (const { to } of Object.values(manifest.packages)) { + expect(published).toContain(to); + } + }); + + it('keep scope-swap target versions in step with the packages', () => { + const { fixed } = readJson('.changeset/config.json') as { + fixed: string[][]; + }; + const group = + fixed.find((g) => g.includes('@skillsoft/gamut-codemods')) ?? []; + const eslintPlugin = readJson('packages/eslint-plugin-gamut/package.json'); + + for (const [pkg, range] of Object.entries(manifest.targetVersions)) { + if (pkg === eslintPlugin.name) { + expect(range).toBe(`^${eslintPlugin.version}`); + } else { + /* Versioned by the fixed group, which is what makes GROUP_RANGE right. */ + expect(group).toContain(pkg); + } + } + }); +}); diff --git a/packages/gamut-codemods/src/bin.ts b/packages/gamut-codemods/src/bin.ts new file mode 100644 index 0000000000..1d6eb0af32 --- /dev/null +++ b/packages/gamut-codemods/src/bin.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { main } from './cli'; + +main(process.argv.slice(2)).then( + (code) => { + process.exitCode = code; + }, + (err: unknown) => { + // eslint-disable-next-line no-console + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + } +); diff --git a/packages/gamut-codemods/src/cli.ts b/packages/gamut-codemods/src/cli.ts new file mode 100644 index 0000000000..911960efdc --- /dev/null +++ b/packages/gamut-codemods/src/cli.ts @@ -0,0 +1,187 @@ +/* eslint-disable no-console -- it's a CLI; stdout is the interface. */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { dirtyTreeReason } from './lib/git'; +import { findLeftovers, walk } from './lib/leftovers'; +import type { FileMigration, Preset } from './lib/types'; +import { getPreset, presets } from './presets'; + +/* No published types for the Runner; it's jscodeshift's programmatic API. */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Runner = require('jscodeshift/src/Runner') as { + run( + transformFile: string, + paths: string[], + options: Record + ): Promise<{ error: number }>; +}; + +const USAGE = `Usage: gamut-codemods [options] + gamut-codemods list + +Options: + --dry Don't write anything; show what would change + --force Run even if the target has uncommitted changes + --only=a,b Run only these migrations + --no-report Skip the leftovers report`; + +interface Options { + preset?: string; + paths: string[]; + dry: boolean; + force: boolean; + only?: string; + report: boolean; +} + +export const parseArgs = (argv: string[]): Options | 'help' | 'list' => { + const [command, ...rest] = argv; + if (!command || command === '-h' || command === '--help') return 'help'; + if (command === 'list') return 'list'; + + const opts: Options = { + preset: command, + paths: [], + dry: false, + force: false, + report: true, + }; + for (const arg of rest) { + if (arg === '--dry') opts.dry = true; + else if (arg === '--force') opts.force = true; + else if (arg === '--no-report') opts.report = false; + else if (arg.startsWith('--only=')) opts.only = arg.slice('--only='.length); + else if (arg.startsWith('--')) throw new Error(`unknown option ${arg}`); + else opts.paths.push(path.resolve(arg)); + } + return opts; +}; + +const heading = (text: string) => console.log(`\n— ${text} —`); + +const runFileMigrations = ( + preset: Preset, + opts: Options, + note: (key: string) => void +) => { + const only = opts.only ? new Set(opts.only.split(',')) : null; + const active = preset.migrations.filter( + (m): m is FileMigration => m.kind === 'file' && (!only || only.has(m.name)) + ); + let changed = 0; + + for (const root of opts.paths) { + const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; + for (const file of files) { + for (const migration of active) { + if (!migration.match(file)) continue; + const source = fs.readFileSync(file, 'utf8'); + const warn = (msg: string) => + console.log(`[${migration.name}] ${file} ${msg}`); + const next = migration.run({ + source, + manifest: preset.manifest, + warn, + note, + }); + if (next === null) continue; + changed += 1; + console.log(`${opts.dry ? 'would update' : 'updated'} ${file}`); + if (!opts.dry) fs.writeFileSync(file, next); + } + } + } + return changed; +}; + +const printChecklist = (preset: Preset, notes: Set) => { + heading('next steps'); + const items = [ + ...Object.entries(preset.conditionalChecklist) + .filter(([key]) => notes.has(key)) + .map(([, text]) => text), + ...preset.checklist, + ]; + items.forEach((item, i) => console.log(`${i + 1}. ${item}`)); +}; + +export const main = async (argv: string[]) => { + const opts = parseArgs(argv); + + if (opts === 'help') { + console.log(USAGE); + return 0; + } + if (opts === 'list') { + for (const preset of Object.values(presets)) { + console.log(`${preset.name} ${preset.description}`); + for (const m of preset.migrations) { + console.log(` ${m.name.padEnd(18)} ${m.description}`); + } + } + return 0; + } + + const preset = getPreset(opts.preset!); + if (opts.paths.length === 0) { + console.log(USAGE); + return 1; + } + + if (!opts.dry && !opts.force) { + const reasons = opts.paths.map(dirtyTreeReason).filter(Boolean); + if (reasons.length > 0) { + reasons.forEach((reason) => console.error(reason)); + console.error( + 'Re-run with --force to write anyway, or --dry to preview.' + ); + return 1; + } + } + + const notesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-codemods-')); + try { + heading('source files'); + const result = await Runner.run( + path.join(__dirname, 'transform.js'), + opts.paths, + { + preset: preset.name, + only: opts.only, + notesDir, + dry: opts.dry, + extensions: 'ts,tsx,js,jsx,mjs,cjs,mts,cts', + /* .d.ts stays in: Emotion theme augmentations import gamut-styles. */ + ignorePattern: ['**/node_modules/**', '**/dist/**', '**/build/**'], + parser: 'tsx', + babel: false, + verbose: 0, + } + ); + + const notes = new Set(fs.readdirSync(notesDir).map(decodeURIComponent)); + + heading('package.json and .eslintrc'); + const changed = runFileMigrations(preset, opts, (key) => notes.add(key)); + console.log(`${changed} file(s) ${opts.dry ? 'would change' : 'changed'}`); + + if (opts.report && opts.dry) { + heading( + 'leftovers skipped in --dry (nothing was written, so everything would show up)' + ); + } else if (opts.report) { + const hits = findLeftovers(opts.paths, preset.manifest); + heading(`leftovers (${hits.length})`); + hits.forEach((hit) => + console.log(`${hit.file}:${hit.line} ${hit.text}`) + ); + } + + if (!opts.dry) printChecklist(preset, notes); + return result.error > 0 ? 1 : 0; + } finally { + fs.rmSync(notesDir, { recursive: true, force: true }); + } +}; diff --git a/packages/gamut-codemods/src/lib/git.ts b/packages/gamut-codemods/src/lib/git.ts new file mode 100644 index 0000000000..6929f0f277 --- /dev/null +++ b/packages/gamut-codemods/src/lib/git.ts @@ -0,0 +1,27 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +/* + The CLI refuses to write into a dirty tree, so `git checkout .` always + undoes a run completely and the codemod's diff never mixes with work in + progress. Returns a reason to refuse, or null if it's safe. +*/ +export const dirtyTreeReason = (target: string) => { + const cwd = fs.statSync(target).isDirectory() ? target : path.dirname(target); + let status: string; + try { + /* execFileSync with an argument array: no shell, so paths can't inject. */ + status = execFileSync('git', ['status', '--porcelain', '--', '.'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return `${target} is not inside a git repository, so a run couldn't be undone.`; + } + if (status.trim()) { + return `${target} has uncommitted changes. Commit or stash them first, so the codemod's diff stands on its own.`; + } + return null; +}; diff --git a/packages/gamut-codemods/src/lib/leftovers.ts b/packages/gamut-codemods/src/lib/leftovers.ts new file mode 100644 index 0000000000..9ddf45e9a0 --- /dev/null +++ b/packages/gamut-codemods/src/lib/leftovers.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Manifest } from './manifest'; + +/* + After the migrations run, anything still naming an old package is for a + person: jest moduleNameMapper regexes, tsconfig paths, flat ESLint + configs, comments, docs. This lists them as file:line. +*/ + +export const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + '.git', + '.next', + '.nx', + 'tmp', +]); +const SKIP_FILES = + /(^|\/)(yarn\.lock|package-lock\.json|pnpm-lock\.yaml|CHANGELOG\.md)$/; +const TEXT_EXT = + /\.(m?[jt]sx?|c[jt]s|json|ya?ml|md|mdx|html|scss|css)$|(^|\/)\.eslintrc$/; + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + +/* + Old names, not already followed by a longer package name, and not the + tail of a new one (`@skillsoft/eslint-plugin-gamut` contains + `eslint-plugin-gamut`). +*/ +const oldNamesPattern = (manifest: Manifest) => { + const names = [ + ...Object.keys(manifest.packages), + ...Object.keys(manifest.removedPackages), + ] + .sort((a, b) => b.length - a.length) + .map(escape); + const prefix = escape(manifest.eslintPlugin.from); + return new RegExp( + `(? { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name)); + } else { + yield path.join(dir, entry.name); + } + } +} + +export interface Leftover { + file: string; + line: number; + text: string; +} + +export const findLeftovers = (roots: string[], manifest: Manifest) => { + const pattern = oldNamesPattern(manifest); + const hits: Leftover[] = []; + for (const root of roots) { + const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; + for (const file of files) { + if (SKIP_FILES.test(file) || !TEXT_EXT.test(file)) continue; + fs.readFileSync(file, 'utf8') + .split('\n') + .forEach((text, i) => { + if (pattern.test(text)) + hits.push({ file, line: i + 1, text: text.trim() }); + }); + } + } + return hits; +}; diff --git a/packages/gamut-codemods/src/lib/manifest.ts b/packages/gamut-codemods/src/lib/manifest.ts new file mode 100644 index 0000000000..495458f723 --- /dev/null +++ b/packages/gamut-codemods/src/lib/manifest.ts @@ -0,0 +1,72 @@ +/* + The shape of a preset's manifest: the data describing what changed. Most + contributions should only add rows to a preset's manifest.ts plus a + fixture; migrations read from here and never hardcode package or export + names. +*/ + +export interface PackageRename { + to: string; +} + +export interface RemovedPackage { + /* Old package names to depend on instead, in package.json. */ + expandTo: string[]; + /* Old package names to share as singletons in Module Federation config. */ + mfSharedAs: string[]; + note: string; +} + +export interface MovedExport { + from: string; + to: string; + names: string[]; + note: string; +} + +export interface DeepImport { + from: string; + /* null when there's no public replacement; the migration warns instead. */ + to: string | null; + /* Old imported name -> new exported name. Local bindings are kept. */ + renames?: Record; + note?: string; +} + +export interface Manifest { + /* Old package -> new package. */ + packages: Record; + /* Packages with no replacement under the new name. */ + removedPackages: Record; + /* Version range written to package.json, keyed by NEW package name. */ + targetVersions: Record; + movedExports: MovedExport[]; + deepImports: DeepImport[]; + /* ESLint plugin prefix, for rule keys, `plugin:` extends, and comments. */ + eslintPlugin: { from: string; to: string }; +} + +/* + Match a specifier against a package name, exact or with a subpath. + '@codecademy/gamut' matches '@codecademy/gamut/Video' but not + '@codecademy/gamut-styles'. Returns the subpath ('' or '/Video') or null. +*/ +export const matchPackage = (specifier: string, pkg: string) => { + if (specifier === pkg) return ''; + if (specifier.startsWith(`${pkg}/`)) return specifier.slice(pkg.length); + return null; +}; + +/* The new name for an old specifier, keeping any subpath. */ +export const renameSpecifier = (manifest: Manifest, value: string) => { + for (const [from, { to }] of Object.entries(manifest.packages)) { + const subpath = matchPackage(value, from); + if (subpath !== null) return to + subpath; + } + return null; +}; + +export const removedPackageFor = (manifest: Manifest, value: string) => + Object.keys(manifest.removedPackages).find( + (pkg) => matchPackage(value, pkg) !== null + ); diff --git a/packages/gamut-codemods/src/lib/module-strings.ts b/packages/gamut-codemods/src/lib/module-strings.ts new file mode 100644 index 0000000000..50b7a5f484 --- /dev/null +++ b/packages/gamut-codemods/src/lib/module-strings.ts @@ -0,0 +1,81 @@ +import type { Collection, JSCodeshift, StringLiteral } from 'jscodeshift'; + +/* + Finds every string in a file that names a module: import and export + sources, require(), dynamic import(), jest/vi mocks, `import('x').T` types, + and `declare module 'x'` augmentations. Migrations that rewrite a specifier + should go through here, so a new place a module name can appear only has + to be taught once. +*/ + +const CALLEE_OBJECTS = new Set(['jest', 'vi']); +const CALLEE_METHODS = new Set([ + 'mock', + 'doMock', + 'unmock', + 'dontMock', + 'requireActual', + 'requireMock', + 'importActual', + 'importMock', + 'setMock', +]); + +/* + The tsx parser produces StringLiteral; the babel/estree ones produce + Literal. Both carry babel's start/end offsets, which splice edits rely on. +*/ +export type StringNode = StringLiteral & { start: number; end: number }; + +export const isStringLiteral = (node: unknown): node is StringNode => { + const n = node as { type?: string; value?: unknown } | null; + return ( + !!n && + (n.type === 'StringLiteral' || + (n.type === 'Literal' && typeof n.value === 'string')) + ); +}; + +/* + Identifier-ish nodes (Identifier, JSXIdentifier, TSTypeParameter) all + carry a string `name`, but the jscodeshift types don't share one shape. +*/ +export const nameOf = (node: unknown) => { + const name = (node as { name?: unknown } | null | undefined)?.name; + return typeof name === 'string' ? name : ''; +}; + +const isModuleCall = (callee: any): boolean => { + if (callee.type === 'Import') return true; + if (callee.type === 'Identifier') return callee.name === 'require'; + if (callee.type === 'MemberExpression' && !callee.computed) { + const { object, property } = callee; + if (object.type !== 'Identifier') return false; + if (object.name === 'require') return property.name === 'resolve'; + return CALLEE_OBJECTS.has(object.name) && CALLEE_METHODS.has(property.name); + } + return false; +}; + +/* The string literal nodes that name a module, in source order. */ +export const findModuleStrings = (j: JSCodeshift, root: Collection) => { + const found: StringNode[] = []; + const push = (node: unknown) => { + if (isStringLiteral(node)) found.push(node); + }; + + root.find(j.ImportDeclaration).forEach((p) => push(p.node.source)); + root.find(j.ExportNamedDeclaration).forEach((p) => push(p.node.source)); + root.find(j.ExportAllDeclaration).forEach((p) => push(p.node.source)); + root.find(j.CallExpression).forEach((p) => { + if (isModuleCall(p.node.callee)) push(p.node.arguments[0]); + }); + root.find(j.ImportExpression).forEach((p) => push(p.node.source)); + root.find(j.TSImportType).forEach((p) => push(p.node.argument)); + root.find(j.TSModuleDeclaration).forEach((p) => push(p.node.id)); + root + .find(j.TSExternalModuleReference) + .forEach((p) => push(p.node.expression)); + + return found; +}; diff --git a/packages/gamut-codemods/src/lib/types.ts b/packages/gamut-codemods/src/lib/types.ts new file mode 100644 index 0000000000..558c9d74c1 --- /dev/null +++ b/packages/gamut-codemods/src/lib/types.ts @@ -0,0 +1,85 @@ +import type { Collection, JSCodeshift } from 'jscodeshift'; + +import type { Manifest } from './manifest'; + +/* Anything with a source location, so warnings can point at a line. */ +export type Locatable = + | { loc?: { start: { line: number } } | null } + | null + | undefined; + +export type Warn = (node: Locatable, message: string) => void; + +/* + Records that a checklist item applies to this run. Keys are defined by the + preset; the CLI prints the matching items once every file is done. +*/ +export type Note = (checklistKey: string) => void; + +export interface SourceContext { + j: JSCodeshift; + root: Collection; + source: string; + manifest: Manifest; + warn: Warn; + note: Note; +} + +export interface Edit { + start: number; + end: number; + text: string; +} + +interface MigrationBase { + name: string; + description: string; +} + +/* + Mutates the shared AST. Use for structural rewrites only: recast reprints + whatever it thinks changed, and in semicolon-less files that has spread to + neighbouring statements. Return true if anything changed. +*/ +export interface AstMigration extends MigrationBase { + kind: 'ast'; + run(ctx: SourceContext): boolean; + /* Formatting fixes on the printed output that the AST can't express. */ + postPrint?(source: string, ctx: { manifest: Manifest }): string; +} + +/* + Uses the AST to find ranges, then edits the original text by offset. The + default for anything that swaps one string for another. +*/ +export interface SpliceMigration extends MigrationBase { + kind: 'splice'; + edits(ctx: SourceContext): Edit[]; +} + +/* Gets raw text for non-source files (package.json, .eslintrc). */ +export interface FileMigration extends MigrationBase { + kind: 'file'; + match(path: string): boolean; + run(ctx: { + source: string; + manifest: Manifest; + warn: (message: string) => void; + note: Note; + }): string | null; +} + +export type SourceMigration = AstMigration | SpliceMigration; +export type Migration = SourceMigration | FileMigration; + +export interface Preset { + name: string; + description: string; + manifest: Manifest; + /* Run in order. All `ast` migrations must come before any `splice` one. */ + migrations: Migration[]; + /* Printed at the end of every run. */ + checklist: string[]; + /* Printed only when a migration calls note(key). */ + conditionalChecklist: Record; +} diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx new file mode 100644 index 0000000000..d45be932f1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx @@ -0,0 +1,7 @@ +import { PopoverContainerProps } from '@codecademy/gamut/dist/PopoverContainer/types'; +import type { Prop } from '@codecademy/variance/dist/types/config'; +import { formStyles } from '@codecademy/gamut/dist/Form/styles'; +import { List } from '@codecademy/gamut/dist/Menu/elements'; +import { Mystery } from '@codecademy/gamut/dist/Nope'; + +jest.mock('@codecademy/gamut/dist/Box/props'); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx new file mode 100644 index 0000000000..0c0e468b23 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx @@ -0,0 +1,7 @@ +import { PopoverContainerProps } from '@codecademy/gamut'; +import type { Prop } from '@codecademy/variance'; +import { formStyles } from '@codecademy/gamut/dist/Form/styles'; +import { List } from '@codecademy/gamut/dist/Menu/elements'; +import { Mystery } from '@codecademy/gamut/dist/Nope'; + +jest.mock('@codecademy/gamut'); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json new file mode 100644 index 0000000000..1e95c663a1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json @@ -0,0 +1 @@ +["'@codecademy/gamut/dist/Form/styles' has no public replacement", "'@codecademy/gamut/dist/Menu/elements' has no public replacement", "unmapped deep import '@codecademy/gamut/dist/Nope'"] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/index.ts b/packages/gamut-codemods/src/migrations/deep-imports/index.ts new file mode 100644 index 0000000000..b394c39c45 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/index.ts @@ -0,0 +1,127 @@ +import type { + ExportSpecifier, + ImportSpecifier, + JSCodeshift, +} from 'jscodeshift'; + +import { findModuleStrings, nameOf } from '../../lib/module-strings'; +import type { AstMigration } from '../../lib/types'; + +/* + `@codecademy/gamut/dist/...` -> the public entry point from + manifest.deepImports. Renamed exports keep their local name so call sites + don't change: `import { List }` becomes `import { MenuList as List }`. +*/ + +const DIST = /^(@codecademy\/[\w-]+|eslint-plugin-gamut)\/dist(\/|$)/; + +type Specifier = ImportSpecifier | ExportSpecifier | { type: string }; + +/* + Specifiers are rebuilt rather than mutated. `import { List }` has imported + and local sharing one source range, so recast's patcher drops the alias if + you only swap `imported`. +*/ +const renameSpecifiers = ( + j: JSCodeshift, + specifiers: Specifier[], + renames: Record +) => + specifiers.map((spec) => { + if (spec.type === 'ImportSpecifier') { + const { imported, local, importKind } = spec as ImportSpecifier & { + importKind?: string; + }; + const next = renames[nameOf(imported)]; + if (!next) return spec; + const renamed = j.importSpecifier( + j.identifier(next), + j.identifier(nameOf(local ?? imported)) + ); + Object.assign(renamed, { importKind }); + return renamed; + } + if (spec.type === 'ExportSpecifier') { + const { local, exported } = spec as ExportSpecifier; + const next = renames[nameOf(local)]; + if (!next) return spec; + return j.exportSpecifier.from({ + local: j.identifier(next), + exported: j.identifier(nameOf(exported ?? local)), + }); + } + return spec; + }); + +export const deepImports: AstMigration = { + name: 'deep-imports', + kind: 'ast', + description: 'Rewrite /dist/ deep imports to public entry points.', + run({ j, root, manifest, warn }) { + const byPath = new Map(manifest.deepImports.map((row) => [row.from, row])); + let changed = false; + + /* Declarations first, so renames can be applied to their specifiers. */ + const declarations = [ + ...root.find(j.ImportDeclaration).nodes(), + ...root.find(j.ExportNamedDeclaration).nodes(), + ...root.find(j.ExportAllDeclaration).nodes(), + ]; + const handled = new Set(); + + for (const node of declarations) { + const source = node.source as { value?: unknown } | null | undefined; + if (typeof source?.value !== 'string' || !DIST.test(source.value)) { + continue; + } + handled.add(source); + const row = byPath.get(source.value); + if (!row) { + warn( + node, + `unmapped deep import '${source.value}'. Add it to the preset manifest's deepImports.` + ); + continue; + } + if (!row.to) { + warn( + node, + `'${row.from}' has no public replacement. ${row.note ?? ''}` + ); + continue; + } + if (row.renames && 'specifiers' in node && node.specifiers) { + node.specifiers = renameSpecifiers( + j, + node.specifiers, + row.renames + ) as typeof node.specifiers; + } + node.source = j.stringLiteral(row.to); + changed = true; + } + + /* Then mocks, require(), import(): only the string can change here. */ + for (const literal of findModuleStrings(j, root)) { + if (handled.has(literal) || !DIST.test(literal.value)) continue; + const row = byPath.get(literal.value); + if (!row?.to) { + warn( + literal, + `deep import '${literal.value}' in a mock or require() needs a manual rewrite.` + ); + continue; + } + if (row.renames) { + warn( + literal, + `'${literal.value}' has renamed exports. Check the mock factory by hand.` + ); + } + literal.value = row.to; + changed = true; + } + + return changed; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.input.tsx b/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.input.tsx new file mode 100644 index 0000000000..61b977827c --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.input.tsx @@ -0,0 +1,6 @@ +// eslint-disable-next-line gamut/no-inline-style +const a = 1; +/* eslint-disable gamut/prefer-themed, react/no-danger */ +// eslint-disable-next-line @skillsoft/gamut/no-css-standalone +const b = 2; +// talking about gamut/no-inline-style in prose stays put diff --git a/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.output.tsx b/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.output.tsx new file mode 100644 index 0000000000..6902386e06 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-comments/__testfixtures__/directives.output.tsx @@ -0,0 +1,6 @@ +// eslint-disable-next-line @skillsoft/gamut/no-inline-style +const a = 1; +/* eslint-disable @skillsoft/gamut/prefer-themed, react/no-danger */ +// eslint-disable-next-line @skillsoft/gamut/no-css-standalone +const b = 2; +// talking about gamut/no-inline-style in prose stays put diff --git a/packages/gamut-codemods/src/migrations/eslint-comments/index.ts b/packages/gamut-codemods/src/migrations/eslint-comments/index.ts new file mode 100644 index 0000000000..977550f4d0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-comments/index.ts @@ -0,0 +1,49 @@ +import type { Edit, SpliceMigration } from '../../lib/types'; + +/* + `// eslint-disable-next-line gamut/no-inline-style` -> + `// eslint-disable-next-line @skillsoft/gamut/no-inline-style`. + Rule names are unchanged; only the plugin prefix moves. +*/ + +const DIRECTIVE = /^\s*(eslint-disable|eslint-enable|eslint\s)/; + +interface CommentNode { + type: string; + value: string; + start: number; + end: number; +} + +const isBlock = (comment: CommentNode) => + comment.type === 'CommentBlock' || comment.type === 'Block'; + +export const eslintComments: SpliceMigration = { + name: 'eslint-comments', + kind: 'splice', + description: + 'Update ESLint directive comments to the new plugin rule prefix.', + edits({ j, root, manifest }) { + const { from, to } = manifest.eslintPlugin; + /* The prefix at the start of a rule name, not one already scoped. */ + const prefix = new RegExp(`(^|[\\s,])${from}/`, 'g'); + const edits: Edit[] = []; + const seen = new Set(); + + /* j.Comment paths point at the owning node, so walk node.comments. */ + root.find(j.Node).forEach((p) => { + const comments = (p.node as { comments?: CommentNode[] }).comments ?? []; + for (const comment of comments) { + if (seen.has(comment.start)) continue; + seen.add(comment.start); + if (!DIRECTIVE.test(comment.value)) continue; + const value = comment.value.replace(prefix, `$1${to}/`); + if (value === comment.value) continue; + const text = isBlock(comment) ? `/*${value}*/` : `//${value}`; + edits.push({ start: comment.start, end: comment.end, text }); + } + }); + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.input.json b/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.input.json new file mode 100644 index 0000000000..8fc62bdc2b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.input.json @@ -0,0 +1,9 @@ +{ + "plugins": ["gamut"], + "extends": ["plugin:gamut/recommended"], + "rules": { + // keep these on + "gamut/no-css-standalone": "error", + "gamut/no-inline-style": "error" + } +} diff --git a/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.output.json b/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.output.json new file mode 100644 index 0000000000..cfb581e347 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-config/__testfixtures__/legacy.output.json @@ -0,0 +1,9 @@ +{ + "plugins": ["@skillsoft/gamut"], + "extends": ["plugin:@skillsoft/gamut/recommended"], + "rules": { + // keep these on + "@skillsoft/gamut/no-css-standalone": "error", + "@skillsoft/gamut/no-inline-style": "error" + } +} diff --git a/packages/gamut-codemods/src/migrations/eslint-config/index.ts b/packages/gamut-codemods/src/migrations/eslint-config/index.ts new file mode 100644 index 0000000000..34f50ed381 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/eslint-config/index.ts @@ -0,0 +1,28 @@ +import type { FileMigration } from '../../lib/types'; + +/* + Legacy .eslintrc / .eslintrc.json only. These are often JSONC (mono's + have comments), so this is a targeted text rewrite rather than + JSON.parse. JS and flat configs are left alone: in a flat config the rule + prefix is whatever key the plugin is registered under, so blindly + renaming rule keys would break it. The leftovers report lists them. +*/ + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const eslintConfig: FileMigration = { + name: 'eslint-config', + kind: 'file', + description: + 'Update the plugin name, `plugin:` extends, and rule prefix in .eslintrc(.json).', + match: (file) => /(^|\/)\.eslintrc(\.json)?$/.test(file), + run({ source, manifest }) { + const { from, to } = manifest.eslintPlugin; + const old = escape(from); + const next = source + .replace(new RegExp(`"plugin:${old}/`, 'g'), `"plugin:${to}/`) + .replace(new RegExp(`"${old}/`, 'g'), `"${to}/`) + .replace(new RegExp(`"(eslint-plugin-)?${old}"`, 'g'), `"${to}"`); + return next === source ? null : next; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts new file mode 100644 index 0000000000..04847f011a --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts @@ -0,0 +1,11 @@ +const shared = { + '@codecademy/gamut': { singleton: true, requiredVersion: '^73.0.0' }, + '@codecademy/gamut-kit': { singleton: true }, +}; +export const config = { shared }; +export const inline = { + shared: { + '@codecademy/gamut-kit': { eager: true }, + '@codecademy/gamut': { singleton: true }, + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts new file mode 100644 index 0000000000..e3154ac7d5 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts @@ -0,0 +1,19 @@ +const shared = { + '@codecademy/gamut': { singleton: true, requiredVersion: '^73.0.0' }, + '@codecademy/gamut-icons': { singleton: true }, + '@codecademy/gamut-illustrations': { singleton: true }, + '@codecademy/gamut-patterns': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + '@codecademy/variance': { singleton: true }, +}; +export const config = { shared }; +export const inline = { + shared: { + '@codecademy/gamut-icons': { eager: true }, + '@codecademy/gamut-illustrations': { eager: true }, + '@codecademy/gamut-patterns': { eager: true }, + '@codecademy/gamut-styles': { eager: true }, + '@codecademy/variance': { eager: true }, + '@codecademy/gamut': { singleton: true }, + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts new file mode 100644 index 0000000000..caf4b09e6d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts @@ -0,0 +1,11 @@ +export default { + plugins: [ + new ModuleFederationPlugin({ + shared: { + react: { singleton: true }, + '@codecademy/gamut-kit': { singleton: true }, + '@emotion/react': { singleton: true }, + }, + }), + ], +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts new file mode 100644 index 0000000000..4c70a53c17 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts @@ -0,0 +1,16 @@ +export default { + plugins: [ + new ModuleFederationPlugin({ + shared: { + react: { singleton: true }, + '@codecademy/gamut': { singleton: true }, + '@codecademy/gamut-icons': { singleton: true }, + '@codecademy/gamut-illustrations': { singleton: true }, + '@codecademy/gamut-patterns': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + '@codecademy/variance': { singleton: true }, + '@emotion/react': { singleton: true }, + }, + }), + ], +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json new file mode 100644 index 0000000000..cc4b2bc076 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json @@ -0,0 +1 @@ +["replaced '@codecademy/gamut-kit' in Module Federation shared config"] diff --git a/packages/gamut-codemods/src/migrations/mf-shared/index.ts b/packages/gamut-codemods/src/migrations/mf-shared/index.ts new file mode 100644 index 0000000000..9d3e677821 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/index.ts @@ -0,0 +1,112 @@ +import { isStringLiteral } from '../../lib/module-strings'; +import type { Edit, SpliceMigration } from '../../lib/types'; + +/* + Module Federation `shared` config that names a removed package: + + shared: { '@codecademy/gamut-kit': { singleton: true } } + becomes + shared: { + '@codecademy/gamut': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + ... + } + + Handles `const shared = { ... }` the same way. + + MF matches shared entries by import name, and code imports the individual + packages, not the kit. So the old entry never deduped anything, and after + this Gamut really is a singleton across host and remotes. That's a runtime + change, hence the warning and checklist item. scope-rename runs after this + and swaps the scope on the new keys. +*/ + +const keyName = (key: any): string | null => { + if (!key) return null; + if (key.type === 'Identifier') return key.name; + if (isStringLiteral(key)) return key.value; + return null; +}; + +/* + The object in `shared: { ... }` (inline plugin options) or + `const shared = { ... }` (declared, then passed in). +*/ +const sharedObject = (node: any) => { + if ( + (node.type === 'ObjectProperty' || node.type === 'Property') && + keyName(node.key) === 'shared' && + node.value?.type === 'ObjectExpression' + ) { + return node.value; + } + if ( + node.type === 'VariableDeclarator' && + keyName(node.id) === 'shared' && + node.init?.type === 'ObjectExpression' + ) { + return node.init; + } + return null; +}; + +export const mfShared: SpliceMigration = { + name: 'mf-shared', + kind: 'splice', + description: + 'Replace removed packages in Module Federation `shared` config with the packages they bundled.', + edits({ j, root, source, manifest, warn, note }) { + const edits: Edit[] = []; + + root + .find(j.Node) + .filter((p) => sharedObject(p.node) !== null) + .forEach((p) => { + const shared = sharedObject(p.node); + const existing = new Set( + shared.properties.map((prop: any) => keyName(prop.key)) + ); + + for (const prop of shared.properties) { + const removed = manifest.removedPackages[keyName(prop.key) ?? '']; + if (!removed) continue; + + const lineStart = source.lastIndexOf('\n', prop.start) + 1; + const before = source.slice(lineStart, prop.start); + /* One entry per line if the old one had its own line, else inline. */ + const ownLine = /^\s*$/.test(before); + const separator = ownLine ? `,\n${before}` : ', '; + const value = source.slice(prop.value.start, prop.value.end); + const additions = removed.mfSharedAs.filter( + (pkg) => !existing.has(pkg) + ); + const text = additions + .map((pkg) => `'${pkg}': ${value}`) + .join(separator); + + /* Nothing to add: drop the entry and the comma that follows it. */ + const end = + text || source[prop.end] !== ',' + ? prop.end + : prop.end + + 1 + + (source.slice(prop.end + 1).match(/^\s*/)?.[0].length ?? 0); + edits.push({ + start: text || !ownLine ? prop.start : lineStart, + end, + text, + }); + + warn( + prop, + `replaced '${keyName( + prop.key + )}' in Module Federation shared config. Gamut is now actually shared as a singleton; test the host and remotes together.` + ); + note('mf-shared'); + } + }); + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.input.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.input.tsx new file mode 100644 index 0000000000..811c580c56 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.input.tsx @@ -0,0 +1,5 @@ +import { Text, Video } from '@codecademy/gamut'; +import { VideoProps } from '@codecademy/gamut/Video'; + +export const x: VideoProps = { videoUrl: '' }; +export const y = [Text, Video]; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.output.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.output.tsx new file mode 100644 index 0000000000..2342b88e7d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/merge-existing.output.tsx @@ -0,0 +1,5 @@ +import { Text } from '@codecademy/gamut'; +import { VideoProps, Video } from '@codecademy/gamut/Video'; + +export const x: VideoProps = { videoUrl: '' }; +export const y = [Text, Video]; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.input.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.input.tsx new file mode 100644 index 0000000000..98db12ff86 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.input.tsx @@ -0,0 +1,7 @@ +import { Box, Video, type VideoProps } from '@codecademy/gamut'; + +export const Player = (props: VideoProps) => ( + + +); diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.output.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.output.tsx new file mode 100644 index 0000000000..a2058bdebb --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/mixed-import.output.tsx @@ -0,0 +1,8 @@ +import { Box } from '@codecademy/gamut'; +import { Video, type VideoProps } from '@codecademy/gamut/Video'; + +export const Player = (props: VideoProps) => ( + + +); diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.input.ts b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.input.ts new file mode 100644 index 0000000000..d12c1f8c44 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.input.ts @@ -0,0 +1 @@ +export { Box, Video } from '@codecademy/gamut'; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.output.ts b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.output.ts new file mode 100644 index 0000000000..efcc9aafd3 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/re-export.output.ts @@ -0,0 +1,2 @@ +export { Box } from '@codecademy/gamut'; +export { Video } from '@codecademy/gamut/Video'; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.input.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.input.tsx new file mode 100644 index 0000000000..1d2a688be7 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.input.tsx @@ -0,0 +1,10 @@ +import * as Gamut from '@codecademy/gamut'; + +export * from '@codecademy/gamut'; + +jest.mock('@codecademy/gamut', () => ({ + ...jest.requireActual('@codecademy/gamut'), + Video: () => null, +})); + +export const V = Gamut.Video; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.output.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.output.tsx new file mode 100644 index 0000000000..1d2a688be7 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.output.tsx @@ -0,0 +1,10 @@ +import * as Gamut from '@codecademy/gamut'; + +export * from '@codecademy/gamut'; + +jest.mock('@codecademy/gamut', () => ({ + ...jest.requireActual('@codecademy/gamut'), + Video: () => null, +})); + +export const V = Gamut.Video; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.warnings.json b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.warnings.json new file mode 100644 index 0000000000..ba02d37ffd --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/unsafe-shapes.warnings.json @@ -0,0 +1 @@ +["namespace import uses Video", "'export *' from '@codecademy/gamut'", "mock of '@codecademy/gamut' stubs Video"] diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.input.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.input.tsx new file mode 100644 index 0000000000..690bc76dad --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.input.tsx @@ -0,0 +1,6 @@ +// the player +import { Video } from '@codecademy/gamut'; +import type { VideoProps } from '@codecademy/gamut'; + +export type P = VideoProps; +export const V = Video; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.output.tsx b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.output.tsx new file mode 100644 index 0000000000..968c84aef1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/__testfixtures__/video-only.output.tsx @@ -0,0 +1,6 @@ +// the player +import { Video } from '@codecademy/gamut/Video'; +import type { VideoProps } from '@codecademy/gamut/Video'; + +export type P = VideoProps; +export const V = Video; diff --git a/packages/gamut-codemods/src/migrations/moved-exports/index.ts b/packages/gamut-codemods/src/migrations/moved-exports/index.ts new file mode 100644 index 0000000000..250d882da6 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/moved-exports/index.ts @@ -0,0 +1,246 @@ +import type { + ASTPath, + Collection, + ExportNamedDeclaration, + ExportSpecifier, + ImportDeclaration, + ImportSpecifier, + JSCodeshift, +} from 'jscodeshift'; + +import type { Manifest, MovedExport } from '../../lib/manifest'; +import { nameOf } from '../../lib/module-strings'; +import type { AstMigration, Warn } from '../../lib/types'; + +/* + Moves named exports that left a package root onto their new subpath, per + manifest.movedExports. For scope-swap that's Video and VideoProps: + + import { Box, Video } from '@codecademy/gamut'; + becomes + import { Box } from '@codecademy/gamut'; + import { Video } from '@codecademy/gamut/Video'; + + Shapes it can't rewrite safely (namespace imports, `export *`, mock + factories) get a warning instead. +*/ + +type Declaration = ImportDeclaration | ExportNamedDeclaration; +type NamedSpecifier = ImportSpecifier | ExportSpecifier; + +const isNamed = (spec: { type: string }): spec is NamedSpecifier => + spec.type === 'ImportSpecifier' || spec.type === 'ExportSpecifier'; + +const specName = (spec: NamedSpecifier) => + nameOf(spec.type === 'ImportSpecifier' ? spec.imported : spec.local); + +const kindOf = (node: Declaration) => + (node as { importKind?: string; exportKind?: string }).importKind ?? + (node as { exportKind?: string }).exportKind ?? + 'value'; + +/* + Fresh copies, not the original nodes. Reparenting nodes that still carry + their source location trips recast's patcher when the file has other edits. +*/ +const cloneSpecifier = (j: JSCodeshift, spec: NamedSpecifier) => { + if (spec.type === 'ExportSpecifier') { + return j.exportSpecifier.from({ + local: j.identifier(nameOf(spec.local)), + exported: j.identifier(nameOf(spec.exported ?? spec.local)), + }); + } + const copy = j.importSpecifier( + j.identifier(nameOf(spec.imported)), + j.identifier(nameOf(spec.local ?? spec.imported)) + ); + Object.assign(copy, { + importKind: (spec as { importKind?: string }).importKind, + }); + return copy; +}; + +/* An existing all-named declaration of the same kind we can merge into. */ +const findTarget = ( + j: JSCodeshift, + root: Collection, + node: Declaration, + to: string, + kind: string +): Declaration | undefined => { + const matches = (candidate: Declaration) => + kindOf(candidate) === kind && + (candidate.specifiers ?? []).every((s) => isNamed(s)); + return node.type === 'ImportDeclaration' + ? root + .find(j.ImportDeclaration, { source: { value: to } }) + .nodes() + .find(matches) + : root + .find(j.ExportNamedDeclaration, { source: { value: to } }) + .nodes() + .find(matches); +}; + +const moveSpecifiers = ( + j: JSCodeshift, + root: Collection, + path: ASTPath, + rule: MovedExport +) => { + const { node } = path; + const names = new Set(rule.names); + const specifiers = (node.specifiers ?? []) as { type: string }[]; + const matched = specifiers.filter( + (s): s is NamedSpecifier => isNamed(s) && names.has(specName(s)) + ); + if (matched.length === 0) return false; + + const moving = matched.map((s) => cloneSpecifier(j, s)); + const staying = specifiers.filter( + (s) => !matched.includes(s as NamedSpecifier) + ); + const kind = kindOf(node); + const existing = findTarget(j, root, node, rule.to, kind); + + if (existing) { + (existing.specifiers as unknown[]).push(...moving); + if (staying.length === 0) path.prune(); + else node.specifiers = staying as typeof node.specifiers; + return true; + } + + if (staying.length === 0) { + /* Whole declaration moves. Keep the node so its comments stay put. */ + node.source = j.stringLiteral(rule.to); + return true; + } + + const created = + node.type === 'ImportDeclaration' + ? j.importDeclaration( + moving as ImportSpecifier[], + j.stringLiteral(rule.to) + ) + : j.exportNamedDeclaration( + null, + moving as ExportSpecifier[], + j.stringLiteral(rule.to) + ); + Object.assign( + created, + node.type === 'ImportDeclaration' + ? { importKind: kind } + : { exportKind: kind } + ); + node.specifiers = staying as typeof node.specifiers; + path.replace(node, created); + return true; +}; + +const warnAboutUnsafeShapes = ( + j: JSCodeshift, + root: Collection, + rule: MovedExport, + warn: Warn +) => { + const list = rule.names.join('/'); + + root + .find(j.ImportDeclaration, { source: { value: rule.from } }) + .forEach((p) => { + for (const spec of p.node.specifiers ?? []) { + if (spec.type !== 'ImportNamespaceSpecifier') continue; + const ns = nameOf(spec.local); + const member = root + .find(j.MemberExpression, { object: { name: ns } }) + .filter((m) => rule.names.includes(nameOf(m.node.property))); + const qualified = root + .find(j.TSQualifiedName, { left: { name: ns } }) + .filter((q) => rule.names.includes(nameOf(q.node.right))); + if (member.size() + qualified.size() > 0) { + warn( + p.node, + `namespace import uses ${list}, which moved to '${rule.to}'. Rewrite by hand.` + ); + } + } + }); + + root + .find(j.ExportAllDeclaration, { source: { value: rule.from } }) + .forEach((p) => { + warn( + p.node, + `'export *' from '${rule.from}' no longer re-exports ${list}. Add an export from '${rule.to}' if you need them.` + ); + }); + + root + .find(j.CallExpression, { arguments: [{ value: rule.from }] }) + .filter((p) => p.node.arguments.length > 1) + .forEach((p) => { + const mentions = j(p.get('arguments', 1)) + .find(j.Identifier) + .filter((id) => rule.names.includes(id.node.name)); + if (mentions.size() > 0) { + warn( + p.node, + `mock of '${rule.from}' stubs ${list}. Mock '${rule.to}' instead.` + ); + } + }); +}; + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + +/* An old specifier and its renamed form, since scope-rename may have run. */ +const bothScopes = (manifest: Manifest, spec: string) => { + const pkg = Object.keys(manifest.packages).find( + (p) => spec === p || spec.startsWith(`${p}/`) + ); + return pkg + ? [spec, manifest.packages[pkg].to + spec.slice(pkg.length)] + : [spec]; +}; + +export const movedExports: AstMigration = { + name: 'moved-exports', + kind: 'ast', + description: + 'Move exports that left a package root (e.g. Video) to their new subpath.', + run({ j, root, manifest, warn }) { + let changed = false; + for (const rule of manifest.movedExports) { + const paths = [ + ...root + .find(j.ImportDeclaration, { source: { value: rule.from } }) + .paths(), + ...root + .find(j.ExportNamedDeclaration, { source: { value: rule.from } }) + .paths(), + ] as ASTPath[]; + for (const p of paths) { + changed = moveSpecifiers(j, root, p, rule) || changed; + } + warnAboutUnsafeShapes(j, root, rule, warn); + } + return changed; + }, + /* + recast copies the gap that followed the original declaration onto the + new one, so a split can leave a blank line between the two halves. + Close exactly that gap and nothing else. + */ + postPrint(source, { manifest }) { + return manifest.movedExports.reduce((out, rule) => { + const from = bothScopes(manifest, rule.from).map(escape).join('|'); + const to = bothScopes(manifest, rule.to).map(escape).join('|'); + const split = new RegExp( + `(from '(?:${from})';?)\\n\\n((?:import|export)[^\\n]*from '(?:${to})';?)`, + 'g' + ); + return out.replace(split, '$1\n$2'); + }, source); + }, +}; diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json new file mode 100644 index 0000000000..1ad0d5bf74 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json @@ -0,0 +1,11 @@ +{ + "name": "app", + "dependencies": { + "@codecademy/gamut": "73.6.1", + "@codecademy/gamut-kit": "3.0.24", + "react": "^19.0.0" + }, + "devDependencies": { + "eslint-plugin-gamut": "3.1.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json new file mode 100644 index 0000000000..779450d73c --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json @@ -0,0 +1,16 @@ +{ + "name": "app", + "dependencies": { + "@skillsoft/gamut": "^{{version}}", + "@skillsoft/gamut-icons": "^{{version}}", + "@skillsoft/gamut-illustrations": "^{{version}}", + "@skillsoft/gamut-patterns": "^{{version}}", + "@skillsoft/gamut-styles": "^{{version}}", + "@skillsoft/gamut-tests": "^{{version}}", + "@skillsoft/variance": "^{{version}}", + "react": "^19.0.0" + }, + "devDependencies": { + "@skillsoft/eslint-plugin-gamut": "^0.0.1" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json new file mode 100644 index 0000000000..47a5eebf34 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json @@ -0,0 +1 @@ +["replaced '@codecademy/gamut-kit'"] diff --git a/packages/gamut-codemods/src/migrations/package-json/index.ts b/packages/gamut-codemods/src/migrations/package-json/index.ts new file mode 100644 index 0000000000..7d25d0ae30 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/index.ts @@ -0,0 +1,72 @@ +import type { FileMigration } from '../../lib/types'; + +/* + Renames dependencies in package.json and pins them to + manifest.targetVersions. Removed packages are replaced by the packages + they bundled. Only touched fields are re-sorted; indentation and the + trailing newline are kept. +*/ + +const FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const; + +const sortKeys = (obj: Record) => + Object.fromEntries( + Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)) + ); + +const detectIndent = (text: string) => + (text.match(/^[ \t]+(?=")/m) ?? [' '])[0]; + +export const packageJson: FileMigration = { + name: 'package-json', + kind: 'file', + description: 'Rename dependencies in package.json.', + match: (file) => /(^|\/)package\.json$/.test(file), + run({ source, manifest, warn, note }) { + const pkg = JSON.parse(source) as Record; + let changed = false; + + for (const field of FIELDS) { + const deps = pkg[field] as Record | undefined; + if (!deps) continue; + const next = { ...deps }; + let touched = false; + + for (const [removed, { expandTo, note: why }] of Object.entries( + manifest.removedPackages + )) { + if (!(removed in next)) continue; + delete next[removed]; + for (const old of expandTo) { + if (!(old in next)) next[old] = '*'; + } + warn( + `${field}: replaced '${removed}' with its individual packages. ${why}` + ); + note(`removed:${removed}`); + touched = true; + } + + for (const [from, { to }] of Object.entries(manifest.packages)) { + if (!(from in next)) continue; + delete next[from]; + next[to] = manifest.targetVersions[to]; + touched = true; + } + + if (touched) { + pkg[field] = sortKeys(next); + changed = true; + } + } + + if (!changed) return null; + const trailing = source.endsWith('\n') ? '\n' : ''; + return JSON.stringify(pkg, null, detectIndent(source)) + trailing; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx new file mode 100644 index 0000000000..db77a7d96b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx @@ -0,0 +1,20 @@ +import { Box } from '@codecademy/gamut'; +import { theme } from '@codecademy/gamut-styles'; +import { Video } from '@codecademy/gamut/Video'; +import { setupRtl } from '@codecademy/gamut-tests'; +import type { CoreTheme } from '@codecademy/gamut-styles'; +import { Kit } from '@codecademy/gamut-kit'; +import { Other } from '@codecademy/gamut-extra-thing'; + +export * from '@codecademy/gamut-icons'; + +declare module '@codecademy/gamut-styles' { + interface Theme extends CoreTheme {} +} + +jest.mock('@codecademy/variance'); +const lazy = () => import('@codecademy/gamut-patterns'); +const req = require('@codecademy/gamut-illustrations'); +type T = import('@codecademy/gamut').BoxProps; + +export const transpilePackages = ['@codecademy/gamut']; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx new file mode 100644 index 0000000000..dca4d84c5d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx @@ -0,0 +1,20 @@ +import { Box } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; +import { Video } from '@skillsoft/gamut/Video'; +import { setupRtl } from '@skillsoft/gamut-tests'; +import type { CoreTheme } from '@skillsoft/gamut-styles'; +import { Kit } from '@codecademy/gamut-kit'; +import { Other } from '@codecademy/gamut-extra-thing'; + +export * from '@skillsoft/gamut-icons'; + +declare module '@skillsoft/gamut-styles' { + interface Theme extends CoreTheme {} +} + +jest.mock('@skillsoft/variance'); +const lazy = () => import('@skillsoft/gamut-patterns'); +const req = require('@skillsoft/gamut-illustrations'); +type T = import('@skillsoft/gamut').BoxProps; + +export const transpilePackages = ['@skillsoft/gamut']; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json new file mode 100644 index 0000000000..158b86d60b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json @@ -0,0 +1 @@ +["'@codecademy/gamut-kit' has no replacement package", "renamed 1 package-name string(s) outside imports (@codecademy/gamut)"] diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx new file mode 100644 index 0000000000..5570545ab3 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx @@ -0,0 +1,13 @@ +import { Box } from '@codecademy/gamut' + +jest.mock('./Card', () => { + return ({ title, description }: any) => ( +
+ {title} - {description} +
+ ) +}) + +jest.mock("@codecademy/gamut-icons", () => ({ + AiEditSparkIcon: () =>
AI Icon
, +})) diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx new file mode 100644 index 0000000000..fac3c38590 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx @@ -0,0 +1,13 @@ +import { Box } from '@skillsoft/gamut' + +jest.mock('./Card', () => { + return ({ title, description }: any) => ( +
+ {title} - {description} +
+ ) +}) + +jest.mock("@skillsoft/gamut-icons", () => ({ + AiEditSparkIcon: () =>
AI Icon
, +})) diff --git a/packages/gamut-codemods/src/migrations/scope-rename/index.ts b/packages/gamut-codemods/src/migrations/scope-rename/index.ts new file mode 100644 index 0000000000..0b62ee9295 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/index.ts @@ -0,0 +1,69 @@ +import { removedPackageFor, renameSpecifier } from '../../lib/manifest'; +import type { StringNode } from '../../lib/module-strings'; +import { findModuleStrings, isStringLiteral } from '../../lib/module-strings'; +import type { Edit, SpliceMigration } from '../../lib/types'; + +/* + Swaps old package names for new ones in every module position: imports, + exports, require, mocks, `declare module`, import types. Runs last, since + every other migration is written against the old names. + + Any other string that exactly names an old package (Next's + transpilePackages, Module Federation shared keys, lint ignore lists) is + rewritten too. Across mono, platform, front, and talent-intelligence every + one of those was a correct rename, so they get one warning per file + asking for a look at the diff, not one per string. + + This is a splice migration: it edits the text inside the quotes and never + asks recast to print. Mutating even one literal's value made recast + reprint neighbouring statements in semicolon-less files, including JSX + text whitespace. +*/ + +export const scopeRename: SpliceMigration = { + name: 'scope-rename', + kind: 'splice', + description: 'Swap old package specifiers for their new names.', + edits({ j, root, manifest, warn }) { + const edits: Edit[] = []; + const moduleStrings = new Set(findModuleStrings(j, root)); + const outsideImports: string[] = []; + + const visit = (literal: StringNode, inModulePosition: boolean) => { + const removed = removedPackageFor(manifest, literal.value); + if (removed) { + warn( + literal, + `'${removed}' has no replacement package. ${manifest.removedPackages[removed].note}` + ); + return; + } + const next = renameSpecifier(manifest, literal.value); + if (!next) return; + if (!inModulePosition) outsideImports.push(literal.value); + /* Inside the quotes only, so the file's quote style survives. */ + edits.push({ + start: literal.start + 1, + end: literal.end - 1, + text: next, + }); + }; + + for (const literal of moduleStrings) visit(literal, true); + + root + .find(j.Node) + .filter((p) => isStringLiteral(p.node) && !moduleStrings.has(p.node)) + .forEach((p) => visit(p.node as StringNode, false)); + + if (outsideImports.length > 0) { + const names = [...new Set(outsideImports)].join(', '); + warn( + null, + `renamed ${outsideImports.length} package-name string(s) outside imports (${names}). Check the diff.` + ); + } + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/presets/index.ts b/packages/gamut-codemods/src/presets/index.ts new file mode 100644 index 0000000000..2ff0c9294a --- /dev/null +++ b/packages/gamut-codemods/src/presets/index.ts @@ -0,0 +1,43 @@ +import type { Preset } from '../lib/types'; +import { scopeSwap } from './scope-swap'; + +/* + Every preset the CLI can run. A preset is one upgrade: its own manifest, + an ordered list of migrations, and the checklist to print afterwards. + Later presets are named for the release they target and can reuse + migrations from src/migrations with a different manifest. +*/ +export const presets: Record = { + [scopeSwap.name]: scopeSwap, +}; + +/* + AST migrations print through recast once, then splice migrations edit that + output by offset. Interleaving them would need a print and re-parse per + switch, so presets have to list every `ast` migration before any + `splice` one. +*/ +export const validatePreset = (preset: Preset) => { + const source = preset.migrations.filter((m) => m.kind !== 'file'); + const firstSplice = source.findIndex((m) => m.kind === 'splice'); + const lateAst = + firstSplice === -1 + ? undefined + : source.slice(firstSplice).find((m) => m.kind === 'ast'); + if (lateAst) { + throw new Error( + `preset '${preset.name}': ast migration '${lateAst.name}' is listed after a splice migration. List every ast migration first.` + ); + } + return preset; +}; + +export const getPreset = (name: string) => { + const preset = presets[name]; + if (!preset) { + throw new Error( + `unknown preset '${name}'. Available: ${Object.keys(presets).join(', ')}` + ); + } + return validatePreset(preset); +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts new file mode 100644 index 0000000000..8ece32e855 --- /dev/null +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -0,0 +1,36 @@ +import type { Preset } from '../../lib/types'; +import { deepImports } from '../../migrations/deep-imports'; +import { eslintComments } from '../../migrations/eslint-comments'; +import { eslintConfig } from '../../migrations/eslint-config'; +import { mfShared } from '../../migrations/mf-shared'; +import { movedExports } from '../../migrations/moved-exports'; +import { packageJson } from '../../migrations/package-json'; +import { scopeRename } from '../../migrations/scope-rename'; +import { manifest } from './manifest'; + +export const scopeSwap: Preset = { + name: 'scope-swap', + description: 'Move from @codecademy/gamut* to @skillsoft/gamut*.', + manifest, + migrations: [ + deepImports, + movedExports, + mfShared, + eslintComments, + /* Last: everything above matches on @codecademy names. */ + scopeRename, + packageJson, + eslintConfig, + ], + checklist: [ + 'Run your formatter (prettier --write / eslint --fix). Split imports come out in recast style.', + 'Reinstall (yarn), then type-check and run your tests.', + 'Work through the warnings above and the leftovers list.', + ], + conditionalChecklist: { + 'removed:@codecademy/gamut-kit': + 'gamut-kit is gone. Delete anything that reads it for versions, such as a sync-gamut-deps script.', + 'mf-shared': + "Module Federation shared config changed. Gamut is now a real singleton: run the host and every remote together, and update the remotes' shared config to match.", + }, +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts new file mode 100644 index 0000000000..dc45537356 --- /dev/null +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -0,0 +1,154 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Manifest } from '../../lib/manifest'; + +/* + What changed between @codecademy/gamut* and @skillsoft/gamut*. Every entry + uses the OLD (@codecademy) names; scope-rename runs last and swaps the + scope, so no other migration needs to know the new one. + + manifest.test.ts type-checks every export this file points at against the + built packages, so a row can't reference an export that doesn't exist. +*/ + +/* + This package is in the changesets `fixed` group with the packages it + migrates to, so its own version is theirs. +*/ +const { version } = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../../package.json'), 'utf8') +) as { version: string }; + +const GROUP_RANGE = `^${version}`; + +export const manifest: Manifest = { + packages: { + '@codecademy/gamut': { to: '@skillsoft/gamut' }, + '@codecademy/gamut-styles': { to: '@skillsoft/gamut-styles' }, + '@codecademy/gamut-icons': { to: '@skillsoft/gamut-icons' }, + '@codecademy/gamut-illustrations': { to: '@skillsoft/gamut-illustrations' }, + '@codecademy/gamut-patterns': { to: '@skillsoft/gamut-patterns' }, + '@codecademy/gamut-tests': { to: '@skillsoft/gamut-tests' }, + '@codecademy/variance': { to: '@skillsoft/variance' }, + 'eslint-plugin-gamut': { to: '@skillsoft/eslint-plugin-gamut' }, + }, + + removedPackages: { + '@codecademy/gamut-kit': { + expandTo: [ + '@codecademy/gamut', + '@codecademy/gamut-icons', + '@codecademy/gamut-illustrations', + '@codecademy/gamut-patterns', + '@codecademy/gamut-styles', + '@codecademy/gamut-tests', + '@codecademy/variance', + ], + /* gamut-tests is test-only, so it has no business in a runtime share. */ + mfSharedAs: [ + '@codecademy/gamut', + '@codecademy/gamut-icons', + '@codecademy/gamut-illustrations', + '@codecademy/gamut-patterns', + '@codecademy/gamut-styles', + '@codecademy/variance', + ], + note: 'gamut-kit is not published under @skillsoft. Depend on the individual packages instead.', + }, + }, + + targetVersions: { + '@skillsoft/gamut': GROUP_RANGE, + '@skillsoft/gamut-styles': GROUP_RANGE, + '@skillsoft/gamut-icons': GROUP_RANGE, + '@skillsoft/gamut-illustrations': GROUP_RANGE, + '@skillsoft/gamut-patterns': GROUP_RANGE, + '@skillsoft/gamut-tests': GROUP_RANGE, + '@skillsoft/variance': GROUP_RANGE, + /* + Versioned outside the fixed group. manifest.test.ts fails if this + falls behind packages/eslint-plugin-gamut/package.json. + */ + '@skillsoft/eslint-plugin-gamut': '^0.0.1', + }, + + movedExports: [ + { + from: '@codecademy/gamut', + to: '@codecademy/gamut/Video', + names: ['Video', 'VideoProps'], + note: 'Video is no longer on the root barrel. See packages/gamut/src/index.tsx.', + }, + ], + + /* + From the GMT-1740 deep-import inventory. Rows are only added once the + target is really exported; manifest.test.ts checks. + */ + deepImports: [ + { + from: '@codecademy/gamut/dist/PopoverContainer/types', + to: '@codecademy/gamut', + }, + { + from: '@codecademy/gamut/dist/Form/SelectDropdown/types', + to: '@codecademy/gamut', + }, + { from: '@codecademy/gamut/dist/Form/types', to: '@codecademy/gamut' }, + { from: '@codecademy/gamut/dist/Box/props', to: '@codecademy/gamut' }, + { + from: '@codecademy/gamut/dist/Tip/shared/types', + to: '@codecademy/gamut', + }, + { from: '@codecademy/gamut/dist/Button/shared', to: '@codecademy/gamut' }, + { + from: '@codecademy/gamut/dist/Markdown/libs/overrides', + to: '@codecademy/gamut', + }, + /* + No public replacement for the next three. Upstream GMT-1740 renamed + them (List -> MenuList, IconOption -> IconOptionComponent) and promoted + ButtonBase, then reverted all three before merging (upstream 009aa43f6) + as breaking changes for a separate PR. skillsoft-gamut matches that. + */ + { + from: '@codecademy/gamut/dist/ButtonBase/ButtonBase', + to: null, + note: 'ButtonBase is deliberately not public. Use FillButton, StrokeButton, TextButton, IconButton, or CTAButton.', + }, + { + from: '@codecademy/gamut/dist/Menu/elements', + to: null, + note: 'Menu list elements are not public (a MenuList* rename was held back upstream). Compose Menu/MenuItem, or copy the element.', + }, + { + from: '@codecademy/gamut/dist/Form/SelectDropdown/elements', + to: null, + note: 'The IconOption component is not public. Copy it into your app if you need it.', + }, + { + from: '@codecademy/gamut/dist/Form/styles', + to: null, + note: 'Form/styles has no public replacement. Inline the styles or use system props / css().', + }, + { + from: '@codecademy/gamut-styles/dist/AssetProvider', + to: '@codecademy/gamut-styles', + }, + { + from: '@codecademy/gamut-icons/dist/types', + to: '@codecademy/gamut-icons', + }, + { + from: '@codecademy/gamut-icons/dist/props', + to: '@codecademy/gamut-icons', + }, + { + from: '@codecademy/variance/dist/types/config', + to: '@codecademy/variance', + }, + ], + + eslintPlugin: { from: 'gamut', to: '@skillsoft/gamut' }, +}; diff --git a/packages/gamut-codemods/src/transform.ts b/packages/gamut-codemods/src/transform.ts new file mode 100644 index 0000000000..2bf5cef8bb --- /dev/null +++ b/packages/gamut-codemods/src/transform.ts @@ -0,0 +1,128 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { API, FileInfo } from 'jscodeshift'; + +import type { + AstMigration, + Edit, + Locatable, + SourceMigration, + SpliceMigration, +} from './lib/types'; +import { getPreset } from './presets'; + +/* + jscodeshift entry point. Two phases: + + 1. AST migrations share one parse and mutate it. If any changed + something, recast prints the file once and `postPrint` hooks run. + 2. Splice migrations run in order, each against a fresh parse of the + current text, returning `{ start, end, text }` edits applied back to + front. A re-parse between them means a later splice sees an earlier + one's output (mf-shared writes @codecademy keys that scope-rename then + renames). + + Options: + preset: preset name (default: scope-swap) + only: comma-separated migration names to run (default: all) + notesDir: directory the CLI reads checklist notes back from. jscodeshift + only aggregates api.stats in --dry runs, so workers leave an + empty marker file per note key instead. + onWarn: (message) => void, for tests; otherwise warnings go to api.report +*/ + +export interface TransformOptions { + preset?: string; + only?: string; + notesDir?: string; + onWarn?: (message: string) => void; +} + +const applyEdits = (source: string, edits: Edit[]) => + [...edits] + .sort((a, b) => b.start - a.start) + .reduce( + (out, { start, end, text }) => + out.slice(0, start) + text + out.slice(end), + source + ); + +export default function transform( + file: FileInfo, + api: API, + options: TransformOptions = {} +) { + const preset = getPreset(options.preset ?? 'scope-swap'); + const { manifest } = preset; + + /* Cheap pre-filter so big repos don't parse every file. */ + const oldNames = [ + ...Object.keys(manifest.packages), + ...Object.keys(manifest.removedPackages), + `${manifest.eslintPlugin.from}/`, + ]; + if (!oldNames.some((name) => file.source.includes(name))) return undefined; + + const only = options.only ? new Set(options.only.split(',')) : null; + const active = preset.migrations.filter( + (m): m is SourceMigration => + m.kind !== 'file' && (!only || only.has(m.name)) + ); + + const j = api.jscodeshift; + const note = (key: string) => { + if (options.notesDir) { + fs.writeFileSync( + path.join(options.notesDir, encodeURIComponent(key)), + '' + ); + } + }; + const warnFor = (name: string) => (node: Locatable, message: string) => { + const line = node?.loc ? `:${node.loc.start.line}` : ''; + const text = `[${name}] ${file.path}${line} ${message}`; + if (options.onWarn) options.onWarn(text); + else api.report(text); + }; + + let { source } = file; + + const astMigrations = active.filter( + (m): m is AstMigration => m.kind === 'ast' + ); + if (astMigrations.length > 0) { + const root = j(source); + let changed = false; + for (const m of astMigrations) { + changed = + m.run({ j, root, source, manifest, warn: warnFor(m.name), note }) || + changed; + } + if (changed) { + source = astMigrations.reduce( + (out, m) => (m.postPrint ? m.postPrint(out, { manifest }) : out), + root.toSource({ quote: 'single' }) + ); + } + } + + for (const m of active.filter( + (x): x is SpliceMigration => x.kind === 'splice' + )) { + const root = j(source); + const edits = m.edits({ + j, + root, + source, + manifest, + warn: warnFor(m.name), + note, + }); + if (edits.length > 0) source = applyEdits(source, edits); + } + + return source === file.source ? undefined : source; +} + +export const parser = 'tsx'; diff --git a/packages/gamut-codemods/tsconfig.json b/packages/gamut-codemods/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/packages/gamut-codemods/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/gamut-codemods/tsconfig.lib.json b/packages/gamut-codemods/tsconfig.lib.json new file mode 100644 index 0000000000..8c51e7c29d --- /dev/null +++ b/packages/gamut-codemods/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "module": "CommonJS", + "outDir": "./dist", + "rootDir": "./src", + "lib": ["es2022"], + "types": ["node"] + }, + "exclude": ["jest.config.ts", "**/*.test.ts", "**/__testfixtures__/**"], + "include": ["./src/**/*.ts"] +} diff --git a/packages/gamut-codemods/tsconfig.spec.json b/packages/gamut-codemods/tsconfig.spec.json new file mode 100644 index 0000000000..38a15e287a --- /dev/null +++ b/packages/gamut-codemods/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./dist", + "lib": ["es2022"], + "types": ["jest", "node"] + }, + "exclude": ["**/__testfixtures__/**"], + "include": ["jest.config.ts", "**/*.test.ts", "**/*.d.ts"] +} diff --git a/yarn.lock b/yarn.lock index 086fa9efad..c62b2f4f7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -478,7 +478,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.29.0": +"@babel/core@npm:^7.24.7, @babel/core@npm:^7.29.0": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -527,12 +527,12 @@ __metadata: languageName: node linkType: hard -"@babel/helper-annotate-as-pure@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-annotate-as-pure@npm:7.25.9" +"@babel/helper-annotate-as-pure@npm:^7.25.9, @babel/helper-annotate-as-pure@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-annotate-as-pure@npm:7.29.7" dependencies: - "@babel/types": "npm:^7.25.9" - checksum: 10c0/095b6ba50489d797733abebc4596a81918316a99e3632755c9f02508882912b00c2ae5e468532a25a5c2108d109ddbe9b7da78333ee7cc13817fc50c00cf06fe + "@babel/types": "npm:^7.29.7" + checksum: 10c0/c56536b52d17632d89d49db2063ed6102f0e3bbadf6a0ccb74e6599d6a77173b644c7fe8c3ef17c7a162709d55b75ee5145ef6db917d16ba7f375fbffcf2e942 languageName: node linkType: hard @@ -572,20 +572,20 @@ __metadata: languageName: node linkType: hard -"@babel/helper-create-class-features-plugin@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-create-class-features-plugin@npm:7.25.9" +"@babel/helper-create-class-features-plugin@npm:^7.25.9, @babel/helper-create-class-features-plugin@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-create-class-features-plugin@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.25.9" - "@babel/helper-member-expression-to-functions": "npm:^7.25.9" - "@babel/helper-optimise-call-expression": "npm:^7.25.9" - "@babel/helper-replace-supers": "npm:^7.25.9" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.25.9" - "@babel/traverse": "npm:^7.25.9" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-member-expression-to-functions": "npm:^7.29.7" + "@babel/helper-optimise-call-expression": "npm:^7.29.7" + "@babel/helper-replace-supers": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/b2bdd39f38056a76b9ba00ec5b209dd84f5c5ebd998d0f4033cf0e73d5f2c357fbb49d1ce52db77a2709fb29ee22321f84a5734dc9914849bdfee9ad12ce8caf + checksum: 10c0/75f34905b5e708b473f1e9b33e07b2fcc8f4c60676df8bc74541bb91c77f387c32a948dd04d5071e469ba454d72d0a872e3ace40fbb1d1e7aaa8569efcf09ed4 languageName: node linkType: hard @@ -631,13 +631,13 @@ __metadata: languageName: node linkType: hard -"@babel/helper-member-expression-to-functions@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-member-expression-to-functions@npm:7.25.9" +"@babel/helper-member-expression-to-functions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-member-expression-to-functions@npm:7.29.7" dependencies: - "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10c0/e08c7616f111e1fb56f398365e78858e26e466d4ac46dff25921adc5ccae9b232f66e952a2f4162bbe336627ba336c7fd9eca4835b6548935973d3380d77eaff + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/eef7940ce0797208854a5af1049a98fee9abbffb5c619640c69ff5a555f8e3552295bb18756490b02bc6af7df8c1babcb83f12203aac2deb9dfecfc78846e12d languageName: node linkType: hard @@ -687,12 +687,12 @@ __metadata: languageName: node linkType: hard -"@babel/helper-optimise-call-expression@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-optimise-call-expression@npm:7.25.9" +"@babel/helper-optimise-call-expression@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-optimise-call-expression@npm:7.29.7" dependencies: - "@babel/types": "npm:^7.25.9" - checksum: 10c0/90203e6607edeadd2a154940803fd616c0ed92c1013d6774c4b8eb491f1a5a3448b68faae6268141caa5c456e55e3ee49a4ed2bd7ddaf2365daea321c435914c + "@babel/types": "npm:^7.29.7" + checksum: 10c0/fd0244b9bfbb487db02d59aa2703c6991d654ea5f3f39d912682842bdca2e87b5ae8643b0ce8069bf5fbee39d1aa9db7abefeb5e6ba1aa650dca12777cf5b7e2 languageName: node linkType: hard @@ -723,36 +723,26 @@ __metadata: languageName: node linkType: hard -"@babel/helper-replace-supers@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-replace-supers@npm:7.25.9" +"@babel/helper-replace-supers@npm:^7.25.9, @babel/helper-replace-supers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-replace-supers@npm:7.29.7" dependencies: - "@babel/helper-member-expression-to-functions": "npm:^7.25.9" - "@babel/helper-optimise-call-expression": "npm:^7.25.9" - "@babel/traverse": "npm:^7.25.9" + "@babel/helper-member-expression-to-functions": "npm:^7.29.7" + "@babel/helper-optimise-call-expression": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/0b40d7d2925bd3ba4223b3519e2e4d2456d471ad69aa458f1c1d1783c80b522c61f8237d3a52afc9e47c7174129bbba650df06393a6787d5722f2ec7f223c3f4 - languageName: node - linkType: hard - -"@babel/helper-simple-access@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-simple-access@npm:7.25.9" - dependencies: - "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10c0/3f1bcdb88ee3883ccf86959869a867f6bbf8c4737cd44fb9f799c38e54f67474590bc66802500ae9fe18161792875b2cfb7ec15673f48ed6c8663f6d09686ca8 + checksum: 10c0/1c7ae37797f226e965ab85f6affa53d25a10c169c604a4daeb36f9df09e673471e6522f631c13761cf9fbafeca2ea14c241dea8d723a51039d561beb01d86ac4 languageName: node linkType: hard -"@babel/helper-skip-transparent-expression-wrappers@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.25.9" +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.25.9, @babel/helper-skip-transparent-expression-wrappers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.29.7" dependencies: - "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10c0/09ace0c6156961624ac9524329ce7f45350bab94bbe24335cbe0da7dfaa1448e658771831983cb83fe91cf6635b15d0a3cab57c03b92657480bfb49fb56dd184 + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/8c59493621487fc491f27adfc200af82a6aca3b9a5511e4e6050f8716593b4b243472cb56c8d2016e828b7ae12d605a819205aa8600ca08ee291dcd58d65c832 languageName: node linkType: hard @@ -987,6 +977,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-flow@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-flow@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/b2e330dd0dc535c7364937ce2b29a06065e60b1d523db75f36ab4fb89e4ba664e2230cbb1937b35712c9a0ebafc3358f323d6e6b22247d5ebad12fdd3e1f6e98 + languageName: node + linkType: hard + "@babel/plugin-syntax-import-assertions@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-syntax-import-assertions@npm:7.26.0" @@ -1212,15 +1213,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-class-properties@npm:^7.22.5, @babel/plugin-transform-class-properties@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-class-properties@npm:7.25.9" +"@babel/plugin-transform-class-properties@npm:^7.22.5, @babel/plugin-transform-class-properties@npm:^7.24.7, @babel/plugin-transform-class-properties@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-class-properties@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.25.9" - "@babel/helper-plugin-utils": "npm:^7.25.9" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/f0603b6bd34d8ba62c03fc0572cb8bbc75874d097ac20cc7c5379e001081210a84dba1749e7123fca43b978382f605bb9973c99caf2c5b4c492d5c0a4a441150 + checksum: 10c0/c370700423439aa9f0c1f8c4b97f2ef7c2dc46a1b04ec3b10e83e6bae5e4e2159f56d8e4376c9d669b3cf827650cc3740170a36e3924e3e9970d27fd85f4e48a languageName: node linkType: hard @@ -1344,6 +1345,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-flow-strip-types@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-flow-strip-types@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/plugin-syntax-flow": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/083e864a60927cde7bd75822bdf5c6c72de200ee955e48f6ff5923f0d2b0744ab8f1b64c45e74b88ae3796f03721489afffdb0536f25c54d0dd58508a8904f40 + languageName: node + linkType: hard + "@babel/plugin-transform-for-of@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-for-of@npm:7.25.9" @@ -1425,16 +1438,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-modules-commonjs@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-modules-commonjs@npm:7.25.9" +"@babel/plugin-transform-modules-commonjs@npm:^7.24.7, @babel/plugin-transform-modules-commonjs@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7" dependencies: - "@babel/helper-module-transforms": "npm:^7.25.9" - "@babel/helper-plugin-utils": "npm:^7.25.9" - "@babel/helper-simple-access": "npm:^7.25.9" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/6ce771fb04d4810257fc8900374fece877dacaed74b05eaa16ad9224b390f43795c4d046cbe9ae304e1eb5aad035d37383895e3c64496d647c2128d183916e74 + checksum: 10c0/9791cb524438b2a8ba6cb8715788fa1e202fbecd4e76b3ccab0af0819fd69212b40ae30d72ac377012f7149889f7792ed8bf91e97bbe9113ca9641f8ad3bf332 languageName: node linkType: hard @@ -1487,14 +1499,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.25.9" +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.9" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/eb623db5be078a1c974afe7c7797b0309ba2ea9e9237c0b6831ade0f56d8248bb4ab3432ab34495ff8c877ec2fe412ff779d1e9b3c2b8139da18e1753d950bc3 + checksum: 10c0/b0c186fe38bc66830e1be76f06fabbae8a655d3896a841ba5ffa12d6c40bb9c8a6ecd38a7e2196034b1d7470653109b1ceac1ef5e46a5fdc9291d14afa56e0d0 languageName: node linkType: hard @@ -1545,15 +1557,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-chaining@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-optional-chaining@npm:7.25.9" +"@babel/plugin-transform-optional-chaining@npm:^7.24.7, @babel/plugin-transform-optional-chaining@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.9" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.25.9" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/041ad2beae5affb8e68a0bcb6882a2dadb758db3c629a0e012f57488ab43a822ac1ea17a29db8ef36560a28262a5dfa4dbbbf06ed6e431db55abe024b7cd3961 + checksum: 10c0/71feacf9a7083030f4c69bf4e91db75f2fceae28e58c58b63db040006d908e15bc1e85a464f24ad659f17702e91a64eabbff9f1dd555ba33d78bb1af8a1d697a languageName: node linkType: hard @@ -1568,15 +1580,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-private-methods@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-private-methods@npm:7.25.9" +"@babel/plugin-transform-private-methods@npm:^7.24.7, @babel/plugin-transform-private-methods@npm:^7.25.9": + version: 7.29.7 + resolution: "@babel/plugin-transform-private-methods@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.25.9" - "@babel/helper-plugin-utils": "npm:^7.25.9" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/64bd71de93d39daefa3e6c878d6f2fd238ed7d4ecfb13b0e771ddbbc131487def3ceb405b62b534a5cbb5043046b504e1b189b0a45229cc75af979a9fbcaa7bd + checksum: 10c0/d0dc12fa478d05e346dfb02fad2ed99b308d9a7324bada71530a62dc1ccbf07b4c2581ac677b7196b3994f191ef1922199e2c8f33957b726e2019ce07b4ced0f languageName: node linkType: hard @@ -1934,6 +1946,19 @@ __metadata: languageName: node linkType: hard +"@babel/preset-flow@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/preset-flow@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" + "@babel/plugin-transform-flow-strip-types": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/17bb0f3c320fadb631ab64951ec0f6e05a62b15418c7864c181645b3205cabd3a8541dd3f497bf06cb1d0e04a3806f6cffee7675c97a87d4aaa04961f334cdcb + languageName: node + linkType: hard + "@babel/preset-modules@npm:0.1.6-no-external-plugins": version: 0.1.6-no-external-plugins resolution: "@babel/preset-modules@npm:0.1.6-no-external-plugins" @@ -1978,6 +2003,21 @@ __metadata: languageName: node linkType: hard +"@babel/register@npm:^7.24.6": + version: 7.29.7 + resolution: "@babel/register@npm:7.29.7" + dependencies: + clone-deep: "npm:^4.0.1" + find-cache-dir: "npm:^2.0.0" + make-dir: "npm:^2.1.0" + pirates: "npm:^4.0.6" + source-map-support: "npm:^0.5.16" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/154dc72cc354a3d2b7b69d10738f02278774bbf53d3b365f7897035c9f26ad6433b24e81ad277c0fbf33139e08230e0cab3913e91d9325308af532da854765f2 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": version: 7.28.2 resolution: "@babel/runtime@npm:7.28.2" @@ -8099,6 +8139,18 @@ __metadata: languageName: unknown linkType: soft +"@skillsoft/gamut-codemods@workspace:packages/gamut-codemods": + version: 0.0.0-use.local + resolution: "@skillsoft/gamut-codemods@workspace:packages/gamut-codemods" + dependencies: + "@skillsoft/gamut": "workspace:*" + "@types/jscodeshift": "npm:^17.3.0" + jscodeshift: "npm:^17.3.0" + bin: + gamut-codemods: ./dist/bin.js + languageName: unknown + linkType: soft + "@skillsoft/gamut-docs@workspace:packages/gamut-docs": version: 0.0.0-use.local resolution: "@skillsoft/gamut-docs@workspace:packages/gamut-docs" @@ -9380,6 +9432,16 @@ __metadata: languageName: node linkType: hard +"@types/jscodeshift@npm:^17.3.0": + version: 17.3.0 + resolution: "@types/jscodeshift@npm:17.3.0" + dependencies: + ast-types: "npm:^0.16.1" + recast: "npm:^0.23.11" + checksum: 10c0/56b889b290f12322b43d74c7434b69cef3e1ca8444e37891e08585f716b1c23f44596f650fb92220a8c6e5776d3dbc7d8ece67e950b6f9e733eee8e712128c73 + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -11946,6 +12008,17 @@ __metadata: languageName: node linkType: hard +"clone-deep@npm:^4.0.1": + version: 4.0.1 + resolution: "clone-deep@npm:4.0.1" + dependencies: + is-plain-object: "npm:^2.0.4" + kind-of: "npm:^6.0.2" + shallow-clone: "npm:^3.0.0" + checksum: 10c0/637753615aa24adf0f2d505947a1bb75e63964309034a1cf56ba4b1f30af155201edd38d26ffe26911adaae267a3c138b344a4947d39f5fc1b6d6108125aa758 + languageName: node + linkType: hard + "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -14503,6 +14576,17 @@ __metadata: languageName: node linkType: hard +"find-cache-dir@npm:^2.0.0": + version: 2.1.0 + resolution: "find-cache-dir@npm:2.1.0" + dependencies: + commondir: "npm:^1.0.1" + make-dir: "npm:^2.0.0" + pkg-dir: "npm:^3.0.0" + checksum: 10c0/556117fd0af14eb88fb69250f4bba9e905e7c355c6136dff0e161b9cbd1f5285f761b778565a278da73a130f42eccc723d7ad4c002ae547ed1d698d39779dabb + languageName: node + linkType: hard + "find-cache-dir@npm:^3.3.1, find-cache-dir@npm:^3.3.2": version: 3.3.2 resolution: "find-cache-dir@npm:3.3.2" @@ -14546,6 +14630,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10c0/2c2e7d0a26db858e2f624f39038c74739e38306dee42b45f404f770db357947be9d0d587f1cac72d20c114deb38aa57316e879eb0a78b17b46da7dab0a3bd6e3 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -14600,6 +14693,22 @@ __metadata: languageName: node linkType: hard +"flow-estree@npm:0.331.0": + version: 0.331.0 + resolution: "flow-estree@npm:0.331.0" + checksum: 10c0/3e967611d5e4f83a4fde886d90cffe48078702b51258eddcc2f6c2e5d8bca37986df5d890b9f787b0760bb35886bcc3aecd7db6f7db1fff0f9f2ef7b216cd281 + languageName: node + linkType: hard + +"flow-parser@npm:0.*": + version: 0.331.0 + resolution: "flow-parser@npm:0.331.0" + dependencies: + flow-estree: "npm:0.331.0" + checksum: 10c0/bfeb7652a3f82c6689b7290ec2e40973c0a665990a199fd160e16854f6f163449e348c296df218cebe303f5e547665a32131e4598a4eeba821ac72fd415e7134 + languageName: node + linkType: hard + "focus-lock@npm:^1.3.6": version: 1.3.6 resolution: "focus-lock@npm:1.3.6" @@ -16407,6 +16516,15 @@ __metadata: languageName: node linkType: hard +"is-plain-object@npm:^2.0.4": + version: 2.0.4 + resolution: "is-plain-object@npm:2.0.4" + dependencies: + isobject: "npm:^3.0.1" + checksum: 10c0/f050fdd5203d9c81e8c4df1b3ff461c4bc64e8b5ca383bcdde46131361d0a678e80bcf00b5257646f6c636197629644d53bd8e2375aea633de09a82d57e942f4 + languageName: node + linkType: hard + "is-plain-object@npm:^5.0.0": version: 5.0.0 resolution: "is-plain-object@npm:5.0.0" @@ -16574,6 +16692,13 @@ __metadata: languageName: node linkType: hard +"isobject@npm:^3.0.1": + version: 3.0.1 + resolution: "isobject@npm:3.0.1" + checksum: 10c0/03344f5064a82f099a0cd1a8a407f4c0d20b7b8485e8e816c39f249e9416b06c322e8dec5b842b6bb8a06de0af9cb48e7bc1b5352f0fadc2f0abac033db3d4db + languageName: node + linkType: hard + "isomorphic-ws@npm:5.0.0": version: 5.0.0 resolution: "isomorphic-ws@npm:5.0.0" @@ -17224,6 +17349,39 @@ __metadata: languageName: node linkType: hard +"jscodeshift@npm:^17.3.0": + version: 17.4.0 + resolution: "jscodeshift@npm:17.4.0" + dependencies: + "@babel/core": "npm:^7.24.7" + "@babel/parser": "npm:^7.24.7" + "@babel/plugin-transform-class-properties": "npm:^7.24.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.24.7" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/preset-flow": "npm:^7.24.7" + "@babel/preset-typescript": "npm:^7.24.7" + "@babel/register": "npm:^7.24.6" + flow-parser: "npm:0.*" + graceful-fs: "npm:^4.2.4" + neo-async: "npm:^2.5.0" + picocolors: "npm:^1.0.1" + picomatch: "npm:^4.0.2" + recast: "npm:^0.23.11" + tmp: "npm:^0.2.3" + write-file-atomic: "npm:^5.0.1" + peerDependencies: + "@babel/preset-env": ^7.1.6 + peerDependenciesMeta: + "@babel/preset-env": + optional: true + bin: + jscodeshift: bin/jscodeshift.js + checksum: 10c0/ef7b71c22f211cbba8ae96ffd2d3e8af28450d8f703d32050ff9f6cb29042bc163ce7d8239798b672b543bb12203adea20fb4d2c0034acc5440d26cb17c7a42f + languageName: node + linkType: hard + "jsdom@npm:^26.1.0": version: 26.1.0 resolution: "jsdom@npm:26.1.0" @@ -17395,7 +17553,7 @@ __metadata: languageName: node linkType: hard -"kind-of@npm:^6.0.3": +"kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": version: 6.0.3 resolution: "kind-of@npm:6.0.3" checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4 @@ -17670,6 +17828,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10c0/3db394b7829a7fe2f4fbdd25d3c4689b85f003c318c5da4052c7e56eed697da8f1bce5294f685c69ff76e32cba7a33629d94396976f6d05fb7f4c755c5e2ae8b + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -17907,6 +18075,16 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^2.0.0, make-dir@npm:^2.1.0": + version: 2.1.0 + resolution: "make-dir@npm:2.1.0" + dependencies: + pify: "npm:^4.0.1" + semver: "npm:^5.6.0" + checksum: 10c0/ada869944d866229819735bee5548944caef560d7a8536ecbc6536edca28c72add47cc4f6fc39c54fb25d06b58da1f8994cf7d9df7dadea047064749efc085d8 + languageName: node + linkType: hard + "make-dir@npm:^3.0.0, make-dir@npm:^3.0.2": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -19223,7 +19401,7 @@ __metadata: languageName: node linkType: hard -"neo-async@npm:^2.6.2": +"neo-async@npm:^2.5.0, neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2" checksum: 10c0/c2f5a604a54a8ec5438a342e1f356dff4bc33ccccdb6dc668d94fe8e5eccfc9d2c2eea6064b0967a767ba63b33763f51ccf2cd2441b461a7322656c1f06b3f5d @@ -19984,7 +20162,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -20011,6 +20189,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10c0/7b7f06f718f19e989ce6280ed4396fb3c34dabdee0df948376483032f9d5ec22fdf7077ec942143a75827bb85b11da72016497fc10dac1106c837ed593969ee8 + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -20233,6 +20420,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10c0/17d6a5664bc0a11d48e2b2127d28a0e58822c6740bde30403f08013da599182289c56518bec89407e3f31d3c2b6b296a4220bc3f867f0911fee6952208b04167 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -20368,6 +20562,13 @@ __metadata: languageName: node linkType: hard +"pify@npm:^4.0.1": + version: 4.0.1 + resolution: "pify@npm:4.0.1" + checksum: 10c0/6f9d404b0d47a965437403c9b90eca8bb2536407f03de165940e62e72c8c8b75adda5516c6b9b23675a5877cc0bcac6bdfb0ef0e39414cd2476d5495da40e7cf + languageName: node + linkType: hard + "pinkie-promise@npm:^2.0.0": version: 2.0.1 resolution: "pinkie-promise@npm:2.0.1" @@ -20384,13 +20585,22 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.7": +"pirates@npm:^4.0.6, pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" checksum: 10c0/a51f108dd811beb779d58a76864bbd49e239fa40c7984cd11596c75a121a8cc789f1c8971d8bb15f0dbf9d48b76c05bb62fcbce840f89b688c0fa64b37e8478a languageName: node linkType: hard +"pkg-dir@npm:^3.0.0": + version: 3.0.0 + resolution: "pkg-dir@npm:3.0.0" + dependencies: + find-up: "npm:^3.0.0" + checksum: 10c0/902a3d0c1f8ac43b1795fa1ba6ffeb37dfd53c91469e969790f6ed5e29ff2bdc50b63ba6115dc056d2efb4a040aa2446d512b3804bdafdf302f734fb3ec21847 + languageName: node + linkType: hard + "pkg-dir@npm:^4.1.0, pkg-dir@npm:^4.2.0": version: 4.2.0 resolution: "pkg-dir@npm:4.2.0" @@ -21359,16 +21569,16 @@ __metadata: languageName: node linkType: hard -"recast@npm:^0.23.5": - version: 0.23.9 - resolution: "recast@npm:0.23.9" +"recast@npm:^0.23.11, recast@npm:^0.23.5": + version: 0.23.21 + resolution: "recast@npm:0.23.21" dependencies: ast-types: "npm:^0.16.1" esprima: "npm:~4.0.0" source-map: "npm:~0.6.1" tiny-invariant: "npm:^1.3.3" tslib: "npm:^2.0.1" - checksum: 10c0/65d6e780351f0180ea4fe5c9593ac18805bf2b79977f5bedbbbf26f6d9b619ed0f6992c1bf9e06dd40fca1aea727ad6d62463cfb5d3a33342ee5a6e486305fe5 + checksum: 10c0/afee4673fda5a95b0db8199a3f75c6f675269f00328a3673f241a6088c99b5a58f7082b6cff683456a182e3302bfb57313ebb1c2073228b260724b0993ff1f9a languageName: node linkType: hard @@ -22553,6 +22763,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^5.6.0": + version: 5.7.2 + resolution: "semver@npm:5.7.2" + bin: + semver: bin/semver + checksum: 10c0/e4cf10f86f168db772ae95d86ba65b3fd6c5967c94d97c708ccb463b778c2ee53b914cd7167620950fc07faf5a564e6efe903836639e512a1aa15fbc9667fa25 + languageName: node + linkType: hard + "semver@npm:^6.0.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -22655,6 +22874,15 @@ __metadata: languageName: node linkType: hard +"shallow-clone@npm:^3.0.0": + version: 3.0.1 + resolution: "shallow-clone@npm:3.0.1" + dependencies: + kind-of: "npm:^6.0.2" + checksum: 10c0/7bab09613a1b9f480c85a9823aebec533015579fa055ba6634aa56ba1f984380670eaf33b8217502931872aa1401c9fcadaa15f9f604d631536df475b05bcf1e + languageName: node + linkType: hard + "shallowequal@npm:^1.1.0": version: 1.1.0 resolution: "shallowequal@npm:1.1.0" @@ -23113,7 +23341,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": +"source-map-support@npm:^0.5.16, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -24058,10 +24286,10 @@ __metadata: languageName: node linkType: hard -"tmp@npm:~0.2.1": - version: 0.2.3 - resolution: "tmp@npm:0.2.3" - checksum: 10c0/3e809d9c2f46817475b452725c2aaa5d11985cf18d32a7a970ff25b568438e2c076c2e8609224feef3b7923fa9749b74428e3e634f6b8e520c534eef2fd24125 +"tmp@npm:^0.2.3, tmp@npm:~0.2.1": + version: 0.2.7 + resolution: "tmp@npm:0.2.7" + checksum: 10c0/59eb55584f2f07210d3231b6a1f6b5c2b9794d8a7b509c8ee867ed2acad6d2245ee2448b7937b676ffbff3155a70077edde8a69f9d7cf0f90c86a62e8910c357 languageName: node linkType: hard From b1b91a74f2465f1a979b9dd3cc0fe1c1d2738e45 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 24 Sep 2026 14:29:31 -0400 Subject: [PATCH 2/8] ci(preview): TEMPORARY publish previews for PRs stacked on cass-gmt-1795 Lets the @skillsoft/gamut-codemods PR, which targets cass-gmt-1795, publish installable pkg.pr.new previews. Revert this commit before either branch ships to main. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/preview.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 441f68ea96..d1caef6201 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -16,7 +16,10 @@ name: Preview on: pull_request: - branches: [main] + # TEMPORARY: cass-gmt-1795 is here so PRs stacked on it (the + # @skillsoft/gamut-codemods PR) publish previews. Revert this before + # either branch ships to main. + branches: [main, cass-gmt-1795] env: NODE_OPTIONS: '--max_old_space_size=8196' From d0693b15eee3599cfa7673262b662c8eb19b6818 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 24 Sep 2026 14:42:38 -0400 Subject: [PATCH 3/8] feat(gamut)!: export Menu list elements and IconOptionComponent from the root Ships the rename upstream GMT-1740 held back (Codecademy/gamut 009aa43f6, "move Menu/elements and IconOptionComponent renames to a separate PR"): - Menu/elements: List* -> MenuList* (List, ListProps, ListItem, ListItemProps, ListLink, ListLinkProps, ListButton), and Menu/index now re-exports './elements'. MenuToolTipWrapper becomes public too. - Form/SelectDropdown: the IconOption component -> IconOptionComponent, exported from the barrel. The IconOption type is unchanged. The names changed because List and IconOption collide with the public List component and IconOption type once they're on the root. The scope-swap codemod maps both deep imports to the root with the new names, keeping local bindings (`import { MenuList as List }`), and its manifest test now type-checks those names against the built package. Co-Authored-By: Claude Opus 5.5 --- .../gamut-menu-selectdropdown-renames.md | 15 +++++++ packages/gamut-codemods/README.md | 18 ++++----- .../gamut-codemods/src/__tests__/lib.test.ts | 35 ----------------- .../__testfixtures__/renames.input.tsx | 6 +++ .../__testfixtures__/renames.output.tsx | 6 +++ .../__testfixtures__/renames.warnings.json | 1 + .../__testfixtures__/rewrites.input.tsx | 2 +- .../__testfixtures__/rewrites.output.tsx | 2 +- .../__testfixtures__/rewrites.warnings.json | 2 +- .../src/presets/scope-swap/manifest.ts | 32 +++++++++------ .../__snapshots__/gamut.test.ts.snap | 6 +++ .../src/Form/SelectDropdown/core/constants.ts | 4 +- .../src/Form/SelectDropdown/elements/index.ts | 2 +- .../Form/SelectDropdown/elements/options.tsx | 2 +- .../gamut/src/Form/SelectDropdown/index.tsx | 1 + packages/gamut/src/Menu/Menu.tsx | 8 ++-- packages/gamut/src/Menu/MenuItem.tsx | 39 +++++++++++-------- packages/gamut/src/Menu/elements.tsx | 23 ++++++----- packages/gamut/src/Menu/index.tsx | 1 + 19 files changed, 111 insertions(+), 94 deletions(-) create mode 100644 .changeset/gamut-menu-selectdropdown-renames.md create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json diff --git a/.changeset/gamut-menu-selectdropdown-renames.md b/.changeset/gamut-menu-selectdropdown-renames.md new file mode 100644 index 0000000000..84bcef371e --- /dev/null +++ b/.changeset/gamut-menu-selectdropdown-renames.md @@ -0,0 +1,15 @@ +--- +'@skillsoft/gamut': minor +--- + +**Breaking:** export Menu's list elements and SelectDropdown's option component from the package root, under new names that don't collide with the public `List` component and `IconOption` type. + +| Old (`dist/Menu/elements`, `dist/Form/SelectDropdown/elements`) | New (`@skillsoft/gamut`) | +| --------------------------------------------------------------- | ----------------------------------- | +| `List`, `ListProps` | `MenuList`, `MenuListProps` | +| `ListItem`, `ListItemProps` | `MenuListItem`, `MenuListItemProps` | +| `ListLink`, `ListLinkProps` | `MenuListLink`, `MenuListLinkProps` | +| `ListButton` | `MenuListButton` | +| `IconOption` (the component) | `IconOptionComponent` | + +`MenuToolTipWrapper` is now exported from the root too. The `IconOption` type is unchanged. Coming from `@codecademy/gamut`, `npx @skillsoft/gamut-codemods scope-swap .` rewrites these imports for you. diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md index c5511ea61e..d4dce90918 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -35,15 +35,15 @@ A run ends with three things: ### What scope-swap changes -| Migration | What it does | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `deep-imports` | `@codecademy/gamut/dist/PopoverContainer/types` → `@codecademy/gamut`, and the rest of the promoted deep imports. Warns on deep imports with no public replacement (`Menu/elements`, `Form/SelectDropdown/elements`, `ButtonBase/ButtonBase`, `Form/styles`) and on ones it doesn't know. | -| `moved-exports` | Moves `Video` and `VideoProps` off the root import onto `/Video`. Warns on namespace imports, `export *`, and mocks that stub `Video`. | -| `mf-shared` | Replaces `@codecademy/gamut-kit` in Module Federation `shared` config with the packages it bundled. Gamut becomes a real singleton, so test the host and remotes together. | -| `eslint-comments` | `// eslint-disable-next-line gamut/x` → `@skillsoft/gamut/x`. | -| `scope-rename` | Every `@codecademy/gamut*` module string becomes `@skillsoft/gamut*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, and `import('x').T`. Also exact package-name strings elsewhere, like `transpilePackages`, with one warning per file. | -| `package-json` | Renames dependencies and sets their versions. Replaces `@codecademy/gamut-kit` with the individual packages. | -| `eslint-config` | `.eslintrc` / `.eslintrc.json`: plugin name, `plugin:` extends, and rule keys. | +| Migration | What it does | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deep-imports` | `@codecademy/gamut/dist/PopoverContainer/types` → `@codecademy/gamut`, and the rest of the promoted deep imports. Menu's `List*` and SelectDropdown's `IconOption` component were renamed on the way to the root (`MenuList*`, `IconOptionComponent`); local names are kept, so `import { List }` becomes `import { MenuList as List }`. Warns on deep imports with no public replacement (`ButtonBase/ButtonBase`, `Form/styles`) and on ones it doesn't know. | +| `moved-exports` | Moves `Video` and `VideoProps` off the root import onto `/Video`. Warns on namespace imports, `export *`, and mocks that stub `Video`. | +| `mf-shared` | Replaces `@codecademy/gamut-kit` in Module Federation `shared` config with the packages it bundled. Gamut becomes a real singleton, so test the host and remotes together. | +| `eslint-comments` | `// eslint-disable-next-line gamut/x` → `@skillsoft/gamut/x`. | +| `scope-rename` | Every `@codecademy/gamut*` module string becomes `@skillsoft/gamut*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, and `import('x').T`. Also exact package-name strings elsewhere, like `transpilePackages`, with one warning per file. | +| `package-json` | Renames dependencies and sets their versions. Replaces `@codecademy/gamut-kit` with the individual packages. | +| `eslint-config` | `.eslintrc` / `.eslintrc.json`: plugin name, `plugin:` extends, and rule keys. | ### Known limits diff --git a/packages/gamut-codemods/src/__tests__/lib.test.ts b/packages/gamut-codemods/src/__tests__/lib.test.ts index fa5953e11a..81de758d10 100644 --- a/packages/gamut-codemods/src/__tests__/lib.test.ts +++ b/packages/gamut-codemods/src/__tests__/lib.test.ts @@ -3,13 +3,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import jscodeshift from 'jscodeshift'; - import { parseArgs } from '../cli'; import { dirtyTreeReason } from '../lib/git'; import { findLeftovers } from '../lib/leftovers'; -import type { Manifest } from '../lib/manifest'; -import { deepImports } from '../migrations/deep-imports'; import { validatePreset } from '../presets'; import { scopeSwap } from '../presets/scope-swap'; import { manifest } from '../presets/scope-swap/manifest'; @@ -17,37 +13,6 @@ import { manifest } from '../presets/scope-swap/manifest'; const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-codemods-test-')); -describe('deep-imports renames', () => { - /* scope-swap has no rename rows today, so this uses its own manifest. */ - it('renames the export and keeps the local binding', () => { - const j = jscodeshift.withParser('tsx'); - const root = j( - "import { List, ListItem as Item } from '@codecademy/gamut/dist/Menu/elements';" - ); - const withRenames: Manifest = { - ...manifest, - deepImports: [ - { - from: '@codecademy/gamut/dist/Menu/elements', - to: '@codecademy/gamut', - renames: { List: 'MenuList', ListItem: 'MenuListItem' }, - }, - ], - }; - deepImports.run({ - j, - root, - source: '', - manifest: withRenames, - warn: () => {}, - note: () => {}, - }); - expect(root.toSource({ quote: 'single' })).toBe( - "import { MenuList as List, MenuListItem as Item } from '@codecademy/gamut';" - ); - }); -}); - describe('validatePreset', () => { it('accepts scope-swap', () => { expect(() => validatePreset(scopeSwap)).not.toThrow(); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.input.tsx new file mode 100644 index 0000000000..32ee05b6fa --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.input.tsx @@ -0,0 +1,6 @@ +import { List, ListItem as Item, type ListProps } from '@codecademy/gamut/dist/Menu/elements'; +import { IconOption } from '@codecademy/gamut/dist/Form/SelectDropdown/elements'; + +export { ListButton } from '@codecademy/gamut/dist/Menu/elements'; + +jest.mock('@codecademy/gamut/dist/Menu/elements', () => ({ List: () => null })); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.output.tsx new file mode 100644 index 0000000000..e05682435d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.output.tsx @@ -0,0 +1,6 @@ +import { MenuList as List, MenuListItem as Item, type MenuListProps as ListProps } from '@codecademy/gamut'; +import { IconOptionComponent as IconOption } from '@codecademy/gamut'; + +export { MenuListButton as ListButton } from '@codecademy/gamut'; + +jest.mock('@codecademy/gamut', () => ({ List: () => null })); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json new file mode 100644 index 0000000000..5d13e00b9c --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json @@ -0,0 +1 @@ +["'@codecademy/gamut/dist/Menu/elements' has renamed exports. Check the mock factory by hand."] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx index d45be932f1..3fad9f3b4b 100644 --- a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx @@ -1,7 +1,7 @@ import { PopoverContainerProps } from '@codecademy/gamut/dist/PopoverContainer/types'; import type { Prop } from '@codecademy/variance/dist/types/config'; import { formStyles } from '@codecademy/gamut/dist/Form/styles'; -import { List } from '@codecademy/gamut/dist/Menu/elements'; +import { ButtonBase } from '@codecademy/gamut/dist/ButtonBase/ButtonBase'; import { Mystery } from '@codecademy/gamut/dist/Nope'; jest.mock('@codecademy/gamut/dist/Box/props'); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx index 0c0e468b23..d882b3d9f0 100644 --- a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.output.tsx @@ -1,7 +1,7 @@ import { PopoverContainerProps } from '@codecademy/gamut'; import type { Prop } from '@codecademy/variance'; import { formStyles } from '@codecademy/gamut/dist/Form/styles'; -import { List } from '@codecademy/gamut/dist/Menu/elements'; +import { ButtonBase } from '@codecademy/gamut/dist/ButtonBase/ButtonBase'; import { Mystery } from '@codecademy/gamut/dist/Nope'; jest.mock('@codecademy/gamut'); diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json index 1e95c663a1..eeb8344b5b 100644 --- a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json @@ -1 +1 @@ -["'@codecademy/gamut/dist/Form/styles' has no public replacement", "'@codecademy/gamut/dist/Menu/elements' has no public replacement", "unmapped deep import '@codecademy/gamut/dist/Nope'"] +["'@codecademy/gamut/dist/Form/styles' has no public replacement", "'@codecademy/gamut/dist/ButtonBase/ButtonBase' has no public replacement", "unmapped deep import '@codecademy/gamut/dist/Nope'"] diff --git a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts index dc45537356..3c3b002e1e 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -107,25 +107,33 @@ export const manifest: Manifest = { to: '@codecademy/gamut', }, /* - No public replacement for the next three. Upstream GMT-1740 renamed - them (List -> MenuList, IconOption -> IconOptionComponent) and promoted - ButtonBase, then reverted all three before merging (upstream 009aa43f6) - as breaking changes for a separate PR. skillsoft-gamut matches that. + Renamed on the way to the root: `List` and `IconOption` collided with + the public List component and IconOption type. This is the rename + upstream GMT-1740 held back (upstream 009aa43f6); skillsoft-gamut + ships it. */ - { - from: '@codecademy/gamut/dist/ButtonBase/ButtonBase', - to: null, - note: 'ButtonBase is deliberately not public. Use FillButton, StrokeButton, TextButton, IconButton, or CTAButton.', - }, { from: '@codecademy/gamut/dist/Menu/elements', - to: null, - note: 'Menu list elements are not public (a MenuList* rename was held back upstream). Compose Menu/MenuItem, or copy the element.', + to: '@codecademy/gamut', + renames: { + List: 'MenuList', + ListProps: 'MenuListProps', + ListItem: 'MenuListItem', + ListItemProps: 'MenuListItemProps', + ListLink: 'MenuListLink', + ListLinkProps: 'MenuListLinkProps', + ListButton: 'MenuListButton', + }, }, { from: '@codecademy/gamut/dist/Form/SelectDropdown/elements', + to: '@codecademy/gamut', + renames: { IconOption: 'IconOptionComponent' }, + }, + { + from: '@codecademy/gamut/dist/ButtonBase/ButtonBase', to: null, - note: 'The IconOption component is not public. Copy it into your app if you need it.', + note: 'ButtonBase is deliberately not public. Use FillButton, StrokeButton, TextButton, IconButton, or CTAButton.', }, { from: '@codecademy/gamut/dist/Form/styles', diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 30593dae53..6e3bc667ee 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -65,6 +65,7 @@ exports[`Gamut Exported Keys 1`] = ` "GridForm", "GridFormContent", "IconButton", + "IconOptionComponent", "iFrameWrapper", "InfoTip", "Input", @@ -78,7 +79,12 @@ exports[`Gamut Exported Keys 1`] = ` "matchDisabledDates", "Menu", "MenuItem", + "MenuList", + "MenuListButton", + "MenuListItem", + "MenuListLink", "MenuSeparator", + "MenuToolTipWrapper", "Modal", "omitProps", "Overlay", diff --git a/packages/gamut/src/Form/SelectDropdown/core/constants.ts b/packages/gamut/src/Form/SelectDropdown/core/constants.ts index a5629613a2..50c1009384 100644 --- a/packages/gamut/src/Form/SelectDropdown/core/constants.ts +++ b/packages/gamut/src/Form/SelectDropdown/core/constants.ts @@ -6,7 +6,7 @@ import { CustomInput, CustomValueContainer, DropdownButton, - IconOption, + IconOptionComponent, MultiValueRemoveButton, MultiValueWithColorMode, RemoveAllButton, @@ -20,7 +20,7 @@ const baseDefaultComponents = { ValueContainer: CustomValueContainer, MultiValue: MultiValueWithColorMode, MultiValueRemove: MultiValueRemoveButton, - Option: IconOption, + Option: IconOptionComponent, SingleValue: AbbreviatedSingleValue, Input: CustomInput, }; diff --git a/packages/gamut/src/Form/SelectDropdown/elements/index.ts b/packages/gamut/src/Form/SelectDropdown/elements/index.ts index 5a67d3619b..9ab1c6655e 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/index.ts +++ b/packages/gamut/src/Form/SelectDropdown/elements/index.ts @@ -18,7 +18,7 @@ export { } from './containers'; export { - IconOption, + IconOptionComponent, AbbreviatedSingleValue, formatOptionLabel, formatGroupLabel, diff --git a/packages/gamut/src/Form/SelectDropdown/elements/options.tsx b/packages/gamut/src/Form/SelectDropdown/elements/options.tsx index e91b25178a..17b011b7d4 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/options.tsx +++ b/packages/gamut/src/Form/SelectDropdown/elements/options.tsx @@ -46,7 +46,7 @@ const IconOptionLabel: React.FC< * Also manages ARIA attributes for accessibility. * Skips the check icon for react-select/creatable's "Add" row (__isNew__). */ -export const IconOption = ({ +export const IconOptionComponent = ({ children, ...rest }: CustomSelectComponentProps) => { diff --git a/packages/gamut/src/Form/SelectDropdown/index.tsx b/packages/gamut/src/Form/SelectDropdown/index.tsx index fd0cb718f8..f6d233827e 100644 --- a/packages/gamut/src/Form/SelectDropdown/index.tsx +++ b/packages/gamut/src/Form/SelectDropdown/index.tsx @@ -1,3 +1,4 @@ export type { SelectDropdownTranslations } from './core/translations'; export * from './types'; +export { IconOptionComponent } from './elements'; export * from './SelectDropdown'; diff --git a/packages/gamut/src/Menu/Menu.tsx b/packages/gamut/src/Menu/Menu.tsx index 83b6bd328d..10d647f08e 100644 --- a/packages/gamut/src/Menu/Menu.tsx +++ b/packages/gamut/src/Menu/Menu.tsx @@ -1,12 +1,12 @@ import type { ComponentProps } from 'react'; import { forwardRef } from 'react'; -import { List } from './elements'; +import { MenuList } from './elements'; import { MenuProvider, useMenu } from './MenuContext'; export const Menu = forwardRef< HTMLUListElement | HTMLOListElement, - Omit, 'root'> + Omit, 'root'> >( ( { children, variant = 'popover', spacing = 'normal', role, ...rest }, @@ -15,9 +15,9 @@ export const Menu = forwardRef< const currentContext = useMenu({ variant, role, spacing }); return ( - + {children} - + ); } ); diff --git a/packages/gamut/src/Menu/MenuItem.tsx b/packages/gamut/src/Menu/MenuItem.tsx index 04ca7efb7c..3288ab987a 100644 --- a/packages/gamut/src/Menu/MenuItem.tsx +++ b/packages/gamut/src/Menu/MenuItem.tsx @@ -6,8 +6,13 @@ import { forwardRef, useId } from 'react'; import { FlexBox } from '../Box'; import type { ToolTipProps } from '../Tip/ToolTip'; import { Text } from '../Typography'; -import type { ListItemProps, ListLinkProps } from './elements'; -import { ListButton, ListItem, ListLink, MenuToolTipWrapper } from './elements'; +import type { MenuListItemProps, MenuListLinkProps } from './elements'; +import { + MenuListButton, + MenuListItem, + MenuListLink, + MenuToolTipWrapper, +} from './elements'; import { useMenuContext } from './MenuContext'; const getListItemType = (href: boolean, onClick: boolean) => @@ -26,7 +31,7 @@ const currentItemText = { type HTMLProps = Partial>; type ForwardListItemProps = Omit< - ComponentProps, + ComponentProps, 'variant' | 'selected' | 'active-navlink' | 'children' >; @@ -97,7 +102,7 @@ export const MenuItem = forwardRef< role: listItemRole, height, width, - } as ListItemProps; + } as MenuListItemProps; const ariaLabel = label ? typeof label === 'string' @@ -139,18 +144,18 @@ export const MenuItem = forwardRef< if (listItemType === 'link' && !disabled) { return ( - + - (ref)} target={target} > {content} - + - + ); } @@ -160,32 +165,32 @@ export const MenuItem = forwardRef< : (props.onClick as any as MouseEventHandler); return ( - + - (ref)} onClick={handleClick} > {content} - + - + ); } return ( // These are non-interactive and will never have tooltips (nor should they). - (ref)} > {content} - + ); } ); diff --git a/packages/gamut/src/Menu/elements.tsx b/packages/gamut/src/Menu/elements.tsx index d0f63d75cc..6ce6501958 100644 --- a/packages/gamut/src/Menu/elements.tsx +++ b/packages/gamut/src/Menu/elements.tsx @@ -34,7 +34,7 @@ const listProps = variance.compose( system.color ); -export interface ListProps extends ListStyleProps, StyleStateProps { +export interface MenuListProps extends ListStyleProps, StyleStateProps { /** How offset spacing should be */ spacing?: 'normal' | 'condensed'; /** Menu variants for specific use cases and styles */ @@ -46,7 +46,7 @@ export interface ListProps extends ListStyleProps, StyleStateProps { showBorder?: boolean; } -const StyledList = styled('ul', styledOptions<'ul'>())( +const StyledList = styled('ul', styledOptions<'ul'>())( system.css({ listStyle: 'none', width: 1, @@ -70,7 +70,7 @@ const StyledList = styled('ul', styledOptions<'ul'>())( listProps ); -export const List = forwardRef< +export const MenuList = forwardRef< HTMLUListElement, ComponentProps >(({ context = true, m = 0, root = true, variant, ...rest }, ref) => ( @@ -195,14 +195,17 @@ const sizeVariants = system.variant({ }, }); -export interface ListItemProps +export interface MenuListItemProps extends ListStyleProps, StyleStateProps, StyleProps, StyleProps, StyleProps {} -export const ListItem = styled('li', styledOptions<'li'>())( +export const MenuListItem = styled( + 'li', + styledOptions<'li'>() +)( interactiveVariants, activeStates, sizeVariants, @@ -210,12 +213,12 @@ export const ListItem = styled('li', styledOptions<'li'>())( listProps ); -export interface ListLinkProps extends ListItemProps { +export interface MenuListLinkProps extends MenuListItemProps { active?: boolean; navlink?: boolean; } -const StyledListLink = styled('a', styledOptions<'a'>())( +const StyledListLink = styled('a', styledOptions<'a'>())( resetStyles, interactiveVariants, activeStates, @@ -224,17 +227,17 @@ const StyledListLink = styled('a', styledOptions<'a'>())( listProps ); -export const ListLink = forwardRef< +export const MenuListLink = forwardRef< HTMLAnchorElement, ComponentProps >(({ zIndex = 1, ...rest }, ref) => ( )); -export const ListButton = styled( +export const MenuListButton = styled( 'button', styledOptions<'button'>() -)( +)( resetStyles, interactiveVariants, activeStates, diff --git a/packages/gamut/src/Menu/index.tsx b/packages/gamut/src/Menu/index.tsx index 65077f603d..54f17e7928 100644 --- a/packages/gamut/src/Menu/index.tsx +++ b/packages/gamut/src/Menu/index.tsx @@ -1,3 +1,4 @@ +export * from './elements'; export * from './Menu'; export * from './MenuItem'; export * from './MenuSeparator'; From 8f6f3ee5cb191f89e60e45801ae0673dc5b47cbb Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 24 Sep 2026 15:17:49 -0400 Subject: [PATCH 4/8] fix(gamut-codemods): keep existing @skillsoft versions; handle .mdx and .yarnrc.yml From a scope-swap run against mono: - package-json no longer overwrites a new package that's already listed. Expanding gamut-kit re-added @codecademy/* names and the rename step then replaced hand-added @skillsoft/* preview URLs with ^0.0.1, which isn't published for most packages, so yarn install 404'd. Expansion now checks for the new name too, and existing entries win. - A `*` peer range stays `*` instead of narrowing to the target version. - New mdx-imports migration: runs the source migrations over ESM import/export statements in .mdx files, skipping fenced code. Stories get the scope rename, Video split, and deep-import rewrites. - New yarnrc migration: renames old names in .yarnrc.yml list items, or drops them when a scope glob like '@skillsoft/*' covers the new name. Quotes the result, since a YAML plain scalar can't start with `@`. The source-migration runner moves to lib/run-source.ts so file migrations can reuse it through a `transformSource` helper. Co-Authored-By: Claude Opus 5.5 --- packages/gamut-codemods/README.md | 4 +- .../src/__tests__/fixtures.test.ts | 1 + packages/gamut-codemods/src/cli.ts | 23 ++++ packages/gamut-codemods/src/lib/run-source.ts | 108 ++++++++++++++++++ packages/gamut-codemods/src/lib/types.ts | 7 +- .../__testfixtures__/story.input.mdx | 18 +++ .../__testfixtures__/story.output.mdx | 19 +++ .../src/migrations/mdx-imports/index.ts | 63 ++++++++++ .../already-renamed.input.json | 9 ++ .../already-renamed.output.json | 12 ++ .../__testfixtures__/peers.input.json | 7 ++ .../__testfixtures__/peers.output.json | 7 ++ .../src/migrations/package-json/index.ts | 16 ++- .../yarnrc/__testfixtures__/no-glob.input.yml | 2 + .../__testfixtures__/no-glob.output.yml | 2 + .../__testfixtures__/preapproved.input.yml | 10 ++ .../__testfixtures__/preapproved.output.yml | 9 ++ .../src/migrations/yarnrc/index.ts | 45 ++++++++ .../src/presets/scope-swap/index.ts | 4 + packages/gamut-codemods/src/transform.ts | 96 +++------------- 20 files changed, 381 insertions(+), 81 deletions(-) create mode 100644 packages/gamut-codemods/src/lib/run-source.ts create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/index.ts create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/index.ts diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md index d4dce90918..27e8c02367 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -42,8 +42,10 @@ A run ends with three things: | `mf-shared` | Replaces `@codecademy/gamut-kit` in Module Federation `shared` config with the packages it bundled. Gamut becomes a real singleton, so test the host and remotes together. | | `eslint-comments` | `// eslint-disable-next-line gamut/x` → `@skillsoft/gamut/x`. | | `scope-rename` | Every `@codecademy/gamut*` module string becomes `@skillsoft/gamut*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, and `import('x').T`. Also exact package-name strings elsewhere, like `transpilePackages`, with one warning per file. | -| `package-json` | Renames dependencies and sets their versions. Replaces `@codecademy/gamut-kit` with the individual packages. | +| `package-json` | Renames dependencies and sets their versions. A new package that's already listed keeps its value (a preview URL, say), and a `*` peer range stays `*`. Replaces `@codecademy/gamut-kit` with the individual packages. | | `eslint-config` | `.eslintrc` / `.eslintrc.json`: plugin name, `plugin:` extends, and rule keys. | +| `mdx-imports` | Runs the migrations above over `import`/`export` statements in `.mdx` files. Fenced code blocks are left alone. | +| `yarnrc` | `.yarnrc.yml` list items, like `npmPreapprovedPackages`: renames old package names, or drops them if a glob like `'@skillsoft/*'` already covers the new one. | ### Known limits diff --git a/packages/gamut-codemods/src/__tests__/fixtures.test.ts b/packages/gamut-codemods/src/__tests__/fixtures.test.ts index 5a001f24c9..cc5f0f2d48 100644 --- a/packages/gamut-codemods/src/__tests__/fixtures.test.ts +++ b/packages/gamut-codemods/src/__tests__/fixtures.test.ts @@ -81,6 +81,7 @@ describe.each(Object.values(presets))('preset $name', (preset) => { manifest: preset.manifest, warn: (w) => warnings.push(w), note: () => {}, + transformSource: (code) => runSource(preset.name, code).out, }); expect((out ?? c.input).trim()).toBe(c.output.trim()); expectWarnings(warnings, c.warnings); diff --git a/packages/gamut-codemods/src/cli.ts b/packages/gamut-codemods/src/cli.ts index 911960efdc..ed8dff62c6 100644 --- a/packages/gamut-codemods/src/cli.ts +++ b/packages/gamut-codemods/src/cli.ts @@ -3,8 +3,15 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import jscodeshift from 'jscodeshift'; + import { dirtyTreeReason } from './lib/git'; import { findLeftovers, walk } from './lib/leftovers'; +import { + activeSourceMigrations, + mentionsOldNames, + runSourceMigrations, +} from './lib/run-source'; import type { FileMigration, Preset } from './lib/types'; import { getPreset, presets } from './presets'; @@ -80,11 +87,27 @@ const runFileMigrations = ( const source = fs.readFileSync(file, 'utf8'); const warn = (msg: string) => console.log(`[${migration.name}] ${file} ${msg}`); + const transformSource = (code: string) => + mentionsOldNames(preset.manifest, code) + ? runSourceMigrations({ + j: jscodeshift.withParser('tsx'), + source: code, + migrations: activeSourceMigrations( + preset.migrations, + opts.only + ), + manifest: preset.manifest, + warnFor: (name) => (_node, message) => + console.log(`[${name}] ${file} ${message}`), + note, + }) + : code; const next = migration.run({ source, manifest: preset.manifest, warn, note, + transformSource, }); if (next === null) continue; changed += 1; diff --git a/packages/gamut-codemods/src/lib/run-source.ts b/packages/gamut-codemods/src/lib/run-source.ts new file mode 100644 index 0000000000..7a78043b7d --- /dev/null +++ b/packages/gamut-codemods/src/lib/run-source.ts @@ -0,0 +1,108 @@ +import type { JSCodeshift } from 'jscodeshift'; + +import type { Manifest } from './manifest'; +import type { + AstMigration, + Edit, + Locatable, + Note, + SourceMigration, + SpliceMigration, +} from './types'; + +/* + Runs source migrations over one piece of source text. Two phases: + + 1. AST migrations share one parse and mutate it. If any changed + something, recast prints the text once and `postPrint` hooks run. + 2. Splice migrations run in order, each against a fresh parse of the + current text, returning `{ start, end, text }` edits applied back to + front. The re-parse means a later splice sees an earlier one's output + (mf-shared writes @codecademy keys that scope-rename then renames). + + Shared by the jscodeshift transform (whole files) and file migrations + that embed code, like MDX's ESM blocks. +*/ + +export interface RunSourceOptions { + j: JSCodeshift; + source: string; + migrations: SourceMigration[]; + manifest: Manifest; + warnFor: (migration: string) => (node: Locatable, message: string) => void; + note: Note; +} + +const applyEdits = (source: string, edits: Edit[]) => + [...edits] + .sort((a, b) => b.start - a.start) + .reduce( + (out, { start, end, text }) => + out.slice(0, start) + text + out.slice(end), + source + ); + +export const runSourceMigrations = ({ + j, + source: original, + migrations, + manifest, + warnFor, + note, +}: RunSourceOptions) => { + let source = original; + + const astMigrations = migrations.filter( + (m): m is AstMigration => m.kind === 'ast' + ); + if (astMigrations.length > 0) { + const root = j(source); + let changed = false; + for (const m of astMigrations) { + changed = + m.run({ j, root, source, manifest, warn: warnFor(m.name), note }) || + changed; + } + if (changed) { + source = astMigrations.reduce( + (out, m) => (m.postPrint ? m.postPrint(out, { manifest }) : out), + root.toSource({ quote: 'single' }) + ); + } + } + + for (const m of migrations.filter( + (x): x is SpliceMigration => x.kind === 'splice' + )) { + const root = j(source); + const edits = m.edits({ + j, + root, + source, + manifest, + warn: warnFor(m.name), + note, + }); + if (edits.length > 0) source = applyEdits(source, edits); + } + + return source; +}; + +/* The pre-filter every caller uses so big repos don't parse every file. */ +export const mentionsOldNames = (manifest: Manifest, source: string) => + [ + ...Object.keys(manifest.packages), + ...Object.keys(manifest.removedPackages), + `${manifest.eslintPlugin.from}/`, + ].some((name) => source.includes(name)); + +export const activeSourceMigrations = ( + migrations: { kind: string; name: string }[], + only?: string +) => { + const names = only ? new Set(only.split(',')) : null; + return migrations.filter( + (m) => m.kind !== 'file' && (!names || names.has(m.name)) + ) as SourceMigration[]; +}; diff --git a/packages/gamut-codemods/src/lib/types.ts b/packages/gamut-codemods/src/lib/types.ts index 558c9d74c1..4321561927 100644 --- a/packages/gamut-codemods/src/lib/types.ts +++ b/packages/gamut-codemods/src/lib/types.ts @@ -57,7 +57,7 @@ export interface SpliceMigration extends MigrationBase { edits(ctx: SourceContext): Edit[]; } -/* Gets raw text for non-source files (package.json, .eslintrc). */ +/* Gets raw text for non-source files (package.json, .eslintrc, .mdx). */ export interface FileMigration extends MigrationBase { kind: 'file'; match(path: string): boolean; @@ -66,6 +66,11 @@ export interface FileMigration extends MigrationBase { manifest: Manifest; warn: (message: string) => void; note: Note; + /* + Runs the preset's source migrations (respecting --only) over a + snippet of JS/TS, for files that embed code, like MDX's ESM blocks. + */ + transformSource: (code: string) => string; }): string | null; } diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx new file mode 100644 index 0000000000..513edb7e2b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx @@ -0,0 +1,18 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Box, Video } from '@codecademy/gamut'; +import { + PopoverContainerProps, +} from '@codecademy/gamut/dist/PopoverContainer/types'; +import * as Stories from './Card.stories'; + + + +# Card + +Built on `@codecademy/gamut`'s Box. + +```tsx +import { Card } from '@codecademy/gamut'; +``` + + diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx new file mode 100644 index 0000000000..4acaac1535 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx @@ -0,0 +1,19 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Box } from '@skillsoft/gamut'; +import { Video } from '@skillsoft/gamut/Video'; +import { + PopoverContainerProps, +} from '@skillsoft/gamut'; +import * as Stories from './Card.stories'; + + + +# Card + +Built on `@codecademy/gamut`'s Box. + +```tsx +import { Card } from '@codecademy/gamut'; +``` + + diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/index.ts b/packages/gamut-codemods/src/migrations/mdx-imports/index.ts new file mode 100644 index 0000000000..f98acf3d56 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/index.ts @@ -0,0 +1,63 @@ +import type { FileMigration } from '../../lib/types'; + +/* + MDX files (Storybook docs, mostly) import from Gamut like any module, but + jscodeshift can't parse MDX. This pulls out the top-level ESM statements + (`import ... from '...'`, `export ... from '...'`) and runs each one + through the preset's source migrations, so stories get the same scope + rename, Video split, and deep-import rewrites as .tsx files. + + Fenced code blocks are left alone. They're documentation, and a code + sample that still says @codecademy is the leftovers report's job. +*/ + +const FENCE = /^\s*(```|~~~)/; +const STATEMENT_START = /^(import\s|export\s+(\*|\{|type\s+\{))/; +const STATEMENT_END = + /(from\s*(['"])[^'"\n]*\2|^import\s*(['"])[^'"\n]*\3)\s*;?\s*$/; +/* A statement that runs longer than this isn't one we understand. */ +const MAX_STATEMENT_LINES = 50; + +export const mdxImports: FileMigration = { + name: 'mdx-imports', + kind: 'file', + description: + 'Run the source migrations over import/export statements in .mdx files.', + match: (file) => file.endsWith('.mdx'), + run({ source, transformSource }) { + const lines = source.split('\n'); + const out: string[] = []; + let inFence = false; + let changed = false; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (FENCE.test(line)) inFence = !inFence; + if (inFence || !STATEMENT_START.test(line)) { + out.push(line); + continue; + } + + let end = i; + while ( + end < lines.length - 1 && + end - i < MAX_STATEMENT_LINES && + !STATEMENT_END.test(lines.slice(i, end + 1).join('\n')) + ) { + end += 1; + } + const statement = lines.slice(i, end + 1).join('\n'); + if (!STATEMENT_END.test(statement)) { + out.push(line); + continue; + } + + const next = transformSource(statement); + if (next !== statement) changed = true; + out.push(next); + i = end; + } + + return changed ? out.join('\n') : null; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json new file mode 100644 index 0000000000..a332e52190 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "dependencies": { + "@codecademy/gamut-kit": "3.0.25", + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@28", + "@skillsoft/gamut-icons": "https://pkg.pr.new/@skillsoft/gamut-icons@28", + "@codecademy/gamut-styles": "21.2.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json new file mode 100644 index 0000000000..e51edd4454 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json @@ -0,0 +1,12 @@ +{ + "name": "app", + "dependencies": { + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@28", + "@skillsoft/gamut-icons": "https://pkg.pr.new/@skillsoft/gamut-icons@28", + "@skillsoft/gamut-illustrations": "^{{version}}", + "@skillsoft/gamut-patterns": "^{{version}}", + "@skillsoft/gamut-styles": "^{{version}}", + "@skillsoft/gamut-tests": "^{{version}}", + "@skillsoft/variance": "^{{version}}" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json new file mode 100644 index 0000000000..27dbc10c94 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json @@ -0,0 +1,7 @@ +{ + "name": "lib", + "peerDependencies": { + "@codecademy/gamut": "*", + "@codecademy/gamut-styles": "^21.0.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json new file mode 100644 index 0000000000..e4ecfdfaf1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json @@ -0,0 +1,7 @@ +{ + "name": "lib", + "peerDependencies": { + "@skillsoft/gamut": "*", + "@skillsoft/gamut-styles": "^{{version}}" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/index.ts b/packages/gamut-codemods/src/migrations/package-json/index.ts index 7d25d0ae30..257bd4fa73 100644 --- a/packages/gamut-codemods/src/migrations/package-json/index.ts +++ b/packages/gamut-codemods/src/migrations/package-json/index.ts @@ -5,6 +5,12 @@ import type { FileMigration } from '../../lib/types'; manifest.targetVersions. Removed packages are replaced by the packages they bundled. Only touched fields are re-sorted; indentation and the trailing newline are kept. + + A new package that's already listed keeps its value. People add the new + names by hand before running this, often pointed at a preview tarball, + and overwriting that with a range that isn't published yet breaks + install. A `*` peer range stays `*`: it means "whatever the host has", + and narrowing it is a decision for the package owner. */ const FIELDS = [ @@ -43,7 +49,8 @@ export const packageJson: FileMigration = { if (!(removed in next)) continue; delete next[removed]; for (const old of expandTo) { - if (!(old in next)) next[old] = '*'; + const renamed = manifest.packages[old]?.to; + if (!(old in next) && !(renamed && renamed in next)) next[old] = '*'; } warn( `${field}: replaced '${removed}' with its individual packages. ${why}` @@ -54,9 +61,14 @@ export const packageJson: FileMigration = { for (const [from, { to }] of Object.entries(manifest.packages)) { if (!(from in next)) continue; + const range = next[from]; delete next[from]; - next[to] = manifest.targetVersions[to]; touched = true; + if (to in next) continue; + next[to] = + field === 'peerDependencies' && range === '*' + ? '*' + : manifest.targetVersions[to]; } if (touched) { diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml new file mode 100644 index 0000000000..6452a33ad1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml @@ -0,0 +1,2 @@ +npmPreapprovedPackages: + - eslint-plugin-gamut # lint rules diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml new file mode 100644 index 0000000000..7a6d8c80bf --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml @@ -0,0 +1,2 @@ +npmPreapprovedPackages: + - '@skillsoft/eslint-plugin-gamut' # lint rules diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml new file mode 100644 index 0000000000..8c251e0841 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml @@ -0,0 +1,10 @@ +npmPreapprovedPackages: + - '@skillsoft/*' + - 'eslint-plugin-gamut' + - "@codecademy/gamut-kit" + - react + +packageExtensions: + '@codecademy/gamut@*': + peerDependencies: + react: '*' diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml new file mode 100644 index 0000000000..0d09a98e03 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml @@ -0,0 +1,9 @@ +npmPreapprovedPackages: + - '@skillsoft/*' + - "@codecademy/gamut-kit" + - react + +packageExtensions: + '@codecademy/gamut@*': + peerDependencies: + react: '*' diff --git a/packages/gamut-codemods/src/migrations/yarnrc/index.ts b/packages/gamut-codemods/src/migrations/yarnrc/index.ts new file mode 100644 index 0000000000..34074ee969 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/index.ts @@ -0,0 +1,45 @@ +import { renameSpecifier } from '../../lib/manifest'; +import type { FileMigration } from '../../lib/types'; + +/* + Package names listed in .yarnrc.yml, like npmPreapprovedPackages: + + npmPreapprovedPackages: + - '@skillsoft/*' + - 'eslint-plugin-gamut' + + An old name is renamed, or dropped if a scope glob in the same file + (`'@skillsoft/*'`) already covers the new one. Only exact list items are + touched; anything more involved shows up in the leftovers report. +*/ + +const LIST_ITEM = /^(\s*-\s*)(['"]?)([^'"\s#]+)\2(\s*(#.*)?)$/; + +export const yarnrc: FileMigration = { + name: 'yarnrc', + kind: 'file', + description: 'Rename or drop old package names in .yarnrc.yml lists.', + match: (file) => /(^|\/)\.yarnrc\.yml$/.test(file), + run({ source, manifest }) { + const lines = source.split('\n'); + const globs = lines + .map((line) => LIST_ITEM.exec(line)?.[3]) + .filter((value): value is string => !!value?.endsWith('/*')) + .map((glob) => glob.slice(0, -1)); + + let changed = false; + const out = lines.flatMap((line) => { + const match = LIST_ITEM.exec(line); + const next = match && renameSpecifier(manifest, match[3]); + if (!match || !next) return [line]; + changed = true; + if (globs.some((prefix) => next.startsWith(prefix))) return []; + const [, lead, quote, , trail] = match; + /* `@` is reserved in YAML, so a plain scalar can't start with it. */ + const q = quote || (next.startsWith('@') ? "'" : ''); + return [`${lead}${q}${next}${q}${trail}`]; + }); + + return changed ? out.join('\n') : null; + }, +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts index 8ece32e855..0174666b37 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/index.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -2,10 +2,12 @@ import type { Preset } from '../../lib/types'; import { deepImports } from '../../migrations/deep-imports'; import { eslintComments } from '../../migrations/eslint-comments'; import { eslintConfig } from '../../migrations/eslint-config'; +import { mdxImports } from '../../migrations/mdx-imports'; import { mfShared } from '../../migrations/mf-shared'; import { movedExports } from '../../migrations/moved-exports'; import { packageJson } from '../../migrations/package-json'; import { scopeRename } from '../../migrations/scope-rename'; +import { yarnrc } from '../../migrations/yarnrc'; import { manifest } from './manifest'; export const scopeSwap: Preset = { @@ -21,6 +23,8 @@ export const scopeSwap: Preset = { scopeRename, packageJson, eslintConfig, + mdxImports, + yarnrc, ], checklist: [ 'Run your formatter (prettier --write / eslint --fix). Split imports come out in recast style.', diff --git a/packages/gamut-codemods/src/transform.ts b/packages/gamut-codemods/src/transform.ts index 2bf5cef8bb..4892c2781e 100644 --- a/packages/gamut-codemods/src/transform.ts +++ b/packages/gamut-codemods/src/transform.ts @@ -3,29 +3,21 @@ import path from 'node:path'; import type { API, FileInfo } from 'jscodeshift'; -import type { - AstMigration, - Edit, - Locatable, - SourceMigration, - SpliceMigration, -} from './lib/types'; +import { + activeSourceMigrations, + mentionsOldNames, + runSourceMigrations, +} from './lib/run-source'; +import type { Locatable } from './lib/types'; import { getPreset } from './presets'; /* - jscodeshift entry point. Two phases: - - 1. AST migrations share one parse and mutate it. If any changed - something, recast prints the file once and `postPrint` hooks run. - 2. Splice migrations run in order, each against a fresh parse of the - current text, returning `{ start, end, text }` edits applied back to - front. A re-parse between them means a later splice sees an earlier - one's output (mf-shared writes @codecademy keys that scope-rename then - renames). + jscodeshift entry point: runs a preset's source migrations over one file + (see lib/run-source.ts for the two phases). Options: - preset: preset name (default: scope-swap) - only: comma-separated migration names to run (default: all) + preset: preset name (default: scope-swap) + only: comma-separated migration names to run (default: all) notesDir: directory the CLI reads checklist notes back from. jscodeshift only aggregates api.stats in --dry runs, so workers leave an empty marker file per note key instead. @@ -39,15 +31,6 @@ export interface TransformOptions { onWarn?: (message: string) => void; } -const applyEdits = (source: string, edits: Edit[]) => - [...edits] - .sort((a, b) => b.start - a.start) - .reduce( - (out, { start, end, text }) => - out.slice(0, start) + text + out.slice(end), - source - ); - export default function transform( file: FileInfo, api: API, @@ -55,22 +38,8 @@ export default function transform( ) { const preset = getPreset(options.preset ?? 'scope-swap'); const { manifest } = preset; + if (!mentionsOldNames(manifest, file.source)) return undefined; - /* Cheap pre-filter so big repos don't parse every file. */ - const oldNames = [ - ...Object.keys(manifest.packages), - ...Object.keys(manifest.removedPackages), - `${manifest.eslintPlugin.from}/`, - ]; - if (!oldNames.some((name) => file.source.includes(name))) return undefined; - - const only = options.only ? new Set(options.only.split(',')) : null; - const active = preset.migrations.filter( - (m): m is SourceMigration => - m.kind !== 'file' && (!only || only.has(m.name)) - ); - - const j = api.jscodeshift; const note = (key: string) => { if (options.notesDir) { fs.writeFileSync( @@ -86,41 +55,14 @@ export default function transform( else api.report(text); }; - let { source } = file; - - const astMigrations = active.filter( - (m): m is AstMigration => m.kind === 'ast' - ); - if (astMigrations.length > 0) { - const root = j(source); - let changed = false; - for (const m of astMigrations) { - changed = - m.run({ j, root, source, manifest, warn: warnFor(m.name), note }) || - changed; - } - if (changed) { - source = astMigrations.reduce( - (out, m) => (m.postPrint ? m.postPrint(out, { manifest }) : out), - root.toSource({ quote: 'single' }) - ); - } - } - - for (const m of active.filter( - (x): x is SpliceMigration => x.kind === 'splice' - )) { - const root = j(source); - const edits = m.edits({ - j, - root, - source, - manifest, - warn: warnFor(m.name), - note, - }); - if (edits.length > 0) source = applyEdits(source, edits); - } + const source = runSourceMigrations({ + j: api.jscodeshift, + source: file.source, + migrations: activeSourceMigrations(preset.migrations, options.only), + manifest, + warnFor, + note, + }); return source === file.source ? undefined : source; } From 1bebf4340951cf62bc1a5bc83f4209106b55d905 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 24 Sep 2026 15:40:51 -0400 Subject: [PATCH 5/8] feat(gamut): export formFieldStyles, formFieldPaddingStyles, conditionalStyles For custom inputs that should look like Input, such as third-party hosted payment fields; mono's Recurly fields import all three from dist/Form/styles today. The rest of Form/styles stays internal. The scope-swap codemod now rewrites Form/styles deep imports to the root when every imported name is one of the three, via a new `onlyNames` manifest field, and warns otherwise. Deep-import rewrites inside mocks now always warn, since mocking the root covers the whole package. Co-Authored-By: Claude Opus 5.5 --- .changeset/gamut-form-field-styles.md | 5 +++ packages/gamut-codemods/README.md | 22 +++++------ .../src/__tests__/manifest.test.ts | 8 ++++ packages/gamut-codemods/src/lib/manifest.ts | 5 +++ .../__testfixtures__/form-styles.input.tsx | 4 ++ .../__testfixtures__/form-styles.output.tsx | 4 ++ .../form-styles.warnings.json | 1 + .../__testfixtures__/renames.warnings.json | 2 +- .../__testfixtures__/rewrites.warnings.json | 2 +- .../src/migrations/deep-imports/index.ts | 39 ++++++++++++++++--- .../src/presets/scope-swap/manifest.ts | 9 ++++- .../__snapshots__/gamut.test.ts.snap | 3 ++ packages/gamut/src/Form/index.tsx | 9 +++++ 13 files changed, 92 insertions(+), 21 deletions(-) create mode 100644 .changeset/gamut-form-field-styles.md create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.warnings.json diff --git a/.changeset/gamut-form-field-styles.md b/.changeset/gamut-form-field-styles.md new file mode 100644 index 0000000000..d75fa130a5 --- /dev/null +++ b/.changeset/gamut-form-field-styles.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut': minor +--- + +Export `formFieldStyles`, `formFieldPaddingStyles`, and `conditionalStyles` from the package root, for custom inputs that should look like `Input`, such as third-party hosted payment fields. The rest of `Form/styles` stays internal. Coming from `@codecademy/gamut/dist/Form/styles`, `npx @skillsoft/gamut-codemods scope-swap .` rewrites imports of these three. diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md index 27e8c02367..f5645d7db5 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -35,17 +35,17 @@ A run ends with three things: ### What scope-swap changes -| Migration | What it does | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `deep-imports` | `@codecademy/gamut/dist/PopoverContainer/types` → `@codecademy/gamut`, and the rest of the promoted deep imports. Menu's `List*` and SelectDropdown's `IconOption` component were renamed on the way to the root (`MenuList*`, `IconOptionComponent`); local names are kept, so `import { List }` becomes `import { MenuList as List }`. Warns on deep imports with no public replacement (`ButtonBase/ButtonBase`, `Form/styles`) and on ones it doesn't know. | -| `moved-exports` | Moves `Video` and `VideoProps` off the root import onto `/Video`. Warns on namespace imports, `export *`, and mocks that stub `Video`. | -| `mf-shared` | Replaces `@codecademy/gamut-kit` in Module Federation `shared` config with the packages it bundled. Gamut becomes a real singleton, so test the host and remotes together. | -| `eslint-comments` | `// eslint-disable-next-line gamut/x` → `@skillsoft/gamut/x`. | -| `scope-rename` | Every `@codecademy/gamut*` module string becomes `@skillsoft/gamut*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, and `import('x').T`. Also exact package-name strings elsewhere, like `transpilePackages`, with one warning per file. | -| `package-json` | Renames dependencies and sets their versions. A new package that's already listed keeps its value (a preview URL, say), and a `*` peer range stays `*`. Replaces `@codecademy/gamut-kit` with the individual packages. | -| `eslint-config` | `.eslintrc` / `.eslintrc.json`: plugin name, `plugin:` extends, and rule keys. | -| `mdx-imports` | Runs the migrations above over `import`/`export` statements in `.mdx` files. Fenced code blocks are left alone. | -| `yarnrc` | `.yarnrc.yml` list items, like `npmPreapprovedPackages`: renames old package names, or drops them if a glob like `'@skillsoft/*'` already covers the new one. | +| Migration | What it does | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deep-imports` | `@codecademy/gamut/dist/PopoverContainer/types` → `@codecademy/gamut`, and the rest of the promoted deep imports. Menu's `List*` and SelectDropdown's `IconOption` component were renamed on the way to the root (`MenuList*`, `IconOptionComponent`); local names are kept, so `import { List }` becomes `import { MenuList as List }`. `Form/styles` is only partly public (`formFieldStyles`, `formFieldPaddingStyles`, `conditionalStyles`), so imports of anything else from it are left alone with a warning. Also warns on deep imports with no public replacement (`ButtonBase/ButtonBase`) and on ones it doesn't know. | +| `moved-exports` | Moves `Video` and `VideoProps` off the root import onto `/Video`. Warns on namespace imports, `export *`, and mocks that stub `Video`. | +| `mf-shared` | Replaces `@codecademy/gamut-kit` in Module Federation `shared` config with the packages it bundled. Gamut becomes a real singleton, so test the host and remotes together. | +| `eslint-comments` | `// eslint-disable-next-line gamut/x` → `@skillsoft/gamut/x`. | +| `scope-rename` | Every `@codecademy/gamut*` module string becomes `@skillsoft/gamut*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, and `import('x').T`. Also exact package-name strings elsewhere, like `transpilePackages`, with one warning per file. | +| `package-json` | Renames dependencies and sets their versions. A new package that's already listed keeps its value (a preview URL, say), and a `*` peer range stays `*`. Replaces `@codecademy/gamut-kit` with the individual packages. | +| `eslint-config` | `.eslintrc` / `.eslintrc.json`: plugin name, `plugin:` extends, and rule keys. | +| `mdx-imports` | Runs the migrations above over `import`/`export` statements in `.mdx` files. Fenced code blocks are left alone. | +| `yarnrc` | `.yarnrc.yml` list items, like `npmPreapprovedPackages`: renames old package names, or drops them if a glob like `'@skillsoft/*'` already covers the new one. | ### Known limits diff --git a/packages/gamut-codemods/src/__tests__/manifest.test.ts b/packages/gamut-codemods/src/__tests__/manifest.test.ts index 4265860ae3..62089107a9 100644 --- a/packages/gamut-codemods/src/__tests__/manifest.test.ts +++ b/packages/gamut-codemods/src/__tests__/manifest.test.ts @@ -36,6 +36,14 @@ const importsToCheck = () => { ); } for (const row of m.deepImports) { + if (row.to && row.onlyNames) { + lines.push( + `import type { ${row.onlyNames.join(', ')} } from '${renameSpecifier( + m, + row.to + )}';` + ); + } if (!row.to || !row.renames) continue; lines.push( `import type { ${Object.values(row.renames).join( diff --git a/packages/gamut-codemods/src/lib/manifest.ts b/packages/gamut-codemods/src/lib/manifest.ts index 495458f723..74ca28b9c3 100644 --- a/packages/gamut-codemods/src/lib/manifest.ts +++ b/packages/gamut-codemods/src/lib/manifest.ts @@ -30,6 +30,11 @@ export interface DeepImport { to: string | null; /* Old imported name -> new exported name. Local bindings are kept. */ renames?: Record; + /* + When only some of the old module went public: the names `to` exports. + A declaration importing anything else is left alone with a warning. + */ + onlyNames?: string[]; note?: string; } diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.input.tsx new file mode 100644 index 0000000000..11737f72e1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.input.tsx @@ -0,0 +1,4 @@ +import { conditionalStyles, formFieldStyles } from '@codecademy/gamut/dist/Form/styles'; +import { formFieldPaddingStyles as padding } from '@codecademy/gamut/dist/Form/styles'; +import { formFieldStyles as f, checkboxLabel } from '@codecademy/gamut/dist/Form/styles'; +import * as formStyles from '@codecademy/gamut/dist/Form/styles'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.output.tsx new file mode 100644 index 0000000000..1c8ceea9f4 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.output.tsx @@ -0,0 +1,4 @@ +import { conditionalStyles, formFieldStyles } from '@codecademy/gamut'; +import { formFieldPaddingStyles as padding } from '@codecademy/gamut'; +import { formFieldStyles as f, checkboxLabel } from '@codecademy/gamut/dist/Form/styles'; +import * as formStyles from '@codecademy/gamut/dist/Form/styles'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.warnings.json new file mode 100644 index 0000000000..e127ff61a0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.warnings.json @@ -0,0 +1 @@ +["checkboxLabel isn't exported from '@codecademy/gamut'", "* isn't exported from '@codecademy/gamut'"] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json index 5d13e00b9c..6b61771448 100644 --- a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json @@ -1 +1 @@ -["'@codecademy/gamut/dist/Menu/elements' has renamed exports. Check the mock factory by hand."] +["That now covers the whole package, and some exports were renamed. Check it by hand."] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json index eeb8344b5b..99cf5b0f92 100644 --- a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json @@ -1 +1 @@ -["'@codecademy/gamut/dist/Form/styles' has no public replacement", "'@codecademy/gamut/dist/ButtonBase/ButtonBase' has no public replacement", "unmapped deep import '@codecademy/gamut/dist/Nope'"] +["'@codecademy/gamut/dist/Form/styles' is only partly public. formStyles isn't exported from '@codecademy/gamut'", "'@codecademy/gamut/dist/ButtonBase/ButtonBase' has no public replacement", "unmapped deep import '@codecademy/gamut/dist/Nope'", "rewrote '@codecademy/gamut/dist/Box/props' to '@codecademy/gamut' in a mock or require(). That now covers the whole package."] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/index.ts b/packages/gamut-codemods/src/migrations/deep-imports/index.ts index b394c39c45..f703d6b9cb 100644 --- a/packages/gamut-codemods/src/migrations/deep-imports/index.ts +++ b/packages/gamut-codemods/src/migrations/deep-imports/index.ts @@ -90,6 +90,30 @@ export const deepImports: AstMigration = { ); continue; } + if (row.onlyNames) { + const allowed = new Set(row.onlyNames); + const specifiers = + 'specifiers' in node ? ((node.specifiers ?? []) as Specifier[]) : []; + const blocked = specifiers + .map((spec) => + spec.type === 'ImportSpecifier' + ? nameOf((spec as ImportSpecifier).imported) + : spec.type === 'ExportSpecifier' + ? nameOf((spec as ExportSpecifier).local) + : '*' + ) + .filter((name) => !allowed.has(name)); + if (node.type === 'ExportAllDeclaration') blocked.push('*'); + if (blocked.length > 0) { + warn( + node, + `'${row.from}' is only partly public. ${blocked.join( + ', ' + )} isn't exported from '${row.to}'. ${row.note ?? ''}` + ); + continue; + } + } if (row.renames && 'specifiers' in node && node.specifiers) { node.specifiers = renameSpecifiers( j, @@ -112,12 +136,15 @@ export const deepImports: AstMigration = { ); continue; } - if (row.renames) { - warn( - literal, - `'${literal.value}' has renamed exports. Check the mock factory by hand.` - ); - } + /* jest.mock of a deep path now mocks the whole package. */ + warn( + literal, + `rewrote '${literal.value}' to '${ + row.to + }' in a mock or require(). That now covers the whole package${ + row.renames ? ', and some exports were renamed' : '' + }. Check it by hand.` + ); literal.value = row.to; changed = true; } diff --git a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts index 3c3b002e1e..556c02c05f 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -137,8 +137,13 @@ export const manifest: Manifest = { }, { from: '@codecademy/gamut/dist/Form/styles', - to: null, - note: 'Form/styles has no public replacement. Inline the styles or use system props / css().', + to: '@codecademy/gamut', + onlyNames: [ + 'conditionalStyles', + 'formFieldPaddingStyles', + 'formFieldStyles', + ], + note: 'The rest of Form/styles is internal. Inline the styles, or use system props / css().', }, { from: '@codecademy/gamut-styles/dist/AssetProvider', diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 6e3bc667ee..c04b2fbe56 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -16,6 +16,7 @@ exports[`Gamut Exported Keys 1`] = ` "Checkbox", "Coachmark", "Column", + "conditionalStyles", "ConnectedCheckbox", "ConnectedForm", "ConnectedFormGroup", @@ -54,6 +55,8 @@ exports[`Gamut Exported Keys 1`] = ` "focusVisibleStyle", "Form", "FormError", + "formFieldPaddingStyles", + "formFieldStyles", "FormGroup", "FormGroupDescription", "FormGroupLabel", diff --git a/packages/gamut/src/Form/index.tsx b/packages/gamut/src/Form/index.tsx index f59e8507d7..7301e9103c 100644 --- a/packages/gamut/src/Form/index.tsx +++ b/packages/gamut/src/Form/index.tsx @@ -13,3 +13,12 @@ export * from './inputs/Radio'; export * from './inputs/RadioGroup'; export * from './inputs/types'; export * from './types'; +/* + For custom inputs that should look like Input, such as third-party hosted + payment fields. The rest of ./styles is internal. +*/ +export { + conditionalStyles, + formFieldPaddingStyles, + formFieldStyles, +} from './styles'; From 658a4df14e03c860c97047da108143fd7497a427 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 24 Sep 2026 15:58:13 -0400 Subject: [PATCH 6/8] fix: Video under node10 resolution; codemod only rewrites names the target exports From a type-checked scope-swap run against mono (15 errors left after the consumer-side fixes, all from these): gamut - Add a Video/package.json stub (and ship it) so `@skillsoft/gamut/Video` resolves under TypeScript's node10 resolution and bundlers that ignore `exports`, the same way upstream does. Drop attw's `no-resolution` ignore, which was hiding exactly this. - Export the HTMLToReactNode and ButtonBaseProps types from the root; mono deep-imports both. gamut-codemods - Five deep-import rows were sending every name to the root even though only some are public there (Box/props, Button/shared, Markdown/libs/overrides, SelectDropdown/elements, gamut-icons/props). They now list their public names in `onlyNames`, and manifest.test.ts fails if any row could send a name its target doesn't export. - New tsconfig-dom report: warns on `lib` without "dom", which Video used to supply through the root import, with a matching next-steps item. - The gamut-kit next step mentions gamut-styles' stylis peer dependency. - README: how to test against pkg.pr.new previews (resolutions to avoid duplicate copies). Co-Authored-By: Claude Opus 5.5 --- .changeset/gamut-video-node10-and-types.md | 6 + packages/gamut-codemods/README.md | 20 +++ .../src/__tests__/manifest.test.ts | 117 +++++++++++++++--- packages/gamut-codemods/src/lib/manifest.ts | 6 +- .../__testfixtures__/has-dom.input.json | 1 + .../__testfixtures__/has-dom.output.json | 1 + .../__testfixtures__/no-dom.input.json | 4 + .../__testfixtures__/no-dom.output.json | 4 + .../__testfixtures__/no-dom.warnings.json | 1 + .../src/migrations/tsconfig-dom/index.ts | 30 +++++ .../src/presets/scope-swap/index.ts | 6 +- .../src/presets/scope-swap/manifest.ts | 24 +++- packages/gamut/Video/package.json | 5 + packages/gamut/package.json | 3 +- packages/gamut/project.json | 5 +- packages/gamut/src/Button/index.ts | 2 +- packages/gamut/src/Markdown/index.tsx | 1 + 17 files changed, 205 insertions(+), 31 deletions(-) create mode 100644 .changeset/gamut-video-node10-and-types.md create mode 100644 packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.input.json create mode 100644 packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.output.json create mode 100644 packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.input.json create mode 100644 packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.output.json create mode 100644 packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/tsconfig-dom/index.ts create mode 100644 packages/gamut/Video/package.json diff --git a/.changeset/gamut-video-node10-and-types.md b/.changeset/gamut-video-node10-and-types.md new file mode 100644 index 0000000000..2fdaa4c386 --- /dev/null +++ b/.changeset/gamut-video-node10-and-types.md @@ -0,0 +1,6 @@ +--- +'@skillsoft/gamut': patch +--- + +- `@skillsoft/gamut/Video` now resolves under TypeScript's `moduleResolution: "node"` (node10) and in bundlers that ignore `package.json#exports`, through a `Video/package.json` stub. +- Export the `HTMLToReactNode` and `ButtonBaseProps` types from the package root. diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md index f5645d7db5..3372a62581 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -33,6 +33,25 @@ A run ends with three things: 2. **Leftovers**: every line that still names an old package. These are for you to fix: jest `moduleNameMapper` regexes, tsconfig `paths`, flat ESLint configs, comments, and docs. 3. **Next steps**, built from what actually happened in the run. +### Trying it against a preview build + +Every skillsoft-gamut pull request publishes installable previews through pkg.pr.new. To test a migration against one: + +1. Add the `@skillsoft/*` packages to your root `package.json` first, pointed at the preview URLs. The codemod keeps any `@skillsoft/*` entry that's already there. +2. Preview packages point at each other through commit-pinned URLs, so without help yarn installs a second copy of `gamut-styles`, `variance`, and so on under `@skillsoft/gamut/node_modules`. Pin every `@skillsoft/*` package you use with `resolutions`: + + ```json + "resolutions": { + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@", + "@skillsoft/gamut-styles": "https://pkg.pr.new/@skillsoft/gamut-styles@", + "@skillsoft/variance": "https://pkg.pr.new/@skillsoft/variance@" + } + ``` + + Published releases don't need this. The packages share one version through the changesets `fixed` group, so their dependencies on each other line up. + +3. Commit, then run the codemod (or pass `--force`). + ### What scope-swap changes | Migration | What it does | @@ -46,6 +65,7 @@ A run ends with three things: | `eslint-config` | `.eslintrc` / `.eslintrc.json`: plugin name, `plugin:` extends, and rule keys. | | `mdx-imports` | Runs the migrations above over `import`/`export` statements in `.mdx` files. Fenced code blocks are left alone. | | `yarnrc` | `.yarnrc.yml` list items, like `npmPreapprovedPackages`: renames old package names, or drops them if a glob like `'@skillsoft/*'` already covers the new one. | +| `tsconfig-dom` | Report only. Warns about `tsconfig` files whose `lib` has no `"dom"`, since Video used to supply the DOM types through the root import. | ### Known limits diff --git a/packages/gamut-codemods/src/__tests__/manifest.test.ts b/packages/gamut-codemods/src/__tests__/manifest.test.ts index 62089107a9..f48b665496 100644 --- a/packages/gamut-codemods/src/__tests__/manifest.test.ts +++ b/packages/gamut-codemods/src/__tests__/manifest.test.ts @@ -24,38 +24,115 @@ const readJson = (file: string) => consumer's bundler would, through the exports map. */ const importsToCheck = () => { - const lines: string[] = []; + const byModule = new Map>(); + const add = (spec: string | null, names: string[]) => { + if (!spec) return; + const set = byModule.get(spec) ?? new Set(); + names.forEach((name) => set.add(name)); + byModule.set(spec, set); + }; for (const preset of Object.values(presets)) { const m = preset.manifest; for (const rule of m.movedExports) { - lines.push( - `import type { ${rule.names.join(', ')} } from '${renameSpecifier( - m, - rule.to - )}';` - ); + add(renameSpecifier(m, rule.to), rule.names); } for (const row of m.deepImports) { - if (row.to && row.onlyNames) { - lines.push( - `import type { ${row.onlyNames.join(', ')} } from '${renameSpecifier( - m, - row.to - )}';` + if (!row.to) continue; + const to = renameSpecifier(m, row.to); + if (row.onlyNames) { + add( + to, + row.onlyNames.map((name) => row.renames?.[name] ?? name) ); } - if (!row.to || !row.renames) continue; - lines.push( - `import type { ${Object.values(row.renames).join( - ', ' - )} } from '${renameSpecifier(m, row.to)}';` - ); + if (row.renames) add(to, Object.values(row.renames)); } } - return lines.join('\n'); + return [...byModule] + .map( + ([spec, names]) => + `import type { ${[...names].join(', ')} } from '${spec}';` + ) + .join('\n'); +}; + +/* `@codecademy/gamut/dist/Form/styles` -> packages/gamut/src/Form/styles(.ts|/index.tsx|...) */ +const sourceModuleFor = (deepPath: string) => { + const match = /^@codecademy\/([\w-]+)\/dist\/(.+)$/.exec(deepPath); + if (!match) return null; + const base = path.join(REPO, 'packages', match[1], 'src', match[2]); + return ( + ['.ts', '.tsx', '/index.ts', '/index.tsx'] + .map((ext) => base + ext) + .find((file) => fs.existsSync(file)) ?? null + ); +}; + +/* Export names of each module specifier, resolved like a bundler would. */ +const exportsOf = (specifiers: string[]) => { + const file = path.join(REPO, 'packages/gamut-codemods/__exports-check__.ts'); + const source = specifiers + .map((spec, i) => `import * as m${i} from '${spec}';`) + .join('\n'); + const host = ts.createCompilerHost({}); + const readFile = host.readFile.bind(host); + host.readFile = (f) => (f === file ? source : readFile(f)); + const fileExists = host.fileExists.bind(host); + host.fileExists = (f) => f === file || fileExists(f); + const program = ts.createProgram( + [file], + { + noEmit: true, + skipLibCheck: true, + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + }, + host + ); + const checker = program.getTypeChecker(); + const imports = program + .getSourceFile(file)! + .statements.filter(ts.isImportDeclaration); + return specifiers.map((_, i) => { + const symbol = checker.getSymbolAtLocation(imports[i].moduleSpecifier); + return new Set( + symbol ? checker.getExportsOfModule(symbol).map((s) => s.name) : [] + ); + }); }; describe('manifests', () => { + /* + A deep-import row sends every name imported from `from` to `to`, so + everything `from` exports has to be available there: directly, via + `renames`, or be left out on purpose by listing what is public in + `onlyNames`. Otherwise the codemod writes imports that don't compile. + */ + it('only rewrite deep imports whose names all exist at the target', () => { + const rows = manifest.deepImports.filter((row) => row.to && !row.onlyNames); + const sources = rows.map((row) => sourceModuleFor(row.from)); + const targets = rows.map((row) => renameSpecifier(manifest, row.to!)!); + const found = exportsOf([ + ...sources.map((s) => s ?? 'missing'), + ...targets, + ]); + + const problems = rows.flatMap((row, i) => { + if (!sources[i]) return [`${row.from}: no source module found`]; + const available = found[rows.length + i]; + const missing = [...found[i]] + .map((name) => row.renames?.[name] ?? name) + .filter((name) => !available.has(name)); + return missing.length + ? [`${row.from} -> ${targets[i]}: missing ${missing.join(', ')}`] + : []; + }); + + expect(problems).toEqual([]); + }); + it('only point at exports that exist in the built packages', () => { const file = path.join( REPO, diff --git a/packages/gamut-codemods/src/lib/manifest.ts b/packages/gamut-codemods/src/lib/manifest.ts index 74ca28b9c3..8fdc71137a 100644 --- a/packages/gamut-codemods/src/lib/manifest.ts +++ b/packages/gamut-codemods/src/lib/manifest.ts @@ -31,8 +31,10 @@ export interface DeepImport { /* Old imported name -> new exported name. Local bindings are kept. */ renames?: Record; /* - When only some of the old module went public: the names `to` exports. - A declaration importing anything else is left alone with a warning. + When only some of the old module went public: the old names that work + from `to` (after `renames`). A declaration importing anything else is + left alone with a warning. manifest.test.ts fails if a row sends names + to `to` that it doesn't export, so partial rows must list these. */ onlyNames?: string[]; note?: string; diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.input.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.input.json new file mode 100644 index 0000000000..38cca72cc6 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.input.json @@ -0,0 +1 @@ +{ "compilerOptions": { "lib": ["es2022", "DOM", "dom.iterable"] } } diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.output.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.output.json new file mode 100644 index 0000000000..38cca72cc6 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/has-dom.output.json @@ -0,0 +1 @@ +{ "compilerOptions": { "lib": ["es2022", "DOM", "dom.iterable"] } } diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.input.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.input.json new file mode 100644 index 0000000000..3a90a0ce69 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.input.json @@ -0,0 +1,4 @@ +{ + // server code + "compilerOptions": { "lib": ["es2022"] } +} diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.output.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.output.json new file mode 100644 index 0000000000..3a90a0ce69 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.output.json @@ -0,0 +1,4 @@ +{ + // server code + "compilerOptions": { "lib": ["es2022"] } +} diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.warnings.json b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.warnings.json new file mode 100644 index 0000000000..f83a4ee348 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/__testfixtures__/no-dom.warnings.json @@ -0,0 +1 @@ +["\"lib\" has no \"dom\""] diff --git a/packages/gamut-codemods/src/migrations/tsconfig-dom/index.ts b/packages/gamut-codemods/src/migrations/tsconfig-dom/index.ts new file mode 100644 index 0000000000..86e7f69b7d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/tsconfig-dom/index.ts @@ -0,0 +1,30 @@ +import type { FileMigration } from '../../lib/types'; + +/* + Report-only. Before scope-swap, the gamut root re-exported Video, which + pulled @vidstack/react's DOM types into every program that imported + gamut. A tsconfig with an explicit `lib` and no "dom" got DOM globals + anyway. Once Video moves to its subpath they're gone, and `document`, + `HTMLInputElement`, `ResizeObserver` and friends stop type-checking. + Adding "dom" is the owner's call, so this warns rather than rewriting. +*/ + +const LIB = /"lib"\s*:\s*\[([^\]]*)\]/; + +export const tsconfigDom: FileMigration = { + name: 'tsconfig-dom', + kind: 'file', + description: + 'Warn about tsconfig `lib` settings without "dom", which Video used to supply.', + match: (file) => /(^|\/)tsconfig[\w.-]*\.json$/.test(file), + run({ source, warn, note }) { + const lib = LIB.exec(source); + if (lib && !/["']dom["']/i.test(lib[1])) { + warn( + `"lib" has no "dom". Gamut's root import used to bring DOM types in through Video; add "dom" if type-checking now fails on document, HTMLElement, and so on.` + ); + note('tsconfig-no-dom'); + } + return null; + }, +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts index 0174666b37..d883d99a3b 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/index.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -7,6 +7,7 @@ import { mfShared } from '../../migrations/mf-shared'; import { movedExports } from '../../migrations/moved-exports'; import { packageJson } from '../../migrations/package-json'; import { scopeRename } from '../../migrations/scope-rename'; +import { tsconfigDom } from '../../migrations/tsconfig-dom'; import { yarnrc } from '../../migrations/yarnrc'; import { manifest } from './manifest'; @@ -25,6 +26,7 @@ export const scopeSwap: Preset = { eslintConfig, mdxImports, yarnrc, + tsconfigDom, ], checklist: [ 'Run your formatter (prettier --write / eslint --fix). Split imports come out in recast style.', @@ -33,7 +35,9 @@ export const scopeSwap: Preset = { ], conditionalChecklist: { 'removed:@codecademy/gamut-kit': - 'gamut-kit is gone. Delete anything that reads it for versions, such as a sync-gamut-deps script.', + "gamut-kit is gone. Delete anything that reads it for versions, such as a sync-gamut-deps script. @skillsoft/gamut-styles has a stylis peer dependency, and it's now a direct dependency of yours, so add stylis if yarn warns that it isn't provided.", + 'tsconfig-no-dom': + 'Some tsconfig files set "lib" without "dom" (see the tsconfig-dom warnings). Video no longer loads with the root import, and it was supplying the DOM types. Add "dom" to those files if type-checking fails on document, HTMLElement, ResizeObserver, and so on.', 'mf-shared': "Module Federation shared config changed. Gamut is now a real singleton: run the host and every remote together, and update the remotes' shared config to match.", }, diff --git a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts index 556c02c05f..3edf272f1b 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -96,15 +96,33 @@ export const manifest: Manifest = { to: '@codecademy/gamut', }, { from: '@codecademy/gamut/dist/Form/types', to: '@codecademy/gamut' }, - { from: '@codecademy/gamut/dist/Box/props', to: '@codecademy/gamut' }, + { + from: '@codecademy/gamut/dist/Box/props', + to: '@codecademy/gamut', + onlyNames: ['BoxProps', 'FlexBoxProps', 'GridBoxProps'], + }, { from: '@codecademy/gamut/dist/Tip/shared/types', to: '@codecademy/gamut', }, - { from: '@codecademy/gamut/dist/Button/shared', to: '@codecademy/gamut' }, + { + from: '@codecademy/gamut/dist/Button/shared', + to: '@codecademy/gamut', + onlyNames: ['ButtonBaseProps', 'ButtonProps', 'buttonProps'], + }, { from: '@codecademy/gamut/dist/Markdown/libs/overrides', to: '@codecademy/gamut', + onlyNames: [ + 'HTMLToReactNode', + 'MarkdownOverrideSetting', + 'MarkdownOverrideSettings', + 'createCodeBlockOverride', + 'createInputOverride', + 'createTagOverride', + 'createVideoOverride', + 'standardOverrides', + ], }, /* Renamed on the way to the root: `List` and `IconOption` collided with @@ -129,6 +147,7 @@ export const manifest: Manifest = { from: '@codecademy/gamut/dist/Form/SelectDropdown/elements', to: '@codecademy/gamut', renames: { IconOption: 'IconOptionComponent' }, + onlyNames: ['IconOption'], }, { from: '@codecademy/gamut/dist/ButtonBase/ButtonBase', @@ -156,6 +175,7 @@ export const manifest: Manifest = { { from: '@codecademy/gamut-icons/dist/props', to: '@codecademy/gamut-icons', + onlyNames: ['GamutIconProps', 'IconStyleProps'], }, { from: '@codecademy/variance/dist/types/config', diff --git a/packages/gamut/Video/package.json b/packages/gamut/Video/package.json new file mode 100644 index 0000000000..9a1490663a --- /dev/null +++ b/packages/gamut/Video/package.json @@ -0,0 +1,5 @@ +{ + "main": "../dist/Video/index.js", + "module": "../dist/Video/index.mjs", + "types": "../dist/Video/index.d.ts" +} diff --git a/packages/gamut/package.json b/packages/gamut/package.json index 1e67294cb6..dcbf3cd213 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -45,7 +45,8 @@ }, "files": [ "dist", - "bin" + "bin", + "Video" ], "license": "MIT", "main": "./dist/index.js", diff --git a/packages/gamut/project.json b/packages/gamut/project.json index 1f01215c71..b9760357dc 100644 --- a/packages/gamut/project.json +++ b/packages/gamut/project.json @@ -39,10 +39,7 @@ "dependsOn": ["build"], "options": { "cwd": "{projectRoot}", - "commands": [ - "publint .", - "attw --pack . --ignore-rules false-cjs no-resolution" - ], + "commands": ["publint .", "attw --pack . --ignore-rules false-cjs"], "parallel": false } }, diff --git a/packages/gamut/src/Button/index.ts b/packages/gamut/src/Button/index.ts index dae5e26ff3..dd02cffb1d 100644 --- a/packages/gamut/src/Button/index.ts +++ b/packages/gamut/src/Button/index.ts @@ -1,4 +1,4 @@ -export type { ButtonProps } from './shared'; +export type { ButtonBaseProps, ButtonProps } from './shared'; export { buttonProps } from './shared'; export * from './CTAButton'; export * from './FillButton'; diff --git a/packages/gamut/src/Markdown/index.tsx b/packages/gamut/src/Markdown/index.tsx index 8b04affd7a..80af520bcb 100644 --- a/packages/gamut/src/Markdown/index.tsx +++ b/packages/gamut/src/Markdown/index.tsx @@ -191,6 +191,7 @@ export class Markdown extends PureComponent { } export type { + HTMLToReactNode, MarkdownOverrideSetting, MarkdownOverrideSettings, } from './libs/overrides'; From 46ad288edbc96e8d749f7ae41ff583d7fa55296d Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 24 Sep 2026 16:19:30 -0400 Subject: [PATCH 7/8] build(deps): make @skillsoft packages easier to install A consumer now needs react, react-dom, @emotion/react, and @emotion/styled alongside @skillsoft/gamut, down from also installing @emotion/cache, lodash, stylis, and typescript by hand. Verified by installing packed tarballs into a fresh yarn project: the only peer warning left is yarn's implicit @types/react. - gamut-styles: stylis, lodash, @emotion/cache peer -> dependency. stylis only feeds the cache's prefixer, and @emotion/cache already ships its own copy, so nothing needs to be shared with the host. - gamut-icons: drop the lodash peer; nothing imports it. - variance: mark the typescript peer optional; no runtime import. - Sibling @skillsoft deps use ^ ranges instead of exact pins, so a consumer on a slightly different patch shares one gamut-styles (one theme context) instead of getting two. - gamut-tests: component-test-setup * -> ^0.3.1. - gamut: @types/marked -> devDependencies; no published .d.ts uses it. Third-party exact pins (react-aria-components and friends) stay exact: loosening them re-resolved react-aria-components 1.7.1 -> 1.21.1, which is an upgrade to test on its own. The scope-swap checklist drops its now-unneeded stylis note. Co-Authored-By: Claude Opus 5.5 --- .changeset/consumer-friendly-deps.md | 18 ++++++ .../src/presets/scope-swap/index.ts | 2 +- packages/gamut-icons/package.json | 5 +- packages/gamut-illustrations/package.json | 2 +- packages/gamut-patterns/package.json | 4 +- packages/gamut-styles/package.json | 12 ++-- packages/gamut-tests/package.json | 4 +- packages/gamut/package.json | 14 +++-- packages/variance/package.json | 7 ++- yarn.lock | 56 ++++++++++--------- 10 files changed, 75 insertions(+), 49 deletions(-) create mode 100644 .changeset/consumer-friendly-deps.md diff --git a/.changeset/consumer-friendly-deps.md b/.changeset/consumer-friendly-deps.md new file mode 100644 index 0000000000..7ff73112d2 --- /dev/null +++ b/.changeset/consumer-friendly-deps.md @@ -0,0 +1,18 @@ +--- +'@skillsoft/gamut': patch +'@skillsoft/gamut-styles': patch +'@skillsoft/gamut-icons': patch +'@skillsoft/gamut-illustrations': patch +'@skillsoft/gamut-patterns': patch +'@skillsoft/gamut-tests': patch +'@skillsoft/variance': patch +--- + +Fewer packages to install by hand. `@skillsoft/gamut` now needs only `react`, `react-dom`, `@emotion/react`, and `@emotion/styled` alongside it. + +- `@skillsoft/gamut-styles`: `stylis`, `lodash`, and `@emotion/cache` are now regular dependencies instead of peer dependencies. +- `@skillsoft/gamut-icons`: dropped an unused `lodash` peer dependency. +- `@skillsoft/variance`: the `typescript` peer dependency is now optional. +- `@skillsoft/*` packages depend on each other with `^` ranges instead of exact versions, so installs share one copy of `gamut-styles` and `variance`. +- `@skillsoft/gamut-tests`: `component-test-setup` is now `^0.3.1` instead of `*`. +- `@skillsoft/gamut`: `@types/marked` moved to devDependencies. The published types don't reference it. diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts index d883d99a3b..268ec8a107 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/index.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -35,7 +35,7 @@ export const scopeSwap: Preset = { ], conditionalChecklist: { 'removed:@codecademy/gamut-kit': - "gamut-kit is gone. Delete anything that reads it for versions, such as a sync-gamut-deps script. @skillsoft/gamut-styles has a stylis peer dependency, and it's now a direct dependency of yours, so add stylis if yarn warns that it isn't provided.", + 'gamut-kit is gone. Delete anything that reads it for versions, such as a sync-gamut-deps script.', 'tsconfig-no-dom': 'Some tsconfig files set "lib" without "dom" (see the tsconfig-dom warnings). Video no longer loads with the root import, and it was supplying the DOM types. Add "dom" to those files if type-checking fails on document, HTMLElement, ResizeObserver, and so on.', 'mf-shared': diff --git a/packages/gamut-icons/package.json b/packages/gamut-icons/package.json index 7b1e72c985..19adbe5907 100644 --- a/packages/gamut-icons/package.json +++ b/packages/gamut-icons/package.json @@ -4,8 +4,8 @@ "version": "0.0.1", "author": "Skillsoft Engineering ", "dependencies": { - "@skillsoft/gamut-styles": "0.0.1", - "@skillsoft/variance": "0.0.1" + "@skillsoft/gamut-styles": "^0.0.1", + "@skillsoft/variance": "^0.0.1" }, "exports": { "./package.json": "./package.json", @@ -24,7 +24,6 @@ "peerDependencies": { "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", - "lodash": "^4.17.23", "react": "^17.0.2 || ^18.3.0 || ^19.0.0" }, "publishConfig": { diff --git a/packages/gamut-illustrations/package.json b/packages/gamut-illustrations/package.json index b2e3f118e0..6dc03baed4 100644 --- a/packages/gamut-illustrations/package.json +++ b/packages/gamut-illustrations/package.json @@ -5,7 +5,7 @@ "author": "Skillsoft Engineering ", "bugs": "https://github.com/Codecademy/skillsoft-gamut/issues", "dependencies": { - "@skillsoft/gamut-styles": "0.0.1", + "@skillsoft/gamut-styles": "^0.0.1", "classnames": "^2.2.5" }, "exports": { diff --git a/packages/gamut-patterns/package.json b/packages/gamut-patterns/package.json index 5bcd8a2d6e..62c4677fd8 100644 --- a/packages/gamut-patterns/package.json +++ b/packages/gamut-patterns/package.json @@ -5,8 +5,8 @@ "author": "Skillsoft Engineering ", "bugs": "https://github.com/Codecademy/skillsoft-gamut/issues", "dependencies": { - "@skillsoft/gamut-styles": "0.0.1", - "@skillsoft/variance": "0.0.1", + "@skillsoft/gamut-styles": "^0.0.1", + "@skillsoft/variance": "^0.0.1", "classnames": "^2.2.5" }, "exports": { diff --git a/packages/gamut-styles/package.json b/packages/gamut-styles/package.json index e43eceef59..142fecbde2 100644 --- a/packages/gamut-styles/package.json +++ b/packages/gamut-styles/package.json @@ -4,11 +4,14 @@ "version": "0.0.1", "author": "Skillsoft Engineering ", "dependencies": { + "@emotion/cache": "^11.4.0", "@emotion/is-prop-valid": "^1.1.0", - "@skillsoft/variance": "0.0.1", + "@skillsoft/variance": "^0.0.1", "framer-motion": "^12.0.0", "get-nonce": "^1.0.0", - "polished": "^4.1.2" + "lodash": "^4.17.23", + "polished": "^4.1.2", + "stylis": "^4.2.0" }, "exports": { "./package.json": "./package.json", @@ -26,12 +29,9 @@ "main": "dist/index.js", "module": "dist/index.mjs", "peerDependencies": { - "@emotion/cache": "^11.4.0", "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", - "lodash": "^4.17.23", - "react": "^17.0.2 || ^18.3.0 || ^19.0.0", - "stylis": "^4.0.7" + "react": "^17.0.2 || ^18.3.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-tests/package.json b/packages/gamut-tests/package.json index d8fbf72a64..62e2bb15fb 100644 --- a/packages/gamut-tests/package.json +++ b/packages/gamut-tests/package.json @@ -4,8 +4,8 @@ "version": "0.0.1", "author": "Skillsoft Engineering ", "dependencies": { - "@skillsoft/gamut-styles": "0.0.1", - "component-test-setup": "*", + "@skillsoft/gamut-styles": "^0.0.1", + "component-test-setup": "^0.3.1", "lodash": "^4.17.23" }, "exports": { diff --git a/packages/gamut/package.json b/packages/gamut/package.json index dcbf3cd213..010bbacd8d 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -7,12 +7,11 @@ "dependencies": { "@formatjs/intl-locale": "5.3.1", "@react-aria/interactions": "3.25.0", - "@skillsoft/gamut-icons": "0.0.1", - "@skillsoft/gamut-illustrations": "0.0.1", - "@skillsoft/gamut-patterns": "0.0.1", - "@skillsoft/gamut-styles": "0.0.1", - "@skillsoft/variance": "0.0.1", - "@types/marked": "^4.0.8", + "@skillsoft/gamut-icons": "^0.0.1", + "@skillsoft/gamut-illustrations": "^0.0.1", + "@skillsoft/gamut-patterns": "^0.0.1", + "@skillsoft/gamut-styles": "^0.0.1", + "@skillsoft/variance": "^0.0.1", "@vidstack/react": "^1.12.12", "classnames": "^2.2.5", "framer-motion": "^12.0.0", @@ -30,6 +29,9 @@ "react-use": "^17.6.1", "sanitize-markdown": "^2.6.7" }, + "devDependencies": { + "@types/marked": "^4.0.8" + }, "exports": { "./package.json": "./package.json", ".": { diff --git a/packages/variance/package.json b/packages/variance/package.json index 9625a1bcc1..a750678e6a 100644 --- a/packages/variance/package.json +++ b/packages/variance/package.json @@ -40,5 +40,10 @@ "build": "nx build variance" }, "sideEffects": false, - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } } diff --git a/yarn.lock b/yarn.lock index c62b2f4f7d..075216b703 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8173,25 +8173,24 @@ __metadata: languageName: unknown linkType: soft -"@skillsoft/gamut-icons@npm:0.0.1, @skillsoft/gamut-icons@workspace:*, @skillsoft/gamut-icons@workspace:packages/gamut-icons": +"@skillsoft/gamut-icons@npm:^0.0.1, @skillsoft/gamut-icons@workspace:*, @skillsoft/gamut-icons@workspace:packages/gamut-icons": version: 0.0.0-use.local resolution: "@skillsoft/gamut-icons@workspace:packages/gamut-icons" dependencies: - "@skillsoft/gamut-styles": "npm:0.0.1" - "@skillsoft/variance": "npm:0.0.1" + "@skillsoft/gamut-styles": "npm:^0.0.1" + "@skillsoft/variance": "npm:^0.0.1" peerDependencies: "@emotion/react": ^11.4.0 "@emotion/styled": ^11.3.0 - lodash: ^4.17.23 react: ^17.0.2 || ^18.3.0 || ^19.0.0 languageName: unknown linkType: soft -"@skillsoft/gamut-illustrations@npm:0.0.1, @skillsoft/gamut-illustrations@workspace:packages/gamut-illustrations": +"@skillsoft/gamut-illustrations@npm:^0.0.1, @skillsoft/gamut-illustrations@workspace:packages/gamut-illustrations": version: 0.0.0-use.local resolution: "@skillsoft/gamut-illustrations@workspace:packages/gamut-illustrations" dependencies: - "@skillsoft/gamut-styles": "npm:0.0.1" + "@skillsoft/gamut-styles": "npm:^0.0.1" classnames: "npm:^2.2.5" peerDependencies: "@emotion/react": ^11.4.0 @@ -8201,12 +8200,12 @@ __metadata: languageName: unknown linkType: soft -"@skillsoft/gamut-patterns@npm:0.0.1, @skillsoft/gamut-patterns@workspace:packages/gamut-patterns": +"@skillsoft/gamut-patterns@npm:^0.0.1, @skillsoft/gamut-patterns@workspace:packages/gamut-patterns": version: 0.0.0-use.local resolution: "@skillsoft/gamut-patterns@workspace:packages/gamut-patterns" dependencies: - "@skillsoft/gamut-styles": "npm:0.0.1" - "@skillsoft/variance": "npm:0.0.1" + "@skillsoft/gamut-styles": "npm:^0.0.1" + "@skillsoft/variance": "npm:^0.0.1" classnames: "npm:^2.2.5" peerDependencies: "@emotion/react": ^11.4.0 @@ -8216,22 +8215,22 @@ __metadata: languageName: unknown linkType: soft -"@skillsoft/gamut-styles@npm:0.0.1, @skillsoft/gamut-styles@workspace:*, @skillsoft/gamut-styles@workspace:packages/gamut-styles": +"@skillsoft/gamut-styles@npm:^0.0.1, @skillsoft/gamut-styles@workspace:*, @skillsoft/gamut-styles@workspace:packages/gamut-styles": version: 0.0.0-use.local resolution: "@skillsoft/gamut-styles@workspace:packages/gamut-styles" dependencies: + "@emotion/cache": "npm:^11.4.0" "@emotion/is-prop-valid": "npm:^1.1.0" - "@skillsoft/variance": "npm:0.0.1" + "@skillsoft/variance": "npm:^0.0.1" framer-motion: "npm:^12.0.0" get-nonce: "npm:^1.0.0" + lodash: "npm:^4.17.23" polished: "npm:^4.1.2" + stylis: "npm:^4.2.0" peerDependencies: - "@emotion/cache": ^11.4.0 "@emotion/react": ^11.4.0 "@emotion/styled": ^11.3.0 - lodash: ^4.17.23 react: ^17.0.2 || ^18.3.0 || ^19.0.0 - stylis: ^4.0.7 languageName: unknown linkType: soft @@ -8239,8 +8238,8 @@ __metadata: version: 0.0.0-use.local resolution: "@skillsoft/gamut-tests@workspace:packages/gamut-tests" dependencies: - "@skillsoft/gamut-styles": "npm:0.0.1" - component-test-setup: "npm:*" + "@skillsoft/gamut-styles": "npm:^0.0.1" + component-test-setup: "npm:^0.3.1" lodash: "npm:^4.17.23" peerDependencies: react: ^17.0.2 || ^18.3.0 || ^19.0.0 @@ -8253,11 +8252,11 @@ __metadata: dependencies: "@formatjs/intl-locale": "npm:5.3.1" "@react-aria/interactions": "npm:3.25.0" - "@skillsoft/gamut-icons": "npm:0.0.1" - "@skillsoft/gamut-illustrations": "npm:0.0.1" - "@skillsoft/gamut-patterns": "npm:0.0.1" - "@skillsoft/gamut-styles": "npm:0.0.1" - "@skillsoft/variance": "npm:0.0.1" + "@skillsoft/gamut-icons": "npm:^0.0.1" + "@skillsoft/gamut-illustrations": "npm:^0.0.1" + "@skillsoft/gamut-patterns": "npm:^0.0.1" + "@skillsoft/gamut-styles": "npm:^0.0.1" + "@skillsoft/variance": "npm:^0.0.1" "@types/marked": "npm:^4.0.8" "@vidstack/react": "npm:^1.12.12" classnames: "npm:^2.2.5" @@ -8303,7 +8302,7 @@ __metadata: languageName: unknown linkType: soft -"@skillsoft/variance@npm:0.0.1, @skillsoft/variance@workspace:packages/variance": +"@skillsoft/variance@npm:^0.0.1, @skillsoft/variance@workspace:packages/variance": version: 0.0.0-use.local resolution: "@skillsoft/variance@workspace:packages/variance" dependencies: @@ -8312,6 +8311,9 @@ __metadata: peerDependencies: "@emotion/react": "*" typescript: ">=4.3.5" + peerDependenciesMeta: + typescript: + optional: true languageName: unknown linkType: soft @@ -12193,7 +12195,7 @@ __metadata: languageName: node linkType: hard -"component-test-setup@npm:*, component-test-setup@npm:^0.3.1": +"component-test-setup@npm:^0.3.1": version: 0.3.3 resolution: "component-test-setup@npm:0.3.3" checksum: 10c0/f85ee9a5bf5f65b8440cd6fe03a626e1222b1bba2f29798a4de1c1d7fdbf87397a5ad11e19b0ed20030033c7bede66d2ae6c5bfba426e00b9383adbe0dd0a584 @@ -23871,10 +23873,10 @@ __metadata: languageName: node linkType: hard -"stylis@npm:^4.3.0": - version: 4.3.4 - resolution: "stylis@npm:4.3.4" - checksum: 10c0/4899c2674cd2538e314257abd1ba7ea3c2176439659ddac6593c78192cfd4a06f814a0a4fc69bc7f8fcc6b997e13d383dd9b578b71074746a0fb86045a83e42d +"stylis@npm:^4.2.0, stylis@npm:^4.3.0": + version: 4.4.0 + resolution: "stylis@npm:4.4.0" + checksum: 10c0/259be096d90dfbfe903c8656dcb7591e52a421e577e950ef42ebd9ca02f387623a1165dd08761492fb6e92a7a562d62a53a694a10b0a2f6dcd7a0db107b4bf55 languageName: node linkType: hard From 9b21a6f6db6d5c6a275a7f106cb81dddff7d3ce6 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 5 Oct 2026 10:54:44 -0400 Subject: [PATCH 8/8] change set --- .changeset/nice-peaches-hear.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/nice-peaches-hear.md diff --git a/.changeset/nice-peaches-hear.md b/.changeset/nice-peaches-hear.md new file mode 100644 index 0000000000..2bdfb62ff3 --- /dev/null +++ b/.changeset/nice-peaches-hear.md @@ -0,0 +1,12 @@ +--- +'@skillsoft/eslint-plugin-gamut': patch +'@skillsoft/gamut': patch +'@skillsoft/gamut-icons': patch +'@skillsoft/gamut-illustrations': patch +'@skillsoft/gamut-patterns': patch +'@skillsoft/gamut-styles': patch +'@skillsoft/gamut-tests': patch +'@skillsoft/variance': patch +--- + +Swap Emotion's transform to swc/plugin and explicit type exporting