From 4728ba1b290fc04126e808db2337750ff378852c Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 09:46:24 -0400 Subject: [PATCH 1/8] feat(gamut-codemods): add the codemods CLI and an empty scope-swap preset Adds @skillsoft/gamut-codemods: the CLI (clean-tree guard, --dry, --only, --no-report, --force), the runner for ast, splice, and file migrations, the shared helpers (module-string finder, leftovers report, version-range checks, flat ESLint config detection), the preset registry, and the fixture harness. The scope-swap preset has its package map but no migrations yet, so a run only reports the lines that still name an old package. Each breaking change lands its migration in its own PR. 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 | 120 ++++++ packages/gamut-codemods/jest.config.ts | 19 + packages/gamut-codemods/package.json | 38 ++ packages/gamut-codemods/project.json | 46 ++ .../__testfixtures__/no-gamut.input.tsx | 4 + .../__testfixtures__/no-gamut.output.tsx | 4 + .../gamut-codemods/src/__tests__/cli.test.ts | 192 +++++++++ .../src/__tests__/fixtures.test.ts | 113 +++++ .../gamut-codemods/src/__tests__/lib.test.ts | 251 +++++++++++ .../src/__tests__/manifest.test.ts | 196 +++++++++ .../src/__tests__/module-strings.test.ts | 21 + packages/gamut-codemods/src/bin.ts | 13 + packages/gamut-codemods/src/cli.ts | 235 +++++++++++ packages/gamut-codemods/src/lib/eslint.ts | 39 ++ packages/gamut-codemods/src/lib/git.ts | 27 ++ packages/gamut-codemods/src/lib/leftovers.ts | 138 ++++++ packages/gamut-codemods/src/lib/manifest.ts | 124 ++++++ .../gamut-codemods/src/lib/module-strings.ts | 81 ++++ packages/gamut-codemods/src/lib/run-source.ts | 112 +++++ packages/gamut-codemods/src/lib/types.ts | 96 +++++ packages/gamut-codemods/src/presets/index.ts | 43 ++ .../src/presets/scope-swap/index.ts | 15 + .../src/presets/scope-swap/manifest.ts | 35 ++ packages/gamut-codemods/src/transform.ts | 77 ++++ packages/gamut-codemods/tsconfig.json | 13 + packages/gamut-codemods/tsconfig.lib.json | 13 + packages/gamut-codemods/tsconfig.spec.json | 11 + yarn.lock | 398 +++++++++++++++++- 33 files changed, 2480 insertions(+), 6 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__/no-gamut.input.tsx create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.output.tsx create mode 100644 packages/gamut-codemods/src/__tests__/cli.test.ts 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/__tests__/module-strings.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/eslint.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/run-source.ts create mode 100644 packages/gamut-codemods/src/lib/types.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..e6581a6692 --- /dev/null +++ b/.changeset/gamut-codemods-scope-swap.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut-codemods': minor +--- + +Add `@skillsoft/gamut-codemods`, a CLI for moving a repo from `@codecademy/gamut*` to `@skillsoft/gamut*`. Run `npx @skillsoft/gamut-codemods scope-swap .` from a clean working tree. This release adds the runner and the `scope-swap` preset with no migrations yet: a run lists every line that still names an old package and prints next steps. Pass `--dry` to preview, `--only=a,b` to pick migrations, and `--no-report` to skip the leftovers list. 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..aabf663a72 --- /dev/null +++ b/packages/gamut-codemods/README.md @@ -0,0 +1,120 @@ +# @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. + +### 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 | +| --------- | ------------ | + +### Known limits + +- Every file is parsed with the `tsx` parser, so Flow-typed JS won't parse. +- 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__/no-gamut.input.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.input.tsx new file mode 100644 index 0000000000..0ac536f08b --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.input.tsx @@ -0,0 +1,4 @@ +import { render } from '@testing-library/react'; +import { Thing } from '@codecademy/other-package'; + +export const run = () => render(); diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.output.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.output.tsx new file mode 100644 index 0000000000..0ac536f08b --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.output.tsx @@ -0,0 +1,4 @@ +import { render } from '@testing-library/react'; +import { Thing } from '@codecademy/other-package'; + +export const run = () => render(); diff --git a/packages/gamut-codemods/src/__tests__/cli.test.ts b/packages/gamut-codemods/src/__tests__/cli.test.ts new file mode 100644 index 0000000000..5c18886764 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/cli.test.ts @@ -0,0 +1,192 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { API, FileInfo } from 'jscodeshift'; +import jscodeshift from 'jscodeshift'; + +import { main } from '../cli'; +import transform, { type TransformOptions } from '../transform'; + +/* + Drives the real CLI. jscodeshift's Runner normally spawns workers that + load a built transform.js; here it's replaced with an in-process loop + over the same transform, so these tests run against source and count + toward coverage. The factory only builds a wrapper, so the mock reference + is resolved at call time. +*/ +const mockRun = jest.fn(); +jest.mock('jscodeshift/src/Runner', () => ({ + run: (...args: unknown[]) => mockRun(...args), +})); + +const SOURCE_EXT = /\.(m?[jt]sx?|c[jt]s)$/; + +const listSources = (target: string): string[] => + fs.statSync(target).isDirectory() + ? fs + .readdirSync(target) + .filter((name) => name !== 'node_modules') + .flatMap((name) => listSources(path.join(target, name))) + : SOURCE_EXT.test(target) + ? [target] + : []; + +const simulateRunner = async ( + _transformFile: string, + paths: string[], + options: Record +) => { + const api = { + jscodeshift: jscodeshift.withParser('tsx'), + report: (message: string) => console.log(message), + stats: () => {}, + }; + for (const file of paths.flatMap(listSources)) { + const source = fs.readFileSync(file, 'utf8'); + const out = transform( + { path: file, source } as FileInfo, + api as unknown as API, + options as TransformOptions + ); + if (out && !options.dry) fs.writeFileSync(file, out); + } + return { error: 0 }; +}; + +const project = (files: Record) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-cli-test-')); + for (const [name, text] of Object.entries(files)) { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + } + return dir; +}; + +const read = (dir: string, name: string) => + fs.readFileSync(path.join(dir, name), 'utf8'); + +const POST = `import { Box, Markdown, Video } from '@codecademy/gamut'; + +export const Post = ({ text, url }: { text: string; url: string }) => ( + + + +); +`; + +let logs: string[]; +let errors: string[]; + +beforeEach(() => { + logs = []; + errors = []; + jest.spyOn(console, 'log').mockImplementation((...args) => { + logs.push(args.join(' ')); + }); + jest.spyOn(console, 'error').mockImplementation((...args) => { + errors.push(args.join(' ')); + }); + mockRun.mockReset(); + mockRun.mockImplementation(simulateRunner); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const output = () => logs.join('\n'); + +describe('main: no-op paths', () => { + it('lists presets and their migrations', async () => { + expect(await main(['list'])).toBe(0); + expect(output()).toMatch(/scope-swap/); + }); + + it('prints usage for --help and for a missing path', async () => { + expect(await main(['--help'])).toBe(0); + expect(output()).toMatch(/Usage: gamut-codemods/); + + logs = []; + expect(await main(['scope-swap'])).toBe(1); + expect(output()).toMatch(/Usage: gamut-codemods/); + }); + + it('rejects an unknown preset', async () => { + await expect(main(['no-such-preset', '/tmp'])).rejects.toThrow( + /unknown preset 'no-such-preset'/ + ); + }); +}); + +describe('main: refusing to write', () => { + it('refuses a target outside git without --force', async () => { + const dir = project({ 'package.json': '{}' }); + expect(await main(['scope-swap', dir])).toBe(1); + expect(errors.join('\n')).toMatch(/not inside a git repository/); + expect(errors.join('\n')).toMatch(/--force/); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it('refuses a dirty git tree without --force', async () => { + const dir = project({ 'src/a.ts': 'export {};\n' }); + const git = (...args: string[]) => + execFileSync('git', args, { cwd: dir, stdio: 'ignore' }); + git('init', '-q'); + expect(await main(['scope-swap', dir])).toBe(1); + expect(errors.join('\n')).toMatch(/uncommitted changes/); + }); +}); + +describe('main: full run', () => { + it('lists leftovers and prints the checklist', async () => { + const dir = project({ + 'src/Post.tsx': POST, + 'package.json': JSON.stringify({ + name: 'app', + dependencies: { '@codecademy/gamut': '^73.0.0' }, + }), + 'jest.config.js': + "module.exports = { moduleNameMapper: { '^@codecademy/gamut/(.*)$': '/x/$1' } };\n", + }); + + expect(await main(['scope-swap', dir, '--force'])).toBe(0); + + const out = output(); + expect(out).toMatch(/file\(s\) changed/); + expect(out).toMatch(/leftovers \(\d+\)/); + expect(out).toMatch(/Post\.tsx:1/); + expect(out).toMatch(/package\.json:1/); + expect(out).toMatch(/jest\.config\.js:1/); + expect(out).toMatch(/next steps/); + expect(out).toMatch(/Reinstall \(yarn\)/); + }); + + it('writes nothing under --dry and skips the checklist and leftovers', async () => { + const pkg = JSON.stringify({ + dependencies: { '@codecademy/gamut': '^73.0.0' }, + }); + const dir = project({ 'src/Post.tsx': POST, 'package.json': pkg }); + expect(await main(['scope-swap', dir, '--dry'])).toBe(0); + expect(read(dir, 'src/Post.tsx')).toBe(POST); + expect(read(dir, 'package.json')).toBe(pkg); + expect(output()).toMatch(/skipped in --dry/); + expect(output()).not.toMatch(/next steps/); + }); + + it('skips the leftovers report with --no-report', async () => { + const dir = project({ 'src/Post.tsx': POST }); + expect(await main(['scope-swap', dir, '--force', '--no-report'])).toBe(0); + expect(output()).not.toMatch(/leftovers \(/); + expect(output()).not.toMatch(/leftovers skipped/); + }); + + it('returns 1 when the source runner reports errors', async () => { + mockRun.mockResolvedValueOnce({ error: 1 }); + const dir = project({ 'src/Post.tsx': POST }); + expect(await main(['scope-swap', dir, '--force'])).toBe(1); + }); +}); 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..cc5f0f2d48 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/fixtures.test.ts @@ -0,0 +1,113 @@ +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: () => {}, + transformSource: (code) => runSource(preset.name, code).out, + }); + 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..5c821d40e1 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/lib.test.ts @@ -0,0 +1,251 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { parseArgs } from '../cli'; +import { findFlatEslintConfig } from '../lib/eslint'; +import { dirtyTreeReason } from '../lib/git'; +import { findLeftovers } from '../lib/leftovers'; +import { isAtLeast, isNewerThan, minVersion } from '../lib/manifest'; +import type { Migration } from '../lib/types'; +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('validatePreset', () => { + it('accepts scope-swap', () => { + expect(() => validatePreset(scopeSwap)).not.toThrow(); + }); + + it('rejects an ast migration listed after a splice one', () => { + const migration = (name: string, kind: 'ast' | 'splice') => + ({ name, kind, description: name } as Migration); + expect(() => + validatePreset({ + ...scopeSwap, + migrations: [ + migration('first-ast', 'ast'), + migration('a-splice', 'splice'), + migration('late-ast', 'ast'), + ], + }) + ).toThrow(/late-ast/); + }); +}); + +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'", + ].join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1]); + }); + + it('tags every old package the manifest replaces', () => { + const dir = tmp(); + const old = [ + ...Object.keys(manifest.packages), + ...Object.keys(manifest.removedPackages), + ]; + fs.writeFileSync( + path.join(dir, 'README.md'), + old.map((name) => `import x from '${name}/dist/x';`).join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual( + old.map((_, i) => i + 1) + ); + }); + + it('flags /dist paths into published packages only', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'README.md'), + [ + "import { X } from '@codecademy/gamut-styles/dist/AssetProvider';", + "import { Y } from '@codecademy/gamut-foo/dist/bar';", + "import { Z } from '@skillsoft/gamut-styles/dist/bar';", + ].join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1]); + }); + + it('reads editor config and extensionless scripts', () => { + const dir = tmp(); + const line = "require('@codecademy/gamut/package.json');"; + fs.writeFileSync( + path.join(dir, 'sync-deps'), + `#!/usr/bin/env node\n${line}` + ); + fs.writeFileSync(path.join(dir, 'LICENSE'), line); + fs.writeFileSync(path.join(dir, 'rules.mdc'), line); + fs.writeFileSync(path.join(dir, 'react.code-snippets'), line); + expect( + findLeftovers([dir], manifest) + .map((h) => path.basename(h.file)) + .sort() + ).toEqual(['react.code-snippets', 'rules.mdc', 'sync-deps']); + }); + + it('skips quoted rule keys when told the repo uses a flat config', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'eslint.config.ts'), + [ + "rules: { 'gamut/no-inline-style': 'error' },", + "import gamut from '@codecademy/eslint-plugin-gamut';", + "import gamut from 'eslint-plugin-gamut';", + ].join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1, 3]); + expect( + findLeftovers([dir], manifest, { eslintRuleKeys: false }).map( + (h) => h.line + ) + ).toEqual([3]); + }); +}); + +describe('findFlatEslintConfig', () => { + it('finds a flat config inside the target', () => { + const dir = tmp(); + fs.mkdirSync(path.join(dir, 'libs', 'a'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'libs', 'a', 'eslint.config.mjs'), ''); + expect(findFlatEslintConfig([dir])).toBe( + path.join(dir, 'libs', 'a', 'eslint.config.mjs') + ); + }); + + it('finds a flat config above the target', () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, 'eslint.config.ts'), ''); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync(path.join(dir, 'src', 'a.tsx'), ''); + expect(findFlatEslintConfig([path.join(dir, 'src')])).toBe( + path.join(dir, 'eslint.config.ts') + ); + expect(findFlatEslintConfig([path.join(dir, 'src', 'a.tsx')])).toBe( + path.join(dir, 'eslint.config.ts') + ); + }); + + it('returns null for a legacy .eslintrc repo', () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, '.eslintrc.json'), '{}'); + expect(findFlatEslintConfig([dir])).toBeNull(); + }); +}); + +describe('isNewerThan', () => { + it.each([ + ['74.0.0', '73.6.1', true], + ['^73.7.0', '73.6.1', true], + ['~73.6.2', '73.6.1', true], + ['npm:@codecademy/gamut@74.0.0', '73.6.1', true], + ['73.6.1', '73.6.1', false], + ['^73.0.0', '73.6.1', false], + ['74.0.0-alpha.1', '74.0.0', false], + ['*', '73.6.1', false], + ['workspace:*', '73.6.1', false], + ['https://example.com/gamut.tgz', '73.6.1', false], + ])('%s vs %s -> %s', (range, version, expected) => { + expect(isNewerThan(range, version)).toBe(expected); + }); + + it('reads the lowest version a range allows', () => { + expect(minVersion('>= 1.2.3')).toEqual([1, 2, 3]); + expect(minVersion('latest')).toBeNull(); + }); +}); + +describe('isAtLeast', () => { + it.each([ + ['74.0.0', true], + ['^74.1.0', true], + ['~75.0.0', true], + ['73.6.3', false], + ['^73.6.1', false], + ['*', false], + ])('%s >= 74.0.0 is %s', (range, expected) => { + expect(isAtLeast(range, '74.0.0')).toBe(expected); + }); +}); + +describe('findLeftovers: scoped directives under a flat config', () => { + const directive = + '// eslint-disable-next-line @skillsoft/gamut/no-inline-style'; + + it('lists them when no file registers the scoped plugin key', () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, 'eslint.config.ts'), 'plugins: { gamut }'); + fs.writeFileSync(path.join(dir, 'a.tsx'), directive); + const hits = findLeftovers([dir], manifest, { eslintRuleKeys: false }); + expect(hits).toHaveLength(1); + expect(hits[0].note).toMatch(/plugins key/); + }); + + it('leaves them alone when the plugin is registered under that key', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'eslint.config.ts'), + "plugins: { '@skillsoft/gamut': gamut }" + ); + fs.writeFileSync(path.join(dir, 'a.tsx'), directive); + expect(findLeftovers([dir], manifest, { eslintRuleKeys: false })).toEqual( + [] + ); + }); + + it('is not checked for legacy configs', () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, 'a.tsx'), directive); + expect(findLeftovers([dir], manifest)).toEqual([]); + }); +}); 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..f48b665496 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/manifest.test.ts @@ -0,0 +1,196 @@ +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 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) { + add(renameSpecifier(m, rule.to), rule.names); + } + for (const row of m.deepImports) { + 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.renames) add(to, Object.values(row.renames)); + } + } + 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, + '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/__tests__/module-strings.test.ts b/packages/gamut-codemods/src/__tests__/module-strings.test.ts new file mode 100644 index 0000000000..758534a63c --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/module-strings.test.ts @@ -0,0 +1,21 @@ +import jscodeshift from 'jscodeshift'; + +import { findModuleStrings } from '../lib/module-strings'; + +/* + Calls that aren't require(), import(), or a known module API must not be + taken for module strings, even when their argument looks like a package. +*/ +describe('findModuleStrings', () => { + it('finds module strings in require and require.resolve, and skips other calls', () => { + const j = jscodeshift.withParser('tsx'); + const root = j(` + require('@codecademy/gamut/a'); + require.resolve('@codecademy/gamut/b'); + getModule()('@codecademy/gamut/c'); + (0, load)('@codecademy/gamut/d'); + `); + const values = findModuleStrings(j, root).map((node) => node.value); + expect(values).toEqual(['@codecademy/gamut/a', '@codecademy/gamut/b']); + }); +}); 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..1774af75a3 --- /dev/null +++ b/packages/gamut-codemods/src/cli.ts @@ -0,0 +1,235 @@ +/* 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 jscodeshift from 'jscodeshift'; + +import { findFlatEslintConfig } from './lib/eslint'; +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'; + +/* 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; + /* Filled in by the CLI from each migration's skipReason, not a flag. */ + skip?: 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 skip = new Set(opts.skip ? opts.skip.split(',') : []); + const active = preset.migrations.filter( + (m): m is FileMigration => + m.kind === 'file' && (!only || only.has(m.name)) && !skip.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 transformSource = (code: string) => + mentionsOldNames(preset.manifest, code) + ? runSourceMigrations({ + j: jscodeshift.withParser('tsx'), + source: code, + migrations: activeSourceMigrations( + preset.migrations, + opts.only, + opts.skip + ), + 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; + 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 notes = new Set(); + const skipped = preset.migrations.flatMap((m) => { + const reason = m.skipReason?.(opts.paths, preset.manifest); + if (!reason) return []; + console.log(`[${m.name}] skipped: ${reason}`); + notes.add(`skipped:${m.name}`); + return [m.name]; + }); + opts.skip = skipped.join(',') || undefined; + + 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, + skip: opts.skip, + 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, + } + ); + + fs.readdirSync(notesDir).forEach((key) => + notes.add(decodeURIComponent(key)) + ); + + 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, { + eslintRuleKeys: !findFlatEslintConfig(opts.paths), + }); + heading(`leftovers (${hits.length})`); + hits.forEach((hit) => + console.log( + `${hit.file}:${hit.line} ${hit.text}${ + hit.note ? ` (${hit.note})` : '' + }` + ) + ); + } + + 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/eslint.ts b/packages/gamut-codemods/src/lib/eslint.ts new file mode 100644 index 0000000000..c2b11adf1c --- /dev/null +++ b/packages/gamut-codemods/src/lib/eslint.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { walk } from './leftovers'; + +const FLAT_CONFIG = /(^|\/)eslint\.config\.[cm]?[jt]s$/; + +const flatConfigIn = (dir: string) => + fs + .readdirSync(dir) + .map((name) => path.join(dir, name)) + .find((file) => FLAT_CONFIG.test(file)); + +/* + The first flat ESLint config that applies to any root, or null: one + inside it, or one in a parent directory up to the git root (running on + a subfolder of a repo still uses the repo's config). In a flat config the rule prefix is + the key the plugin is registered under (`plugins: { gamut }`), not the + package name, so renaming the package doesn't change it. +*/ +export const findFlatEslintConfig = (roots: string[]) => { + for (const root of roots) { + const isDir = fs.statSync(root).isDirectory(); + for ( + let dir = isDir ? root : path.dirname(root); + dir !== path.dirname(dir); + dir = path.dirname(dir) + ) { + const found = flatConfigIn(dir); + if (found) return found; + if (fs.existsSync(path.join(dir, '.git'))) break; + } + if (!isDir) continue; + for (const file of walk(root)) { + if (FLAT_CONFIG.test(file)) return file; + } + } + return null; +}; 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..45220b8a60 --- /dev/null +++ b/packages/gamut-codemods/src/lib/leftovers.ts @@ -0,0 +1,138 @@ +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)$/; +/* .mdc is Cursor rules; .code-snippets is VS Code snippets (JSONC). */ +const TEXT_EXT = + /\.(m?[jt]sx?|c[jt]s|json|ya?ml|md|mdx|mdc|html|scss|css|code-snippets)$|(^|\/)\.eslintrc$/; + +/* Extensionless executables, like tools/script/sync-gamut-deps. */ +const isScript = (file: string) => { + if (path.basename(file).includes('.')) return false; + const fd = fs.openSync(file, 'r'); + try { + const head = Buffer.alloc(2); + fs.readSync(fd, head, 0, 2, 0); + return head.toString() === '#!'; + } finally { + fs.closeSync(fd); + } +}; + +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`). + + A `/dist` path into one of the packages this repo publishes also counts, + even outside files the parser reads (docs, JSON, Flow). Only the packages + in manifest.packages are checked. Removed and unrelated `@codecademy` + packages aren't ours to rewrite. + + Quoted `gamut/` rule keys and `plugin:gamut/` only count when + eslintRuleKeys is set. In a flat config they're correct as written. +*/ +const oldNamesPattern = (manifest: Manifest, eslintRuleKeys: boolean) => { + const published = Object.keys(manifest.packages); + const names = [...published, ...Object.keys(manifest.removedPackages)] + .sort((a, b) => b.length - a.length) + .map(escape); + const dist = published.map(escape).join('|'); + const prefix = escape(manifest.eslintPlugin.from); + const ruleKeys = eslintRuleKeys ? `|plugin:${prefix}/|["'\`]${prefix}/` : ''; + 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; + /* Why the line is listed, when the old name alone doesn't explain it. */ + note?: string; +} + +/* + Under a flat config the directive prefix is the plugins key. Older builds + of this codemod rewrote `gamut/` directives to `@skillsoft/gamut/` anyway. + Unless some file registers the plugin under that key, those directives + name a rule that doesn't exist and stop suppressing the real one. +*/ +const scopedDirectivePattern = (manifest: Manifest) => + new RegExp( + `eslint-(?:disable|enable)\\S*\\s.*(? + new RegExp(`["'\`]${escape(manifest.eslintPlugin.to)}["'\`]\\s*:`); + +export const findLeftovers = ( + roots: string[], + manifest: Manifest, + { eslintRuleKeys = true }: { eslintRuleKeys?: boolean } = {} +) => { + const pattern = oldNamesPattern(manifest, eslintRuleKeys); + const scopedDirective = scopedDirectivePattern(manifest); + const registersKey = registersScopedKey(manifest); + const hits: Leftover[] = []; + const directives: Leftover[] = []; + let keyRegistered = false; + for (const root of roots) { + const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; + for (const file of files) { + if (SKIP_FILES.test(file)) continue; + if (!TEXT_EXT.test(file) && !isScript(file)) continue; + fs.readFileSync(file, 'utf8') + .split('\n') + .forEach((text, i) => { + if (pattern.test(text)) + hits.push({ file, line: i + 1, text: text.trim() }); + if (eslintRuleKeys) return; + if (registersKey.test(text)) keyRegistered = true; + if (scopedDirective.test(text)) + directives.push({ + file, + line: i + 1, + text: text.trim(), + note: `no flat config registers the plugin as '${manifest.eslintPlugin.to}'; use its plugins key, e.g. ${manifest.eslintPlugin.from}/`, + }); + }); + } + } + return keyRegistered ? hits : [...hits, ...directives]; +}; diff --git a/packages/gamut-codemods/src/lib/manifest.ts b/packages/gamut-codemods/src/lib/manifest.ts new file mode 100644 index 0000000000..bd5976333e --- /dev/null +++ b/packages/gamut-codemods/src/lib/manifest.ts @@ -0,0 +1,124 @@ +/* + 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; + /* Checklist key (see the preset) to print when this rule moves anything. */ + checklist?: 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; + /* + 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; +} + +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; + /* + Newest release of each OLD package the new one is known to match, + keyed by OLD name. A consumer already on something newer may use APIs + the new package doesn't have yet; package-json warns. + */ + sourceVersions: 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 lowest x.y.z a package.json range allows, or null for anything that + isn't a plain version, `^`/`~`/`>=` range, or `npm:` alias (`*`, URLs, + `workspace:`). Prerelease tags are ignored. +*/ +export const minVersion = (range: string) => { + const match = + /^(?:npm:(?:@[^/]+\/)?[^@]+@)?(?:\^|~|>=)?\s*v?(\d+)\.(\d+)\.(\d+)/.exec( + range.trim() + ); + return match ? match.slice(1, 4).map(Number) : null; +}; + +const parseVersion = (version: string) => version.split('.').map(Number); + +/* True when `range` can't be satisfied by `version` or anything older. */ +export const isNewerThan = (range: string, version: string) => { + const min = minVersion(range); + if (!min) return false; + const max = parseVersion(version); + for (let i = 0; i < 3; i += 1) { + if (min[i] !== max[i]) return min[i] > max[i]; + } + return false; +}; + +/* True when every version `range` allows is `version` or newer. */ +export const isAtLeast = (range: string, version: string) => { + const min = minVersion(range); + if (!min) return false; + const floor = parseVersion(version); + for (let i = 0; i < 3; i += 1) { + if (min[i] !== floor[i]) return min[i] > floor[i]; + } + return true; +}; + +/* 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/run-source.ts b/packages/gamut-codemods/src/lib/run-source.ts new file mode 100644 index 0000000000..42eb019392 --- /dev/null +++ b/packages/gamut-codemods/src/lib/run-source.ts @@ -0,0 +1,112 @@ +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)); + +/* `only` and `skip` are comma-separated migration names. */ +export const activeSourceMigrations = ( + migrations: { kind: string; name: string }[], + only?: string, + skip?: string +) => { + const names = only ? new Set(only.split(',')) : null; + const skipped = new Set(skip ? skip.split(',') : []); + return migrations.filter( + (m) => + m.kind !== 'file' && (!names || names.has(m.name)) && !skipped.has(m.name) + ) as SourceMigration[]; +}; diff --git a/packages/gamut-codemods/src/lib/types.ts b/packages/gamut-codemods/src/lib/types.ts new file mode 100644 index 0000000000..76064080ca --- /dev/null +++ b/packages/gamut-codemods/src/lib/types.ts @@ -0,0 +1,96 @@ +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; + /* + Checked once per run, before any file is touched. Return a reason to + skip this migration for the whole run (the CLI prints it and notes + `skipped:`), or null to run it. + */ + skipReason?(roots: string[], manifest: Manifest): string | null; +} + +/* + 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, .mdx). */ +export interface FileMigration extends MigrationBase { + kind: 'file'; + match(path: string): boolean; + run(ctx: { + source: string; + 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; +} + +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/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..69f1682399 --- /dev/null +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -0,0 +1,15 @@ +import type { Preset } from '../../lib/types'; +import { manifest } from './manifest'; + +export const scopeSwap: Preset = { + name: 'scope-swap', + description: 'Move from @codecademy/gamut* to @skillsoft/gamut*.', + manifest, + migrations: [], + checklist: [ + 'Run your formatter and lint autofix (prettier --write, then eslint --fix). Split imports come out in recast style, `@skillsoft` sorts differently from `@codecademy` for import-order rules like simple-import-sort, and the shorter scope lets some wrapped imports fit on one line.', + 'Reinstall (yarn), then type-check and run your tests.', + 'Work through the warnings above and the leftovers list.', + ], + conditionalChecklist: {}, +}; 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..6802bda554 --- /dev/null +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -0,0 +1,35 @@ +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. +*/ + +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: {}, + + targetVersions: {}, + + sourceVersions: {}, + + movedExports: [], + + deepImports: [], + + 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..e1e60939f0 --- /dev/null +++ b/packages/gamut-codemods/src/transform.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { API, FileInfo } from 'jscodeshift'; + +import { + activeSourceMigrations, + mentionsOldNames, + runSourceMigrations, +} from './lib/run-source'; +import type { Locatable } from './lib/types'; +import { getPreset } from './presets'; + +/* + 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) + skip: comma-separated migration names the CLI ruled out for this + run (see Migration.skipReason) + 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; + skip?: string; + notesDir?: string; + onWarn?: (message: string) => void; +} + +export default function transform( + file: FileInfo, + api: API, + options: TransformOptions = {} +) { + const preset = getPreset(options.preset ?? 'scope-swap'); + const { manifest } = preset; + if (!mentionsOldNames(manifest, file.source)) return undefined; + + 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); + }; + + const source = runSourceMigrations({ + j: api.jscodeshift, + source: file.source, + migrations: activeSourceMigrations( + preset.migrations, + options.only, + options.skip + ), + manifest, + warnFor, + note, + }); + + 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 a9ef908690..56c2d68611 100644 --- a/yarn.lock +++ b/yarn.lock @@ -400,7 +400,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.29.0": +"@babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.7, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.29.0": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -445,6 +445,15 @@ __metadata: languageName: node linkType: hard +"@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.29.7" + checksum: 10c0/c56536b52d17632d89d49db2063ed6102f0e3bbadf6a0ccb74e6599d6a77173b644c7fe8c3ef17c7a162709d55b75ee5145ef6db917d16ba7f375fbffcf2e942 + languageName: node + linkType: hard + "@babel/helper-builder-binary-assignment-operator-visitor@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-builder-binary-assignment-operator-visitor@npm:7.25.9" @@ -485,6 +494,23 @@ __metadata: languageName: node linkType: hard +"@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.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/75f34905b5e708b473f1e9b33e07b2fcc8f4c60676df8bc74541bb91c77f387c32a948dd04d5071e469ba454d72d0a872e3ace40fbb1d1e7aaa8569efcf09ed4 + languageName: node + linkType: hard + "@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-create-regexp-features-plugin@npm:7.25.9" @@ -530,6 +556,16 @@ __metadata: languageName: node linkType: hard +"@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.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/eef7940ce0797208854a5af1049a98fee9abbffb5c619640c69ff5a555f8e3552295bb18756490b02bc6af7df8c1babcb83f12203aac2deb9dfecfc78846e12d + languageName: node + linkType: hard + "@babel/helper-module-imports@npm:^7.16.7, @babel/helper-module-imports@npm:^7.18.6, @babel/helper-module-imports@npm:^7.25.9, @babel/helper-module-imports@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-module-imports@npm:7.29.7" @@ -562,6 +598,15 @@ __metadata: languageName: node linkType: hard +"@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.29.7" + checksum: 10c0/fd0244b9bfbb487db02d59aa2703c6991d654ea5f3f39d912682842bdca2e87b5ae8643b0ce8069bf5fbee39d1aa9db7abefeb5e6ba1aa650dca12777cf5b7e2 + languageName: node + linkType: hard + "@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.22.5, @babel/helper-plugin-utils@npm:^7.25.9, @babel/helper-plugin-utils@npm:^7.27.1, @babel/helper-plugin-utils@npm:^7.29.7, @babel/helper-plugin-utils@npm:^7.8.0": version: 7.29.7 resolution: "@babel/helper-plugin-utils@npm:7.29.7" @@ -595,6 +640,19 @@ __metadata: languageName: node linkType: hard +"@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.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/1c7ae37797f226e965ab85f6affa53d25a10c169c604a4daeb36f9df09e673471e6522f631c13761cf9fbafeca2ea14c241dea8d723a51039d561beb01d86ac4 + 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" @@ -615,6 +673,16 @@ __metadata: languageName: node linkType: hard +"@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.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/8c59493621487fc491f27adfc200af82a6aca3b9a5511e4e6050f8716593b4b243472cb56c8d2016e828b7ae12d605a819205aa8600ca08ee291dcd58d65c832 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-string-parser@npm:7.29.7" @@ -804,6 +872,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" @@ -1041,6 +1120,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-class-properties@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-class-properties@npm:7.29.7" + dependencies: + "@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/c370700423439aa9f0c1f8c4b97f2ef7c2dc46a1b04ec3b10e83e6bae5e4e2159f56d8e4376c9d669b3cf827650cc3740170a36e3924e3e9970d27fd85f4e48a + languageName: node + linkType: hard + "@babel/plugin-transform-class-static-block@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-transform-class-static-block@npm:7.26.0" @@ -1161,6 +1252,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" @@ -1242,6 +1345,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-commonjs@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7" + dependencies: + "@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/9791cb524438b2a8ba6cb8715788fa1e202fbecd4e76b3ccab0af0819fd69212b40ae30d72ac377012f7149889f7792ed8bf91e97bbe9113ca9641f8ad3bf332 + 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" @@ -1304,6 +1419,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/b0c186fe38bc66830e1be76f06fabbae8a655d3896a841ba5ffa12d6c40bb9c8a6ecd38a7e2196034b1d7470653109b1ceac1ef5e46a5fdc9291d14afa56e0d0 + 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" @@ -1362,6 +1488,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-optional-chaining@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7" + dependencies: + "@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/71feacf9a7083030f4c69bf4e91db75f2fceae28e58c58b63db040006d908e15bc1e85a464f24ad659f17702e91a64eabbff9f1dd555ba33d78bb1af8a1d697a + 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" @@ -1385,6 +1523,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-private-methods@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-private-methods@npm:7.29.7" + dependencies: + "@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/d0dc12fa478d05e346dfb02fad2ed99b308d9a7324bada71530a62dc1ccbf07b4c2581ac677b7196b3994f191ef1922199e2c8f33957b726e2019ce07b4ced0f + 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" @@ -1751,6 +1901,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" @@ -1795,6 +1958,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" @@ -7631,6 +7809,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" @@ -8962,6 +9152,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" @@ -11542,6 +11742,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" @@ -14115,6 +14326,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" @@ -14164,6 +14386,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" @@ -14218,6 +14449,22 @@ __metadata: languageName: node linkType: hard +"flow-estree@npm:0.335.0": + version: 0.335.0 + resolution: "flow-estree@npm:0.335.0" + checksum: 10c0/e7289495d86aabc0cec83e88d04ca294ea820061dbda0b171fb112b29ccb318533e2822f1b9aa8de0a590d039d7ac013ca7dc6d4f50a367de96776976b1458a3 + languageName: node + linkType: hard + +"flow-parser@npm:0.*": + version: 0.335.0 + resolution: "flow-parser@npm:0.335.0" + dependencies: + flow-estree: "npm:0.335.0" + checksum: 10c0/79e7896a6591dac44a9a63c6fab6945bab4dd55cc665d923b596b9f72416d8fae8d25df1fb74932c96ad374e7b927044f5c8adb1f42f2658bf7668bc44f2a25c + languageName: node + linkType: hard + "focus-lock@npm:^1.3.6": version: 1.3.6 resolution: "focus-lock@npm:1.3.6" @@ -16048,6 +16295,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" @@ -16215,6 +16471,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" @@ -16854,6 +17117,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" @@ -17025,7 +17321,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 @@ -17311,6 +17607,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" @@ -17539,6 +17845,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" @@ -18876,7 +19192,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 @@ -19637,7 +19953,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: @@ -19664,6 +19980,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" @@ -19893,6 +20218,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" @@ -20011,6 +20343,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" @@ -20027,13 +20366,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" @@ -21007,6 +21355,19 @@ __metadata: languageName: node linkType: hard +"recast@npm:^0.23.11": + 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/afee4673fda5a95b0db8199a3f75c6f675269f00328a3673f241a6088c99b5a58f7082b6cff683456a182e3302bfb57313ebb1c2073228b260724b0993ff1f9a + languageName: node + linkType: hard + "recast@npm:^0.23.5": version: 0.23.9 resolution: "recast@npm:0.23.9" @@ -22075,6 +22436,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" @@ -22177,6 +22547,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" @@ -22635,7 +23014,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: @@ -23591,6 +23970,13 @@ __metadata: languageName: node linkType: hard +"tmp@npm:^0.2.3": + version: 0.2.7 + resolution: "tmp@npm:0.2.7" + checksum: 10c0/59eb55584f2f07210d3231b6a1f6b5c2b9794d8a7b509c8ee867ed2acad6d2245ee2448b7937b676ffbff3155a70077edde8a69f9d7cf0f90c86a62e8910c357 + languageName: node + linkType: hard + "tmp@npm:~0.2.1": version: 0.2.3 resolution: "tmp@npm:0.2.3" From 5a91398cb1fb72866237dfcb6e7120920f80d96b Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 09:48:11 -0400 Subject: [PATCH 2/8] feat(gamut-codemods): rename packages and replace gamut-kit in scope-swap scope-rename swaps @codecademy/gamut* and eslint-plugin-gamut for their @skillsoft names in every module position and in comments. package-json, mdx-imports, and yarnrc cover dependencies, MDX ESM blocks, and .yarnrc.yml lists. @codecademy/gamut-kit has no replacement, so package-json and mf-shared expand it into the packages it bundled. In Module Federation config that makes Gamut a real singleton, which the checklist calls out. Co-Authored-By: Claude Opus 5.5 --- .changeset/gamut-codemods-scope-rename.md | 5 + packages/gamut-codemods/README.md | 9 +- .../__testfixtures__/end-to-end.input.tsx | 5 + .../__testfixtures__/end-to-end.output.tsx | 5 + .../gamut-codemods/src/__tests__/cli.test.ts | 31 ++++- .../gamut-codemods/src/__tests__/lib.test.ts | 7 +- .../__testfixtures__/story.input.mdx | 16 +++ .../__testfixtures__/story.output.mdx | 16 +++ .../__testfixtures__/unterminated.input.mdx | 4 + .../__testfixtures__/unterminated.output.mdx | 4 + .../src/migrations/mdx-imports/index.ts | 63 ++++++++++ .../__testfixtures__/already-shared.input.ts | 11 ++ .../__testfixtures__/already-shared.output.ts | 19 +++ .../__testfixtures__/computed-key.input.ts | 5 + .../__testfixtures__/computed-key.output.ts | 5 + .../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 ++++++++++++++++++ .../already-renamed.input.json | 9 ++ .../already-renamed.output.json | 12 ++ .../__testfixtures__/kit.input.json | 11 ++ .../__testfixtures__/kit.output.json | 16 +++ .../__testfixtures__/kit.warnings.json | 1 + .../newer-than-source.input.json | 7 ++ .../newer-than-source.output.json | 7 ++ .../newer-than-source.warnings.json | 1 + .../__testfixtures__/peers.input.json | 7 ++ .../__testfixtures__/peers.output.json | 7 ++ .../src/migrations/package-json/index.ts | 95 +++++++++++++++ .../__testfixtures__/comments.input.tsx | 12 ++ .../__testfixtures__/comments.output.tsx | 12 ++ .../__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 ++ .../__testfixtures__/require-forms.input.ts | 3 + .../__testfixtures__/require-forms.output.ts | 3 + .../src/migrations/scope-rename/index.ts | 109 +++++++++++++++++ .../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 | 23 +++- .../src/presets/scope-swap/manifest.ts | 68 ++++++++++- 47 files changed, 869 insertions(+), 14 deletions(-) create mode 100644 .changeset/gamut-codemods-scope-rename.md 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/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/__testfixtures__/unterminated.input.mdx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.output.mdx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/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__/computed-key.input.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.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/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__/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/__testfixtures__/newer-than-source.input.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.output.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.warnings.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/package-json/index.ts create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.output.tsx 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/__testfixtures__/require-forms.input.ts create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.output.ts create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/index.ts 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/.changeset/gamut-codemods-scope-rename.md b/.changeset/gamut-codemods-scope-rename.md new file mode 100644 index 0000000000..3a6a13605f --- /dev/null +++ b/.changeset/gamut-codemods-scope-rename.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut-codemods': minor +--- + +`scope-swap` now renames every `@codecademy/gamut*` package and `eslint-plugin-gamut` to `@skillsoft/*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, import types, and package names in comments, `package.json`, `.mdx` ESM blocks, and `.yarnrc.yml` lists. `@codecademy/gamut-kit` has no `@skillsoft` replacement, so it's replaced with the packages it bundled, in `package.json` and in Module Federation `shared` config. That makes Gamut a real singleton across host and remotes, so the run ends with a reminder to deploy them together. Dependencies on a newer upstream release than `@skillsoft` has caught up to get a warning. diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md index aabf663a72..fb4ab06c5e 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -54,8 +54,13 @@ Every skillsoft-gamut pull request publishes installable previews through pkg.pr ### What scope-swap changes -| Migration | What it does | -| --------- | ------------ | +| Migration | What it does | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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, and package names in comments (`/dist` paths and removed packages are left for the leftovers). | +| `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. | +| `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__/__testfixtures__/end-to-end.input.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx new file mode 100644 index 0000000000..d3ece33271 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx @@ -0,0 +1,5 @@ +import { Box } from '@codecademy/gamut'; +import { theme } from '@codecademy/gamut-styles'; + +export const Thing = () => ; +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..3c06199eab --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx @@ -0,0 +1,5 @@ +import { Box } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; + +export const Thing = () => ; +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__/cli.test.ts b/packages/gamut-codemods/src/__tests__/cli.test.ts index 5c18886764..d17bc79f16 100644 --- a/packages/gamut-codemods/src/__tests__/cli.test.ts +++ b/packages/gamut-codemods/src/__tests__/cli.test.ts @@ -142,12 +142,16 @@ describe('main: refusing to write', () => { }); describe('main: full run', () => { - it('lists leftovers and prints the checklist', async () => { + it('rewrites sources, manifests, and docs, then prints the checklist', async () => { const dir = project({ 'src/Post.tsx': POST, + 'docs/story.mdx': "import { Box } from '@codecademy/gamut';\n\n# Hi\n", 'package.json': JSON.stringify({ name: 'app', - dependencies: { '@codecademy/gamut': '^73.0.0' }, + dependencies: { + '@codecademy/gamut': '^73.0.0', + '@codecademy/gamut-kit': '^73.0.0', + }, }), 'jest.config.js': "module.exports = { moduleNameMapper: { '^@codecademy/gamut/(.*)$': '/x/$1' } };\n", @@ -155,13 +159,17 @@ describe('main: full run', () => { expect(await main(['scope-swap', dir, '--force'])).toBe(0); + expect(read(dir, 'src/Post.tsx')).toContain("from '@skillsoft/gamut'"); + expect(read(dir, 'docs/story.mdx')).toContain("'@skillsoft/gamut'"); + expect(read(dir, 'package.json')).toContain('"@skillsoft/gamut"'); + expect(read(dir, 'package.json')).not.toContain('gamut-kit'); + const out = output(); expect(out).toMatch(/file\(s\) changed/); expect(out).toMatch(/leftovers \(\d+\)/); - expect(out).toMatch(/Post\.tsx:1/); - expect(out).toMatch(/package\.json:1/); expect(out).toMatch(/jest\.config\.js:1/); expect(out).toMatch(/next steps/); + expect(out).toMatch(/gamut-kit is gone/); expect(out).toMatch(/Reinstall \(yarn\)/); }); @@ -173,6 +181,7 @@ describe('main: full run', () => { expect(await main(['scope-swap', dir, '--dry'])).toBe(0); expect(read(dir, 'src/Post.tsx')).toBe(POST); expect(read(dir, 'package.json')).toBe(pkg); + expect(output()).toMatch(/would update .*package\.json/); expect(output()).toMatch(/skipped in --dry/); expect(output()).not.toMatch(/next steps/); }); @@ -184,6 +193,20 @@ describe('main: full run', () => { expect(output()).not.toMatch(/leftovers skipped/); }); + it('runs only the named migrations with --only', async () => { + const dir = project({ + 'src/Post.tsx': POST, + 'package.json': JSON.stringify({ + dependencies: { '@codecademy/gamut': '^73.0.0' }, + }), + }); + expect( + await main(['scope-swap', dir, '--force', '--only=package-json']) + ).toBe(0); + expect(read(dir, 'src/Post.tsx')).toBe(POST); + expect(read(dir, 'package.json')).toContain('"@skillsoft/gamut"'); + }); + it('returns 1 when the source runner reports errors', async () => { mockRun.mockResolvedValueOnce({ error: 1 }); const dir = project({ 'src/Post.tsx': POST }); diff --git a/packages/gamut-codemods/src/__tests__/lib.test.ts b/packages/gamut-codemods/src/__tests__/lib.test.ts index 5c821d40e1..f4d9cd1b6e 100644 --- a/packages/gamut-codemods/src/__tests__/lib.test.ts +++ b/packages/gamut-codemods/src/__tests__/lib.test.ts @@ -84,12 +84,13 @@ describe('findLeftovers', () => { "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]); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1, 4]); }); - it('tags every old package the manifest replaces', () => { + it('tags every old package the manifest replaces, including gamut-kit', () => { const dir = tmp(); const old = [ ...Object.keys(manifest.packages), @@ -119,7 +120,7 @@ describe('findLeftovers', () => { it('reads editor config and extensionless scripts', () => { const dir = tmp(); - const line = "require('@codecademy/gamut/package.json');"; + const line = "require('@codecademy/gamut-kit/package.json');"; fs.writeFileSync( path.join(dir, 'sync-deps'), `#!/usr/bin/env node\n${line}` 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..74c89ceda0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx @@ -0,0 +1,16 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Box } from '@codecademy/gamut'; +import { theme } from '@codecademy/gamut-styles'; +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..9631c38c47 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx @@ -0,0 +1,16 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Box } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; +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__/unterminated.input.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.input.mdx new file mode 100644 index 0000000000..db0db1b105 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.input.mdx @@ -0,0 +1,4 @@ +import { + Text, + +Some prose that never closes the import. diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.output.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.output.mdx new file mode 100644 index 0000000000..db0db1b105 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.output.mdx @@ -0,0 +1,4 @@ +import { + Text, + +Some prose that never closes the import. 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/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__/computed-key.input.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.input.ts new file mode 100644 index 0000000000..0d890b8874 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.input.ts @@ -0,0 +1,5 @@ +const key = '@codecademy/gamut'; +export const shared = { + [key]: { singleton: true }, + 123: { singleton: true }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.output.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.output.ts new file mode 100644 index 0000000000..0d890b8874 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.output.ts @@ -0,0 +1,5 @@ +const key = '@codecademy/gamut'; +export const shared = { + [key]: { singleton: true }, + 123: { 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/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__/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/__testfixtures__/newer-than-source.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.input.json new file mode 100644 index 0000000000..23e11d6c43 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.input.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "dependencies": { + "@codecademy/gamut": "74.0.0", + "@codecademy/gamut-styles": "^21.2.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.output.json new file mode 100644 index 0000000000..780d3a617b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.output.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "dependencies": { + "@skillsoft/gamut": "^{{version}}", + "@skillsoft/gamut-styles": "^{{version}}" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.warnings.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.warnings.json new file mode 100644 index 0000000000..9fe9ebdcc7 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.warnings.json @@ -0,0 +1 @@ +["'@codecademy/gamut@74.0.0' is newer than 73.6.1"] 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 new file mode 100644 index 0000000000..24c14cb656 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/index.ts @@ -0,0 +1,95 @@ +import { isNewerThan } from '../../lib/manifest'; +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. + + 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. + + A range newer than manifest.sourceVersions gets a warning: the code may + already use APIs that only exist upstream, which no rename can fix. +*/ + +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) { + 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}` + ); + note(`removed:${removed}`); + touched = true; + } + + for (const [from, { to }] of Object.entries(manifest.packages)) { + if (!(from in next)) continue; + const range = next[from]; + const covered = manifest.sourceVersions[from]; + if (covered && isNewerThan(range, covered)) { + warn( + `${field}: '${from}@${range}' is newer than ${covered}, the last release ${to} matches. Code written against the newer release may not type-check until ${to} catches up.` + ); + note('newer-than-source'); + } + delete next[from]; + touched = true; + if (to in next) continue; + next[to] = + field === 'peerDependencies' && range === '*' + ? '*' + : manifest.targetVersions[to]; + } + + 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__/comments.input.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.input.tsx new file mode 100644 index 0000000000..bc44b7f3c0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.input.tsx @@ -0,0 +1,12 @@ +/** + * @deprecated + * @see `Disclosure` and `List` from `@codecademy/gamut` + * @example + * import { FillButton } from '@codecademy/gamut'; + * import { theme } from '@codecademy/gamut-styles'; + */ +export const Old = () => null; + +// Built on @codecademy/gamut-kit and @codecademy/gamut/dist/Box/props, see eslint-plugin-gamut. +// Already moved: @skillsoft/gamut and @skillsoft/eslint-plugin-gamut. +export const el =
{/* from @codecademy/variance */}
; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.output.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.output.tsx new file mode 100644 index 0000000000..d1f4e01a73 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.output.tsx @@ -0,0 +1,12 @@ +/** + * @deprecated + * @see `Disclosure` and `List` from `@skillsoft/gamut` + * @example + * import { FillButton } from '@skillsoft/gamut'; + * import { theme } from '@skillsoft/gamut-styles'; + */ +export const Old = () => null; + +// Built on @codecademy/gamut-kit and @codecademy/gamut/dist/Box/props, see @skillsoft/eslint-plugin-gamut. +// Already moved: @skillsoft/gamut and @skillsoft/eslint-plugin-gamut. +export const el =
{/* from @skillsoft/variance */}
; 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/__testfixtures__/require-forms.input.ts b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.input.ts new file mode 100644 index 0000000000..e3383b9181 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.input.ts @@ -0,0 +1,3 @@ +import gamut = require('@codecademy/gamut'); +export const resolved = require.resolve('@codecademy/gamut/Video'); +export { gamut }; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.output.ts b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.output.ts new file mode 100644 index 0000000000..885aecb532 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.output.ts @@ -0,0 +1,3 @@ +import gamut = require('@skillsoft/gamut'); +export const resolved = require.resolve('@skillsoft/gamut/Video'); +export { gamut }; 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..0c29583d10 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/index.ts @@ -0,0 +1,109 @@ +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. + + Package names in comments (JSDoc `@see`, usage examples) are renamed as + well. `/dist` paths and removed packages are left for the leftovers + report, since there's no name that's right for them. +*/ + +interface CommentNode { + value: string; + start: number; + end: number; +} + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + +/* Old names, not part of a longer name and not followed by `/dist`. */ +const commentPattern = (from: string[]) => + new RegExp( + `(? b.length - a.length) + .map(escape) + .join('|')})(?![\\w-])(?!/dist(?![\\w-]))`, + 'g' + ); + +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)); + + const pattern = commentPattern(Object.keys(manifest.packages)); + 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); + for (const match of comment.value.matchAll(pattern)) { + const next = manifest.packages[match[0]]?.to; + if (!next) continue; + /* +2 skips the `//` or `/*` that `value` leaves out. */ + const start = comment.start + 2 + (match.index ?? 0); + edits.push({ start, end: start + match[0].length, text: next }); + } + } + }); + + 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/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 69f1682399..3f5d5d99ba 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/index.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -1,15 +1,34 @@ import type { Preset } from '../../lib/types'; +import { mdxImports } from '../../migrations/mdx-imports'; +import { mfShared } from '../../migrations/mf-shared'; +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 = { name: 'scope-swap', description: 'Move from @codecademy/gamut* to @skillsoft/gamut*.', manifest, - migrations: [], + migrations: [ + mfShared, + /* Last: everything above matches on @codecademy names. */ + scopeRename, + packageJson, + mdxImports, + yarnrc, + ], checklist: [ 'Run your formatter and lint autofix (prettier --write, then eslint --fix). Split imports come out in recast style, `@skillsoft` sorts differently from `@codecademy` for import-order rules like simple-import-sort, and the shorter scope lets some wrapped imports fit on one line.', 'Reinstall (yarn), then type-check and run your tests.', 'Work through the warnings above and the leftovers list.', ], - conditionalChecklist: {}, + 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 @emotion/cache and stylis peer dependencies, so add them if yarn warns that they aren't provided.", + 'newer-than-source': + 'Some @codecademy packages were on a newer release than @skillsoft has caught up to (see the package-json warnings). Expect type errors wherever the code uses what that release added, and check the upstream CHANGELOG for it.', + '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 6802bda554..6605d88983 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import type { Manifest } from '../../lib/manifest'; /* @@ -9,6 +12,16 @@ import type { Manifest } from '../../lib/manifest'; 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' }, @@ -21,11 +34,60 @@ export const manifest: Manifest = { 'eslint-plugin-gamut': { to: '@skillsoft/eslint-plugin-gamut' }, }, - removedPackages: {}, + 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: {}, + 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', + }, - sourceVersions: {}, + /* + The upstream @codecademy release each package was forked from (the top + of its CHANGELOG.md before any @skillsoft entries). Bump these when + upstream changes are synced in. + */ + sourceVersions: { + '@codecademy/gamut': '73.6.1', + '@codecademy/gamut-styles': '21.2.0', + '@codecademy/gamut-icons': '10.2.0', + '@codecademy/gamut-illustrations': '1.1.0', + '@codecademy/gamut-patterns': '1.1.0', + '@codecademy/gamut-tests': '7.1.0', + '@codecademy/variance': '1.1.0', + 'eslint-plugin-gamut': '3.1.0', + }, movedExports: [], From 195e6d66bf3f3342dab9fa8054fcb239d340b66e Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 09:53:23 -0400 Subject: [PATCH 3/8] feat(gamut)!: promote deep imports to the root, renaming Menu and SelectDropdown elements Exports formFieldStyles, formFieldPaddingStyles, conditionalStyles, ButtonBaseProps, HTMLToReactNode, MenuToolTipWrapper, and Menu's list elements from the root. The list elements and SelectDropdown's option component are renamed (MenuList*, MenuListButton, IconOptionComponent) so they don't collide with the public List component and IconOption type. The deep-imports codemod rewrites known /dist/ paths to the root, keeps local names across renames, merges into existing imports, and warns on paths with no public replacement. Co-Authored-By: Claude Opus 5.5 --- .changeset/gamut-codemods-deep-imports.md | 5 + .changeset/gamut-form-field-styles.md | 5 + .../gamut-menu-selectdropdown-renames.md | 15 ++ .changeset/gamut-root-types.md | 5 + packages/gamut-codemods/README.md | 15 +- .../end-to-end-deep-imports.input.tsx | 5 + .../end-to-end-deep-imports.output.tsx | 3 + .../export-namespace.input.tsx | 1 + .../export-namespace.output.tsx | 1 + .../export-unrenamed.input.tsx | 1 + .../export-unrenamed.output.tsx | 1 + .../__testfixtures__/form-styles.input.tsx | 4 + .../__testfixtures__/form-styles.output.tsx | 3 + .../form-styles.warnings.json | 1 + .../__testfixtures__/merge.input.tsx | 8 + .../__testfixtures__/merge.output.tsx | 5 + .../__testfixtures__/merge.warnings.json | 1 + .../__testfixtures__/mocks-unmapped.input.tsx | 5 + .../mocks-unmapped.output.tsx | 5 + .../mocks-unmapped.warnings.json | 4 + .../__testfixtures__/renames.input.tsx | 6 + .../__testfixtures__/renames.output.tsx | 10 + .../__testfixtures__/renames.warnings.json | 1 + .../__testfixtures__/rewrites.input.tsx | 7 + .../__testfixtures__/rewrites.output.tsx | 7 + .../__testfixtures__/rewrites.warnings.json | 1 + .../src/migrations/deep-imports/index.ts | 218 ++++++++++++++++++ .../src/presets/scope-swap/index.ts | 2 + .../src/presets/scope-swap/manifest.ts | 101 +++++++- .../__snapshots__/gamut.test.ts.snap | 9 + packages/gamut/src/Button/index.ts | 2 +- .../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/Form/index.tsx | 9 + packages/gamut/src/Markdown/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 + 41 files changed, 503 insertions(+), 44 deletions(-) create mode 100644 .changeset/gamut-codemods-deep-imports.md create mode 100644 .changeset/gamut-form-field-styles.md create mode 100644 .changeset/gamut-menu-selectdropdown-renames.md create mode 100644 .changeset/gamut-root-types.md create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end-deep-imports.input.tsx create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end-deep-imports.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-namespace.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-namespace.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-unrenamed.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-unrenamed.output.tsx 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 create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.warnings.json 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 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 diff --git a/.changeset/gamut-codemods-deep-imports.md b/.changeset/gamut-codemods-deep-imports.md new file mode 100644 index 0000000000..9c62c709cd --- /dev/null +++ b/.changeset/gamut-codemods-deep-imports.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut-codemods': minor +--- + +`scope-swap` now rewrites `/dist/` deep imports that have a public replacement to the package root, and merges them into an existing import from the same package. Renamed exports keep their local name, so `import { List } from '@codecademy/gamut/dist/Menu/elements'` becomes `import { MenuList as List } from '@skillsoft/gamut'`. Imports of names that stayed internal, deep imports with no public replacement (`ButtonBase`), unknown deep imports, and deep paths in mocks or `require()` get a warning. 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/.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/.changeset/gamut-root-types.md b/.changeset/gamut-root-types.md new file mode 100644 index 0000000000..a60f927738 --- /dev/null +++ b/.changeset/gamut-root-types.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut': patch +--- + +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 fb4ab06c5e..33d318c1c6 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -54,13 +54,14 @@ Every skillsoft-gamut pull request publishes installable previews through pkg.pr ### What scope-swap changes -| Migration | What it does | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `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, and package names in comments (`/dist` paths and removed packages are left for the leftovers). | -| `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. | -| `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. A rewritten import is merged into an existing named import from the same package. Also warns on deep imports with no public replacement (`ButtonBase/ButtonBase`) and on ones it doesn't know. | +| `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. | +| `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, and package names in comments (`/dist` paths and removed packages are left for the leftovers). | +| `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. | +| `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__/__testfixtures__/end-to-end-deep-imports.input.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end-deep-imports.input.tsx new file mode 100644 index 0000000000..457f6b3a07 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end-deep-imports.input.tsx @@ -0,0 +1,5 @@ +import { Box } from '@codecademy/gamut'; +import { PopoverContainerProps } from '@codecademy/gamut/dist/PopoverContainer/types'; +import { List } from '@codecademy/gamut/dist/Menu/elements'; + +export const Thing = (p: PopoverContainerProps) => ; diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end-deep-imports.output.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end-deep-imports.output.tsx new file mode 100644 index 0000000000..cfb308a2cb --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end-deep-imports.output.tsx @@ -0,0 +1,3 @@ +import { Box, PopoverContainerProps, MenuList as List } from '@skillsoft/gamut'; + +export const Thing = (p: PopoverContainerProps) => ; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-namespace.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-namespace.input.tsx new file mode 100644 index 0000000000..ea78e5f00c --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-namespace.input.tsx @@ -0,0 +1 @@ +export * as Menu from '@codecademy/gamut/dist/Menu/elements'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-namespace.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-namespace.output.tsx new file mode 100644 index 0000000000..ee558bb537 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-namespace.output.tsx @@ -0,0 +1 @@ +export * as Menu from '@codecademy/gamut'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-unrenamed.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-unrenamed.input.tsx new file mode 100644 index 0000000000..be42f12b6d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-unrenamed.input.tsx @@ -0,0 +1 @@ +export { MenuIcon, ListItem } from '@codecademy/gamut/dist/Menu/elements'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-unrenamed.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-unrenamed.output.tsx new file mode 100644 index 0000000000..00eb532465 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/export-unrenamed.output.tsx @@ -0,0 +1 @@ +export { MenuIcon, MenuListItem as ListItem } from '@codecademy/gamut'; 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..4bc7534bda --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/form-styles.output.tsx @@ -0,0 +1,3 @@ +import { conditionalStyles, formFieldStyles, 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__/merge.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.input.tsx new file mode 100644 index 0000000000..50ad1aeb75 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.input.tsx @@ -0,0 +1,8 @@ +import { Box, FlexBox, List, Text } from '@codecademy/gamut'; +import { ListItem } from '@codecademy/gamut/dist/Menu/elements'; +import type { BoxProps } from '@codecademy/gamut'; +import type { PopoverContainerProps } from '@codecademy/gamut/dist/PopoverContainer/types'; +import { Box as Again } from '@codecademy/gamut/dist/Box/props'; +import * as Gamut from '@codecademy/gamut'; +import { variance } from '@codecademy/variance'; +import { Prop } from '@codecademy/variance/dist/types/config'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.output.tsx new file mode 100644 index 0000000000..3ed5ef37eb --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.output.tsx @@ -0,0 +1,5 @@ +import { Box, FlexBox, List, Text, MenuListItem as ListItem } from '@codecademy/gamut'; +import type { BoxProps, PopoverContainerProps } from '@codecademy/gamut'; +import { Box as Again } from '@codecademy/gamut/dist/Box/props'; +import * as Gamut from '@codecademy/gamut'; +import { variance, Prop } from '@codecademy/variance'; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.warnings.json new file mode 100644 index 0000000000..e75a27f408 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.warnings.json @@ -0,0 +1 @@ +["is only partly public"] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.input.tsx new file mode 100644 index 0000000000..e812781efe --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.input.tsx @@ -0,0 +1,5 @@ +import { Box } from '@codecademy/gamut/dist/Box'; + +jest.mock('@codecademy/gamut/dist/Nope/thing', () => ({})); +const button = require('@codecademy/gamut/dist/ButtonBase/ButtonBase'); +export const B = [Box, button]; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.output.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.output.tsx new file mode 100644 index 0000000000..e812781efe --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.output.tsx @@ -0,0 +1,5 @@ +import { Box } from '@codecademy/gamut/dist/Box'; + +jest.mock('@codecademy/gamut/dist/Nope/thing', () => ({})); +const button = require('@codecademy/gamut/dist/ButtonBase/ButtonBase'); +export const B = [Box, button]; diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.warnings.json new file mode 100644 index 0000000000..a953128956 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/mocks-unmapped.warnings.json @@ -0,0 +1,4 @@ +[ + "'@codecademy/gamut/dist/Nope/thing' in a mock or require() needs a manual rewrite", + "'@codecademy/gamut/dist/ButtonBase/ButtonBase' in a mock or require() needs a manual rewrite" +] 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..10ed954ccc --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.output.tsx @@ -0,0 +1,10 @@ +import { + MenuList as List, + MenuListItem as Item, + type MenuListProps as ListProps, + 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..6b61771448 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/renames.warnings.json @@ -0,0 +1 @@ +["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.input.tsx b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.input.tsx new file mode 100644 index 0000000000..3fad9f3b4b --- /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 { 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 new file mode 100644 index 0000000000..d882b3d9f0 --- /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 { 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 new file mode 100644 index 0000000000..99cf5b0f92 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/rewrites.warnings.json @@ -0,0 +1 @@ +["'@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 new file mode 100644 index 0000000000..0f4ac866d2 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/deep-imports/index.ts @@ -0,0 +1,218 @@ +import type { + Collection, + ExportSpecifier, + ImportDeclaration, + 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; + }); + +/* Only plain `{ a, b as c }` imports can be merged; defaults and `* as` can't. */ +const namedOnly = (node: ImportDeclaration) => + (node.specifiers ?? []).every((spec) => spec.type === 'ImportSpecifier'); + +const mergeKey = (node: ImportDeclaration) => + `${String(node.source.value)}\0${node.importKind ?? 'value'}`; + +type NamedSpecifier = ImportSpecifier & { importKind?: string }; + +const specifierKey = (spec: NamedSpecifier) => + `${spec.importKind ?? 'value'} ${nameOf(spec.imported)} ${nameOf( + spec.local ?? spec.imported + )}`; + +/* + A rewritten deep import often lands on a package the file already + imports from, and two declarations from one source trip + import/no-duplicates. Fold each rewritten declaration into the first + plain named import of the same source and kind. Declarations the file + already had are never merged with each other. +*/ +const mergeRewritten = ( + j: JSCodeshift, + root: Collection, + rewritten: Set +) => { + const targets = new Map(); + root.find(j.ImportDeclaration).forEach((p) => { + const { node } = p; + if (!namedOnly(node)) return; + const key = mergeKey(node); + const target = targets.get(key); + if (!target) { + targets.set(key, node); + return; + } + if (!rewritten.has(node)) return; + + const existing = new Set( + (target.specifiers as NamedSpecifier[]).map(specifierKey) + ); + /* Clone into the target; recast's patcher can crash on moved nodes. */ + const moved = (node.specifiers as NamedSpecifier[]) + .filter((spec) => !existing.has(specifierKey(spec))) + .map((spec) => + Object.assign( + j.importSpecifier( + j.identifier(nameOf(spec.imported)), + j.identifier(nameOf(spec.local ?? spec.imported)) + ), + { importKind: spec.importKind } + ) + ); + target.specifiers = [...(target.specifiers ?? []), ...moved]; + j(p).remove(); + }); +}; + +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(); + const rewritten = 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.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, + node.specifiers, + row.renames + ) as typeof node.specifiers; + } + node.source = j.stringLiteral(row.to); + if (node.type === 'ImportDeclaration') rewritten.add(node); + changed = true; + } + + if (rewritten.size > 0) mergeRewritten(j, root, rewritten); + + /* 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; + } + /* 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; + } + + return changed; + }, +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts index 3f5d5d99ba..04051a5c46 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/index.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -1,4 +1,5 @@ import type { Preset } from '../../lib/types'; +import { deepImports } from '../../migrations/deep-imports'; import { mdxImports } from '../../migrations/mdx-imports'; import { mfShared } from '../../migrations/mf-shared'; import { packageJson } from '../../migrations/package-json'; @@ -11,6 +12,7 @@ export const scopeSwap: Preset = { description: 'Move from @codecademy/gamut* to @skillsoft/gamut*.', manifest, migrations: [ + deepImports, mfShared, /* Last: everything above matches on @codecademy names. */ scopeRename, diff --git a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts index 6605d88983..92b0d8c2f7 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -91,7 +91,106 @@ export const manifest: Manifest = { movedExports: [], - deepImports: [], + /* + 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', + onlyNames: ['BoxProps', 'FlexBoxProps', 'GridBoxProps'], + }, + { + from: '@codecademy/gamut/dist/Tip/shared/types', + 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 + 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/Menu/elements', + 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' }, + onlyNames: ['IconOption'], + }, + { + 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/Form/styles', + 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', + to: '@codecademy/gamut-styles', + }, + { + from: '@codecademy/gamut-icons/dist/types', + to: '@codecademy/gamut-icons', + }, + { + from: '@codecademy/gamut-icons/dist/props', + to: '@codecademy/gamut-icons', + onlyNames: ['GamutIconProps', 'IconStyleProps'], + }, + { + from: '@codecademy/variance/dist/types/config', + to: '@codecademy/variance', + }, + ], eslintPlugin: { from: 'gamut', to: '@skillsoft/gamut' }, }; diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 30593dae53..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", @@ -65,6 +68,7 @@ exports[`Gamut Exported Keys 1`] = ` "GridForm", "GridFormContent", "IconButton", + "IconOptionComponent", "iFrameWrapper", "InfoTip", "Input", @@ -78,7 +82,12 @@ exports[`Gamut Exported Keys 1`] = ` "matchDisabledDates", "Menu", "MenuItem", + "MenuList", + "MenuListButton", + "MenuListItem", + "MenuListLink", "MenuSeparator", + "MenuToolTipWrapper", "Modal", "omitProps", "Overlay", 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/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/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'; diff --git a/packages/gamut/src/Markdown/index.tsx b/packages/gamut/src/Markdown/index.tsx index 544bc6bc5e..9659797567 100644 --- a/packages/gamut/src/Markdown/index.tsx +++ b/packages/gamut/src/Markdown/index.tsx @@ -197,6 +197,7 @@ export class Markdown extends PureComponent { } export type { + HTMLToReactNode, MarkdownOverrideSetting, MarkdownOverrideSettings, } from './libs/overrides'; 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 e6923f5ed3c1d053bf8f9ae13970d93a33baed71 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 11:10:37 -0400 Subject: [PATCH 4/8] fix(gamut-codemods): rename FileMigration.match to appliesTo CodeQL read `migration.match(file)` in cli.ts as String.prototype.match on a command-line argument and flagged js/regex-injection. Nothing builds a regex from the path; the method just had the same name. Co-Authored-By: Claude Opus 5.5 --- packages/gamut-codemods/README.md | 2 +- packages/gamut-codemods/src/cli.ts | 2 +- packages/gamut-codemods/src/lib/types.ts | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md index aabf663a72..8f174671a3 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -90,7 +90,7 @@ Two rules hold it together: - **`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). +- **`file`**: gets raw text for files its `appliesTo()` accepts (package.json, .eslintrc). ### Changing a preset's manifest diff --git a/packages/gamut-codemods/src/cli.ts b/packages/gamut-codemods/src/cli.ts index 1774af75a3..7315dd62a3 100644 --- a/packages/gamut-codemods/src/cli.ts +++ b/packages/gamut-codemods/src/cli.ts @@ -88,7 +88,7 @@ const runFileMigrations = ( const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; for (const file of files) { for (const migration of active) { - if (!migration.match(file)) continue; + if (!migration.appliesTo(file)) continue; const source = fs.readFileSync(file, 'utf8'); const warn = (msg: string) => console.log(`[${migration.name}] ${file} ${msg}`); diff --git a/packages/gamut-codemods/src/lib/types.ts b/packages/gamut-codemods/src/lib/types.ts index 76064080ca..ee3e0690fb 100644 --- a/packages/gamut-codemods/src/lib/types.ts +++ b/packages/gamut-codemods/src/lib/types.ts @@ -66,7 +66,11 @@ export interface SpliceMigration extends MigrationBase { /* Gets raw text for non-source files (package.json, .eslintrc, .mdx). */ export interface FileMigration extends MigrationBase { kind: 'file'; - match(path: string): boolean; + /* + Not `match`: CodeQL reads `migration.match(file)` as + String.prototype.match on a CLI argument and flags it as regex injection. + */ + appliesTo(path: string): boolean; run(ctx: { source: string; manifest: Manifest; From 97d492c4da652cb787c3ac6c30b03e878ac82029 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 11:10:46 -0400 Subject: [PATCH 5/8] fix(gamut-codemods): rename match to appliesTo in this branch's file migrations Co-Authored-By: Claude Opus 5.5 --- packages/gamut-codemods/src/migrations/mdx-imports/index.ts | 2 +- packages/gamut-codemods/src/migrations/package-json/index.ts | 2 +- packages/gamut-codemods/src/migrations/yarnrc/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/index.ts b/packages/gamut-codemods/src/migrations/mdx-imports/index.ts index f98acf3d56..b0e7b34915 100644 --- a/packages/gamut-codemods/src/migrations/mdx-imports/index.ts +++ b/packages/gamut-codemods/src/migrations/mdx-imports/index.ts @@ -23,7 +23,7 @@ export const mdxImports: FileMigration = { kind: 'file', description: 'Run the source migrations over import/export statements in .mdx files.', - match: (file) => file.endsWith('.mdx'), + appliesTo: (file) => file.endsWith('.mdx'), run({ source, transformSource }) { const lines = source.split('\n'); const out: string[] = []; diff --git a/packages/gamut-codemods/src/migrations/package-json/index.ts b/packages/gamut-codemods/src/migrations/package-json/index.ts index 24c14cb656..d01070608d 100644 --- a/packages/gamut-codemods/src/migrations/package-json/index.ts +++ b/packages/gamut-codemods/src/migrations/package-json/index.ts @@ -36,7 +36,7 @@ export const packageJson: FileMigration = { name: 'package-json', kind: 'file', description: 'Rename dependencies in package.json.', - match: (file) => /(^|\/)package\.json$/.test(file), + appliesTo: (file) => /(^|\/)package\.json$/.test(file), run({ source, manifest, warn, note }) { const pkg = JSON.parse(source) as Record; let changed = false; diff --git a/packages/gamut-codemods/src/migrations/yarnrc/index.ts b/packages/gamut-codemods/src/migrations/yarnrc/index.ts index 34074ee969..d3c6931788 100644 --- a/packages/gamut-codemods/src/migrations/yarnrc/index.ts +++ b/packages/gamut-codemods/src/migrations/yarnrc/index.ts @@ -19,7 +19,7 @@ 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), + appliesTo: (file) => /(^|\/)\.yarnrc\.yml$/.test(file), run({ source, manifest }) { const lines = source.split('\n'); const globs = lines From 7cf5c68eb0b902fb91aee9ab45c5476ba8f7e3d8 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 11:29:28 -0400 Subject: [PATCH 6/8] refactor(gamut-codemods): drop the newer-than-source checklist item The package-json warning already names each range that's newer than the fork point, so the extra next step only repeated it. Co-Authored-By: Claude Opus 5.5 --- packages/gamut-codemods/src/migrations/package-json/index.ts | 1 - packages/gamut-codemods/src/presets/scope-swap/index.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/packages/gamut-codemods/src/migrations/package-json/index.ts b/packages/gamut-codemods/src/migrations/package-json/index.ts index d01070608d..de42ad63cf 100644 --- a/packages/gamut-codemods/src/migrations/package-json/index.ts +++ b/packages/gamut-codemods/src/migrations/package-json/index.ts @@ -71,7 +71,6 @@ export const packageJson: FileMigration = { warn( `${field}: '${from}@${range}' is newer than ${covered}, the last release ${to} matches. Code written against the newer release may not type-check until ${to} catches up.` ); - note('newer-than-source'); } delete next[from]; touched = true; diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts index 3f5d5d99ba..f4db213fa3 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/index.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -26,8 +26,6 @@ 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 @emotion/cache and stylis peer dependencies, so add them if yarn warns that they aren't provided.", - 'newer-than-source': - 'Some @codecademy packages were on a newer release than @skillsoft has caught up to (see the package-json warnings). Expect type errors wherever the code uses what that release added, and check the upstream CHANGELOG for it.', '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.", }, From 79fe67853dc35a052a5ba7a7972def78770f4d9e Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 11:46:27 -0400 Subject: [PATCH 7/8] chore(changeset): one changeset for the new gamut root exports Co-Authored-By: Claude Opus 5.5 --- .changeset/gamut-form-field-styles.md | 5 ----- ...u-selectdropdown-renames.md => gamut-root-exports.md} | 9 ++++++++- .changeset/gamut-root-types.md | 5 ----- 3 files changed, 8 insertions(+), 11 deletions(-) delete mode 100644 .changeset/gamut-form-field-styles.md rename .changeset/{gamut-menu-selectdropdown-renames.md => gamut-root-exports.md} (69%) delete mode 100644 .changeset/gamut-root-types.md diff --git a/.changeset/gamut-form-field-styles.md b/.changeset/gamut-form-field-styles.md deleted file mode 100644 index d75fa130a5..0000000000 --- a/.changeset/gamut-form-field-styles.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@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/.changeset/gamut-menu-selectdropdown-renames.md b/.changeset/gamut-root-exports.md similarity index 69% rename from .changeset/gamut-menu-selectdropdown-renames.md rename to .changeset/gamut-root-exports.md index 84bcef371e..e61364305e 100644 --- a/.changeset/gamut-menu-selectdropdown-renames.md +++ b/.changeset/gamut-root-exports.md @@ -12,4 +12,11 @@ | `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. +`MenuToolTipWrapper` is now exported from the root too. The `IconOption` type is unchanged. + +Also new at the root: + +- `formFieldStyles`, `formFieldPaddingStyles`, and `conditionalStyles`, for custom inputs that should look like `Input`, such as third-party hosted payment fields. The rest of `Form/styles` stays internal. +- The `HTMLToReactNode` and `ButtonBaseProps` types. + +Coming from `@codecademy/gamut`, `npx @skillsoft/gamut-codemods scope-swap .` rewrites imports of all of these, including from their old `/dist/` paths. diff --git a/.changeset/gamut-root-types.md b/.changeset/gamut-root-types.md deleted file mode 100644 index a60f927738..0000000000 --- a/.changeset/gamut-root-types.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@skillsoft/gamut': patch ---- - -Export the `HTMLToReactNode` and `ButtonBaseProps` types from the package root. From e893c26c2e7b174c5bc8b79319b08531e967f078 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 28 Oct 2026 10:12:59 -0400 Subject: [PATCH 8/8] mergewarnings --- .../__testfixtures__/merge.warnings.json | 2 +- .../src/migrations/deep-imports/index.ts | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.warnings.json b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.warnings.json index e75a27f408..178e416e6d 100644 --- a/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.warnings.json +++ b/packages/gamut-codemods/src/migrations/deep-imports/__testfixtures__/merge.warnings.json @@ -1 +1 @@ -["is only partly public"] +["is only partly public", "Box is already imported from '@skillsoft/gamut'"] diff --git a/packages/gamut-codemods/src/migrations/deep-imports/index.ts b/packages/gamut-codemods/src/migrations/deep-imports/index.ts index 0f4ac866d2..efde70cd7a 100644 --- a/packages/gamut-codemods/src/migrations/deep-imports/index.ts +++ b/packages/gamut-codemods/src/migrations/deep-imports/index.ts @@ -6,6 +6,7 @@ import type { JSCodeshift, } from 'jscodeshift'; +import { renameSpecifier } from '../../lib/manifest'; import { findModuleStrings, nameOf } from '../../lib/module-strings'; import type { AstMigration } from '../../lib/types'; @@ -59,6 +60,22 @@ const renameSpecifiers = ( const namedOnly = (node: ImportDeclaration) => (node.specifiers ?? []).every((spec) => spec.type === 'ImportSpecifier'); +/* Names the file already imports by name from `source`. */ +const importedNamesFrom = (root: Collection, j: JSCodeshift, source: string) => + new Set( + root + .find(j.ImportDeclaration) + .nodes() + .filter((node) => node.source.value === source) + .flatMap((node) => + (node.specifiers ?? []) + .filter( + (spec): spec is ImportSpecifier => spec.type === 'ImportSpecifier' + ) + .map((spec) => nameOf(spec.imported)) + ) + ); + const mergeKey = (node: ImportDeclaration) => `${String(node.source.value)}\0${node.importKind ?? 'value'}`; @@ -172,6 +189,24 @@ export const deepImports: AstMigration = { ', ' )} isn't exported from '${row.to}'. ${row.note ?? ''}` ); + /* Usually a name the file already imports from the public entry. */ + const alreadyImported = importedNamesFrom(root, j, row.to); + const duplicates = blocked.filter((name) => + alreadyImported.has(name) + ); + /* Warn with the names the run leaves behind, not the old scope. */ + const after = (value: string) => + renameSpecifier(manifest, value) ?? value; + if (duplicates.length > 0) { + warn( + node, + `${duplicates.join(', ')} is already imported from '${after( + row.to + )}' in this file, so this import duplicates it. Drop the '${after( + row.from + )}' import and use the '${after(row.to)}' one.` + ); + } continue; } }