-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: lint YAML comments in md files
- Loading branch information
Showing
4 changed files
with
241 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); |