expiring-todo-comments.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  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) {
  160. const now = new Date().toISOString().slice(0, 10);
  161. return Date.parse(past) < Date.parse(now);
  162. }
  163. function tryToCoerceVersion(rawVersion) {
  164. /* istanbul ignore if: version in `package.json` and comment can't be empty */
  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. /* istanbul ignore if: We don't have this `package.json` to test */
  185. if (parts.length > 1) {
  186. version = parts[0];
  187. }
  188. /* istanbul ignore if: We don't have this `package.json` to test */
  189. if (semver.valid(version)) {
  190. return version;
  191. }
  192. try {
  193. // Try to semver.parse a perfect match while semver.coerce tries to fix errors
  194. // But coerce can't parse pre-releases.
  195. return semver.parse(version) || semver.coerce(version);
  196. } catch {
  197. /* istanbul ignore next: We don't have this `package.json` to test */
  198. return false;
  199. }
  200. }
  201. function semverComparisonForOperator(operator) {
  202. return {
  203. '>': semver.gt,
  204. '>=': semver.gte,
  205. }[operator];
  206. }
  207. /** @param {import('eslint').Rule.RuleContext} context */
  208. const create = context => {
  209. const options = {
  210. terms: ['todo', 'fixme', 'xxx'],
  211. ignore: [],
  212. ignoreDatesOnPullRequests: true,
  213. allowWarningComments: true,
  214. ...context.options[0],
  215. };
  216. const ignoreRegexes = options.ignore.map(
  217. pattern => pattern instanceof RegExp ? pattern : new RegExp(pattern, 'u'),
  218. );
  219. const sourceCode = context.getSourceCode();
  220. const comments = sourceCode.getAllComments();
  221. const unusedComments = comments
  222. .filter(token => token.type !== 'Shebang')
  223. // Block comments come as one.
  224. // Split for situations like this:
  225. // /*
  226. // * TODO [2999-01-01]: Validate this
  227. // * TODO [2999-01-01]: And this
  228. // * TODO [2999-01-01]: Also this
  229. // */
  230. .flatMap(comment =>
  231. comment.value.split('\n').map(line => ({
  232. ...comment,
  233. value: line,
  234. })),
  235. ).filter(comment => processComment(comment));
  236. // This is highly dependable on ESLint's `no-warning-comments` implementation.
  237. // What we do is patch the parts we know the rule will use, `getAllComments`.
  238. // Since we have priority, we leave only the comments that we didn't use.
  239. const fakeContext = {
  240. ...context,
  241. getSourceCode() {
  242. return {
  243. ...sourceCode,
  244. getAllComments() {
  245. return options.allowWarningComments ? [] : unusedComments;
  246. },
  247. };
  248. },
  249. };
  250. const rules = baseRule.create(fakeContext);
  251. function processComment(comment) {
  252. if (ignoreRegexes.some(ignore => ignore.test(comment.value))) {
  253. return;
  254. }
  255. const parsed = parseTodoWithArguments(comment.value, options);
  256. if (!parsed) {
  257. return true;
  258. }
  259. // Count if there are valid properties.
  260. // Otherwise, it's a useless TODO and falls back to `no-warning-comments`.
  261. let uses = 0;
  262. const {
  263. packageVersions = [],
  264. dates = [],
  265. dependencies = [],
  266. engines = [],
  267. unknowns = [],
  268. } = parsed;
  269. if (dates.length > 1) {
  270. uses++;
  271. context.report({
  272. loc: comment.loc,
  273. messageId: MESSAGE_ID_AVOID_MULTIPLE_DATES,
  274. data: {
  275. expirationDates: dates.join(', '),
  276. message: parseTodoMessage(comment.value),
  277. },
  278. });
  279. } else if (dates.length === 1) {
  280. uses++;
  281. const [date] = dates;
  282. const shouldIgnore = options.ignoreDatesOnPullRequests && ci.isPR;
  283. if (!shouldIgnore && reachedDate(date)) {
  284. context.report({
  285. loc: comment.loc,
  286. messageId: MESSAGE_ID_EXPIRED_TODO,
  287. data: {
  288. expirationDate: date,
  289. message: parseTodoMessage(comment.value),
  290. },
  291. });
  292. }
  293. }
  294. if (packageVersions.length > 1) {
  295. uses++;
  296. context.report({
  297. loc: comment.loc,
  298. messageId: MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS,
  299. data: {
  300. versions: packageVersions
  301. .map(({condition, version}) => `${condition}${version}`)
  302. .join(', '),
  303. message: parseTodoMessage(comment.value),
  304. },
  305. });
  306. } else if (packageVersions.length === 1) {
  307. uses++;
  308. const [{condition, version}] = packageVersions;
  309. const packageVersion = tryToCoerceVersion(packageJson.version);
  310. const decidedPackageVersion = tryToCoerceVersion(version);
  311. const compare = semverComparisonForOperator(condition);
  312. if (packageVersion && compare(packageVersion, decidedPackageVersion)) {
  313. context.report({
  314. loc: comment.loc,
  315. messageId: MESSAGE_ID_REACHED_PACKAGE_VERSION,
  316. data: {
  317. comparison: `${condition}${version}`,
  318. message: parseTodoMessage(comment.value),
  319. },
  320. });
  321. }
  322. }
  323. // Inclusion: 'in', 'out'
  324. // Comparison: '>', '>='
  325. for (const dependency of dependencies) {
  326. uses++;
  327. const targetPackageRawVersion = packageDependencies[dependency.name];
  328. const hasTargetPackage = Boolean(targetPackageRawVersion);
  329. const isInclusion = ['in', 'out'].includes(dependency.condition);
  330. if (isInclusion) {
  331. const [trigger, messageId]
  332. = dependency.condition === 'in'
  333. ? [hasTargetPackage, MESSAGE_ID_HAVE_PACKAGE]
  334. : [!hasTargetPackage, MESSAGE_ID_DONT_HAVE_PACKAGE];
  335. if (trigger) {
  336. context.report({
  337. loc: comment.loc,
  338. messageId,
  339. data: {
  340. package: dependency.name,
  341. message: parseTodoMessage(comment.value),
  342. },
  343. });
  344. }
  345. continue;
  346. }
  347. const todoVersion = tryToCoerceVersion(dependency.version);
  348. const targetPackageVersion = tryToCoerceVersion(targetPackageRawVersion);
  349. /* istanbul ignore if: Can't test in Node.js */
  350. if (!hasTargetPackage || !targetPackageVersion) {
  351. // Can't compare `¯\_(ツ)_/¯`
  352. continue;
  353. }
  354. const compare = semverComparisonForOperator(dependency.condition);
  355. if (compare(targetPackageVersion, todoVersion)) {
  356. context.report({
  357. loc: comment.loc,
  358. messageId: MESSAGE_ID_VERSION_MATCHES,
  359. data: {
  360. comparison: `${dependency.name} ${dependency.condition} ${dependency.version}`,
  361. message: parseTodoMessage(comment.value),
  362. },
  363. });
  364. }
  365. }
  366. const packageEngines = packageJson.engines || {};
  367. for (const engine of engines) {
  368. uses++;
  369. const targetPackageRawEngineVersion = packageEngines.node;
  370. const hasTargetEngine = Boolean(targetPackageRawEngineVersion);
  371. /* istanbul ignore if: Can't test in this repo */
  372. if (!hasTargetEngine) {
  373. continue;
  374. }
  375. const todoEngine = tryToCoerceVersion(engine.version);
  376. const targetPackageEngineVersion = tryToCoerceVersion(
  377. targetPackageRawEngineVersion,
  378. );
  379. const compare = semverComparisonForOperator(engine.condition);
  380. if (compare(targetPackageEngineVersion, todoEngine)) {
  381. context.report({
  382. loc: comment.loc,
  383. messageId: MESSAGE_ID_ENGINE_MATCHES,
  384. data: {
  385. comparison: `node${engine.condition}${engine.version}`,
  386. message: parseTodoMessage(comment.value),
  387. },
  388. });
  389. }
  390. }
  391. for (const unknown of unknowns) {
  392. // In this case, check if there's just an '@' missing before a '>' or '>='.
  393. const hasAt = unknown.includes('@');
  394. const comparisonIndex = unknown.indexOf('>');
  395. if (!hasAt && comparisonIndex !== -1) {
  396. const testString = `${unknown.slice(
  397. 0,
  398. comparisonIndex,
  399. )}@${unknown.slice(comparisonIndex)}`;
  400. if (parseArgument(testString).type !== 'unknowns') {
  401. uses++;
  402. context.report({
  403. loc: comment.loc,
  404. messageId: MESSAGE_ID_MISSING_AT_SYMBOL,
  405. data: {
  406. original: unknown,
  407. fix: testString,
  408. message: parseTodoMessage(comment.value),
  409. },
  410. });
  411. continue;
  412. }
  413. }
  414. const withoutWhitespace = unknown.replace(/ /g, '');
  415. if (parseArgument(withoutWhitespace).type !== 'unknowns') {
  416. uses++;
  417. context.report({
  418. loc: comment.loc,
  419. messageId: MESSAGE_ID_REMOVE_WHITESPACE,
  420. data: {
  421. original: unknown,
  422. fix: withoutWhitespace,
  423. message: parseTodoMessage(comment.value),
  424. },
  425. });
  426. continue;
  427. }
  428. }
  429. return uses === 0;
  430. }
  431. return {
  432. Program() {
  433. rules.Program(); // eslint-disable-line new-cap
  434. },
  435. };
  436. };
  437. const schema = [
  438. {
  439. type: 'object',
  440. additionalProperties: false,
  441. properties: {
  442. terms: {
  443. type: 'array',
  444. items: {
  445. type: 'string',
  446. },
  447. },
  448. ignore: {
  449. type: 'array',
  450. uniqueItems: true,
  451. },
  452. ignoreDatesOnPullRequests: {
  453. type: 'boolean',
  454. default: true,
  455. },
  456. allowWarningComments: {
  457. type: 'boolean',
  458. default: false,
  459. },
  460. },
  461. },
  462. ];
  463. /** @type {import('eslint').Rule.RuleModule} */
  464. module.exports = {
  465. create,
  466. meta: {
  467. type: 'suggestion',
  468. docs: {
  469. description: 'Add expiration conditions to TODO comments.',
  470. },
  471. schema,
  472. messages,
  473. },
  474. };