securityos/node_modules/eslint-plugin-unicorn/rules/expiring-todo-comments.js

562 lines
15 KiB
JavaScript

'use strict';
const readPkgUp = require('read-pkg-up');
const semver = require('semver');
const ci = require('ci-info');
const getBuiltinRule = require('./utils/get-builtin-rule.js');
const baseRule = getBuiltinRule('no-warning-comments');
// `unicorn/` prefix is added to avoid conflicts with core rule
const MESSAGE_ID_AVOID_MULTIPLE_DATES = 'unicorn/avoidMultipleDates';
const MESSAGE_ID_EXPIRED_TODO = 'unicorn/expiredTodo';
const MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS
= 'unicorn/avoidMultiplePackageVersions';
const MESSAGE_ID_REACHED_PACKAGE_VERSION = 'unicorn/reachedPackageVersion';
const MESSAGE_ID_HAVE_PACKAGE = 'unicorn/havePackage';
const MESSAGE_ID_DONT_HAVE_PACKAGE = 'unicorn/dontHavePackage';
const MESSAGE_ID_VERSION_MATCHES = 'unicorn/versionMatches';
const MESSAGE_ID_ENGINE_MATCHES = 'unicorn/engineMatches';
const MESSAGE_ID_REMOVE_WHITESPACE = 'unicorn/removeWhitespaces';
const MESSAGE_ID_MISSING_AT_SYMBOL = 'unicorn/missingAtSymbol';
// Override of core rule message with a more specific one - no prefix
const MESSAGE_ID_CORE_RULE_UNEXPECTED_COMMENT = 'unexpectedComment';
const messages = {
[MESSAGE_ID_AVOID_MULTIPLE_DATES]:
'Avoid using multiple expiration dates in TODO: {{expirationDates}}. {{message}}',
[MESSAGE_ID_EXPIRED_TODO]:
'There is a TODO that is past due date: {{expirationDate}}. {{message}}',
[MESSAGE_ID_REACHED_PACKAGE_VERSION]:
'There is a TODO that is past due package version: {{comparison}}. {{message}}',
[MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS]:
'Avoid using multiple package versions in TODO: {{versions}}. {{message}}',
[MESSAGE_ID_HAVE_PACKAGE]:
'There is a TODO that is deprecated since you installed: {{package}}. {{message}}',
[MESSAGE_ID_DONT_HAVE_PACKAGE]:
'There is a TODO that is deprecated since you uninstalled: {{package}}. {{message}}',
[MESSAGE_ID_VERSION_MATCHES]:
'There is a TODO match for package version: {{comparison}}. {{message}}',
[MESSAGE_ID_ENGINE_MATCHES]:
'There is a TODO match for Node.js version: {{comparison}}. {{message}}',
[MESSAGE_ID_REMOVE_WHITESPACE]:
'Avoid using whitespace on TODO argument. On \'{{original}}\' use \'{{fix}}\'. {{message}}',
[MESSAGE_ID_MISSING_AT_SYMBOL]:
'Missing \'@\' on TODO argument. On \'{{original}}\' use \'{{fix}}\'. {{message}}',
...baseRule.meta.messages,
[MESSAGE_ID_CORE_RULE_UNEXPECTED_COMMENT]:
'Unexpected \'{{matchedTerm}}\' comment without any conditions: \'{{comment}}\'.',
};
// We don't need to normalize the package.json data, because we are only using 2 properties and those 2 properties
// aren't validated by the normalization. But when this plugin is used in a monorepo, the name field in the
// package.json is invalid and would make this plugin throw an error. See also #1871
const packageResult = readPkgUp.sync({normalize: false});
const hasPackage = Boolean(packageResult);
const packageJson = hasPackage ? packageResult.packageJson : {};
const packageDependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
const DEPENDENCY_INCLUSION_RE = /^[+-]\s*@?\S+\/?\S+/;
const VERSION_COMPARISON_RE = /^(?<name>@?\S\/?\S+)@(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)/i;
const PKG_VERSION_RE = /^(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)\s*$/;
const ISO8601_DATE = /\d{4}-\d{2}-\d{2}/;
function parseTodoWithArguments(string, {terms}) {
const lowerCaseString = string.toLowerCase();
const lowerCaseTerms = terms.map(term => term.toLowerCase());
const hasTerm = lowerCaseTerms.some(term => lowerCaseString.includes(term));
if (!hasTerm) {
return false;
}
const TODO_ARGUMENT_RE = /\[(?<rawArguments>[^}]+)]/i;
const result = TODO_ARGUMENT_RE.exec(string);
if (!result) {
return false;
}
const {rawArguments} = result.groups;
const parsedArguments = rawArguments
.split(',')
.map(argument => parseArgument(argument.trim()));
return createArgumentGroup(parsedArguments);
}
function createArgumentGroup(arguments_) {
const groups = {};
for (const {value, type} of arguments_) {
groups[type] = groups[type] || [];
groups[type].push(value);
}
return groups;
}
function parseArgument(argumentString) {
if (ISO8601_DATE.test(argumentString)) {
return {
type: 'dates',
value: argumentString,
};
}
if (hasPackage && DEPENDENCY_INCLUSION_RE.test(argumentString)) {
const condition = argumentString[0] === '+' ? 'in' : 'out';
const name = argumentString.slice(1).trim();
return {
type: 'dependencies',
value: {
name,
condition,
},
};
}
if (hasPackage && VERSION_COMPARISON_RE.test(argumentString)) {
const {groups} = VERSION_COMPARISON_RE.exec(argumentString);
const name = groups.name.trim();
const condition = groups.condition.trim();
const version = groups.version.trim();
const hasEngineKeyword = name.indexOf('engine:') === 0;
const isNodeEngine = hasEngineKeyword && name === 'engine:node';
if (hasEngineKeyword && isNodeEngine) {
return {
type: 'engines',
value: {
condition,
version,
},
};
}
if (!hasEngineKeyword) {
return {
type: 'dependencies',
value: {
name,
condition,
version,
},
};
}
}
if (hasPackage && PKG_VERSION_RE.test(argumentString)) {
const result = PKG_VERSION_RE.exec(argumentString);
const {condition, version} = result.groups;
return {
type: 'packageVersions',
value: {
condition: condition.trim(),
version: version.trim(),
},
};
}
// Currently being ignored as integration tests pointed
// some TODO comments have `[random data like this]`
return {
type: 'unknowns',
value: argumentString,
};
}
function parseTodoMessage(todoString) {
// @example "TODO [...]: message here"
// @example "TODO [...] message here"
const argumentsEnd = todoString.indexOf(']');
const afterArguments = todoString.slice(argumentsEnd + 1).trim();
// Check if have to skip colon
// @example "TODO [...]: message here"
const dropColon = afterArguments[0] === ':';
if (dropColon) {
return afterArguments.slice(1).trim();
}
return afterArguments;
}
function reachedDate(past, now) {
return Date.parse(past) < Date.parse(now);
}
function tryToCoerceVersion(rawVersion) {
// `version` in `package.json` and comment can't be empty
/* c8 ignore next 3 */
if (!rawVersion) {
return false;
}
let version = String(rawVersion);
// Remove leading things like `^1.0.0`, `>1.0.0`
const leadingNoises = [
'>=',
'<=',
'>',
'<',
'~',
'^',
];
const foundTrailingNoise = leadingNoises.find(noise => version.startsWith(noise));
if (foundTrailingNoise) {
version = version.slice(foundTrailingNoise.length);
}
// Get only the first member for cases such as `1.0.0 - 2.9999.9999`
const parts = version.split(' ');
// We don't have this `package.json` to test
/* c8 ignore next 3 */
if (parts.length > 1) {
version = parts[0];
}
// We don't have this `package.json` to test
/* c8 ignore next 3 */
if (semver.valid(version)) {
return version;
}
try {
// Try to semver.parse a perfect match while semver.coerce tries to fix errors
// But coerce can't parse pre-releases.
return semver.parse(version) || semver.coerce(version);
} catch {
// We don't have this `package.json` to test
/* c8 ignore next 3 */
return false;
}
}
function semverComparisonForOperator(operator) {
return {
'>': semver.gt,
'>=': semver.gte,
}[operator];
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const options = {
terms: ['todo', 'fixme', 'xxx'],
ignore: [],
ignoreDatesOnPullRequests: true,
allowWarningComments: true,
date: new Date().toISOString().slice(0, 10),
...context.options[0],
};
const ignoreRegexes = options.ignore.map(
pattern => pattern instanceof RegExp ? pattern : new RegExp(pattern, 'u'),
);
const sourceCode = context.getSourceCode();
const comments = sourceCode.getAllComments();
const unusedComments = comments
.filter(token => token.type !== 'Shebang')
// Block comments come as one.
// Split for situations like this:
// /*
// * TODO [2999-01-01]: Validate this
// * TODO [2999-01-01]: And this
// * TODO [2999-01-01]: Also this
// */
.flatMap(comment =>
comment.value.split('\n').map(line => ({
...comment,
value: line,
})),
).filter(comment => processComment(comment));
// This is highly dependable on ESLint's `no-warning-comments` implementation.
// What we do is patch the parts we know the rule will use, `getAllComments`.
// Since we have priority, we leave only the comments that we didn't use.
const fakeContext = {
...context,
getSourceCode() {
return {
...sourceCode,
getAllComments() {
return options.allowWarningComments ? [] : unusedComments;
},
};
},
};
const rules = baseRule.create(fakeContext);
function processComment(comment) {
if (ignoreRegexes.some(ignore => ignore.test(comment.value))) {
return;
}
const parsed = parseTodoWithArguments(comment.value, options);
if (!parsed) {
return true;
}
// Count if there are valid properties.
// Otherwise, it's a useless TODO and falls back to `no-warning-comments`.
let uses = 0;
const {
packageVersions = [],
dates = [],
dependencies = [],
engines = [],
unknowns = [],
} = parsed;
if (dates.length > 1) {
uses++;
context.report({
loc: comment.loc,
messageId: MESSAGE_ID_AVOID_MULTIPLE_DATES,
data: {
expirationDates: dates.join(', '),
message: parseTodoMessage(comment.value),
},
});
} else if (dates.length === 1) {
uses++;
const [expirationDate] = dates;
const shouldIgnore = options.ignoreDatesOnPullRequests && ci.isPR;
if (!shouldIgnore && reachedDate(expirationDate, options.date)) {
context.report({
loc: comment.loc,
messageId: MESSAGE_ID_EXPIRED_TODO,
data: {
expirationDate,
message: parseTodoMessage(comment.value),
},
});
}
}
if (packageVersions.length > 1) {
uses++;
context.report({
loc: comment.loc,
messageId: MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS,
data: {
versions: packageVersions
.map(({condition, version}) => `${condition}${version}`)
.join(', '),
message: parseTodoMessage(comment.value),
},
});
} else if (packageVersions.length === 1) {
uses++;
const [{condition, version}] = packageVersions;
const packageVersion = tryToCoerceVersion(packageJson.version);
const decidedPackageVersion = tryToCoerceVersion(version);
const compare = semverComparisonForOperator(condition);
if (packageVersion && compare(packageVersion, decidedPackageVersion)) {
context.report({
loc: comment.loc,
messageId: MESSAGE_ID_REACHED_PACKAGE_VERSION,
data: {
comparison: `${condition}${version}`,
message: parseTodoMessage(comment.value),
},
});
}
}
// Inclusion: 'in', 'out'
// Comparison: '>', '>='
for (const dependency of dependencies) {
uses++;
const targetPackageRawVersion = packageDependencies[dependency.name];
const hasTargetPackage = Boolean(targetPackageRawVersion);
const isInclusion = ['in', 'out'].includes(dependency.condition);
if (isInclusion) {
const [trigger, messageId]
= dependency.condition === 'in'
? [hasTargetPackage, MESSAGE_ID_HAVE_PACKAGE]
: [!hasTargetPackage, MESSAGE_ID_DONT_HAVE_PACKAGE];
if (trigger) {
context.report({
loc: comment.loc,
messageId,
data: {
package: dependency.name,
message: parseTodoMessage(comment.value),
},
});
}
continue;
}
const todoVersion = tryToCoerceVersion(dependency.version);
const targetPackageVersion = tryToCoerceVersion(targetPackageRawVersion);
/* c8 ignore start */
if (!hasTargetPackage || !targetPackageVersion) {
// Can't compare `¯\_(ツ)_/¯`
continue;
}
/* c8 ignore end */
const compare = semverComparisonForOperator(dependency.condition);
if (compare(targetPackageVersion, todoVersion)) {
context.report({
loc: comment.loc,
messageId: MESSAGE_ID_VERSION_MATCHES,
data: {
comparison: `${dependency.name} ${dependency.condition} ${dependency.version}`,
message: parseTodoMessage(comment.value),
},
});
}
}
const packageEngines = packageJson.engines || {};
for (const engine of engines) {
uses++;
const targetPackageRawEngineVersion = packageEngines.node;
const hasTargetEngine = Boolean(targetPackageRawEngineVersion);
/* c8 ignore next 3 */
if (!hasTargetEngine) {
continue;
}
const todoEngine = tryToCoerceVersion(engine.version);
const targetPackageEngineVersion = tryToCoerceVersion(
targetPackageRawEngineVersion,
);
const compare = semverComparisonForOperator(engine.condition);
if (compare(targetPackageEngineVersion, todoEngine)) {
context.report({
loc: comment.loc,
messageId: MESSAGE_ID_ENGINE_MATCHES,
data: {
comparison: `node${engine.condition}${engine.version}`,
message: parseTodoMessage(comment.value),
},
});
}
}
for (const unknown of unknowns) {
// In this case, check if there's just an '@' missing before a '>' or '>='.
const hasAt = unknown.includes('@');
const comparisonIndex = unknown.indexOf('>');
if (!hasAt && comparisonIndex !== -1) {
const testString = `${unknown.slice(
0,
comparisonIndex,
)}@${unknown.slice(comparisonIndex)}`;
if (parseArgument(testString).type !== 'unknowns') {
uses++;
context.report({
loc: comment.loc,
messageId: MESSAGE_ID_MISSING_AT_SYMBOL,
data: {
original: unknown,
fix: testString,
message: parseTodoMessage(comment.value),
},
});
continue;
}
}
const withoutWhitespace = unknown.replace(/ /g, '');
if (parseArgument(withoutWhitespace).type !== 'unknowns') {
uses++;
context.report({
loc: comment.loc,
messageId: MESSAGE_ID_REMOVE_WHITESPACE,
data: {
original: unknown,
fix: withoutWhitespace,
message: parseTodoMessage(comment.value),
},
});
continue;
}
}
return uses === 0;
}
return {
Program() {
rules.Program(); // eslint-disable-line new-cap
},
};
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
terms: {
type: 'array',
items: {
type: 'string',
},
},
ignore: {
type: 'array',
uniqueItems: true,
},
ignoreDatesOnPullRequests: {
type: 'boolean',
default: true,
},
allowWarningComments: {
type: 'boolean',
default: false,
},
date: {
type: 'string',
format: 'date',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Add expiration conditions to TODO comments.',
},
schema,
messages,
},
};