@ethlete/eslint-plugin 1.0.0-next.12 → 1.0.0-next.14

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.14
4
+
5
+ ### Patch Changes
6
+
7
+ - [`423b30b`](https://github.com/ethlete-io/ethdk/commit/423b30ba4f368d7cee8c464ebd89b5df20d3934c) Thanks [@TomTomB](https://github.com/TomTomB)! - The `static` class member ban now excepts `ngTemplateContextGuard`, which Angular's template type checker requires to be static.
8
+
9
+ - [`7ca77d4`](https://github.com/ethlete-io/ethdk/commit/7ca77d4a9e1dd3abad7237227c62643159719b74) Thanks [@TomTomB](https://github.com/TomTomB)! - `no-trivial-return-type`: self-referencing (recursive) functions keep their return type annotation — TypeScript cannot infer a return type that depends on itself (TS7023), so the fixer no longer strips it there.
10
+
11
+ ## 1.0.0-next.13
12
+
13
+ ### Minor Changes
14
+
15
+ - [#3022](https://github.com/ethlete-io/ethdk/pull/3022) [`5a43c83`](https://github.com/ethlete-io/ethdk/commit/5a43c8347a98a815562d1bb0f59bc0db1d765262) Thanks [@github-actions](https://github.com/apps/github-actions)! - Recommended config: the `@Injectable` ban now also flags Angular 22's `@Service` decorator — use `createProvider` / `createRootProvider` from `@ethlete/core` instead.
16
+
17
+ - [#3022](https://github.com/ethlete-io/ethdk/pull/3022) [`9be3738`](https://github.com/ethlete-io/ethdk/commit/9be3738e18ef1837841c79131127fa42406a3e92) Thanks [@github-actions](https://github.com/apps/github-actions)! - New template rule `ethlete/prefer-static-boolean-properties` (in `recommendedTemplate` as `warn`): flags property bindings of static booleans like `[isReadonly]="true"` and suggests the static-attribute form (`isReadonly` / `isReadonly="false"`). Suggestion-only, since the rewrite is only safe for inputs with a `booleanAttribute` transform.
18
+
3
19
  ## 1.0.0-next.12
4
20
 
5
21
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.12",
3
+ "version": "1.0.0-next.14",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -139,9 +139,10 @@ const recommendedTs = {
139
139
  selector: 'FunctionExpression[async=true]',
140
140
  message: 'No async/await. Use RxJS for all async operations.',
141
141
  },
142
- // No static class members
142
+ // No static class members — except ngTemplateContextGuard, which Angular's template
143
+ // type checker requires to be static (it types a structural directive's `let-` bindings)
143
144
  {
144
- selector: 'MethodDefinition[static=true]',
145
+ selector: "MethodDefinition[static=true]:not([key.name='ngTemplateContextGuard'])",
145
146
  message: 'No static class members.',
146
147
  },
147
148
  {
@@ -169,10 +170,12 @@ const recommendedTs = {
169
170
  message:
170
171
  'No legacy Angular lifecycle hooks. Use afterNextRender, inject(DestroyRef).onDestroy, or constructor-based effects instead.',
171
172
  },
172
- // No @Injectable — use createRootProvider / createProvider from @ethlete/core
173
+ // No @Injectable / @Service — use createRootProvider / createProvider from @ethlete/core
173
174
  {
174
- selector: "Decorator[expression.callee.name='Injectable'], Decorator[expression.name='Injectable']",
175
- message: 'No @Injectable services. Use createRootProvider / createProvider from @ethlete/core instead.',
175
+ selector:
176
+ 'Decorator[expression.callee.name=/^(Injectable|Service)$/], Decorator[expression.name=/^(Injectable|Service)$/]',
177
+ message:
178
+ 'No @Injectable / @Service services. Use createRootProvider / createProvider from @ethlete/core instead.',
176
179
  },
177
180
  // No route guards — handle access control inside the component itself
178
181
  {
@@ -403,6 +406,10 @@ const recommendedTemplate = {
403
406
  '@angular-eslint/template/no-any': 'error',
404
407
  // Prefer plain attribute over unnecessary property binding for static strings: etIcon="foo" not [etIcon]="'foo'"
405
408
  '@angular-eslint/template/prefer-static-string-properties': 'error',
409
+ // Prefer plain attribute over unnecessary property binding for static booleans: isReadonly not [isReadonly]="true"
410
+ // (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
411
+ // which a template rule cannot verify)
412
+ 'ethlete/prefer-static-boolean-properties': 'warn',
406
413
  },
407
414
  };
408
415
 
package/src/index.js CHANGED
@@ -50,6 +50,7 @@ const preferConciseAngularStyleMetadata = require('./rules/prefer-concise-angula
50
50
  const noTypedInjectedElementRef = require('./rules/no-typed-injected-element-ref');
51
51
  const noNativeHtmlInputName = require('./rules/no-native-html-input-name');
52
52
  const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
53
+ const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-properties');
53
54
  const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
54
55
 
55
56
  /** @type {import('eslint').ESLint.Plugin} */
@@ -108,6 +109,7 @@ const plugin = {
108
109
  'no-typed-injected-element-ref': noTypedInjectedElementRef,
109
110
  'no-native-html-input-name': noNativeHtmlInputName,
110
111
  'prefer-present-tense-output': preferPresentTenseOutput,
112
+ 'prefer-static-boolean-properties': preferStaticBooleanProperties,
111
113
  },
112
114
  };
113
115
 
@@ -121,6 +123,7 @@ const recommendedTsWithPlugin = {
121
123
  /** @type {import('eslint').Linter.Config} */
122
124
  const recommendedTemplateWithPlugin = {
123
125
  ...recommendedTemplate,
126
+ plugins: { ethlete: plugin },
124
127
  };
125
128
 
126
129
  const configs = {
@@ -11,6 +11,11 @@
11
11
  *
12
12
  * ALSO GOOD (non-trivial, keep it):
13
13
  * const parse = (): Date => new Date(value)
14
+ *
15
+ * Self-referencing (recursive) functions are exempt: TypeScript cannot infer a
16
+ * return type that depends on itself (TS7023 under noImplicitAny), so the
17
+ * annotation is required there:
18
+ * const walk = (node): boolean => node.ok && walk(node.next)
14
19
  */
15
20
 
16
21
  const TRIVIAL_TYPES = new Set([
@@ -69,6 +74,93 @@ const noTrivialReturnType = {
69
74
  return false;
70
75
  };
71
76
 
77
+ /**
78
+ * The names under which a function implementation can call itself: its own name
79
+ * (named function expression, or the variable an arrow is assigned to) and/or
80
+ * `this.<key>` for class/object methods and class-field arrows.
81
+ * @param {any} fn
82
+ */
83
+ const selfReferenceNames = (fn) => {
84
+ /** @type {{ kind: 'name' | 'this', name: string }[]} */
85
+ const names = [];
86
+
87
+ if (fn.id && fn.id.type === 'Identifier') {
88
+ names.push({ kind: 'name', name: fn.id.name });
89
+ }
90
+
91
+ const parent = fn.parent;
92
+
93
+ if (parent) {
94
+ if (
95
+ (parent.type === 'MethodDefinition' || parent.type === 'PropertyDefinition' || parent.type === 'Property') &&
96
+ !parent.computed &&
97
+ parent.key &&
98
+ parent.key.type === 'Identifier'
99
+ ) {
100
+ names.push({ kind: 'this', name: parent.key.name });
101
+ }
102
+
103
+ if (parent.type === 'VariableDeclarator' && parent.id && parent.id.type === 'Identifier') {
104
+ names.push({ kind: 'name', name: parent.id.name });
105
+ }
106
+ }
107
+
108
+ return names;
109
+ };
110
+
111
+ /**
112
+ * Whether the function body references the function itself (recursion) — there the
113
+ * annotation is required, since TypeScript cannot infer a self-dependent return type.
114
+ * @param {any} fn
115
+ */
116
+ const isSelfReferencing = (fn) => {
117
+ const names = selfReferenceNames(fn);
118
+
119
+ if (!names.length || !fn.body) {
120
+ return false;
121
+ }
122
+
123
+ const stack = [fn.body];
124
+
125
+ while (stack.length) {
126
+ const current = stack.pop();
127
+
128
+ if (!current || typeof current !== 'object') continue;
129
+
130
+ if (Array.isArray(current)) {
131
+ stack.push(...current);
132
+ continue;
133
+ }
134
+
135
+ if (typeof current.type !== 'string') continue;
136
+
137
+ for (const { kind, name } of names) {
138
+ if (kind === 'name' && current.type === 'Identifier' && current.name === name) {
139
+ return true;
140
+ }
141
+
142
+ if (
143
+ kind === 'this' &&
144
+ current.type === 'MemberExpression' &&
145
+ current.object.type === 'ThisExpression' &&
146
+ !current.computed &&
147
+ current.property.type === 'Identifier' &&
148
+ current.property.name === name
149
+ ) {
150
+ return true;
151
+ }
152
+ }
153
+
154
+ for (const key of Object.keys(current)) {
155
+ if (key === 'parent') continue;
156
+ const value = current[key];
157
+ if (value && typeof value === 'object') stack.push(value);
158
+ }
159
+ }
160
+
161
+ return false;
162
+ };
163
+
72
164
  /**
73
165
  * @param {import('eslint').Rule.Node} node
74
166
  */
@@ -81,6 +173,7 @@ const noTrivialReturnType = {
81
173
 
82
174
  const annotation = fn.returnType.typeAnnotation;
83
175
  if (!TRIVIAL_TYPES.has(annotation.type)) return;
176
+ if (isSelfReferencing(fn)) return;
84
177
 
85
178
  const label = TRIVIAL_LABELS[annotation.type] ?? annotation.type;
86
179
 
@@ -0,0 +1,85 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Flags property bindings whose value is a static boolean literal:
6
+ *
7
+ * <my-cmp [isReadonly]="true" />
8
+ * <my-cmp [isReadonly]="false" />
9
+ *
10
+ * If the target input uses a `booleanAttribute` transform (the styleguide
11
+ * default for boolean inputs), the binding is unnecessary — a static attribute
12
+ * expresses the same thing without a change-detection expression:
13
+ *
14
+ * <my-cmp isReadonly /> (true)
15
+ * <my-cmp isReadonly="false" /> (false — `booleanAttribute` coerces the string 'false' to false)
16
+ *
17
+ * Whether the input actually has that transform is invisible to a template
18
+ * rule (no type information), so the rewrite is offered as a suggestion, not
19
+ * an auto-fix: without the transform a static attribute would pass the string
20
+ * 'true' / 'false' instead of a boolean.
21
+ *
22
+ * Companion to `@angular-eslint/template/prefer-static-string-properties`,
23
+ * which covers the string-literal case (where the rewrite is always safe).
24
+ */
25
+
26
+ /** @type {import('eslint').Rule.RuleModule} */
27
+ const preferStaticBooleanProperties = {
28
+ meta: {
29
+ type: 'suggestion',
30
+ docs: {
31
+ description:
32
+ 'Prefer a static attribute over a property binding for static boolean values when the input uses a booleanAttribute transform.',
33
+ },
34
+ hasSuggestions: true,
35
+ schema: [],
36
+ messages: {
37
+ preferStaticBooleanProperty:
38
+ 'Binding a static boolean is unnecessary if the `{{ name }}` input uses a `booleanAttribute` transform — use a static attribute instead. Keep the binding if the input takes a plain boolean without a transform.',
39
+ useBareAttribute: 'Replace with the bare attribute `{{ name }}`',
40
+ useStaticAttribute: 'Replace with the static attribute `{{ name }}="false"`',
41
+ },
42
+ },
43
+ create(context) {
44
+ const parserServices = /** @type {any} */ (context.sourceCode.parserServices);
45
+
46
+ // Only meaningful when parsed with @angular-eslint/template-parser
47
+ if (!parserServices?.convertNodeSourceSpanToLoc) return {};
48
+
49
+ return {
50
+ /** @param {any} node */
51
+ ['BoundAttribute.inputs'](node) {
52
+ const { name, sourceSpan, keySpan, value } = node;
53
+
54
+ // Exclude @animation, class./style./attr. sub-properties, and
55
+ // structural directives (*foo has no keySpan details)
56
+ const isBindingProperty = keySpan?.details && !keySpan.details.includes('@') && !keySpan.details.includes('.');
57
+ if (!isBindingProperty) return;
58
+
59
+ // Only literal `true` / `false` expressions. `value.ast` is a
60
+ // LiteralPrimitive for literals; the raw-source check guards against
61
+ // other node shapes that happen to carry a boolean `value`.
62
+ const source = typeof value?.source === 'string' ? value.source.trim() : null;
63
+ if (typeof value?.ast?.value !== 'boolean' || (source !== 'true' && source !== 'false')) return;
64
+
65
+ const isTrue = value.ast.value === true;
66
+ const replacement = isTrue ? name : `${name}="false"`;
67
+
68
+ context.report({
69
+ loc: parserServices.convertNodeSourceSpanToLoc(sourceSpan),
70
+ messageId: 'preferStaticBooleanProperty',
71
+ data: { name },
72
+ suggest: [
73
+ {
74
+ messageId: isTrue ? 'useBareAttribute' : 'useStaticAttribute',
75
+ data: { name },
76
+ fix: (fixer) => fixer.replaceTextRange([sourceSpan.start.offset, sourceSpan.end.offset], replacement),
77
+ },
78
+ ],
79
+ });
80
+ },
81
+ };
82
+ },
83
+ };
84
+
85
+ module.exports = preferStaticBooleanProperties;