diff --git a/nx.json b/nx.json index d1f329741846e..815db7e977236 100644 --- a/nx.json +++ b/nx.json @@ -247,5 +247,12 @@ "parallel": 1, "cacheDirectory": "/tmp/nx-cache", "bust": 1, - "defaultBase": "master" + "defaultBase": "master", + "conformance": { + "rules": [ + { + "rule": "@nx/workspace-plugin/conformance-rules/project-package-json" + } + ] + } } diff --git a/package.json b/package.json index c523954a1182c..b0d98743ef8d0 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,9 @@ "@eslint/eslintrc": "^2.1.1", "@eslint/js": "^8.48.0", "@floating-ui/react": "0.26.6", - "@jest/reporters": "^29.4.1", - "@jest/test-result": "^29.4.1", - "@jest/types": "^29.4.1", + "@jest/reporters": "29.7.0", + "@jest/test-result": "29.7.0", + "@jest/types": "29.6.3", "@module-federation/enhanced": "0.7.6", "@module-federation/sdk": "0.7.6", "@monodon/rust": "2.1.1", @@ -83,11 +83,11 @@ "@nx/powerpack-enterprise-cloud": "1.1.0-beta.5", "@nx/powerpack-license": "1.1.0-beta.5", "@nx/react": "20.2.0-beta.3", + "@nx/rspack": "20.2.0-beta.3", "@nx/storybook": "20.2.0-beta.3", "@nx/vite": "20.2.0-beta.3", "@nx/web": "20.2.0-beta.3", "@nx/webpack": "20.2.0-beta.3", - "@nx/rspack": "20.2.0-beta.3", "@phenomnomnominal/tsquery": "~5.0.1", "@playwright/test": "^1.36.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", @@ -217,13 +217,13 @@ "jasmine-core": "~2.99.1", "jasmine-spec-reporter": "~4.2.1", "jest": "29.7.0", - "jest-config": "^29.4.1", - "jest-diff": "^29.4.1", + "jest-config": "29.7.0", + "jest-diff": "29.7.0", "jest-environment-jsdom": "29.7.0", - "jest-environment-node": "^29.4.1", - "jest-resolve": "^29.4.1", - "jest-runtime": "^29.4.1", - "jest-util": "^29.4.1", + "jest-environment-node": "29.7.0", + "jest-resolve": "29.7.0", + "jest-runtime": "29.7.0", + "jest-util": "29.7.0", "js-tokens": "^4.0.0", "jsonc-eslint-parser": "^2.1.0", "jsonc-parser": "3.2.0", diff --git a/packages/rspack/package.json b/packages/rspack/package.json index 5893615706a16..9fafa353e25e6 100644 --- a/packages/rspack/package.json +++ b/packages/rspack/package.json @@ -60,5 +60,8 @@ }, "nx-migrations": { "migrations": "./migrations.json" + }, + "publishConfig": { + "access": "public" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed917312c1f7e..b067a18581027 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,13 +239,13 @@ importers: specifier: 0.26.6 version: 0.26.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@jest/reporters': - specifier: ^29.4.1 + specifier: 29.7.0 version: 29.7.0 '@jest/test-result': - specifier: ^29.4.1 + specifier: 29.7.0 version: 29.7.0 '@jest/types': - specifier: ^29.4.1 + specifier: 29.6.3 version: 29.6.3 '@module-federation/enhanced': specifier: 0.7.6 @@ -740,25 +740,25 @@ importers: specifier: 29.7.0 version: 29.7.0(@types/node@20.16.10)(ts-node@10.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@types/node@20.16.10)(typescript@5.5.4)) jest-config: - specifier: ^29.4.1 + specifier: 29.7.0 version: 29.7.0(@types/node@20.16.10)(ts-node@10.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@types/node@20.16.10)(typescript@5.5.4)) jest-diff: - specifier: ^29.4.1 + specifier: 29.7.0 version: 29.7.0 jest-environment-jsdom: specifier: 29.7.0 version: 29.7.0 jest-environment-node: - specifier: ^29.4.1 + specifier: 29.7.0 version: 29.7.0 jest-resolve: - specifier: ^29.4.1 + specifier: 29.7.0 version: 29.7.0 jest-runtime: - specifier: ^29.4.1 + specifier: 29.7.0 version: 29.7.0 jest-util: - specifier: ^29.4.1 + specifier: 29.7.0 version: 29.7.0 js-tokens: specifier: ^4.0.0 diff --git a/tools/workspace-plugin/jest.config.ts b/tools/workspace-plugin/jest.config.ts index 946685cfeee00..b2304c749967d 100644 --- a/tools/workspace-plugin/jest.config.ts +++ b/tools/workspace-plugin/jest.config.ts @@ -1,7 +1,8 @@ /* eslint-disable */ export default { displayName: 'workspace-plugin', - preset: '../../jest.preset.js', + // TODO: For some reason our patched jest resolve cannot work with @nx/powerpack-conformance + // preset: '../../jest.preset.js', transform: { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], }, diff --git a/tools/workspace-plugin/src/conformance-rules/project-package-json/index.spec.ts b/tools/workspace-plugin/src/conformance-rules/project-package-json/index.spec.ts new file mode 100644 index 0000000000000..8d478a59d498c --- /dev/null +++ b/tools/workspace-plugin/src/conformance-rules/project-package-json/index.spec.ts @@ -0,0 +1,261 @@ +const mockExistsSync = jest.fn(); +jest.mock('node:fs', () => { + return { + ...jest.requireActual('node:fs'), + existsSync: mockExistsSync, + }; +}); + +import { validateProjectPackageJson } from './index'; + +const VALID_PACKAGE_JSON_BASE = { + name: '@nx/test-project', + publishConfig: { + access: 'public', + }, +}; + +describe('project-package-json', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + // Unit test the core implementation details of validating the project package.json + describe('validateProjectPackageJson()', () => { + it('should return no violations for a valid project package.json', () => { + const packageJson = { + ...VALID_PACKAGE_JSON_BASE, + }; + const sourceProject = 'test-project'; + const sourceProjectRoot = '/path/to/test-project'; + const violations = validateProjectPackageJson( + packageJson, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ); + expect(violations).toEqual([]); + }); + + it('should return a violation if the name is not a string', () => { + const packageJson = { + ...VALID_PACKAGE_JSON_BASE, + }; + delete packageJson.name; + + const sourceProject = 'test-project'; + const sourceProjectRoot = '/path/to/test-project'; + const violations = validateProjectPackageJson( + packageJson, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ); + expect(violations).toMatchInlineSnapshot(` + [ + { + "file": "/path/to/test-project/package.json", + "message": "The project package.json should have a "name" field", + "sourceProject": "test-project", + }, + ] + `); + }); + + it('should return a violation if the name is not scoped an org that is not @nx', () => { + const sourceProject = 'test-project'; + const sourceProjectRoot = '/path/to/test-project'; + + expect( + validateProjectPackageJson( + // Should be fine, as not scoped + { + ...VALID_PACKAGE_JSON_BASE, + name: 'test-project', + }, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toEqual([]); + + // Should return a violation, as scoped to an org that is not @nx + const packageJsonWithScope = { + ...VALID_PACKAGE_JSON_BASE, + name: '@nx-labs/test-project', + }; + expect( + validateProjectPackageJson( + packageJsonWithScope, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toMatchInlineSnapshot(` + [ + { + "file": "/path/to/test-project/package.json", + "message": "The package name should be scoped to the @nx org", + "sourceProject": "test-project", + }, + ] + `); + }); + + it('should return a violation if a public package does not have publishConfig.access set to public', () => { + const sourceProject = 'some-project-name'; + const sourceProjectRoot = '/path/to/some-project-name'; + + expect( + validateProjectPackageJson( + // Should be fine, as private + { + private: true, + name: 'test-project', + }, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toEqual([]); + + // Should return a violation, as not private + const packageJsonWithoutPublicAccess = { + ...VALID_PACKAGE_JSON_BASE, + }; + delete packageJsonWithoutPublicAccess.publishConfig; + expect( + validateProjectPackageJson( + packageJsonWithoutPublicAccess, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toMatchInlineSnapshot(` + [ + { + "file": "/path/to/some-project-name/package.json", + "message": "Public packages should have "publishConfig": { "access": "public" } set in their package.json", + "sourceProject": "some-project-name", + }, + ] + `); + }); + + it('should return a violation if the project has an executors.json but does not reference it in the package.json', () => { + const sourceProject = 'some-project-name'; + const sourceProjectRoot = '/path/to/some-project-name'; + + // The project does not have an executors.json, so no violation + expect( + validateProjectPackageJson( + { + ...VALID_PACKAGE_JSON_BASE, + }, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toEqual([]); + + // The project has an executors.json + mockExistsSync.mockImplementation((path) => { + if (path.endsWith('executors.json')) { + return true; + } + return false; + }); + + // The project references the executors.json in the package.json, so no violation + expect( + validateProjectPackageJson( + { + ...VALID_PACKAGE_JSON_BASE, + executors: './executors.json', + }, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toEqual([]); + + // The project does not reference the executors.json in the package.json, so a violation is returned + expect( + validateProjectPackageJson( + { + ...VALID_PACKAGE_JSON_BASE, + }, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toMatchInlineSnapshot(` + [ + { + "file": "/path/to/some-project-name/package.json", + "message": "The project has an executors.json, but does not reference "./executors.json" in the "executors" field of its package.json", + "sourceProject": "some-project-name", + }, + ] + `); + }); + + it('should return a violation if the project has an generators.json but does not reference it in the package.json', () => { + const sourceProject = 'some-project-name'; + const sourceProjectRoot = '/path/to/some-project-name'; + + // The project does not have an generators.json, so no violation + expect( + validateProjectPackageJson( + { + ...VALID_PACKAGE_JSON_BASE, + }, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toEqual([]); + + // The project has an generators.json + mockExistsSync.mockImplementation((path) => { + if (path.endsWith('generators.json')) { + return true; + } + return false; + }); + + // The project references the generators.json in the package.json, so no violation + expect( + validateProjectPackageJson( + { + ...VALID_PACKAGE_JSON_BASE, + generators: './generators.json', + }, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toEqual([]); + + // The project does not reference the generators.json in the package.json, so a violation is returned + expect( + validateProjectPackageJson( + { + ...VALID_PACKAGE_JSON_BASE, + }, + sourceProject, + sourceProjectRoot, + `${sourceProjectRoot}/package.json` + ) + ).toMatchInlineSnapshot(` + [ + { + "file": "/path/to/some-project-name/package.json", + "message": "The project has an generators.json, but does not reference "./generators.json" in the "generators" field of its package.json", + "sourceProject": "some-project-name", + }, + ] + `); + }); + }); +}); diff --git a/tools/workspace-plugin/src/conformance-rules/project-package-json/index.ts b/tools/workspace-plugin/src/conformance-rules/project-package-json/index.ts new file mode 100644 index 0000000000000..e50457395f706 --- /dev/null +++ b/tools/workspace-plugin/src/conformance-rules/project-package-json/index.ts @@ -0,0 +1,107 @@ +import { readJsonFile, workspaceRoot } from '@nx/devkit'; +import { createConformanceRule } from '@nx/powerpack-conformance'; +import type { Violation } from '@nx/powerpack-conformance/src/reporters/project-files-reporter'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +export default createConformanceRule({ + name: 'project-package-json', + category: 'consistency', + description: + 'Ensures consistency across our project package.json files within the Nx repo', + reporter: 'project-files-reporter', + implementation: async ({ projectGraph }) => { + const violations: Violation[] = []; + + for (const project of Object.values(projectGraph.nodes)) { + const projectPackageJsonPath = join( + workspaceRoot, + project.data.root, + 'package.json' + ); + if (existsSync(projectPackageJsonPath)) { + const projectPackageJson = readJsonFile(projectPackageJsonPath); + violations.push( + ...validateProjectPackageJson( + projectPackageJson, + project.name, + project.data.root, + projectPackageJsonPath + ) + ); + } + } + + return { + severity: 'medium', + details: { + violations, + }, + }; + }, +}); + +export function validateProjectPackageJson( + projectPackageJson: Record, + sourceProject: string, + sourceProjectRoot: string, + projectPackageJsonPath: string +): Violation[] { + const violations: Violation[] = []; + + if (typeof projectPackageJson.name !== 'string') { + violations.push({ + message: 'The project package.json should have a "name" field', + sourceProject, + file: projectPackageJsonPath, + }); + } else { + // Ensure that if a scope is used, it is only the @nx scope + if ( + projectPackageJson.name.startsWith('@') && + !projectPackageJson.name.startsWith('@nx/') + ) { + violations.push({ + message: 'The package name should be scoped to the @nx org', + sourceProject, + file: projectPackageJsonPath, + }); + } + } + + // Public packages + if (!projectPackageJson.private) { + if ((projectPackageJson.publishConfig as any)?.access !== 'public') { + violations.push({ + message: + 'Public packages should have "publishConfig": { "access": "public" } set in their package.json', + sourceProject, + file: projectPackageJsonPath, + }); + } + } + + // Nx config properties + if (existsSync(join(sourceProjectRoot, 'executors.json'))) { + if (projectPackageJson.executors !== './executors.json') { + violations.push({ + message: + 'The project has an executors.json, but does not reference "./executors.json" in the "executors" field of its package.json', + sourceProject, + file: projectPackageJsonPath, + }); + } + } + if (existsSync(join(sourceProjectRoot, 'generators.json'))) { + if (projectPackageJson.generators !== './generators.json') { + violations.push({ + message: + 'The project has an generators.json, but does not reference "./generators.json" in the "generators" field of its package.json', + sourceProject, + file: projectPackageJsonPath, + }); + } + } + + return violations; +} diff --git a/tools/workspace-plugin/src/conformance-rules/project-package-json/schema.json b/tools/workspace-plugin/src/conformance-rules/project-package-json/schema.json new file mode 100644 index 0000000000000..5ba9e821a0d58 --- /dev/null +++ b/tools/workspace-plugin/src/conformance-rules/project-package-json/schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {}, + "additionalProperties": false +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 4283250d13bcc..11268f85c7c8e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -150,6 +150,9 @@ "@nx/webpack/*": ["packages/webpack/*"], "@nx/workspace": ["packages/workspace"], "@nx/workspace-plugin": ["tools/workspace-plugin/src/index.ts"], + "@nx/workspace-plugin/conformance-rules/*": [ + "tools/workspace-plugin/src/conformance-rules/*" + ], "@nx/workspace/*": ["packages/workspace/*"], "create-nx-workspace": ["packages/create-nx-workspace/index.ts"], "create-nx-workspace/*": ["packages/create-nx-workspace/*"],