@benjavicente/lint-angular 0.0.4 → 0.0.5

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 (3) hide show
  1. package/README.md +26 -24
  2. package/dist/index.mjs +668 -152
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -4,30 +4,32 @@ Opinated Oxlint/ESLint-compatible plugin for Angular project rules.
4
4
 
5
5
  ## Rules
6
6
 
7
-
8
- | Rule | Default | Fixable | Description |
9
- | ----------------------------------------------------------------------------------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------- |
10
- | [`rules-of-inject`](./src/rules/rules-of-inject/) | ✅ | | Restrict `inject()` usage to valid Angular injection contexts. |
11
- | [`avoid-explicit-injection-context`](./src/rules/avoid-explicit-injection-context/) | ✅ | | Avoid explicit injection-context APIs such as `inject(Injector)` and `runInInjectionContext`. |
12
- | [`avoid-explicit-subscription-management`](./src/rules/avoid-explicit-subscription-management/) | ✅ | | Avoid storing and manually managing RxJS subscriptions in Angular classes. |
13
- | [`avoid-inappropriate-intimacy`](./src/rules/avoid-inappropriate-intimacy/) | ✅ | | Avoid passing Angular component, directive, and service instances as function arguments. |
14
- | [`avoid-ng-modules`](./src/rules/avoid-ng-modules/) | ✅ | | Avoid NgModules in favor of standalone Angular APIs. |
15
- | [`avoid-rxjs-state-in-component`](./src/rules/avoid-rxjs-state-in-component/) | ✅ | | Avoid RxJS subjects for component and directive-local state. |
16
- | [`avoid-writing-signals-in-reactive-context`](./src/rules/avoid-writing-signals-in-reactive-context/) | ✅ | | Avoid writing to signals from reactive Angular contexts. |
17
- | [`class-member-order`](./src/rules/class-member-order/) | ✅ | | Enforce a consistent member order for Angular classes. |
18
- | [`class-matches-filename`](./src/rules/class-matches-filename/) | ✅ | | Require Angular class names to match component, directive, and service filenames. |
19
- | [`component-resource-filenames`](./src/rules/component-resource-filenames/) | ✅ | | Require component resource filenames to match the component TypeScript filename. |
20
- | [`decorator-filename-suffix`](./src/rules/decorator-filename-suffix/) | ✅ | | Require Angular decorators to be declared in files with matching filename suffixes. |
21
- | [`injects-tanstack-query-only-in-component-body`](./src/rules/injects-tanstack-query-only-in-component-body/) | ✅ | | Require TanStack Query inject helpers to be direct component/directive class fields. |
22
- | [`no-resource-api`](./src/rules/no-resource-api/) | ✅ | | Avoid Angular resource APIs for server state. |
23
- | [`no-route-resolvers`](./src/rules/no-route-resolvers/) | ✅ | | Avoid Angular route resolvers for data loading. |
24
- | [`no-ui-inheritance`](./src/rules/no-ui-inheritance/) | ✅ | | Avoid inheritance for Angular components and directives. |
25
- | [`prefer-private-elements`](./src/rules/prefer-private-elements/) | ✅ | ✅ | Prefer ECMAScript private elements over TypeScript `private` members. |
26
- | [`prefer-load-component-over-load-children`](./src/rules/prefer-load-component-over-load-children/) | ✅ | | Prefer `loadComponent` for lazy routes that load a standalone component. |
27
- | [`prefer-style-url`](./src/rules/prefer-style-url/) | ✅ | ✅ | Prefer `styleUrl` when a component has exactly one stylesheet. |
28
- | [`public-component-interface`](./src/rules/public-component-interface/) | ✅ | ✅ | Keep component and directive signal interfaces public while hiding injected dependencies. |
29
- | [`restrict-injectable-provided-in`](./src/rules/restrict-injectable-provided-in/) | ✅ | | Restrict `@Injectable` `providedIn` values to `root` or `platform`. |
30
-
7
+ | Rule | Default | Fixable | Description |
8
+ | ------------------------------------------------------------------------------------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------- |
9
+ | [`rules-of-inject`](./src/rules/rules-of-inject/) | | | Restrict `inject()` usage to valid Angular injection contexts. |
10
+ | [`avoid-explicit-injection-context`](./src/rules/avoid-explicit-injection-context/) | ✅ | | Avoid explicit injection-context APIs such as `inject(Injector)` and `runInInjectionContext`. |
11
+ | [`avoid-explicit-subscription-management`](./src/rules/avoid-explicit-subscription-management/) | ✅ | | Avoid storing and manually managing RxJS subscriptions in Angular classes. |
12
+ | [`avoid-inappropriate-intimacy`](./src/rules/avoid-inappropriate-intimacy/) | ✅ | | Avoid passing Angular component, directive, and service instances as function arguments. |
13
+ | [`avoid-ng-modules`](./src/rules/avoid-ng-modules/) | ✅ | | Avoid NgModules in favor of standalone Angular APIs. |
14
+ | [`avoid-rxjs-state-in-component`](./src/rules/avoid-rxjs-state-in-component/) | ✅ | | Avoid RxJS subjects for component and directive-local state. |
15
+ | [`avoid-writing-signals-in-reactive-context`](./src/rules/avoid-writing-signals-in-reactive-context/) | ✅ | | Avoid writing to signals from reactive Angular contexts. |
16
+ | [`class-member-order`](./src/rules/class-member-order/) | ✅ | | Enforce a consistent member order for Angular classes. |
17
+ | [`class-matches-filename`](./src/rules/class-matches-filename/) | ✅ | | Require Angular class names to match component, directive, and service filenames. |
18
+ | [`component-resource-filenames`](./src/rules/component-resource-filenames/) | ✅ | | Require component resource filenames to match the component TypeScript filename. |
19
+ | [`decorator-filename-suffix`](./src/rules/decorator-filename-suffix/) | ✅ | | Require Angular decorators to be declared in files with matching filename suffixes. |
20
+ | [`no-manual-change-detection`](./src/rules/no-manual-change-detection/) | ✅ | | Avoid manual Angular change detection through `ChangeDetectorRef`. |
21
+ | [`no-resource-api`](./src/rules/no-resource-api/) | ✅ | | Avoid Angular resource APIs for server state. |
22
+ | [`no-route-resolvers`](./src/rules/no-route-resolvers/) | ✅ | | Avoid Angular route resolvers for data loading. |
23
+ | [`no-ui-inheritance`](./src/rules/no-ui-inheritance/) | ✅ | | Avoid inheritance for Angular components and directives. |
24
+ | [`prefer-private-elements`](./src/rules/prefer-private-elements/) | ✅ || Prefer ECMAScript private elements over TypeScript `private` members. |
25
+ | [`prefer-load-component-over-load-children`](./src/rules/prefer-load-component-over-load-children/) | ✅ | | Prefer `loadComponent` for lazy routes that load a standalone component. |
26
+ | [`prefer-style-url`](./src/rules/prefer-style-url/) | ✅ || Prefer `styleUrl` when a component has exactly one stylesheet. |
27
+ | [`public-component-interface`](./src/rules/public-component-interface/) | ✅ | ✅ | Keep component and directive signal interfaces public while hiding injected dependencies. |
28
+ | [`restrict-injectable-provided-in`](./src/rules/restrict-injectable-provided-in/) | ✅ | | Restrict `@Injectable` `providedIn` values to `root` or `platform`. |
29
+ | [`tanstack-query-injects-only-in-component-body`](./src/rules/tanstack-query-injects-only-in-component-body/) | ✅ | | Require TanStack Query inject helpers to be direct component/directive class fields. |
30
+ | [`tanstack-query-inlined-keys`](./src/rules/tanstack-query-inlined-keys/) | ✅ | | Require TanStack Query query option keys to be inline arrays. |
31
+ | [`tanstack-query-prefer-query-options`](./src/rules/tanstack-query-prefer-query-options/) | ✅ | | Prefer `queryOptions()` to co-locate TanStack Query query keys and functions. |
32
+ | [`vitest-no-incompatible-angular-testing-apis`](./src/rules/vitest-no-incompatible-angular-testing-apis/) | ✅ | | Disallow Angular testing APIs that are incompatible with Vitest. |
31
33
 
32
34
  ## Oxlint setup
33
35
 
package/dist/index.mjs CHANGED
@@ -265,7 +265,7 @@ function classifyMember(context, element) {
265
265
  if (element.type === "MethodDefinition") return 3;
266
266
  return 3;
267
267
  }
