feat(nx-plugin): add plugin eslint rules (#9697)

* feat(nx-plugin): add lint rule for nx-plugin validation

* chore(repo): review feedback

Co-authored-by: Giora Guttsait <giora111@gmail.com>

* chore(repo): review feedback

Co-authored-by: Giora Guttsait <giora111@gmail.com>

* chore(nx-plugin): review comments

* chore(nx-plugin): review feedback

Co-authored-by: Giora Guttsait <giora111@gmail.com>
This commit is contained in:
Craigory Coppola 2022-06-03 16:03:39 -04:00 committed by GitHub
parent 70efd2edd7
commit 10363e3bec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1229 additions and 128 deletions

View File

@ -434,7 +434,13 @@ A plugin for Nx
### TargetConfiguration
**TargetConfiguration**: `Object`
**TargetConfiguration**<`T`\>: `Object`
#### Type parameters
| Name | Type |
| :--- | :---- |
| `T` | `any` |
---

File diff suppressed because one or more lines are too long

View File

@ -50,7 +50,8 @@
"description": "The tool to use for running lint checks.",
"type": "string",
"enum": ["eslint", "tslint"],
"default": "eslint"
"default": "eslint",
"x-deprecated": "TSLint support is deprecated and will be removed"
},
"unitTestRunner": {
"type": "string",
@ -73,6 +74,11 @@
"default": false,
"description": "Do not update tsconfig.json for development experience."
},
"skipLintChecks": {
"type": "boolean",
"default": false,
"description": "Do not eslint configuration for plugin json files."
},
"standaloneConfig": {
"description": "Split the project configuration into `<projectRoot>/project.json` rather than including it inside `workspace.json`.",
"type": "boolean"
@ -315,6 +321,32 @@
"aliases": [],
"hidden": false,
"path": "/packages/nx-plugin/src/generators/executor/schema.json"
},
{
"name": "plugin-lint-checks",
"factory": "./src/generators/lint-checks/generator",
"schema": {
"$schema": "http://json-schema.org/schema",
"cli": "nx",
"$id": "PluginLint",
"title": "",
"type": "object",
"description": "Adds linting configuration to validate common json files for nx plugins.",
"properties": {
"projectName": {
"type": "string",
"description": "Which project should be the configuration be added to?",
"$default": { "$source": "projectName" }
}
},
"required": ["projectName"],
"presets": []
},
"description": "Adds linting configuration to validate common json files for nx plugins.",
"implementation": "/packages/nx-plugin/src/generators/lint-checks/generator.ts",
"aliases": [],
"hidden": false,
"path": "/packages/nx-plugin/src/generators/lint-checks/schema.json"
}
],
"executors": [

View File

@ -188,7 +188,8 @@
"e2e-project",
"migration",
"generator",
"executor"
"executor",
"plugin-lint-checks"
]
}
},

View File

@ -185,6 +185,94 @@ describe('Nx Plugin', () => {
});
}, 90000);
it('should catch invalid implementations, schemas, and version in lint', async () => {
const plugin = uniq('plugin');
const goodGenerator = uniq('good-generator');
const goodExecutor = uniq('good-executor');
const goodMigration = uniq('good-migration');
const badMigrationVersion = uniq('bad-version');
const missingMigrationVersion = uniq('missing-version');
// Generating the plugin results in a generator also called {plugin},
// as well as an executor called "build"
runCLI(`generate @nrwl/nx-plugin:plugin ${plugin} --linter=eslint`);
runCLI(
`generate @nrwl/nx-plugin:generator ${goodGenerator} --project=${plugin}`
);
runCLI(
`generate @nrwl/nx-plugin:executor ${goodExecutor} --project=${plugin}`
);
runCLI(
`generate @nrwl/nx-plugin:migration ${badMigrationVersion} --project=${plugin} --packageVersion="invalid"`
);
runCLI(
`generate @nrwl/nx-plugin:migration ${missingMigrationVersion} --project=${plugin} --packageVersion="0.1.0"`
);
runCLI(
`generate @nrwl/nx-plugin:migration ${goodMigration} --project=${plugin} --packageVersion="0.1.0"`
);
updateFile(`libs/${plugin}/generators.json`, (f) => {
const json = JSON.parse(f);
// @proj/plugin:plugin has an invalid implementation path
json.generators[plugin].factory = `./generators/${plugin}/bad-path`;
// @proj/plugin:non-existant has a missing implementation path amd schema
json.generators['non-existant-generator'] = {};
return JSON.stringify(json);
});
updateFile(`libs/${plugin}/executors.json`, (f) => {
const json = JSON.parse(f);
// @proj/plugin:build has an invalid implementation path
json.executors['build'].implementation = './executors/build/bad-path';
// @proj/plugin:non-existant has a missing implementation path amd schema
json.executors['non-existant-executor'] = {};
return JSON.stringify(json);
});
updateFile(`libs/${plugin}/migrations.json`, (f) => {
const json = JSON.parse(f);
delete json.generators[missingMigrationVersion].version;
return JSON.stringify(json);
});
const results = runCLI(`lint ${plugin}`, { silenceError: true });
expect(results).toContain(
`${plugin}: Implementation path should point to a valid file`
);
expect(results).toContain(
`non-existant-generator: Missing required property - \`schema\``
);
expect(results).toContain(
`non-existant-generator: Missing required property - \`implementation\``
);
expect(results).not.toContain(goodGenerator);
expect(results).toContain(
`build: Implementation path should point to a valid file`
);
expect(results).toContain(
`non-existant-executor: Missing required property - \`schema\``
);
expect(results).toContain(
`non-existant-executor: Missing required property - \`implementation\``
);
expect(results).not.toContain(goodExecutor);
expect(results).toContain(
`${missingMigrationVersion}: Missing required property - \`version\``
);
expect(results).toContain(
`${badMigrationVersion}: Version should be a valid semver`
);
expect(results).not.toContain(goodMigration);
});
describe('local plugins', () => {
const plugin = uniq('plugin');
beforeEach(() => {

View File

@ -20,6 +20,7 @@
"build-base",
"test",
"lint",
"lint-base",
"e2e",
"sitemap"
],

View File

@ -36,6 +36,7 @@
"@nrwl/workspace": "file:../workspace",
"@typescript-eslint/experimental-utils": "~5.24.0",
"chalk": "4.1.0",
"confusing-browser-globals": "^1.0.9"
"confusing-browser-globals": "^1.0.9",
"semver": "7.3.4"
}
}

View File

@ -11,6 +11,10 @@ import enforceModuleBoundaries, {
RULE_NAME as enforceModuleBoundariesRuleName,
} from './rules/enforce-module-boundaries';
import nxPluginChecksRule, {
RULE_NAME as nxPluginChecksRuleName,
} from './rules/nx-plugin-checks';
// Resolve any custom rules that might exist in the current workspace
import { workspaceRules } from './resolve-workspace-rules';
@ -27,6 +31,7 @@ module.exports = {
},
rules: {
[enforceModuleBoundariesRuleName]: enforceModuleBoundaries,
[nxPluginChecksRuleName]: nxPluginChecksRule,
...workspaceRules,
},
};

