diff --git a/e2e/plugin/src/nx-plugin-ts-solution.test.ts b/e2e/plugin/src/nx-plugin-ts-solution.test.ts index df3473383c663..e2a8838f93445 100644 --- a/e2e/plugin/src/nx-plugin-ts-solution.test.ts +++ b/e2e/plugin/src/nx-plugin-ts-solution.test.ts @@ -69,7 +69,7 @@ describe('Nx Plugin (TS solution)', () => { const executor = uniq('executor'); const generatedProject = uniq('project'); - runCLI(`generate @nx/plugin:plugin packages/${plugin}`); + runCLI(`generate @nx/plugin:plugin packages/${plugin} --linter eslint`); runCLI( `generate @nx/plugin:generator --name ${generator} --path packages/${plugin}/src/generators/${generator}/generator` @@ -97,6 +97,7 @@ describe('Nx Plugin (TS solution)', () => { expect(() => checkFilesExist(`libs/${generatedProject}`)).not.toThrow(); expect(() => runCLI(`execute ${generatedProject}`)).not.toThrow(); + expect(() => runCLI(`lint ${generatedProject}`)).not.toThrow(); }); it('should be able to resolve local generators and executors using package.json development condition export', async () => { diff --git a/packages/eslint-plugin/src/rules/nx-plugin-checks.ts b/packages/eslint-plugin/src/rules/nx-plugin-checks.ts index c6c7f9c3d5077..c2a2bec8fc585 100644 --- a/packages/eslint-plugin/src/rules/nx-plugin-checks.ts +++ b/packages/eslint-plugin/src/rules/nx-plugin-checks.ts @@ -9,7 +9,6 @@ import { } from '@nx/devkit'; import { getRootTsConfigPath } from '@nx/js'; import { registerTsProject } from '@nx/js/src/internal'; -import { existsSync } from 'fs'; import * as path from 'path'; import { valid } from 'semver'; import { readProjectGraph } from '../utils/project-graph-utils'; @@ -26,15 +25,22 @@ type Options = [ migrationsJson?: string; packageJson?: string; allowedVersionStrings: string[]; + tsConfig?: string; } ]; +type NormalizedOptions = Options[0] & { + rootDir?: string; + outDir?: string; +}; + const DEFAULT_OPTIONS: Options[0] = { generatorsJson: 'generators.json', executorsJson: 'executors.json', migrationsJson: 'migrations.json', packageJson: 'package.json', allowedVersionStrings: ['*', 'latest', 'next'], + tsConfig: 'tsconfig.lib.json', }; export type MessageIds = @@ -88,6 +94,11 @@ export default ESLintUtils.RuleCreator(() => ``)({ 'A list of specifiers that are valid for versions within package group. Defaults to ["*", "latest", "next"]', items: { type: 'string' }, }, + tsConfig: { + type: 'string', + description: + 'The path to the tsconfig file used to build the plugin. Defaults to "tsconfig.lib.json".', + }, }, additionalProperties: false, }, @@ -159,11 +170,11 @@ export default ESLintUtils.RuleCreator(() => ``)({ node: AST.JSONObjectExpression ) { if (sourceFilePath === generatorsJson) { - checkCollectionFileNode(node, 'generator', context); + checkCollectionFileNode(node, 'generator', context, options); } else if (sourceFilePath === migrationsJson) { - checkCollectionFileNode(node, 'migration', context); + checkCollectionFileNode(node, 'migration', context, options); } else if (sourceFilePath === executorsJson) { - checkCollectionFileNode(node, 'executor', context); + checkCollectionFileNode(node, 'executor', context, options); } else if (sourceFilePath === packageJson) { validatePackageGroup(node, context); } @@ -175,8 +186,21 @@ export default ESLintUtils.RuleCreator(() => ``)({ function normalizeOptions( sourceProject: ProjectGraphProjectNode, options: Options[0] -): Options[0] { +): NormalizedOptions { + let rootDir: string; + let outDir: string; const base = { ...DEFAULT_OPTIONS, ...options }; + let runtimeTsConfig: string; + try { + runtimeTsConfig = require.resolve( + path.join(sourceProject.data.root, base.tsConfig) + ); + const tsConfig = readJsonFile(runtimeTsConfig); + rootDir = tsConfig.compilerOptions?.rootDir; + outDir = tsConfig.compilerOptions?.outDir; + } catch { + // nothing + } const pathPrefix = sourceProject.data.root !== '.' ? `${sourceProject.data.root}/` : ''; return { @@ -193,13 +217,16 @@ function normalizeOptions( packageJson: base.packageJson ? `${pathPrefix}${base.packageJson}` : undefined, + rootDir: rootDir ? path.join(sourceProject.data.root, rootDir) : undefined, + outDir: outDir ? path.join(sourceProject.data.root, outDir) : undefined, }; } export function checkCollectionFileNode( baseNode: AST.JSONObjectExpression, mode: 'migration' | 'generator' | 'executor', - context: TSESLint.RuleContext + context: TSESLint.RuleContext, + options: NormalizedOptions ) { const schematicsRootNode = baseNode.properties.find( (x) => x.key.type === 'JSONLiteral' && x.key.value === 'schematics' @@ -246,7 +273,7 @@ export function checkCollectionFileNode( node: schematicsRootNode as any, }); } else { - checkCollectionNode(collectionNode.value, mode, context); + checkCollectionNode(collectionNode.value, mode, context, options); } } } @@ -254,7 +281,8 @@ export function checkCollectionFileNode( export function checkCollectionNode( baseNode: AST.JSONObjectExpression, mode: 'migration' | 'generator' | 'executor', - context: TSESLint.RuleContext + context: TSESLint.RuleContext, + options: NormalizedOptions ) { const entries = baseNode.properties; @@ -270,7 +298,8 @@ export function checkCollectionNode( entryNode.value, entryNode.key.value.toString(), mode, - context + context, + options ); } } @@ -280,8 +309,9 @@ export function validateEntry( baseNode: AST.JSONObjectExpression, key: string, mode: 'migration' | 'generator' | 'executor', - context: TSESLint.RuleContext -) { + context: TSESLint.RuleContext, + options: NormalizedOptions +): void { const schemaNode = baseNode.properties.find( (x) => x.key.type === 'JSONLiteral' && x.key.value === 'schema' ); @@ -303,24 +333,29 @@ export function validateEntry( node: schemaNode.value as any, }); } else { + let validJsonFound = false; const schemaFilePath = path.join( path.dirname(context.filename ?? context.getFilename()), schemaNode.value.value ); - if (!existsSync(schemaFilePath)) { + try { + readJsonFile(schemaFilePath); + validJsonFound = true; + } catch { + try { + // Try to map back to source, which will be the case with TS solution setup. + readJsonFile(schemaFilePath.replace(options.outDir, options.rootDir)); + validJsonFound = true; + } catch { + // nothing, will be reported below + } + } + + if (!validJsonFound) { context.report({ messageId: 'invalidSchemaPath', node: schemaNode.value as any, }); - } else { - try { - readJsonFile(schemaFilePath); - } catch (e) { - context.report({ - messageId: 'invalidSchemaPath', - node: schemaNode.value as any, - }); - } } } } @@ -339,7 +374,7 @@ export function validateEntry( node: baseNode as any, }); } else { - validateImplemenationNode(implementationNode, key, context); + validateImplementationNode(implementationNode, key, context, options); } if (mode === 'migration') { @@ -380,10 +415,11 @@ export function validateEntry( } } -export function validateImplemenationNode( +export function validateImplementationNode( implementationNode: AST.JSONProperty, key: string, - context: TSESLint.RuleContext + context: TSESLint.RuleContext, + options: NormalizedOptions ) { if ( implementationNode.value.type !== 'JSONLiteral' || @@ -408,7 +444,17 @@ export function validateImplemenationNode( try { resolvedPath = require.resolve(modulePath); - } catch (e) { + } catch { + try { + resolvedPath = require.resolve( + modulePath.replace(options.outDir, options.rootDir) + ); + } catch { + // nothing, will be reported below + } + } + + if (!resolvedPath) { context.report({ messageId: 'invalidImplementationPath', data: {