@ethlete/eslint-plugin 1.0.0-next.24 → 1.0.0-next.26

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 (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/migrations.json +12 -0
  3. package/package.json +13 -2
  4. package/src/configs/recommended.js +37 -27
  5. package/src/generators/generators.json +10 -0
  6. package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
  7. package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
  8. package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
  9. package/src/index.js +8 -2
  10. package/src/rules/angular-decorator-property-order.js +24 -7
  11. package/src/rules/class-constant-property.js +1 -1
  12. package/src/rules/class-member-order.js +60 -8
  13. package/src/rules/consistent-type-definitions.js +60 -0
  14. package/src/rules/enforce-routing-view-naming.js +34 -23
  15. package/src/rules/guard-return-newline.js +4 -7
  16. package/src/rules/internals/angular-member-visibility.js +24 -2
  17. package/src/rules/internals/angular-metadata-fix.js +231 -1
  18. package/src/rules/internals/angular-version.js +50 -0
  19. package/src/rules/internals/implemented-contract-members.js +26 -11
  20. package/src/rules/no-angular-router-api.js +86 -33
  21. package/src/rules/no-angular-seo-services.js +1 -0
  22. package/src/rules/no-async-await.js +5 -1
  23. package/src/rules/no-cdk-import.js +32 -7
  24. package/src/rules/no-csp-unsafe.js +362 -0
  25. package/src/rules/no-direct-dom-manipulation.js +50 -28
  26. package/src/rules/no-document-cookie.js +51 -8
  27. package/src/rules/no-dom-query.js +5 -2
  28. package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
  29. package/src/rules/no-empty-newlines-between-imports.js +4 -1
  30. package/src/rules/no-impure-top-level-provider.js +42 -7
  31. package/src/rules/no-leading-underscore-class-member.js +27 -2
  32. package/src/rules/no-legacy-angular-decorators.js +11 -6
  33. package/src/rules/no-legacy-prepare-without-injector.js +57 -12
  34. package/src/rules/no-legacy-query-import.js +61 -16
  35. package/src/rules/no-member-alias.js +0 -5
  36. package/src/rules/no-native-observers.js +89 -24
  37. package/src/rules/no-pipe-logic.js +1 -2
  38. package/src/rules/no-readonly-signal.js +1 -5
  39. package/src/rules/no-redundant-internal.js +5 -2
  40. package/src/rules/no-redundant-on-push-change-detection.js +3 -48
  41. package/src/rules/no-rxjs-in-effect.js +7 -5
  42. package/src/rules/no-screaming-case-local.js +3 -5
  43. package/src/rules/no-standalone-flag.js +1 -0
  44. package/src/rules/no-subscribe-with-body.js +30 -1
  45. package/src/rules/no-template-literal-before-inline-template.js +6 -0
  46. package/src/rules/no-trivial-return-type.js +18 -1
  47. package/src/rules/no-unused-class-member.js +1 -1
  48. package/src/rules/no-window-location.js +60 -22
  49. package/src/rules/prefer-clone-equal.js +61 -8
  50. package/src/rules/prefer-concise-angular-host-directives.js +39 -11
  51. package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
  52. package/src/rules/prefer-element-dimensions.js +264 -11
  53. package/src/rules/prefer-linked-signal.js +37 -39
  54. package/src/rules/prefer-match-media.js +17 -3
  55. package/src/rules/prefer-rxjs-timer.js +26 -4
  56. package/src/rules/prefer-scroll-state.js +0 -4
  57. package/src/rules/prefer-static-boolean-properties.js +1 -0
  58. package/src/rules/prefer-viewport-size.js +153 -16
  59. package/src/rules/require-dollar-suffix.js +11 -3
  60. package/src/rules/require-form-submit.js +4 -1
  61. package/src/rules/require-on-push-change-detection.js +7 -259
  62. package/src/rules/require-view-encapsulation-none.js +6 -214
  63. package/src/rules/template-member-accessibility.js +12 -4
@@ -24,27 +24,43 @@ const { isImportedAs } = require('./internals/import-resolution');
24
24
  * GOOD:
25
25
  * selectedItem = linkedSignal(() => this.items()[0] ?? null);
26
26
  *
27
- * NOTE: Only flag when .set() is called directly inside an effect() callback.
28
- * Setting signals outside effects (in methods, event handlers, etc.) is fine.
27
+ * Also flagged when the write sits behind an `if`, as long as every branch only
28
+ * writes signals: `linkedSignal` keeps the previous value for the untaken branch.
29
29
  */
30
30
 
31
31
  /**
32
- * Returns true only when the `.set()` call node is a *pure derivation* inside
33
- * an effect() callback — meaning:
34
- *
35
- * 1. The IMMEDIATE enclosing function (the first function boundary walking up
36
- * from the node) IS the first argument passed to `effect()`. This ensures
37
- * that `.set()` calls nested inside `untracked(() => ...)` or other inner
38
- * functions within the effect are NOT flagged — those are intentional
39
- * write-back patterns, not derivations.
40
- *
41
- * 2. The `.set()` call is the SOLE expression in that callback:
42
- * - Arrow with expression body: `effect(() => sig.set(x))`
43
- * - Block body with a single ExpressionStatement: `effect(() => { sig.set(x); })`
32
+ * @param {any} node
33
+ * @returns {boolean}
34
+ */
35
+ const isSetCall = (node) =>
36
+ node.type === 'CallExpression' &&
37
+ node.callee.type === 'MemberExpression' &&
38
+ node.callee.property.type === 'Identifier' &&
39
+ node.callee.property.name === 'set' &&
40
+ node.arguments.length === 1;
41
+
42
+ /**
43
+ * A statement that does nothing but write signals: a lone `.set()`, a block holding one, or an
44
+ * `if`/`else` whose every branch is one.
44
45
  *
45
- * Multi-statement blocks (even if they end with a `.set()`) indicate that the
46
- * effect is performing side effects alongside the signal write, so they are
47
- * not flagged either.
46
+ * @param {any} statement
47
+ * @returns {boolean}
48
+ */
49
+ const isSetOnlyStatement = (statement) => {
50
+ if (!statement) return false;
51
+ if (statement.type === 'ExpressionStatement') return isSetCall(statement.expression);
52
+ if (statement.type === 'BlockStatement') return statement.body.length === 1 && isSetOnlyStatement(statement.body[0]);
53
+ if (statement.type === 'IfStatement') {
54
+ return (
55
+ isSetOnlyStatement(statement.consequent) && (!statement.alternate || isSetOnlyStatement(statement.alternate))
56
+ );
57
+ }
58
+ return false;
59
+ };
60
+
61
+ /**
62
+ * True when the `.set()` call sits directly in an `effect()` callback (not in a nested function such as
63
+ * `untracked(() => …)`) and that callback does nothing but write signals, optionally behind an `if`.
48
64
  *
49
65
  * @param {import('eslint').SourceCode} sourceCode
50
66
  * @param {import('eslint').Rule.Node} node The `.set()` CallExpression node
@@ -75,20 +91,10 @@ const isPureSetInDirectEffectCallback = (sourceCode, node) => {
75
91
  return false;
76
92
  }
77
93
 
78
- // The .set() must be the sole expression — no surrounding statements.
79
- // Case A: arrow with expression body — the body IS the .set() call.
80
- if (immediateFunction.type === 'ArrowFunctionExpression' && immediateFunction.body === node) {
81
- return true;
82
- }
83
-
84
- // Case B: block body with exactly one ExpressionStatement wrapping the .set() call.
85
94
  const body = immediateFunction.body;
86
- return (
87
- body.type === 'BlockStatement' &&
88
- body.body.length === 1 &&
89
- body.body[0].type === 'ExpressionStatement' &&
90
- body.body[0].expression === node
91
- );
95
+ if (body.type !== 'BlockStatement') return body === node;
96
+
97
+ return node.parent.type === 'ExpressionStatement' && body.body.length === 1 && isSetOnlyStatement(body.body[0]);
92
98
  };
93
99
 
94
100
  /** @type {import('eslint').Rule.RuleModule} */
@@ -108,15 +114,7 @@ const preferLinkedSignal = {
108
114
  create(context) {
109
115
  return {
110
116
  CallExpression(node) {
111
- const { callee } = node;
112
-
113
- if (
114
- callee.type === 'MemberExpression' &&
115
- callee.property.type === 'Identifier' &&
116
- callee.property.name === 'set' &&
117
- node.arguments.length === 1 &&
118
- isPureSetInDirectEffectCallback(context.sourceCode, node)
119
- ) {
117
+ if (isSetCall(node) && isPureSetInDirectEffectCallback(context.sourceCode, node)) {
120
118
  context.report({ node, messageId: 'preferLinkedSignal' });
121
119
  }
122
120
  },
@@ -37,7 +37,12 @@
37
37
  * // … etc. All from @ethlete/core.
38
38
  */
39
39
 
40
- const { MODULE_REFERENCE_SELECTOR, getModuleReference, isImportedAs } = require('./internals/import-resolution');
40
+ const {
41
+ MODULE_REFERENCE_SELECTOR,
42
+ getModuleReference,
43
+ isGlobalReference,
44
+ isImportedAs,
45
+ } = require('./internals/import-resolution');
41
46
 
42
47
  const CDK_LAYOUT = '@angular/cdk/layout';
43
48
 
@@ -62,7 +67,15 @@ const preferMatchMedia = {
62
67
  CallExpression(node) {
63
68
  const { callee } = node;
64
69
 
65
- // ── window.matchMedia() ───────────────────────────────────────────────
70
+ if (
71
+ callee.type === 'Identifier' &&
72
+ callee.name === 'matchMedia' &&
73
+ isGlobalReference(context.sourceCode, callee)
74
+ ) {
75
+ context.report({ node, messageId: 'preferMatchMedia' });
76
+ return;
77
+ }
78
+
66
79
  if (
67
80
  callee.type === 'MemberExpression' &&
68
81
  callee.property.type === 'Identifier' &&
@@ -74,9 +87,10 @@ const preferMatchMedia = {
74
87
  return;
75
88
  }
76
89
 
77
- // ── inject(BreakpointObserver) ────────────────────────────────────────
78
90
  if (
79
91
  isImportedAs(context.sourceCode, callee, 'inject') &&
92
+ node.arguments[0]?.type === 'Identifier' &&
93
+ isGlobalReference(context.sourceCode, node.arguments[0]) &&
80
94
  isImportedAs(context.sourceCode, node.arguments[0], 'BreakpointObserver', CDK_LAYOUT)
81
95
  ) {
82
96
  context.report({ node, messageId: 'noBreakpointObserver' });
@@ -21,6 +21,9 @@
21
21
 
22
22
  const { isGlobalReference } = require('./internals/import-resolution');
23
23
 
24
+ const GLOBAL_OBJECTS = new Set(['window', 'globalThis', 'self']);
25
+ const WINDOW_MEMBERS = new Set(['win', 'window', '_win', '_window']);
26
+
24
27
  /** @type {import('eslint').Rule.RuleModule} */
25
28
  const preferRxjsTimer = {
26
29
  meta: {
@@ -44,13 +47,33 @@ const preferRxjsTimer = {
44
47
  schema: [],
45
48
  },
46
49
  create(context) {
50
+ const getTimerName = (callee) => {
51
+ if (callee.type === 'Identifier') {
52
+ return isGlobalReference(context.sourceCode, callee) ? callee.name : null;
53
+ }
54
+ if (callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') return null;
55
+
56
+ const { object } = callee;
57
+ const isWindow =
58
+ (object.type === 'Identifier' &&
59
+ GLOBAL_OBJECTS.has(object.name) &&
60
+ isGlobalReference(context.sourceCode, object)) ||
61
+ (object.type === 'MemberExpression' &&
62
+ object.object.type === 'ThisExpression' &&
63
+ !object.computed &&
64
+ object.property.type === 'Identifier' &&
65
+ WINDOW_MEMBERS.has(object.property.name));
66
+
67
+ return isWindow ? callee.property.name : null;
68
+ };
69
+
47
70
  return {
48
71
  CallExpression(node) {
49
72
  const { callee } = node;
50
73
 
51
- // ── Global timer calls ────────────────────────────────────────────────
52
- if (isGlobalReference(context.sourceCode, callee)) {
53
- const { name } = callee;
74
+ const timerName = getTimerName(callee);
75
+ if (timerName) {
76
+ const name = timerName;
54
77
  if (name === 'setTimeout') {
55
78
  context.report({ node, messageId: 'preferTimer' });
56
79
  return;
@@ -65,7 +88,6 @@ const preferRxjsTimer = {
65
88
  }
66
89
  }
67
90
 
68
- // ── Member calls: .addEventListener / .removeEventListener ────────────
69
91
  if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
70
92
  const methodName = callee.property.name;
71
93
  if (methodName === 'addEventListener') {
@@ -41,7 +41,6 @@ const preferScrollState = {
41
41
  const { callee } = node;
42
42
  const args = node.arguments;
43
43
 
44
- // ── el.addEventListener('scroll', fn) / window.addEventListener('scroll', fn) ──
45
44
  if (
46
45
  callee.type === 'MemberExpression' &&
47
46
  callee.property.type === 'Identifier' &&
@@ -53,7 +52,6 @@ const preferScrollState = {
53
52
  return;
54
53
  }
55
54
 
56
- // ── fromEvent(el, 'scroll') ──────────────────────────────────────────
57
55
  if (
58
56
  callee.type === 'Identifier' &&
59
57
  callee.name === 'fromEvent' &&
@@ -64,7 +62,6 @@ const preferScrollState = {
64
62
  return;
65
63
  }
66
64
 
67
- // ── renderer.listen(el, 'scroll', fn) ───────────────────────────────
68
65
  if (
69
66
  callee.type === 'MemberExpression' &&
70
67
  callee.property.type === 'Identifier' &&
@@ -77,7 +74,6 @@ const preferScrollState = {
77
74
  },
78
75
 
79
76
  AssignmentExpression(node) {
80
- // ── el.onscroll = fn ────────────────────────────────────────────────
81
77
  const { left } = node;
82
78
  if (
83
79
  left.type === 'MemberExpression' &&
@@ -57,6 +57,7 @@ const preferStaticBooleanProperties = {
57
57
  docs: {
58
58
  description:
59
59
  'Prefer a static attribute over a property binding for static boolean values when the input uses a booleanAttribute transform.',
60
+ recommended: true,
60
61
  },
61
62
  hasSuggestions: true,
62
63
  schema: [],
@@ -20,8 +20,37 @@
20
20
  * const h = viewport().height;
21
21
  */
22
22
 
23
- /** Properties on window that report viewport dimensions. */
23
+ const { isGlobalReference } = require('./internals/import-resolution');
24
+
24
25
  const VIEWPORT_PROPERTIES = new Set(['innerWidth', 'innerHeight']);
26
+ const GLOBAL_OBJECTS = new Set(['window', 'globalThis', 'self']);
27
+
28
+ /**
29
+ * @param {any} node
30
+ * @returns {string | null}
31
+ */
32
+ const getPropertyName = (node) => {
33
+ if (!node.computed && node.property.type === 'Identifier') return node.property.name;
34
+ if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string') {
35
+ return node.property.value;
36
+ }
37
+
38
+ return null;
39
+ };
40
+
41
+ /**
42
+ * @param {any} node
43
+ */
44
+ const getThisClassBody = (node) => {
45
+ let current = node.parent;
46
+
47
+ while (current) {
48
+ if (current.type === 'ClassBody') return current;
49
+ current = current.parent;
50
+ }
51
+
52
+ return null;
53
+ };
25
54
 
26
55
  /** @type {import('eslint').Rule.RuleModule} */
27
56
  const preferViewportSize = {
@@ -38,30 +67,138 @@ const preferViewportSize = {
38
67
  schema: [],
39
68
  },
40
69
  create(context) {
41
- return {
42
- MemberExpression(node) {
43
- if (node.property.type !== 'Identifier') return;
44
- if (!VIEWPORT_PROPERTIES.has(node.property.name)) return;
70
+ const { sourceCode } = context;
71
+ /** @type {WeakMap<any, Set<string>>} */
72
+ const classWindowMembers = new WeakMap();
73
+
74
+ /**
75
+ * @param {any} classBody
76
+ * @returns {Set<string>}
77
+ */
78
+ const getClassWindowMembers = (classBody) => {
79
+ const cached = classWindowMembers.get(classBody);
80
+ if (cached) return cached;
81
+
82
+ /** @type {Set<string>} */
83
+ const members = new Set();
84
+ classWindowMembers.set(classBody, members);
85
+
86
+ /** @param {any} node */
87
+ const visit = (node) => {
88
+ if (!node || typeof node.type !== 'string') return;
89
+ if (node !== classBody && node.type === 'ClassBody') return;
45
90
 
46
- // Only flag when the object is clearly a window-like reference.
47
- const obj = node.object;
48
- const objText = context.sourceCode.getText(obj).toLowerCase();
91
+ if (
92
+ node.type === 'PropertyDefinition' &&
93
+ !node.computed &&
94
+ node.key.type === 'Identifier' &&
95
+ isWindowLike(node.value)
96
+ ) {
97
+ members.add(node.key.name);
98
+ }
49
99
 
50
- const isWindowRef =
51
- objText === 'window' ||
52
- objText.endsWith('.defaultview') ||
53
- objText.endsWith('?.defaultview') ||
54
- // document.defaultView without optional chain
55
- /defaultview$/.test(objText);
100
+ if (
101
+ node.type === 'AssignmentExpression' &&
102
+ node.left.type === 'MemberExpression' &&
103
+ node.left.object.type === 'ThisExpression' &&
104
+ !node.left.computed &&
105
+ node.left.property.type === 'Identifier' &&
106
+ isWindowLike(node.right)
107
+ ) {
108
+ members.add(node.left.property.name);
109
+ }
110
+
111
+ for (const key of Object.keys(node)) {
112
+ if (key === 'parent') continue;
113
+
114
+ const value = node[key];
115
+ if (Array.isArray(value)) value.forEach(visit);
116
+ else if (value && typeof value.type === 'string') visit(value);
117
+ }
118
+ };
119
+
120
+ visit(classBody);
121
+
122
+ return members;
123
+ };
56
124
 
57
- if (!isWindowRef) return;
125
+ /**
126
+ * @param {any} node
127
+ * @param {Set<any>} seen
128
+ * @returns {boolean}
129
+ */
130
+ const isWindowLike = (node, seen = new Set()) => {
131
+ if (!node || seen.has(node)) return false;
132
+ seen.add(node);
133
+
134
+ if (node.type === 'ChainExpression' || node.type === 'TSNonNullExpression' || node.type === 'TSAsExpression') {
135
+ return isWindowLike(node.expression, seen);
136
+ }
137
+
138
+ if (node.type === 'Identifier') {
139
+ if (isGlobalReference(sourceCode, node)) return GLOBAL_OBJECTS.has(node.name);
140
+
141
+ /** @type {import('eslint').Scope.Scope | null} */
142
+ let scope = sourceCode.getScope(node);
143
+
144
+ while (scope) {
145
+ const variable = scope.set.get(node.name);
146
+
147
+ if (variable) {
148
+ const definition = /** @type {any} */ (variable.defs[0]);
149
+
150
+ return definition?.type === 'Variable' && isWindowLike(definition.node.init, seen);
151
+ }
152
+
153
+ scope = scope.upper;
154
+ }
155
+
156
+ return false;
157
+ }
158
+
159
+ if (node.type !== 'MemberExpression') return false;
160
+
161
+ const name = getPropertyName(node);
162
+ if (name === 'defaultView') return true;
163
+ if (name && GLOBAL_OBJECTS.has(name) && isWindowLike(node.object, seen)) return true;
164
+ if (node.object.type !== 'ThisExpression' || !name) return false;
165
+
166
+ const classBody = getThisClassBody(node);
167
+
168
+ return !!classBody && getClassWindowMembers(classBody).has(name);
169
+ };
170
+
171
+ return {
172
+ MemberExpression(node) {
173
+ const prop = getPropertyName(node);
174
+ if (!prop || !VIEWPORT_PROPERTIES.has(prop)) return;
175
+ if (!isWindowLike(node.object)) return;
58
176
 
59
177
  context.report({
60
178
  node,
61
179
  messageId: 'preferViewportSize',
62
- data: { prop: node.property.name },
180
+ data: { prop },
63
181
  });
64
182
  },
183
+ 'Program:exit'(program) {
184
+ const globalScope = sourceCode.getScope(program);
185
+ const declaredGlobalReferences = [...VIEWPORT_PROPERTIES].flatMap((name) => {
186
+ const variable = globalScope.set.get(name);
187
+
188
+ return variable && variable.defs.length === 0 ? variable.references : [];
189
+ });
190
+
191
+ for (const reference of [...globalScope.through, ...declaredGlobalReferences]) {
192
+ const { identifier } = reference;
193
+ if (!VIEWPORT_PROPERTIES.has(identifier.name)) continue;
194
+
195
+ context.report({
196
+ node: identifier,
197
+ messageId: 'preferViewportSize',
198
+ data: { prop: identifier.name },
199
+ });
200
+ }
201
+ },
65
202
  };
66
203
  },
67
204
  };
@@ -12,7 +12,8 @@ const { getImportedName } = require('./internals/import-resolution');
12
12
  * - `new Subject / BehaviorSubject / ReplaySubject / AsyncSubject / Observable()`
13
13
  * - a call to a known RxJS creator: from, of, interval, timer, fromEvent,
14
14
  * fromEventPattern, combineLatest, merge, concat, forkJoin, race, zip,
15
- * defer, iif, throwError, using, partition
15
+ * defer, iif, throwError, using
16
+ * - `toObservable(...)` or `.asObservable()`
16
17
  *
17
18
  * ❌ const myData = someObs.pipe(map(...));
18
19
  * ❌ const clicks = fromEvent(el, 'click');
@@ -40,7 +41,6 @@ const RXJS_CREATORS = new Set([
40
41
  'iif',
41
42
  'throwError',
42
43
  'using',
43
- 'partition',
44
44
  ]);
45
45
 
46
46
  const RXJS_SUBJECT_CTORS = new Set(['Subject', 'BehaviorSubject', 'ReplaySubject', 'AsyncSubject', 'Observable']);
@@ -58,7 +58,7 @@ const isObservableInit = (sourceCode, node) => {
58
58
  node.type === 'CallExpression' &&
59
59
  node.callee.type === 'MemberExpression' &&
60
60
  node.callee.property.type === 'Identifier' &&
61
- node.callee.property.name === 'pipe'
61
+ (node.callee.property.name === 'pipe' || node.callee.property.name === 'asObservable')
62
62
  ) {
63
63
  return true;
64
64
  }
@@ -68,6 +68,13 @@ const isObservableInit = (sourceCode, node) => {
68
68
  return true;
69
69
  }
70
70
 
71
+ if (
72
+ node.type === 'CallExpression' &&
73
+ getImportedName(sourceCode, node.callee, '@angular/core/rxjs-interop') === 'toObservable'
74
+ ) {
75
+ return true;
76
+ }
77
+
71
78
  // new Subject(), new BehaviorSubject(...), etc.
72
79
  if (node.type === 'NewExpression' && RXJS_SUBJECT_CTORS.has(getImportedName(sourceCode, node.callee, 'rxjs') ?? '')) {
73
80
  return true;
@@ -82,6 +89,7 @@ const rule = {
82
89
  type: 'suggestion',
83
90
  docs: {
84
91
  description: 'Require observable variables and class properties to use the $ suffix.',
92
+ recommended: true,
85
93
  },
86
94
  schema: [],
87
95
  messages: {
@@ -28,7 +28,9 @@ const handlesSubmit = (node) =>
28
28
 
29
29
  /** @param {any} node */
30
30
  const submitsNatively = (node) =>
31
- hasBinding(node, 'action') || hasBinding(node, 'ngNoForm') || attribute(node, 'method')?.value === 'dialog';
31
+ hasBinding(node, 'action') ||
32
+ hasBinding(node, 'ngNoForm') ||
33
+ attribute(node, 'method')?.value?.trim().toLowerCase() === 'dialog';
32
34
 
33
35
  /** @type {import('eslint').Rule.RuleModule} */
34
36
  const requireFormSubmit = {
@@ -36,6 +38,7 @@ const requireFormSubmit = {
36
38
  type: 'problem',
37
39
  docs: {
38
40
  description: 'Require a `<form>` to handle its own submission.',
41
+ recommended: true,
39
42
  },
40
43
  schema: [],
41
44
  messages: {