diff --git a/.eslintrc.yml b/.eslintrc.yml index b753f7b2..ce7ec1da 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -8,6 +8,7 @@ extends: root: true rules: require-jsdoc: error + self/require-meta-docs-url: off self/report-message-format: - error - '^[^a-z].*\.$' diff --git a/README.md b/README.md index 17993afa..b02bde3a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Name | ✔️ | 🛠 | Description [prefer-placeholders](https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/prefer-placeholders.md) | | | disallow template literals as report messages [prefer-replace-text](https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/prefer-replace-text.md) | | | prefer using replaceText instead of replaceTextRange. [report-message-format](https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/report-message-format.md) | | | enforce a consistent format for rule report messages +[require-meta-docs-url](https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/require-meta-docs-url.md) | | 🛠 | require rules to implement a meta.docs.url property [require-meta-fixable](https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/require-meta-fixable.md) | ✔️ | | require rules to implement a meta.fixable property [test-case-property-ordering](https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/test-case-property-ordering.md) | | 🛠 | Requires the properties of a test case to be placed in a consistent order [test-case-shorthand-strings](https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/test-case-shorthand-strings.md) | | 🛠 | Enforce consistent usage of shorthand strings for test cases with no options diff --git a/docs/rules/require-meta-docs-url.md b/docs/rules/require-meta-docs-url.md new file mode 100644 index 00000000..e0875c2b --- /dev/null +++ b/docs/rules/require-meta-docs-url.md @@ -0,0 +1,150 @@ +# require rules to implement a meta.docs.url property (require-meta-docs-url) + +`meta.docs.url` property is the official location to store a URL to their documentation in the rule metadata. +Some integration tools will show the URL to users to understand rules. + +## Rule Details + +This rule aims to require ESLint rules to have a `meta.docs.url` property. + +This rule has an option. + +```json +{ + "eslint-plugin/require-meta-docs-url": ["error", { + "pattern": "https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/{{name}}.md" + }] +} +``` + +- `pattern` (`string`) ... A pattern to enforce rule's document URL. It replaces `{{name}}` placeholder by each rule name. The rule name is the basename of each rule file. Default is undefined. + +If you set the `pattern` option, this rule adds `meta.docs.url` property automatically when you execute `eslint --fix` command. + +The following patterns are considered warnings: + +```js + +/* eslint eslint-plugin/require-meta-docs-url: "error" */ + +module.exports = { + meta: {}, + create(context) { + } +}; + +``` + +```js + +/* eslint eslint-plugin/require-meta-docs-url: "error" */ + +module.exports = { + meta: { + docs: { + url: undefined + } + }, + create(context) { + } +}; + +``` + +```js + +/* eslint eslint-plugin/require-meta-docs-url: ["error", {"pattern": "path/to/{{name}}.md"}] */ + +module.exports = { + meta: { + docs: { + url: "wrong URL" + } + }, + create(context) { + } +}; + +``` + +The following patterns are not warnings: + +```js + +/* eslint eslint-plugin/require-meta-docs-url: "error" */ + +module.exports = { + meta: { + docs: { + url: "a URL" + } + }, + create(context) { + } +}; + +``` + +```js + +/* eslint eslint-plugin/require-meta-docs-url: ["error", {"pattern": "path/to/{{name}}.md"}] */ + +module.exports = { + meta: { + docs: { + url: "path/to/rule-name.md" + } + }, + create(context) { + } +}; + +``` + +## Version specific URL + +If you want to enforce version-specific URLs, it's feasible easily with `.eslintrc.js` and `npm version ` script. +For example: + +**.eslintrc.js**: + +```js +"use strict" + +const version = require("./package.json").version + +module.exports = { + plugins: ["eslint-plugin"], + // ... leaving out ... + rules: { + "eslint-plugin/require-meta-docs-url": ["error", { + pattern: `path/to/v${version}/docs/rules/{{name}}.md`, + }], + } +} +``` + +**package.json**: + +```json +{ + "version": "1.0.0", + "scripts": { + "pretest": "eslint .", + "test": "... leaving out ...", + "preversion": "npm test", + "version": "eslint . --fix && git add ." + }, + // ... leaving out ... +} +``` + +Then `npm version ` command will update every rule to the new version's URL. + +> npm runs `preversion` script on the current version, runs `version` script on the new version, and commits and makes a tag. +> +> Further reading: https://docs.npmjs.com/cli/version + +## When Not To Use It + +If you do not plan to provide rule's documentation in website, you can turn off this rule. diff --git a/lib/rules/require-meta-docs-url.js b/lib/rules/require-meta-docs-url.js new file mode 100644 index 00000000..dfde4162 --- /dev/null +++ b/lib/rules/require-meta-docs-url.js @@ -0,0 +1,139 @@ +/** + * @author Toru Nagashima + */ + +'use strict'; + +// ----------------------------------------------------------------------------- +// Requirements +// ----------------------------------------------------------------------------- + +const path = require('path'); +const util = require('../utils'); + +// ----------------------------------------------------------------------------- +// Rule Definition +// ----------------------------------------------------------------------------- + +module.exports = { + meta: { + docs: { + description: 'require rules to implement a meta.docs.url property', + category: 'Rules', + recommended: false, + }, + fixable: 'code', + schema: [{ + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + additionalProperties: false, + }], + }, + + /** + * Creates AST event handlers for require-meta-docs-url. + * @param {RuleContext} context - The rule context. + * @returns {Object} AST event handlers. + */ + create (context) { + const options = context.options[0] || {}; + const sourceCode = context.getSourceCode(); + const filename = context.getFilename(); + const ruleName = filename === '' ? undefined : path.basename(filename, '.js'); + const expectedUrl = !options.pattern || !ruleName + ? undefined + : options.pattern.replace(/{{\s*name\s*}}/g, ruleName); + + /** + * Check whether a given node is the expected URL. + * @param {Node} node The node of property value to check. + * @returns {boolean} `true` if the node is the expected URL. + */ + function isExpectedUrl (node) { + return Boolean( + node && + node.type === 'Literal' && + typeof node.value === 'string' && + ( + expectedUrl === undefined || + node.value === expectedUrl + ) + ); + } + + /** + * Insert a given property into a given object literal. + * @param {SourceCodeFixer} fixer The fixer. + * @param {Node} node The ObjectExpression node to insert a property. + * @param {string} propertyText The property code to insert. + * @returns {void} + */ + function insertProperty (fixer, node, propertyText) { + if (node.properties.length === 0) { + return fixer.replaceText(node, `{\n${propertyText}\n}`); + } + return fixer.insertTextAfter( + sourceCode.getLastToken(node.properties[node.properties.length - 1]), + `,\n${propertyText}` + ); + } + + return { + Program (node) { + const info = util.getRuleInfo(node); + if (info === null) { + return; + } + + const metaNode = info.meta; + const docsPropNode = + metaNode && + metaNode.properties && + metaNode.properties.find(p => p.type === 'Property' && util.getKeyName(p) === 'docs'); + const urlPropNode = + docsPropNode && + docsPropNode.value.properties && + docsPropNode.value.properties.find(p => p.type === 'Property' && util.getKeyName(p) === 'url'); + + if (isExpectedUrl(urlPropNode && urlPropNode.value)) { + return; + } + + context.report({ + loc: + (urlPropNode && urlPropNode.value.loc) || + (docsPropNode && docsPropNode.value.loc) || + (metaNode && metaNode.loc) || + node.loc.start, + + message: + !urlPropNode ? 'Rules should export a `meta.docs.url` property.' : + !expectedUrl ? '`meta.docs.url` property must be a string.' : + /* otherwise */ '`meta.docs.url` property must be `{{expectedUrl}}`.', + + data: { + expectedUrl, + }, + + fix (fixer) { + if (expectedUrl) { + const urlString = JSON.stringify(expectedUrl); + if (urlPropNode) { + return fixer.replaceText(urlPropNode.value, urlString); + } + if (docsPropNode && docsPropNode.value.type === 'ObjectExpression') { + return insertProperty(fixer, docsPropNode.value, `url: ${urlString}`); + } + if (!docsPropNode && metaNode && metaNode.type === 'ObjectExpression') { + return insertProperty(fixer, metaNode, `docs: {\nurl: ${urlString}\n}`); + } + } + return null; + }, + }); + }, + }; + }, +}; diff --git a/tests/lib/rules/require-meta-docs-url.js b/tests/lib/rules/require-meta-docs-url.js new file mode 100644 index 00000000..1b4e42a0 --- /dev/null +++ b/tests/lib/rules/require-meta-docs-url.js @@ -0,0 +1,683 @@ +/** + * @author Toru Nagashima + * @copyright 2016 Toru Nagashima. All rights reserved. + * See LICENSE file in root directory for full license. + */ + +'use strict'; + +// ----------------------------------------------------------------------------- +// Requirements +// ----------------------------------------------------------------------------- + +const RuleTester = require('eslint').RuleTester; +const rule = require('../../../lib/rules/require-meta-docs-url'); + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +const tester = new RuleTester({ + parserOptions: { + ecmaVersion: 2015, + }, +}); + +tester.run('require-meta-docs-url', rule, { + valid: [ + 'foo()', + ` + module.exports.meta = {docs: {url: ""}} + module.exports.create = function() {} + `, + ` + module.exports = { + meta: {docs: {url: ""}}, + create() {} + } + `, + ` + module.exports = { + ["meta"]: {["docs"]: {["url"]: ""}}, + create() {} + } + `, + { + code: ` + // If filename is not provided, don't check the value. + module.exports = { + meta: {docs: {url: ""}}, + create() {} + } + `, + options: [{ + pattern: 'path/to/{{name}}.md', + }], + }, + { + code: ` + module.exports = { + meta: {docs: {url: "path/to/test-rule.md"}}, + create() {} + } + `, + options: [{ + pattern: 'path/to/{{name}}.md', + }], + filename: 'test-rule', + }, + ], + + invalid: [ + { + code: ` + module.exports = function() {} + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: 100, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: {}, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + fixable: null + }, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + fixable: null, + }, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs + }, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs: {} + }, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs: { + description: "" + } + }, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs: { + description: "", + } + }, + create() {} + } + `, + output: null, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs: { + url: 100, + } + }, + create() {} + } + `, + output: null, + errors: ['`meta.docs.url` property must be a string.'], + }, + { + code: ` + module.exports = { + meta: { + docs: { + ...url + } + }, + create() {} + } + `, + output: null, + parserOptions: { + ecmaVersion: 2015, + ecmaFeatures: { + experimentalObjectRestSpread: true, + }, + }, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + + // ------------------------------------------------------------------------- + // pattern option without filename + // ------------------------------------------------------------------------- + { + code: ` + module.exports = function() {} + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: 100, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: {}, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + fixable: null + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + fixable: null, + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs: {} + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs: { + description: "" + } + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs: { + description: "", + } + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + }, + { + code: ` + module.exports = { + meta: { + docs: { + url: 100, + } + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['`meta.docs.url` property must be a string.'], + }, + { + code: ` + module.exports = { + meta: { + docs: { + ...url + } + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + parserOptions: { + ecmaVersion: 2015, + ecmaFeatures: { + experimentalObjectRestSpread: true, + }, + }, + errors: ['Rules should export a `meta.docs.url` property.'], + }, + + // ------------------------------------------------------------------------- + // pattern option with filename + // ------------------------------------------------------------------------- + { + code: ` + module.exports = function() {} + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: 100, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: {}, + create() {} + } + `, + output: ` + module.exports = { + meta: { +docs: { +url: "plugin-name/test.md" +} +}, + create() {} + } + `, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: { + fixable: null + }, + create() {} + } + `, + output: ` + module.exports = { + meta: { + fixable: null, +docs: { +url: "plugin-name/test.md" +} + }, + create() {} + } + `, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: { + fixable: null, + }, + create() {} + } + `, + output: ` + module.exports = { + meta: { + fixable: null, +docs: { +url: "plugin-name/test.md" +}, + }, + create() {} + } + `, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: { + docs + }, + create() {} + } + `, + output: null, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: { + docs: {} + }, + create() {} + } + `, + output: ` + module.exports = { + meta: { + docs: { +url: "plugin-name/test.md" +} + }, + create() {} + } + `, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: { + docs: { + description: "" + } + }, + create() {} + } + `, + output: ` + module.exports = { + meta: { + docs: { + description: "", +url: "plugin-name/test.md" + } + }, + create() {} + } + `, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: { + docs: { + description: "", + } + }, + create() {} + } + `, + output: ` + module.exports = { + meta: { + docs: { + description: "", +url: "plugin-name/test.md", + } + }, + create() {} + } + `, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: { + docs: { + url: 100, + } + }, + create() {} + } + `, + output: ` + module.exports = { + meta: { + docs: { + url: "plugin-name/test.md", + } + }, + create() {} + } + `, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + errors: ['`meta.docs.url` property must be `plugin-name/test.md`.'], + filename: 'test.js', + }, + { + code: ` + module.exports = { + meta: { + docs: { + ...url + } + }, + create() {} + } + `, + output: ` + module.exports = { + meta: { + docs: { + ...url, +url: "plugin-name/test.md" + } + }, + create() {} + } + `, + options: [{ + pattern: 'plugin-name/{{ name }}.md', + }], + parserOptions: { + ecmaVersion: 2015, + ecmaFeatures: { + experimentalObjectRestSpread: true, + }, + }, + errors: ['Rules should export a `meta.docs.url` property.'], + filename: 'test.js', + }, + ], +});