View File

@ -3,8 +3,6 @@ import {
joinPathFragments,
normalizePath,
ProjectGraphExternalNode,
readCachedProjectGraph,
readNxJson,
} from '@nrwl/devkit';
import {
DepConstraint,
@ -21,10 +19,8 @@ import {
isAbsoluteImportIntoAnotherProject,
isAngularSecondaryEntrypoint,
isDirectDependency,
isTerminalRun,
MappedProjectGraph,
MappedProjectGraphNode,
mapProjectGraphFiles,
matchImportWithWildcard,
onlyLoadChildren,
stringifyTags,
@ -40,13 +36,16 @@ import {
findFilesInCircularPath,
} from '@nrwl/workspace/src/utils/graph-utils';
import { isRelativePath } from '@nrwl/workspace/src/utilities/fileutils';
import * as chalk from 'chalk';
import { basename, dirname, relative } from 'path';
import {
getBarrelEntryPointByImportScope,
getBarrelEntryPointProjectNode,
getRelativeImportPath,
} from '../utils/ast-utils';
import {
ensureGlobalProjectGraph,
readProjectGraph,
} from '../utils/project-graph-utils';
type Options = [
{
@ -148,40 +147,14 @@ export default createESLintRule<Options, MessageIds>({
const projectPath = normalizePath(
(global as any).projectPath || workspaceRoot
);
/**
* Only reuse graph when running from terminal
* Enforce every IDE change to get a fresh nxdeps.json
*/
if (!(global as any).projectGraph || !isTerminalRun()) {
const nxJson = readNxJson();
(global as any).workspaceLayout = nxJson.workspaceLayout;
/**
* Because there are a number of ways in which the rule can be invoked (executor vs ESLint CLI vs IDE Plugin),
* the ProjectGraph may or may not exist by the time the lint rule is invoked for the first time.
*/
try {
(global as any).projectGraph = mapProjectGraphFiles(
readCachedProjectGraph()
);
} catch {
const WARNING_PREFIX = `${chalk.reset.keyword('orange')('warning')}`;
const RULE_NAME_SUFFIX = `${chalk.reset.dim(
'@nrwl/nx/enforce-module-boundaries'
)}`;
process.stdout
.write(`${WARNING_PREFIX} No cached ProjectGraph is available. The rule will be skipped.
If you encounter this error as part of running standard \`nx\` commands then please open an issue on https://github.com/nrwl/nx
${RULE_NAME_SUFFIX}\n`);
}
}
const projectGraph = readProjectGraph(RULE_NAME);
if (!(global as any).projectGraph) {
if (!projectGraph) {
return {};
}
const workspaceLayout = (global as any).workspaceLayout;
const projectGraph = (global as any).projectGraph as MappedProjectGraph;
if (!(global as any).targetProjectLocator) {
(global as any).targetProjectLocator = new TargetProjectLocator(

View File

@ -0,0 +1,480 @@
import type { AST } from 'jsonc-eslint-parser';
import type { TSESLint } from '@typescript-eslint/utils';
import { readJsonFile, workspaceRoot } from '@nrwl/devkit';
import {
findSourceProject,
getSourceFilePath,
MappedProjectGraphNode,
} from '@nrwl/workspace/src/utils/runtime-lint-utils';
import { existsSync } from 'fs';
import { registerTsProject } from 'nx/src/utils/register';
import * as path from 'path';
import { createESLintRule } from '../utils/create-eslint-rule';
import { readProjectGraph } from '../utils/project-graph-utils';
import { valid } from 'semver';
type Options = [
{
generatorsJson?: string;
executorsJson?: string;
migrationsJson?: string;
packageJson?: string;
}
];
const DEFAULT_OPTIONS = {
generatorsJson: 'generators.json',
executorsJson: 'executors.json',
migrationsJson: 'migrations.json',
packageJson: 'package.json',
};
export type MessageIds =
| 'missingRequiredSchema'
| 'invalidSchemaPath'
| 'missingImplementation'
| 'invalidImplementationPath'
| 'invalidImplementationModule'
| 'unableToReadImplementationExports'
| 'invalidVersion'
| 'missingVersion'
| 'noGeneratorsOrSchematicsFound'
| 'noExecutorsOrBuildersFound'
| 'valueShouldBeObject';
export const RULE_NAME = 'nx-plugin-checks';
export default createESLintRule<Options, MessageIds>({
name: RULE_NAME,
meta: {
docs: {
description: 'Checks common nx-plugin configuration files for validity',
recommended: 'error',
},
schema: {},
type: 'problem',
messages: {
invalidSchemaPath: 'Schema path should point to a valid file',
invalidImplementationPath:
'{{ key }}: Implementation path should point to a valid file',
invalidImplementationModule:
'{{ key }}: Unable to find export {{ identifier }} in implementation module',
unableToReadImplementationExports:
'{{ key }}: Unable to read exports for implementation module',
invalidVersion: '{{ key }}: Version should be a valid semver',
noGeneratorsOrSchematicsFound:
'Unable to find `generators` or `schematics` property',
noExecutorsOrBuildersFound:
'Unable to find `executors` or `builders` property',
valueShouldBeObject: '{{ key }} should be an object',
missingRequiredSchema: '{{ key }}: Missing required property - `schema`',
missingImplementation:
'{{ key }}: Missing required property - `implementation`',
missingVersion: '{{ key }}: Missing required property - `version`',
},
},
defaultOptions: [DEFAULT_OPTIONS],
create(context) {
// jsonc-eslint-parser adds this property to parserServices where appropriate
if (!(context.parserServices as any).isJSON) {
return {};
}
const projectGraph = readProjectGraph(RULE_NAME);
const sourceFilePath = getSourceFilePath(
context.getFilename(),
workspaceRoot
);
const sourceProject = findSourceProject(projectGraph, sourceFilePath);
// If source is not part of an nx workspace, return.
if (!sourceProject) {
return {};
}
const { generatorsJson, executorsJson, migrationsJson, packageJson } =
normalizeOptions(sourceProject, context.options[0]);
if (
![generatorsJson, executorsJson, migrationsJson, packageJson].includes(
sourceFilePath
)
) {
return {};
}
if (!(global as any).tsProjectRegistered) {
registerTsProject(workspaceRoot, 'tsconfig.base.json');
(global as any).tsProjectRegistered = true;
}
return {
['JSONExpressionStatement > JSONObjectExpression'](
node: AST.JSONObjectExpression
) {
if (sourceFilePath === generatorsJson) {
checkCollectionFileNode(node, 'generator', context);
} else if (sourceFilePath === migrationsJson) {
checkCollectionFileNode(node, 'migration', context);
} else if (sourceFilePath === executorsJson) {
checkCollectionFileNode(node, 'executor', context);
} else if (sourceFilePath === packageJson) {
validatePackageGroup(node, context);
}
},
};
},
});
function normalizeOptions(
sourceProject: MappedProjectGraphNode<{}>,
options: Options[0]
): Options[0] {
const base = { ...DEFAULT_OPTIONS, ...options };
return {
executorsJson: base.executorsJson
? `${sourceProject.data.root}/${base.executorsJson}`
: undefined,
generatorsJson: base.generatorsJson
? `${sourceProject.data.root}/${base.generatorsJson}`
: undefined,
migrationsJson: base.migrationsJson
? `${sourceProject.data.root}/${base.migrationsJson}`
: undefined,
packageJson: base.packageJson
? `${sourceProject.data.root}/${base.packageJson}`
: undefined,
};
}
export function checkCollectionFileNode(
baseNode: AST.JSONObjectExpression,
mode: 'migration' | 'generator' | 'executor',
context: TSESLint.RuleContext<MessageIds, Options>
) {
const schematicsRootNode = baseNode.properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'schematics'
);
const generatorsRootNode = baseNode.properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'generators'
);
const executorsRootNode = baseNode.properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'executors'
);
const buildersRootNode = baseNode.properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'builders'
);
if (!schematicsRootNode && !generatorsRootNode && mode !== 'executor') {
context.report({
messageId: 'noGeneratorsOrSchematicsFound',
node: baseNode as any,
});
return;
}
if (!executorsRootNode && !buildersRootNode && mode === 'executor') {
context.report({
messageId: 'noExecutorsOrBuildersFound',
node: baseNode as any,
});
return;
}
const collectionNodes = [
{ collectionNode: schematicsRootNode, key: 'schematics' },
{ collectionNode: generatorsRootNode, key: 'generators' },
{ collectionNode: executorsRootNode, key: 'executors' },
{ collectionNode: buildersRootNode, key: 'builders' },
].filter(({ collectionNode }) => !!collectionNode);
for (const { collectionNode, key } of collectionNodes) {
if (collectionNode.value.type !== 'JSONObjectExpression') {
context.report({
messageId: 'valueShouldBeObject',
data: { key },
node: schematicsRootNode as any,
});
} else {
checkCollectionNode(collectionNode.value, mode, context);
}
}
}
export function checkCollectionNode(
baseNode: AST.JSONObjectExpression,
mode: 'migration' | 'generator' | 'executor',
context: TSESLint.RuleContext<MessageIds, Options>
) {
const entries = baseNode.properties;
for (const entryNode of entries) {
if (entryNode.value.type !== 'JSONObjectExpression') {
context.report({
messageId: 'valueShouldBeObject',
data: { key: (entryNode.key as AST.JSONLiteral).value },
node: entryNode as any,
});
} else if (entryNode.key.type === 'JSONLiteral') {
validateEntry(
entryNode.value,
entryNode.key.value.toString(),
mode,
context
);
}
}
}
export function validateEntry(
baseNode: AST.JSONObjectExpression,
key: string,
mode: 'migration' | 'generator' | 'executor',
context: TSESLint.RuleContext<MessageIds, Options>
) {
const schemaNode = baseNode.properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'schema'
);
if (mode !== 'migration' && !schemaNode) {
context.report({
messageId: 'missingRequiredSchema',
data: {
key,
},
node: baseNode as any,
});
} else if (schemaNode) {
if (
schemaNode.value.type !== 'JSONLiteral' ||
typeof schemaNode.value.value !== 'string'
) {
context.report({
messageId: 'invalidSchemaPath',
node: schemaNode.value as any,
});
} else {
const schemaFilePath = path.join(
path.dirname(context.getFilename()),
schemaNode.value.value
);
if (!existsSync(schemaFilePath)) {
context.report({
messageId: 'invalidSchemaPath',
node: schemaNode.value as any,
});
} else {
try {
readJsonFile(schemaFilePath);
} catch (e) {
context.report({
messageId: 'invalidSchemaPath',
node: schemaNode.value as any,
});
}
}
}
}
const implementationNode = baseNode.properties.find(
(x) =>
x.key.type === 'JSONLiteral' &&
(x.key.value === 'implementation' || x.key.value === 'factory')
);
if (!implementationNode) {
context.report({
messageId: 'missingImplementation',
data: {
key,
},
node: baseNode as any,
});
} else {
validateImplemenationNode(implementationNode, key, context);
}
if (mode === 'migration') {
const versionNode = baseNode.properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'version'
);
if (!versionNode) {
context.report({
messageId: 'missingVersion',
data: {
key,
},
node: baseNode as any,
});
} else if (
versionNode.value.type !== 'JSONLiteral' ||
typeof versionNode.value.value !== 'string'
) {
context.report({
messageId: 'invalidVersion',
data: {
key,
},
node: versionNode.value as any,
});
} else {
const specifiedVersion = versionNode.value.value;
if (!valid(specifiedVersion)) {
context.report({
messageId: 'invalidVersion',
data: {
key,
},
node: versionNode.value as any,
});
}
}
}
}
export function validateImplemenationNode(
implementationNode: AST.JSONProperty,
key: string,
context: TSESLint.RuleContext<MessageIds, Options>
) {
if (
implementationNode.value.type !== 'JSONLiteral' ||
typeof implementationNode.value.value !== 'string'
) {
context.report({
messageId: 'invalidImplementationPath',
data: {
key,
},
node: implementationNode.value as any,
});
} else {
const [implementationPath, identifier] =
implementationNode.value.value.split('#');
let resolvedPath: string;
const modulePath = path.join(
path.dirname(context.getFilename()),
implementationPath
);
try {
resolvedPath = require.resolve(modulePath);
} catch (e) {
context.report({
messageId: 'invalidImplementationPath',
data: {
key,
},
node: implementationNode.value as any,
});
}
if (identifier) {
try {
const m = require(resolvedPath);
if (!(identifier in m && typeof m[identifier] === 'function')) {
context.report({
messageId: 'invalidImplementationModule',
node: implementationNode.value as any,
data: {
identifier,
key,
},
});
}
} catch {
context.report({
messageId: 'unableToReadImplementationExports',
node: implementationNode.value as any,
data: {
key,
},
});
}
}
}
}
export function validatePackageGroup(
baseNode: AST.JSONObjectExpression,
context: TSESLint.RuleContext<MessageIds, Options>
) {
const migrationsNode = baseNode.properties.find(
(x) =>
x.key.type === 'JSONLiteral' &&
x.value.type === 'JSONObjectExpression' &&
(x.key.value === 'nx-migrations' ||
x.key.value === 'ng-update' ||
x.key.value === 'migrations')
)?.value as AST.JSONObjectExpression;
const packageGroupNode = migrationsNode?.properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'packageGroup'
);
if (packageGroupNode) {
// Package group is defined as an array
if (packageGroupNode.value.type === 'JSONArrayExpression') {
// Look at entries which are an object
const members = packageGroupNode.value.elements.filter(
(x) => x.type === 'JSONObjectExpression'
);
// validate that the version property exists and is valid
for (const member of members) {
const versionPropertyNode = (
member as AST.JSONObjectExpression
).properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'version'
);
const packageNode = (
member as AST.JSONObjectExpression
).properties.find(
(x) => x.key.type === 'JSONLiteral' && x.key.value === 'package'
);
const key = (packageNode?.value as AST.JSONLiteral)?.value ?? 'unknown';
if (versionPropertyNode) {
if (!validateVersionJsonExpression(versionPropertyNode.value))
context.report({
messageId: 'invalidVersion',
data: { key },
node: versionPropertyNode.value as any,
});
} else {
context.report({
messageId: 'missingVersion',
data: { key },
node: member as any,
});
}
}
// Package group is defined as an object (Record<PackageName, Version>)
} else if (packageGroupNode.value.type === 'JSONObjectExpression') {
const properties = packageGroupNode.value.properties;
// For each property, ensure its value is a valid version
for (const propertyNode of properties) {
if (!validateVersionJsonExpression(propertyNode.value)) {
context.report({
messageId: 'invalidVersion',
data: {
key: (propertyNode.key as AST.JSONLiteral).value,
},
node: propertyNode.value as any,
});
}
}
}
}
}
export function validateVersionJsonExpression(node: AST.JSONExpression) {
return (
node &&
node.type === 'JSONLiteral' &&
typeof node.value === 'string' &&
valid(node.value)
);
}

View File

@ -0,0 +1,40 @@
import { readCachedProjectGraph, readNxJson } from '@nrwl/devkit';
import {
isTerminalRun,
MappedProjectGraph,
mapProjectGraphFiles,
} from '@nrwl/workspace/src/utils/runtime-lint-utils';
import * as chalk from 'chalk';
export function ensureGlobalProjectGraph(ruleName: string) {
/**
* Only reuse graph when running from terminal
* Enforce every IDE change to get a fresh nxdeps.json
*/
if (!(global as any).projectGraph || !isTerminalRun()) {
const nxJson = readNxJson();
(global as any).workspaceLayout = nxJson.workspaceLayout;
/**
* Because there are a number of ways in which the rule can be invoked (executor vs ESLint CLI vs IDE Plugin),
* the ProjectGraph may or may not exist by the time the lint rule is invoked for the first time.
*/
try {
(global as any).projectGraph = mapProjectGraphFiles(
readCachedProjectGraph()
);
} catch {
const WARNING_PREFIX = `${chalk.reset.keyword('orange')('warning')}`;
const RULE_NAME_SUFFIX = `${chalk.reset.dim(`@nrwl/nx/${ruleName}`)}`;
process.stdout
.write(`${WARNING_PREFIX} No cached ProjectGraph is available. The rule will be skipped.
If you encounter this error as part of running standard \`nx\` commands then please open an issue on https://github.com/nrwl/nx
${RULE_NAME_SUFFIX}\n`);
}
}
}
export function readProjectGraph(ruleName: string) {
ensureGlobalProjectGraph(ruleName);
return (global as any).projectGraph as MappedProjectGraph;
}

View File

@ -16,7 +16,6 @@ import {
writeJson,
} from '@nrwl/devkit';
import { jestProjectGenerator } from '@nrwl/jest';
import { findRootJestPreset } from '@nrwl/jest/src/utils/config/find-root-jest-files';
import { Linter, lintProjectGenerator } from '@nrwl/linter';
import { runTasksInSerial } from '@nrwl/workspace/src/utilities/run-tasks-in-serial';
import {

View File

@ -0,0 +1,4 @@
import { convertNxExecutor } from '@nrwl/devkit';
import executor from './lint.impl';
export default convertNxExecutor(executor);

View File

@ -28,6 +28,11 @@
"factory": "./src/generators/executor/executor",
"schema": "./src/generators/executor/schema.json",
"description": "Create a executor for an Nx Plugin."
},
"plugin-lint-checks": {
"factory": "./src/generators/lint-checks/generator",
"schema": "./src/generators/lint-checks/schema.json",
"description": "Adds linting configuration to validate common json files for nx plugins."
}
},
"schematics": {

View File

@ -33,6 +33,7 @@
"@nrwl/linter": "file:../linter",
"fs-extra": "^10.1.0",
"rxjs": "^6.5.4",
"semver": "7.3.4",
"tslib": "^2.3.0"
}
}

