Skip to content

Commit

Permalink
feat: lint YAML comments in md files (#139)
Browse files Browse the repository at this point in the history
  • Loading branch information
aduh95 committed Oct 16, 2020
1 parent 133ed88 commit e4ddfef
Show file tree
Hide file tree
Showing 4 changed files with 241 additions and 7 deletions.
1 change: 1 addition & 0 deletions index.js
Expand Up @@ -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"),
[
Expand Down
13 changes: 7 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
230 changes: 230 additions & 0 deletions 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("<!-- YAML\n")) return;
try {
const meta = yaml.safeLoad("#" + node.value.slice(0, -"-->".length));

validateMeta(node, file, meta);
} catch (e) {
file.message(e, node);
}
});
}

module.exports = rule("remark-lint:nodejs-yaml-comments", validateYAMLComments);

0 comments on commit e4ddfef

Please sign in to comment.