@taiga-ui/eslint-plugin-experience-next 0.473.0 → 0.474.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
@@ -54,7 +54,7 @@ export default [
54
54
  | no-playwright-empty-fill | Enforce `clear()` over `fill('')` in Playwright tests | ✅ | 🔧 | |
55
55
  | no-project-as-in-ng-template | `ngProjectAs` has no effect inside `<ng-template>` or dynamic outlets | ✅ | | |
56
56
  | no-redundant-type-annotation | Disallow redundant type annotations when the type is already inferred from the initializer | ✅ | 🔧 | |
57
- | no-side-effects-in-computed | Disallow observable side effects inside Angular `computed()` callbacks | ✅ | | |
57
+ | no-side-effects-in-computed | Disallow side effects and effectful helper calls inside Angular `computed()` callbacks | ✅ | | |
58
58
  | no-signal-reads-after-await-in-reactive-context | Disallow bare signal reads after `await` inside reactive callbacks | ✅ | | |
59
59
  | no-string-literal-concat | Disallow string literal concatenation; merge adjacent literals into one | ✅ | 🔧 | |
60
60
  | no-untracked-outside-reactive-context | Disallow `untracked()` outside reactive callbacks, except explicit post-`await` snapshots | ✅ | 🔧 | |
@@ -681,8 +681,9 @@ const doubled = computed(() => {
681
681
  <sup>`✅ Recommended`</sup>
682
682
 
683
683
  `computed()` should only derive a value from its inputs. This rule reports observable side effects inside Angular
684
- `computed()` callbacks, including signal writes (`.set()`, `.update()`, `.mutate()`), assignments to captured state,
685
- `++/--`, `delete`, and property mutations on objects that were not created inside the computation itself.
684
+ `computed()` callbacks, including signal writes (`.set()`, `.update()`, `.mutate()`), `effect()`, `inject()`,
685
+ assignments to captured state, `++/--`, `delete`, property mutations on objects that were not created inside the
686
+ computation itself, and calls to local helper functions or methods when their bodies perform those operations.
686
687
 
687
688
  ```ts
688
689
  // ❌ error
@@ -691,8 +692,12 @@ import {computed, signal} from '@angular/core';
691
692
  const source = signal(0);
692
693
  const target = signal(0);
693
694
 
694
- const derived = computed(() => {
695
+ function syncTarget(): void {
695
696
  target.set(source() + 1);
697
+ }
698
+
699
+ const derived = computed(() => {
700
+ syncTarget();
696
701
  return target();
697
702
  });
698
703
  ```
package/index.esm.js CHANGED
@@ -2718,6 +2718,9 @@ function appendObjectPropertyReactiveScopes(scopes, call, object, labels) {
2718
2718
  function isAngularEffectCall(node, program) {
2719
2719
  return isAngularCoreCall(node, program, 'effect');
2720
2720
  }
2721
+ function isAngularInjectCall(node, program) {
2722
+ return isAngularCoreCall(node, program, 'inject');
2723
+ }
2721
2724
  function isAngularUntrackedCall(node, program) {
2722
2725
  return isAngularCoreCall(node, program, 'untracked');
2723
2726
  }
@@ -3463,6 +3466,11 @@ function unwrapExpression(expression) {
3463
3466
  }
3464
3467
 
3465
3468
  const createRule$8 = ESLintUtils.RuleCreator((name) => name);
3469
+ function isFunctionLikeScope(node) {
3470
+ return (node?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
3471
+ node?.type === AST_NODE_TYPES.FunctionDeclaration ||
3472
+ node?.type === AST_NODE_TYPES.FunctionExpression);
3473
+ }
3466
3474
  function isReactiveCallback(node) {
3467
3475
  return (node?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
3468
3476
  node?.type === AST_NODE_TYPES.FunctionExpression);
@@ -3507,7 +3515,7 @@ function getSymbolAtNode(node, checker, esTreeNodeToTSNodeMap) {
3507
3515
  }
3508
3516
  return checker.getSymbolAtLocation(tsNode) ?? null;
3509
3517
  }
3510
- function isLocalIdentifier(node, context) {
3518
+ function isLocalIdentifier(node, context, localScopes) {
3511
3519
  const symbol = getSymbolAtNode(node, context.checker, context.esTreeNodeToTSNodeMap);
3512
3520
  if (!symbol) {
3513
3521
  return false;
@@ -3515,10 +3523,24 @@ function isLocalIdentifier(node, context) {
3515
3523
  return (symbol.declarations ?? []).some((declaration) => {
3516
3524
  const estreeDeclaration = context.tsNodeToESTreeNodeMap.get(declaration);
3517
3525
  return (!!estreeDeclaration &&
3518
- isNodeInsideSynchronousReactiveScope(estreeDeclaration, context.callback));
3526
+ isDeclaredInsideLocalScope(estreeDeclaration, localScopes));
3519
3527
  });
3520
3528
  }
3521
- function isLocallyCreatedExpression(node, context) {
3529
+ function isDeclaredInsideLocalScope(node, localScopes) {
3530
+ return localScopes.some((scope) => isNodeInsideFunctionScope(node, scope));
3531
+ }
3532
+ function isNodeInsideFunctionScope(node, scope) {
3533
+ let found = false;
3534
+ walkSynchronousAst(scope, (inner) => {
3535
+ if (inner !== node) {
3536
+ return;
3537
+ }
3538
+ found = true;
3539
+ return false;
3540
+ });
3541
+ return found;
3542
+ }
3543
+ function isLocallyCreatedExpression(node, context, localScopes) {
3522
3544
  const expression = unwrapExpression(node);
3523
3545
  switch (expression.type) {
3524
3546
  case AST_NODE_TYPES.ArrayExpression:
@@ -3526,60 +3548,222 @@ function isLocallyCreatedExpression(node, context) {
3526
3548
  case AST_NODE_TYPES.ObjectExpression:
3527
3549
  return true;
3528
3550
  case AST_NODE_TYPES.Identifier:
3529
- return isLocalIdentifier(expression, context);
3551
+ return isLocalIdentifier(expression, context, localScopes);
3530
3552
  case AST_NODE_TYPES.MemberExpression:
3531
- return isLocallyCreatedExpression(expression.object, context);
3553
+ return isLocallyCreatedExpression(expression.object, context, localScopes);
3532
3554
  default:
3533
3555
  return false;
3534
3556
  }
3535
3557
  }
3536
- function hasObservableMutationTarget(node, context) {
3558
+ function hasObservableMutationTarget(node, context, localScopes) {
3537
3559
  return collectMutationTargets(node).some((target) => {
3538
3560
  if (target.type === AST_NODE_TYPES.Identifier) {
3539
- return !isLocalIdentifier(target, context);
3561
+ return !isLocalIdentifier(target, context, localScopes);
3540
3562
  }
3541
- return !isLocallyCreatedExpression(target.object, context);
3563
+ return !isLocallyCreatedExpression(target.object, context, localScopes);
3542
3564
  });
3543
3565
  }
3544
- function reportSideEffect(node, inspectionContext, report) {
3566
+ function reportSideEffect(node, context, report) {
3545
3567
  const key = String(node.range);
3546
- if (inspectionContext.reported.has(key)) {
3568
+ if (context.reported.has(key)) {
3547
3569
  return;
3548
3570
  }
3549
- inspectionContext.reported.add(key);
3571
+ context.reported.add(key);
3550
3572
  report(node);
3551
3573
  }
3552
- function inspectComputedBody(root, inspectionContext, report) {
3574
+ function isDirectAngularSideEffectCall(node, context) {
3575
+ return (isWritableSignalWrite(node, context.checker, context.esTreeNodeToTSNodeMap) ||
3576
+ isAngularEffectCall(node, context.program) ||
3577
+ isAngularInjectCall(node, context.program));
3578
+ }
3579
+ function isInspectableFunctionContainer(node) {
3580
+ return (!!node &&
3581
+ (isFunctionLikeScope(node) ||
3582
+ node.type === AST_NODE_TYPES.MethodDefinition ||
3583
+ node.type === AST_NODE_TYPES.Property ||
3584
+ node.type === AST_NODE_TYPES.PropertyDefinition ||
3585
+ node.type === AST_NODE_TYPES.VariableDeclarator));
3586
+ }
3587
+ function resolveFunctionLikeFromContainer(node, context, seenSymbols = new Set()) {
3588
+ if (isFunctionLikeScope(node)) {
3589
+ return [node];
3590
+ }
3591
+ if ((node.type === AST_NODE_TYPES.MethodDefinition &&
3592
+ isFunctionLikeScope(node.value)) ||
3593
+ (node.type === AST_NODE_TYPES.Property && isFunctionLikeScope(node.value))) {
3594
+ return [node.value];
3595
+ }
3596
+ if (node.type === AST_NODE_TYPES.Property &&
3597
+ node.value.type === AST_NODE_TYPES.Identifier) {
3598
+ return resolveFunctionLikeFromIdentifier(node.value, context, seenSymbols);
3599
+ }
3600
+ if (node.type === AST_NODE_TYPES.PropertyDefinition &&
3601
+ node.value &&
3602
+ isFunctionLikeScope(node.value)) {
3603
+ return [node.value];
3604
+ }
3605
+ if (node.type === AST_NODE_TYPES.PropertyDefinition &&
3606
+ node.value?.type === AST_NODE_TYPES.Identifier) {
3607
+ return resolveFunctionLikeFromIdentifier(node.value, context, seenSymbols);
3608
+ }
3609
+ if (node.type === AST_NODE_TYPES.VariableDeclarator) {
3610
+ const { init } = node;
3611
+ if (init && isFunctionLikeScope(init)) {
3612
+ return [init];
3613
+ }
3614
+ if (init?.type === AST_NODE_TYPES.Identifier) {
3615
+ return resolveFunctionLikeFromIdentifier(init, context, seenSymbols);
3616
+ }
3617
+ }
3618
+ return [];
3619
+ }
3620
+ function resolveFunctionLikeFromIdentifier(node, context, seenSymbols = new Set()) {
3621
+ const symbol = getSymbolAtNode(node, context.checker, context.esTreeNodeToTSNodeMap);
3622
+ if (!symbol) {
3623
+ return [];
3624
+ }
3625
+ const symbolId = `${symbol.name}:${symbol.declarations?.[0]?.pos ?? -1}`;
3626
+ if (seenSymbols.has(symbolId)) {
3627
+ return [];
3628
+ }
3629
+ seenSymbols.add(symbolId);
3630
+ return (symbol.declarations ?? []).flatMap((declaration) => {
3631
+ const estreeDeclaration = context.tsNodeToESTreeNodeMap.get(declaration);
3632
+ if (!estreeDeclaration || !isInspectableFunctionContainer(estreeDeclaration)) {
3633
+ return [];
3634
+ }
3635
+ return resolveFunctionLikeFromContainer(estreeDeclaration, context, seenSymbols);
3636
+ });
3637
+ }
3638
+ function resolveCalledFunctions(node, context) {
3639
+ const resolved = new Map();
3640
+ const tsNode = context.esTreeNodeToTSNodeMap.get(node);
3641
+ const signature = tsNode ? context.checker.getResolvedSignature(tsNode) : undefined;
3642
+ const declarations = new Set();
3643
+ if (signature?.declaration) {
3644
+ declarations.add(signature.declaration);
3645
+ }
3646
+ const callee = unwrapExpression(node.callee);
3647
+ if (callee.type === AST_NODE_TYPES.Identifier ||
3648
+ callee.type === AST_NODE_TYPES.MemberExpression) {
3649
+ const symbol = getSymbolAtNode(callee, context.checker, context.esTreeNodeToTSNodeMap);
3650
+ for (const declaration of symbol?.declarations ?? []) {
3651
+ declarations.add(declaration);
3652
+ }
3653
+ }
3654
+ for (const declaration of declarations) {
3655
+ const estreeDeclaration = context.tsNodeToESTreeNodeMap.get(declaration);
3656
+ if (!estreeDeclaration || !isInspectableFunctionContainer(estreeDeclaration)) {
3657
+ continue;
3658
+ }
3659
+ for (const fn of resolveFunctionLikeFromContainer(estreeDeclaration, context)) {
3660
+ resolved.set(String(fn.range), fn);
3661
+ }
3662
+ }
3663
+ return [...resolved.values()];
3664
+ }
3665
+ function functionHasObservableSideEffects(root, context, localScopes, visitedFunctions) {
3666
+ let hasSideEffect = false;
3553
3667
  walkSynchronousAst(root, (node) => {
3554
3668
  if (node.type === AST_NODE_TYPES.CallExpression) {
3555
- if (isWritableSignalWrite(node, inspectionContext.checker, inspectionContext.esTreeNodeToTSNodeMap)) {
3556
- reportSideEffect(node, inspectionContext, report);
3669
+ if (isDirectAngularSideEffectCall(node, context)) {
3670
+ hasSideEffect = true;
3671
+ return false;
3672
+ }
3673
+ if (isAngularUntrackedCall(node, context.program)) {
3674
+ const [arg] = node.arguments;
3675
+ if (isReactiveCallback(arg) &&
3676
+ functionHasObservableSideEffects(arg, context, [...localScopes, arg], visitedFunctions)) {
3677
+ hasSideEffect = true;
3678
+ }
3679
+ return false;
3680
+ }
3681
+ if (isReactiveCallback(node.callee)) {
3682
+ if (functionHasObservableSideEffects(node.callee, context, [...localScopes, node.callee], visitedFunctions)) {
3683
+ hasSideEffect = true;
3684
+ }
3685
+ return false;
3686
+ }
3687
+ for (const calledFunction of resolveCalledFunctions(node, context)) {
3688
+ const key = String(calledFunction.range);
3689
+ if (visitedFunctions.has(key)) {
3690
+ continue;
3691
+ }
3692
+ visitedFunctions.add(key);
3693
+ const calledFunctionHasSideEffects = functionHasObservableSideEffects(calledFunction, context, [...localScopes, calledFunction], visitedFunctions);
3694
+ visitedFunctions.delete(key);
3695
+ if (!calledFunctionHasSideEffects) {
3696
+ continue;
3697
+ }
3698
+ hasSideEffect = true;
3699
+ return false;
3700
+ }
3701
+ }
3702
+ if (node.type === AST_NODE_TYPES.AssignmentExpression &&
3703
+ hasObservableMutationTarget(node.left, context, localScopes)) {
3704
+ hasSideEffect = true;
3705
+ return false;
3706
+ }
3707
+ if (node.type === AST_NODE_TYPES.UpdateExpression &&
3708
+ hasObservableMutationTarget(node.argument, context, localScopes)) {
3709
+ hasSideEffect = true;
3710
+ return false;
3711
+ }
3712
+ if (node.type === AST_NODE_TYPES.UnaryExpression &&
3713
+ node.operator === 'delete' &&
3714
+ hasObservableMutationTarget(node.argument, context, localScopes)) {
3715
+ hasSideEffect = true;
3716
+ return false;
3717
+ }
3718
+ return;
3719
+ });
3720
+ return hasSideEffect;
3721
+ }
3722
+ function inspectComputedBody(root, context, localScopes, visitedFunctions, report) {
3723
+ walkSynchronousAst(root, (node) => {
3724
+ if (node.type === AST_NODE_TYPES.CallExpression) {
3725
+ if (isDirectAngularSideEffectCall(node, context)) {
3726
+ reportSideEffect(node, context, report);
3727
+ return false;
3557
3728
  }
3558
- if (isAngularUntrackedCall(node, inspectionContext.program)) {
3729
+ if (isAngularUntrackedCall(node, context.program)) {
3559
3730
  const [arg] = node.arguments;
3560
3731
  if (isReactiveCallback(arg)) {
3561
- inspectComputedBody(arg, inspectionContext, report);
3732
+ inspectComputedBody(arg, context, [...localScopes, arg], visitedFunctions, report);
3562
3733
  }
3563
3734
  return false;
3564
3735
  }
3565
- if (node.callee.type === AST_NODE_TYPES.ArrowFunctionExpression ||
3566
- node.callee.type === AST_NODE_TYPES.FunctionExpression) {
3567
- inspectComputedBody(node.callee, inspectionContext, report);
3736
+ if (isReactiveCallback(node.callee)) {
3737
+ inspectComputedBody(node.callee, context, [...localScopes, node.callee], visitedFunctions, report);
3738
+ return false;
3739
+ }
3740
+ for (const calledFunction of resolveCalledFunctions(node, context)) {
3741
+ const key = String(calledFunction.range);
3742
+ if (visitedFunctions.has(key)) {
3743
+ continue;
3744
+ }
3745
+ visitedFunctions.add(key);
3746
+ const calledFunctionHasSideEffects = functionHasObservableSideEffects(calledFunction, context, [...localScopes, calledFunction], visitedFunctions);
3747
+ visitedFunctions.delete(key);
3748
+ if (!calledFunctionHasSideEffects) {
3749
+ continue;
3750
+ }
3751
+ reportSideEffect(node, context, report);
3568
3752
  return false;
3569
3753
  }
3570
3754
  }
3571
3755
  if (node.type === AST_NODE_TYPES.AssignmentExpression &&
3572
- hasObservableMutationTarget(node.left, inspectionContext)) {
3573
- reportSideEffect(node.left, inspectionContext, report);
3756
+ hasObservableMutationTarget(node.left, context, localScopes)) {
3757
+ reportSideEffect(node.left, context, report);
3574
3758
  }
3575
3759
  if (node.type === AST_NODE_TYPES.UpdateExpression &&
3576
- hasObservableMutationTarget(node.argument, inspectionContext)) {
3577
- reportSideEffect(node.argument, inspectionContext, report);
3760
+ hasObservableMutationTarget(node.argument, context, localScopes)) {
3761
+ reportSideEffect(node.argument, context, report);
3578
3762
  }
3579
3763
  if (node.type === AST_NODE_TYPES.UnaryExpression &&
3580
3764
  node.operator === 'delete' &&
3581
- hasObservableMutationTarget(node.argument, inspectionContext)) {
3582
- reportSideEffect(node.argument, inspectionContext, report);
3765
+ hasObservableMutationTarget(node.argument, context, localScopes)) {
3766
+ reportSideEffect(node.argument, context, report);
3583
3767
  }
3584
3768
  return;
3585
3769
  });
@@ -3598,15 +3782,14 @@ const rule$b = createRule$8({
3598
3782
  if (scope.kind !== 'computed()') {
3599
3783
  continue;
3600
3784
  }
3601
- const inspectionContext = {
3602
- callback: scope.callback,
3785
+ const analysisContext = {
3603
3786
  checker,
3604
3787
  esTreeNodeToTSNodeMap,
3605
3788
  program,
3606
3789
  reported: new Set(),
3607
3790
  tsNodeToESTreeNodeMap,
3608
3791
  };
3609
- inspectComputedBody(scope.callback, inspectionContext, (reportNode) => {
3792
+ inspectComputedBody(scope.callback, analysisContext, [scope.callback], new Set([String(scope.callback.range)]), (reportNode) => {
3610
3793
  context.report({
3611
3794
  data: { expression: sourceCode.getText(reportNode) },
3612
3795
  messageId: 'sideEffectInComputed',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ui/eslint-plugin-experience-next",
3
- "version": "0.473.0",
3
+ "version": "0.474.0",
4
4
  "description": "An ESLint plugin to enforce a consistent code styles across taiga-ui projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,7 @@ export interface SignalUsage {
17
17
  readonly writes: TSESTree.CallExpression[];
18
18
  }
19
19
  export declare function isAngularEffectCall(node: TSESTree.CallExpression, program: TSESTree.Program): boolean;
20
+ export declare function isAngularInjectCall(node: TSESTree.CallExpression, program: TSESTree.Program): boolean;
20
21
  export declare function isAngularUntrackedCall(node: TSESTree.CallExpression, program: TSESTree.Program): boolean;
21
22
  export declare function getReactiveScopes(node: TSESTree.CallExpression, program: TSESTree.Program): ReactiveScope[];
22
23
  export declare function isNodeInsideSynchronousReactiveScope(node: TSESTree.Node, callback: ReactiveCallback): boolean;