| 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 ruleconst 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 prefixconst 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,	},};
 |