View File

@ -0,0 +1,148 @@
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import {
Tree,
readProjectConfiguration,
readJson,
updateJson,
joinPathFragments,
writeJson,
} from '@nrwl/devkit';
import type { Linter as ESLint } from 'eslint';
import { Schema as EsLintExecutorOptions } from '@nrwl/linter/src/executors/eslint/schema';
import generator from './generator';
import pluginGenerator from '../plugin/plugin';
import { Linter } from '@nrwl/linter';
import { PackageJson } from 'nx/src/utils/package-json';
describe('lint-checks generator', () => {
let appTree: Tree;
beforeEach(async () => {
appTree = createTreeWithEmptyWorkspace(2);
await pluginGenerator(appTree, {
name: 'plugin',
importPath: '@acme/plugin',
compiler: 'tsc',
linter: Linter.EsLint,
skipFormat: false,
skipTsConfig: false,
skipLintChecks: true, // we manually call it s.t. we can update config files first
unitTestRunner: 'jest',
});
});
it('should update configuration files for default plugin', async () => {
await generator(appTree, { projectName: 'plugin' });
const projectConfig = readProjectConfiguration(appTree, 'plugin');
const targetConfig = projectConfig.targets?.['lint'];
const eslintConfig: ESLint.Config = readJson(
appTree,
`${projectConfig.root}/.eslintrc.json`
);
expect(targetConfig.options.lintFilePatterns).toContain(
`${projectConfig.root}/generators.json`
);
expect(targetConfig.options.lintFilePatterns).toContain(
`${projectConfig.root}/executors.json`
);
expect(targetConfig.options.lintFilePatterns).toContain(
`${projectConfig.root}/package.json`
);
expect(eslintConfig.overrides).toContainEqual(
expect.objectContaining({
files: expect.arrayContaining([
'./executors.json',
'./package.json',
'./generators.json',
]),
rules: {
'@nrwl/nx/nx-plugin-checks': 'error',
},
})
);
});
it('should not duplicate configuration', async () => {
await generator(appTree, { projectName: 'plugin' });
await generator(appTree, { projectName: 'plugin' });
const projectConfig = readProjectConfiguration(appTree, 'plugin');
const targetConfig = projectConfig.targets?.['lint']
.options as EsLintExecutorOptions;
const eslintConfig: ESLint.Config = readJson(
appTree,
`${projectConfig.root}/.eslintrc.json`
);
const uniqueLintFilePatterns = new Set(targetConfig.lintFilePatterns);
expect(targetConfig.lintFilePatterns).toHaveLength(
uniqueLintFilePatterns.size
);
expect(
eslintConfig.overrides.filter(
(x) => '@nrwl/nx/nx-plugin-checks' in x.rules
)
).toHaveLength(1);
});
it('should update configuration files for angular-style plugin', async () => {
const startingProjectConfig = readProjectConfiguration(appTree, 'plugin');
updateJson(
appTree,
joinPathFragments(startingProjectConfig.root, 'package.json'),
(json: PackageJson) => {
json.schematics = './collection.json';
delete json.generators;
json.builders = './builders.json';
delete json.executors;
json['ng-update'] = './migrations.json';
return json;
}
);
writeJson(
appTree,
joinPathFragments(startingProjectConfig.root, 'migrations.json'),
{}
);
await generator(appTree, { projectName: 'plugin' });
const projectConfig = readProjectConfiguration(appTree, 'plugin');
const targetConfig = projectConfig.targets?.['lint'];
const eslintConfig: ESLint.Config = readJson(
appTree,
`${projectConfig.root}/.eslintrc.json`
);
expect(targetConfig.options.lintFilePatterns).not.toContain(
`${projectConfig.root}/generators.json`
);
expect(targetConfig.options.lintFilePatterns).toContain(
`${projectConfig.root}/collection.json`
);
expect(targetConfig.options.lintFilePatterns).not.toContain(
`${projectConfig.root}/executors.json`
);
expect(targetConfig.options.lintFilePatterns).toContain(
`${projectConfig.root}/builders.json`
);
expect(targetConfig.options.lintFilePatterns).toContain(
`${projectConfig.root}/migrations.json`
);
expect(eslintConfig.overrides).toContainEqual(
expect.objectContaining({
files: expect.arrayContaining([
'./collection.json',
'./package.json',
'./builders.json',
'./migrations.json',
]),
rules: {
'@nrwl/nx/nx-plugin-checks': 'error',
},
})
);
});
});

