@ethlete/eslint-plugin 1.0.0-next.29 → 1.0.0-next.30

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.30
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `ethlete/no-native-title`, off in `recommendedTemplate` until projects opt in: it reports a native `title` attribute (`title="…"`, `[title]`, `[attr.title]`) on HTML elements, because the SDK tooltip replaces it. Components with a dash in their name keep their own `title` input.
8
+ - Add the opt-in `no-silent-catch-error` rule, which flags a `catchError` handler that ignores the error and returns `of()`, `EMPTY` or `NEVER`.
9
+
3
10
  ## 1.0.0-next.29
4
11
 
5
12
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.29",
3
+ "version": "1.0.0-next.30",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ethlete-io/ethdk",
package/src/index.js CHANGED
@@ -57,9 +57,11 @@ const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-pro
57
57
  const noImpureTopLevelProvider = require('./rules/no-impure-top-level-provider');
58
58
  const noLegacyPrepareWithoutInjector = require('./rules/no-legacy-prepare-without-injector');
59
59
  const requireFormSubmit = require('./rules/require-form-submit');
60
+ const noNativeTitle = require('./rules/no-native-title');
60
61
  const noCdkImport = require('./rules/no-cdk-import');
61
62
  const noLegacyQueryImport = require('./rules/no-legacy-query-import');
62
63
  const noAsyncAwait = require('./rules/no-async-await');
64
+ const noSilentCatchError = require('./rules/no-silent-catch-error');
63
65
  const noEnum = require('./rules/no-enum');
64
66
  const consistentTypeDefinitions = require('./rules/consistent-type-definitions');
65
67
  const takeUntilDestroyedLast = require('./rules/take-until-destroyed-last');
@@ -130,9 +132,11 @@ const plugin = {
130
132
  'prefer-static-boolean-properties': preferStaticBooleanProperties,
131
133
  'no-impure-top-level-provider': noImpureTopLevelProvider,
132
134
  'require-form-submit': requireFormSubmit,
135
+ 'no-native-title': noNativeTitle,
133
136
  'no-cdk-import': noCdkImport,
134
137
  'no-legacy-query-import': noLegacyQueryImport,
135
138
  'no-async-await': noAsyncAwait,
139
+ 'no-silent-catch-error': noSilentCatchError,
136
140
  'no-enum': noEnum,
137
141
  'consistent-type-definitions': consistentTypeDefinitions,
138
142
  },
