diff --git a/src/rules/eslint-types.js b/src/rules/eslint-types.js new file mode 100644 index 0000000..bdaa517 --- /dev/null +++ b/src/rules/eslint-types.js @@ -0,0 +1,16 @@ +module.exports = { + types: { + ArrayExpression: 'ArrayExpression', + ArrowFunctionExpression: 'ArrowFunctionExpression', + AssignmentExpression: 'AssignmentExpression', + BlockStatement: 'BlockStatement', + CallExpression: 'CallExpression', + ExpressionStatement: 'ExpressionStatement', + FunctionExpression: 'FunctionExpression', + Identifier: 'Identifier', + MemberExpression: 'MemberExpression', + ObjectExpression: 'ObjectExpression', + Property: 'Property', + ReturnStatement: 'ReturnStatement', + }, +}; diff --git a/src/rules/webpack-entry-point.js b/src/rules/webpack-entry-point.js new file mode 100644 index 0000000..95d9985 --- /dev/null +++ b/src/rules/webpack-entry-point.js @@ -0,0 +1,227 @@ +const path = require('path'); +const { types } = require('./eslint-types'); +/* + Ensure that the `webpack.config.js` file correctly specifies the entry point + as `aurelia-bootstrapper`. + + It expects the export to look something like this (exporting either a function or object) + + module.exports = function () { + return { + entry: { + app: ['aurelia-bootstrapper'] + }, + } + }; +*/ + +const webpackConfigFileName = 'webpack.config.js'; + +const isAssignedToModuleExports = context => { + const ancestors = context.getAncestors(); + + const appProperty = ancestors.pop(); + if ( + !appProperty || + appProperty.type !== types.Property || + appProperty.key.name !== 'app' + ) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring appProperty', appProperty); + + return false; + } + + const entryObjectExpression = ancestors.pop(); + if ( + !entryObjectExpression || + entryObjectExpression.type !== types.ObjectExpression + ) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring entryObjectExpression', entryObjectExpression); + return false; + } + + const entryProperty = ancestors.pop(); + if ( + !entryProperty || + entryProperty.type !== types.Property || + entryProperty.key.name !== 'entry' + ) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring entryProperty', entryProperty); + return false; + } + + const objectExpression = ancestors.pop(); + if (!objectExpression || objectExpression.type !== types.ObjectExpression) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring objectExpression', objectExpression); + return false; + } + + // TODO Handle direct assignment rather than functions + // TODO Handle arrow functions with body + const returnStatementOrArrowFunctionExpression = ancestors.pop(); + if (!returnStatementOrArrowFunctionExpression) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log( + 'Ignoring returnStatementOrArrowFunctionExpression', + returnStatementOrArrowFunctionExpression + ); + return false; + } + + if (returnStatementOrArrowFunctionExpression.type === types.ReturnStatement) { + const blockStatement = ancestors.pop(); + if (!blockStatement || blockStatement.type !== types.BlockStatement) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring blockStatement', blockStatement); + return false; + } + + const functionExpression = ancestors.pop(); + if ( + !functionExpression || + functionExpression.type !== types.FunctionExpression + ) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring functionExpression', functionExpression); + return false; + } + } else if ( + returnStatementOrArrowFunctionExpression.type === + types.ArrowFunctionExpression + ) { + // No additional tokens to consume + } else { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log( + 'Ignoring unknown type returnStatementOrArrowFunctionExpression', + returnStatementOrArrowFunctionExpression + ); + } + + const assignmentExpression = ancestors.pop(); + if ( + !assignmentExpression || + assignmentExpression.type !== types.AssignmentExpression || + assignmentExpression.operator !== '=' + ) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring assignmentExpression', assignmentExpression); + return false; + } + + const expressionStatement = ancestors.pop(); + if ( + !expressionStatement || + expressionStatement.type !== types.ExpressionStatement + ) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring expressionStatement', expressionStatement); + return false; + } + + const leftHandSideOfExpressionStatement = expressionStatement.expression.left; + if ( + !leftHandSideOfExpressionStatement || + leftHandSideOfExpressionStatement.type !== types.MemberExpression + ) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log( + 'Ignoring leftHandSideOfExpressionStatement', + leftHandSideOfExpressionStatement + ); + return false; + } + + const object = leftHandSideOfExpressionStatement.object; + if (!object || object.type !== types.Identifier || object.name !== 'module') { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring object', object); + return false; + } + + const property = leftHandSideOfExpressionStatement.property; + if ( + !property || + property.type !== types.Identifier || + property.name !== 'exports' + ) { + // TODO: Remove Console + // eslint-disable-next-line no-console + console.log('Ignoring property', property); + return false; + } + + return true; +}; + +const webpackEntryPointIsAureliaBootrap = context => node => { + const basename = path.basename(context.getFilename()); + + // Only webpack.config.js is checked + if (basename !== webpackConfigFileName) { + return; + } + + if (node.name === 'app') { + if (!isAssignedToModuleExports(context)) { + return; + } + + const parent = node.parent; + const value = parent.value; + if (value.type !== types.ArrayExpression) { + context.report({ + node, + message: 'entry.app must be an array of strings', + }); + return; + } + const elements = value.elements; + + if (elements.length !== 1 || elements[0].value !== 'aurelia-bootstrapper') { + context.report({ + node, + message: + "entry.app must be ['aurelia-bootstrapper']: found {{ value }}", + data: { + value: context.getSourceCode().getText(value), + }, + }); + return; + } + } +}; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'TODO', + category: 'aurelia', + fixable: 'code', + }, + }, + create: context => { + // state here + + return { + Identifier: webpackEntryPointIsAureliaBootrap(context), + }; + }, +}; diff --git a/test/rules/webpack-entry-point.spec.js b/test/rules/webpack-entry-point.spec.js new file mode 100644 index 0000000..78c54eb --- /dev/null +++ b/test/rules/webpack-entry-point.spec.js @@ -0,0 +1,75 @@ +import eslint from 'eslint'; +import rule from '../../src/rules/webpack-entry-point'; + +const ruleTester = new eslint.RuleTester({ parser: 'babel-eslint' }); + +const moduleExportsAsFunction = ({ app }) => ` + module.exports = function () { + return { + entry: { + ${app ? `app: ['${app}']` : ''} + }, + } + }; +`; + +const moduleExportsAsArrowFunction = ({ app }) => ` + module.exports = ({ production, server, extractCss, coverage, analyze, karma } = {}) => ({ + entry: { + ${app ? `app: ['${app}']` : ''} + }, + }); +`; + +const embedFilenameInCode = ({ filename, code }) => ({ + filename, + code: `// filename=${filename}\n${code}`, +}); + +// Use https://astexplorer.net/ and `espree` tokens to transform your `code` +// into an AST for use here. +ruleTester.run('webpack-entry-point', rule, { + valid: [ + { + // Only webpack.config.js is checked for valid entry.app values + ...embedFilenameInCode({ + filename: '/some/dir/not.webpack.config.js', + code: moduleExportsAsFunction({ app: 'not-aurelia-bootstrapper' }), + }), + }, + { + ...embedFilenameInCode({ + filename: '/some/dir/webpack.config.js', + code: moduleExportsAsArrowFunction({ app: 'aurelia-bootstrapper' }), + }), + }, + ], + invalid: [ + { + ...embedFilenameInCode({ + filename: '/some/dir/webpack.config.js', + code: moduleExportsAsFunction({ app: 'not-aurelia-bootstrapper' }), + }), + errors: [ + { + message: + "entry.app must be ['aurelia-bootstrapper']: found ['not-aurelia-bootstrapper']", + type: 'Identifier', + }, + ], + }, + { + ...embedFilenameInCode({ + filename: '/some/dir/webpack.config.js', + code: moduleExportsAsArrowFunction({ app: 'not-aurelia-bootstrapper' }), + }), + errors: [ + { + message: + "entry.app must be ['aurelia-bootstrapper']: found ['not-aurelia-bootstrapper']", + type: 'Identifier', + }, + ], + }, + ], +});