expiring-todo-comments.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. 'use strict';
  2. const readPkgUp = require('read-pkg-up');
  3. const semver = require('semver');
  4. const ci = require('ci-info');
  5. const getBuiltinRule = require('./utils/get-builtin-rule.js');
  6. const baseRule = getBuiltinRule('no-warning-comments');
  7. // `unicorn/` prefix is added to avoid conflicts with core rule
  8. const MESSAGE_ID_AVOID_MULTIPLE_DATES = 'unicorn/avoidMultipleDates';
  9. const MESSAGE_ID_EXPIRED_TODO = 'unicorn/expiredTodo';
  10. const MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS
  11. = 'unicorn/avoidMultiplePackageVersions';
  12. const MESSAGE_ID_REACHED_PACKAGE_VERSION = 'unicorn/reachedPackageVersion';
  13. const MESSAGE_ID_HAVE_PACKAGE = 'unicorn/havePackage';
  14. const MESSAGE_ID_DONT_HAVE_PACKAGE = 'unicorn/dontHavePackage';
  15. const MESSAGE_ID_VERSION_MATCHES = 'unicorn/versionMatches';
  16. const MESSAGE_ID_ENGINE_MATCHES = 'unicorn/engineMatches';
  17. const MESSAGE_ID_REMOVE_WHITESPACE = 'unicorn/removeWhitespaces';
  18. const MESSAGE_ID_MISSING_AT_SYMBOL = 'unicorn/missingAtSymbol';
  19. // Override of core rule message with a more specific one - no prefix
  20. const MESSAGE_ID_CORE_RULE_UNEXPECTED_COMMENT = 'unexpectedComment';
  21. const messages = {
  22. [MESSAGE_ID_AVOID_MULTIPLE_DATES]:
  23. 'Avoid using multiple expiration dates in TODO: {{expirationDates}}. {{message}}',
  24. [MESSAGE_ID_EXPIRED_TODO]:
  25. 'There is a TODO that is past due date: {{expirationDate}}. {{message}}',
  26. [MESSAGE_ID_REACHED_PACKAGE_VERSION]:
  27. 'There is a TODO that is past due package version: {{comparison}}. {{message}}',
  28. [MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS]:
  29. 'Avoid using multiple package versions in TODO: {{versions}}. {{message}}',
  30. [MESSAGE_ID_HAVE_PACKAGE]:
  31. 'There is a TODO that is deprecated since you installed: {{package}}. {{message}}',
  32. [MESSAGE_ID_DONT_HAVE_PACKAGE]:
  33. 'There is a TODO that is deprecated since you uninstalled: {{package}}. {{message}}',
  34. [MESSAGE_ID_VERSION_MATCHES]:
  35. 'There is a TODO match for package version: {{comparison}}. {{message}}',
  36. [MESSAGE_ID_ENGINE_MATCHES]:
  37. 'There is a TODO match for Node.js version: {{comparison}}. {{message}}',
  38. [MESSAGE_ID_REMOVE_WHITESPACE]:
  39. 'Avoid using whitespace on TODO argument. On \'{{original}}\' use \'{{fix}}\'. {{message}}',
  40. [MESSAGE_ID_MISSING_AT_SYMBOL]:
  41. 'Missing \'@\' on TODO argument. On \'{{original}}\' use \'{{fix}}\'. {{message}}',
  42. ...baseRule.meta.messages,
  43. [MESSAGE_ID_CORE_RULE_UNEXPECTED_COMMENT]:
  44. 'Unexpected \'{{matchedTerm}}\' comment without any conditions: \'{{comment}}\'.',
  45. };
  46. const packageResult = readPkgUp.sync();
  47. const hasPackage = Boolean(packageResult);
  48. const packageJson = hasPackage ? packageResult.packageJson : {};
  49. const packageDependencies = {
  50. ...packageJson.dependencies,
  51. ...packageJson.devDependencies,
  52. };
  53. const DEPENDENCY_INCLUSION_RE = /^[+-]\s*@?\S+\/?\S+/;
  54. const VERSION_COMPARISON_RE = /^(?<name>@?\S\/?\S+)@(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)/i;
  55. const PKG_VERSION_RE = /^(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)\s*$/;
  56. const ISO8601_DATE = /\d{4}-\d{2}-\d{2}/;
  57. function parseTodoWithArguments(string, {terms}) {
  58. const lowerCaseString = string.toLowerCase();
  59. const lowerCaseTerms = terms.map(term => term.toLowerCase());
  60. const hasTerm = lowerCaseTerms.some(term => lowerCaseString.includes(term));
  61. if (!hasTerm) {
  62. return false;
  63. }
  64. const TODO_ARGUMENT_RE = /\[(?<rawArguments>[^}]+)]/i;
  65. const result = TODO_ARGUMENT_RE.exec(string);
  66. if (!result) {
  67. return false;
  68. }
  69. const {rawArguments} = result.groups;
  70. const parsedArguments = rawArguments
  71. .split(',')
  72. .map(argument => parseArgument(argument.trim()));
  73. return createArgumentGroup(parsedArguments);
  74. }
  75. function createArgumentGroup(arguments_) {
  76. const groups = {};
  77. for (const {value, type} of arguments_) {
  78. groups[type] = groups[type] || [];
  79. groups[type].push(value);
  80. }
  81. return groups;
  82. }
  83. function parseArgument(argumentString) {
  84. if (ISO8601_DATE.test(argumentString)) {
  85. return {
  86. type: 'dates',
  87. value: argumentString,
  88. };
  89. }
  90. if (hasPackage && DEPENDENCY_INCLUSION_RE.test(argumentString)) {
  91. const condition = argumentString[0] === '+' ? 'in' : 'out';
  92. const name = argumentString.slice(1).trim();
  93. return {
  94. type: 'dependencies',
  95. value: {
  96. name,
  97. condition,
  98. },
  99. };
  100. }
  101. if (hasPackage && VERSION_COMPARISON_RE.test(argumentString)) {
  102. const {groups} = VERSION_COMPARISON_RE.exec(argumentString);
  103. const name = groups.name.trim();
  104. const condition = groups.condition.trim();
  105. const version = groups.version.trim();
  106. const hasEngineKeyword = name.indexOf('engine:') === 0;
  107. const isNodeEngine = hasEngineKeyword && name === 'engine:node';
  108. if (hasEngineKeyword && isNodeEngine) {
  109. return {
  110. type: 'engines',
  111. value: {
  112. condition,
  113. version,
  114. },
  115. };
  116. }
  117. if (!hasEngineKeyword) {
  118. return {
  119. type: 'dependencies',
  120. value: {
  121. name,
  122. condition,
  123. version,
  124. },
  125. };
  126. }
  127. }
  128. if (hasPackage && PKG_VERSION_RE.test(argumentString)) {
  129. const result = PKG_VERSION_RE.exec(argumentString);
  130. const {condition, version} = result.groups;
  131. return {
  132. type: 'packageVersions',
  133. value: {
  134. condition: condition.trim(),
  135. version: version.trim(),
  136. },
  137. };
  138. }
  139. // Currently being ignored as integration tests pointed
  140. // some TODO comments have `[random data like this]`
  141. return {
  142. type: 'unknowns',
  143. value: argumentString,
  144. };
  145. }
  146. function parseTodoMessage(todoString) {
  147. // @example "TODO [...]: message here"
  148. // @example "TODO [...] message here"
  149. const argumentsEnd = todoString.indexOf(']');
  150. const afterArguments = todoString.slice(argumentsEnd + 1).trim();
  151. // Check if have to skip colon
  152. // @example "TODO [...]: message here"
  153. const dropColon = afterArguments[0] === ':';
  154. if (dropColon) {
  155. return afterArguments.slice(1).trim();
  156. }
  157. return afterArguments;
  158. }
  159. function reachedDate(past, now) {
  160. return Date.parse(past) < Date.parse(now);
  161. }
  162. function tryToCoerceVersion(rawVersion) {
  163. // `version` in `package.json` and comment can't be empty
  164. /* c8 ignore next 3 */
  165. if (!rawVersion) {
  166. return false;
  167. }
  168. let version = String(rawVersion);
  169. // Remove leading things like `^1.0.0`, `>1.0.0`
  170. const leadingNoises = [
  171. '>=',
  172. '<=',
  173. '>',
  174. '<',
  175. '~',
  176. '^',
  177. ];
  178. const foundTrailingNoise = leadingNoises.find(noise => version.startsWith(noise));
  179. if (foundTrailingNoise) {
  180. version = version.slice(foundTrailingNoise.length);
  181. }
  182. // Get only the first member for cases such as `1.0.0 - 2.9999.9999`
  183. const parts = version.split(' ');
  184. // We don't have this `package.json` to test
  185. /* c8 ignore next 3 */
  186. if (parts.length > 1) {
  187. version = parts[0];
  188. }
  189. // We don't have this `package.json` to test
  190. /* c8 ignore next 3 */
  191. if (semver.valid(version)) {
  192. return version;
  193. }
  194. try {
  195. // Try to semver.parse a perfect match while semver.coerce tries to fix errors
  196. // But coerce can't parse pre-releases.
  197. return semver.parse(version) || semver.coerce(version);
  198. } catch {
  199. // We don't have this `package.json` to test
  200. /* c8 ignore next 3 */
  201. return false;
  202. }
  203. }
  204. function semverComparisonForOperator(operator) {
  205. return {
  206. '>': semver.gt,
  207. '>=': semver.gte,
  208. }[operator];
  209. }
  210. /** @param {import('eslint').Rule.RuleContext} context */
  211. const create = context => {
  212. const options = {
  213. terms: ['todo', 'fixme', 'xxx'],
  214. ignore: [],
  215. ignoreDatesOnPullRequests: true,
  216. allowWarningComments: true,
  217. date: new Date().toISOString().slice(0, 10),
  218. ...context.options[0],
  219. };
  220. const ignoreRegexes = options.ignore.map(
  221. pattern => pattern instanceof RegExp ? pattern : new RegExp(pattern, 'u'),
  222. );
  223. const sourceCode = context.getSourceCode();
  224. const comments = sourceCode.getAllComments();
  225. const unusedComments = comments
  226. .filter(token => token.type !== 'Shebang')
  227. // Block comments come as one.
  228. // Split for situations like this:
  229. // /*
  230. // * TODO [2999-01-01]: Validate this
  231. // * TODO [2999-01-01]: And this
  232. // * TODO [2999-01-01]: Also this
  233. // */
  234. .flatMap(comment =>
  235. comment.value.split('\n').map(line => ({
  236. ...comment,
  237. value: line,
  238. })),
  239. ).filter(comment => processComment(comment));
  240. // This is highly dependable on ESLint's `no-warning-comments` implementation.
  241. // What we do is patch the parts we know the rule will use, `getAllComments`.
  242. // Since we have priority, we leave only the comments that we didn't use.
  243. const fakeContext = {
  244. ...context,
  245. getSourceCode() {
  246. return {
  247. ...sourceCode,
  248. getAllComments() {
  249. return options.allowWarningComments ? [] : unusedComments;
  250. },
  251. };
  252. },
  253. };
  254. const rules = baseRule.create(fakeContext);
  255. function processComment(comment) {
  256. if (ignoreRegexes.some(ignore => ignore.test(comment.value))) {
  257. return;
  258. }
  259. const parsed = parseTodoWithArguments(comment.value, options);
  260. if (!parsed) {
  261. return true;
  262. }
  263. // Count if there are valid properties.
  264. // Otherwise, it's a useless TODO and falls back to `no-warning-comments`.
  265. let uses = 0;
  266. const {
  267. packageVersions = [],
  268. dates = [],
  269. dependencies = [],
  270. engines = [],
  271. unknowns = [],
  272. } = parsed;
  273. if (dates.length > 1) {
  274. uses++;
  275. context.report({
  276. loc: comment.loc,
  277. messageId: MESSAGE_ID_AVOID_MULTIPLE_DATES,
  278. data: {
  279. expirationDates: dates.join(', '),
  280. message: parseTodoMessage(comment.value),
  281. },
  282. });
  283. } else if (dates.length === 1) {
  284. uses++;
  285. const [expirationDate] = dates;
  286. const shouldIgnore = options.ignoreDatesOnPullRequests && ci.isPR;
  287. if (!shouldIgnore && reachedDate(expirationDate, options.date)) {
  288. context.report({
  289. loc: comment.loc,
  290. messageId: MESSAGE_ID_EXPIRED_TODO,
  291. data: {
  292. expirationDate,
  293. message: parseTodoMessage(comment.value),
  294. },
  295. });
  296. }
  297. }
  298. if (packageVersions.length > 1) {
  299. uses++;
  300. context.report({
  301. loc: comment.loc,
  302. messageId: MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS,
  303. data: {
  304. versions: packageVersions
  305. .map(({condition, version}) => `${condition}${version}`)
  306. .join(', '),
  307. message: parseTodoMessage(comment.value),
  308. },
  309. });
  310. } else if (packageVersions.length === 1) {
  311. uses++;
  312. const [{condition, version}] = packageVersions;
  313. const packageVersion = tryToCoerceVersion(packageJson.version);
  314. const decidedPackageVersion = tryToCoerceVersion(version);
  315. const compare = semverComparisonForOperator(condition);
  316. if (packageVersion && compare(packageVersion, decidedPackageVersion)) {
  317. context.report({
  318. loc: comment.loc,
  319. messageId: MESSAGE_ID_REACHED_PACKAGE_VERSION,
  320. data: {
  321. comparison: `${condition}${version}`,
  322. message: parseTodoMessage(comment.value),
  323. },
  324. });
  325. }
  326. }
  327. // Inclusion: 'in', 'out'
  328. // Comparison: '>', '>='
  329. for (const dependency of dependencies) {
  330. uses++;
  331. const targetPackageRawVersion = packageDependencies[dependency.name];
  332. const hasTargetPackage = Boolean(targetPackageRawVersion);
  333. const isInclusion = ['in', 'out'].includes(dependency.condition);
  334. if (isInclusion) {
  335. const [trigger, messageId]
  336. = dependency.condition === 'in'
  337. ? [hasTargetPackage, MESSAGE_ID_HAVE_PACKAGE]
  338. : [!hasTargetPackage, MESSAGE_ID_DONT_HAVE_PACKAGE];
  339. if (trigger) {
  340. context.report({
  341. loc: comment.loc,
  342. messageId,
  343. data: {
  344. package: dependency.name,
  345. message: parseTodoMessage(comment.value),
  346. },
  347. });
  348. }
  349. continue;
  350. }
  351. const todoVersion = tryToCoerceVersion(dependency.version);
  352. const targetPackageVersion = tryToCoerceVersion(targetPackageRawVersion);
  353. /* c8 ignore start */
  354. if (!hasTargetPackage || !targetPackageVersion) {
  355. // Can't compare `¯\_(ツ)_/¯`
  356. continue;
  357. }
  358. /* c8 ignore end */
  359. const compare = semverComparisonForOperator(dependency.condition);
  360. if (compare(targetPackageVersion, todoVersion)) {
  361. context.report({
  362. loc: comment.loc,
  363. messageId: MESSAGE_ID_VERSION_MATCHES,
  364. data: {
  365. comparison: `${dependency.name} ${dependency.condition} ${dependency.version}`,
  366. message: parseTodoMessage(comment.value),
  367. },
  368. });
  369. }
  370. }
  371. const packageEngines = packageJson.engines || {};
  372. for (const engine of engines) {
  373. uses++;
  374. const targetPackageRawEngineVersion = packageEngines.node;
  375. const hasTargetEngine = Boolean(targetPackageRawEngineVersion);
  376. /* c8 ignore next 3 */
  377. if (!hasTargetEngine) {
  378. continue;
  379. }
  380. const todoEngine = tryToCoerceVersion(engine.version);
  381. const targetPackageEngineVersion = tryToCoerceVersion(
  382. targetPackageRawEngineVersion,
  383. );
  384. const compare = semverComparisonForOperator(engine.condition);
  385. if (compare(targetPackageEngineVersion, todoEngine)) {
  386. context.report({
  387. loc: comment.loc,
  388. messageId: MESSAGE_ID_ENGINE_MATCHES,
  389. data: {
  390. comparison: `node${engine.condition}${engine.version}`,
  391. message: parseTodoMessage(comment.value),
  392. },
  393. });
  394. }
  395. }
  396. for (const unknown of unknowns) {
  397. // In this case, check if there's just an '@' missing before a '>' or '>='.
  398. const hasAt = unknown.includes('@');
  399. const comparisonIndex = unknown.indexOf('>');
  400. if (!hasAt && comparisonIndex !== -1) {
  401. const testString = `${unknown.slice(
  402. 0,
  403. comparisonIndex,
  404. )}@${unknown.slice(comparisonIndex)}`;
  405. if (parseArgument(testString).type !== 'unknowns') {
  406. uses++;
  407. context.report({
  408. loc: comment.loc,
  409. messageId: MESSAGE_ID_MISSING_AT_SYMBOL,
  410. data: {
  411. original: unknown,
  412. fix: testString,
  413. message: parseTodoMessage(comment.value),
  414. },
  415. });
  416. continue;
  417. }
  418. }
  419. const withoutWhitespace = unknown.replace(/ /g, '');
  420. if (parseArgument(withoutWhitespace).type !== 'unknowns') {
  421. uses++;
  422. context.report({
  423. loc: comment.loc,
  424. messageId: MESSAGE_ID_REMOVE_WHITESPACE,
  425. data: {
  426. original: unknown,
  427. fix: withoutWhitespace,
  428. message: parseTodoMessage(comment.value),
  429. },
  430. });
  431. continue;
  432. }
  433. }
  434. return uses === 0;
  435. }
  436. return {
  437. Program() {
  438. rules.Program(); // eslint-disable-line new-cap
  439. },
  440. };
  441. };
  442. const schema = [
  443. {
  444. type: 'object',
  445. additionalProperties: false,
  446. properties: {
  447. terms: {
  448. type: 'array',
  449. items: {
  450. type: 'string',
  451. },
  452. },
  453. ignore: {
  454. type: 'array',
  455. uniqueItems: true,
  456. },
  457. ignoreDatesOnPullRequests: {
  458. type: 'boolean',
  459. default: true,
  460. },
  461. allowWarningComments: {
  462. type: 'boolean',
  463. default: false,
  464. },
  465. date: {
  466. type: 'string',
  467. format: 'date',
  468. },
  469. },
  470. },
  471. ];
  472. /** @type {import('eslint').Rule.RuleModule} */
  473. module.exports = {
  474. create,
  475. meta: {
  476. type: 'suggestion',
  477. docs: {
  478. description: 'Add expiration conditions to TODO comments.',
  479. },
  480. schema,
  481. messages,
  482. },
  483. };