UNPKG

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