diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/package.json new file mode 100644 index 0000000000000..4da92049f9be5 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/package.json @@ -0,0 +1,10 @@ +{ + "name": "root", + "workspaces": [ + "packages/*" + ], + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {}, + "packageManager": "npm@1.2.3" +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/packages/ui/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/packages/ui/package.json new file mode 100644 index 0000000000000..0a833e899ae58 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/packages/ui/package.json @@ -0,0 +1,6 @@ +{ + "name": "ui", + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/packages/utils/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/packages/utils/package.json new file mode 100644 index 0000000000000..f980397cf1c01 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/correct-names/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "utils", + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/package.json new file mode 100644 index 0000000000000..4da92049f9be5 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/package.json @@ -0,0 +1,10 @@ +{ + "name": "root", + "workspaces": [ + "packages/*" + ], + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {}, + "packageManager": "npm@1.2.3" +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/apps/docs/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/apps/docs/package.json new file mode 100644 index 0000000000000..3abadc5cc44c3 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/apps/docs/package.json @@ -0,0 +1,6 @@ +{ + "name": "@acme/docs", + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/apps/web/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/apps/web/package.json new file mode 100644 index 0000000000000..3abadc5cc44c3 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/apps/web/package.json @@ -0,0 +1,6 @@ +{ + "name": "@acme/docs", + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/ui/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/ui/package.json new file mode 100644 index 0000000000000..fe616c702a22a --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/ui/package.json @@ -0,0 +1,6 @@ +{ + "name": "some-pkg", + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/utils/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/utils/package.json new file mode 100644 index 0000000000000..fe616c702a22a --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/duplicate-names/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "some-pkg", + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/package.json new file mode 100644 index 0000000000000..4da92049f9be5 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/package.json @@ -0,0 +1,10 @@ +{ + "name": "root", + "workspaces": [ + "packages/*" + ], + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {}, + "packageManager": "npm@1.2.3" +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/packages/ui/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/packages/ui/package.json new file mode 100644 index 0000000000000..2e5879a21b308 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/packages/ui/package.json @@ -0,0 +1,5 @@ +{ + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/packages/utils/package.json b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/packages/utils/package.json new file mode 100644 index 0000000000000..2e5879a21b308 --- /dev/null +++ b/packages/turbo-codemod/__tests__/__fixtures__/add-package-names/missing-names/packages/utils/package.json @@ -0,0 +1,5 @@ +{ + "version": "1.0.0", + "dependencies": {}, + "devDependencies": {} +} diff --git a/packages/turbo-codemod/__tests__/add-package-names.test.ts b/packages/turbo-codemod/__tests__/add-package-names.test.ts new file mode 100644 index 0000000000000..ea3b98a20568c --- /dev/null +++ b/packages/turbo-codemod/__tests__/add-package-names.test.ts @@ -0,0 +1,117 @@ +import { setupTestFixtures } from "@turbo/test-utils"; +import { transformer } from "../src/transforms/add-package-names"; + +describe("add-package-names", () => { + const { useFixture } = setupTestFixtures({ + directory: __dirname, + test: "add-package-names", + }); + + test("missing names", async () => { + // load the fixture for the test + const { root, readJson } = useFixture({ + fixture: "missing-names", + }); + + // run the transformer + const result = await transformer({ + root, + options: { force: false, dry: false, print: false }, + }); + + // result should be correct + expect(result.fatalError).toBeUndefined(); + expect(result.changes).toMatchInlineSnapshot(` + Object { + "packages/ui/package.json": Object { + "action": "modified", + "additions": 1, + "deletions": 0, + }, + "packages/utils/package.json": Object { + "action": "modified", + "additions": 1, + "deletions": 0, + }, + } + `); + + // validate unique names + const names = new Set(); + + for (const pkg of ["ui", "utils"]) { + const pkgJson = readJson<{ name: string }>( + `packages/${pkg}/package.json` + ); + expect(pkgJson?.name).toBeDefined(); + expect(names.has(pkgJson?.name)).toBe(false); + names.add(pkgJson?.name); + } + }); + + test("duplicate names", async () => { + // load the fixture for the test + const { root, readJson } = useFixture({ + fixture: "duplicate-names", + }); + + // run the transformer + const result = await transformer({ + root, + options: { force: false, dry: false, print: false }, + }); + + // result should be correct + expect(result.fatalError).toBeUndefined(); + expect(result.changes).toMatchInlineSnapshot(` + Object { + "packages/utils/package.json": Object { + "action": "modified", + "additions": 1, + "deletions": 1, + }, + } + `); + + // validate unique names + const names = new Set(); + + for (const pkg of ["ui", "utils"]) { + const pkgJson = readJson<{ name: string }>( + `packages/${pkg}/package.json` + ); + expect(pkgJson?.name).toBeDefined(); + expect(names.has(pkgJson?.name)).toBe(false); + names.add(pkgJson?.name); + } + }); + + test("correct names", async () => { + // load the fixture for the test + const { root, readJson } = useFixture({ + fixture: "correct-names", + }); + + // run the transformer + const result = await transformer({ + root, + options: { force: false, dry: false, print: false }, + }); + + // result should be correct + expect(result.fatalError).toBeUndefined(); + expect(result.changes).toMatchInlineSnapshot(`Object {}`); + + // validate unique names + const names = new Set(); + + for (const pkg of ["ui", "utils"]) { + const pkgJson = readJson<{ name: string }>( + `packages/${pkg}/package.json` + ); + expect(pkgJson?.name).toBeDefined(); + expect(names.has(pkgJson?.name)).toBe(false); + names.add(pkgJson?.name); + } + }); +}); diff --git a/packages/turbo-codemod/__tests__/generate-package-name.test.ts b/packages/turbo-codemod/__tests__/generate-package-name.test.ts new file mode 100644 index 0000000000000..9a4e210471113 --- /dev/null +++ b/packages/turbo-codemod/__tests__/generate-package-name.test.ts @@ -0,0 +1,29 @@ +import { getNewPkgName } from "../src/transforms/add-package-names"; + +describe("getNewPkgName", () => { + it.each([ + { + pkgPath: "/packages/ui/package.json", + pkgName: "old-name", + expected: "ui-old-name", + }, + // scoped + { + pkgPath: "/packages/ui/package.json", + pkgName: "@acme/name", + expected: "@acme/ui-name", + }, + // no name + { + pkgPath: "/packages/ui/package.json", + pkgName: undefined, + expected: "ui", + }, + ])( + "should return a new package name for pkgPath: $pkgPath and pkgName: $pkgName", + ({ pkgPath, pkgName, expected }) => { + const newName = getNewPkgName({ pkgPath, pkgName }); + expect(newName).toBe(expected); + } + ); +}); diff --git a/packages/turbo-codemod/src/transforms/add-package-names.ts b/packages/turbo-codemod/src/transforms/add-package-names.ts new file mode 100644 index 0000000000000..e25d799291b4e --- /dev/null +++ b/packages/turbo-codemod/src/transforms/add-package-names.ts @@ -0,0 +1,127 @@ +import path from "node:path"; +import { getWorkspaceDetails, type Project } from "@turbo/workspaces"; +import { readJson } from "fs-extra"; +import type { TransformerArgs } from "../types"; +import type { TransformerResults } from "../runner"; +import { getTransformerHelpers } from "../utils/getTransformerHelpers"; + +// transformer details +const TRANSFORMER = "add-package-names"; +const DESCRIPTION = "Ensure all packages have a name in their package.json"; +const INTRODUCED_IN = "2.0.0"; + +interface PartialPackageJson { + name?: string; +} + +async function readPkgJson( + pkgJsonPath: string +): Promise { + try { + return (await readJson(pkgJsonPath)) as { name?: string }; + } catch (e) { + return null; + } +} + +export function getNewPkgName({ + pkgPath, + pkgName, +}: { + pkgPath: string; + pkgName?: string; +}): string { + // find the scope if it exists + let scope = ""; + let name = pkgName; + if (pkgName && pkgName.startsWith("@") && pkgName.includes("/")) { + const parts = pkgName.split("/"); + scope = `${parts[0]}/`; + name = parts[1]; + } + + const dirName = path.basename(path.dirname(pkgPath)); + if (pkgName) { + return `${scope}${dirName}-${name}`; + } + + return `${scope}${dirName}`; +} + +export async function transformer({ + root, + options, +}: TransformerArgs): Promise { + const { log, runner } = getTransformerHelpers({ + transformer: TRANSFORMER, + rootPath: root, + options, + }); + + log.info('Validating that each package has a unique "name"...'); + + let project: Project; + try { + project = await getWorkspaceDetails({ root }); + } catch (e) { + return runner.abortTransform({ + reason: `Unable to determine package manager for ${root}`, + }); + } + + const packagePaths: Array = [project.paths.packageJson]; + const packagePromises: Array> = [ + readPkgJson(project.paths.packageJson), + ]; + + // add all workspace package.json files + project.workspaceData.workspaces.forEach((workspace) => { + const pkgJsonPath = workspace.paths.packageJson; + packagePaths.push(pkgJsonPath); + packagePromises.push(readPkgJson(pkgJsonPath)); + }); + + // await, and then zip the paths and promise results together + const packageContent = await Promise.all(packagePromises); + const packageToContent = Object.fromEntries( + packagePaths.map((pkgJsonPath, idx) => [pkgJsonPath, packageContent[idx]]) + ); + + // wait for all package.json files to be read + const names = new Set(); + for (const [pkgJsonPath, pkgJsonContent] of Object.entries( + packageToContent + )) { + if (pkgJsonContent) { + // name is missing or isn't unique + if (!pkgJsonContent.name || names.has(pkgJsonContent.name)) { + const newName = getNewPkgName({ + pkgPath: pkgJsonPath, + pkgName: pkgJsonContent.name, + }); + runner.modifyFile({ + filePath: pkgJsonPath, + after: { + ...pkgJsonContent, + name: newName, + }, + }); + names.add(newName); + } else { + names.add(pkgJsonContent.name); + } + } + } + + return runner.finish(); +} + +const transformerMeta = { + name: `${TRANSFORMER}: ${DESCRIPTION}`, + value: TRANSFORMER, + introducedIn: INTRODUCED_IN, + transformer, +}; + +// eslint-disable-next-line import/no-default-export -- transforms require default export +export default transformerMeta;