@@ -0,0 +1,60 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Flags a native `title` attribute on an HTML element - it renders the browser tooltip, which the SDK tooltip replaces.
6
+ *
7
+ * <button title="Save"> ✘
8
+ * <button [title]="label()"> ✘
9
+ * <button [attr.title]="label()"> ✘
10
+ *
11
+ * An element with a dash in its name is a component or custom element; its `title` input or attribute
12
+ * is its own API, so only `[attr.title]` is reported there.
13
+ */
14
+
15
+ /** @type {import('eslint').Rule.RuleModule} */
16
+ const noNativeTitle = {
17
+ meta: {
18
+ type: 'suggestion',
19
+ docs: {
20
+ description: 'Disallow the native `title` attribute in Angular templates; use the SDK tooltip.',
21
+ recommended: true,
22
+ },
23
+ schema: [],
24
+ messages: {
25
+ nativeTitle:
26
+ 'A native `title` shows the browser tooltip. Use the SDK tooltip (`etTooltip` from `@ethlete/components`) instead.',
27
+ },
28
+ },
29
+ create(context) {
30
+ const parserServices = /** @type {any} */ (context.sourceCode.parserServices);
31
+
32
+ if (!parserServices?.convertNodeSourceSpanToLoc) return {};
33
+
34
+ /** @param {any} node */
35
+ const report = (node) =>
36
+ context.report({
37
+ loc: parserServices.convertNodeSourceSpanToLoc(node.sourceSpan),
38
+ messageId: 'nativeTitle',
39
+ });
40
+
41
+ return {
42
+ /** @param {any} node */
43
+ Element(node) {
44
+ const isCustom = node.name.includes('-');
45
+
46
+ for (const attribute of node.attributes ?? []) {
47
+ if (attribute.name === 'title' && !isCustom) report(attribute);
48
+ }
49
+
50
+ for (const input of node.inputs ?? []) {
51
+ if (input.name !== 'title') continue;
52
+ const isAttrBinding = input.keySpan?.toString() === 'attr.title';
53
+ if (isAttrBinding || !isCustom) report(input);
54
+ }
55
+ },
56
+ };
57
+ },
58
+ };
59
+
60
+ module.exports = noNativeTitle;
@@ -0,0 +1,105 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallow an RxJS catchError whose handler ignores the error and returns of(...), EMPTY or NEVER.
6
+ *
7
+ * BAD:
8
+ * source$.pipe(catchError(() => of(null)));
9
+ *
10
+ * GOOD:
11
+ * source$.pipe(catchError((error) => { this.error.set(error); return of(null); }));
12
+ */
13
+
14
+ const SWALLOWING_CONSTANTS = new Set(['EMPTY', 'NEVER']);
15
+
16
+ const isSwallowingValue = (node) => {
17
+ if (!node) return false;
18
+
19
+ if (node.type === 'Identifier') return SWALLOWING_CONSTANTS.has(node.name);
20
+
21
+ return node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'of';
22
+ };
23
+
24
+ const collectReturnedValues = (fn) => {
25
+ if (fn.body.type !== 'BlockStatement') return [fn.body];
26
+
27
+ const values = [];
28
+
29
+ const visit = (node) => {
30
+ if (!node || typeof node.type !== 'string') return;
31
+
32
+ if (
33
+ node.type === 'FunctionDeclaration' ||
34
+ node.type === 'FunctionExpression' ||
35
+ node.type === 'ArrowFunctionExpression'
36
+ ) {
37
+ return;
38
+ }
39
+
40
+ if (node.type === 'ReturnStatement') {
41
+ values.push(node.argument);
42
+ return;
43
+ }
44
+
45
+ for (const key of Object.keys(node)) {
46
+ if (key === 'parent') continue;
47
+
48
+ const child = node[key];
49
+
50
+ if (Array.isArray(child)) child.forEach(visit);
51
+ else if (child && typeof child.type === 'string') visit(child);
52
+ }
53
+ };
54
+
55
+ fn.body.body.forEach(visit);
56
+
57
+ return values;
58
+ };
59
+
60
+ /** @type {import('eslint').Rule.RuleModule} */
61
+ const noSilentCatchError = {
62
+ meta: {
63
+ type: 'suggestion',
64
+ docs: {
65
+ description: 'Disallow a catchError handler that ignores the error and swallows it with of(), EMPTY or NEVER.',
66
+ recommended: false,
67
+ },
68
+ messages: {
69
+ silentCatchError:
70
+ 'The error disappears silently. Use it (log it, store it in a signal, rethrow it) or disable this rule with a reason.',
71
+ },
72
+ schema: [],
73
+ },
74
+ create(context) {
75
+ const sourceCode = context.sourceCode;
76
+
77
+ const ignoresError = (fn) => {
78
+ const [param] = fn.params;
79
+
80
+ if (!param) return true;
81
+ if (param.type !== 'Identifier') return false;
82
+
83
+ const variable = sourceCode.getDeclaredVariables(fn).find((v) => v.name === param.name);
84
+
85
+ return !variable || variable.references.length === 0;
86
+ };
87
+
88
+ return {
89
+ CallExpression(node) {
90
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'catchError') return;
91
+
92
+ const [handler] = node.arguments;
93
+
94
+ if (!handler || (handler.type !== 'ArrowFunctionExpression' && handler.type !== 'FunctionExpression')) return;
95
+ if (!ignoresError(handler)) return;
96
+
97
+ if (collectReturnedValues(handler).some(isSwallowingValue)) {
98
+ context.report({ node: handler, messageId: 'silentCatchError' });
99
+ }
100
+ },
101
+ };
102
+ },
103
+ };
104
+
105
+ module.exports = noSilentCatchError;