1 | 'use strict';
|
2 | const readPkgUp = require('read-pkg-up');
|
3 | const semver = require('semver');
|
4 | const ci = require('ci-info');
|
5 | const baseRule = require('eslint/lib/rules/no-warning-comments');
|
6 | const getDocumentationUrl = require('./utils/get-documentation-url');
|
7 |
|
8 |
|
9 | const MESSAGE_ID_AVOID_MULTIPLE_DATES = 'unicorn/avoidMultipleDates';
|
10 | const MESSAGE_ID_EXPIRED_TODO = 'unicorn/expiredTodo';
|
11 | const MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS =
|
12 | 'unicorn/avoidMultiplePackageVersions';
|
13 | const MESSAGE_ID_REACHED_PACKAGE_VERSION = 'unicorn/reachedPackageVersion';
|
14 | const MESSAGE_ID_HAVE_PACKAGE = 'unicorn/havePackage';
|
15 | const MESSAGE_ID_DONT_HAVE_PACKAGE = 'unicorn/dontHavePackage';
|
16 | const MESSAGE_ID_VERSION_MATCHES = 'unicorn/versionMatches';
|
17 | const MESSAGE_ID_ENGINE_MATCHES = 'unicorn/engineMatches';
|
18 | const MESSAGE_ID_REMOVE_WHITESPACES = 'unicorn/removeWhitespaces';
|
19 | const MESSAGE_ID_MISSING_AT_SYMBOL = 'unicorn/missingAtSymbol';
|
20 |
|
21 | const packageResult = readPkgUp.sync();
|
22 | const hasPackage = Boolean(packageResult);
|
23 | const packageJson = hasPackage ? packageResult.packageJson : {};
|
24 |
|
25 | const packageDependencies = {
|
26 | ...packageJson.dependencies,
|
27 | ...packageJson.devDependencies
|
28 | };
|
29 |
|
30 | const DEPENDENCY_INCLUSION_RE = /^[+-]\s*@?\S+\/?\S+/;
|
31 | const VERSION_COMPARISON_RE = /^(?<name>@?\S\/?\S+)@(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)/i;
|
32 | const PKG_VERSION_RE = /^(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)\s*$/;
|
33 | const ISO8601_DATE = /\d{4}-\d{2}-\d{2}/;
|
34 |
|
35 | function 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 |
|
66 | function 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 |
|
132 |
|
133 | return {
|
134 | type: 'unknowns',
|
135 | value: argumentString
|
136 | };
|
137 | }
|
138 |
|
139 | function parseTodoMessage(todoString) {
|
140 |
|
141 |
|
142 | const argumentsEnd = todoString.indexOf(']');
|
143 |
|
144 | const afterArguments = todoString.slice(argumentsEnd + 1).trim();
|
145 |
|
146 |
|
147 |
|
148 | const dropColon = afterArguments[0] === ':';
|
149 | if (dropColon) {
|
150 | return afterArguments.slice(1).trim();
|
151 | }
|
152 |
|
153 | return afterArguments;
|
154 | }
|
155 |
|
156 | function reachedDate(past) {
|
157 | const now = new Date().toISOString().slice(0, 10);
|
158 | return Date.parse(past) < Date.parse(now);
|
159 | }
|
160 |
|
161 | function tryToCoerceVersion(rawVersion) {
|
162 | if (!rawVersion) {
|
163 | return false;
|
164 | }
|
165 |
|
166 | let version = String(rawVersion);
|
167 |
|
168 |
|
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 |
|
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 |
|
194 |
|
195 | return semver.parse(version) || semver.coerce(version);
|
196 | } catch (_) {
|
197 | return false;
|
198 | }
|
199 | }
|
200 |
|
201 | function semverComparisonForOperator(operator) {
|
202 | return {
|
203 | '>': semver.gt,
|
204 | '>=': semver.gte
|
205 | }[operator];
|
206 | }
|
207 |
|
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 |
|
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 |
|
226 |
|
227 |
|
228 |
|
229 |
|
230 |
|
231 |
|
232 | .map(comment =>
|
233 | comment.value.split('\n').map(line => ({
|
234 | ...comment,
|
235 | value: line
|
236 | }))
|
237 | )
|
238 |
|
239 | .reduce((accumulator, array) => accumulator.concat(array), [])
|
240 | .filter(comment => processComment(comment));
|
241 |
|
242 |
|
243 |
|
244 |
|
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 |
|
270 |
|
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 |
|
341 |
|
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 |
|
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 |
|
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();
|
470 | }
|
471 | };
|
472 | };
|
473 |
|
474 | const 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 |
|
501 | module.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 | };
|