chore(repo): add conformance rule for our package.json files (#29078)

This commit is contained in:
James Henry 2024-12-19 16:17:00 +04:00 committed by GitHub
parent 2eb524307b
commit dfd5014792
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 854 additions and 284 deletions

40
nx.json
View File

@ -246,5 +246,43 @@
"nxCloudUrl": "https://staging.nx.app", "nxCloudUrl": "https://staging.nx.app",
"parallel": 1, "parallel": 1,
"bust": 1, "bust": 1,
"defaultBase": "master" "defaultBase": "master",
"conformance": {
"rules": [
{
"rule": "@nx/workspace-plugin/conformance-rules/project-package-json",
"projects": [
"!.",
"!create-nx-*",
"!cypress",
"!detox",
"!devkit",
"!esbuild",
"!eslint-plugin",
"!eslint",
"!expo",
"!express",
"!jest",
"!js",
"!module-federation",
"!nest",
"!next",
"!node",
"!nuxt",
"!packages/nx/**",
"!plugin",
"!react-native",
"!react",
"!rollup",
"!rsbuild",
"!rspack",
"!storybook",
"!vue",
"!web",
"!webpack",
"!workspace"
]
}
]
}
} }

View File

@ -50,9 +50,9 @@
"@eslint/eslintrc": "^2.1.1", "@eslint/eslintrc": "^2.1.1",
"@eslint/js": "^8.48.0", "@eslint/js": "^8.48.0",
"@floating-ui/react": "0.26.6", "@floating-ui/react": "0.26.6",
"@jest/reporters": "^29.4.1", "@jest/reporters": "29.7.0",
"@jest/test-result": "^29.4.1", "@jest/test-result": "29.7.0",
"@jest/types": "^29.4.1", "@jest/types": "29.6.3",
"@module-federation/enhanced": "0.7.6", "@module-federation/enhanced": "0.7.6",
"@module-federation/sdk": "0.7.6", "@module-federation/sdk": "0.7.6",
"@monodon/rust": "2.1.1", "@monodon/rust": "2.1.1",
@ -79,9 +79,9 @@
"@nx/js": "20.3.0-beta.0", "@nx/js": "20.3.0-beta.0",
"@nx/next": "20.3.0-beta.0", "@nx/next": "20.3.0-beta.0",
"@nx/playwright": "20.3.0-beta.0", "@nx/playwright": "20.3.0-beta.0",
"@nx/powerpack-conformance": "1.1.0-beta.9", "@nx/powerpack-conformance": "1.1.1-alpha.1",
"@nx/powerpack-enterprise-cloud": "1.1.0-beta.9", "@nx/powerpack-enterprise-cloud": "1.1.1-alpha.1",
"@nx/powerpack-license": "1.1.0-beta.9", "@nx/powerpack-license": "1.1.1-alpha.1",
"@nx/react": "20.3.0-beta.0", "@nx/react": "20.3.0-beta.0",
"@nx/rsbuild": "20.3.0-beta.0", "@nx/rsbuild": "20.3.0-beta.0",
"@nx/rspack": "20.3.0-beta.0", "@nx/rspack": "20.3.0-beta.0",
@ -219,13 +219,13 @@
"jasmine-core": "~2.99.1", "jasmine-core": "~2.99.1",
"jasmine-spec-reporter": "~4.2.1", "jasmine-spec-reporter": "~4.2.1",
"jest": "29.7.0", "jest": "29.7.0",
"jest-config": "^29.4.1", "jest-config": "29.7.0",
"jest-diff": "^29.4.1", "jest-diff": "29.7.0",
"jest-environment-jsdom": "29.7.0", "jest-environment-jsdom": "29.7.0",
"jest-environment-node": "^29.4.1", "jest-environment-node": "29.7.0",
"jest-resolve": "^29.4.1", "jest-resolve": "29.7.0",
"jest-runtime": "^29.4.1", "jest-runtime": "29.7.0",
"jest-util": "^29.4.1", "jest-util": "29.7.0",
"js-tokens": "^4.0.0", "js-tokens": "^4.0.0",
"jsonc-eslint-parser": "^2.1.0", "jsonc-eslint-parser": "^2.1.0",
"jsonc-parser": "3.2.0", "jsonc-parser": "3.2.0",