268
- function getMemberName$1(node) {
268
+ function getMemberName$2(node) {
269
269
  if (!node) return null;
270
270
  if (node.type === "Identifier" || node.type === "PrivateIdentifier") return node.name;
271
271
  return null;
@@ -388,7 +388,7 @@ const classMemberOrder = defineRule({
388
388
  element,
389
389
  group,
390
390
  effectiveGroup: group,
391
- name: getMemberName$1(element.key),
391
+ name: getMemberName$2(element.key),
392
392
  dependencies: collectThisMemberReferences(element.value)
393
393
  });
394
394
  }
@@ -423,17 +423,17 @@ const classMemberOrder = defineRule({
423
423
  const DEFAULT_DISALLOW_INJECT_INJECTOR = true;
424
424
  const DEFAULT_DISALLOW_RUN_IN_INJECTION_CONTEXT = true;
425
425
  const RUN_IN_INJECTION_CONTEXT_NAMES = new Set(["runInInjectionContext", "runInContext"]);
426
- const INJECT_NAMES = new Set(["inject"]);
426
+ const INJECT_NAMES$2 = new Set(["inject"]);
427
427
  const INJECTOR_NAMES = new Set(["Injector"]);
428
- function isInjectCall(context, callNode) {
428
+ function isInjectCall$1(context, callNode) {
429
429
  const callee = callNode.callee;
430
- return isImportedReference(context, callee, "@angular/core", INJECT_NAMES) || isImportedNamespaceMember(context, callee, "@angular/core", INJECT_NAMES);
430
+ return isImportedReference(context, callee, "@angular/core", INJECT_NAMES$2) || isImportedNamespaceMember(context, callee, "@angular/core", INJECT_NAMES$2);
431
431
  }
432
432
  function isInjectorReference(context, node) {
433
433
  return isImportedReference(context, node, "@angular/core", INJECTOR_NAMES) || isImportedNamespaceMember(context, node, "@angular/core", INJECTOR_NAMES);
434
434
  }
435
435
  function isDisallowedInjectInjector(context, callNode) {
436
- if (!isInjectCall(context, callNode)) return false;
436
+ if (!isInjectCall$1(context, callNode)) return false;
437
437
  return isInjectorReference(context, callNode.arguments?.[0]);
438
438
  }
439
439
  function isDisallowedRunInInjectionContext(context, callNode) {
@@ -490,7 +490,7 @@ const TARGET_DECORATORS$4 = new Set([
490
490
  "Directive",
491
491
  "Injectable"
492
492
  ]);
493
- const FIELD_NODE_TYPES$2 = new Set([
493
+ const FIELD_NODE_TYPES$3 = new Set([
494
494
  "AccessorProperty",
495
495
  "FieldDefinition",
496
496
  "PropertyDefinition"
@@ -644,10 +644,10 @@ function isUnmanagedSubscribeCall(context, node, rxjsSubscribableReferences, rxj
644
644
  if (getPropertyName(callee.property) !== "subscribe") return false;
645
645
  return isKnownRxjsSubscribableExpression(context, callee.object, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces) && !isTakeUntilDestroyedSubscribe(node, takeUntilDestroyedLocalNames, interopNamespaces);
646
646
  }
647
- function walkNode$1(node, containingClass, visitor) {
647
+ function walkNode$2(node, containingClass, visitor) {
648
648
  if (!node) return;
649
649
  if (Array.isArray(node)) {
650
- for (const child of node) walkNode$1(child, containingClass, visitor);
650
+ for (const child of node) walkNode$2(child, containingClass, visitor);
651
651
  return;
652
652
  }
653
653
  if (node !== containingClass && (node.type === "ClassDeclaration" || node.type === "ClassExpression")) return;
@@ -655,12 +655,12 @@ function walkNode$1(node, containingClass, visitor) {
655
655
  for (const [key, value] of Object.entries(node)) {
656
656
  if (key === "parent") continue;
657
657
  if (!value || typeof value !== "object") continue;
658
- walkNode$1(value, containingClass, visitor);
658
+ walkNode$2(value, containingClass, visitor);
659
659
  }
660
660
  }
661
661
  function containsUnmanagedSubscribeCall(context, node, containingClass, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces) {
662
662
  let found = false;
663
- walkNode$1(node, containingClass, (current) => {
663
+ walkNode$2(node, containingClass, (current) => {
664
664
  if (isUnmanagedSubscribeCall(context, current, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) {
665
665
  found = true;
666
666
  return false;
@@ -673,8 +673,8 @@ function collectRxjsSubscribableReferences(context, classBody, classNode, rxjsSu
673
673
  let changed = true;
674
674
  while (changed) {
675
675
  changed = false;
676
- walkNode$1(classBody, classNode, (node) => {
677
- if (FIELD_NODE_TYPES$2.has(node.type)) {
676
+ walkNode$2(classBody, classNode, (node) => {
677
+ if (FIELD_NODE_TYPES$3.has(node.type)) {
678
678
  const name = getDeclaredName(node.key);
679
679
  if (!name || hasTrackedReferenceName(references, name)) return;
680
680
  if (isObservableReferenceName(name) || hasRxjsSubscribableTypeReference(context, node.typeAnnotation?.typeAnnotation, rxjsSubscribableLocalNames, rxjsNamespaces) || isRxjsSubscribableConstructor(context, node.value, rxjsSubscribableLocalNames, rxjsNamespaces) || isKnownRxjsSubscribableExpression(context, node.value, references, rxjsSubscribableLocalNames, rxjsNamespaces)) {
@@ -705,8 +705,8 @@ function collectRxjsSubscribableReferences(context, classBody, classNode, rxjsSu
705
705
  }
706
706
  function collectSubscriptionReferences(context, classBody, classNode, subscriptionLocalNames, rxjsNamespaces, rxjsSubscribableReferences, rxjsSubscribableLocalNames, takeUntilDestroyedLocalNames, interopNamespaces) {
707
707
  const references = /* @__PURE__ */ new Map();
708
- walkNode$1(classBody, classNode, (node) => {
709
- if (FIELD_NODE_TYPES$2.has(node.type)) {
708
+ walkNode$2(classBody, classNode, (node) => {
709
+ if (FIELD_NODE_TYPES$3.has(node.type)) {
710
710
  const name = getDeclaredName(node.key);
711
711
  if (!name) return;
712
712
  if (hasSubscriptionTypeReference(context, node.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces) || isSubscriptionConstructor(context, node.value, subscriptionLocalNames, rxjsNamespaces)) addTrackedReference(references, name, node.key);
@@ -796,8 +796,8 @@ const avoidExplicitSubscriptionManagement = defineRule({
796
796
  if (!classNode || !hasTargetDecorator$4(context, classNode)) return;
797
797
  const rxjsSubscribableReferences = collectRxjsSubscribableReferences(context, classBody, classNode, rxjsSubscribableLocalNames, rxjsNamespaces);
798
798
  const subscriptionReferences = collectSubscriptionReferences(context, classBody, classNode, subscriptionLocalNames, rxjsNamespaces, rxjsSubscribableReferences, rxjsSubscribableLocalNames, takeUntilDestroyedLocalNames, interopNamespaces);
799
- walkNode$1(classBody, classNode, (current) => {
800
- if (FIELD_NODE_TYPES$2.has(current.type)) {
799
+ walkNode$2(classBody, classNode, (current) => {
800
+ if (FIELD_NODE_TYPES$3.has(current.type)) {
801
801
  if (hasSubscriptionTypeReference(context, current.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces)) context.report({
802
802
  node: current.key ?? current,
803
803
  messageId: "explicitSubscriptionType"
@@ -1068,7 +1068,7 @@ const SUBJECT_NAMES = new Set([
1068
1068
  "ReplaySubject",
1069
1069
  "Subject"
1070
1070
  ]);
1071
- const FIELD_NODE_TYPES$1 = new Set([
1071
+ const FIELD_NODE_TYPES$2 = new Set([
1072
1072
  "AccessorProperty",
1073
1073
  "FieldDefinition",
1074
1074
  "PropertyDefinition"
@@ -1101,7 +1101,7 @@ function getSubjectKindFromConstructor(context, node) {
1101
1101
  const memberName = getPropertyName(callee.property);
1102
1102
  return SUBJECT_NAMES.has(memberName) ? memberName : null;
1103
1103
  }
1104
- function getMemberName(node) {
1104
+ function getMemberName$1(node) {
1105
1105
  const expression = unwrapExpression(node);
1106
1106
  if (!expression) return null;
1107
1107
  if (expression.type === "Identifier") return expression.name;
@@ -1131,17 +1131,17 @@ function isCallOnThisField(node, fields) {
1131
1131
  function isNgOnDestroyMethod(member) {
1132
1132
  return member.type === "MethodDefinition" && getPropertyName(member.key) === "ngOnDestroy" && !!member.value;
1133
1133
  }
1134
- function walkNode(node, visitor) {
1134
+ function walkNode$1(node, visitor) {
1135
1135
  if (!node) return;
1136
1136
  if (Array.isArray(node)) {
1137
- for (const child of node) walkNode(child, visitor);
1137
+ for (const child of node) walkNode$1(child, visitor);
1138
1138
  return;
1139
1139
  }
1140
1140
  visitor(node);
1141
1141
  for (const [key, value] of Object.entries(node)) {
1142
1142
  if (key === "parent") continue;
1143
1143
  if (!value || typeof value !== "object") continue;
1144
- walkNode(value, visitor);
1144
+ walkNode$1(value, visitor);
1145
1145
  }
1146
1146
  }
1147
1147
  function getUsage(usages, fieldName) {
@@ -1159,8 +1159,8 @@ function getUsage(usages, fieldName) {
1159
1159
  function collectSubjectFields(context, classBody) {
1160
1160
  const fields = /* @__PURE__ */ new Map();
1161
1161
  for (const member of classBody.body ?? []) {
1162
- if (!FIELD_NODE_TYPES$1.has(member.type)) continue;
1163
- const fieldName = getMemberName(member.key);
1162
+ if (!FIELD_NODE_TYPES$2.has(member.type)) continue;
1163
+ const fieldName = getMemberName$1(member.key);
1164
1164
  if (!fieldName) continue;
1165
1165
  const typeNode = member.typeAnnotation?.typeAnnotation;
1166
1166
  const kind = getSubjectKindFromConstructor(context, member.value) ?? getSubjectKindFromType(context, typeNode);
@@ -1176,7 +1176,7 @@ function collectFieldUsages(classBody, fields) {
1176
1176
  const usages = /* @__PURE__ */ new Map();
1177
1177
  const ngOnDestroyMethods = /* @__PURE__ */ new Set();
1178
1178
  for (const member of classBody.body ?? []) if (isNgOnDestroyMethod(member)) ngOnDestroyMethods.add(member.value);
1179
- walkNode(classBody, (node) => {
1179
+ walkNode$1(classBody, (node) => {
1180
1180
  const call = isCallOnThisField(node, fields);
1181
1181
  if (!call) return;
1182
1182
  const usage = getUsage(usages, call.fieldName);
@@ -1722,56 +1722,142 @@ const decoratorFilenameSuffix = defineRule({
1722
1722
  }
1723
1723
  });
1724
1724
  //#endregion
1725
- //#region src/rules/injects-tanstack-query-only-in-component-body/index.ts
1726
- const TARGET_DECORATORS$1 = new Set(["Component", "Directive"]);
1727
- const TANSTACK_QUERY_APIS = new Set(["injectQuery", "injectMutation"]);
1728
- const TANSTACK_QUERY_SOURCES = new Set(["@tanstack/angular-query", "@benjavicente/angular-query"]);
1729
- const CLASS_FIELD_TYPES$1 = new Set([
1725
+ //#region src/rules/no-manual-change-detection/index.ts
1726
+ const ANGULAR_CORE_SOURCE$1 = "@angular/core";
1727
+ const CHANGE_DETECTOR_REF_NAMES = new Set(["ChangeDetectorRef"]);
1728
+ const INJECT_NAMES$1 = new Set(["inject"]);
1729
+ const MANUAL_CHANGE_DETECTION_METHODS = new Set([
1730
+ "checkNoChanges",
1731
+ "detach",
1732
+ "detectChanges",
1733
+ "markForCheck",
1734
+ "reattach"
1735
+ ]);
1736
+ const FIELD_NODE_TYPES$1 = new Set([
1730
1737
  "AccessorProperty",
1731
1738
  "FieldDefinition",
1732
1739
  "PropertyDefinition"
1733
1740
  ]);
1734
- const FUNCTION_TYPES$1 = new Set([
1735
- "ArrowFunctionExpression",
1736
- "FunctionDeclaration",
1737
- "FunctionExpression"
1738
- ]);
1739
- function hasTargetDecorator$1(context, classNode) {
1740
- if (!classNode || !Array.isArray(classNode.decorators)) return false;
1741
- return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, TARGET_DECORATORS$1));
1741
+ function isChangeDetectorRefReference(context, node) {
1742
+ return isImportedReference(context, node, ANGULAR_CORE_SOURCE$1, CHANGE_DETECTOR_REF_NAMES) || isImportedNamespaceMember(context, node, ANGULAR_CORE_SOURCE$1, CHANGE_DETECTOR_REF_NAMES);
1742
1743
  }
1743
- function isTanstackQueryInjectCall(context, callNode) {
1744
- return [...TANSTACK_QUERY_SOURCES].some((source) => isImportedReference(context, callNode.callee, source, TANSTACK_QUERY_APIS));
1744
+ function isInjectCall(context, node) {
1745
+ const expression = unwrapExpression(node);
1746
+ if (expression?.type !== "CallExpression") return false;
1747
+ return isImportedReference(context, expression.callee, ANGULAR_CORE_SOURCE$1, INJECT_NAMES$1) && isChangeDetectorRefReference(context, expression.arguments?.[0]);
1745
1748
  }
1746
- function isDirectComponentOrDirectiveFieldInitializer(context, callNode) {
1747
- const ancestors = context.sourceCode.getAncestors(callNode);
1748
- const classField = ancestors.findLast((ancestor) => CLASS_FIELD_TYPES$1.has(ancestor.type));
1749
- if (!classField || unwrapExpression(classField.value) !== callNode) return false;
1750
- const classBodyIndex = ancestors.findLastIndex((ancestor) => ancestor.type === "ClassBody");
1751
- if (classBodyIndex === -1) return false;
1752
- const classBody = ancestors[classBodyIndex];
1753
- if (ancestors.slice(classBodyIndex + 1).some((ancestor) => FUNCTION_TYPES$1.has(ancestor.type))) return false;
1754
- return hasTargetDecorator$1(context, classBody?.parent);
1749
+ function isChangeDetectorRefType(context, node) {
1750
+ if (!node) return false;
1751
+ if (node.type === "TSTypeReference") {
1752
+ if (isChangeDetectorRefReference(context, node.typeName)) return true;
1753
+ if (node.typeName?.type === "TSQualifiedName" && node.typeName.left?.type === "Identifier") return isNamespaceImport(context, node.typeName.left, ANGULAR_CORE_SOURCE$1) && getPropertyName(node.typeName.right) === "ChangeDetectorRef";
1754
+ }
1755
+ return false;
1756
+ }
1757
+ function getMemberName(node) {
1758
+ const expression = unwrapExpression(node);
1759
+ if (!expression) return null;
1760
+ if (expression.type === "Identifier") return expression.name;
1761
+ if (expression.type === "PrivateIdentifier") return expression.name;
1762
+ return null;
1763
+ }
1764
+ function getParameterName(node) {
1765
+ const parameter = node?.type === "TSParameterProperty" ? node.parameter : node;
1766
+ if (parameter?.type === "Identifier") return parameter.name;
1767
+ if (parameter?.type === "AssignmentPattern" && parameter.left?.type === "Identifier") return parameter.left.name;
1768
+ return null;
1769
+ }
1770
+ function getParameterType(node) {
1771
+ const parameter = node?.type === "TSParameterProperty" ? node.parameter : node;
1772
+ if (parameter?.type === "AssignmentPattern") return parameter.left?.typeAnnotation?.typeAnnotation ?? null;
1773
+ return parameter?.typeAnnotation?.typeAnnotation ?? null;
1774
+ }
1775
+ function getParameterBinding(node) {
1776
+ const parameter = node?.type === "TSParameterProperty" ? node.parameter : node;
1777
+ if (parameter?.type === "Identifier") return parameter;
1778
+ if (parameter?.type === "AssignmentPattern" && parameter.left?.type === "Identifier") return parameter.left;
1779
+ return null;
1780
+ }
1781
+ function collectChangeDetectorRefBindings(context, classNode) {
1782
+ const fields = /* @__PURE__ */ new Set();
1783
+ const parameters = /* @__PURE__ */ new Set();
1784
+ for (const member of classNode.body?.body ?? []) {
1785
+ if (member.type === "MethodDefinition" && getPropertyName(member.key) === "constructor") for (const parameter of member.value?.params ?? []) {
1786
+ const name = getParameterName(parameter);
1787
+ if (name && isChangeDetectorRefType(context, getParameterType(parameter))) {
1788
+ const binding = getParameterBinding(parameter);
1789
+ if (binding) parameters.add(binding);
1790
+ if (parameter.type === "TSParameterProperty") fields.add(name);
1791
+ }
1792
+ }
1793
+ if (!FIELD_NODE_TYPES$1.has(member.type)) continue;
1794
+ const name = getMemberName(member.key);
1795
+ if (!name) continue;
1796
+ const typeNode = member.typeAnnotation?.typeAnnotation;
1797
+ if (isChangeDetectorRefType(context, typeNode) || isInjectCall(context, member.value)) fields.add(name);
1798
+ }
1799
+ return {
1800
+ fields,
1801
+ parameters
1802
+ };
1803
+ }
1804
+ function getManualChangeDetectionCall(context, callNode, bindings) {
1805
+ const callee = unwrapExpression(callNode.callee);
1806
+ if (callee?.type !== "MemberExpression") return null;
1807
+ const methodName = getPropertyName(callee.property);
1808
+ if (!methodName || !MANUAL_CHANGE_DETECTION_METHODS.has(methodName)) return null;
1809
+ const object = unwrapExpression(callee.object);
1810
+ if (object?.type === "Identifier") {
1811
+ const binding = findNearestBindingIdentifier(context, object);
1812
+ return binding && bindings.parameters.has(binding) ? {
1813
+ methodName,
1814
+ node: callee.property ?? callee
1815
+ } : null;
1816
+ }
1817
+ if (object?.type !== "MemberExpression") return null;
1818
+ if (object.object?.type !== "ThisExpression") return null;
1819
+ const fieldName = getPropertyName(object.property);
1820
+ return fieldName && bindings.fields.has(fieldName) ? {
1821
+ methodName,
1822
+ node: callee.property ?? callee
1823
+ } : null;
1755
1824
  }
1756
- const injectsTanstackQueryOnlyInComponentBody = defineRule({
1825
+ function walkNode(node, visitor) {
1826
+ if (!node) return;
1827
+ if (Array.isArray(node)) {
1828
+ for (const child of node) walkNode(child, visitor);
1829
+ return;
1830
+ }
1831
+ visitor(node);
1832
+ for (const [key, value] of Object.entries(node)) {
1833
+ if (key === "parent") continue;
1834
+ if (!value || typeof value !== "object") continue;
1835
+ walkNode(value, visitor);
1836
+ }
1837
+ }
1838
+ const noManualChangeDetection = defineRule({
1757
1839
  meta: {
1758
- type: "problem",
1840
+ type: "suggestion",
1759
1841
  docs: {
1760
- description: "Require Angular TanStack Query injectQuery/injectMutation calls to be direct component/directive class fields.",
1842
+ description: "Disallow manual Angular change detection through ChangeDetectorRef APIs.",
1761
1843
  recommended: true
1762
1844
  },
1763
1845
  schema: [],
1764
- messages: { onlyInComponentBody: "Call {{name}} only as a direct class field initializer in an Angular component or directive." }
1846
+ messages: { noManualChangeDetection: "Avoid manual change detection with ChangeDetectorRef. Prefer Angular's normal change detection triggers, signals, async bindings, or input updates." }
1765
1847
  },
1766
1848
  createOnce(context) {
1767
- return { CallExpression(node) {
1768
- const callNode = node;
1769
- if (!isTanstackQueryInjectCall(context, callNode)) return;
1770
- if (isDirectComponentOrDirectiveFieldInitializer(context, callNode)) return;
1771
- context.report({
1772
- node: callNode.callee ?? callNode,
1773
- messageId: "onlyInComponentBody",
1774
- data: { name: callNode.callee?.name ?? "this TanStack Query inject API" }
1849
+ return { ClassDeclaration(node) {
1850
+ const classNode = node;
1851
+ const changeDetectorRefBindings = collectChangeDetectorRefBindings(context, classNode);
1852
+ if (changeDetectorRefBindings.fields.size === 0 && changeDetectorRefBindings.parameters.size === 0) return;
1853
+ walkNode(classNode.body, (child) => {
1854
+ if (child.type !== "CallExpression") return;
1855
+ const manualCall = getManualChangeDetectionCall(context, child, changeDetectorRefBindings);
1856
+ if (!manualCall) return;
1857
+ context.report({
1858
+ node: manualCall.node,
1859
+ messageId: "noManualChangeDetection"
1860
+ });
1775
1861
  });
1776
1862
  } };
1777
1863
  }
@@ -2198,7 +2284,7 @@ const preferStyleUrl = defineRule({
2198
2284
  });
2199
2285
  //#endregion
2200
2286
  //#region src/rules/public-component-interface/index.ts
2201
- const TARGET_DECORATORS = new Set(["Component", "Directive"]);
2287
+ const TARGET_DECORATORS$1 = new Set(["Component", "Directive"]);
2202
2288
  const INPUT_MODEL_APIS = new Set(["input", "model"]);
2203
2289
  const OUTPUT_APIS = new Set(["output", "outputFromObservable"]);
2204
2290
  const INJECT_APIS = new Set(["inject"]);
@@ -2207,9 +2293,9 @@ const FIELD_NODE_TYPES = new Set([
2207
2293
  "FieldDefinition",
2208
2294
  "PropertyDefinition"
2209
2295
  ]);
2210
- function hasTargetDecorator(context, classNode) {
2296
+ function hasTargetDecorator$1(context, classNode) {
2211
2297
  if (!classNode || !Array.isArray(classNode.decorators)) return false;
2212
- return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, TARGET_DECORATORS));
2298
+ return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, TARGET_DECORATORS$1));
2213
2299
  }
2214
2300
  function isApiCallFromAngularCore(context, node, apiNames) {
2215
2301
  if (!node || node.type !== "CallExpression") return false;
@@ -2268,7 +2354,7 @@ const publicComponentInterface = defineRule({
2268
2354
  createOnce(context) {
2269
2355
  return { ClassBody(node) {
2270
2356
  const classNode = node.parent;
2271
- if (!hasTargetDecorator(context, classNode)) return;
2357
+ if (!hasTargetDecorator$1(context, classNode)) return;
2272
2358
  for (const member of node.body ?? []) {
2273
2359
  if (!FIELD_NODE_TYPES.has(member.type)) continue;
2274
2360
  const isInputModelMember = isApiCallFromAngularCore(context, member.value, INPUT_MODEL_APIS);
@@ -2416,18 +2502,18 @@ const INJECTION_CONTEXT_FUNCTION_TYPE_NAMES = new Set([
2416
2502
  "RedirectFunction",
2417
2503
  "ResolveFn"
2418
2504
  ]);
2419
- const FUNCTION_TYPES = new Set([
2505
+ const FUNCTION_TYPES$1 = new Set([
2420
2506
  "ArrowFunctionExpression",
2421
2507
  "FunctionDeclaration",
2422
2508
  "FunctionExpression"
2423
2509
  ]);
2424
- const CLASS_FIELD_TYPES = new Set([
2510
+ const CLASS_FIELD_TYPES$1 = new Set([
2425
2511
  "AccessorProperty",
2426
2512
  "FieldDefinition",
2427
2513
  "PropertyDefinition"
2428
2514
  ]);
2429
2515
  function isFunction(node) {
2430
- return !!node && FUNCTION_TYPES.has(node.type);
2516
+ return !!node && FUNCTION_TYPES$1.has(node.type);
2431
2517
  }
2432
2518
  function getAncestors(context, node) {
2433
2519
  return context.sourceCode.getAncestors(node);
@@ -2483,7 +2569,7 @@ function getFunctionContextTypeName(functionNode) {
2483
2569
  const parent = skipTransparentExpressionParents(functionNode.parent);
2484
2570
  if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") return getTypeName(parent.id.typeAnnotation?.typeAnnotation);
2485
2571
  if (parent?.type === "Property" && parent.value === functionNode) return getTypeName(parent.typeAnnotation?.typeAnnotation);
2486
- if (parent && CLASS_FIELD_TYPES.has(parent.type) && parent.value === functionNode) return getTypeName(parent.typeAnnotation?.typeAnnotation);
2572
+ if (parent && CLASS_FIELD_TYPES$1.has(parent.type) && parent.value === functionNode) return getTypeName(parent.typeAnnotation?.typeAnnotation);
2487
2573
  return null;
2488
2574
  }
2489
2575
  function isTypedInjectionContextFunction(functionNode) {
@@ -2517,7 +2603,7 @@ function isConstructorFunction(functionNode) {
2517
2603
  return functionNode.parent?.type === "MethodDefinition" && functionNode.parent.kind === "constructor" && functionNode.parent.value === functionNode;
2518
2604
  }
2519
2605
  function isDirectClassFieldInitializer(ancestors, nearestFunction) {
2520
- return !!ancestors.findLast((ancestor) => CLASS_FIELD_TYPES.has(ancestor.type)) && !nearestFunction;
2606
+ return !!ancestors.findLast((ancestor) => CLASS_FIELD_TYPES$1.has(ancestor.type)) && !nearestFunction;
2521
2607
  }
2522
2608
  function hasSupportedAngularClassDecorator(classNode) {
2523
2609
  if (!classNode) return false;
@@ -2561,6 +2647,494 @@ function isInjectLikeHelperCall(context, node, injectionContextApiLocalNames, in
2561
2647
  if (runsInInjectionContextFunctionNames.has(callee.name)) return true;
2562
2648
  return !injectionContextApiLocalNames.has(callee.name) && callee.name !== "inject" && (injectFunctionPrefixes.some((prefix) => callee.name.startsWith(prefix)) || injectFunctionSuffixes.some((suffix) => callee.name.endsWith(suffix)));
2563
2649
  }
2650
+ const rulesOfInject = defineRule({
2651
+ meta: {
2652
+ type: "problem",
2653
+ docs: {
2654
+ description: "Require Angular APIs that depend on injection context to appear only in known injection contexts.",
2655
+ recommended: true
2656
+ },
2657
+ schema: [{
2658
+ type: "object",
2659
+ additionalProperties: false,
2660
+ properties: {
2661
+ allowedFunctionNames: {
2662
+ type: "array",
2663
+ items: { type: "string" },
2664
+ default: DEFAULT_ALLOWED_FUNCTION_NAMES
2665
+ },
2666
+ checkUnimportedInject: {
2667
+ type: "boolean",
2668
+ default: false
2669
+ },
2670
+ injectFunctionPrefixes: {
2671
+ type: "array",
2672
+ items: { type: "string" },
2673
+ default: DEFAULT_INJECT_FUNCTION_PREFIXES
2674
+ },
2675
+ injectFunctionSuffixes: {
2676
+ type: "array",
2677
+ items: { type: "string" },
2678
+ default: DEFAULT_INJECT_FUNCTION_SUFFIXES
2679
+ },
2680
+ runsInInjectionContext: {
2681
+ type: "array",
2682
+ items: {
2683
+ type: "object",
2684
+ additionalProperties: false,
2685
+ required: ["from", "imports"],
2686
+ properties: {
2687
+ from: { type: "string" },
2688
+ imports: { anyOf: [{ const: "all" }, {
2689
+ type: "array",
2690
+ items: { type: "string" }
2691
+ }] }
2692
+ }
2693
+ },
2694
+ default: DEFAULT_RUNS_IN_INJECTION_CONTEXT
2695
+ }
2696
+ }
2697
+ }],
2698
+ messages: { disallowedInject: "Angular APIs that depend on injection context must be called from an injection context: a class field initializer or constructor in an Angular-decorated class, provider factory, InjectionToken factory, runInInjectionContext/runInContext callback, Angular route callback property (for example loadComponent/canActivate), an inject* or *Guard function, or configured allowed function." }
2699
+ },
2700
+ createOnce(context) {
2701
+ const injectionContextApiLocalNames = /* @__PURE__ */ new Set();
2702
+ const injectionContextApiNamespaceMembers = /* @__PURE__ */ new Map();
2703
+ const runsInInjectionContextFunctionNames = /* @__PURE__ */ new Set();
2704
+ let runsInInjectionContextRules = [];
2705
+ return {
2706
+ before() {
2707
+ injectionContextApiLocalNames.clear();
2708
+ injectionContextApiNamespaceMembers.clear();
2709
+ runsInInjectionContextFunctionNames.clear();
2710
+ runsInInjectionContextRules = (context.options[0] ?? {}).runsInInjectionContext ?? DEFAULT_RUNS_IN_INJECTION_CONTEXT;
2711
+ },
2712
+ ImportDeclaration(node) {
2713
+ const source = node.source?.value;
2714
+ const matchingRule = typeof source === "string" ? runsInInjectionContextRules.find((rule) => rule.from === source) : null;
2715
+ if (matchingRule) for (const specifier of node.specifiers ?? []) {
2716
+ if (specifier.type === "ImportSpecifier" && (matchingRule.imports === "all" || matchingRule.imports.includes(getPropertyName(specifier.imported) ?? ""))) runsInInjectionContextFunctionNames.add(specifier.local.name);
2717
+ if ((specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") && matchingRule.imports === "all") runsInInjectionContextFunctionNames.add(specifier.local.name);
2718
+ }
2719
+ const knownApiImports = typeof source === "string" ? getKnownInjectionContextApiImports(source) : null;
2720
+ if (knownApiImports) for (const specifier of node.specifiers ?? []) {
2721
+ if (specifier.type === "ImportSpecifier" && knownApiImports.has(getPropertyName(specifier.imported) ?? "")) injectionContextApiLocalNames.add(specifier.local.name);
2722
+ if (specifier.type === "ImportNamespaceSpecifier") injectionContextApiNamespaceMembers.set(specifier.local.name, knownApiImports);
2723
+ }
2724
+ },
2725
+ CallExpression(node) {
2726
+ const options = context.options[0] ?? {};
2727
+ const allowedFunctionNames = new Set(options.allowedFunctionNames ?? DEFAULT_ALLOWED_FUNCTION_NAMES);
2728
+ const checkUnimportedInject = options.checkUnimportedInject ?? false;
2729
+ const injectFunctionPrefixes = options.injectFunctionPrefixes ?? DEFAULT_INJECT_FUNCTION_PREFIXES;
2730
+ const injectFunctionSuffixes = options.injectFunctionSuffixes ?? DEFAULT_INJECT_FUNCTION_SUFFIXES;
2731
+ const inAllowedContext = isAllowedInjectionContext(context, node, allowedFunctionNames, injectFunctionPrefixes, injectFunctionSuffixes);
2732
+ if (isInjectLikeHelperCall(context, node, injectionContextApiLocalNames, injectFunctionPrefixes, injectFunctionSuffixes, runsInInjectionContextFunctionNames) && !inAllowedContext) {
2733
+ context.report({
2734
+ node: node.callee,
2735
+ messageId: "disallowedInject"
2736
+ });
2737
+ return;
2738
+ }
2739
+ if (!isKnownInjectionContextApiCall(context, node, injectionContextApiLocalNames, injectionContextApiNamespaceMembers, checkUnimportedInject)) return;
2740
+ if (inAllowedContext) return;
2741
+ context.report({
2742
+ node: node.callee,
2743
+ messageId: "disallowedInject"
2744
+ });
2745
+ }
2746
+ };
2747
+ }
2748
+ });
2749
+ //#endregion
2750
+ //#region src/utilities/tanstack-query.ts
2751
+ const DEFAULT_TANSTACK_QUERY_SOURCES = [
2752
+ "@tanstack/angular-query",
2753
+ "@benjavicente/angular-query",
2754
+ "@tanstack/angular-query-experimental"
2755
+ ];
2756
+ const QUERY_CORE_SOURCE = "@tanstack/query-core";
2757
+ const QUERY_OPTIONS_BUILDERS = new Set(["queryOptions", "infiniteQueryOptions"]);
2758
+ function getTanstackQuerySources(options) {
2759
+ return new Set(options.tanstackQuerySources ?? DEFAULT_TANSTACK_QUERY_SOURCES);
2760
+ }
2761
+ function getTanstackQueryImportName(context, node, sources) {
2762
+ const expression = unwrapExpression(node);
2763
+ if (expression?.type === "Identifier") {
2764
+ for (const source of sources) {
2765
+ const importedName = getImportedName(context, expression, source);
2766
+ if (importedName) return importedName;
2767
+ }
2768
+ return null;
2769
+ }
2770
+ if (expression?.type !== "MemberExpression" || expression.object?.type !== "Identifier") return null;
2771
+ for (const source of sources) if (isNamespaceImport(context, expression.object, source)) return getPropertyName(expression.property);
2772
+ return null;
2773
+ }
2774
+ function isTanstackQueryImportedReference(context, node, sources, names) {
2775
+ for (const source of sources) if (isImportedReference(context, node, source, names)) return true;
2776
+ return false;
2777
+ }
2778
+ //#endregion
2779
+ //#region src/rules/tanstack-query-injects-only-in-component-body/index.ts
2780
+ const TARGET_DECORATORS = new Set(["Component", "Directive"]);
2781
+ const TANSTACK_QUERY_APIS = new Set(["injectQuery", "injectMutation"]);
2782
+ const CLASS_FIELD_TYPES = new Set([
2783
+ "AccessorProperty",
2784
+ "FieldDefinition",
2785
+ "PropertyDefinition"
2786
+ ]);
2787
+ const FUNCTION_TYPES = new Set([
2788
+ "ArrowFunctionExpression",
2789
+ "FunctionDeclaration",
2790
+ "FunctionExpression"
2791
+ ]);
2792
+ function hasTargetDecorator(context, classNode) {
2793
+ if (!classNode || !Array.isArray(classNode.decorators)) return false;
2794
+ return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, TARGET_DECORATORS));
2795
+ }
2796
+ function isTanstackQueryInjectCall(context, callNode, tanstackQuerySources) {
2797
+ return isTanstackQueryImportedReference(context, callNode.callee, tanstackQuerySources, TANSTACK_QUERY_APIS);
2798
+ }
2799
+ function isDirectComponentOrDirectiveFieldInitializer(context, callNode) {
2800
+ const ancestors = context.sourceCode.getAncestors(callNode);
2801
+ const classField = ancestors.findLast((ancestor) => CLASS_FIELD_TYPES.has(ancestor.type));
2802
+ if (!classField || unwrapExpression(classField.value) !== callNode) return false;
2803
+ const classBodyIndex = ancestors.findLastIndex((ancestor) => ancestor.type === "ClassBody");
2804
+ if (classBodyIndex === -1) return false;
2805
+ const classBody = ancestors[classBodyIndex];
2806
+ if (ancestors.slice(classBodyIndex + 1).some((ancestor) => FUNCTION_TYPES.has(ancestor.type))) return false;
2807
+ return hasTargetDecorator(context, classBody?.parent);
2808
+ }
2809
+ const tanstackQueryInjectsOnlyInComponentBody = defineRule({
2810
+ meta: {
2811
+ type: "problem",
2812
+ docs: {
2813
+ description: "Require Angular TanStack Query injectQuery/injectMutation calls to be direct component/directive class fields.",
2814
+ recommended: true
2815
+ },
2816
+ schema: [{
2817
+ type: "object",
2818
+ additionalProperties: false,
2819
+ properties: { tanstackQuerySources: {
2820
+ type: "array",
2821
+ items: { type: "string" },
2822
+ default: DEFAULT_TANSTACK_QUERY_SOURCES
2823
+ } }
2824
+ }],
2825
+ messages: { onlyInComponentBody: "Call {{name}} only as a direct class field initializer in an Angular component or directive." }
2826
+ },
2827
+ createOnce(context) {
2828
+ let tanstackQuerySources = new Set(DEFAULT_TANSTACK_QUERY_SOURCES);
2829
+ return {
2830
+ before() {
2831
+ tanstackQuerySources = getTanstackQuerySources(context.options?.[0] ?? {});
2832
+ },
2833
+ CallExpression(node) {
2834
+ const callNode = node;
2835
+ if (!isTanstackQueryInjectCall(context, callNode, tanstackQuerySources)) return;
2836
+ if (isDirectComponentOrDirectiveFieldInitializer(context, callNode)) return;
2837
+ context.report({
2838
+ node: callNode.callee ?? callNode,
2839
+ messageId: "onlyInComponentBody",
2840
+ data: { name: callNode.callee?.name ?? "this TanStack Query inject API" }
2841
+ });
2842
+ }
2843
+ };
2844
+ }
2845
+ });
2846
+ //#endregion
2847
+ //#region src/rules/tanstack-query-inlined-keys/index.ts
2848
+ function getProperty$1(node, name) {
2849
+ for (const property of node.properties ?? []) if (property.type === "Property" && getPropertyName(property.key) === name) return property;
2850
+ return null;
2851
+ }
2852
+ function isInlineArrayExpression$1(node) {
2853
+ return unwrapExpression(node)?.type === "ArrayExpression";
2854
+ }
2855
+ const tanstackQueryInlinedKeys = defineRule({
2856
+ meta: {
2857
+ type: "problem",
2858
+ docs: {
2859
+ description: "Require TanStack Query queryOptions() keys to be inline arrays.",
2860
+ recommended: true
2861
+ },
2862
+ schema: [{
2863
+ type: "object",
2864
+ additionalProperties: false,
2865
+ properties: { tanstackQuerySources: {
2866
+ type: "array",
2867
+ items: { type: "string" },
2868
+ default: DEFAULT_TANSTACK_QUERY_SOURCES
2869
+ } }
2870
+ }],
2871
+ messages: { inlinedKeys: "Inline queryKey as an array in queryOptions(). Query keys are implementation details and should be read through query options." }
2872
+ },
2873
+ createOnce(context) {
2874
+ let tanstackQuerySources = new Set(DEFAULT_TANSTACK_QUERY_SOURCES);
2875
+ return {
2876
+ before() {
2877
+ tanstackQuerySources = getTanstackQuerySources(context.options?.[0] ?? {});
2878
+ },
2879
+ CallExpression(node) {
2880
+ const callNode = node;
2881
+ const importName = getTanstackQueryImportName(context, callNode.callee, tanstackQuerySources);
2882
+ if (!importName || !QUERY_OPTIONS_BUILDERS.has(importName)) return;
2883
+ const options = unwrapExpression(callNode.arguments?.[0]);
2884
+ if (options?.type !== "ObjectExpression") return;
2885
+ const queryKey = getProperty$1(options, "queryKey");
2886
+ if (!queryKey || isInlineArrayExpression$1(queryKey.value)) return;
2887
+ context.report({
2888
+ node: queryKey.value ?? queryKey,
2889
+ messageId: "inlinedKeys"
2890
+ });
2891
+ }
2892
+ };
2893
+ }
2894
+ });
2895
+ //#endregion
2896
+ //#region src/rules/tanstack-query-prefer-query-options/index.ts
2897
+ const ANGULAR_CORE_SOURCE = "@angular/core";
2898
+ const INJECT_NAMES = new Set(["inject"]);
2899
+ const QUERY_INJECT_APIS = new Set(["injectQuery", "injectInfiniteQuery"]);
2900
+ const QUERIES_INJECT_APIS = new Set(["injectQueries"]);
2901
+ const FILTER_INJECT_APIS = new Set(["injectIsFetching"]);
2902
+ const QUERY_CLIENT_OPTION_METHODS = new Set([
2903
+ "ensureInfiniteQueryData",
2904
+ "ensureQueryData",
2905
+ "fetchInfiniteQuery",
2906
+ "fetchQuery",
2907
+ "prefetchInfiniteQuery",
2908
+ "prefetchQuery"
2909
+ ]);
2910
+ const QUERY_CLIENT_QUERY_KEY_METHODS = new Set([
2911
+ "getQueryData",
2912
+ "getQueryDefaults",
2913
+ "getQueryState",
2914
+ "setQueryData",
2915
+ "setQueryDefaults"
2916
+ ]);
2917
+ const QUERY_CLIENT_FILTER_METHODS = new Set([
2918
+ "cancelQueries",
2919
+ "getQueriesData",
2920
+ "invalidateQueries",
2921
+ "isFetching",
2922
+ "refetchQueries",
2923
+ "removeQueries",
2924
+ "resetQueries",
2925
+ "setQueriesData"
2926
+ ]);
2927
+ const QUERY_CLIENT_NAMES = new Set(["QueryClient"]);
2928
+ const SKIP_TOKEN_NAMES = new Set(["skipToken"]);
2929
+ function getProperty(node, name) {
2930
+ for (const property of node.properties ?? []) if (property.type === "Property" && getPropertyName(property.key) === name) return property;
2931
+ return null;
2932
+ }
2933
+ function isObjectExpression(node) {
2934
+ return unwrapExpression(node)?.type === "ObjectExpression";
2935
+ }
2936
+ function isInlineArrayExpression(node) {
2937
+ return unwrapExpression(node)?.type === "ArrayExpression";
2938
+ }
2939
+ function isSkipToken(context, node, sources) {
2940
+ const expression = unwrapExpression(node);
2941
+ if (!expression) return false;
2942
+ if (expression.type === "ConditionalExpression") return isSkipToken(context, expression.consequent, sources) || isSkipToken(context, expression.alternate, sources);
2943
+ if (expression.type === "LogicalExpression") return isSkipToken(context, expression.left, sources) || isSkipToken(context, expression.right, sources);
2944
+ for (const source of [...sources, QUERY_CORE_SOURCE]) if (isImportedReference(context, expression, source, SKIP_TOKEN_NAMES)) return true;
2945
+ return false;
2946
+ }
2947
+ function hasObjectSpread(node) {
2948
+ return (node.properties ?? []).some((property) => property.type === "SpreadElement");
2949
+ }
2950
+ function hasInlineQueryOptions(context, node, sources) {
2951
+ if (getProperty(node, "queryKey")) return true;
2952
+ const queryFn = getProperty(node, "queryFn");
2953
+ if (!queryFn) return false;
2954
+ return !(hasObjectSpread(node) && isSkipToken(context, queryFn.value, sources));
2955
+ }
2956
+ function hasInlineFilterQueryKey(node) {
2957
+ const queryKey = getProperty(node, "queryKey")?.value;
2958
+ return isInlineArrayExpression(queryKey);
2959
+ }
2960
+ function getReturnedObjectExpressions(node) {
2961
+ const expression = unwrapExpression(node);
2962
+ if (!expression) return [];
2963
+ if (expression.type === "ObjectExpression") return [expression];
2964
+ if (expression.type === "ArrowFunctionExpression" || expression.type === "FunctionExpression") return getReturnedObjectExpressions(expression.body);
2965
+ if (expression.type === "BlockStatement") return (expression.body ?? []).flatMap((statement) => statement.type === "ReturnStatement" ? getReturnedObjectExpressions(statement.argument) : []);
2966
+ if (expression.type === "ConditionalExpression") return [...getReturnedObjectExpressions(expression.consequent), ...getReturnedObjectExpressions(expression.alternate)];
2967
+ if (expression.type === "LogicalExpression") return [...getReturnedObjectExpressions(expression.left), ...getReturnedObjectExpressions(expression.right)];
2968
+ if (expression.type === "SequenceExpression") return (expression.expressions ?? []).flatMap((child) => getReturnedObjectExpressions(child));
2969
+ return [];
2970
+ }
2971
+ function getQueryObjects(node) {
2972
+ const expression = unwrapExpression(node);
2973
+ if (!expression) return [];
2974
+ if (expression.type === "ArrayExpression") return (expression.elements ?? []).filter(isObjectExpression).map((element) => unwrapExpression(element));
2975
+ if (expression.type === "CallExpression" && expression.callee?.type === "MemberExpression" && getPropertyName(expression.callee.property) === "map") {
2976
+ const mapper = expression.arguments?.[0];
2977
+ if (mapper?.type === "ArrowFunctionExpression" || mapper?.type === "FunctionExpression") return getReturnedObjectExpressions(mapper);
2978
+ }
2979
+ return [];
2980
+ }
2981
+ function getBindingInitializer(binding) {
2982
+ const parent = binding?.parent;
2983
+ if (!parent) return null;
2984
+ if (parent.type === "VariableDeclarator" && parent.id === binding) return parent.init ?? null;
2985
+ if (parent.type === "AssignmentPattern" && parent.left === binding) return parent.right ?? null;
2986
+ return null;
2987
+ }
2988
+ function getThisFieldInitializer(context, node, fieldName) {
2989
+ const classBody = context.sourceCode.getAncestors(node).findLast((ancestor) => ancestor.type === "ClassBody");
2990
+ if (!classBody) return null;
2991
+ for (const member of classBody.body ?? []) if (getPropertyName(member.key) === fieldName) return member.value ?? null;
2992
+ return null;
2993
+ }
2994
+ function isQueryClientReference(context, node, sources) {
2995
+ const expression = unwrapExpression(node);
2996
+ if (!expression) return false;
2997
+ return isTanstackQueryImportedReference(context, expression, sources, QUERY_CLIENT_NAMES) || isImportedReference(context, expression, "@tanstack/query-core", QUERY_CLIENT_NAMES);
2998
+ }
2999
+ function isInjectQueryClientCall(context, node, sources) {
3000
+ const expression = unwrapExpression(node);
3001
+ return expression?.type === "CallExpression" && isImportedReference(context, expression.callee, ANGULAR_CORE_SOURCE, INJECT_NAMES) && isQueryClientReference(context, expression.arguments?.[0], sources);
3002
+ }
3003
+ function isQueryClientSource(context, node, sources) {
3004
+ const expression = unwrapExpression(node);
3005
+ if (!expression) return false;
3006
+ if (expression.type === "NewExpression") return isQueryClientReference(context, expression.callee, sources);
3007
+ return isInjectQueryClientCall(context, expression, sources);
3008
+ }
3009
+ function resolveQueryClientSource(context, node, sources) {
3010
+ let current = unwrapExpression(node);
3011
+ const visited = /* @__PURE__ */ new Set();
3012
+ while (current && !visited.has(current)) {
3013
+ visited.add(current);
3014
+ if (isQueryClientSource(context, current, sources)) return current;
3015
+ if (current.type === "Identifier") {
3016
+ const initializer = getBindingInitializer(findNearestBindingIdentifier(context, current));
3017
+ if (!initializer) return current;
3018
+ current = unwrapExpression(initializer);
3019
+ continue;
3020
+ }
3021
+ if (current.type === "MemberExpression" && current.object?.type === "ThisExpression") {
3022
+ const initializer = getThisFieldInitializer(context, current, getPropertyName(current.property) ?? "");
3023
+ if (!initializer) return current;
3024
+ current = unwrapExpression(initializer);
3025
+ continue;
3026
+ }
3027
+ return current;
3028
+ }
3029
+ return current ?? null;
3030
+ }
3031
+ function isTanstackQueryClient(context, node, sources) {
3032
+ return isQueryClientSource(context, resolveQueryClientSource(context, node, sources), sources);
3033
+ }
3034
+ function reportInlineQueryOptions(context, node, sources) {
3035
+ const expression = unwrapExpression(node);
3036
+ if (!expression || expression.type !== "ObjectExpression") return;
3037
+ if (!hasInlineQueryOptions(context, expression, sources)) return;
3038
+ context.report({
3039
+ node: expression,
3040
+ messageId: "preferQueryOptions"
3041
+ });
3042
+ }
3043
+ function reportInlineFilterQueryKey(context, node) {
3044
+ const expression = unwrapExpression(node);
3045
+ if (!expression || expression.type !== "ObjectExpression") return;
3046
+ if (!hasInlineFilterQueryKey(expression)) return;
3047
+ context.report({
3048
+ node: expression,
3049
+ messageId: "preferQueryOptionsQueryKey"
3050
+ });
3051
+ }
3052
+ const tanstackQueryPreferQueryOptions = defineRule({
3053
+ meta: {
3054
+ type: "problem",
3055
+ docs: {
3056
+ description: "Prefer queryOptions() to co-locate TanStack Query queryKey and queryFn.",
3057
+ recommended: true
3058
+ },
3059
+ schema: [{
3060
+ type: "object",
3061
+ additionalProperties: false,
3062
+ properties: { tanstackQuerySources: {
3063
+ type: "array",
3064
+ items: { type: "string" },
3065
+ default: DEFAULT_TANSTACK_QUERY_SOURCES
3066
+ } }
3067
+ }],
3068
+ messages: {
3069
+ preferQueryOptions: "Prefer using queryOptions() or infiniteQueryOptions() to co-locate queryKey and queryFn.",
3070
+ preferQueryOptionsQueryKey: "Prefer referencing a queryKey from a queryOptions() result instead of typing it manually."
3071
+ }
3072
+ },
3073
+ createOnce(context) {
3074
+ let tanstackQuerySources = new Set(DEFAULT_TANSTACK_QUERY_SOURCES);
3075
+ return {
3076
+ before() {
3077
+ tanstackQuerySources = getTanstackQuerySources(context.options?.[0] ?? {});
3078
+ },
3079
+ CallExpression(node) {
3080
+ const callNode = node;
3081
+ const importName = getTanstackQueryImportName(context, callNode.callee, tanstackQuerySources);
3082
+ if (importName && QUERY_OPTIONS_BUILDERS.has(importName)) return;
3083
+ if (importName && QUERY_INJECT_APIS.has(importName)) {
3084
+ for (const objectExpression of getReturnedObjectExpressions(callNode.arguments?.[0])) reportInlineQueryOptions(context, objectExpression, tanstackQuerySources);
3085
+ return;
3086
+ }
3087
+ if (importName && QUERIES_INJECT_APIS.has(importName)) {
3088
+ for (const objectExpression of getReturnedObjectExpressions(callNode.arguments?.[0])) {
3089
+ const queries = getProperty(objectExpression, "queries")?.value;
3090
+ for (const query of getQueryObjects(queries)) reportInlineQueryOptions(context, query, tanstackQuerySources);
3091
+ }
3092
+ return;
3093
+ }
3094
+ if (importName && FILTER_INJECT_APIS.has(importName)) {
3095
+ reportInlineFilterQueryKey(context, callNode.arguments?.[0]);
3096
+ return;
3097
+ }
3098
+ const callee = unwrapExpression(callNode.callee);
3099
+ if (callee?.type !== "MemberExpression" || !isTanstackQueryClient(context, callee.object, tanstackQuerySources)) return;
3100
+ const method = getPropertyName(callee.property);
3101
+ const options = callNode.arguments?.[0];
3102
+ if (QUERY_CLIENT_OPTION_METHODS.has(method ?? "")) {
3103
+ reportInlineQueryOptions(context, options, tanstackQuerySources);
3104
+ return;
3105
+ }
3106
+ if (QUERY_CLIENT_QUERY_KEY_METHODS.has(method ?? "") && isInlineArrayExpression(options)) {
3107
+ context.report({
3108
+ node: unwrapExpression(options) ?? options,
3109
+ messageId: "preferQueryOptionsQueryKey"
3110
+ });
3111
+ return;
3112
+ }
3113
+ if (QUERY_CLIENT_FILTER_METHODS.has(method ?? "")) reportInlineFilterQueryKey(context, options);
3114
+ }
3115
+ };
3116
+ }
3117
+ });
3118
+ //#endregion
3119
+ //#region src/rules/vitest-no-incompatible-angular-testing-apis/index.ts
3120
+ const ANGULAR_TESTING_SOURCE = "@angular/core/testing";
3121
+ const VITEST_INCOMPATIBLE_APIS = new Set([
3122
+ "discardPeriodicTasks",
3123
+ "fakeAsync",
3124
+ "flush",
3125
+ "flushMicrotasks",
3126
+ "resetFakeAsyncZone",
3127
+ "tick",
3128
+ "waitForAsync"
3129
+ ]);
3130
+ function getIncompatibleImportName(specifier) {
3131
+ if (specifier.type !== "ImportSpecifier") return null;
3132
+ const importedName = getPropertyName(specifier.imported);
3133
+ return importedName && VITEST_INCOMPATIBLE_APIS.has(importedName) ? importedName : null;
3134
+ }
3135
+ function isIncompatibleNamespaceCall(context, callNode) {
3136
+ return isImportedNamespaceMember(context, callNode.callee, ANGULAR_TESTING_SOURCE, VITEST_INCOMPATIBLE_APIS);
3137
+ }
2564
3138
  //#endregion
2565
3139
  //#region src/index.ts
2566
3140
  const plugin = eslintCompatPlugin({
@@ -2576,7 +3150,7 @@ const plugin = eslintCompatPlugin({
2576
3150
  "class-matches-filename": classMatchesFilename,
2577
3151
  "component-resource-filenames": componentResourceFilenames,
2578
3152
  "decorator-filename-suffix": decoratorFilenameSuffix,
2579
- "injects-tanstack-query-only-in-component-body": injectsTanstackQueryOnlyInComponentBody,
3153
+ "no-manual-change-detection": noManualChangeDetection,
2580
3154
  "no-resource-api": noResourceApi,
2581
3155
  "no-route-resolvers": noRouteResolvers,
2582
3156
  "no-ui-inheritance": noUiInheritance,
@@ -2585,100 +3159,42 @@ const plugin = eslintCompatPlugin({
2585
3159
  "prefer-style-url": preferStyleUrl,
2586
3160
  "public-component-interface": publicComponentInterface,
2587
3161
  "restrict-injectable-provided-in": restrictInjectableProvidedIn,
2588
- "rules-of-inject": defineRule({
3162
+ "rules-of-inject": rulesOfInject,
3163
+ "tanstack-query-injects-only-in-component-body": tanstackQueryInjectsOnlyInComponentBody,
3164
+ "tanstack-query-inlined-keys": tanstackQueryInlinedKeys,
3165
+ "tanstack-query-prefer-query-options": tanstackQueryPreferQueryOptions,
3166
+ "vitest-no-incompatible-angular-testing-apis": defineRule({
2589
3167
  meta: {
2590
3168
  type: "problem",
2591
3169
  docs: {
2592
- description: "Require Angular APIs that depend on injection context to appear only in known injection contexts.",
3170
+ description: "Disallow Angular testing APIs that depend on Zone.js and are incompatible with Vitest.",
2593
3171
  recommended: true
2594
3172
  },
2595
- schema: [{
2596
- type: "object",
2597
- additionalProperties: false,
2598
- properties: {
2599
- allowedFunctionNames: {
2600
- type: "array",
2601
- items: { type: "string" },
2602
- default: DEFAULT_ALLOWED_FUNCTION_NAMES
2603
- },
2604
- checkUnimportedInject: {
2605
- type: "boolean",
2606
- default: false
2607
- },
2608
- injectFunctionPrefixes: {
2609
- type: "array",
2610
- items: { type: "string" },
2611
- default: DEFAULT_INJECT_FUNCTION_PREFIXES
2612
- },
2613
- injectFunctionSuffixes: {
2614
- type: "array",
2615
- items: { type: "string" },
2616
- default: DEFAULT_INJECT_FUNCTION_SUFFIXES
2617
- },
2618
- runsInInjectionContext: {
2619
- type: "array",
2620
- items: {
2621
- type: "object",
2622
- additionalProperties: false,
2623
- required: ["from", "imports"],
2624
- properties: {
2625
- from: { type: "string" },
2626
- imports: { anyOf: [{ const: "all" }, {
2627
- type: "array",
2628
- items: { type: "string" }
2629
- }] }
2630
- }
2631
- },
2632
- default: DEFAULT_RUNS_IN_INJECTION_CONTEXT
2633
- }
2634
- }
2635
- }],
2636
- messages: { disallowedInject: "Angular APIs that depend on injection context must be called from an injection context: a class field initializer or constructor in an Angular-decorated class, provider factory, InjectionToken factory, runInInjectionContext/runInContext callback, Angular route callback property (for example loadComponent/canActivate), an inject* or *Guard function, or configured allowed function." }
3173
+ schema: [],
3174
+ messages: { vitestNoIncompatibleAngularTestingApi: "Avoid Angular testing API '{{name}}'. It depends on Zone.js and is not compatible with Angular tests running on Vitest." }
2637
3175
  },
2638
3176
  createOnce(context) {
2639
- const injectionContextApiLocalNames = /* @__PURE__ */ new Set();
2640
- const injectionContextApiNamespaceMembers = /* @__PURE__ */ new Map();
2641
- const runsInInjectionContextFunctionNames = /* @__PURE__ */ new Set();
2642
- let runsInInjectionContextRules = [];
2643
3177
  return {
2644
- before() {
2645
- injectionContextApiLocalNames.clear();
2646
- injectionContextApiNamespaceMembers.clear();
2647
- runsInInjectionContextFunctionNames.clear();
2648
- runsInInjectionContextRules = (context.options[0] ?? {}).runsInInjectionContext ?? DEFAULT_RUNS_IN_INJECTION_CONTEXT;
2649
- },
2650
3178
  ImportDeclaration(node) {
2651
- const source = node.source?.value;
2652
- const matchingRule = typeof source === "string" ? runsInInjectionContextRules.find((rule) => rule.from === source) : null;
2653
- if (matchingRule) for (const specifier of node.specifiers ?? []) {
2654
- if (specifier.type === "ImportSpecifier" && (matchingRule.imports === "all" || matchingRule.imports.includes(getPropertyName(specifier.imported) ?? ""))) runsInInjectionContextFunctionNames.add(specifier.local.name);
2655
- if ((specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") && matchingRule.imports === "all") runsInInjectionContextFunctionNames.add(specifier.local.name);
2656
- }
2657
- const knownApiImports = typeof source === "string" ? getKnownInjectionContextApiImports(source) : null;
2658
- if (knownApiImports) for (const specifier of node.specifiers ?? []) {
2659
- if (specifier.type === "ImportSpecifier" && knownApiImports.has(getPropertyName(specifier.imported) ?? "")) injectionContextApiLocalNames.add(specifier.local.name);
2660
- if (specifier.type === "ImportNamespaceSpecifier") injectionContextApiNamespaceMembers.set(specifier.local.name, knownApiImports);
2661
- }
2662
- },
2663
- CallExpression(node) {
2664
- const options = context.options[0] ?? {};
2665
- const allowedFunctionNames = new Set(options.allowedFunctionNames ?? DEFAULT_ALLOWED_FUNCTION_NAMES);
2666
- const checkUnimportedInject = options.checkUnimportedInject ?? false;
2667
- const injectFunctionPrefixes = options.injectFunctionPrefixes ?? DEFAULT_INJECT_FUNCTION_PREFIXES;
2668
- const injectFunctionSuffixes = options.injectFunctionSuffixes ?? DEFAULT_INJECT_FUNCTION_SUFFIXES;
2669
- const inAllowedContext = isAllowedInjectionContext(context, node, allowedFunctionNames, injectFunctionPrefixes, injectFunctionSuffixes);
2670
- if (isInjectLikeHelperCall(context, node, injectionContextApiLocalNames, injectFunctionPrefixes, injectFunctionSuffixes, runsInInjectionContextFunctionNames) && !inAllowedContext) {
3179
+ const importNode = node;
3180
+ if (importNode.source?.value !== ANGULAR_TESTING_SOURCE) return;
3181
+ for (const specifier of importNode.specifiers ?? []) {
3182
+ const name = getIncompatibleImportName(specifier);
3183
+ if (!name) continue;
2671
3184
  context.report({
2672
- node: node.callee,
2673
- messageId: "disallowedInject"
3185
+ node: specifier,
3186
+ messageId: "vitestNoIncompatibleAngularTestingApi",
3187
+ data: { name }
2674
3188
  });
2675
- return;
2676
3189
  }
2677
- if (!isKnownInjectionContextApiCall(context, node, injectionContextApiLocalNames, injectionContextApiNamespaceMembers, checkUnimportedInject)) return;
2678
- if (inAllowedContext) return;
3190
+ },
3191
+ CallExpression(node) {
3192
+ const callNode = node;
3193
+ if (!isIncompatibleNamespaceCall(context, callNode)) return;
2679
3194
  context.report({
2680
- node: node.callee,
2681
- messageId: "disallowedInject"
3195
+ node: callNode.callee,
3196
+ messageId: "vitestNoIncompatibleAngularTestingApi",
3197
+ data: { name: getPropertyName(callNode.callee?.property) ?? "this API" }
2682
3198
  });
2683
3199
  }
2684
3200
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benjavicente/lint-angular",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Oxlint/ESLint-compatible rules for Angular.",
5
5
  "keywords": [
6
6
  "angular",
@@ -30,7 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@types/estree": "^1.0.9",
32
32
  "typescript": "^6.0.3",
33
- "vite-plus": "^0.1.20",
33
+ "vite-plus": "^0.1.22",
34
34
  "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20",
35
35
  "oxlint-vitest-rule-tester": "0.0.1"
36
36
  },