@ethlete/eslint-plugin 1.0.0-next.15 → 1.0.0-next.16

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.16
4
+
5
+ ### Minor Changes
6
+
7
+ - [#3037](https://github.com/ethlete-io/ethdk/pull/3037) [`19469c2`](https://github.com/ethlete-io/ethdk/commit/19469c21903217e05b974ac69773f222e63ae4e1) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `no-effect-cleanup-return`: flags a cleanup function returned from `effect()` /
8
+ `afterRenderEffect()`, which Angular ignores — so the teardown silently never runs. Auto-fixes the
9
+ mechanical case to the `onCleanup` parameter; otherwise points at `inject(DestroyRef).onDestroy()`.
10
+
11
+ - [#3037](https://github.com/ethlete-io/ethdk/pull/3037) [`3151b7a`](https://github.com/ethlete-io/ethdk/commit/3151b7a253d14e38e22e20d67bf0191f141c144e) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `ethlete/no-template-literal-before-inline-template`, and restructure the files it flagged.
12
+
13
+ The Angular VS Code extension decides **client-side** whether the cursor sits inside an inline `template:` before it forwards completion, hover, go-to-definition or signature-help to the language server. That check (`isNotTypescriptOrSupportedDecoratorField`) walks the file with a bare `ts.createScanner()` loop, which cannot re-scan `}` as `TemplateMiddle`/`TemplateTail` — that needs the parser's `reScanTemplateToken()`. So the first template literal containing a `${…}` substitution desynchronises both the token stream and the brace counter, the scanner never recognises `template` `:` again, and every template request below it is dropped. The language server answers those requests correctly; the editor just never asks, so the template silently has no IntelliSense at all.
14
+
15
+ The new rule reproduces that scanner verbatim, so it reports exactly the templates the extension would abandon — no heuristic. Twenty inline templates across `components`, `cdk` and the playground were affected, all of them behind a fixture or helper that happened to use an interpolated template literal. Story fixtures moved into sibling `*-storybook.data.ts` files; spec fixtures and in-class helpers that must stay above their component (because a later `@Component` references the class in `imports`) were rewritten without the interpolation.
16
+
17
+ No public API changed — the `components` and `cdk` bumps are story/spec restructuring plus moving `signalVisibilityChangeClasses` below `RichFilterHostComponent` in the same module.
18
+
3
19
  ## 1.0.0-next.15
4
20
 
5
21
  ### Minor Changes
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.15",
3
+ "version": "1.0.0-next.16",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
7
7
  "peerDependencies": {
8
8
  "@angular-eslint/eslint-plugin-template": ">=21.0.0",
9
- "@angular/core": "22.0.5",
9
+ "@angular/core": "22.0.7",
10
10
  "@typescript-eslint/eslint-plugin": ">=8.0.0",
11
11
  "eslint": ">=9.0.0",
12
12
  "tslib": "2.8.1",
@@ -253,6 +253,9 @@ const recommendedTs = {
253
253
  // No .subscribe() inside effect() or computed()
254
254
  'ethlete/no-rxjs-in-effect': 'error',
255
255
 
256
+ // No cleanup function returned from effect() — Angular ignores it, so it never runs
257
+ 'ethlete/no-effect-cleanup-return': 'error',
258
+
256
259
  // No readonly on reactive class properties (signals, inputs, computed, inject, etc.)
257
260
  'ethlete/no-readonly-signal': 'error',
258
261
 
@@ -343,6 +346,10 @@ const recommendedTs = {
343
346
  // Remove standalone metadata — standalone is implicit and should not be declared
344
347
  'ethlete/no-standalone-flag': 'error',
345
348
 
349
+ // No interpolated template literal above an inline template — it silently kills
350
+ // Angular language service completions for the rest of the file
351
+ 'ethlete/no-template-literal-before-inline-template': 'error',
352
+
346
353
  // ── Angular outputs ─────────────────────────────────────────────────────
347
354
 
348
355
  // No on-prefixed outputs (onSelectDate → selectDate)
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ const noInjectChain = require('./rules/no-inject-chain');
5
5
  const noTrivialReturnType = require('./rules/no-trivial-return-type');
6
6
  const noSubscribeInPipe = require('./rules/no-subscribe-in-pipe');
7
7
  const noRxjsInEffect = require('./rules/no-rxjs-in-effect');
8
+ const noEffectCleanupReturn = require('./rules/no-effect-cleanup-return');
8
9
  const noReadonlySignal = require('./rules/no-readonly-signal');
9
10
  const noScreamingCaseLocal = require('./rules/no-screaming-case-local');
10
11
  const preferRxjsTimer = require('./rules/prefer-rxjs-timer');
@@ -40,6 +41,7 @@ const noUnusedClassMember = require('./rules/no-unused-class-member');
40
41
  const noDomQuery = require('./rules/no-dom-query');
41
42
  const noLeadingUnderscoreClassMember = require('./rules/no-leading-underscore-class-member');
42
43
  const noEmptyAngularMetadataArrays = require('./rules/no-empty-angular-metadata-arrays');
44
+ const noTemplateLiteralBeforeInlineTemplate = require('./rules/no-template-literal-before-inline-template');
43
45
  const noStandaloneFlag = require('./rules/no-standalone-flag');
44
46
  const noEmptyNewlinesBetweenImports = require('./rules/no-empty-newlines-between-imports');
45
47
  const angularDecoratorPropertyOrder = require('./rules/angular-decorator-property-order');
@@ -64,6 +66,7 @@ const plugin = {
64
66
  'no-trivial-return-type': noTrivialReturnType,
65
67
  'no-subscribe-in-pipe': noSubscribeInPipe,
66
68
  'no-rxjs-in-effect': noRxjsInEffect,
69
+ 'no-effect-cleanup-return': noEffectCleanupReturn,
67
70
  'no-readonly-signal': noReadonlySignal,
68
71
  'no-screaming-case-local': noScreamingCaseLocal,
69
72
  'prefer-rxjs-timer': preferRxjsTimer,
@@ -99,6 +102,7 @@ const plugin = {
99
102
  'no-dom-query': noDomQuery,
100
103
  'no-leading-underscore-class-member': noLeadingUnderscoreClassMember,
101
104
  'no-empty-angular-metadata-arrays': noEmptyAngularMetadataArrays,
105
+ 'no-template-literal-before-inline-template': noTemplateLiteralBeforeInlineTemplate,
102
106
  'no-standalone-flag': noStandaloneFlag,
103
107
  'no-empty-newlines-between-imports': noEmptyNewlinesBetweenImports,
104
108
  'angular-decorator-property-order': angularDecoratorPropertyOrder,
@@ -0,0 +1,179 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows returning a cleanup function from an `effect()` callback.
6
+ *
7
+ * Angular's `effect()` ignores whatever the callback returns — the React/Svelte idiom of returning a
8
+ * teardown function silently does nothing, so the "cleanup" never runs. The bug is invisible: the code
9
+ * reads as if it unregisters, and only the leak shows up later.
10
+ *
11
+ * BAD:
12
+ * effect(() => {
13
+ * this.group?.registerItem(this);
14
+ *
15
+ * return () => this.group?.unregisterItem(this); // ❌ never called
16
+ * });
17
+ *
18
+ * GOOD — teardown tied to each re-run (the registered key is reactive):
19
+ * effect((onCleanup) => {
20
+ * const id = this.ref().id;
21
+ * this.stack?.registerItem(id);
22
+ *
23
+ * onCleanup(() => this.stack?.unregisterItem(id));
24
+ * });
25
+ *
26
+ * GOOD — teardown tied to the lifetime, for a registration that never changes:
27
+ * this.group?.registerItem(this);
28
+ * inject(DestroyRef).onDestroy(() => this.group?.unregisterItem(this));
29
+ */
30
+
31
+ /** Calls whose first argument is a body that ignores its return value. */
32
+ const EFFECT_CALLEES = new Set(['effect', 'afterRenderEffect']);
33
+
34
+ /**
35
+ * @param {any} node
36
+ * @returns {boolean}
37
+ */
38
+ const isFunctionExpression = (node) =>
39
+ !!node && (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression');
40
+
41
+ /**
42
+ * @param {any} node
43
+ * @returns {boolean}
44
+ */
45
+ const isFunction = (node) => isFunctionExpression(node) || node?.type === 'FunctionDeclaration';
46
+
47
+ /**
48
+ * Every `return` the given function owns — returns inside nested functions belong to those, so the walk
49
+ * stops at each function boundary.
50
+ * @param {any} fn
51
+ * @returns {any[]}
52
+ */
53
+ const ownReturnStatements = (fn) => {
54
+ /** @type {any[]} */
55
+ const returns = [];
56
+
57
+ /** @param {any} node */
58
+ const visit = (node) => {
59
+ if (!node || typeof node.type !== 'string') {
60
+ return;
61
+ }
62
+
63
+ if (node.type === 'ReturnStatement') {
64
+ returns.push(node);
65
+
66
+ return;
67
+ }
68
+
69
+ if (isFunction(node)) {
70
+ return;
71
+ }
72
+
73
+ for (const key of Object.keys(node)) {
74
+ if (key === 'parent') {
75
+ continue;
76
+ }
77
+
78
+ const value = node[key];
79
+
80
+ if (Array.isArray(value)) {
81
+ value.forEach(visit);
82
+ } else if (value && typeof value.type === 'string') {
83
+ visit(value);
84
+ }
85
+ }
86
+ };
87
+
88
+ fn.body.body.forEach(visit);
89
+
90
+ return returns;
91
+ };
92
+
93
+ /** @type {import('eslint').Rule.RuleModule} */
94
+ const noEffectCleanupReturn = {
95
+ meta: {
96
+ type: 'problem',
97
+ docs: {
98
+ description: 'Disallow returning a cleanup function from an effect() callback — Angular ignores it.',
99
+ recommended: true,
100
+ },
101
+ messages: {
102
+ returnedCleanup:
103
+ '{{callee}}() ignores the value its callback returns, so this cleanup function never runs. ' +
104
+ 'Register it via the onCleanup parameter — {{callee}}((onCleanup) => { …; onCleanup(() => …) }) — or, ' +
105
+ 'when the teardown belongs to the lifetime rather than to each re-run, drop the effect and use ' +
106
+ 'inject(DestroyRef).onDestroy(() => …).',
107
+ },
108
+ schema: [],
109
+ fixable: 'code',
110
+ },
111
+ create(context) {
112
+ const sourceCode = context.sourceCode;
113
+
114
+ return {
115
+ CallExpression(node) {
116
+ const { callee } = node;
117
+
118
+ if (callee.type !== 'Identifier' || !EFFECT_CALLEES.has(callee.name)) {
119
+ return;
120
+ }
121
+
122
+ const callback = /** @type {any} */ (node.arguments[0]);
123
+
124
+ if (!isFunctionExpression(callback)) {
125
+ return;
126
+ }
127
+
128
+ // effect(() => () => cleanup()) — the whole body *is* the ignored cleanup
129
+ if (isFunctionExpression(callback.body)) {
130
+ context.report({
131
+ node: callback.body,
132
+ messageId: 'returnedCleanup',
133
+ data: { callee: callee.name },
134
+ });
135
+
136
+ return;
137
+ }
138
+
139
+ if (callback.body.type !== 'BlockStatement') {
140
+ return;
141
+ }
142
+
143
+ const returns = ownReturnStatements(callback);
144
+ const cleanupReturns = returns.filter((statement) => isFunctionExpression(statement.argument));
145
+
146
+ if (cleanupReturns.length === 0) {
147
+ return;
148
+ }
149
+
150
+ // Only the mechanical shape is auto-fixed: a zero-parameter callback whose single `return` hands
151
+ // back a function literal. With an existing parameter, an early `return`, or a returned reference,
152
+ // the intent needs a human.
153
+ // The parameter list's `(` — the first one before the body, so this works for `() => {}` and
154
+ // `function () {}` alike (their first token differs).
155
+ const openParen = sourceCode
156
+ .getTokens(callback)
157
+ .find((token) => token.value === '(' && token.range[1] <= callback.body.range[0]);
158
+ const fixable = callback.params.length === 0 && returns.length === 1 && cleanupReturns.length === 1;
159
+
160
+ for (const statement of cleanupReturns) {
161
+ context.report({
162
+ node: statement.argument,
163
+ messageId: 'returnedCleanup',
164
+ data: { callee: callee.name },
165
+ fix:
166
+ fixable && openParen
167
+ ? (fixer) => [
168
+ fixer.insertTextAfter(openParen, 'onCleanup'),
169
+ fixer.replaceText(statement, `onCleanup(${sourceCode.getText(statement.argument)});`),
170
+ ]
171
+ : null,
172
+ });
173
+ }
174
+ },
175
+ };
176
+ },
177
+ };
178
+
179
+ module.exports = noEffectCleanupReturn;
@@ -0,0 +1,190 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const ts = require('typescript');
5
+
6
+ /**
7
+ * The Angular VS Code extension decides client-side whether a cursor in a `.ts` file sits inside an
8
+ * inline `template:` before it forwards completion / hover / definition / signature-help to the
9
+ * language server. That check (`isNotTypescriptOrSupportedDecoratorField` in
10
+ * `client/src/embedded_support.js`) walks the file with a bare `ts.createScanner()` loop.
11
+ *
12
+ * A bare scanner cannot re-scan `}` as `TemplateMiddle` / `TemplateTail` — that needs the parser's
13
+ * `reScanTemplateToken()`. So the first template literal containing a substitution desynchronises
14
+ * both the token stream and the brace counter, the scanner never recognises `template` `:` again,
15
+ * and every template request in the rest of the file is silently dropped. The language server
16
+ * answers those requests correctly; the editor just never asks.
17
+ *
18
+ * This rule reproduces that scanner verbatim, so it reports exactly the templates the extension
19
+ * would abandon — no heuristic, no false positives.
20
+ */
21
+ const ANGULAR_PROPERTY_ASSIGNMENTS = new Set(['template', 'templateUrl', 'styleUrls', 'styleUrl', 'host']);
22
+
23
+ /**
24
+ * @param {import('typescript').SyntaxKind} token
25
+ */
26
+ const isPropertyAssignmentTerminator = (token) =>
27
+ token === ts.SyntaxKind.EndOfFileToken ||
28
+ token === ts.SyntaxKind.CommaToken ||
29
+ token === ts.SyntaxKind.SemicolonToken ||
30
+ token === ts.SyntaxKind.CloseBraceToken;
31
+
32
+ /**
33
+ * Mirrors the extension's `isPropertyAssignmentToStringOrStringInArray`. Returns whether the editor
34
+ * would recognise `offset` as sitting inside a supported decorator field.
35
+ *
36
+ * @param {string} text
37
+ * @param {number} offset
38
+ */
39
+ const editorSeesDecoratorField = (text, offset) => {
40
+ const scanner = ts.createScanner(ts.ScriptTarget.ESNext, true);
41
+ scanner.setText(text);
42
+
43
+ let token = scanner.scan();
44
+ let lastToken;
45
+ let lastTokenText;
46
+ let unclosedBraces = 0;
47
+ let unclosedBrackets = 0;
48
+ let propertyAssignmentContext = false;
49
+
50
+ while (token !== ts.SyntaxKind.EndOfFileToken && scanner.getTokenFullStart() < offset) {
51
+ if (
52
+ lastToken === ts.SyntaxKind.Identifier &&
53
+ lastTokenText !== undefined &&
54
+ token === ts.SyntaxKind.ColonToken &&
55
+ ANGULAR_PROPERTY_ASSIGNMENTS.has(lastTokenText)
56
+ ) {
57
+ propertyAssignmentContext = true;
58
+ token = scanner.scan();
59
+ continue;
60
+ }
61
+
62
+ if (unclosedBraces === 0 && unclosedBrackets === 0 && isPropertyAssignmentTerminator(token)) {
63
+ propertyAssignmentContext = false;
64
+ }
65
+
66
+ if (token === ts.SyntaxKind.OpenBracketToken) unclosedBrackets++;
67
+ else if (token === ts.SyntaxKind.OpenBraceToken) unclosedBraces++;
68
+ else if (token === ts.SyntaxKind.CloseBracketToken) unclosedBrackets--;
69
+ else if (token === ts.SyntaxKind.CloseBraceToken) unclosedBraces--;
70
+
71
+ const isStringToken =
72
+ token === ts.SyntaxKind.StringLiteral || token === ts.SyntaxKind.NoSubstitutionTemplateLiteral;
73
+ const isCursorInToken =
74
+ scanner.getTokenFullStart() <= offset && scanner.getTokenFullStart() + scanner.getTokenText().length >= offset;
75
+
76
+ if (propertyAssignmentContext && isCursorInToken && isStringToken) return true;
77
+
78
+ lastTokenText = scanner.getTokenText();
79
+ lastToken = token;
80
+ token = scanner.scan();
81
+ }
82
+
83
+ return false;
84
+ };
85
+
86
+ /**
87
+ * @param {import('estree').Property['key']} key
88
+ */
89
+ const getPropertyName = (key) => {
90
+ if (key.type === 'Identifier') return key.name;
91
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
92
+ return null;
93
+ };
94
+
95
+ /**
96
+ * @param {any} node
97
+ */
98
+ const getDecoratorName = (node) => {
99
+ const expression = node.expression;
100
+ if (!expression) return null;
101
+ if (expression.type === 'CallExpression') {
102
+ return expression.callee.type === 'Identifier' ? expression.callee.name : null;
103
+ }
104
+
105
+ return expression.type === 'Identifier' ? expression.name : null;
106
+ };
107
+
108
+ /**
109
+ * The inline template text, or null when the property is not a plain non-empty string literal.
110
+ *
111
+ * @param {any} value
112
+ */
113
+ const getInlineTemplateText = (value) => {
114
+ if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : null;
115
+ if (value.type === 'TemplateLiteral' && value.expressions.length === 0) return value.quasis[0]?.value.raw ?? '';
116
+ return null;
117
+ };
118
+
119
+ /** @type {import('eslint').Rule.RuleModule} */
120
+ const noTemplateLiteralBeforeInlineTemplate = {
121
+ meta: {
122
+ type: 'problem',
123
+ docs: {
124
+ description:
125
+ 'Disallow a substituted template literal above an inline component template, which silently disables Angular language service completions for the rest of the file.',
126
+ },
127
+ schema: [],
128
+ messages: {
129
+ breaksLanguageService:
130
+ 'The Angular language service gives no completions, hover or go-to-definition inside this template. Its editor-side scanner desynchronises on the interpolated template literal at line {{line}}, so every template request below it is dropped. Move that code below the component, extract it to a sibling file, or write it without an interpolation.',
131
+ },
132
+ },
133
+ create(context) {
134
+ const sourceCode = context.sourceCode;
135
+ const text = sourceCode.getText();
136
+
137
+ /** @type {number[]} */
138
+ const interpolatedLiteralStarts = [];
139
+ /** @type {any[]} */
140
+ const inlineTemplates = [];
141
+
142
+ return {
143
+ /** @param {any} node */
144
+ TemplateLiteral(node) {
145
+ if (node.expressions.length > 0) interpolatedLiteralStarts.push(node.range[0]);
146
+ },
147
+
148
+ /** @param {import('eslint').Rule.Node} node */
149
+ Decorator(node) {
150
+ const decoratorName = getDecoratorName(node);
151
+ if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
152
+
153
+ const expression = /** @type {any} */ (node).expression;
154
+ if (expression.type !== 'CallExpression') return;
155
+
156
+ const metadata = expression.arguments[0];
157
+ if (!metadata || metadata.type !== 'ObjectExpression') return;
158
+
159
+ for (const property of metadata.properties) {
160
+ if (property.type !== 'Property') continue;
161
+ if (getPropertyName(property.key) !== 'template') continue;
162
+
163
+ const templateText = getInlineTemplateText(property.value);
164
+ if (templateText === null || templateText.trim().length === 0) continue;
165
+
166
+ inlineTemplates.push(property);
167
+ }
168
+ },
169
+
170
+ 'Program:exit'() {
171
+ for (const property of inlineTemplates) {
172
+ // Where the editor would put the cursor: just inside the opening quote or backtick.
173
+ const cursor = property.value.range[0] + 2;
174
+ if (editorSeesDecoratorField(text, cursor)) continue;
175
+
176
+ const culprit = interpolatedLiteralStarts.find((start) => start < property.value.range[0]);
177
+ if (culprit === undefined) continue;
178
+
179
+ context.report({
180
+ node: property.key,
181
+ messageId: 'breaksLanguageService',
182
+ data: { line: String(sourceCode.getLocFromIndex(culprit).line) },
183
+ });
184
+ }
185
+ },
186
+ };
187
+ },
188
+ };
189
+
190
+ module.exports = noTemplateLiteralBeforeInlineTemplate;