eslint-plugin-test-governance 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/README.md +166 -64
  3. package/dist/index.d.ts +14 -12
  4. package/dist/index.js +22 -12
  5. package/dist/rules/marker-matches-state.d.ts +1 -2
  6. package/dist/rules/marker-matches-state.js +7 -7
  7. package/dist/rules/no-conflicting-states.d.ts +1 -2
  8. package/dist/rules/no-conflicting-states.js +12 -20
  9. package/dist/rules/no-orphaned-marker.d.ts +1 -2
  10. package/dist/rules/no-orphaned-marker.js +15 -10
  11. package/dist/rules/require-ticket-in-comments.d.ts +1 -2
  12. package/dist/rules/require-ticket-in-comments.js +13 -18
  13. package/dist/rules/require-ticket.d.ts +1 -2
  14. package/dist/rules/require-ticket.js +18 -10
  15. package/dist/utils/analyze.d.ts +17 -14
  16. package/dist/utils/analyze.js +370 -153
  17. package/dist/utils/constants.d.ts +5 -3
  18. package/dist/utils/constants.js +4 -3
  19. package/dist/utils/create-rule.d.ts +9 -10
  20. package/dist/utils/create-rule.js +5 -11
  21. package/dist/utils/distance.d.ts +0 -1
  22. package/dist/utils/distance.js +0 -1
  23. package/dist/utils/errors.d.ts +0 -1
  24. package/dist/utils/errors.js +0 -1
  25. package/dist/utils/fix.d.ts +0 -3
  26. package/dist/utils/fix.js +0 -8
  27. package/dist/utils/frameworks.d.ts +59 -0
  28. package/dist/utils/frameworks.js +102 -0
  29. package/dist/utils/options.d.ts +26 -11
  30. package/dist/utils/options.js +13 -9
  31. package/dist/utils/schema.d.ts +1 -2
  32. package/dist/utils/schema.js +2 -2
  33. package/dist/utils/tickets.d.ts +0 -1
  34. package/dist/utils/tickets.js +4 -4
  35. package/dist/utils/validate.d.ts +0 -1
  36. package/dist/utils/validate.js +3 -3
  37. package/docs/rules/marker-matches-state.md +18 -10
  38. package/docs/rules/no-conflicting-states.md +14 -8
  39. package/docs/rules/no-orphaned-marker.md +31 -17
  40. package/docs/rules/require-ticket-in-comments.md +26 -22
  41. package/docs/rules/require-ticket.md +107 -47
  42. package/package.json +7 -10