View File

@ -0,0 +1,255 @@
import {
addDependenciesToPackageJson,
joinPathFragments,
logger,
ProjectConfiguration,
readJson,
readProjectConfiguration,
TargetConfiguration,
Tree,
updateJson,
updateProjectConfiguration,
writeJson,
} from '@nrwl/devkit';
import type { Linter as ESLint } from 'eslint';
import { Schema as EsLintExecutorOptions } from '@nrwl/linter/src/executors/eslint/schema';
import { jsoncEslintParserVersion } from '../../utils/versions';
import { PluginLintChecksGeneratorSchema } from './schema';
import { NX_PREFIX } from 'nx/src/utils/logger';
import { PackageJson, readNxMigrateConfig } from 'nx/src/utils/package-json';
export default async function pluginLintCheckGenerator(
host: Tree,
options: PluginLintChecksGeneratorSchema
) {
const project = readProjectConfiguration(host, options.projectName);
const packageJson = readJson<PackageJson>(
host,
joinPathFragments(project.root, 'package.json')
);
// This rule is eslint **only**
if (projectIsEsLintEnabled(project)) {
updateRootEslintConfig(host);
updateProjectEslintConfig(host, project, packageJson);
updateProjectTarget(host, options, packageJson);
// Project is setup for vscode
if (host.exists('.vscode')) {
setupVsCodeLintingForJsonFiles(host);
}
// Project contains migrations.json
const migrationsPath = readNxMigrateConfig(packageJson).migrations;
if (
migrationsPath &&
host.exists(joinPathFragments(project.root, migrationsPath))
) {
addMigrationJsonChecks(host, options, packageJson);
}
} else {
logger.error(
`${NX_PREFIX} plugin lint checks can only be added to plugins which use eslint for linting`
);
}
const installTask = addDependenciesToPackageJson(
host,
{},
{ 'jsonc-eslint-parser': jsoncEslintParserVersion }
);
return () => installTask;
}
export function addMigrationJsonChecks(
host: Tree,
options: PluginLintChecksGeneratorSchema,
packageJson: PackageJson
) {
const projectConfiguration = readProjectConfiguration(
host,
options.projectName
);
const [eslintTarget, eslintTargetConfiguration] =
getEsLintOptions(projectConfiguration);
const relativeMigrationsJsonPath =
readNxMigrateConfig(packageJson).migrations;
if (!relativeMigrationsJsonPath) {
return;
}
const migrationsJsonPath = joinPathFragments(
projectConfiguration.root,
relativeMigrationsJsonPath
);
if (
eslintTarget &&
!eslintTargetConfiguration.options?.lintFilePatterns?.includes(
migrationsJsonPath
)
) {
// Add to lintFilePatterns
eslintTargetConfiguration.options.lintFilePatterns.push(migrationsJsonPath);
updateProjectConfiguration(host, options.projectName, projectConfiguration);
// Update project level eslintrc
updateJson<ESLint.Config>(
host,
`${projectConfiguration.root}/.eslintrc.json`,
(c) => {
const override = c.overrides.find((o) =>
Object.keys(o.rules ?? {})?.includes('@nrwl/nx/nx-plugin-checks')
);
if (
Array.isArray(override?.files) &&
!override.files.includes(relativeMigrationsJsonPath)
) {
override.files.push(relativeMigrationsJsonPath);
}
return c;
}
);
}
}
function updateProjectTarget(
host: Tree,
options: PluginLintChecksGeneratorSchema,
packageJson: PackageJson
) {
const project = readProjectConfiguration(host, options.projectName);
if (!project.targets) {
return;
}
for (const [target, configuration] of Object.entries(project.targets)) {
if (configuration.executor === '@nrwl/linter:eslint') {
const opts: EsLintExecutorOptions = configuration.options ?? {};
opts.lintFilePatterns ??= [];
if (packageJson.generators) {
opts.lintFilePatterns.push(
joinPathFragments(project.root, packageJson.generators)
);
}
if (
packageJson.schematics &&
packageJson.schematics !== packageJson.generators
) {
opts.lintFilePatterns.push(
joinPathFragments(project.root, packageJson.schematics)
);
}
if (packageJson.executors) {
opts.lintFilePatterns.push(
joinPathFragments(project.root, packageJson.executors)
);
}
if (
packageJson.builders &&
packageJson.builders !== packageJson.executors
) {
opts.lintFilePatterns.push(
joinPathFragments(project.root, packageJson.builders)
);
}
opts.lintFilePatterns.push(`${project.root}/package.json`);
opts.lintFilePatterns = [...new Set(opts.lintFilePatterns)];
project.targets[target].options = opts;
}
}
updateProjectConfiguration(host, options.projectName, project);
}
function updateProjectEslintConfig(
host: Tree,
options: ProjectConfiguration,
packageJson: PackageJson
) {
// Update the project level lint configuration to specify
// the plugin schema rule for generated files
const eslintPath = `${options.root}/.eslintrc.json`;
const eslintConfig = readJson<ESLint.Config>(host, eslintPath);
eslintConfig.overrides ??= [];
if (
!eslintConfig.overrides.some((x) =>
Object.keys(x.rules ?? {}).includes('@nrwl/nx/nx-plugin-checks')
)
) {
eslintConfig.overrides.push({
files: [
'./package.json',
packageJson.generators,
packageJson.executors,
packageJson.schematics,
packageJson.builders,
].filter((f) => !!f),
parser: 'jsonc-eslint-parser',
rules: {
'@nrwl/nx/nx-plugin-checks': 'error',
},
});
}
writeJson(host, eslintPath, eslintConfig);
}
// Update the root eslint to specify a parser for json files
// This is required, otherwise every json file that is not overriden
// will display false errors in the IDE
function updateRootEslintConfig(host: Tree) {
const rootESLint = readJson<ESLint.Config>(host, '.eslintrc.json');
rootESLint.overrides ??= [];
if (!eslintConfigContainsJsonOverride(rootESLint)) {
rootESLint.overrides.push({
files: '*.json',
parser: 'jsonc-eslint-parser',
rules: {},
});
writeJson(host, '.eslintrc.json', rootESLint);
}
}
function setupVsCodeLintingForJsonFiles(host: Tree) {
let existing: Record<string, unknown> = {};
if (host.exists('.vscode/settings.json')) {
existing = readJson<Record<string, unknown>>(host, '.vscode/settings.json');
} else {
logger.info(
`${NX_PREFIX} We've updated the vscode settings for this repository to ensure that plugin lint checks show up inside your IDE. This created .vscode/settings.json. To read more about this file, check vscode's documentation. It is frequently not commited, so other developers may need to add similar settings if they'd like to see the lint checks in the IDE rather than only during linting.`
);
}
// setup eslint validation for json files
const eslintValidate = (existing['eslint.validate'] as string[]) ?? [];
if (!eslintValidate.includes('json')) {
existing['eslint.validate'] = [...eslintValidate, 'json'];
}
writeJson(host, '.vscode/settings.json', existing);
}
function eslintConfigContainsJsonOverride(eslintConfig: ESLint.Config) {
return eslintConfig.overrides.some((x) => {
if (typeof x.files === 'string' && x.files.includes('.json')) {
return true;
}
return Array.isArray(x.files) && x.files.some((f) => f.includes('.json'));
});
}
function projectIsEsLintEnabled(project: ProjectConfiguration) {
return !!getEsLintOptions(project);
}
export function getEsLintOptions(
project: ProjectConfiguration
): [target: string, configuration: TargetConfiguration<EsLintExecutorOptions>] {
return Object.entries(project.targets || {}).find(
([, x]) => x.executor === '@nrwl/linter:eslint'
);
}

