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:
parent
70efd2edd7
commit
10363e3bec
@ -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
@ -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": [
|
||||
|
||||
@ -188,7 +188,8 @@
|
||||
"e2e-project",
|
||||
"migration",
|
||||
"generator",
|
||||
"executor"
|
||||
"executor",
|
||||
"plugin-lint-checks"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
480
packages/eslint-plugin-nx/src/rules/nx-plugin-checks.ts
Normal file
480
packages/eslint-plugin-nx/src/rules/nx-plugin-checks.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
40
packages/eslint-plugin-nx/src/utils/project-graph-utils.ts
Normal file
40
packages/eslint-plugin-nx/src/utils/project-graph-utils.ts
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
4
packages/linter/src/executors/lint/compat.ts
Normal file
4
packages/linter/src/executors/lint/compat.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { convertNxExecutor } from '@nrwl/devkit';
|
||||
import executor from './lint.impl';
|
||||
|
||||
export default convertNxExecutor(executor);
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
148
packages/nx-plugin/src/generators/lint-checks/generator.spec.ts
Normal file
148
packages/nx-plugin/src/generators/lint-checks/generator.spec.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
255
packages/nx-plugin/src/generators/lint-checks/generator.ts
Normal file
255
packages/nx-plugin/src/generators/lint-checks/generator.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
3
packages/nx-plugin/src/generators/lint-checks/schema.d.ts
vendored
Normal file
3
packages/nx-plugin/src/generators/lint-checks/schema.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export interface PluginLintChecksGeneratorSchema {
|
||||
projectName: string;
|
||||
}
|
||||
18
packages/nx-plugin/src/generators/lint-checks/schema.json
Normal file
18
packages/nx-plugin/src/generators/lint-checks/schema.json
Normal 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"]
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -6,6 +6,7 @@ export interface Schema {
|
||||
importPath?: string;
|
||||
skipTsConfig: boolean;
|
||||
skipFormat: boolean;
|
||||
skipLintChecks: boolean;
|
||||
tags?: string;
|
||||
unitTestRunner: 'jest' | 'none';
|
||||
linter: Linter;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export const nxVersion = require('../../package.json').version;
|
||||
export const jsoncEslintParserVersion = '^2.1.0';
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user