@angular-eslint/eslint-plugin 22.2.1-alpha.9 → 22.3.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/README.md CHANGED
@@ -29,6 +29,7 @@ The premade flat configs that combine these rules are provided by the umbrella [
29
29
  | [`no-developer-preview`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-developer-preview.md) | Disallow using code which is marked as developer preview | | | |
30
30
  | [`no-experimental`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-experimental.md) | Disallow using code which is marked as experimental | | | |
31
31
  | [`no-implicit-take-until-destroyed`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-implicit-take-until-destroyed.md) | Ensures that `takeUntilDestroyed()` is called with an explicit `DestroyRef` when used outside of an injection context | | | |
32
+ | [`reactive-context-must-read-signal`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/reactive-context-must-read-signal.md) | Ensures that reactive contexts such as computed(), linkedSignal() and effect() read at least one reactive value (a signal). A reactive context with no reactive dependencies never re-runs, which is almost always a mistake. | | | |
32
33
  | [`require-lifecycle-on-prototype`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md) | Ensures that lifecycle methods are defined on the object's prototype instead of on an instance. | | | |
33
34
  | [`sort-lifecycle-methods`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/sort-lifecycle-methods.md) | Ensures that lifecycle methods are declared in order of execution | | | |
34
35
  <!-- prettier-ignore-end -->
package/dist/index.d.ts CHANGED
@@ -27,7 +27,7 @@ declare const _default: {
27
27
  "directive-selector": import("@typescript-eslint/utils/ts-eslint").RuleModule<import("./rules/directive-selector").MessageIds, import("./rules/directive-selector").Options, import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
28
28
  name: string;
29
29
  };
30
- "inject-at-top": import("@typescript-eslint/utils/ts-eslint").RuleModule<"injectAtTop", [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
30
+ "inject-at-top": import("@typescript-eslint/utils/ts-eslint").RuleModule<"injectAtTop", import("./rules/inject-at-top").Options, import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
31
31
  name: string;
32
32
  };
33
33
  "no-async-lifecycle-method": import("@typescript-eslint/utils/ts-eslint").RuleModule<"noAsyncLifecycleMethod", [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
@@ -108,7 +108,9 @@ declare const _default: {
108
108
  "prefer-service-decorator": import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferServiceDecorator", [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
109
109
  name: string;
110
110
  };
111
- "prefer-signal-model": import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferSignalModel", [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
111
+ "prefer-signal-model": import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferSignalModel", [{
112
+ useTypeChecking: boolean;
113
+ }], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
112
114
  name: string;
113
115
  };
