diff --git a/index.js b/index.js index ef49a3e8..a72c6133 100644 --- a/index.js +++ b/index.js @@ -55,6 +55,7 @@ module.exports.plugins = [ require("remark-lint-no-table-indentation"), require("remark-lint-no-tabs"), require("remark-lint-no-trailing-spaces"), + require("./remark-lint-nodejs-yaml-comments.js"), [ require("remark-lint-prohibited-strings"), [ diff --git a/package-lock.json b/package-lock.json index 3f51ce07..849fe457 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -305,8 +304,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "extend": { "version": "3.0.2", @@ -565,7 +563,6 @@ "version": "3.14.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", - "dev": true, "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -1520,6 +1517,11 @@ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true }, + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -1534,8 +1536,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "state-toggle": { "version": "1.0.3", diff --git a/package.json b/package.json index 0aa62e5f..f9317f8f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "index.js" ], "dependencies": { + "js-yaml": "^3.14.0", "remark-lint": "^8.0.0", "remark-lint-blockquote-indentation": "^2.0.0", "remark-lint-checkbox-character-style": "^3.0.0", @@ -56,7 +57,8 @@ "remark-lint-table-cell-padding": "^2.0.0", "remark-lint-table-pipes": "^2.0.0", "remark-lint-unordered-list-marker-style": "^2.0.0", - "remark-preset-lint-recommended": "^4.0.0" + "remark-preset-lint-recommended": "^4.0.0", + "semver": "^7.3.2" }, "devDependencies": { "lockfile-lint": "^4.2.2", diff --git a/remark-lint-nodejs-yaml-comments.js b/remark-lint-nodejs-yaml-comments.js new file mode 100644 index 00000000..2e919c9b --- /dev/null +++ b/remark-lint-nodejs-yaml-comments.js @@ -0,0 +1,230 @@ +"use strict"; + +const yaml = require("js-yaml"); +const visit = require("unist-util-visit"); +const rule = require("unified-lint-rule"); +const semverParse = require("semver/functions/parse"); +const semverLt = require("semver/functions/lt"); + +const allowedKeys = [ + "added", + "napiVersion", + "deprecated", + "removed", + "changes", +]; +const changesExpectedKeys = ["version", "pr-url", "description"]; +const VERSION_PLACEHOLDER = "REPLACEME"; +const MAX_SAFE_SEMVER_VERSION = semverParse( + Array.from({ length: 3 }, () => Number.MAX_SAFE_INTEGER).join(".") +); +const validVersionNumberRegex = /^v\d+\.\d+\.\d+$/; +const prUrlRegex = new RegExp("^https://github.com/nodejs/node/pull/\\d+$"); +const privatePRUrl = "https://github.com/nodejs-private/node-private/pull/"; + +const kContainsIllegalKey = Symbol("illegal key"); +const kWrongKeyOrder = Symbol("Wrong key order"); +function unorderedKeys(meta) { + const keys = Object.keys(meta); + let previousKeyIndex = -1; + for (const key of keys) { + const keyIndex = allowedKeys.indexOf(key); + if (keyIndex <= previousKeyIndex) { + return keyIndex === -1 ? kContainsIllegalKey : kWrongKeyOrder; + } + previousKeyIndex = keyIndex; + } +} + +function containsInvalidVersionNumber(version) { + if (Array.isArray(version)) { + return version.some(containsInvalidVersionNumber); + } + + return ( + version !== undefined && + version !== VERSION_PLACEHOLDER && + !validVersionNumberRegex.test(version) + ); +} +const getValidSemver = (version) => + version === VERSION_PLACEHOLDER ? MAX_SAFE_SEMVER_VERSION : version; +function areVersionsUnordered(versions) { + if (!Array.isArray(versions)) return false; + + for (let index = 1; index < versions.length; index++) { + if ( + semverLt( + getValidSemver(versions[index - 1]), + getValidSemver(versions[index]) + ) + ) { + return true; + } + } +} + +function invalidChangesKeys(change) { + const keys = Object.keys(change); + const { length } = keys; + if (length !== changesExpectedKeys.length) return true; + for (let index = 0; index < length; index++) { + if (keys[index] !== changesExpectedKeys[index]) return true; + } +} +function validateSecurityChange(file, node, change, index) { + if ("commit" in change) { + if (typeof change.commit !== "string" || isNaN(`0x${change.commit}`)) { + file.message( + `changes[${index}]: Ill-formed security change commit ID`, + node + ); + } + + if (Object.keys(change)[1] === "commit") { + change = { ...change }; + delete change.commit; + } + } + if (invalidChangesKeys(change)) { + const securityChangeExpectedKeys = [...changesExpectedKeys]; + securityChangeExpectedKeys[0] += "[, commit]"; + file.message( + `changes[${index}]: Invalid keys. Expected keys are: ` + + securityChangeExpectedKeys.join(", "), + node + ); + } +} +function validateChanges(file, node, changes) { + if (!Array.isArray(changes)) + return file.message("`changes` must be a YAML list", node); + + const changesVersions = []; + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + + const isAncient = + typeof change.version === "string" && change.version.startsWith("v0."); + const isSecurityChange = + !isAncient && + typeof change["pr-url"] === "string" && + change["pr-url"].startsWith(privatePRUrl); + + if (isSecurityChange) { + validateSecurityChange(file, node, change, index); + } else if (!isAncient && invalidChangesKeys(change)) { + file.message( + `changes[${index}]: Invalid keys. Expected keys are: ` + + changesExpectedKeys.join(", "), + node + ); + } + + if (containsInvalidVersionNumber(change.version)) { + file.message( + `changes[${index}]: version(s) must respect the pattern \`vx.x.x\` ` + + `or use the placeholder \`${VERSION_PLACEHOLDER}\``, + node + ); + } else if (areVersionsUnordered(change.version)) { + file.message(`changes[${index}]: list of versions is not in order`, node); + } + + if (!isAncient && !isSecurityChange && !prUrlRegex.test(change["pr-url"])) { + file.message( + `changes[${index}]: PR-URL does not match the expected pattern`, + node + ); + } + + if (typeof change.description !== "string" || !change.description.length) { + file.message( + `changes[${index}]: must contain a non-empty description`, + node + ); + } else if (!change.description.endsWith(".")) { + file.message( + `changes[${index}]: description must end with a period`, + node + ); + } + + changesVersions.push( + Array.isArray(change.version) ? change.version[0] : change.version + ); + } + + if (areVersionsUnordered(changesVersions)) { + file.message("Items in `changes` list are not in order", node); + } +} + +function validateMeta(node, file, meta) { + switch (unorderedKeys(meta)) { + case kContainsIllegalKey: + file.message( + "YAML dictionary contains illegal keys. Accepted values are: " + + allowedKeys.join(", "), + node + ); + break; + + case kWrongKeyOrder: + file.message( + "YAML dictionary keys should be respect this order: " + + allowedKeys.join(", "), + node + ); + break; + } + + if (containsInvalidVersionNumber(meta.added)) { + file.message( + "Invalid `added` value: version(s) must respect the pattern `vx.x.x` " + + `or use the placeholder \`${VERSION_PLACEHOLDER}\``, + node + ); + } else if (areVersionsUnordered(meta.added)) { + file.message("Versions in `added` list are not in order", node); + } + + if (containsInvalidVersionNumber(meta.deprecated)) { + file.message( + "Invalid `deprecated` value: version(s) must respect the pattern `vx.x.x` " + + `or use the placeholder \`${VERSION_PLACEHOLDER}\``, + node + ); + } else if (areVersionsUnordered(meta.deprecated)) { + file.message("Versions in `deprecated` list are not in order", node); + } + + if (containsInvalidVersionNumber(meta.removed)) { + file.message( + "Invalid `removed` value: version(s) must respect the pattern `vx.x.x` " + + `or use the placeholder \`${VERSION_PLACEHOLDER}\``, + node + ); + } else if (areVersionsUnordered(meta.removed)) { + file.message("Versions in `removed` list are not in order", node); + } + + if ("changes" in meta) { + validateChanges(file, node, meta.changes); + } +} + +function validateYAMLComments(tree, file) { + visit(tree, "html", function visitor(node) { + if (!node.value.startsWith("".length)); + + validateMeta(node, file, meta); + } catch (e) { + file.message(e, node); + } + }); +} + +module.exports = rule("remark-lint:nodejs-yaml-comments", validateYAMLComments);