View File

@ -0,0 +1,3 @@
export interface PluginLintChecksGeneratorSchema {
projectName: string;
}

View File

@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/schema",
"cli": "nx",
"$id": "PluginLint",
"title": "",
"type": "object",
"description": "Adds linting configuration to validate common json files for nx plugins.",
"properties": {
"projectName": {
"type": "string",
"description": "Which project should be the configuration be added to?",
"$default": {
"$source": "projectName"
}
}
},
"required": ["projectName"]
}

View File

@ -7,11 +7,14 @@ import {
updateJson,
readJson,
writeJson,
joinPathFragments,
} from '@nrwl/devkit';
import type { Tree } from '@nrwl/devkit';
import type { Schema } from './schema';
import * as path from 'path';
import { addMigrationJsonChecks } from '../lint-checks/generator';
import type { Linter as EsLint } from 'eslint';
import { PackageJson } from 'nx/src/utils/package-json';
interface NormalizedSchema extends Schema {
projectRoot: string;
projectSourceRoot: string;
@ -128,6 +131,18 @@ export async function migrationGenerator(host: Tree, schema: Schema) {
updateWorkspaceJson(host, options);
updateMigrationsJson(host, options);
updatePackageJson(host, options);
if (!host.exists('migrations.json')) {
const packageJsonPath = joinPathFragments(
options.projectRoot,
'package.json'
);
addMigrationJsonChecks(
host,
{ projectName: schema.project },
readJson<PackageJson>(host, packageJsonPath)
);
}
}
export default migrationGenerator;

View File

@ -16,6 +16,7 @@ const getSchema: (overrides?: Partial<Schema>) => Schema = (
compiler: 'tsc',
skipTsConfig: false,
skipFormat: false,
skipLintChecks: false,
linter: Linter.EsLint,
unitTestRunner: 'jest',
...overrides,
@ -68,7 +69,12 @@ describe('NxPlugin Plugin Generator', () => {
executor: '@nrwl/linter:eslint',
outputs: ['{options.outputFile}'],
options: {
lintFilePatterns: ['libs/my-plugin/**/*.ts'],
lintFilePatterns: expect.arrayContaining([
'libs/my-plugin/**/*.ts',
'libs/my-plugin/generators.json',
'libs/my-plugin/package.json',
'libs/my-plugin/executors.json',
]),
},
});
expect(project.targets.test).toEqual({

View File

@ -3,17 +3,14 @@ import {
convertNxGenerator,
formatFiles,
generateFiles,
GeneratorCallback,
getWorkspaceLayout,
installPackagesTask,
joinPathFragments,
names,
normalizePath,
readProjectConfiguration,
Tree,
updateProjectConfiguration,
} from '@nrwl/devkit';
import { libraryGenerator } from '@nrwl/js';
import { Linter } from '@nrwl/linter';
import { addSwcDependencies } from '@nrwl/js/src/utils/swc/add-swc-dependencies';
import { swcNodeVersion } from 'nx/src/utils/versions';
import * as path from 'path';
@ -22,49 +19,10 @@ import { nxVersion } from '../../utils/versions';
import { e2eProjectGenerator } from '../e2e-project/e2e';
import { executorGenerator } from '../executor/executor';
import { generatorGenerator } from '../generator/generator';
import pluginLintCheckGenerator from '../lint-checks/generator';
import { NormalizedSchema, normalizeOptions } from './utils/normalize-schema';
import type { Schema } from './schema';
interface NormalizedSchema extends Schema {
name: string;
fileName: string;
libsDir: string;
projectRoot: string;
projectDirectory: string;
parsedTags: string[];
npmScope: string;
npmPackageName: string;
}
function normalizeOptions(host: Tree, options: Schema): NormalizedSchema {
const { npmScope, libsDir } = getWorkspaceLayout(host);
const name = names(options.name).fileName;
const projectDirectory = options.directory
? `${names(options.directory).fileName}/${name}`
: name;
const projectName = projectDirectory.replace(new RegExp('/', 'g'), '-');
const fileName = projectName;
const projectRoot = joinPathFragments(libsDir, projectDirectory);
const parsedTags = options.tags
? options.tags.split(',').map((s) => s.trim())
: [];
const npmPackageName =
options.importPath || resolvePackageName(npmScope, name);
return {
...options,
fileName,
npmScope,
libsDir,
name: projectName,
projectRoot,
projectDirectory,
parsedTags,
npmPackageName,
};
}
async function addFiles(host: Tree, options: NormalizedSchema) {
host.delete(normalizePath(`${options.projectRoot}/src/lib`));
@ -129,7 +87,7 @@ function updateWorkspaceJson(host: Tree, options: NormalizedSchema) {
export async function pluginGenerator(host: Tree, schema: Schema) {
const options = normalizeOptions(host, schema);
const libraryTask = await libraryGenerator(host, {
await libraryGenerator(host, {
...schema,
config: options.standaloneConfig !== false ? 'project' : 'workspace',
buildable: true,
@ -154,7 +112,6 @@ export async function pluginGenerator(host: Tree, schema: Schema) {
await addFiles(host, options);
updateWorkspaceJson(host, options);
await e2eProjectGenerator(host, {
pluginName: options.name,
projectDirectory: options.projectDirectory,
@ -162,19 +119,14 @@ export async function pluginGenerator(host: Tree, schema: Schema) {
npmPackageName: options.npmPackageName,
standaloneConfig: options.standaloneConfig ?? true,
});
if (options.linter === Linter.EsLint && !options.skipLintChecks) {
await pluginLintCheckGenerator(host, { projectName: options.name });
}
await formatFiles(host);
return () => installPackagesTask(host);
}
function resolvePackageName(npmScope: string, name: string): string {
if (npmScope && npmScope !== '') {
return `@${npmScope}/${name}`;
} else {
return name;
}
}
export default pluginGenerator;
export const pluginSchematic = convertNxGenerator(pluginGenerator);

View File

@ -6,6 +6,7 @@ export interface Schema {
importPath?: string;
skipTsConfig: boolean;
skipFormat: boolean;
skipLintChecks: boolean;
tags?: string;
unitTestRunner: 'jest' | 'none';
linter: Linter;

View File

@ -34,7 +34,8 @@
"description": "The tool to use for running lint checks.",
"type": "string",
"enum": ["eslint", "tslint"],
"default": "eslint"
"default": "eslint",
"x-deprecated": "TSLint support is deprecated and will be removed"
},
"unitTestRunner": {
"type": "string",
@ -57,6 +58,11 @@
"default": false,
"description": "Do not update tsconfig.json for development experience."
},
"skipLintChecks": {
"type": "boolean",
"default": false,
"description": "Do not eslint configuration for plugin json files."
},
"standaloneConfig": {
"description": "Split the project configuration into `<projectRoot>/project.json` rather than including it inside `workspace.json`.",
"type": "boolean"

View File

@ -0,0 +1,59 @@
import {
getWorkspaceLayout,
joinPathFragments,
names,
Tree,
} from '@nrwl/devkit';
import { Schema } from '../schema';
export interface NormalizedSchema extends Schema {
name: string;
fileName: string;
libsDir: string;
projectRoot: string;
projectDirectory: string;
parsedTags: string[];
npmScope: string;
npmPackageName: string;
}
export function normalizeOptions(
host: Tree,
options: Schema
): NormalizedSchema {
const { npmScope, libsDir } = getWorkspaceLayout(host);
const name = names(options.name).fileName;
const projectDirectory = options.directory
? `${names(options.directory).fileName}/${name}`
: name;
const projectName = projectDirectory.replace(new RegExp('/', 'g'), '-');
const fileName = projectName;
const projectRoot = joinPathFragments(libsDir, projectDirectory);
const parsedTags = options.tags
? options.tags.split(',').map((s) => s.trim())
: [];
const npmPackageName =
options.importPath || resolvePackageName(npmScope, name);
return {
...options,
fileName,
npmScope,
libsDir,
name: projectName,
projectRoot,
projectDirectory,
parsedTags,
npmPackageName,
};
}
function resolvePackageName(npmScope: string, name: string): string {
if (npmScope && npmScope !== '') {
return `@${npmScope}/${name}`;
} else {
return name;
}
}

View File

@ -1 +1,2 @@
export const nxVersion = require('../../package.json').version;
export const jsoncEslintParserVersion = '^2.1.0';

View File

@ -20,6 +20,7 @@ import {
NxMigrationsConfiguration,
PackageGroup,
PackageJson,
readNxMigrateConfig,
} from '../utils/package-json';
import {
createTempNpmDirectory,
@ -593,33 +594,6 @@ async function getPackageMigrationsUsingRegistry(
);
}
function resolveNxMigrationConfig(json: Partial<PackageJson>) {
const parseNxMigrationsConfig = (
fromJson?: string | NxMigrationsConfiguration
): NxMigrationsConfiguration => {
if (!fromJson) {
return {};
}
if (typeof fromJson === 'string') {
return { migrations: fromJson, packageGroup: [] };
}
return {
...(fromJson.migrations ? { migrations: fromJson.migrations } : {}),
...(fromJson.packageGroup ? { packageGroup: fromJson.packageGroup } : {}),
};
};
const config: NxMigrationsConfiguration = {
...parseNxMigrationsConfig(json['ng-update']),
...parseNxMigrationsConfig(json['nx-migrations']),
// In case there's a `migrations` field in `package.json`
...parseNxMigrationsConfig(json as any),
};
return config;
}
async function getPackageMigrationsConfigFromRegistry(
packageName: string,
packageVersion: string
@ -634,7 +608,7 @@ async function getPackageMigrationsConfigFromRegistry(
return null;
}
return resolveNxMigrationConfig(JSON.parse(result));
return readNxMigrateConfig(JSON.parse(result));
}
async function downloadPackageMigrationsFromRegistry(

View File

@ -112,7 +112,7 @@ export interface TargetDependencyConfig {
/**
* Target's configuration
*/
export interface TargetConfiguration {
export interface TargetConfiguration<T = any> {
/**
* The executor/builder used to implement the target.
*
@ -134,7 +134,7 @@ export interface TargetConfiguration {
/**
* Target's options. They are passed in to the executor.
*/
options?: any;
options?: T;
/**
* Sets of options

View File

@ -54,6 +54,33 @@ export interface PackageJson {
'ng-update'?: string | NxMigrationsConfiguration;
}
export function readNxMigrateConfig(
json: Partial<PackageJson>
): NxMigrationsConfiguration {
const parseNxMigrationsConfig = (
fromJson?: string | NxMigrationsConfiguration
): NxMigrationsConfiguration => {
if (!fromJson) {
return {};
}
if (typeof fromJson === 'string') {
return { migrations: fromJson, packageGroup: [] };
}
return {
...(fromJson.migrations ? { migrations: fromJson.migrations } : {}),
...(fromJson.packageGroup ? { packageGroup: fromJson.packageGroup } : {}),
};
};
return {
...parseNxMigrationsConfig(json['ng-update']),
...parseNxMigrationsConfig(json['nx-migrations']),
// In case there's a `migrations` field in `package.json`
...parseNxMigrationsConfig(json as any),
};
}
export function buildTargetFromScript(
script: string,
nx: NxProjectPackageJsonConfiguration

View File

@ -70,7 +70,7 @@
},
"hook": {
"factory": "./src/generators/hook/hook#chookSchematic",
"factory": "./src/generators/hook/hook#hookSchematic",
"schema": "./src/generators/hook/schema.json",
"description": "Create a hook.",
"aliases": ["h"]