123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558 |
- '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}}\'.',
- };
- const packageResult = readPkgUp.sync();
- 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,
- },
- };
|