diff --git a/src/configuration.js b/src/configuration.js index 0ed927af..6d41bba4 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -3,6 +3,7 @@ import { readSync, readFileSync } from 'fs'; import path from 'path'; import { sync as globSync, hasMagic as globHasMagic } from 'glob'; +import ModuleResolver from './relative_module_resolver.js'; import { SourceMap } from './source_map.js'; import JSONFormatter from './formatters/json_formatter.js'; import TextFormatter from './formatters/text_formatter.js'; @@ -15,6 +16,7 @@ export class Configuration { - format: (required) `text` | `json` - rules: [string array] whitelist rules - schemaPaths: [string array] file(s) to read schema from + - customRulePackages: [string array] names of packages where the entry point named exports are rules - customRulePaths: [string array] path to additional custom rules to be loaded - stdin: [boolean] pass schema via stdin? - commentDescriptions: [boolean] use old way of defining descriptions in GraphQL SDL @@ -23,6 +25,7 @@ export class Configuration { constructor(options = {}, stdinFd = null) { const defaultOptions = { format: 'text', + customRulePackages: [], customRulePaths: [], commentDescriptions: false, oldImplementsSyntax: false, @@ -36,6 +39,7 @@ export class Configuration { this.schema = null; this.sourceMap = null; this.rules = null; + this.rulePackages = this.options.customRulePackages; this.builtInRulePaths = path.join(__dirname, 'rules/*.js'); this.rulePaths = this.options.customRulePaths.concat(this.builtInRulePaths); } @@ -129,11 +133,30 @@ export class Configuration { return this.rules; } - this.rules = this.getRulesFromPaths(this.rulePaths); + const packageRules = this.getRulesFromPackages(this.rulePackages); + const pathRules = this.getRulesFromPaths(this.rulePaths); + + this.rules = packageRules.concat(pathRules); return this.rules; } + getRulesFromPackages(rulePackages) { + const rules = new Set([]); + + rulePackages.map(rulePackage => { + // We can't simply call `require()` because it needs to be from the project's node_modules + const rulePackagePath = ModuleResolver.resolve( + rulePackage, + path.join(process.cwd(), '__placeholder__.js') + ); + let ruleMap = require(rulePackagePath); + Object.keys(ruleMap).forEach(k => rules.add(ruleMap[k])); + }); + + return Array.from(rules); + } + getRulesFromPaths(rulePaths) { const expandedPaths = expandPaths(rulePaths); const rules = new Set([]); @@ -156,23 +179,43 @@ export class Configuration { let rules; try { - rules = this.getAllRules(); + rules = this.getRulesFromPackages(this.rulePackages); } catch (e) { if (e.code === 'MODULE_NOT_FOUND') { issues.push({ message: `There was an issue loading the specified custom rules: '${ e.message.split('\n')[0] }'`, - field: 'custom-rule-paths', + field: 'custom-rule-packages', type: 'error', }); - rules = this.getAllBuiltInRules(); + rules = []; + } else { + throw e; + } + } + + try { + rules = rules.concat(this.getRulesFromPaths(this.rulePaths)); + } catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + issues.push({ + message: `There was an issue loading the specified custom rules: '${ + e.message.split('\n')[0] + }'`, + field: 'custom-rule-paths', + type: 'error', + }); } else { throw e; } } + if (rules.length === 0) { + rules = this.getAllBuiltInRules(); + } + const ruleNames = rules.map(rule => rule.name); let misConfiguredRuleNames = [] @@ -234,6 +277,7 @@ function loadOptionsFromConfig(configDirectory) { return { rules: cosmic.config.rules, + customRulePackages: cosmic.config.customRulePackages, customRulePaths: customRulePaths || [], schemaPaths: schemaPaths, }; diff --git a/src/relative_module_resolver.js b/src/relative_module_resolver.js new file mode 100644 index 00000000..275af8e5 --- /dev/null +++ b/src/relative_module_resolver.js @@ -0,0 +1,42 @@ +/** + * Utility for resolving a module relative to another module + * @author Teddy Katz + * Copied from https://github.com/eslint/eslint/blob/master/lib/shared/relative-module-resolver.js + */ + +'use strict'; + +const Module = require('module'); + +/* + * `Module.createRequire` is added in v12.2.0. It supports URL as well. + * We only support the case where the argument is a filepath, not a URL. + */ +const createRequire = Module.createRequire || Module.createRequireFromPath; + +module.exports = { + /** + * Resolves a Node module relative to another module + * @param {string} moduleName The name of a Node module, or a path to a Node module. + * @param {string} relativeToPath An absolute path indicating the module that `moduleName` should be resolved relative to. This must be + * a file rather than a directory, but the file need not actually exist. + * @returns {string} The absolute path that would result from calling `require.resolve(moduleName)` in a file located at `relativeToPath` + */ + resolve(moduleName, relativeToPath) { + try { + return createRequire(relativeToPath).resolve(moduleName); + } catch (error) { + // This `if` block is for older Node.js than 12.0.0. We can remove this block in the future. + if ( + typeof error === 'object' && + error !== null && + error.code === 'MODULE_NOT_FOUND' && + !error.requireStack && + error.message.includes(moduleName) + ) { + error.message += `\nRequire stack:\n- ${relativeToPath}`; + } + throw error; + } + }, +};