diff --git a/src/main.test.ts b/src/main.test.ts index de124e3..2afc7b1 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -73,6 +73,7 @@ describe('main', () => { JSON.stringify({ packageManager: 'yarn', engines: { node: 'test' }, + devDependencies: { eslint: '1.1.0' }, }), ); await writeFile( @@ -106,6 +107,7 @@ repo-1 - Does the package have a well-formed manifest (\`package.json\`)? ✅ - Does the \`packageManager\` field in \`package.json\` conform? ✅ - Does the \`engines.node\` field in \`package.json\` conform? ✅ + - Do the lint-related \`devDependencies\` in \`package.json\` conform? ✅ - Is \`README.md\` present? ✅ - Does the README conform by recommending the correct Yarn version to install? ✅ - Does the README conform by recommending node install from nodejs.org? ✅ @@ -114,7 +116,7 @@ repo-1 - Does the \`src/\` directory exist? ✅ - Is \`.nvmrc\` present, and does it conform? ✅ -Results: 11 passed, 0 failed, 11 total +Results: 12 passed, 0 failed, 12 total Elapsed time: 0 ms @@ -125,6 +127,7 @@ repo-2 - Does the package have a well-formed manifest (\`package.json\`)? ✅ - Does the \`packageManager\` field in \`package.json\` conform? ✅ - Does the \`engines.node\` field in \`package.json\` conform? ✅ + - Do the lint-related \`devDependencies\` in \`package.json\` conform? ✅ - Is \`README.md\` present? ✅ - Does the README conform by recommending the correct Yarn version to install? ✅ - Does the README conform by recommending node install from nodejs.org? ✅ @@ -133,7 +136,7 @@ repo-2 - Does the \`src/\` directory exist? ✅ - Is \`.nvmrc\` present, and does it conform? ✅ -Results: 11 passed, 0 failed, 11 total +Results: 12 passed, 0 failed, 12 total Elapsed time: 0 ms `, @@ -337,6 +340,7 @@ Elapsed time: 0 ms JSON.stringify({ packageManager: 'yarn', engines: { node: 'test' }, + devDependencies: { eslint: '1.1.0' }, }), ); await writeFile( @@ -370,6 +374,7 @@ repo-1 - Does the package have a well-formed manifest (\`package.json\`)? ✅ - Does the \`packageManager\` field in \`package.json\` conform? ✅ - Does the \`engines.node\` field in \`package.json\` conform? ✅ + - Do the lint-related \`devDependencies\` in \`package.json\` conform? ✅ - Is \`README.md\` present? ✅ - Does the README conform by recommending the correct Yarn version to install? ✅ - Does the README conform by recommending node install from nodejs.org? ✅ @@ -378,7 +383,7 @@ repo-1 - Does the \`src/\` directory exist? ✅ - Is \`.nvmrc\` present, and does it conform? ✅ -Results: 11 passed, 0 failed, 11 total +Results: 12 passed, 0 failed, 12 total Elapsed time: 0 ms @@ -389,6 +394,7 @@ repo-2 - Does the package have a well-formed manifest (\`package.json\`)? ✅ - Does the \`packageManager\` field in \`package.json\` conform? ✅ - Does the \`engines.node\` field in \`package.json\` conform? ✅ + - Do the lint-related \`devDependencies\` in \`package.json\` conform? ✅ - Is \`README.md\` present? ✅ - Does the README conform by recommending the correct Yarn version to install? ✅ - Does the README conform by recommending node install from nodejs.org? ✅ @@ -397,7 +403,7 @@ repo-2 - Does the \`src/\` directory exist? ✅ - Is \`.nvmrc\` present, and does it conform? ✅ -Results: 11 passed, 0 failed, 11 total +Results: 12 passed, 0 failed, 12 total Elapsed time: 0 ms `, diff --git a/src/rules/index.ts b/src/rules/index.ts index 6e314eb..ced17c7 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,6 +1,7 @@ import allYarnModernFilesConform from './all-yarn-modern-files-conform'; import classicYarnConfigFileAbsent from './classic-yarn-config-file-absent'; import packageEnginesNodeFieldConforms from './package-engines-node-field-conforms'; +import packageLintDependenciesConform from './package-lint-dependencies-conform'; import packagePackageManagerFieldConforms from './package-package-manager-field-conforms'; import readmeListsCorrectYarnVersion from './readme-lists-correct-yarn-version'; import readmeListsNodejsWebsite from './readme-recommends-node-install'; @@ -20,4 +21,5 @@ export const rules = [ requireNvmrc, packageEnginesNodeFieldConforms, readmeListsNodejsWebsite, + packageLintDependenciesConform, ] as const; diff --git a/src/rules/package-engines-node-field-conforms.test.ts b/src/rules/package-engines-node-field-conforms.test.ts index cd59da3..412c2bf 100644 --- a/src/rules/package-engines-node-field-conforms.test.ts +++ b/src/rules/package-engines-node-field-conforms.test.ts @@ -14,7 +14,11 @@ describe('Rule: package-engines-node-field-conforms', () => { }); await writeFile( path.join(template.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'a', engines: { node: 'test' } }), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const project = buildMetaMaskRepository({ shortname: 'project', @@ -22,7 +26,11 @@ describe('Rule: package-engines-node-field-conforms', () => { }); await writeFile( path.join(project.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'a', engines: { node: 'test' } }), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const result = await packageEnginesNodeFieldConforms.execute({ @@ -46,7 +54,11 @@ describe('Rule: package-engines-node-field-conforms', () => { }); await writeFile( path.join(template.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'a', engines: { node: 'test1' } }), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test1' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const project = buildMetaMaskRepository({ shortname: 'project', @@ -54,7 +66,11 @@ describe('Rule: package-engines-node-field-conforms', () => { }); await writeFile( path.join(project.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'a', engines: { node: 'test2' } }), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test2' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const result = await packageEnginesNodeFieldConforms.execute({ diff --git a/src/rules/package-lint-dependencies-conform.test.ts b/src/rules/package-lint-dependencies-conform.test.ts new file mode 100644 index 0000000..14394e1 --- /dev/null +++ b/src/rules/package-lint-dependencies-conform.test.ts @@ -0,0 +1,206 @@ +import { writeFile } from '@metamask/utils/node'; +import path from 'path'; + +import packageLintDependenciesConform from './package-lint-dependencies-conform'; +import { buildMetaMaskRepository, withinSandbox } from '../../tests/helpers'; +import { fail, pass } from '../rule-helpers'; + +describe('Rule: package-lint-dependencies-conform', () => { + it("passes if the lint related dependencies in the project's package.json matches the one in the template's package.json", async () => { + await withinSandbox(async (sandbox) => { + const template = buildMetaMaskRepository({ + shortname: 'template', + directoryPath: path.join(sandbox.directoryPath, 'template'), + }); + await writeFile( + path.join(template.directoryPath, 'package.json'), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { + '@metamask/eslint-config-foo': '1.0.0', + '@typescript-eslint/foo': '1.0.0', + eslint: '1.0.0', + 'eslint-plugin-foo': '1.0.0', + 'eslint-config-foo': '1.0.0', + prettier: '1.0.0', + 'prettier-plugin-foo': '1.0.0', + 'prettier-config-foo': '1.0.0', + }, + }), + ); + const project = buildMetaMaskRepository({ + shortname: 'project', + directoryPath: path.join(sandbox.directoryPath, 'project'), + }); + await writeFile( + path.join(project.directoryPath, 'package.json'), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { + '@metamask/eslint-config-foo': '1.0.0', + '@typescript-eslint/foo': '1.0.0', + eslint: '1.0.0', + 'eslint-plugin-foo': '1.0.0', + 'eslint-config-foo': '1.0.0', + prettier: '1.0.0', + 'prettier-plugin-foo': '1.0.0', + 'prettier-config-foo': '1.0.0', + }, + }), + ); + + const result = await packageLintDependenciesConform.execute({ + template, + project, + pass, + fail, + }); + + expect(result).toStrictEqual({ + passed: true, + }); + }); + }); + + it("fails if the version of lint related dependencies in the project's package.json does not match the one in the template's package.json", async () => { + await withinSandbox(async (sandbox) => { + const template = buildMetaMaskRepository({ + shortname: 'template', + directoryPath: path.join(sandbox.directoryPath, 'template'), + }); + await writeFile( + path.join(template.directoryPath, 'package.json'), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { eslint: '1.1.0' }, + }), + ); + const project = buildMetaMaskRepository({ + shortname: 'project', + directoryPath: path.join(sandbox.directoryPath, 'project'), + }); + await writeFile( + path.join(project.directoryPath, 'package.json'), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { eslint: '1.0.0' }, + }), + ); + + const result = await packageLintDependenciesConform.execute({ + template, + project, + pass, + fail, + }); + + expect(result).toStrictEqual({ + passed: false, + failures: [ + { message: '`eslint` is "1.0.0", when it should be "1.1.0".' }, + ], + }); + }); + }); + + it("fails if the lint related dependency exist in the template's package.json, but not in the project's package.json", async () => { + await withinSandbox(async (sandbox) => { + const template = buildMetaMaskRepository({ + shortname: 'template', + directoryPath: path.join(sandbox.directoryPath, 'template'), + }); + await writeFile( + path.join(template.directoryPath, 'package.json'), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { eslint: '1.1.0' }, + }), + ); + const project = buildMetaMaskRepository({ + shortname: 'project', + directoryPath: path.join(sandbox.directoryPath, 'project'), + }); + await writeFile( + path.join(project.directoryPath, 'package.json'), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { testlint: '1.0.0' }, + }), + ); + + const result = await packageLintDependenciesConform.execute({ + template, + project, + pass, + fail, + }); + + expect(result).toStrictEqual({ + passed: false, + failures: [ + { + message: + '`package.json` should list `"eslint": "1.1.0"` in `devDependencies`, but does not.', + }, + ], + }); + }); + }); + + it("passes if the there're no lint related dependencies in the template's package.json", async () => { + await withinSandbox(async (sandbox) => { + const template = buildMetaMaskRepository({ + shortname: 'template', + directoryPath: path.join(sandbox.directoryPath, 'template'), + }); + await writeFile( + path.join(template.directoryPath, 'package.json'), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { + '@metamask/test-config-foo': '1.0.0', + }, + }), + ); + const project = buildMetaMaskRepository({ + shortname: 'project', + directoryPath: path.join(sandbox.directoryPath, 'project'), + }); + await writeFile( + path.join(project.directoryPath, 'package.json'), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { + '@metamask/eslint-config-foo': '1.0.0', + '@typescript-eslint/foo': '1.0.0', + eslint: '1.0.0', + 'eslint-plugin-foo': '1.0.0', + 'eslint-config-foo': '1.0.0', + prettier: '1.0.0', + 'prettier-plugin-foo': '1.0.0', + 'prettier-config-foo': '1.0.0', + }, + }), + ); + + const result = await packageLintDependenciesConform.execute({ + template, + project, + pass, + fail, + }); + + expect(result).toStrictEqual({ + passed: true, + }); + }); + }); +}); diff --git a/src/rules/package-lint-dependencies-conform.ts b/src/rules/package-lint-dependencies-conform.ts new file mode 100644 index 0000000..e5cc649 --- /dev/null +++ b/src/rules/package-lint-dependencies-conform.ts @@ -0,0 +1,92 @@ +import { buildRule } from './build-rule'; +import { PackageManifestSchema, RuleName } from './types'; +import type { RuleExecutionFailure } from '../execute-rules'; + +export default buildRule({ + name: RuleName.PackageLintDependenciesConform, + description: + 'Do the lint-related `devDependencies` in `package.json` conform?', + dependencies: [RuleName.RequireValidPackageManifest], + execute: async ({ project, template, pass, fail }) => { + const entryPath = 'package.json'; + + const templateManifest = await template.fs.readJsonFileAs( + entryPath, + PackageManifestSchema, + ); + + const projectManifest = await project.fs.readJsonFileAs( + entryPath, + PackageManifestSchema, + ); + + const failures: RuleExecutionFailure[] = await lintPackageConform( + templateManifest.devDependencies, + projectManifest.devDependencies, + ); + + return failures.length === 0 ? pass() : fail(failures); + }, +}); + +/** + * Validates whether target project has all the required lint packages with versions matching with template project. + * + * @param templateDependencies - The record of lint package name and version from template. + * @param projectDependencies - The record of lint package name and version from project. + */ +async function lintPackageConform( + templateDependencies: Record, + projectDependencies: Record, +): Promise { + const templateLintPackages = getTemplateLintPackages(templateDependencies); + const failures: RuleExecutionFailure[] = []; + for (const [templatePackageName, templatePackageVersion] of Object.entries( + templateLintPackages, + )) { + const projectPackageVersion = projectDependencies[templatePackageName]; + if (!projectPackageVersion) { + failures.push({ + message: `\`package.json\` should list \`"${templatePackageName}": "${templatePackageVersion}"\` in \`devDependencies\`, but does not.`, + }); + + continue; + } + + if (projectPackageVersion !== templatePackageVersion) { + failures.push({ + message: `\`${templatePackageName}\` is "${projectPackageVersion}", when it should be "${templatePackageVersion}".`, + }); + } + } + + return failures; +} + +/** + * Extracts the records of lint package name and version from template's package.json. + * + * @param templateDependencies - The record of lint package name and version. + * @returns The records of lint package name and version. + */ +function getTemplateLintPackages( + templateDependencies: Record, +): Record { + const requiredPackagePatterns: RegExp[] = [ + /^@metamask\/eslint-config-[^/]+$/u, + /^@typescript-eslint\/[^/]+$/u, + /^eslint(-[^/]+)?$/u, + /^prettier(-[^/]+)?$/u, + ]; + return Object.entries(templateDependencies).reduce>( + (packages, [packageName, packageVersion]) => { + if ( + requiredPackagePatterns.some((pattern) => packageName.match(pattern)) + ) { + return { ...packages, [packageName]: packageVersion }; + } + return packages; + }, + {}, + ); +} diff --git a/src/rules/package-package-manager-field-conforms.test.ts b/src/rules/package-package-manager-field-conforms.test.ts index cf93ed1..7631c7f 100644 --- a/src/rules/package-package-manager-field-conforms.test.ts +++ b/src/rules/package-package-manager-field-conforms.test.ts @@ -14,7 +14,11 @@ describe('Rule: package-manager-field-conforms', () => { }); await writeFile( path.join(template.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'a', engines: { node: 'test' } }), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const project = buildMetaMaskRepository({ shortname: 'project', @@ -22,7 +26,11 @@ describe('Rule: package-manager-field-conforms', () => { }); await writeFile( path.join(project.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'a', engines: { node: 'test' } }), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const result = await packageManagerFieldConforms.execute({ @@ -46,7 +54,11 @@ describe('Rule: package-manager-field-conforms', () => { }); await writeFile( path.join(template.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'a', engines: { node: 'test' } }), + JSON.stringify({ + packageManager: 'a', + engines: { node: 'test' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const project = buildMetaMaskRepository({ shortname: 'project', @@ -54,7 +66,11 @@ describe('Rule: package-manager-field-conforms', () => { }); await writeFile( path.join(project.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'b', engines: { node: 'test' } }), + JSON.stringify({ + packageManager: 'b', + engines: { node: 'test' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const result = await packageManagerFieldConforms.execute({ diff --git a/src/rules/require-valid-package-manifest.test.ts b/src/rules/require-valid-package-manifest.test.ts index 92dacab..44d5cf8 100644 --- a/src/rules/require-valid-package-manifest.test.ts +++ b/src/rules/require-valid-package-manifest.test.ts @@ -14,7 +14,11 @@ describe('Rule: require-package-manifest', () => { }); await writeFile( path.join(project.directoryPath, 'package.json'), - JSON.stringify({ packageManager: 'foo', engines: { node: 'test' } }), + JSON.stringify({ + packageManager: 'foo', + engines: { node: 'test' }, + devDependencies: { eslint: '1.0.0' }, + }), ); const result = await requireValidPackageManifest.execute({ @@ -78,7 +82,7 @@ describe('Rule: require-package-manifest', () => { failures: [ { message: - 'Invalid `package.json`: Missing `packageManager`; Missing `engines`.', + 'Invalid `package.json`: Missing `packageManager`; Missing `engines`; Missing `devDependencies`.', }, ], }); diff --git a/src/rules/types.ts b/src/rules/types.ts index 5dbd0c3..0e7c34c 100644 --- a/src/rules/types.ts +++ b/src/rules/types.ts @@ -1,4 +1,4 @@ -import { type, string } from 'superstruct'; +import { type, string, record } from 'superstruct'; /** * All of the known rules. @@ -14,6 +14,7 @@ export enum RuleName { RequireNvmrc = 'require-nvmrc', PackageEnginesNodeFieldConforms = 'package-engines-node-field-conforms', ReadmeRecommendsNodeInstall = 'readme-recommends-node-install', + PackageLintDependenciesConform = 'package-lint-dependencies-conform', } export const PackageManifestSchema = type({ @@ -21,4 +22,5 @@ export const PackageManifestSchema = type({ engines: type({ node: string(), }), + devDependencies: record(string(), string()), });