View File

@ -60,5 +60,8 @@
}, },
"nx-migrations": { "nx-migrations": {
"migrations": "./migrations.json" "migrations": "./migrations.json"
},
"publishConfig": {
"access": "public"
} }
} }

643
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -63,7 +63,10 @@ module.exports = function (path, options) {
} }
// Try to use the defaultResolver // Try to use the defaultResolver
try { try {
if (path.startsWith('@nx/')) throw new Error('custom resolution'); // powerpack packages are installed via npm and resolved like any other packages
if (path.startsWith('@nx/') && !path.startsWith('@nx/powerpack-')) {
throw new Error('custom resolution');
}
if (path.startsWith('nx/')) throw new Error('custom resolution'); if (path.startsWith('nx/')) throw new Error('custom resolution');
if (path.indexOf('@nx/workspace') > -1) { if (path.indexOf('@nx/workspace') > -1) {

View File

@ -0,0 +1,289 @@
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',
},
exports: {
'./package.json': './package.json',
},
};
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",
},
]
`);
});
it('should return a violation if the project does not specify an exports object in the package.json', () => {
const sourceProject = 'test-project';
const sourceProjectRoot = '/path/to/test-project';
expect(
validateProjectPackageJson(
{
...VALID_PACKAGE_JSON_BASE,
exports: undefined,
},
sourceProject,
sourceProjectRoot,
`${sourceProjectRoot}/package.json`
)
).toMatchInlineSnapshot(`
[
{
"file": "/path/to/test-project/package.json",
"message": "The project package.json should have an "exports" object specified",
"sourceProject": "test-project",
},
]
`);
});
});
});

View File

@ -0,0 +1,125 @@
import { readJsonFile, workspaceRoot } from '@nx/devkit';
import {
createConformanceRule,
type ProjectFilesViolation,
} from '@nx/powerpack-conformance';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
export default createConformanceRule<object>({
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: ProjectFilesViolation[] = [];
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<string, unknown>,
sourceProject: string,
sourceProjectRoot: string,
projectPackageJsonPath: string
): ProjectFilesViolation[] {
const violations: ProjectFilesViolation[] = [];
// Private packages are exempt from this rule
if (projectPackageJson.private === true) {
return [];
}
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,
});
}
}
// Publish config
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,
});
}
}
const hasExportsEntries =
typeof projectPackageJson.exports === 'object' &&
Object.keys(projectPackageJson.exports ?? {}).length > 0;
if (!hasExportsEntries) {
violations.push({
message:
'The project package.json should have an "exports" object specified',
sourceProject,
file: projectPackageJsonPath,
});
}
return violations;
}

View File

@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -152,6 +152,9 @@
"@nx/webpack/*": ["packages/webpack/*"], "@nx/webpack/*": ["packages/webpack/*"],
"@nx/workspace": ["packages/workspace"], "@nx/workspace": ["packages/workspace"],
"@nx/workspace-plugin": ["tools/workspace-plugin/src/index.ts"], "@nx/workspace-plugin": ["tools/workspace-plugin/src/index.ts"],
"@nx/workspace-plugin/conformance-rules/*": [
"tools/workspace-plugin/src/conformance-rules/*"
],
"@nx/workspace/*": ["packages/workspace/*"], "@nx/workspace/*": ["packages/workspace/*"],
"create-nx-workspace": ["packages/create-nx-workspace/index.ts"], "create-nx-workspace": ["packages/create-nx-workspace/index.ts"],
"create-nx-workspace/*": ["packages/create-nx-workspace/*"], "create-nx-workspace/*": ["packages/create-nx-workspace/*"],