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 | const {flatten} = require('lodash');
|
8 |
|
9 |
|
10 | const MESSAGE_ID_AVOID_MULTIPLE_DATES = 'unicorn/avoidMultipleDates';
|
11 | const MESSAGE_ID_EXPIRED_TODO = 'unicorn/expiredTodo';
|
12 | const MESSAGE_ID_AVOID_MULTIPLE_PACKAGE_VERSIONS =
|
13 | 'unicorn/avoidMultiplePackageVersions';
|
14 | const MESSAGE_ID_REACHED_PACKAGE_VERSION = 'unicorn/reachedPackageVersion';
|
15 | const MESSAGE_ID_HAVE_PACKAGE = 'unicorn/havePackage';
|
16 | const MESSAGE_ID_DONT_HAVE_PACKAGE = 'unicorn/dontHavePackage';
|
17 | const MESSAGE_ID_VERSION_MATCHES = 'unicorn/versionMatches';
|
18 | const MESSAGE_ID_ENGINE_MATCHES = 'unicorn/engineMatches';
|
19 | const MESSAGE_ID_REMOVE_WHITESPACE = 'unicorn/removeWhitespaces';
|
20 | const MESSAGE_ID_MISSING_AT_SYMBOL = 'unicorn/missingAtSymbol';
|
21 |
|
22 | const packageResult = readPkgUp.sync();
|
23 | const hasPackage = Boolean(packageResult);
|
24 | const packageJson = hasPackage ? packageResult.packageJson : {};
|
25 |
|
26 | const packageDependencies = {
|
27 | ...packageJson.dependencies,
|
28 | ...packageJson.devDependencies
|
29 | };
|
30 |
|
31 | const DEPENDENCY_INCLUSION_RE = /^[+-]\s*@?\S+\/?\S+/;
|
32 | const VERSION_COMPARISON_RE = /^(?<name>@?\S\/?\S+)@(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)/i;
|
33 | const PKG_VERSION_RE = /^(?<condition>>|>=)(?<version>\d+(?:\.\d+){0,2}(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)\s*$/;
|
34 | const ISO8601_DATE = /\d{4}-\d{2}-\d{2}/;
|
35 |
|
36 | function 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 |
|
61 | function 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 |
|
71 | function 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 |
|
137 |
|
138 | return {
|
139 | type: 'unknowns',
|
140 | value: argumentString
|
141 | };
|
142 | }
|
143 |
|
144 | function parseTodoMessage(todoString) {
|
145 |
|
146 |
|
147 | const argumentsEnd = todoString.indexOf(']');
|
148 |
|
149 | const afterArguments = todoString.slice(argumentsEnd + 1).trim();
|
150 |
|
151 |
|
152 |
|
153 | const dropColon = afterArguments[0] === ':';
|
154 | if (dropColon) {
|
155 | return afterArguments.slice(1).trim();
|
156 | }
|
157 |
|
158 | return afterArguments;
|
159 | }
|
160 |
|
161 | function reachedDate(past) {
|
162 | const now = new Date().toISOString().slice(0, 10);
|
163 | return Date.parse(past) < Date.parse(now);
|
164 | }
|
165 |
|
166 | function tryToCoerceVersion(rawVersion) {
|
167 | if (!rawVersion) {
|
168 | return false;
|
169 | }
|
170 |
|
171 | let version = String(rawVersion);
|
172 |
|
173 |
|
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 |
|
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 |
|
199 |
|
200 | return semver.parse(version) || semver.coerce(version);
|
201 | } catch {
|
202 | return false;
|
203 | }
|
204 | }
|
205 |
|
206 | function semverComparisonForOperator(operator) {
|
207 | return {
|
208 | '>': semver.gt,
|
209 | '>=': semver.gte
|
210 | }[operator];
|
211 | }
|
212 |
|
213 | const 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 |
|
232 |
|
233 |
|
234 |
|
235 |
|
236 |
|
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 |
|
247 |
|
248 |
|
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 |
|
274 |
|
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 |
|
345 |
|
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 |
|
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 |
|
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();
|
474 | }
|
475 | };
|
476 | };
|
477 |
|
478 | const 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 |
|
505 | module.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 | };
|