114
116
  "prefer-signals": import("@typescript-eslint/utils/ts-eslint").RuleModule<import("./rules/prefer-signals").MessageIds, [{
@@ -123,6 +125,9 @@ declare const _default: {
123
125
  "prefer-standalone": import("@typescript-eslint/utils/ts-eslint").RuleModule<import("./rules/prefer-standalone").MessageIds, [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
124
126
  name: string;
125
127
  };
128
+ "reactive-context-must-read-signal": import("@typescript-eslint/utils/ts-eslint").RuleModule<"mustReadSignal", import("./rules/reactive-context-must-read-signal").Options, import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
129
+ name: string;
130
+ };
126
131
  "relative-url-prefix": import("@typescript-eslint/utils/ts-eslint").RuleModule<"relativeUrlPrefix", [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
127
132
  name: string;
128
133
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuJA,kBAsDE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0JA,kBAuDE"}
package/dist/index.js CHANGED
@@ -82,6 +82,7 @@ const no_developer_preview_1 = __importStar(require("./rules/no-developer-previe
82
82
  const no_implicit_take_until_destroyed_1 = __importStar(require("./rules/no-implicit-take-until-destroyed"));
83
83
  const computed_must_return_1 = __importStar(require("./rules/computed-must-return"));
84
84
  const inject_at_top_1 = __importStar(require("./rules/inject-at-top"));
85
+ const reactive_context_must_read_signal_1 = __importStar(require("./rules/reactive-context-must-read-signal"));
85
86
  module.exports = {
86
87
  rules: {
87
88
  [component_class_suffix_1.RULE_NAME]: component_class_suffix_1.default,
@@ -123,6 +124,7 @@ module.exports = {
123
124
  [prefer_signal_model_1.RULE_NAME]: prefer_signal_model_1.default,
124
125
  [prefer_signals_1.RULE_NAME]: prefer_signals_1.default,
125
126
  [prefer_standalone_1.RULE_NAME]: prefer_standalone_1.default,
127
+ [reactive_context_must_read_signal_1.RULE_NAME]: reactive_context_must_read_signal_1.default,
126
128
  [relative_url_prefix_1.RULE_NAME]: relative_url_prefix_1.default,
127
129
  [require_lifecycle_on_prototype_1.RULE_NAME]: require_lifecycle_on_prototype_1.default,
128
130
  [require_localize_metadata_1.RULE_NAME]: require_localize_metadata_1.default,
@@ -1,7 +1,11 @@
1
- export type Options = [];
1
+ export type Options = [
2
+ {
3
+ readonly additionalInjectFunctions?: readonly string[];
4
+ }
5
+ ];
2
6
  export type MessageIds = 'injectAtTop';
3
7
  export declare const RULE_NAME = "inject-at-top";
4
- declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"injectAtTop", [], import("../utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
8
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"injectAtTop", Options, import("../utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
5
9
  name: string;
6
10
  };
7
11
  export default _default;
@@ -1 +1 @@
1
- {"version":3,"file":"inject-at-top.d.ts","sourceRoot":"","sources":["../../src/rules/inject-at-top.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,OAAO,GAAG,EAAE,CAAC;AAEzB,MAAM,MAAM,UAAU,GAAG,aAAa,CAAC;AACvC,eAAO,MAAM,SAAS,kBAAkB,CAAC;;;;AAEzC,wBAyFG;AAEH,eAAO,MAAM,mBAAmB;;CAG/B,CAAC"}
1
+ {"version":3,"file":"inject-at-top.d.ts","sourceRoot":"","sources":["../../src/rules/inject-at-top.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,OAAO,GAAG;IACpB;QACE,QAAQ,CAAC,yBAAyB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;KACxD;CACF,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG,aAAa,CAAC;AACvC,eAAO,MAAM,SAAS,kBAAkB,CAAC;;;;AAIzC,wBAyHG;AAEH,eAAO,MAAM,mBAAmB;;CAG/B,CAAC"}
@@ -5,6 +5,7 @@ const utils_1 = require("@angular-eslint/utils");
5
5
  const utils_2 = require("@typescript-eslint/utils");
6
6
  const create_eslint_rule_1 = require("../utils/create-eslint-rule");
7
7
  exports.RULE_NAME = 'inject-at-top';
8
+ const DEFAULT_OPTIONS = { additionalInjectFunctions: [] };
8
9
  exports.default = (0, create_eslint_rule_1.createESLintRule)({
9
10
  name: exports.RULE_NAME,
10
11
  meta: {
@@ -12,13 +13,28 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
12
13
  docs: {
13
14
  description: 'Requires inject() calls to be declared at the top of the class, before any other member',
14
15
  },
15
- schema: [],
16
+ schema: [
17
+ {
18
+ type: 'object',
19
+ properties: {
20
+ additionalInjectFunctions: {
21
+ type: 'array',
22
+ items: {
23
+ type: 'string',
24
+ },
25
+ description: 'A list of additional functions which perform injection, such as your own `injectTranslations()` helper. Each entry is a regular expression which must match the whole function name, so plain names such as `injectTranslations` work as well as patterns such as `inject[A-Z].*`.',
26
+ uniqueItems: true,
27
+ },
28
+ },
29
+ additionalProperties: false,
30
+ },
31
+ ],
16
32
  messages: {
17
- injectAtTop: 'Move this inject() to the top of the class. Class fields are initialized in the order they are written, so anything declared above this line cannot safely use the service yet.',
33
+ injectAtTop: 'Move this {{functionName}}() to the top of the class. Class fields are initialized in the order they are written, so anything declared above this line cannot safely use the service yet.',
18
34
  },
19
- defaultOptions: [],
35
+ defaultOptions: [DEFAULT_OPTIONS],
20
36
  },
21
- create(context) {
37
+ create(context, [{ additionalInjectFunctions = [] }]) {
22
38
  const angularDecoratorsPattern = (0, utils_1.toPattern)([
23
39
  'Component',
24
40
  'Directive',
@@ -26,14 +42,18 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
26
42
  'Pipe',
27
43
  'Service',
28
44
  ]);
29
- function hasEagerInject(node) {
45
+ const injectFunctionsPattern = (0, utils_1.toPattern)([
46
+ 'inject',
47
+ ...additionalInjectFunctions,
48
+ ]);
49
+ function findEagerInjectName(node) {
30
50
  if (!node) {
31
- return false;
51
+ return null;
32
52
  }
33
53
  if (node.type === utils_2.AST_NODE_TYPES.CallExpression &&
34
54
  node.callee.type === utils_2.AST_NODE_TYPES.Identifier &&
35
- node.callee.name === 'inject') {
36
- return true;
55
+ injectFunctionsPattern.test(node.callee.name)) {
56
+ return node.callee.name;
37
57
  }
38
58
  const isFn = node.type === utils_2.AST_NODE_TYPES.ArrowFunctionExpression ||
39
59
  node.type === utils_2.AST_NODE_TYPES.FunctionExpression;
@@ -41,15 +61,20 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
41
61
  node.parent?.type === utils_2.AST_NODE_TYPES.CallExpression &&
42
62
  node.parent.callee === node;
43
63
  if (node.type === utils_2.AST_NODE_TYPES.ClassExpression || (isFn && !isIIFE)) {
44
- return false;
64
+ return null;
65
+ }
66
+ for (const child of Object.values(node).flat()) {
67
+ if (child &&
68
+ typeof child === 'object' &&
69
+ 'type' in child &&
70
+ child !== node.parent) {
71
+ const name = findEagerInjectName(child);
72
+ if (name) {
73
+ return name;
74
+ }
75
+ }
45
76
  }
46
- return Object.values(node)
47
- .flat()
48
- .some((child) => child &&
49
- typeof child === 'object' &&
50
- 'type' in child &&
51
- child !== node.parent &&
52
- hasEagerInject(child));
77
+ return null;
53
78
  }
54
79
  return {
55
80
  [`${utils_1.Selectors.decoratorDefinition(angularDecoratorsPattern)} > ClassBody`](node) {
@@ -58,12 +83,18 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
58
83
  if ('static' in member && member.static) {
59
84
  continue;
60
85
  }
61
- if (member.type === utils_2.AST_NODE_TYPES.PropertyDefinition &&
62
- hasEagerInject(member.value)) {
63
- if (seenNonInject) {
64
- context.report({ node: member, messageId: 'injectAtTop' });
86
+ if (member.type === utils_2.AST_NODE_TYPES.PropertyDefinition) {
87
+ const functionName = findEagerInjectName(member.value);
88
+ if (functionName) {
89
+ if (seenNonInject) {
90
+ context.report({
91
+ node: member,
92
+ messageId: 'injectAtTop',
93
+ data: { functionName },
94
+ });
95
+ }
96
+ continue;
65
97
  }
66
- continue;
67
98
  }
68
99
  seenNonInject = true;
69
100
  }
@@ -72,5 +103,5 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
72
103
  },
73
104
  });
74
105
  exports.RULE_DOCS_EXTENSION = {
75
- rationale: "Class fields are initialized in the order they are declared, so a field that calls inject() only creates its value when the initializer runs. Anything declared above that line which tries to read the injected service will see undefined, and the error message (usually something like 'Cannot read properties of undefined') points at the consumer, not at the field that was initialized out of order. TypeScript catches the obvious shape of this bug when a later-declared field is referenced directly from an earlier initializer, but it does not trace through a getter body or a method call, so the moment the read of this.someService happens behind that indirection the compiler lets it through and the failure only shows up at runtime. The scenario is deceptively easy to introduce: a getter that reads an injected service is defined below the inject() call, someone later adds a small convenience field above the getter that references it, and the class breaks at construction time even though nothing about the change looks suspicious. Keeping every inject() call at the very top of the class removes the ordering concern entirely, because every dependency is guaranteed to exist before any other field, getter, method, or constructor statement runs.",
106
+ rationale: "Class fields are initialized in the order they are declared, so a field that calls inject() only creates its value when the initializer runs. Anything declared above that line which tries to read the injected service will see undefined, and the error message (usually something like 'Cannot read properties of undefined') points at the consumer, not at the field that was initialized out of order. TypeScript catches the obvious shape of this bug when a later-declared field is referenced directly from an earlier initializer, but it does not trace through a getter body or a method call, so the moment the read of this.someService happens behind that indirection the compiler lets it through and the failure only shows up at runtime. The scenario is deceptively easy to introduce: a getter that reads an injected service is defined below the inject() call, someone later adds a small convenience field above the getter that references it, and the class breaks at construction time even though nothing about the change looks suspicious. Keeping every inject() call at the very top of the class removes the ordering concern entirely, because every dependency is guaranteed to exist before any other field, getter, method, or constructor statement runs. Codebases often wrap inject() in their own helpers, for example an injectTranslations() function that injects a translation service and derives a scoped accessor from it; such a helper has exactly the same ordering constraint, so it can be declared through the additionalInjectFunctions option to be treated like inject() itself.",
76
107
  };
@@ -1,6 +1,12 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ type Options = [
3
+ {
4
+ useTypeChecking: boolean;
5
+ }
6
+ ];
1
7
  export type MessageIds = 'preferSignalModel';
2
8
  export declare const RULE_NAME = "prefer-signal-model";
3
- declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferSignalModel", [], import("../utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
9
+ declare const _default: ESLintUtils.RuleModule<"preferSignalModel", Options, import("../utils/create-eslint-rule").RuleDocs, ESLintUtils.RuleListener> & {
4
10
  name: string;
5
11
  };
6
12
  export default _default;
@@ -1 +1 @@
1
- {"version":3,"file":"prefer-signal-model.d.ts","sourceRoot":"","sources":["../../src/rules/prefer-signal-model.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,UAAU,GAAG,mBAAmB,CAAC;AAC7C,eAAO,MAAM,SAAS,wBAAwB,CAAC;;;;AAE/C,wBA4FG;AAEH,eAAO,MAAM,mBAAmB;;CAG/B,CAAC"}
1
+ {"version":3,"file":"prefer-signal-model.d.ts","sourceRoot":"","sources":["../../src/rules/prefer-signal-model.ts"],"names":[],"mappings":"AASA,OAAO,EAAkB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAIvE,KAAK,OAAO,GAAG;IACb;QACE,eAAe,EAAE,OAAO,CAAC;KAC1B;CACF,CAAC;AAMF,MAAM,MAAM,UAAU,GAAG,mBAAmB,CAAC;AAC7C,eAAO,MAAM,SAAS,wBAAwB,CAAC;;;;AA8B/C,wBAuKG;AAEH,eAAO,MAAM,mBAAmB;;CAG/B,CAAC"}
@@ -1,9 +1,25 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.RULE_DOCS_EXTENSION = exports.RULE_NAME = void 0;
4
7
  const utils_1 = require("@angular-eslint/utils");
8
+ const utils_2 = require("@typescript-eslint/utils");
9
+ const typescript_1 = __importDefault(require("typescript"));
5
10
  const create_eslint_rule_1 = require("../utils/create-eslint-rule");
11
+ const DEFAULT_OPTIONS = {
12
+ useTypeChecking: false,
13
+ };
6
14
  exports.RULE_NAME = 'prefer-signal-model';
15
+ function hasTransformOption(options) {
16
+ return (options?.type === utils_2.AST_NODE_TYPES.ObjectExpression &&
17
+ options.properties.some((property) => property.type === utils_2.AST_NODE_TYPES.Property &&
18
+ ((property.key.type === utils_2.AST_NODE_TYPES.Identifier &&
19
+ property.key.name === 'transform') ||
20
+ (property.key.type === utils_2.AST_NODE_TYPES.Literal &&
21
+ property.key.value === 'transform'))));
22
+ }
7
23
  exports.default = (0, create_eslint_rule_1.createESLintRule)({
8
24
  name: exports.RULE_NAME,
9
25
  meta: {
@@ -12,66 +28,116 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
12
28
  description: 'Use `model` instead of `input` and `output` for two-way bindings',
13
29
  },
14
30
  fixable: 'code',
15
- schema: [],
31
+ schema: [
32
+ {
33
+ type: 'object',
34
+ properties: {
35
+ useTypeChecking: {
36
+ type: 'boolean',
37
+ default: DEFAULT_OPTIONS.useTypeChecking,
38
+ },
39
+ },
40
+ additionalProperties: false,
41
+ },
42
+ ],
16
43
  messages: {
17
44
  preferSignalModel: 'Use `model` for two-way bindings instead of `input()` and `output()`',
18
45
  },
19
- defaultOptions: [],
46
+ defaultOptions: [{ ...DEFAULT_OPTIONS }],
20
47
  },
21
- create(context) {
48
+ create(context, [{ useTypeChecking = DEFAULT_OPTIONS.useTypeChecking }]) {
49
+ const { sourceCode } = context;
22
50
  const inputs = new Map();
23
51
  const outputs = new Map();
24
- return {
25
- "PropertyDefinition > CallExpression[callee.name='input']"(node) {
26
- // Skip inputs with a transform option because model() does not support transform.
52
+ let services;
53
+ function getTypeServices() {
54
+ return (services ??= utils_2.ESLintUtils.getParserServices(context));
55
+ }
56
+ function createSignalCollector(signals, { hasInitialValueArgument }) {
57
+ return (node) => {
58
+ const options = node.arguments[hasInitialValueArgument ? 1 : 0];
59
+ // `model()` has no transform support.
27
60
  // https://github.com/angular/angular/issues/55166#issuecomment-2032150999
28
- const options = node.arguments[1];
29
- const hasTransformOption = options?.type === 'ObjectExpression' &&
30
- options.properties.some((prop) => prop.type === 'Property' &&
31
- ((prop.key.type === 'Identifier' &&
32
- prop.key.name === 'transform') ||
33
- (prop.key.type === 'Literal' &&
34
- prop.key.value === 'transform')));
35
- if (hasTransformOption) {
61
+ if (hasTransformOption(options)) {
36
62
  return;
37
63
  }
38
- const propertyDef = node.parent;
39
- const propertyName = utils_1.ASTUtils.getPropertyDefinitionName(propertyDef);
40
- inputs.set(propertyName, propertyDef);
41
- },
42
- "PropertyDefinition > CallExpression[callee.name='output']"(node) {
43
- const propertyDef = node.parent;
44
- const propertyName = utils_1.ASTUtils.getPropertyDefinitionName(propertyDef);
45
- outputs.set(propertyName, propertyDef);
46
- },
64
+ const property = node.parent;
65
+ signals.set(utils_1.ASTUtils.getPropertyDefinitionName(property), {
66
+ property,
67
+ callee: (node.callee.type === utils_2.AST_NODE_TYPES.MemberExpression
68
+ ? node.callee.object
69
+ : node.callee),
70
+ typeArgument: node.typeArguments?.params[0],
71
+ initialValue: hasInitialValueArgument ? node.arguments[0] : undefined,
72
+ });
73
+ };
74
+ }
75
+ function getValueType({ typeArgument, initialValue }) {
76
+ const typeServices = getTypeServices();
77
+ if (typeArgument) {
78
+ return typeServices.getTypeAtLocation(typeArgument);
79
+ }
80
+ if (initialValue) {
81
+ return typeServices.program
82
+ .getTypeChecker()
83
+ .getBaseTypeOfLiteralType(typeServices.getTypeAtLocation(initialValue));
84
+ }
85
+ return undefined;
86
+ }
87
+ function haveEquallyWrittenTypes(input, output) {
88
+ return (!input.typeArgument ||
89
+ !output.typeArgument ||
90
+ sourceCode.getText(input.typeArgument) ===
91
+ sourceCode.getText(output.typeArgument));
92
+ }
93
+ function haveMergeableTypes(input, output) {
94
+ if (!useTypeChecking) {
95
+ return haveEquallyWrittenTypes(input, output);
96
+ }
97
+ const inputType = getValueType(input);
98
+ const outputType = getValueType(output);
99
+ if (!inputType || !outputType) {
100
+ return true;
101
+ }
102
+ // A type argument that does not resolve becomes the error type, which is
103
+ // assignable in both directions, so only the written text is trustworthy.
104
+ if ((inputType.flags | outputType.flags) & typescript_1.default.TypeFlags.Any) {
105
+ return haveEquallyWrittenTypes(input, output);
106
+ }
107
+ // Mutual assignability stands in for the internal `isTypeIdenticalTo`;
108
+ // the same type written twice yields two distinct `ts.Type` objects.
109
+ const checker = getTypeServices().program.getTypeChecker();
110
+ return (checker.isTypeAssignableTo(inputType, outputType) &&
111
+ checker.isTypeAssignableTo(outputType, inputType));
112
+ }
113
+ return {
114
+ "PropertyDefinition > CallExpression[callee.name='input']": createSignalCollector(inputs, { hasInitialValueArgument: true }),
115
+ "PropertyDefinition > CallExpression[callee.object.name='input'][callee.property.name='required']": createSignalCollector(inputs, { hasInitialValueArgument: false }),
116
+ "PropertyDefinition > CallExpression[callee.name='output']": createSignalCollector(outputs, { hasInitialValueArgument: false }),
47
117
  'ClassDeclaration:exit'() {
48
- for (const [inputName, inputProperty] of inputs) {
49
- const outputName = `${inputName}Change`;
50
- const outputProperty = outputs.get(outputName);
51
- if (outputProperty) {
52
- // Report on the input property
53
- context.report({
54
- node: inputProperty,
55
- messageId: 'preferSignalModel',
56
- fix: (fixer) => {
57
- const sourceCode = context.sourceCode;
58
- const inputText = sourceCode.getText(inputProperty);
59
- const fixedInputText = inputText.replace(/\binput\b/, 'model');
60
- return [
61
- utils_1.RuleFixes.getImportAddFix({
62
- fixer,
63
- importName: 'model',
64
- moduleName: '@angular/core',
65
- node: inputProperty,
66
- }),
67
- fixer.replaceText(inputProperty, fixedInputText),
68
- fixer.remove(outputProperty),
69
- ].filter(utils_1.isNotNullOrUndefined);
70
- },
71
- });
72
- }
118
+ const twoWayBindings = [...inputs]
119
+ .map(([name, input]) => ({
120
+ input,
121
+ output: outputs.get(`${name}Change`),
122
+ }))
123
+ .filter((binding) => binding.output !== undefined &&
124
+ haveMergeableTypes(binding.input, binding.output));
125
+ for (const { input, output } of twoWayBindings) {
126
+ context.report({
127
+ node: input.property,
128
+ messageId: 'preferSignalModel',
129
+ fix: (fixer) => [
130
+ utils_1.RuleFixes.getImportAddFix({
131
+ fixer,
132
+ importName: 'model',
133
+ moduleName: '@angular/core',
134
+ node: input.property,
135
+ }),
136
+ fixer.replaceText(input.callee, 'model'),
137
+ fixer.remove(output.property),
138
+ ].filter(utils_1.isNotNullOrUndefined),
139
+ });
73
140
  }
74
- // Clear the maps for the next class
75
141
  inputs.clear();
76
142
  outputs.clear();
77
143
  },
@@ -79,5 +145,5 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
79
145
  },
80
146
  });
81
147
  exports.RULE_DOCS_EXTENSION = {
82
- rationale: "The model() function is Angular's modern API for two-way bindings, combining both input and output into a single signal. When you have an input property paired with an output property that follows the naming pattern of `propertyChange` (e.g., `enabled` input with `enabledChange` output), this is the traditional pattern for two-way binding. The model() function provides a cleaner, more concise way to express this pattern with better type safety and integration with Angular's signal ecosystem. It eliminates the boilerplate of managing separate input and output properties while maintaining the same two-way binding functionality.",
148
+ rationale: "The model() function is Angular's modern API for two-way bindings, combining both input and output into a single signal. When you have an input property paired with an output property that follows the naming pattern of `propertyChange` (e.g., `enabled` input with `enabledChange` output), this is the traditional pattern for two-way binding. The model() function provides a cleaner, more concise way to express this pattern with better type safety and integration with Angular's signal ecosystem. It eliminates the boilerplate of managing separate input and output properties while maintaining the same two-way binding functionality.\n\nBecause `model()` exposes a single type for both directions, only pairs whose `input` and `output` types match are reported. By default the written type arguments are compared as text, and a pair is assumed compatible when either side has no type argument. Enabling the `useTypeChecking` option compares the types semantically instead, and infers an `input`'s type from its initial value, so `input('')` paired with `output<number>()` is left alone. A type argument that does not resolve falls back to the text comparison.",
83
149
  };
@@ -0,0 +1,16 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ export type Options = [
3
+ {
4
+ readonly checkResources?: boolean;
5
+ }
6
+ ];
7
+ export type MessageIds = 'mustReadSignal';
8
+ export declare const RULE_NAME = "reactive-context-must-read-signal";
9
+ declare const _default: ESLintUtils.RuleModule<"mustReadSignal", Options, import("../utils/create-eslint-rule").RuleDocs, ESLintUtils.RuleListener> & {
10
+ name: string;
11
+ };
12
+ export default _default;
13
+ export declare const RULE_DOCS_EXTENSION: {
14
+ rationale: string;
15
+ };
16
+ //# sourceMappingURL=reactive-context-must-read-signal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reactive-context-must-read-signal.d.ts","sourceRoot":"","sources":["../../src/rules/reactive-context-must-read-signal.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,WAAW,EAGZ,MAAM,0BAA0B,CAAC;AAMlC,MAAM,MAAM,OAAO,GAAG;IACpB;QACE,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;KACnC;CACF,CAAC;AACF,MAAM,MAAM,UAAU,GAAG,gBAAgB,CAAC;AAC1C,eAAO,MAAM,SAAS,sCAAsC,CAAC;;;;AA8D7D,wBA2VG;AAEH,eAAO,MAAM,mBAAmB;;CAG/B,CAAC"}
@@ -0,0 +1,357 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.RULE_DOCS_EXTENSION = exports.RULE_NAME = void 0;
40
+ const utils_1 = require("@typescript-eslint/utils");
41
+ const tsutils = __importStar(require("ts-api-utils"));
42
+ const typescript_1 = __importDefault(require("typescript"));
43
+ const create_eslint_rule_1 = require("../utils/create-eslint-rule");
44
+ const signals_1 = require("../utils/signals");
45
+ exports.RULE_NAME = 'reactive-context-must-read-signal';
46
+ /**
47
+ * Reactive contexts that should depend on at least one signal. The tracked
48
+ * function is the one whose signal reads define the context's dependencies:
49
+ * - `computed`/`effect`: the first argument.
50
+ * - `linkedSignal`: the first argument (shorthand) or the `source`/`computation`
51
+ * functions of the options object.
52
+ * - `afterRenderEffect`: the first argument (shorthand) or the phase functions
53
+ * of the options object.
54
+ */
55
+ const DEFAULT_PRIMITIVES = {
56
+ afterRenderEffect: {
57
+ args: [0],
58
+ optionKeys: ['earlyRead', 'write', 'mixedReadWrite', 'read'],
59
+ },
60
+ computed: { args: [0], optionKeys: [] },
61
+ effect: { args: [0], optionKeys: [] },
62
+ linkedSignal: { args: [0], optionKeys: ['source', 'computation'] },
63
+ };
64
+ /**
65
+ * For resources, only the `params` (formerly `request`) function defines the
66
+ * reactive dependencies; reading a signal in the `loader`/`stream` does not make
67
+ * the resource reload. These are opt-in via the `checkResources` option.
68
+ */
69
+ const RESOURCE_PRIMITIVES = {
70
+ resource: { args: [0], optionKeys: ['params', 'request'] },
71
+ rxResource: { args: [0], optionKeys: ['params', 'request'] },
72
+ };
73
+ function isFunction(node) {
74
+ return (node?.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
75
+ node?.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression);
76
+ }
77
+ exports.default = (0, create_eslint_rule_1.createESLintRule)({
78
+ name: exports.RULE_NAME,
79
+ meta: {
80
+ type: 'problem',
81
+ docs: {
82
+ description: 'Ensures that reactive contexts such as computed(), linkedSignal() and effect() read at least one reactive value (a signal). A reactive context with no reactive dependencies never re-runs, which is almost always a mistake.',
83
+ },
84
+ schema: [
85
+ {
86
+ type: 'object',
87
+ properties: {
88
+ checkResources: {
89
+ type: 'boolean',
90
+ description: 'Also check that the `params` function of `resource()` and `rxResource()` reads a signal.',
91
+ },
92
+ },
93
+ additionalProperties: false,
94
+ },
95
+ ],
96
+ messages: {
97
+ mustReadSignal: '{{primitive}}() must read at least one signal, otherwise it will never re-run when state changes.',
98
+ },
99
+ },
100
+ defaultOptions: [{ checkResources: false }],
101
+ create(context, [{ checkResources = false }]) {
102
+ const services = utils_1.ESLintUtils.getParserServices(context);
103
+ const checker = services.program.getTypeChecker();
104
+ const primitives = checkResources
105
+ ? { ...DEFAULT_PRIMITIVES, ...RESOURCE_PRIMITIVES }
106
+ : DEFAULT_PRIMITIVES;
107
+ const analyses = new Map();
108
+ const trackedFnToCall = new Map();
109
+ const frameStack = [];
110
+ /**
111
+ * Returns true if calling `callee` reads a signal, i.e. `callee` has one of
112
+ * the known signal types. This mirrors the detection used by
113
+ * `no-uncalled-signals`.
114
+ */
115
+ function isSignalRead(callee) {
116
+ const type = services.getTypeAtLocation(callee);
117
+ const symbol = type.getSymbol();
118
+ if (symbol && signals_1.KNOWN_SIGNAL_TYPES.has(symbol.name)) {
119
+ return true;
120
+ }
121
+ // The `Signal` type is a type alias, so it is only reachable via the
122
+ // alias symbol. Other signal types (e.g. `InputSignal`) are interfaces
123
+ // and are found through `getSymbol()` above.
124
+ if (type.aliasSymbol && signals_1.KNOWN_SIGNAL_TYPES.has(type.aliasSymbol.name)) {
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ /**
130
+ * Returns true if the symbol is only declared in TypeScript's default
131
+ * library files. Those declarations (arrays, strings, `Math`, `JSON`,
132
+ * `Object`, `console`, DOM APIs, ...) cannot read an Angular signal.
133
+ */
134
+ function isDeclaredInDefaultLib(symbol) {
135
+ const declarations = symbol.getDeclarations();
136
+ return (!!declarations?.length &&
137
+ declarations.every((declaration) => services.program.isSourceFileDefaultLibrary(declaration.getSourceFile())));
138
+ }
139
+ /**
140
+ * Returns true if the node is a function-typed value that we cannot look
141
+ * inside, e.g. `this.transform` in `this.items.map(this.transform)`. The
142
+ * callee could invoke it, and it could read a signal.
143
+ */
144
+ function isOpaqueFunctionValue(node) {
145
+ if (isFunction(node)) {
146
+ // Inline functions are visited by this rule, so any signal they read is
147
+ // attributed to the enclosing reactive context.
148
+ return false;
149
+ }
150
+ if (node.type === utils_1.AST_NODE_TYPES.SpreadElement) {
151
+ // We cannot tell what a spread expands to, so assume the worst.
152
+ return true;
153
+ }
154
+ const type = services.getTypeAtLocation(node);
155
+ if (tsutils.isTypeFlagSet(type, typescript_1.default.TypeFlags.Any | typescript_1.default.TypeFlags.Unknown)) {
156
+ // We know nothing about the value, so it could well be a function
157
+ // that reads a signal.
158
+ return true;
159
+ }
160
+ return tsutils
161
+ .unionConstituents(type)
162
+ .some((constituent) => constituent.getCallSignatures().length > 0);
163
+ }
164
+ /**
165
+ * Returns true if the property with the given name is a getter on any
166
+ * constituent of the type. A getter runs arbitrary code, so it can read a
167
+ * signal even though reading the property does not look like a call.
168
+ */
169
+ function isGetter(type, name) {
170
+ return tsutils.unionConstituents(type).some((constituent) => {
171
+ const property = constituent.getProperty(name);
172
+ return (property !== undefined &&
173
+ tsutils.isSymbolFlagSet(property, typescript_1.default.SymbolFlags.GetAccessor));
174
+ });
175
+ }
176
+ /**
177
+ * Records what a call, `new` expression or tagged template tells us about
178
+ * the reactive context we are currently inside. It is only known not to
179
+ * read a signal when the callee resolves to a declaration in a TypeScript
180
+ * default library and no argument is an opaque function value.
181
+ */
182
+ function analyseCallLike(callee, args, analysis) {
183
+ let symbol = services.getSymbolAtLocation(callee);
184
+ if (symbol && tsutils.isSymbolFlagSet(symbol, typescript_1.default.SymbolFlags.Alias)) {
185
+ symbol = checker.getAliasedSymbol(symbol);
186
+ }
187
+ if (!symbol ||
188
+ !isDeclaredInDefaultLib(symbol) ||
189
+ args.some(isOpaqueFunctionValue)) {
190
+ analysis.hasUnknown = true;
191
+ }
192
+ }
193
+ /** The analysis of the reactive context we are currently inside, if any. */
194
+ function currentAnalysis() {
195
+ const top = frameStack[frameStack.length - 1];
196
+ return top && analyses.get(top.call);
197
+ }
198
+ function registerTrackedFunction(node, call, analysis) {
199
+ if (isFunction(node)) {
200
+ trackedFnToCall.set(node, call);
201
+ analysis.hasTrackedFn = true;
202
+ }
203
+ else {
204
+ // The tracked slot holds something we cannot look inside, such as a
205
+ // signal passed directly as `source`, or a reference to a function
206
+ // declared elsewhere. It may well be reactive, so stay silent.
207
+ analysis.hasUnknown = true;
208
+ }
209
+ }
210
+ function registerTrackedOptions(options, call, config, analysis) {
211
+ for (const property of options.properties) {
212
+ if (property.type === utils_1.AST_NODE_TYPES.SpreadElement) {
213
+ // The spread could provide any of the tracked options.
214
+ analysis.hasUnknown = true;
215
+ }
216
+ else if (!property.computed &&
217
+ property.key.type === utils_1.AST_NODE_TYPES.Identifier &&
218
+ config.optionKeys.includes(property.key.name)) {
219
+ registerTrackedFunction(property.value, call, analysis);
220
+ }
221
+ }
222
+ }
223
+ function registerTrackedFunctions(call, config, analysis) {
224
+ for (const index of config.args) {
225
+ const arg = call.arguments[index];
226
+ if (arg === undefined) {
227
+ continue;
228
+ }
229
+ if (config.optionKeys.length > 0 &&
230
+ arg.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
231
+ registerTrackedOptions(arg, call, config, analysis);
232
+ }
233
+ else {
234
+ registerTrackedFunction(arg, call, analysis);
235
+ }
236
+ }
237
+ }
238
+ function enterFunction(node) {
239
+ const call = trackedFnToCall.get(node);
240
+ if (call) {
241
+ frameStack.push({ fn: node, call });
242
+ }
243
+ }
244
+ function exitFunction(node) {
245
+ const top = frameStack[frameStack.length - 1];
246
+ if (top?.fn === node) {
247
+ frameStack.pop();
248
+ }
249
+ }
250
+ return {
251
+ ArrowFunctionExpression: enterFunction,
252
+ 'ArrowFunctionExpression:exit': exitFunction,
253
+ FunctionExpression: enterFunction,
254
+ 'FunctionExpression:exit': exitFunction,
255
+ MemberExpression(node) {
256
+ const analysis = currentAnalysis();
257
+ if (!analysis) {
258
+ return;
259
+ }
260
+ // A getter runs arbitrary code, so `this.name` can read a signal even
261
+ // though it does not look like a call.
262
+ const symbol = services.getSymbolAtLocation(node);
263
+ if (symbol &&
264
+ tsutils.isSymbolFlagSet(symbol, typescript_1.default.SymbolFlags.GetAccessor)) {
265
+ analysis.hasUnknown = true;
266
+ }
267
+ },
268
+ ObjectPattern(node) {
269
+ const analysis = currentAnalysis();
270
+ if (!analysis) {
271
+ return;
272
+ }
273
+ // Destructuring invokes getters just like a member expression does,
274
+ // e.g. `const { double } = this`.
275
+ const type = services.getTypeAtLocation(node);
276
+ for (const property of node.properties) {
277
+ if (property.type !== utils_1.AST_NODE_TYPES.Property ||
278
+ property.computed ||
279
+ property.key.type !== utils_1.AST_NODE_TYPES.Identifier) {
280
+ // A rest element copies every property and a computed or literal
281
+ // key can name any property, so assume a getter could be involved.
282
+ analysis.hasUnknown = true;
283
+ }
284
+ else if (isGetter(type, property.key.name)) {
285
+ analysis.hasUnknown = true;
286
+ }
287
+ }
288
+ },
289
+ NewExpression(node) {
290
+ const analysis = currentAnalysis();
291
+ if (analysis) {
292
+ analyseCallLike(node.callee, node.arguments, analysis);
293
+ }
294
+ },
295
+ TaggedTemplateExpression(node) {
296
+ const analysis = currentAnalysis();
297
+ if (analysis) {
298
+ analyseCallLike(node.tag, node.quasi.expressions, analysis);
299
+ }
300
+ },
301
+ CallExpression(node) {
302
+ // Attribute this call to the tracked function we are currently inside,
303
+ // if any. This runs before registration below, so a reactive context
304
+ // nested inside another tracked function is correctly treated as an
305
+ // (unknown) call of the outer function.
306
+ const analysis = currentAnalysis();
307
+ if (analysis) {
308
+ if (isSignalRead(node.callee)) {
309
+ analysis.hasReactiveRead = true;
310
+ }
311
+ else {
312
+ analyseCallLike(node.callee, node.arguments, analysis);
313
+ }
314
+ }
315
+ // Register this call if it is one of the reactive contexts.
316
+ if (node.callee.type !== utils_1.AST_NODE_TYPES.Identifier) {
317
+ return;
318
+ }
319
+ const config = primitives[node.callee.name];
320
+ if (!config) {
321
+ return;
322
+ }
323
+ const newAnalysis = {
324
+ call: node,
325
+ primitiveName: node.callee.name,
326
+ hasTrackedFn: false,
327
+ hasReactiveRead: false,
328
+ hasUnknown: false,
329
+ };
330
+ registerTrackedFunctions(node, config, newAnalysis);
331
+ analyses.set(node, newAnalysis);
332
+ },
333
+ 'CallExpression:exit'(node) {
334
+ const analysis = analyses.get(node);
335
+ if (!analysis) {
336
+ return;
337
+ }
338
+ analyses.delete(node);
339
+ // Only report when we are certain there is no reactivity: there is a
340
+ // tracked function, no signal was read, and there was nothing that
341
+ // might have read a signal on our behalf.
342
+ if (analysis.hasTrackedFn &&
343
+ !analysis.hasReactiveRead &&
344
+ !analysis.hasUnknown) {
345
+ context.report({
346
+ node: analysis.call,
347
+ messageId: 'mustReadSignal',
348
+ data: { primitive: analysis.primitiveName },
349
+ });
350
+ }
351
+ },
352
+ };
353
+ },
354
+ });
355
+ exports.RULE_DOCS_EXTENSION = {
356
+ rationale: 'Reactive contexts like `computed()`, `linkedSignal()`, `effect()` and `afterRenderEffect()` re-run whenever a signal they read changes. If the relevant function never reads a signal, the context runs once and can never react to anything, so it adds overhead without providing reactivity. This is usually a mistake: either the developer forgot to call a signal (e.g. wrote `firstName` instead of `firstName()`), or the value is actually static and should be a plain constant, a `signal()`, or an `afterNextRender()` in the case of `afterRenderEffect()`. To avoid false positives, the rule only reports when everything the tracked function does is known not to read a signal. Calls into the TypeScript standard library (arrays, strings, `Math`, `JSON`, `console`, DOM APIs, etc.) are known to be safe; anything else keeps the rule silent, including a helper function, a service method, a getter, an unresolved symbol, and any Angular API such as `untracked()`. One known limitation: a standard library call that reaches user code through a protocol method, such as `toJSON()` in `JSON.stringify()` or `toString()` in a template literal, is still treated as safe, so a signal read inside such a method is not detected. For `resource()`/`rxResource()` only the `params` function defines the dependencies, and these are opt-in via the `checkResources` option.',
357
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angular-eslint/eslint-plugin",
3
- "version": "22.2.1-alpha.9",
3
+ "version": "22.3.0",
4
4
  "description": "ESLint plugin for Angular applications, following https://angular.dev/style-guide",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -19,12 +19,12 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "dependencies": {
22
- "@angular-eslint/bundled-angular-compiler": "22.2.1-alpha.9",
23
- "@angular-eslint/utils": "22.2.1-alpha.9",
22
+ "@angular-eslint/bundled-angular-compiler": "22.3.0",
23
+ "@angular-eslint/utils": "22.3.0",
24
24
  "ts-api-utils": "^2.1.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@angular-eslint/test-utils": "22.2.1-alpha.9"
27
+ "@angular-eslint/test-utils": "22.3.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@typescript-eslint/utils": "^8.0.0",