UNPKG

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