Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: lint YAML comments in md files #139

Merged
merged 1 commit into from Oct 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);