eslint-plugin-test-governance 0.1.0 → 0.2.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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +1 -1
- package/README.md +69 -50
- package/dist/index.d.ts +7 -2
- package/dist/index.js +27 -16
- package/dist/rules/marker-matches-state.js +4 -4
- package/dist/rules/no-conflicting-states.js +14 -7
- package/dist/rules/no-orphaned-marker.js +16 -4
- package/dist/rules/require-ticket-in-comments.d.ts +3 -0
- package/dist/rules/require-ticket-in-comments.js +96 -0
- package/dist/rules/require-ticket.d.ts +1 -1
- package/dist/rules/require-ticket.js +5 -3
- package/dist/utils/analyze.d.ts +34 -4
- package/dist/utils/analyze.js +141 -47
- package/dist/utils/constants.d.ts +10 -0
- package/dist/utils/constants.js +18 -0
- package/dist/utils/create-rule.d.ts +10 -5
- package/dist/utils/create-rule.js +3 -4
- package/dist/utils/errors.d.ts +5 -0
- package/dist/utils/errors.js +8 -0
- package/dist/utils/fix.js +4 -2
- package/dist/utils/options.d.ts +36 -23
- package/dist/utils/options.js +28 -36
- package/dist/utils/schema.d.ts +3 -3
- package/dist/utils/schema.js +40 -31
- package/dist/utils/tickets.js +9 -1
- package/dist/utils/validate.d.ts +21 -0
- package/dist/utils/validate.js +122 -0
- package/docs/rules/marker-matches-state.md +4 -1
- package/docs/rules/no-conflicting-states.md +5 -1
- package/docs/rules/no-orphaned-marker.md +24 -2
- package/docs/rules/require-ticket-in-comments.md +80 -0
- package/docs/rules/require-ticket.md +31 -21
- package/package.json +17 -7
package/dist/utils/options.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { DEFAULT_COMMENT_KEYWORDS } from './constants.js';
|
|
2
|
+
import { ConfigError } from './errors.js';
|
|
3
|
+
import { optionsSchema } from './schema.js';
|
|
1
4
|
import { compileTicketSpec, DEFAULT_PLACEHOLDERS } from './tickets.js';
|
|
5
|
+
import { validate } from './validate.js';
|
|
2
6
|
export const SETTINGS_KEY = 'test-governance';
|
|
3
|
-
export
|
|
7
|
+
export function isTagState(state) {
|
|
8
|
+
return state.tag !== undefined;
|
|
9
|
+
}
|
|
10
|
+
const BUILTIN_STATES = {
|
|
4
11
|
skip: { marker: 'SKIP', modifier: 'skip', enabled: true },
|
|
5
12
|
fixme: { marker: 'FIXME', modifier: 'fixme', enabled: true },
|
|
6
13
|
fail: { marker: 'FAIL', modifier: 'fail', enabled: false },
|
|
@@ -8,39 +15,30 @@ export const BUILTIN_STATES = {
|
|
|
8
15
|
new: { marker: 'NEW', tag: '@new', lifecycle: true, enabled: false },
|
|
9
16
|
unstable: { marker: 'UNSTABLE', tag: '@unstable', lifecycle: true, enabled: false },
|
|
10
17
|
};
|
|
11
|
-
export
|
|
12
|
-
export const TAG_RE = /^@[\w-]+$/;
|
|
13
|
-
export class ConfigError extends Error {
|
|
14
|
-
constructor(message) {
|
|
15
|
-
super(`eslint-plugin-test-governance: ${message}`);
|
|
16
|
-
this.name = 'ConfigError';
|
|
17
|
-
}
|
|
18
|
-
}
|
|
18
|
+
export { ConfigError } from './errors.js';
|
|
19
19
|
function toSpecs(spec) {
|
|
20
20
|
return Array.isArray(spec) ? spec : [spec];
|
|
21
21
|
}
|
|
22
22
|
const cache = new WeakMap();
|
|
23
23
|
const EMPTY = {};
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Compiles `settings['test-governance']`. Every rule reads the same settings, so they always agree,
|
|
26
|
+
* and the result is cached per settings object, so the rules share one compile.
|
|
27
27
|
*/
|
|
28
|
-
export function resolveOptions(settings
|
|
28
|
+
export function resolveOptions(settings) {
|
|
29
29
|
const shared = (settings?.[SETTINGS_KEY] ?? EMPTY);
|
|
30
|
-
|
|
31
|
-
let byRule = cache.get(shared);
|
|
32
|
-
if (!byRule) {
|
|
33
|
-
byRule = new Map();
|
|
34
|
-
cache.set(shared, byRule);
|
|
35
|
-
}
|
|
36
|
-
let resolved = byRule.get(key);
|
|
30
|
+
let resolved = cache.get(shared);
|
|
37
31
|
if (!resolved) {
|
|
38
|
-
resolved = compileOptions(
|
|
39
|
-
|
|
32
|
+
resolved = compileOptions(shared);
|
|
33
|
+
cache.set(shared, resolved);
|
|
40
34
|
}
|
|
41
35
|
return resolved;
|
|
42
36
|
}
|
|
43
37
|
export function compileOptions(options) {
|
|
38
|
+
const problems = validate(options, optionsSchema);
|
|
39
|
+
if (problems.length > 0) {
|
|
40
|
+
throw new ConfigError(`invalid options:\n${problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
41
|
+
}
|
|
44
42
|
const placeholders = options.placeholders ?? DEFAULT_PLACEHOLDERS;
|
|
45
43
|
const defaultTicket = toSpecs(options.ticket ?? { preset: 'any' });
|
|
46
44
|
const compile = (spec) => compileTicketSpec(spec === undefined ? defaultTicket : toSpecs(spec), placeholders);
|
|
@@ -53,21 +51,17 @@ export function compileOptions(options) {
|
|
|
53
51
|
const enabled = override.enabled ?? (def.lifecycle ? (options.lifecycleTags ?? def.enabled) : def.enabled);
|
|
54
52
|
if (!enabled)
|
|
55
53
|
continue;
|
|
56
|
-
|
|
54
|
+
const base = {
|
|
57
55
|
name,
|
|
58
|
-
marker:
|
|
59
|
-
modifier: def.modifier,
|
|
60
|
-
tag: def.tag,
|
|
56
|
+
marker: override.marker ?? def.marker,
|
|
61
57
|
ticket: override.ticket === undefined ? defaultMatcher : compile(override.ticket),
|
|
62
|
-
}
|
|
58
|
+
};
|
|
59
|
+
states.push(def.modifier === undefined ? { ...base, tag: def.tag } : { ...base, modifier: def.modifier });
|
|
63
60
|
}
|
|
64
61
|
for (const [name, custom] of Object.entries(options.customStates ?? {})) {
|
|
65
|
-
if (!TAG_RE.test(custom.when)) {
|
|
66
|
-
throw new ConfigError(`customStates.${name}.when must be a tag like "@${name}", got "${custom.when}".`);
|
|
67
|
-
}
|
|
68
62
|
states.push({
|
|
69
63
|
name,
|
|
70
|
-
marker:
|
|
64
|
+
marker: custom.marker,
|
|
71
65
|
tag: custom.when,
|
|
72
66
|
ticket: custom.ticket === undefined ? defaultMatcher : compile(custom.ticket),
|
|
73
67
|
});
|
|
@@ -84,6 +78,7 @@ export function compileOptions(options) {
|
|
|
84
78
|
tags.add(state.tag);
|
|
85
79
|
}
|
|
86
80
|
}
|
|
81
|
+
const commentKeywords = options.comments === false ? [] : (options.comments?.keywords ?? DEFAULT_COMMENT_KEYWORDS);
|
|
87
82
|
return {
|
|
88
83
|
testFunctions: new Set(options.testFunctions ?? ['test']),
|
|
89
84
|
states,
|
|
@@ -91,12 +86,9 @@ export function compileOptions(options) {
|
|
|
91
86
|
allowBlankLine: options.allowBlankLine ?? false,
|
|
92
87
|
allowNotes: options.allowNotes ?? false,
|
|
93
88
|
reportDynamicTitles: options.reportDynamicTitles ?? false,
|
|
89
|
+
commentKeywords,
|
|
90
|
+
workCommentKeywords: new Set([...DEFAULT_COMMENT_KEYWORDS, ...commentKeywords]),
|
|
91
|
+
commentTicket: defaultMatcher,
|
|
94
92
|
};
|
|
95
93
|
}
|
|
96
|
-
function checkMarker(marker, state) {
|
|
97
|
-
if (!MARKER_RE.test(marker)) {
|
|
98
|
-
throw new ConfigError(`the marker for state "${state}" must be uppercase letters, digits, "-" or "_", got "${marker}".`);
|
|
99
|
-
}
|
|
100
|
-
return marker;
|
|
101
|
-
}
|
|
102
94
|
//# sourceMappingURL=options.js.map
|
package/dist/utils/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
/**
|
|
3
|
-
export declare const optionsSchema:
|
|
1
|
+
import type { Schema } from './validate.js';
|
|
2
|
+
/** The shape of `settings['test-governance']`, the options of `configure()`. */
|
|
3
|
+
export declare const optionsSchema: Schema;
|
|
4
4
|
//# sourceMappingURL=schema.d.ts.map
|
package/dist/utils/schema.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BUILTIN_STATE_NAMES, MARKER_RE, TAG_RE, TICKET_PRESETS } from './constants.js';
|
|
2
|
+
const MARKER_HINT = 'an uppercase keyword such as NEEDS-DATA (letters, digits, "-" and "_")';
|
|
3
|
+
const TAG_HINT = 'a tag such as @needs-data';
|
|
2
4
|
const ticketSpecSchema = {
|
|
3
5
|
type: 'object',
|
|
4
6
|
properties: {
|
|
5
|
-
preset: {
|
|
6
|
-
type: 'string',
|
|
7
|
-
enum: ['any', 'jira', 'github', 'gitlab', 'linear', 'azure-devops', 'numeric', 'pattern'],
|
|
8
|
-
},
|
|
7
|
+
preset: { type: 'string', enum: [...TICKET_PRESETS] },
|
|
9
8
|
projects: { type: 'array', items: { type: 'string' } },
|
|
10
9
|
teams: { type: 'array', items: { type: 'string' } },
|
|
11
10
|
host: { type: 'string' },
|
|
@@ -27,46 +26,56 @@ const stateOverrideSchema = {
|
|
|
27
26
|
type: 'object',
|
|
28
27
|
properties: {
|
|
29
28
|
enabled: { type: 'boolean' },
|
|
30
|
-
marker: { type: 'string', pattern: MARKER_RE.source },
|
|
29
|
+
marker: { type: 'string', pattern: MARKER_RE.source, patternHint: MARKER_HINT },
|
|
31
30
|
ticket: ticketSchema,
|
|
32
31
|
},
|
|
33
32
|
additionalProperties: false,
|
|
34
33
|
},
|
|
35
34
|
],
|
|
36
35
|
};
|
|
37
|
-
/**
|
|
38
|
-
export const optionsSchema =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
/** The shape of `settings['test-governance']`, the options of `configure()`. */
|
|
37
|
+
export const optionsSchema = {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
testFunctions: { type: 'array', items: { type: 'string' } },
|
|
41
|
+
ticket: ticketSchema,
|
|
42
|
+
placeholders: { type: 'array', items: { type: 'string' } },
|
|
43
|
+
lifecycleTags: { type: 'boolean' },
|
|
44
|
+
states: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: Object.fromEntries(BUILTIN_STATE_NAMES.map((name) => [name, stateOverrideSchema])),
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
customStates: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
additionalProperties: {
|
|
47
52
|
type: 'object',
|
|
48
|
-
properties:
|
|
53
|
+
properties: {
|
|
54
|
+
when: { type: 'string', pattern: TAG_RE.source, patternHint: TAG_HINT },
|
|
55
|
+
marker: { type: 'string', pattern: MARKER_RE.source, patternHint: MARKER_HINT },
|
|
56
|
+
ticket: ticketSchema,
|
|
57
|
+
},
|
|
58
|
+
required: ['when', 'marker'],
|
|
49
59
|
additionalProperties: false,
|
|
50
60
|
},
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
},
|
|
62
|
+
comments: {
|
|
63
|
+
anyOf: [
|
|
64
|
+
{ type: 'boolean', enum: [false] },
|
|
65
|
+
{
|
|
54
66
|
type: 'object',
|
|
55
67
|
properties: {
|
|
56
|
-
|
|
57
|
-
marker: { type: 'string', pattern: MARKER_RE.source },
|
|
58
|
-
ticket: ticketSchema,
|
|
68
|
+
keywords: { type: 'array', items: { type: 'string', pattern: MARKER_RE.source, patternHint: MARKER_HINT } },
|
|
59
69
|
},
|
|
60
|
-
required: ['when', 'marker'],
|
|
61
70
|
additionalProperties: false,
|
|
62
71
|
},
|
|
63
|
-
|
|
64
|
-
requireTicketForConditional: { type: 'boolean' },
|
|
65
|
-
allowBlankLine: { type: 'boolean' },
|
|
66
|
-
allowNotes: { type: 'boolean' },
|
|
67
|
-
reportDynamicTitles: { type: 'boolean' },
|
|
72
|
+
],
|
|
68
73
|
},
|
|
69
|
-
|
|
74
|
+
requireTicketForConditional: { type: 'boolean' },
|
|
75
|
+
allowBlankLine: { type: 'boolean' },
|
|
76
|
+
allowNotes: { type: 'boolean' },
|
|
77
|
+
reportDynamicTitles: { type: 'boolean' },
|
|
70
78
|
},
|
|
71
|
-
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
};
|
|
72
81
|
//# sourceMappingURL=schema.js.map
|
package/dist/utils/tickets.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ConfigError } from './errors.js';
|
|
1
2
|
export const DEFAULT_PLACEHOLDERS = ['TODO', 'TBD', 'XXX-*', '0', '123', '1234', '12345'];
|
|
2
3
|
// Every pattern below is anchored and has no nested or overlapping quantifiers, so matching is
|
|
3
4
|
// linear in the token length. URLs are parsed with `new URL` rather than a regex.
|
|
@@ -122,6 +123,9 @@ function compilePreset(spec) {
|
|
|
122
123
|
case 'numeric': {
|
|
123
124
|
const min = spec.minLength ?? 1;
|
|
124
125
|
const max = spec.maxLength ?? 20;
|
|
126
|
+
if (min > max) {
|
|
127
|
+
throw new ConfigError(`numeric ticket minLength (${min}) is greater than maxLength (${max}).`);
|
|
128
|
+
}
|
|
125
129
|
return {
|
|
126
130
|
test: (t) => DIGITS_RE.test(t) && t.length >= min && t.length <= max,
|
|
127
131
|
expected: min === max ? `a ${min}-digit number` : `a number with ${min} to ${max} digits`,
|
|
@@ -129,12 +133,16 @@ function compilePreset(spec) {
|
|
|
129
133
|
};
|
|
130
134
|
}
|
|
131
135
|
case 'pattern': {
|
|
136
|
+
// The schema can't tie `pattern` to this preset, so check it here.
|
|
137
|
+
if (spec.pattern === undefined) {
|
|
138
|
+
throw new ConfigError('the "pattern" ticket preset needs a `pattern` regex string.');
|
|
139
|
+
}
|
|
132
140
|
let re;
|
|
133
141
|
try {
|
|
134
142
|
re = new RegExp(`^(?:${spec.pattern})$`, spec.flags);
|
|
135
143
|
}
|
|
136
144
|
catch (error) {
|
|
137
|
-
throw new
|
|
145
|
+
throw new ConfigError(`invalid ticket pattern "${spec.pattern}": ${error.message}`);
|
|
138
146
|
}
|
|
139
147
|
return { test: (t) => re.test(t), expected: `a ticket matching /${spec.pattern}/`, example: 'TICKET' };
|
|
140
148
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The subset of JSON Schema that the options schema uses. */
|
|
2
|
+
export interface Schema {
|
|
3
|
+
type?: 'object' | 'array' | 'string' | 'boolean' | 'integer';
|
|
4
|
+
enum?: readonly unknown[];
|
|
5
|
+
pattern?: string;
|
|
6
|
+
/** How to describe `pattern` in messages, e.g. "an uppercase keyword such as NEEDS-DATA". */
|
|
7
|
+
patternHint?: string;
|
|
8
|
+
minimum?: number;
|
|
9
|
+
minItems?: number;
|
|
10
|
+
items?: Schema;
|
|
11
|
+
properties?: Record<string, Schema>;
|
|
12
|
+
additionalProperties?: boolean | Schema;
|
|
13
|
+
required?: readonly string[];
|
|
14
|
+
anyOf?: readonly Schema[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Validates `value` against the subset of JSON Schema that the options schema uses and returns
|
|
18
|
+
* readable problems such as `ticket.preset must be one of "any", "jira", … (got "jria")`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function validate(value: unknown, schema: Schema, path?: string): string[];
|
|
21
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { editDistance } from './distance.js';
|
|
2
|
+
function kindOf(value) {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return 'null';
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return 'array';
|
|
7
|
+
if (typeof value === 'number')
|
|
8
|
+
return Number.isInteger(value) ? 'integer' : 'number';
|
|
9
|
+
if (typeof value === 'object')
|
|
10
|
+
return 'object';
|
|
11
|
+
if (typeof value === 'string')
|
|
12
|
+
return 'string';
|
|
13
|
+
if (typeof value === 'boolean')
|
|
14
|
+
return 'boolean';
|
|
15
|
+
return 'other';
|
|
16
|
+
}
|
|
17
|
+
function matchesType(value, type) {
|
|
18
|
+
return kindOf(value) === type;
|
|
19
|
+
}
|
|
20
|
+
const ARTICLES = { object: 'an object', array: 'an array', integer: 'an integer' };
|
|
21
|
+
function describeType(schema) {
|
|
22
|
+
if (schema.enum?.length === 1)
|
|
23
|
+
return show(schema.enum[0]);
|
|
24
|
+
const type = String(schema.type);
|
|
25
|
+
return ARTICLES[type] ?? `a ${type}`;
|
|
26
|
+
}
|
|
27
|
+
/** `JSON.stringify` returns undefined for values JSON can't represent, such as symbols; its typings don't say so. */
|
|
28
|
+
const toJson = JSON.stringify;
|
|
29
|
+
function show(value) {
|
|
30
|
+
if (typeof value === 'function')
|
|
31
|
+
return 'a function';
|
|
32
|
+
const text = toJson(value) ?? String(value);
|
|
33
|
+
return text.length > 40 ? `${text.slice(0, 37)}...` : text;
|
|
34
|
+
}
|
|
35
|
+
function join(path, key) {
|
|
36
|
+
if (typeof key === 'number')
|
|
37
|
+
return `${path}[${key}]`;
|
|
38
|
+
return path ? `${path}.${key}` : key;
|
|
39
|
+
}
|
|
40
|
+
/** The closest known name to a misspelled one, if it's close enough to be a likely typo. */
|
|
41
|
+
function suggest(name, known) {
|
|
42
|
+
const lower = name.toLowerCase();
|
|
43
|
+
let best;
|
|
44
|
+
let bestDistance = Infinity;
|
|
45
|
+
for (const candidate of known) {
|
|
46
|
+
const distance = editDistance(lower, candidate.toLowerCase());
|
|
47
|
+
if (distance < bestDistance) {
|
|
48
|
+
best = candidate;
|
|
49
|
+
bestDistance = distance;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return bestDistance <= Math.max(2, Math.floor(name.length / 3)) ? best : undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Validates `value` against the subset of JSON Schema that the options schema uses and returns
|
|
56
|
+
* readable problems such as `ticket.preset must be one of "any", "jira", … (got "jria")`.
|
|
57
|
+
*/
|
|
58
|
+
export function validate(value, schema, path = '') {
|
|
59
|
+
const label = path || 'options';
|
|
60
|
+
if (schema.anyOf) {
|
|
61
|
+
const branches = schema.anyOf;
|
|
62
|
+
const results = branches.map((branch) => validate(value, branch, path));
|
|
63
|
+
if (results.some((problems) => problems.length === 0))
|
|
64
|
+
return [];
|
|
65
|
+
const sameType = branches.findIndex((branch) => matchesType(value, branch.type));
|
|
66
|
+
if (sameType !== -1)
|
|
67
|
+
return results[sameType];
|
|
68
|
+
return [`${label} must be ${branches.map(describeType).join(' or ')} (got ${show(value)})`];
|
|
69
|
+
}
|
|
70
|
+
if (schema.type !== undefined && !matchesType(value, schema.type)) {
|
|
71
|
+
return [`${label} must be ${describeType(schema)} (got ${show(value)})`];
|
|
72
|
+
}
|
|
73
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
74
|
+
const allowed = schema.enum.map(show);
|
|
75
|
+
return [
|
|
76
|
+
allowed.length === 1
|
|
77
|
+
? `${label} must be ${allowed[0]} (got ${show(value)})`
|
|
78
|
+
: `${label} must be one of ${allowed.join(', ')} (got ${show(value)})`,
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
if (typeof value === 'string' && schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
|
|
82
|
+
return [`${label} must be ${schema.patternHint ?? `a string matching /${schema.pattern}/`} (got ${show(value)})`];
|
|
83
|
+
}
|
|
84
|
+
if (typeof value === 'number' && schema.minimum !== undefined && value < schema.minimum) {
|
|
85
|
+
return [`${label} must be at least ${schema.minimum} (got ${value})`];
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
const problems = [];
|
|
89
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
90
|
+
problems.push(`${label} must have at least ${schema.minItems} item${schema.minItems === 1 ? '' : 's'}`);
|
|
91
|
+
}
|
|
92
|
+
const { items } = schema;
|
|
93
|
+
if (items)
|
|
94
|
+
value.forEach((item, index) => problems.push(...validate(item, items, join(path, index))));
|
|
95
|
+
return problems;
|
|
96
|
+
}
|
|
97
|
+
if (kindOf(value) === 'object') {
|
|
98
|
+
const object = value;
|
|
99
|
+
const properties = schema.properties ?? {};
|
|
100
|
+
const known = Object.keys(properties);
|
|
101
|
+
const problems = [];
|
|
102
|
+
for (const key of schema.required ?? []) {
|
|
103
|
+
if (object[key] === undefined)
|
|
104
|
+
problems.push(`${join(path, key)} is required`);
|
|
105
|
+
}
|
|
106
|
+
for (const [key, item] of Object.entries(object)) {
|
|
107
|
+
if (item === undefined)
|
|
108
|
+
continue;
|
|
109
|
+
const propertySchema = properties[key] ?? schema.additionalProperties;
|
|
110
|
+
if (typeof propertySchema === 'object') {
|
|
111
|
+
problems.push(...validate(item, propertySchema, join(path, key)));
|
|
112
|
+
}
|
|
113
|
+
else if (propertySchema === false) {
|
|
114
|
+
const hint = suggest(key, known);
|
|
115
|
+
problems.push(`${join(path, key)} is not a known option${hint ? `; did you mean "${hint}"?` : ` (expected one of: ${known.join(', ')})`}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return problems;
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -20,12 +20,14 @@ that reuses the same ticket.
|
|
|
20
20
|
Incorrect:
|
|
21
21
|
|
|
22
22
|
<!-- example: invalid -->
|
|
23
|
+
|
|
23
24
|
```js
|
|
24
25
|
// SKIP: WEB-500
|
|
25
26
|
test.fixme('splits shipping', async () => {});
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
<!-- example: invalid settings={"lifecycleTags":true} -->
|
|
30
|
+
|
|
29
31
|
```js
|
|
30
32
|
// SKIP: WEB-500
|
|
31
33
|
test.skip('splits shipping @unstable', async () => {});
|
|
@@ -34,12 +36,14 @@ test.skip('splits shipping @unstable', async () => {});
|
|
|
34
36
|
Correct:
|
|
35
37
|
|
|
36
38
|
<!-- example: valid -->
|
|
39
|
+
|
|
37
40
|
```js
|
|
38
41
|
// FIXME: WEB-500
|
|
39
42
|
test.fixme('splits shipping', async () => {});
|
|
40
43
|
```
|
|
41
44
|
|
|
42
45
|
<!-- example: valid settings={"lifecycleTags":true} -->
|
|
46
|
+
|
|
43
47
|
```js
|
|
44
48
|
// SKIP: WEB-500
|
|
45
49
|
// UNSTABLE: WEB-500
|
|
@@ -49,4 +53,3 @@ test.skip('splits shipping @unstable', async () => {});
|
|
|
49
53
|
## Options
|
|
50
54
|
|
|
51
55
|
Uses the shared options described in the [README](../../README.md#options).
|
|
52
|
-
|
|
@@ -24,11 +24,13 @@ every tag state that is on, including custom states.
|
|
|
24
24
|
Incorrect:
|
|
25
25
|
|
|
26
26
|
<!-- example: invalid settings={"lifecycleTags":true} -->
|
|
27
|
+
|
|
27
28
|
```js
|
|
28
29
|
test('filters by category @new @unstable', async () => {});
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
<!-- example: invalid settings={"lifecycleTags":true} -->
|
|
33
|
+
|
|
32
34
|
```js
|
|
33
35
|
// SKIP: WEB-9
|
|
34
36
|
// NEW: WEB-9
|
|
@@ -36,11 +38,13 @@ test.skip('filters by category @new', async () => {});
|
|
|
36
38
|
```
|
|
37
39
|
|
|
38
40
|
<!-- example: invalid settings={"lifecycleTags":true} -->
|
|
41
|
+
|
|
39
42
|
```js
|
|
40
43
|
test('invoice download @New', async () => {});
|
|
41
44
|
```
|
|
42
45
|
|
|
43
46
|
<!-- example: invalid settings={"lifecycleTags":true} -->
|
|
47
|
+
|
|
44
48
|
```js
|
|
45
49
|
test('loyalty points @unstabel', async () => {});
|
|
46
50
|
```
|
|
@@ -48,6 +52,7 @@ test('loyalty points @unstabel', async () => {});
|
|
|
48
52
|
Correct:
|
|
49
53
|
|
|
50
54
|
<!-- example: valid settings={"lifecycleTags":true} -->
|
|
55
|
+
|
|
51
56
|
```js
|
|
52
57
|
test('filters by category @unstable', async () => {});
|
|
53
58
|
```
|
|
@@ -55,4 +60,3 @@ test('filters by category @unstable', async () => {});
|
|
|
55
60
|
## Options
|
|
56
61
|
|
|
57
62
|
Uses the shared options described in the [README](../../README.md#options).
|
|
58
|
-
|
|
@@ -14,6 +14,16 @@ A leftover marker makes searches for open tickets lie. This rule reports:
|
|
|
14
14
|
- a marker above a test, describe or skip call that is not in that marker's state, and
|
|
15
15
|
- a marker that is not directly above a test, describe or skip call at all, so it has no effect.
|
|
16
16
|
|
|
17
|
+
`FIXME:` and `TODO:` are also everyday work comments, so this rule leaves them to
|
|
18
|
+
[`require-ticket-in-comments`](require-ticket-in-comments.md), which requires a ticket in them anywhere
|
|
19
|
+
in a test file. A `// FIXME: WEB-12` above a helper is a tracked work comment, not a stray marker. A
|
|
20
|
+
`// FIXME: refactor this` gets one clear error from that rule rather than a confusing one from this.
|
|
21
|
+
|
|
22
|
+
Other markers that read as a sentence, such as `// SKIP: this one is flaky`, are left alone too: the rule
|
|
23
|
+
counts a comment as prose when its first word is not a valid ticket and more text follows.
|
|
24
|
+
|
|
25
|
+
Files that don't use Playwright, such as application code, are not checked.
|
|
26
|
+
|
|
17
27
|
The rule offers a suggestion to delete the comment.
|
|
18
28
|
|
|
19
29
|
## Examples
|
|
@@ -21,15 +31,17 @@ The rule offers a suggestion to delete the comment.
|
|
|
21
31
|
Incorrect:
|
|
22
32
|
|
|
23
33
|
<!-- example: invalid settings={"lifecycleTags":true} -->
|
|
34
|
+
|
|
24
35
|
```js
|
|
25
36
|
// NEW: WEB-80 promoted last sprint
|
|
26
37
|
test('saved addresses', async () => {});
|
|
27
38
|
```
|
|
28
39
|
|
|
29
40
|
<!-- example: invalid -->
|
|
41
|
+
|
|
30
42
|
```js
|
|
31
43
|
test('checkout', async ({ page }) => {
|
|
32
|
-
//
|
|
44
|
+
// SKIP: WEB-81
|
|
33
45
|
await page.goto('/checkout');
|
|
34
46
|
});
|
|
35
47
|
```
|
|
@@ -37,11 +49,22 @@ test('checkout', async ({ page }) => {
|
|
|
37
49
|
Correct:
|
|
38
50
|
|
|
39
51
|
<!-- example: valid settings={"lifecycleTags":true} -->
|
|
52
|
+
|
|
40
53
|
```js
|
|
41
54
|
test('saved addresses', async () => {});
|
|
42
55
|
```
|
|
43
56
|
|
|
44
57
|
<!-- example: valid -->
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
test('checkout', async ({ page }) => {
|
|
61
|
+
// FIXME: the banner sometimes covers the button on slow machines
|
|
62
|
+
await page.goto('/checkout');
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
<!-- example: valid -->
|
|
67
|
+
|
|
45
68
|
```js
|
|
46
69
|
// FIXME: WEB-81
|
|
47
70
|
test.fixme('checkout', async ({ page }) => {
|
|
@@ -52,4 +75,3 @@ test.fixme('checkout', async ({ page }) => {
|
|
|
52
75
|
## Options
|
|
53
76
|
|
|
54
77
|
Uses the shared options described in the [README](../../README.md#options).
|
|
55
|
-
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Require FIXME and TODO comments in test files to start with a ticket (`test-governance/require-ticket-in-comments`)
|
|
2
|
+
|
|
3
|
+
📝 Require FIXME and TODO comments in test files to start with a ticket.
|
|
4
|
+
|
|
5
|
+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/Huldoser/eslint-plugin-test-governance#usage).
|
|
6
|
+
|
|
7
|
+
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
|
8
|
+
|
|
9
|
+
<!-- end auto-generated rule header -->
|
|
10
|
+
|
|
11
|
+
A `// TODO: clean this up` comment is a promise nobody tracks. This rule makes every `FIXME` and
|
|
12
|
+
`TODO` comment in a test file start with the ticket that tracks the work, so the ticket stays the
|
|
13
|
+
source of truth and nothing is forgotten in a comment.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// TODO: WEB-512 ✓
|
|
17
|
+
// FIXME: WEB-77, WEB-78 ✓ several tickets, separated by commas
|
|
18
|
+
// TODO: refactor this ✖ no ticket
|
|
19
|
+
// FIXME: WEB-77 flaky locator ✖ only tickets are allowed (allowNotes: true allows the note)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
How comments are read:
|
|
23
|
+
|
|
24
|
+
- The keyword starts the comment (or a line of a block comment). `TODO` and `FIXME` in capitals
|
|
25
|
+
count with or without a colon. Other casings, such as `todo:`, need the colon, so a sentence that
|
|
26
|
+
happens to start with "Todo" is left alone.
|
|
27
|
+
- The tickets follow the same rules as markers: the shared ticket format, placeholders such as `TBD`
|
|
28
|
+
rejected, and no text after the tickets unless `allowNotes` is set.
|
|
29
|
+
- A `// FIXME:` marker directly above a `test.fixme(...)` is checked by
|
|
30
|
+
[`require-ticket`](require-ticket.md) instead, so it is reported once.
|
|
31
|
+
- Only files that use Playwright (a Playwright import or a call to a test function) are checked.
|
|
32
|
+
Application code is left alone, even if your config applies the plugin to it.
|
|
33
|
+
|
|
34
|
+
## Examples
|
|
35
|
+
|
|
36
|
+
Incorrect:
|
|
37
|
+
|
|
38
|
+
<!-- example: invalid -->
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
test('checkout', async ({ page }) => {
|
|
42
|
+
// FIXME: this locator breaks on mobile
|
|
43
|
+
await page.click('#pay');
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
<!-- example: invalid -->
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
// TODO: TBD
|
|
51
|
+
test('refund', async ({ page }) => {});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Correct:
|
|
55
|
+
|
|
56
|
+
<!-- example: valid -->
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
test('checkout', async ({ page }) => {
|
|
60
|
+
// FIXME: WEB-81
|
|
61
|
+
await page.click('#pay');
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
<!-- example: valid settings={"comments":{"keywords":["FIXME"]}} -->
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// TODO: add a refund test once the API is stable
|
|
69
|
+
test('checkout', async ({ page }) => {});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Options
|
|
73
|
+
|
|
74
|
+
This rule has no options of its own. It reads the shared options described in the
|
|
75
|
+
[README](../../README.md#options):
|
|
76
|
+
|
|
77
|
+
- `comments`: the keywords to check, `{ keywords: ['FIXME', 'TODO'] }` by default. Use
|
|
78
|
+
`{ keywords: ['FIXME'] }` to allow free-form `TODO`s, add your own such as `HACK`, or set
|
|
79
|
+
`comments: false` to turn the check off. Turning the rule off in your config works too.
|
|
80
|
+
- `ticket`, `placeholders` and `allowNotes` work as they do for markers.
|