@@ -1,17 +1,17 @@
1
- import { headLoc } from '../utils/analyze.js';
2
- import { createRule } from '../utils/create-rule.js';
3
- import { isTagState } from '../utils/options.js';
4
- import { editDistance, typoThreshold } from '../utils/distance.js';
5
- import { tagRange } from '../utils/fix.js';
1
+ import { headLoc } from "../utils/analyze.js";
2
+ import { createRule } from "../utils/create-rule.js";
3
+ import { isTagState } from "../utils/options.js";
4
+ import { editDistance, typoThreshold } from "../utils/distance.js";
6
5
  export default createRule({
7
- name: 'no-conflicting-states',
8
6
  meta: {
9
7
  type: 'problem',
10
- fixable: 'code',
11
8
  docs: {
12
9
  description: 'Disallow state tags that contradict each other, differ in case or look like typos',
13
10
  recommended: 'warn',
11
+ url: 'https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/rules/no-conflicting-states.md',
14
12
  },
13
+ fixable: 'code',
14
+ schema: [],
15
15
  messages: {
16
16
  newAndUnstable: '`{{new}}` and `{{unstable}}` are separate stages; a test should be in only one of them.',
17
17
  newSkipped: "`{{new}}` on a skipped test: a new test that doesn't run can't be promoted. Fix the test or drop the tag.",
@@ -19,19 +19,13 @@ export default createRule({
19
19
  tagTypo: '`{{found}}` looks like a typo of `{{expected}}`.',
20
20
  },
21
21
  },
22
- check(context, analysis, options) {
23
- const { sourceCode } = context;
22
+ create(context, analysis, options) {
24
23
  const tagStates = options.states.filter(isTagState);
25
24
  const newTag = options.states.find((s) => s.name === 'new')?.tag;
26
25
  const unstableTag = options.states.find((s) => s.name === 'unstable')?.tag;
27
26
  for (const subject of analysis.subjects) {
28
- const ownIndex = new Map();
29
- for (const occurrence of subject.tags) {
30
- const key = `${occurrence.tag}\0${occurrence.node.range[0]}`;
31
- const index = ownIndex.get(key) ?? 0;
32
- ownIndex.set(key, index + 1);
33
- checkSpelling(occurrence, index);
34
- }
27
+ for (const occurrence of subject.tags)
28
+ checkSpelling(occurrence);
35
29
  if (newTag === undefined)
36
30
  continue;
37
31
  const own = (tag) => subject.tags.find((t) => t.tag === tag);
@@ -55,14 +49,13 @@ export default createRule({
55
49
  });
56
50
  }
57
51
  }
58
- function checkSpelling(occurrence, index) {
59
- const { tag } = occurrence;
52
+ function checkSpelling(occurrence) {
53
+ const { tag, range } = occurrence;
60
54
  if (tagStates.some((s) => s.tag === tag))
61
55
  return;
62
56
  const lower = tag.toLowerCase();
63
57
  const sameLetters = tagStates.find((s) => s.tag.toLowerCase() === lower);
64
58
  if (sameLetters) {
65
- const range = tagRange(sourceCode, occurrence.node, tag, index);
66
59
  context.report({
67
60
  node: occurrence.node,
68
61
  messageId: 'tagCase',
@@ -84,4 +77,3 @@ export default createRule({
84
77
  }
85
78
  },
86
79
  });
87
- //# sourceMappingURL=no-conflicting-states.js.map
@@ -1,3 +1,2 @@
1
- declare const _default: import("../utils/create-rule.js").Rule<"orphaned" | "detached" | "removeMarker">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"orphaned" | "detached" | "removeMarker">;
2
2
  export default _default;
3
- //# sourceMappingURL=no-orphaned-marker.d.ts.map
@@ -1,29 +1,30 @@
1
- import { describeSubject, isProse, missingStates, strayMarkers } from '../utils/analyze.js';
2
- import { createRule } from '../utils/create-rule.js';
3
- import { removeComment } from '../utils/fix.js';
1
+ import { describeSubject, isProse, missingStates, strayMarkers } from "../utils/analyze.js";
2
+ import { createRule } from "../utils/create-rule.js";
3
+ import { removeComment } from "../utils/fix.js";
4
4
  export default createRule({
5
- name: 'no-orphaned-marker',
6
5
  meta: {
7
6
  type: 'problem',
8
- hasSuggestions: true,
9
7
  docs: {
10
8
  description: 'Disallow marker comments that no longer match a test state',
11
9
  recommended: 'error',
10
+ url: 'https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/rules/no-orphaned-marker.md',
12
11
  },
12
+ hasSuggestions: true,
13
+ schema: [],
13
14
  messages: {
14
15
  orphaned: "`{{marker}}:` is left over: {{subject}} is not in the '{{state}}' state.",
15
16
  detached: '`{{marker}}:` is not directly above a test, describe or skip call, so it has no effect.',
16
17
  removeMarker: 'Remove this comment.',
17
18
  },
18
19
  },
19
- check(context, analysis, options) {
20
- // Files without Playwright tests, such as application code, have no markers to check.
20
+ create(context, analysis, options) {
21
+ // Files without tests, such as application code, have no markers to check.
21
22
  if (!analysis.isTestFile)
22
23
  return;
23
24
  const { sourceCode } = context;
24
25
  const markers = [];
25
26
  for (const marker of analysis.detachedMarkers) {
26
- // `// FIXME: WEB-12` above a helper is a tracked work comment, checked by require-ticket-in-comments.
27
+ // `// FIXME: TRADE-12` above a helper is a tracked work comment, checked by require-ticket-in-comments.
27
28
  if (options.workCommentKeywords.has(marker.keyword))
28
29
  continue;
29
30
  markers.push({ marker, messageId: 'detached', subject: '' });
@@ -33,8 +34,13 @@ export default createRule({
33
34
  if (missingStates(subject).length > 0)
34
35
  continue;
35
36
  const name = describeSubject(subject).replace(/^This/, 'this');
36
- for (const marker of strayMarkers(subject, options))
37
+ for (const marker of strayMarkers(subject, options)) {
38
+ // TODO is the everyday work-comment keyword, so `// TODO: TRADE-12` above a test that isn't a todo
39
+ // is a tracked note about it far more often than a todo test's forgotten marker.
40
+ if (marker.state.name === 'todo' && options.workCommentKeywords.has(marker.keyword))
41
+ continue;
37
42
  markers.push({ marker, messageId: 'orphaned', subject: name });
43
+ }
38
44
  }
39
45
  for (const { marker, messageId, subject } of markers) {
40
46
  // `// SKIP: this is flaky` is prose, not a leftover ticket.
@@ -49,4 +55,3 @@ export default createRule({
49
55
  }
50
56
  },
51
57
  });
52
- //# sourceMappingURL=no-orphaned-marker.js.map
@@ -1,3 +1,2 @@
1
- declare const _default: import("../utils/create-rule.js").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText">;
2
2
  export default _default;
3
- //# sourceMappingURL=require-ticket-in-comments.d.ts.map
@@ -1,20 +1,21 @@
1
- import { appliesTo, isStateMarker, parseTickets } from '../utils/analyze.js';
2
- import { createRule } from '../utils/create-rule.js';
3
- import { compileTicketSpec } from '../utils/tickets.js';
1
+ import { appliesTo, commentLines, isStateMarker, parseTickets } from "../utils/analyze.js";
2
+ import { createRule } from "../utils/create-rule.js";
3
+ import { compileTicketSpec } from "../utils/tickets.js";
4
4
  /** Recognises anything shaped like a ticket, to tell a wrong-format ticket from plain prose. */
5
5
  const ticketShape = compileTicketSpec([{ preset: 'any' }], []);
6
6
  function escapeRegExp(text) {
7
7
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
8
  }
9
9
  export default createRule({
10
- name: 'require-ticket-in-comments',
11
10
  meta: {
12
11
  type: 'suggestion',
13
- hasSuggestions: true,
14
12
  docs: {
15
13
  description: 'Require FIXME and TODO comments in test files to start with a ticket',
16
14
  recommended: 'error',
15
+ url: 'https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/rules/require-ticket-in-comments.md',
17
16
  },
17
+ hasSuggestions: true,
18
+ schema: [],
18
19
  messages: {
19
20
  missingTicket: '`{{keyword}}` comments must start with a ticket, e.g. `// {{upper}}: {{example}}`. Put the details in the ticket.',
20
21
  invalidTicket: "'{{ticket}}' is not a valid ticket. Expected {{expected}}.",
@@ -23,7 +24,7 @@ export default createRule({
23
24
  removeExtraText: 'Remove `{{text}}`.',
24
25
  },
25
26
  },
26
- check(context, analysis, options) {
27
+ create(context, analysis, options) {
27
28
  const keywords = options.commentKeywords;
28
29
  if (keywords.length === 0 || !analysis.isTestFile)
29
30
  return;
@@ -39,12 +40,12 @@ export default createRule({
39
40
  }
40
41
  // `TODO`/`FIXME` in capitals counts with or without a colon. Other casings need the colon, so a
41
42
  // sentence that happens to start with "Todo" is not mistaken for a work comment. The keyword can
42
- // carry its ticket in parentheses, `TODO(WEB-123): ...`, a common style that tools also use for
43
- // a name, `TODO(alice)`, which is not a ticket.
43
+ // carry its ticket in parentheses, `TODO(TRADE-123): ...`, a common style that tools also use for
44
+ // a name, `TODO(alice)`, which is not a ticket. Some teams put a space before the parenthesis.
44
45
  const alternatives = keywords.map(escapeRegExp).join('|');
45
- const keywordRe = new RegExp(`^(\\s*\\*?\\s*)(${alternatives})(?:\\(([^)]*)\\))?(?:\\s*:|(?=\\s|$))`, 'i');
46
+ const keywordRe = new RegExp(`^(\\s*\\*?\\s*)(${alternatives})(?:\\s*\\(([^)]*)\\))?(?:\\s*:|(?=\\s|$))`, 'i');
46
47
  /**
47
- * `TODO(WEB-1, WEB-2): text`: the tickets are in the parentheses, and anything after them, from the
48
+ * `TODO(TRADE-1, TRADE-2): text`: the tickets are in the parentheses, and anything after them, from the
48
49
  * closing parenthesis on, is a note.
49
50
  */
50
51
  function parseParenthesized(line, start, open, inParens) {
@@ -61,12 +62,7 @@ export default createRule({
61
62
  for (const comment of sourceCode.getAllComments()) {
62
63
  if (owned.has(comment))
63
64
  continue;
64
- // `//` and `/*` are both two characters, so the comment's value starts two characters in.
65
- let lineStart = comment.range[0] + 2;
66
- for (const rawLine of comment.value.split('\n')) {
67
- const line = rawLine.replace(/\r$/, '');
68
- const start = lineStart;
69
- lineStart += rawLine.length + 1;
65
+ for (const { text: line, start } of commentLines(comment)) {
70
66
  const match = keywordRe.exec(line);
71
67
  if (!match)
72
68
  continue;
@@ -78,7 +74,7 @@ export default createRule({
78
74
  continue;
79
75
  const { tickets, extra } = inParens === undefined
80
76
  ? parseTickets(line.slice(whole.length), start + whole.length, matcher)
81
- : parseParenthesized(line, start, indent.length + keyword.length, inParens);
77
+ : parseParenthesized(line, start, whole.indexOf('(', indent.length + keyword.length), inParens);
82
78
  const loc = {
83
79
  start: sourceCode.getLocFromIndex(start + indent.length),
84
80
  end: sourceCode.getLocFromIndex(start + line.trimEnd().length),
@@ -114,4 +110,3 @@ export default createRule({
114
110
  }
115
111
  },
116
112
  });
117
- //# sourceMappingURL=require-ticket-in-comments.js.map
@@ -1,3 +1,2 @@
1
- declare const _default: import("../utils/create-rule.js").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle">;
2
2
  export default _default;
3
- //# sourceMappingURL=require-ticket.d.ts.map
@@ -1,14 +1,15 @@
1
- import { appliesTo, describeSubject, evaluate, headLoc } from '../utils/analyze.js';
2
- import { createRule } from '../utils/create-rule.js';
1
+ import { appliesTo, describeSubject, evaluate, headLoc } from "../utils/analyze.js";
2
+ import { createRule } from "../utils/create-rule.js";
3
3
  export default createRule({
4
- name: 'require-ticket',
5
4
  meta: {
6
5
  type: 'problem',
7
- hasSuggestions: true,
8
6
  docs: {
9
7
  description: 'Require a ticket marker comment above skipped, fixme and tagged tests',
10
8
  recommended: 'error',
9
+ url: 'https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/rules/require-ticket.md',
11
10
  },
11
+ hasSuggestions: true,
12
+ schema: [],
12
13
  messages: {
13
14
  missingMarker: "{{subject}} is in the '{{state}}' state but has no `// {{marker}}: <ticket>` comment directly above it, e.g. `// {{marker}}: {{example}}`.",
14
15
  missingTicket: '`{{marker}}:` needs a ticket right after the colon, e.g. `// {{marker}}: {{example}}`.',
@@ -20,7 +21,7 @@ export default createRule({
20
21
  dynamicTitle: "This title isn't static text, so its tags can't be checked. Use a string or template literal.",
21
22
  },
22
23
  },
23
- check(context, analysis, options) {
24
+ create(context, analysis, options) {
24
25
  const hasTagStates = options.states.some((s) => s.tag !== undefined);
25
26
  // A broken marker on a describe can be reached from several tests; report it once.
26
27
  const reported = new Set();
@@ -38,7 +39,6 @@ export default createRule({
38
39
  !reportOnce(`${result.marker.comment.range[0]}:${state.name}`)) {
39
40
  continue;
40
41
  }
41
- const data = { state: state.name, marker: state.marker, example: state.ticket.example };
42
42
  switch (result.kind) {
43
43
  case 'ok':
44
44
  break;
@@ -46,7 +46,12 @@ export default createRule({
46
46
  context.report({
47
47
  loc: headLoc(subject),
48
48
  messageId: 'missingMarker',
49
- data: { ...data, subject: describeSubject(subject) },
49
+ data: {
50
+ subject: describeSubject(subject),
51
+ state: state.name,
52
+ marker: state.marker,
53
+ example: state.ticket.example,
54
+ },
50
55
  });
51
56
  break;
52
57
  case 'case':
@@ -57,13 +62,17 @@ export default createRule({
57
62
  });
58
63
  break;
59
64
  case 'no-ticket':
60
- context.report({ loc: result.marker.comment.loc, messageId: 'missingTicket', data });
65
+ context.report({
66
+ loc: result.marker.comment.loc,
67
+ messageId: 'missingTicket',
68
+ data: { marker: state.marker, example: state.ticket.example },
69
+ });
61
70
  break;
62
71
  case 'bad-ticket':
63
72
  context.report({
64
73
  loc: result.marker.comment.loc,
65
74
  messageId: result.ticket.result === 'placeholder' ? 'placeholderTicket' : 'invalidTicket',
66
- data: { ...data, ticket: result.ticket.text, expected: state.ticket.expected },
75
+ data: { ticket: result.ticket.text, state: state.name, expected: state.ticket.expected },
67
76
  });
68
77
  break;
69
78
  }
@@ -97,4 +106,3 @@ export default createRule({
97
106
  }
98
107
  },
99
108
  });
100
- //# sourceMappingURL=require-ticket.js.map
@@ -1,10 +1,10 @@
1
1
  import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
- import type { ResolvedOptions, StateDef } from './options.js';
3
- import type { TicketCheck, TicketMatcher } from './tickets.js';
2
+ import type { ResolvedOptions, StateDef } from './options.ts';
3
+ import type { TicketCheck, TicketMatcher } from './tickets.ts';
4
4
  type Node = TSESTree.Node;
5
5
  type Comment = TSESTree.Comment;
6
6
  type SourceCode = Readonly<TSESLint.SourceCode>;
7
- export interface MarkerTicket {
7
+ interface MarkerTicket {
8
8
  text: string;
9
9
  result: TicketCheck;
10
10
  }
@@ -31,14 +31,19 @@ export declare function isStateMarker(marker: Marker): marker is StateMarker;
31
31
  export interface TagOccurrence {
32
32
  tag: string;
33
33
  node: TSESTree.Literal | TSESTree.TemplateLiteral;
34
+ /**
35
+ * Where the tag is written in the source. Unset when an escape such as `\u002D` comes before the
36
+ * tag or inside it, since the source then no longer lines up with the title.
37
+ */
38
+ range?: TSESTree.Range;
34
39
  }
35
- export interface StateSource {
40
+ interface StateSource {
36
41
  state: StateDef;
37
42
  /** False for conditional skips unless `requireTicketForConditional` is set. */
38
43
  required: boolean;
39
44
  }
40
45
  export interface Subject {
41
- kind: 'test' | 'describe' | 'runtime';
46
+ kind: 'test' | 'describe' | 'step' | 'runtime';
42
47
  node: TSESTree.CallExpression;
43
48
  /** The node the marker comment block sits above. */
44
49
  anchor: Node;
@@ -63,7 +68,7 @@ export interface Analysis {
63
68
  subjects: Subject[];
64
69
  /** Markers that are not in the comment block of any test, describe or runtime call. */
65
70
  detachedMarkers: StateMarker[];
66
- /** Whether the file uses Playwright at all: a Playwright import or a call to a test function. */
71
+ /** Whether the file uses the test framework at all: an import from it or a call to a test function. */
67
72
  isTestFile: boolean;
68
73
  }
69
74
  export type Evaluation = {
@@ -86,6 +91,11 @@ export type Evaluation = {
86
91
  * of `text[0]`. Anything after the last ticket, including a trailing comma, is returned as `extra`.
87
92
  */
88
93
  export declare function parseTickets(text: string, offset: number, matcher: TicketMatcher): Pick<Marker, 'tickets' | 'extra'>;
94
+ /** The lines of a comment and where each starts in the source, split at every line break JavaScript has. */
95
+ export declare function commentLines(comment: Comment): {
96
+ text: string;
97
+ start: number;
98
+ }[];
89
99
  export declare function analyze(sourceCode: SourceCode, options: ResolvedOptions): Analysis;
90
100
  /** Whether `subject` is in `stateName` at all, required or not, including inherited and runtime states. */
91
101
  export declare function appliesTo(subject: Subject, stateName: string): boolean;
@@ -98,20 +108,13 @@ export declare function missingStates(subject: Subject): StateDef[];
98
108
  * after it.
99
109
  */
100
110
  export declare function isProse(marker: Marker): boolean;
101
- /**
102
- * Whether a marker is really an ordinary work comment such as `// FIXME: refactor this`: its keyword
103
- * is also a work-comment keyword and it doesn't start with a valid ticket. `require-ticket-in-comments`
104
- * reports those; the marker rules leave them alone.
105
- */
106
- export declare function isWorkComment(marker: Marker, options: ResolvedOptions): boolean;
107
111
  /** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
108
112
  export declare function strayMarkers(subject: Subject, options: ResolvedOptions): StateMarker[];
109
113
  /**
110
114
  * Where to report a problem with a test or describe: from the callee to the end of the title
111
- * (`test.skip('pays with PayPal'`), so editors underline the declaration rather than the whole body.
115
+ * (`test.skip('places a limit order'`), so editors underline the declaration rather than the whole body.
112
116
  * Runtime calls such as `test.skip()` are short, so they are reported whole.
113
117
  */
114
118
  export declare function headLoc(subject: Subject): TSESTree.SourceLocation;
115
119
  export declare function describeSubject(subject: Subject): string;
116
120
  export {};
117
- //# sourceMappingURL=analyze.d.ts.map