@taiga-ui/eslint-plugin-experience-next 0.466.0 → 0.467.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/index.esm.js CHANGED
@@ -905,10 +905,16 @@ var recommended = defineConfig([
905
905
  },
906
906
  ],
907
907
  '@taiga-ui/experience-next/no-deep-imports-to-indexed-packages': 'error',
908
+ '@taiga-ui/experience-next/no-fully-untracked-effect': 'error',
908
909
  '@taiga-ui/experience-next/no-implicit-public': 'error',
909
910
  '@taiga-ui/experience-next/no-redundant-type-annotation': 'error',
911
+ '@taiga-ui/experience-next/no-signal-reads-after-await-in-reactive-context': 'error',
912
+ '@taiga-ui/experience-next/no-untracked-outside-reactive-context': 'error',
913
+ '@taiga-ui/experience-next/no-useless-untracked': 'error',
910
914
  '@taiga-ui/experience-next/object-single-line': ['error', { printWidth: 90 }],
911
915
  '@taiga-ui/experience-next/prefer-multi-arg-push': 'error',
916
+ '@taiga-ui/experience-next/prefer-untracked-incidental-signal-reads': 'error',
917
+ '@taiga-ui/experience-next/prefer-untracked-signal-getter': 'error',
912
918
  '@taiga-ui/experience-next/short-tui-imports': 'error',
913
919
  '@taiga-ui/experience-next/standalone-imports-sort': [
914
920
  'error',
@@ -1706,7 +1712,7 @@ const config$2 = {
1706
1712
  const MESSAGE_ID$5 = 'invalid-injection-token-description';
1707
1713
  const ERROR_MESSAGE$3 = "InjectionToken's description should contain token's name";
1708
1714
  const createRule$d = ESLintUtils.RuleCreator((name) => name);
1709
- const rule$a = createRule$d({
1715
+ const rule$g = createRule$d({
1710
1716
  create(context) {
1711
1717
  return {
1712
1718
  'NewExpression[callee.name="InjectionToken"]'(node) {
@@ -1774,7 +1780,7 @@ const DEFAULT_OPTIONS = {
1774
1780
  projectName: String.raw `(?<=^@taiga-ui/)([-\w]+)`,
1775
1781
  };
1776
1782
  const createRule$c = ESLintUtils.RuleCreator((name) => name);
1777
- const rule$9 = createRule$c({
1783
+ const rule$f = createRule$c({
1778
1784
  create(context) {
1779
1785
  const { currentProject, deepImport, ignoreImports, importDeclaration, projectName, } = { ...DEFAULT_OPTIONS, ...context.options[0] };
1780
1786
  const hasNonCodeExtension = (source) => {
@@ -2062,6 +2068,534 @@ function stripKnownExtensions(filePathOrSpecifier) {
2062
2068
  return filePathOrSpecifier.replace(/\.(?:d\.ts|ts|tsx|js|jsx|mjs|cjs)$/, '');
2063
2069
  }
2064
2070
 
2071
+ const ANGULAR_CORE$1 = '@angular/core';
2072
+ /**
2073
+ * Returns the local name bound to a named import from a given source.
2074
+ * Handles aliased imports: `import { untracked as ngUntracked } from '@angular/core'`
2075
+ * returns `'ngUntracked'` for `exportedName = 'untracked'`.
2076
+ */
2077
+ function getLocalNameForImport(program, source, exportedName) {
2078
+ for (const node of program.body) {
2079
+ if (node.type !== AST_NODE_TYPES.ImportDeclaration ||
2080
+ node.source.value !== source) {
2081
+ continue;
2082
+ }
2083
+ for (const spec of node.specifiers) {
2084
+ if (spec.type !== AST_NODE_TYPES.ImportSpecifier) {
2085
+ continue;
2086
+ }
2087
+ const imported = spec.imported.type === AST_NODE_TYPES.Identifier
2088
+ ? spec.imported.name
2089
+ : spec.imported.value;
2090
+ if (imported === exportedName) {
2091
+ return spec.local.name;
2092
+ }
2093
+ }
2094
+ }
2095
+ return null;
2096
+ }
2097
+ function findAngularCoreImports(program) {
2098
+ return program.body.filter((node) => node.type === AST_NODE_TYPES.ImportDeclaration &&
2099
+ node.source.value === ANGULAR_CORE$1);
2100
+ }
2101
+ function findRuntimeAngularCoreImport(program) {
2102
+ return (findAngularCoreImports(program).find((node) => node.importKind !== 'type') ?? null);
2103
+ }
2104
+ function findAngularCoreImportSpecifier(program, exportedName) {
2105
+ for (const importDecl of findAngularCoreImports(program)) {
2106
+ for (const specifier of importDecl.specifiers) {
2107
+ if (specifier.type !== AST_NODE_TYPES.ImportSpecifier) {
2108
+ continue;
2109
+ }
2110
+ const imported = specifier.imported.type === AST_NODE_TYPES.Identifier
2111
+ ? specifier.imported.name
2112
+ : specifier.imported.value;
2113
+ if (imported === exportedName) {
2114
+ return { importDecl, specifier };
2115
+ }
2116
+ }
2117
+ }
2118
+ return null;
2119
+ }
2120
+
2121
+ function isFunctionLike$1(node) {
2122
+ return (node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
2123
+ node.type === AST_NODE_TYPES.FunctionDeclaration ||
2124
+ node.type === AST_NODE_TYPES.FunctionExpression);
2125
+ }
2126
+ function getOrderedChildren(node) {
2127
+ if (isFunctionLike$1(node)) {
2128
+ const children = [
2129
+ ...node.params,
2130
+ node.body,
2131
+ ];
2132
+ return children.filter((child) => child !== undefined && child !== null);
2133
+ }
2134
+ if (node.type === AST_NODE_TYPES.BlockStatement) {
2135
+ return node.body;
2136
+ }
2137
+ if (node.type === AST_NODE_TYPES.Program) {
2138
+ return node.body;
2139
+ }
2140
+ if (node.type === AST_NODE_TYPES.IfStatement) {
2141
+ return node.alternate
2142
+ ? [node.test, node.consequent, node.alternate]
2143
+ : [node.test, node.consequent];
2144
+ }
2145
+ if (node.type === AST_NODE_TYPES.ConditionalExpression) {
2146
+ return [node.test, node.consequent, node.alternate];
2147
+ }
2148
+ if (node.type === AST_NODE_TYPES.WhileStatement) {
2149
+ return [node.test, node.body];
2150
+ }
2151
+ if (node.type === AST_NODE_TYPES.DoWhileStatement) {
2152
+ return [node.body, node.test];
2153
+ }
2154
+ if (node.type === AST_NODE_TYPES.ForStatement) {
2155
+ const children = [
2156
+ node.init && 'type' in node.init ? node.init : null,
2157
+ node.test,
2158
+ node.update,
2159
+ node.body,
2160
+ ];
2161
+ return children.filter((child) => child !== null);
2162
+ }
2163
+ if (node.type === AST_NODE_TYPES.ForInStatement ||
2164
+ node.type === AST_NODE_TYPES.ForOfStatement) {
2165
+ return [node.left, node.right, node.body];
2166
+ }
2167
+ const children = [];
2168
+ for (const key of Object.keys(node)) {
2169
+ if (key === 'parent') {
2170
+ continue;
2171
+ }
2172
+ const child = node[key];
2173
+ if (Array.isArray(child)) {
2174
+ for (const item of child) {
2175
+ if (item && typeof item === 'object' && 'type' in item) {
2176
+ children.push(item);
2177
+ }
2178
+ }
2179
+ }
2180
+ else if (child && typeof child === 'object' && 'type' in child) {
2181
+ children.push(child);
2182
+ }
2183
+ }
2184
+ return children;
2185
+ }
2186
+ /**
2187
+ * Walks the synchronous portion of a reactive scope.
2188
+ *
2189
+ * - Stops descending into nested function boundaries, since they run in their
2190
+ * own call context and should not be treated as reads/writes of the current
2191
+ * reactive scope.
2192
+ * - Traverses the argument of `await`, because it is evaluated synchronously,
2193
+ * then stops visiting subsequent sibling nodes in the current execution path.
2194
+ */
2195
+ function walkSynchronousAst(root, visitor) {
2196
+ traverse(root, true);
2197
+ function traverse(node, isRoot = false) {
2198
+ if (visitor(node) === false) {
2199
+ return false;
2200
+ }
2201
+ if (!isRoot && isFunctionLike$1(node)) {
2202
+ return false;
2203
+ }
2204
+ if (node.type === AST_NODE_TYPES.AwaitExpression) {
2205
+ return traverse(node.argument, false) || true;
2206
+ }
2207
+ for (const child of getOrderedChildren(node)) {
2208
+ if (traverse(child, false)) {
2209
+ return true;
2210
+ }
2211
+ }
2212
+ return false;
2213
+ }
2214
+ }
2215
+ /**
2216
+ * Walks nodes that run after an async boundary inside a reactive callback.
2217
+ *
2218
+ * The traversal is intentionally conservative:
2219
+ * - it never descends into nested function boundaries
2220
+ * - it propagates async state through straight-line code
2221
+ * - it does not propagate branch-local `await` from optional control-flow
2222
+ * bodies into later siblings, which avoids noisy false positives
2223
+ */
2224
+ function walkAfterAsyncBoundaryAst(root, visitor) {
2225
+ traverse(root, true, false);
2226
+ function traverse(node, isRoot = false, afterBoundary = false) {
2227
+ if (afterBoundary) {
2228
+ visitor(node);
2229
+ }
2230
+ if (!isRoot && isFunctionLike$1(node)) {
2231
+ return false;
2232
+ }
2233
+ if (node.type === AST_NODE_TYPES.AwaitExpression) {
2234
+ traverse(node.argument, false, afterBoundary);
2235
+ return true;
2236
+ }
2237
+ if (node.type === AST_NODE_TYPES.BlockStatement ||
2238
+ node.type === AST_NODE_TYPES.Program) {
2239
+ let crossed = afterBoundary;
2240
+ for (const child of node.body) {
2241
+ const childCrossed = traverse(child, false, crossed);
2242
+ crossed = crossed || childCrossed;
2243
+ }
2244
+ return crossed && !afterBoundary;
2245
+ }
2246
+ if (node.type === AST_NODE_TYPES.IfStatement) {
2247
+ const crossedInTest = traverse(node.test, false, afterBoundary);
2248
+ const branchAfterBoundary = afterBoundary || crossedInTest;
2249
+ traverse(node.consequent, false, branchAfterBoundary);
2250
+ if (node.alternate) {
2251
+ traverse(node.alternate, false, branchAfterBoundary);
2252
+ }
2253
+ return crossedInTest && !afterBoundary;
2254
+ }
2255
+ let crossed = afterBoundary;
2256
+ for (const child of getOrderedChildren(node)) {
2257
+ const childCrossed = traverse(child, false, crossed);
2258
+ crossed = crossed || childCrossed;
2259
+ }
2260
+ return crossed && !afterBoundary;
2261
+ }
2262
+ }
2263
+ /**
2264
+ * Shallow AST walker. Visits `root` and every descendant, skipping the
2265
+ * synthetic `parent` back-pointer to avoid cycles.
2266
+ *
2267
+ * If the visitor returns `false` for a node, that node's children are NOT
2268
+ * visited (prune / stop-descend). Any other return value (including `void`)
2269
+ * continues the walk.
2270
+ */
2271
+ function walkAst(root, visitor) {
2272
+ if (visitor(root) === false) {
2273
+ return;
2274
+ }
2275
+ for (const key of Object.keys(root)) {
2276
+ if (key === 'parent') {
2277
+ continue;
2278
+ }
2279
+ const child = root[key];
2280
+ if (Array.isArray(child)) {
2281
+ for (const item of child) {
2282
+ if (item && typeof item === 'object' && 'type' in item) {
2283
+ walkAst(item, visitor);
2284
+ }
2285
+ }
2286
+ }
2287
+ else if (child && typeof child === 'object' && 'type' in child) {
2288
+ walkAst(child, visitor);
2289
+ }
2290
+ }
2291
+ }
2292
+
2293
+ const ANGULAR_CORE = '@angular/core';
2294
+ const SIGNAL_WRITE_METHODS = new Set(['mutate', 'set', 'update']);
2295
+ const AFTER_RENDER_EFFECT_PHASES = new Map([
2296
+ ['earlyRead', 'afterRenderEffect().earlyRead'],
2297
+ ['mixedReadWrite', 'afterRenderEffect().mixedReadWrite'],
2298
+ ['read', 'afterRenderEffect().read'],
2299
+ ['write', 'afterRenderEffect().write'],
2300
+ ]);
2301
+ function isReactiveCallback(node) {
2302
+ return (node?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
2303
+ node?.type === AST_NODE_TYPES.FunctionExpression);
2304
+ }
2305
+ function getPropertyName(property) {
2306
+ if (property.computed) {
2307
+ return null;
2308
+ }
2309
+ if (property.key.type === AST_NODE_TYPES.Identifier) {
2310
+ return property.key.name;
2311
+ }
2312
+ return typeof property.key.value === 'string' ? property.key.value : null;
2313
+ }
2314
+ function isAngularCoreCall(node, program, exportedName) {
2315
+ const localName = getLocalNameForImport(program, ANGULAR_CORE, exportedName);
2316
+ if (!localName) {
2317
+ return false;
2318
+ }
2319
+ return (node.callee.type === AST_NODE_TYPES.Identifier &&
2320
+ node.callee.name === localName &&
2321
+ node.arguments.length >= 1);
2322
+ }
2323
+ function appendFirstArgReactiveScope(scopes, call, kind) {
2324
+ const [arg] = call.arguments;
2325
+ if (!isReactiveCallback(arg)) {
2326
+ return;
2327
+ }
2328
+ scopes.push({ callback: arg, kind, owner: call, reportNode: call });
2329
+ }
2330
+ function appendObjectPropertyReactiveScopes(scopes, call, object, labels) {
2331
+ for (const property of object.properties) {
2332
+ if (property.type !== AST_NODE_TYPES.Property) {
2333
+ continue;
2334
+ }
2335
+ const name = getPropertyName(property);
2336
+ const label = name ? labels.get(name) : null;
2337
+ if (!label || !isReactiveCallback(property.value)) {
2338
+ continue;
2339
+ }
2340
+ scopes.push({
2341
+ callback: property.value,
2342
+ kind: label,
2343
+ owner: call,
2344
+ reportNode: property,
2345
+ });
2346
+ }
2347
+ }
2348
+ function isAngularEffectCall(node, program) {
2349
+ return isAngularCoreCall(node, program, 'effect');
2350
+ }
2351
+ function isAngularUntrackedCall(node, program) {
2352
+ return isAngularCoreCall(node, program, 'untracked');
2353
+ }
2354
+ function getReactiveScopes(node, program) {
2355
+ const scopes = [];
2356
+ const [arg] = node.arguments;
2357
+ if (isAngularEffectCall(node, program)) {
2358
+ appendFirstArgReactiveScope(scopes, node, 'effect()');
2359
+ }
2360
+ if (isAngularCoreCall(node, program, 'computed')) {
2361
+ appendFirstArgReactiveScope(scopes, node, 'computed()');
2362
+ }
2363
+ if (isAngularCoreCall(node, program, 'linkedSignal')) {
2364
+ appendFirstArgReactiveScope(scopes, node, 'linkedSignal()');
2365
+ if (arg?.type === AST_NODE_TYPES.ObjectExpression) {
2366
+ appendObjectPropertyReactiveScopes(scopes, node, arg, new Map([
2367
+ ['computation', 'linkedSignal().computation'],
2368
+ ['source', 'linkedSignal().source'],
2369
+ ]));
2370
+ }
2371
+ }
2372
+ if (isAngularCoreCall(node, program, 'resource')) {
2373
+ if (arg?.type === AST_NODE_TYPES.ObjectExpression) {
2374
+ appendObjectPropertyReactiveScopes(scopes, node, arg, new Map([
2375
+ ['loader', 'resource().loader'],
2376
+ ['params', 'resource().params'],
2377
+ ]));
2378
+ }
2379
+ }
2380
+ if (isAngularCoreCall(node, program, 'afterRenderEffect')) {
2381
+ appendFirstArgReactiveScope(scopes, node, 'afterRenderEffect()');
2382
+ if (arg?.type === AST_NODE_TYPES.ObjectExpression) {
2383
+ appendObjectPropertyReactiveScopes(scopes, node, arg, AFTER_RENDER_EFFECT_PHASES);
2384
+ }
2385
+ }
2386
+ return scopes;
2387
+ }
2388
+ function isNodeInsideSynchronousReactiveScope(node, callback) {
2389
+ let found = false;
2390
+ walkSynchronousAst(callback, (inner) => {
2391
+ if (inner !== node) {
2392
+ return;
2393
+ }
2394
+ found = true;
2395
+ return false;
2396
+ });
2397
+ return found;
2398
+ }
2399
+ function findEnclosingReactiveScope(node, program) {
2400
+ for (let current = node.parent; current; current = current.parent) {
2401
+ if (current.type !== AST_NODE_TYPES.CallExpression) {
2402
+ continue;
2403
+ }
2404
+ for (const scope of getReactiveScopes(current, program)) {
2405
+ if (isNodeInsideSynchronousReactiveScope(node, scope.callback)) {
2406
+ return scope;
2407
+ }
2408
+ }
2409
+ }
2410
+ return null;
2411
+ }
2412
+ /**
2413
+ * Returns true when the TypeScript type at `node` is an Angular signal type.
2414
+ * Uses duck-typing: callable type whose name contains "Signal", or whose
2415
+ * string-keyed properties include the Angular `ɵ` brand.
2416
+ *
2417
+ * Falls back to `false` on any error.
2418
+ */
2419
+ function isSignalType(node, checker, esTreeNodeToTSNodeMap) {
2420
+ try {
2421
+ const tsNode = esTreeNodeToTSNodeMap.get(node);
2422
+ if (!tsNode) {
2423
+ return false;
2424
+ }
2425
+ const type = checker.getTypeAtLocation(tsNode);
2426
+ if (type.getCallSignatures().length === 0) {
2427
+ return false;
2428
+ }
2429
+ if (checker.typeToString(type).includes('Signal')) {
2430
+ return true;
2431
+ }
2432
+ const typeSymbol = type.getSymbol();
2433
+ const aliasSymbol = type.aliasSymbol;
2434
+ if (typeSymbol?.getName().includes('Signal') ||
2435
+ aliasSymbol?.getName().includes('Signal')) {
2436
+ return true;
2437
+ }
2438
+ return type
2439
+ .getProperties()
2440
+ .some((p) => p.name.startsWith('ɵ') || p.name === '__SIGNAL');
2441
+ }
2442
+ catch {
2443
+ return false;
2444
+ }
2445
+ }
2446
+ function isSignalReadCall(node, checker, esTreeNodeToTSNodeMap) {
2447
+ const { callee } = node;
2448
+ if (callee.type !== AST_NODE_TYPES.Identifier &&
2449
+ callee.type !== AST_NODE_TYPES.MemberExpression) {
2450
+ return false;
2451
+ }
2452
+ return isSignalType(callee, checker, esTreeNodeToTSNodeMap);
2453
+ }
2454
+ function isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap) {
2455
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
2456
+ return false;
2457
+ }
2458
+ const { object, property } = node.callee;
2459
+ if (property.type !== AST_NODE_TYPES.Identifier) {
2460
+ return false;
2461
+ }
2462
+ if (!SIGNAL_WRITE_METHODS.has(property.name)) {
2463
+ return false;
2464
+ }
2465
+ return isSignalType(object, checker, esTreeNodeToTSNodeMap);
2466
+ }
2467
+ /**
2468
+ * Returns true when `node` is a member expression `foo.bar` where `bar` is a
2469
+ * TypeScript getter. Getter accesses are opaque — the getter body can read
2470
+ * signals, so wrapping them in `untracked()` is justified.
2471
+ */
2472
+ function isGetterMemberAccess(node, checker, esTreeNodeToTSNodeMap) {
2473
+ try {
2474
+ const tsNode = esTreeNodeToTSNodeMap.get(node);
2475
+ if (!tsNode) {
2476
+ return false;
2477
+ }
2478
+ const symbol = checker.getSymbolAtLocation(tsNode);
2479
+ if (!symbol) {
2480
+ return false;
2481
+ }
2482
+ return !!(symbol.flags & ts.SymbolFlags.GetAccessor);
2483
+ }
2484
+ catch {
2485
+ return false;
2486
+ }
2487
+ }
2488
+ /**
2489
+ * Walks `scopeNode` and collects all signal reads and writes within it,
2490
+ * NOT recursing into nested `untracked(...)` calls (their contents are
2491
+ * already hidden from tracking).
2492
+ */
2493
+ function collectSignalUsages(scopeNode, checker, esTreeNodeToTSNodeMap, program) {
2494
+ const reads = [];
2495
+ const writes = [];
2496
+ walkSynchronousAst(scopeNode, (node) => {
2497
+ if (node.type !== AST_NODE_TYPES.CallExpression) {
2498
+ return;
2499
+ }
2500
+ // Skip contents of nested untracked — they are already isolated
2501
+ if (isAngularUntrackedCall(node, program)) {
2502
+ return false;
2503
+ }
2504
+ if (isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap)) {
2505
+ writes.push(node);
2506
+ for (const arg of node.arguments) {
2507
+ walkSynchronousAst(arg, (inner) => {
2508
+ if (inner.type === AST_NODE_TYPES.CallExpression &&
2509
+ isSignalReadCall(inner, checker, esTreeNodeToTSNodeMap)) {
2510
+ reads.push(inner);
2511
+ }
2512
+ });
2513
+ }
2514
+ return false;
2515
+ }
2516
+ if (isSignalReadCall(node, checker, esTreeNodeToTSNodeMap)) {
2517
+ reads.push(node);
2518
+ }
2519
+ return;
2520
+ });
2521
+ return { reads, writes };
2522
+ }
2523
+
2524
+ const ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL = 'https://angular.dev/guide/signals#reading-without-tracking-dependencies';
2525
+ const ANGULAR_SIGNALS_ASYNC_GUIDE_URL = 'https://angular.dev/guide/signals#reactive-context-and-async-operations';
2526
+ const UNTRACKED_RULES_README_URL = 'https://github.com/taiga-family/taiga-ui/blob/main/projects/eslint-plugin-experience-next/README.md';
2527
+ const createUntrackedRule = ESLintUtils.RuleCreator((name) => `${UNTRACKED_RULES_README_URL}#${name}`);
2528
+
2529
+ /**
2530
+ * Collects signal reads that appear inside `untracked(...)` callbacks within
2531
+ * `root`, without descending into nested `untracked(...)` scopes.
2532
+ */
2533
+ function collectReadsInsideUntracked(root, checker, esTreeNodeToTSNodeMap, program) {
2534
+ const reads = [];
2535
+ walkSynchronousAst(root, (node) => {
2536
+ if (node.type !== AST_NODE_TYPES.CallExpression) {
2537
+ return;
2538
+ }
2539
+ if (!isAngularUntrackedCall(node, program)) {
2540
+ return;
2541
+ }
2542
+ const [arg] = node.arguments;
2543
+ if (!arg ||
2544
+ (arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
2545
+ arg.type !== AST_NODE_TYPES.FunctionExpression)) {
2546
+ return false;
2547
+ }
2548
+ walkSynchronousAst(arg, (inner) => {
2549
+ if (inner.type === AST_NODE_TYPES.CallExpression &&
2550
+ isSignalReadCall(inner, checker, esTreeNodeToTSNodeMap)) {
2551
+ reads.push(inner);
2552
+ }
2553
+ });
2554
+ return false; // Do not descend into nested untracked from the outer walk
2555
+ });
2556
+ return reads;
2557
+ }
2558
+ const rule$e = createUntrackedRule({
2559
+ create(context) {
2560
+ const parserServices = ESLintUtils.getParserServices(context);
2561
+ const checker = parserServices.program.getTypeChecker();
2562
+ const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
2563
+ const { sourceCode } = context;
2564
+ const program = sourceCode.ast;
2565
+ return {
2566
+ CallExpression(node) {
2567
+ for (const scope of getReactiveScopes(node, program)) {
2568
+ const { reads: trackedReads } = collectSignalUsages(scope.callback, checker, esTreeNodeToTSNodeMap, program);
2569
+ if (trackedReads.length > 0) {
2570
+ continue;
2571
+ }
2572
+ const untrackedReads = collectReadsInsideUntracked(scope.callback, checker, esTreeNodeToTSNodeMap, program);
2573
+ if (untrackedReads.length === 0) {
2574
+ continue;
2575
+ }
2576
+ context.report({
2577
+ data: { kind: scope.kind },
2578
+ messageId: 'noTrackedReads',
2579
+ node: scope.reportNode,
2580
+ });
2581
+ }
2582
+ },
2583
+ };
2584
+ },
2585
+ meta: {
2586
+ docs: {
2587
+ description: 'Disallow reactive callbacks where all signal reads are inside `untracked()`, leaving the callback without tracked dependencies',
2588
+ url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
2589
+ },
2590
+ messages: {
2591
+ noTrackedReads: 'This `{{ kind }}` callback has no tracked signal reads: every signal read is hidden inside `untracked()`, so changes will not re-run it. Move reads that should create dependencies outside `untracked()`. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
2592
+ },
2593
+ schema: [],
2594
+ type: 'problem',
2595
+ },
2596
+ name: 'no-fully-untracked-effect',
2597
+ });
2598
+
2065
2599
  const MESSAGE_ID$3 = 'no-href-with-router-link';
2066
2600
  const ERROR_MESSAGE$1 = 'Do not use href and routerLink attributes together on the same element';
2067
2601
  const config$1 = {
@@ -2108,7 +2642,7 @@ const config$1 = {
2108
2642
  };
2109
2643
 
2110
2644
  const createRule$a = ESLintUtils.RuleCreator((name) => name);
2111
- const rule$8 = createRule$a({
2645
+ const rule$d = createRule$a({
2112
2646
  create(context) {
2113
2647
  const checkImplicitPublic = (node) => {
2114
2648
  const classRef = getClass(node);
@@ -2181,7 +2715,7 @@ function getClass(node) {
2181
2715
 
2182
2716
  const createRule$9 = ESLintUtils.RuleCreator((name) => name);
2183
2717
  const LEGACY_PEER_DEPS_PATTERN = /^legacy-peer-deps\s*=\s*true$/i;
2184
- const rule$7 = createRule$9({
2718
+ const rule$c = createRule$9({
2185
2719
  create(context) {
2186
2720
  return {
2187
2721
  Program(node) {
@@ -2220,7 +2754,7 @@ const rule$7 = createRule$9({
2220
2754
  });
2221
2755
 
2222
2756
  const createRule$8 = ESLintUtils.RuleCreator((name) => name);
2223
- const rule$6 = createRule$8({
2757
+ const rule$b = createRule$8({
2224
2758
  create(context) {
2225
2759
  const services = ESLintUtils.getParserServices(context);
2226
2760
  const checker = services.program.getTypeChecker();
@@ -2411,7 +2945,7 @@ function collectArrayExpressions(node) {
2411
2945
  }
2412
2946
  return result;
2413
2947
  }
2414
- const rule$5 = createRule$7({
2948
+ const rule$a = createRule$7({
2415
2949
  create(context) {
2416
2950
  const parserServices = ESLintUtils.getParserServices(context);
2417
2951
  const typeChecker = parserServices.program.getTypeChecker();
@@ -2507,6 +3041,54 @@ const rule$5 = createRule$7({
2507
3041
  name: 'no-redundant-type-annotation',
2508
3042
  });
2509
3043
 
3044
+ const rule$9 = createUntrackedRule({
3045
+ create(context) {
3046
+ const parserServices = ESLintUtils.getParserServices(context);
3047
+ const checker = parserServices.program.getTypeChecker();
3048
+ const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
3049
+ const { sourceCode } = context;
3050
+ const program = sourceCode.ast;
3051
+ return {
3052
+ CallExpression(node) {
3053
+ for (const scope of getReactiveScopes(node, program)) {
3054
+ const reported = new Set();
3055
+ walkAfterAsyncBoundaryAst(scope.callback, (inner) => {
3056
+ if (inner.type !== AST_NODE_TYPES.CallExpression ||
3057
+ !isSignalReadCall(inner, checker, esTreeNodeToTSNodeMap)) {
3058
+ return;
3059
+ }
3060
+ const key = String(inner.range);
3061
+ if (reported.has(key)) {
3062
+ return;
3063
+ }
3064
+ reported.add(key);
3065
+ context.report({
3066
+ data: {
3067
+ kind: scope.kind,
3068
+ name: sourceCode.getText(inner),
3069
+ },
3070
+ messageId: 'readAfterAwait',
3071
+ node: inner,
3072
+ });
3073
+ });
3074
+ }
3075
+ },
3076
+ };
3077
+ },
3078
+ meta: {
3079
+ docs: {
3080
+ description: 'Disallow signal reads that occur after `await` inside reactive callbacks, because Angular no longer tracks them as dependencies',
3081
+ url: ANGULAR_SIGNALS_ASYNC_GUIDE_URL,
3082
+ },
3083
+ messages: {
3084
+ readAfterAwait: '`{{ name }}` is read after `await` inside `{{ kind }}`. Angular only tracks synchronous signal reads, so this dependency will not be tracked. Read it before `await` and store the snapshot. See Angular guide: https://angular.dev/guide/signals#reactive-context-and-async-operations',
3085
+ },
3086
+ schema: [],
3087
+ type: 'problem',
3088
+ },
3089
+ name: 'no-signal-reads-after-await-in-reactive-context',
3090
+ });
3091
+
2510
3092
  const createRule$6 = ESLintUtils.RuleCreator((name) => name);
2511
3093
  function isStringLiteral(node) {
2512
3094
  return (node.type === AST_NODE_TYPES.Literal &&
@@ -2571,7 +3153,7 @@ function hasTemplateLiteralAncestor(node) {
2571
3153
  }
2572
3154
  return false;
2573
3155
  }
2574
- const rule$4 = createRule$6({
3156
+ const rule$8 = createRule$6({
2575
3157
  create(context) {
2576
3158
  const { sourceCode } = context;
2577
3159
  let parserServices = null;
@@ -2630,49 +3212,620 @@ const rule$4 = createRule$6({
2630
3212
  return;
2631
3213
  }
2632
3214
  context.report({
2633
- fix: (fixer) => fixer.replaceText(node, allLiterals
2634
- ? buildMergedString(parts)
2635
- : `\`${partsToTemplateContent(parts, getText)}\``),
2636
- messageId: allLiterals ? 'mergeLiterals' : 'useTemplate',
2637
- node,
3215
+ fix: (fixer) => fixer.replaceText(node, allLiterals
3216
+ ? buildMergedString(parts)
3217
+ : `\`${partsToTemplateContent(parts, getText)}\``),
3218
+ messageId: allLiterals ? 'mergeLiterals' : 'useTemplate',
3219
+ node,
3220
+ });
3221
+ },
3222
+ TemplateLiteral(node) {
3223
+ // Tagged templates: changing quasis/expressions count alters behaviour
3224
+ if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) {
3225
+ return;
3226
+ }
3227
+ for (const [i, expr] of node.expressions.entries()) {
3228
+ if (expr.type === AST_NODE_TYPES.TemplateLiteral &&
3229
+ expr.parent.type !== AST_NODE_TYPES.TaggedTemplateExpression) {
3230
+ context.report({
3231
+ fix: (fixer) => fixer.replaceText(node, `\`${templateContent(node, (e, j) => (j === i ? templateContent(expr, wrapExpr) : wrapExpr(e)))}\``),
3232
+ messageId: 'flattenTemplate',
3233
+ node: expr,
3234
+ });
3235
+ }
3236
+ }
3237
+ },
3238
+ };
3239
+ },
3240
+ meta: {
3241
+ docs: {
3242
+ description: 'Disallow string concatenation. Merge adjacent string literals into one; use template literals for string variables.',
3243
+ },
3244
+ fixable: 'code',
3245
+ messages: {
3246
+ flattenTemplate: 'Flatten nested template literal into its parent.',
3247
+ mergeLiterals: 'Merge string literals instead of concatenating them.',
3248
+ useTemplate: 'Use a template literal instead of string concatenation.',
3249
+ },
3250
+ schema: [],
3251
+ type: 'suggestion',
3252
+ },
3253
+ name: 'no-string-literal-concat',
3254
+ });
3255
+
3256
+ /** Returns the local alias for `untracked` from `@angular/core`, or null if not imported. */
3257
+ function findUntrackedAlias(program) {
3258
+ return getLocalNameForImport(program, '@angular/core', 'untracked');
3259
+ }
3260
+ /**
3261
+ * Builds fixer actions that add `untracked` to an existing `@angular/core` import,
3262
+ * or insert a new import declaration when none exists.
3263
+ */
3264
+ function buildUntrackedImportFixes(program, fixer) {
3265
+ const coreImport = findRuntimeAngularCoreImport(program);
3266
+ if (!coreImport) {
3267
+ const firstStatement = program.body[0];
3268
+ if (!firstStatement) {
3269
+ return [];
3270
+ }
3271
+ return [
3272
+ fixer.insertTextBefore(firstStatement, "import { untracked } from '@angular/core';\n"),
3273
+ ];
3274
+ }
3275
+ const namedSpecifiers = coreImport.specifiers.filter((specifier) => specifier.type === AST_NODE_TYPES.ImportSpecifier);
3276
+ if (namedSpecifiers.length > 0) {
3277
+ return [
3278
+ fixer.insertTextAfter(namedSpecifiers[namedSpecifiers.length - 1], ', untracked'),
3279
+ ];
3280
+ }
3281
+ const defaultImport = coreImport.specifiers.find((specifier) => specifier.type === AST_NODE_TYPES.ImportDefaultSpecifier);
3282
+ if (defaultImport) {
3283
+ return [fixer.insertTextAfter(defaultImport, ', { untracked }')];
3284
+ }
3285
+ return [
3286
+ fixer.insertTextAfter(coreImport, "\nimport { untracked } from '@angular/core';"),
3287
+ ];
3288
+ }
3289
+ /**
3290
+ * Removes the `untracked` import specifier from `@angular/core`.
3291
+ * When it is the last specifier, removes the entire declaration.
3292
+ */
3293
+ function buildImportRemovalFixes(program, fixer, sourceCode) {
3294
+ const match = findAngularCoreImportSpecifier(program, 'untracked');
3295
+ if (!match) {
3296
+ return [];
3297
+ }
3298
+ const { importDecl, specifier: untrackedSpec } = match;
3299
+ const namedSpecifiers = importDecl.specifiers.filter((s) => s.type === AST_NODE_TYPES.ImportSpecifier);
3300
+ if (importDecl.specifiers.length === 1) {
3301
+ return [fixer.remove(importDecl)];
3302
+ }
3303
+ const importText = sourceCode.getText(importDecl);
3304
+ const importStart = importDecl.range[0];
3305
+ const specStart = untrackedSpec.range[0];
3306
+ const specEnd = untrackedSpec.range[1];
3307
+ // Try to remove a trailing comma
3308
+ const textAfter = importText.slice(specEnd - importStart);
3309
+ const trailingComma = /^(\s*,)/.exec(textAfter);
3310
+ if (trailingComma) {
3311
+ return [fixer.removeRange([specStart, specEnd + trailingComma[1].length])];
3312
+ }
3313
+ // Try to remove a leading comma (last specifier)
3314
+ const textBefore = importText.slice(0, specStart - importStart);
3315
+ const leadingCommaIdx = textBefore.lastIndexOf(',');
3316
+ if (leadingCommaIdx !== -1) {
3317
+ return [fixer.removeRange([importStart + leadingCommaIdx, specEnd])];
3318
+ }
3319
+ if (namedSpecifiers.length === 1) {
3320
+ return [fixer.remove(untrackedSpec)];
3321
+ }
3322
+ return [fixer.remove(untrackedSpec)];
3323
+ }
3324
+
3325
+ const IMPERATIVE_UNTRACKED_METHODS = new Set(['registerOnChange', 'writeValue']);
3326
+ function dedent$1(text, extraSpaces) {
3327
+ if (extraSpaces <= 0) {
3328
+ return text;
3329
+ }
3330
+ const prefix = ' '.repeat(extraSpaces);
3331
+ return text
3332
+ .split('\n')
3333
+ .map((line) => (line.startsWith(prefix) ? line.slice(extraSpaces) : line))
3334
+ .join('\n');
3335
+ }
3336
+ function isFunctionLike(node) {
3337
+ return (node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
3338
+ node.type === AST_NODE_TYPES.FunctionDeclaration ||
3339
+ node.type === AST_NODE_TYPES.FunctionExpression);
3340
+ }
3341
+ function getObjectPropertyName(node) {
3342
+ if (node.computed) {
3343
+ return null;
3344
+ }
3345
+ if (node.key.type === AST_NODE_TYPES.Identifier) {
3346
+ return node.key.name;
3347
+ }
3348
+ return typeof node.key.value === 'string' ? node.key.value : null;
3349
+ }
3350
+ function getEnclosingClassMember(node) {
3351
+ for (let current = node.parent; current; current = current.parent) {
3352
+ if (current.type === AST_NODE_TYPES.MethodDefinition ||
3353
+ current.type === AST_NODE_TYPES.PropertyDefinition) {
3354
+ return current;
3355
+ }
3356
+ }
3357
+ return null;
3358
+ }
3359
+ function getEnclosingFunction(node) {
3360
+ for (let current = node.parent; current; current = current.parent) {
3361
+ if (isFunctionLike(current)) {
3362
+ return current;
3363
+ }
3364
+ }
3365
+ return null;
3366
+ }
3367
+ function getClassMemberName(member) {
3368
+ if (member.key.type === AST_NODE_TYPES.Identifier) {
3369
+ return member.key.name;
3370
+ }
3371
+ if (member.key.type === AST_NODE_TYPES.Literal &&
3372
+ typeof member.key.value === 'string') {
3373
+ return member.key.value;
3374
+ }
3375
+ return null;
3376
+ }
3377
+ function hasNamedDecorator(node, name) {
3378
+ return node.decorators.some((decorator) => {
3379
+ const expression = decorator.expression;
3380
+ if (expression.type === AST_NODE_TYPES.Identifier) {
3381
+ return expression.name === name;
3382
+ }
3383
+ return (expression.type === AST_NODE_TYPES.CallExpression &&
3384
+ expression.callee.type === AST_NODE_TYPES.Identifier &&
3385
+ expression.callee.name === name);
3386
+ });
3387
+ }
3388
+ function isPipeTransformMember(member) {
3389
+ if (getClassMemberName(member) !== 'transform') {
3390
+ return false;
3391
+ }
3392
+ return hasNamedDecorator(member.parent.parent, 'Pipe');
3393
+ }
3394
+ function isAllowedImperativeAngularContext(node) {
3395
+ const member = getEnclosingClassMember(node);
3396
+ const memberName = member ? getClassMemberName(member) : null;
3397
+ if (!member || !memberName) {
3398
+ return false;
3399
+ }
3400
+ return IMPERATIVE_UNTRACKED_METHODS.has(memberName) || isPipeTransformMember(member);
3401
+ }
3402
+ function isDirectCallbackArgument(fn) {
3403
+ const parent = fn.parent;
3404
+ if (fn.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
3405
+ fn.type !== AST_NODE_TYPES.FunctionExpression) {
3406
+ return false;
3407
+ }
3408
+ return ((parent.type === AST_NODE_TYPES.CallExpression ||
3409
+ parent.type === AST_NODE_TYPES.NewExpression) &&
3410
+ parent.arguments.includes(fn));
3411
+ }
3412
+ function getScopeRoot(node) {
3413
+ for (let current = node.parent; current; current = current.parent) {
3414
+ if (current.type === AST_NODE_TYPES.Program || isFunctionLike(current)) {
3415
+ return current;
3416
+ }
3417
+ }
3418
+ return node;
3419
+ }
3420
+ function isStoredCallbackUsedAsArgument(fn, checker, esTreeNodeToTSNodeMap) {
3421
+ const parent = fn.parent;
3422
+ if (parent.type !== AST_NODE_TYPES.VariableDeclarator ||
3423
+ parent.id.type !== AST_NODE_TYPES.Identifier) {
3424
+ return false;
3425
+ }
3426
+ const id = parent.id;
3427
+ const tsNode = esTreeNodeToTSNodeMap.get(id);
3428
+ if (!tsNode) {
3429
+ return false;
3430
+ }
3431
+ const symbol = checker.getSymbolAtLocation(tsNode);
3432
+ if (!symbol) {
3433
+ return false;
3434
+ }
3435
+ let found = false;
3436
+ const scopeRoot = getScopeRoot(parent);
3437
+ walkAst(scopeRoot, (node) => {
3438
+ if (node.type !== AST_NODE_TYPES.Identifier ||
3439
+ node === id ||
3440
+ node.name !== id.name) {
3441
+ return;
3442
+ }
3443
+ const referenceTsNode = esTreeNodeToTSNodeMap.get(node);
3444
+ const referenceSymbol = referenceTsNode
3445
+ ? checker.getSymbolAtLocation(referenceTsNode)
3446
+ : null;
3447
+ if (referenceSymbol !== symbol) {
3448
+ return;
3449
+ }
3450
+ const usageParent = node.parent;
3451
+ if ((usageParent.type === AST_NODE_TYPES.CallExpression ||
3452
+ usageParent.type === AST_NODE_TYPES.NewExpression) &&
3453
+ usageParent.arguments.includes(node)) {
3454
+ found = true;
3455
+ return false;
3456
+ }
3457
+ return;
3458
+ });
3459
+ return found;
3460
+ }
3461
+ function isAllowedDeferredCallbackContext(node, checker, esTreeNodeToTSNodeMap) {
3462
+ const [arg] = node.arguments;
3463
+ if (!arg ||
3464
+ arg.type === AST_NODE_TYPES.SpreadElement ||
3465
+ (arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
3466
+ arg.type !== AST_NODE_TYPES.FunctionExpression)) {
3467
+ return false;
3468
+ }
3469
+ const fn = getEnclosingFunction(node);
3470
+ if (!fn) {
3471
+ return false;
3472
+ }
3473
+ return (isDirectCallbackArgument(fn) ||
3474
+ isStoredCallbackUsedAsArgument(fn, checker, esTreeNodeToTSNodeMap));
3475
+ }
3476
+ function isAngularInjectionTokenFactoryFunction(fn, program) {
3477
+ const parent = fn.parent;
3478
+ const injectionTokenName = getLocalNameForImport(program, '@angular/core', 'InjectionToken');
3479
+ if (!injectionTokenName ||
3480
+ parent.type !== AST_NODE_TYPES.Property ||
3481
+ getObjectPropertyName(parent) !== 'factory') {
3482
+ return false;
3483
+ }
3484
+ const objectExpression = parent.parent;
3485
+ return (objectExpression.type === AST_NODE_TYPES.ObjectExpression &&
3486
+ objectExpression.parent.type === AST_NODE_TYPES.NewExpression &&
3487
+ objectExpression.parent.arguments.includes(objectExpression) &&
3488
+ objectExpression.parent.callee.type === AST_NODE_TYPES.Identifier &&
3489
+ objectExpression.parent.callee.name === injectionTokenName);
3490
+ }
3491
+ function isAngularUseFactoryFunction(fn) {
3492
+ const parent = fn.parent;
3493
+ if (parent.type !== AST_NODE_TYPES.Property ||
3494
+ getObjectPropertyName(parent) !== 'useFactory') {
3495
+ return false;
3496
+ }
3497
+ const objectExpression = parent.parent;
3498
+ return (objectExpression.type === AST_NODE_TYPES.ObjectExpression &&
3499
+ objectExpression.properties.some((property) => property.type === AST_NODE_TYPES.Property &&
3500
+ getObjectPropertyName(property) === 'provide'));
3501
+ }
3502
+ function isReactiveOwnerCall(node, program) {
3503
+ return (node.type === AST_NODE_TYPES.CallExpression &&
3504
+ getReactiveScopes(node, program).length > 0);
3505
+ }
3506
+ function getFixableReactiveCall(node, program) {
3507
+ const [arg] = node.arguments;
3508
+ if (!arg ||
3509
+ arg.type === AST_NODE_TYPES.SpreadElement ||
3510
+ (arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
3511
+ arg.type !== AST_NODE_TYPES.FunctionExpression)) {
3512
+ return null;
3513
+ }
3514
+ if (isReactiveOwnerCall(arg.body, program)) {
3515
+ return arg.body;
3516
+ }
3517
+ if (arg.body.type !== AST_NODE_TYPES.BlockStatement || arg.body.body.length !== 1) {
3518
+ return null;
3519
+ }
3520
+ const [statement] = arg.body.body;
3521
+ if (statement?.type === AST_NODE_TYPES.ReturnStatement &&
3522
+ statement.argument &&
3523
+ isReactiveOwnerCall(statement.argument, program)) {
3524
+ return statement.argument;
3525
+ }
3526
+ if (node.parent.type === AST_NODE_TYPES.ExpressionStatement &&
3527
+ statement?.type === AST_NODE_TYPES.ExpressionStatement &&
3528
+ isReactiveOwnerCall(statement.expression, program)) {
3529
+ return statement.expression;
3530
+ }
3531
+ return null;
3532
+ }
3533
+ function isAllowedLazyAngularFactoryContext(node, program) {
3534
+ const fn = getEnclosingFunction(node);
3535
+ if (!fn || !getFixableReactiveCall(node, program)) {
3536
+ return false;
3537
+ }
3538
+ return (isAngularInjectionTokenFactoryFunction(fn, program) ||
3539
+ isAngularUseFactoryFunction(fn));
3540
+ }
3541
+ function buildReactiveCallReplacement(outerUntrackedCall, reactiveCall, sourceCode) {
3542
+ const text = sourceCode.getText(reactiveCall);
3543
+ if (reactiveCall.parent.type !== AST_NODE_TYPES.ExpressionStatement ||
3544
+ outerUntrackedCall.parent.type !== AST_NODE_TYPES.ExpressionStatement) {
3545
+ return text;
3546
+ }
3547
+ return dedent$1(text, reactiveCall.loc.start.column - outerUntrackedCall.parent.loc.start.column);
3548
+ }
3549
+ const rule$7 = createUntrackedRule({
3550
+ create(context) {
3551
+ const parserServices = ESLintUtils.getParserServices(context);
3552
+ const checker = parserServices.program.getTypeChecker();
3553
+ const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
3554
+ const { sourceCode } = context;
3555
+ const program = sourceCode.ast;
3556
+ const getUntrackedLocalName = () => findUntrackedAlias(program);
3557
+ function isUntrackedUsedElsewhere(localName, excludeNode) {
3558
+ let found = false;
3559
+ walkAst(program, (node) => {
3560
+ if (node.type === AST_NODE_TYPES.CallExpression &&
3561
+ node !== excludeNode &&
3562
+ node.callee.type === AST_NODE_TYPES.Identifier &&
3563
+ node.callee.name === localName) {
3564
+ found = true;
3565
+ return false;
3566
+ }
3567
+ return;
3568
+ });
3569
+ return found;
3570
+ }
3571
+ return {
3572
+ CallExpression(node) {
3573
+ if (!isAngularUntrackedCall(node, program)) {
3574
+ return;
3575
+ }
3576
+ if (findEnclosingReactiveScope(node, program)) {
3577
+ return;
3578
+ }
3579
+ if (isAllowedImperativeAngularContext(node)) {
3580
+ return;
3581
+ }
3582
+ if (isAllowedDeferredCallbackContext(node, checker, esTreeNodeToTSNodeMap)) {
3583
+ return;
3584
+ }
3585
+ if (isAllowedLazyAngularFactoryContext(node, program)) {
3586
+ return;
3587
+ }
3588
+ const reactiveCall = getFixableReactiveCall(node, program);
3589
+ context.report({
3590
+ fix: reactiveCall
3591
+ ? (fixer) => {
3592
+ const fixes = [
3593
+ fixer.replaceText(node, buildReactiveCallReplacement(node, reactiveCall, sourceCode)),
3594
+ ];
3595
+ const untrackedLocalName = getUntrackedLocalName();
3596
+ const stillUsed = untrackedLocalName !== null &&
3597
+ isUntrackedUsedElsewhere(untrackedLocalName, node);
3598
+ if (!stillUsed) {
3599
+ fixes.push(...buildImportRemovalFixes(program, fixer, sourceCode));
3600
+ }
3601
+ return fixes;
3602
+ }
3603
+ : undefined,
3604
+ messageId: 'outsideReactiveContext',
3605
+ node: node.callee.type === AST_NODE_TYPES.Identifier
3606
+ ? node.callee
3607
+ : node,
2638
3608
  });
2639
3609
  },
2640
- TemplateLiteral(node) {
2641
- // Tagged templates: changing quasis/expressions count alters behaviour
2642
- if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) {
3610
+ };
3611
+ },
3612
+ meta: {
3613
+ docs: {
3614
+ description: 'Disallow `untracked()` outside the synchronous body of a reactive callback, except for supported imperative Angular hooks, deferred callback wrappers, and lazy DI factories that may execute under an ambient reactive context',
3615
+ url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
3616
+ },
3617
+ fixable: 'code',
3618
+ messages: {
3619
+ outsideReactiveContext: '`untracked()` is used outside the synchronous body of a reactive callback and outside the supported imperative/deferred/lazy-factory exceptions, so it does not prevent dependency tracking and only adds noise. Remove it. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
3620
+ },
3621
+ schema: [],
3622
+ type: 'problem',
3623
+ },
3624
+ name: 'no-untracked-outside-reactive-context',
3625
+ });
3626
+
3627
+ /**
3628
+ * Removes `extraSpaces` leading spaces from every line of `text` that starts
3629
+ * with at least that many spaces.
3630
+ */
3631
+ function dedent(text, extraSpaces) {
3632
+ if (extraSpaces <= 0) {
3633
+ return text;
3634
+ }
3635
+ const prefix = ' '.repeat(extraSpaces);
3636
+ return text
3637
+ .split('\n')
3638
+ .map((line) => (line.startsWith(prefix) ? line.slice(extraSpaces) : line))
3639
+ .join('\n');
3640
+ }
3641
+ /**
3642
+ * Builds the replacement text for the parent ExpressionStatement of an
3643
+ * `untracked(...)` call.
3644
+ *
3645
+ * - Block body: extracts inner statements and re-indents them to match the
3646
+ * parent ExpressionStatement's indentation.
3647
+ * - Expression body: returns `<expr>;` (no trailing double-semicolon).
3648
+ *
3649
+ * Returns null if the untracked argument is not a function expression.
3650
+ */
3651
+ function buildReplacement(untrackedCall, parentStatement, sourceCode) {
3652
+ const [arg] = untrackedCall.arguments;
3653
+ if (!arg ||
3654
+ (arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
3655
+ arg.type !== AST_NODE_TYPES.FunctionExpression)) {
3656
+ return null;
3657
+ }
3658
+ const { body } = arg;
3659
+ if (body.type === AST_NODE_TYPES.BlockStatement) {
3660
+ const { body: stmts } = body;
3661
+ if (stmts.length === 0) {
3662
+ return null; // Nothing to replace with — let caller delete the statement
3663
+ }
3664
+ const parentColumn = parentStatement.loc.start.column;
3665
+ const firstStmtColumn = stmts[0].loc.start.column;
3666
+ const extra = firstStmtColumn - parentColumn;
3667
+ const indented = stmts.map((s) => dedent(sourceCode.getText(s), extra));
3668
+ return indented.join(`\n${''.padStart(parentColumn)}`);
3669
+ }
3670
+ // Expression body: arrow `() => expr` — just emit `expr;`
3671
+ return `${sourceCode.getText(body)};`;
3672
+ }
3673
+ function getAllCallExpressions(root) {
3674
+ const result = [];
3675
+ walkAst(root, (node) => {
3676
+ if (node.type === AST_NODE_TYPES.CallExpression) {
3677
+ result.push(node);
3678
+ }
3679
+ });
3680
+ return result;
3681
+ }
3682
+ function hasOpaqueSynchronousCalls(root, checker, esTreeNodeToTSNodeMap, program) {
3683
+ let found = false;
3684
+ walkSynchronousAst(root, (node) => {
3685
+ if (node.type === AST_NODE_TYPES.MemberExpression) {
3686
+ if (isGetterMemberAccess(node, checker, esTreeNodeToTSNodeMap)) {
3687
+ found = true;
3688
+ return false;
3689
+ }
3690
+ return;
3691
+ }
3692
+ if (node.type !== AST_NODE_TYPES.CallExpression) {
3693
+ return;
3694
+ }
3695
+ if (isAngularUntrackedCall(node, program) ||
3696
+ isSignalReadCall(node, checker, esTreeNodeToTSNodeMap) ||
3697
+ isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap)) {
3698
+ return;
3699
+ }
3700
+ found = true;
3701
+ return false;
3702
+ });
3703
+ return found;
3704
+ }
3705
+ const rule$6 = createUntrackedRule({
3706
+ create(context) {
3707
+ const parserServices = ESLintUtils.getParserServices(context);
3708
+ const checker = parserServices.program.getTypeChecker();
3709
+ const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
3710
+ const { sourceCode } = context;
3711
+ const program = sourceCode.ast;
3712
+ const getUntrackedLocalName = () => getLocalNameForImport(program, '@angular/core', 'untracked');
3713
+ function isUntrackedUsedElsewhere(localName, excludeNode) {
3714
+ return getAllCallExpressions(program).some((n) => n !== excludeNode &&
3715
+ n.callee.type === AST_NODE_TYPES.Identifier &&
3716
+ n.callee.name === localName);
3717
+ }
3718
+ function checkUntrackedCall(untrackedCall, kind) {
3719
+ const [arg] = untrackedCall.arguments;
3720
+ if (!arg ||
3721
+ (arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
3722
+ arg.type !== AST_NODE_TYPES.FunctionExpression)) {
3723
+ return;
3724
+ }
3725
+ const { reads } = collectSignalUsages(arg, checker, esTreeNodeToTSNodeMap, program);
3726
+ if (reads.length > 0) {
3727
+ // Snapshot reads inside reactive callbacks are a valid Angular
3728
+ // pattern even when the snapshot later influences branching.
3729
+ return;
3730
+ }
3731
+ if (hasOpaqueSynchronousCalls(arg, checker, esTreeNodeToTSNodeMap, program)) {
3732
+ return;
3733
+ }
3734
+ // Only fix when the parent is a plain ExpressionStatement so we can
3735
+ // replace statement-for-statement without breaking surrounding structure.
3736
+ const parent = untrackedCall.parent;
3737
+ const canFix = parent.type === AST_NODE_TYPES.ExpressionStatement;
3738
+ context.report({
3739
+ data: { kind },
3740
+ fix: canFix
3741
+ ? (fixer) => {
3742
+ const parentStmt = parent;
3743
+ const replacement = buildReplacement(untrackedCall, parentStmt, sourceCode);
3744
+ if (replacement === null) {
3745
+ return null;
3746
+ }
3747
+ const untrackedLocalName = getUntrackedLocalName();
3748
+ const stillUsed = untrackedLocalName !== null &&
3749
+ isUntrackedUsedElsewhere(untrackedLocalName, untrackedCall);
3750
+ const fixes = [fixer.replaceText(parentStmt, replacement)];
3751
+ if (!stillUsed) {
3752
+ fixes.push(...buildImportRemovalFixes(program, fixer, sourceCode));
3753
+ }
3754
+ return fixes;
3755
+ }
3756
+ : undefined,
3757
+ messageId: 'uselessUntracked',
3758
+ node: untrackedCall,
3759
+ });
3760
+ }
3761
+ function walkForUntracked(root, kind) {
3762
+ walkSynchronousAst(root, (node) => {
3763
+ if (node.type !== AST_NODE_TYPES.CallExpression ||
3764
+ !isAngularUntrackedCall(node, program)) {
2643
3765
  return;
2644
3766
  }
2645
- for (const [i, expr] of node.expressions.entries()) {
2646
- if (expr.type === AST_NODE_TYPES.TemplateLiteral &&
2647
- expr.parent.type !== AST_NODE_TYPES.TaggedTemplateExpression) {
2648
- context.report({
2649
- fix: (fixer) => fixer.replaceText(node, `\`${templateContent(node, (e, j) => (j === i ? templateContent(expr, wrapExpr) : wrapExpr(e)))}\``),
2650
- messageId: 'flattenTemplate',
2651
- node: expr,
2652
- });
2653
- }
3767
+ checkUntrackedCall(node, kind);
3768
+ // Do not descend — nested untracked bodies are separate scopes
3769
+ return false;
3770
+ });
3771
+ }
3772
+ return {
3773
+ CallExpression(node) {
3774
+ for (const scope of getReactiveScopes(node, program)) {
3775
+ walkForUntracked(scope.callback, scope.kind);
2654
3776
  }
2655
3777
  },
2656
3778
  };
2657
3779
  },
2658
3780
  meta: {
2659
3781
  docs: {
2660
- description: 'Disallow string concatenation. Merge adjacent string literals into one; use template literals for string variables.',
3782
+ description: 'Disallow `untracked()` wrappers inside reactive callbacks when they contain no signal reads and do not wrap opaque external code that may read signals',
3783
+ url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
2661
3784
  },
2662
3785
  fixable: 'code',
2663
3786
  messages: {
2664
- flattenTemplate: 'Flatten nested template literal into its parent.',
2665
- mergeLiterals: 'Merge string literals instead of concatenating them.',
2666
- useTemplate: 'Use a template literal instead of string concatenation.',
3787
+ uselessUntracked: 'This `untracked()` wrapper is unnecessary inside `{{ kind }}` because its callback contains no signal reads that need shielding from dependency tracking. Pure writes (`.set()`, `.update()`, `.mutate()`) do not require `untracked()`. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
2667
3788
  },
2668
3789
  schema: [],
2669
3790
  type: 'suggestion',
2670
3791
  },
2671
- name: 'no-string-literal-concat',
3792
+ name: 'no-useless-untracked',
2672
3793
  });
2673
3794
 
3795
+ /**
3796
+ * Strips TypeScript-only wrapper nodes that have no runtime meaning:
3797
+ * `as` casts, non-null assertions (`!`), type assertions (`<T>expr`), and
3798
+ * optional-chain wrappers. Iterates until no more wrappers are found.
3799
+ */
3800
+ function unwrapExpression(expression) {
3801
+ let current = expression;
3802
+ let didUnwrap = true;
3803
+ while (didUnwrap) {
3804
+ didUnwrap = false;
3805
+ switch (current.type) {
3806
+ case AST_NODE_TYPES.ChainExpression:
3807
+ current = current.expression;
3808
+ didUnwrap = true;
3809
+ break;
3810
+ case AST_NODE_TYPES.TSAsExpression:
3811
+ current = current.expression;
3812
+ didUnwrap = true;
3813
+ break;
3814
+ case AST_NODE_TYPES.TSNonNullExpression:
3815
+ current = current.expression;
3816
+ didUnwrap = true;
3817
+ break;
3818
+ case AST_NODE_TYPES.TSTypeAssertion:
3819
+ current = current.expression;
3820
+ didUnwrap = true;
3821
+ break;
3822
+ }
3823
+ }
3824
+ return current;
3825
+ }
3826
+
2674
3827
  const createRule$5 = ESLintUtils.RuleCreator((name) => name);
2675
- const rule$3 = createRule$5({
3828
+ const rule$5 = createRule$5({
2676
3829
  create(context, [{ printWidth }]) {
2677
3830
  const sourceCode = context.sourceCode;
2678
3831
  const getLineEndIndex = (lineStartIndex) => {
@@ -2712,32 +3865,6 @@ const rule$3 = createRule$5({
2712
3865
  const [onlyProperty] = node.properties;
2713
3866
  return onlyProperty ? !isForbiddenProperty(onlyProperty) : false;
2714
3867
  };
2715
- const unwrapExpression = (expression) => {
2716
- let current = expression;
2717
- let didUnwrap = true;
2718
- while (didUnwrap) {
2719
- didUnwrap = false;
2720
- switch (current.type) {
2721
- case AST_NODE_TYPES$1.ChainExpression:
2722
- current = current.expression;
2723
- didUnwrap = true;
2724
- break;
2725
- case AST_NODE_TYPES$1.TSAsExpression:
2726
- current = current.expression;
2727
- didUnwrap = true;
2728
- break;
2729
- case AST_NODE_TYPES$1.TSNonNullExpression:
2730
- current = current.expression;
2731
- didUnwrap = true;
2732
- break;
2733
- case AST_NODE_TYPES$1.TSTypeAssertion:
2734
- current = current.expression;
2735
- didUnwrap = true;
2736
- break;
2737
- }
2738
- }
2739
- return current;
2740
- };
2741
3868
  const getParenthesizedInner = (expression) => {
2742
3869
  const anyExpression = expression;
2743
3870
  if (anyExpression?.type === 'ParenthesizedExpression') {
@@ -3316,7 +4443,7 @@ function getPushCall(node) {
3316
4443
  }
3317
4444
  return call;
3318
4445
  }
3319
- const rule$2 = createRule$3({
4446
+ const rule$4 = createRule$3({
3320
4447
  create(context) {
3321
4448
  const { sourceCode } = context;
3322
4449
  function checkBody(statements) {
@@ -3394,6 +4521,398 @@ const rule$2 = createRule$3({
3394
4521
  name: 'prefer-multi-arg-push',
3395
4522
  });
3396
4523
 
4524
+ /**
4525
+ * prefer-untracked-incidental-signal-reads
4526
+ *
4527
+ * Conservatively flags signal reads inside reactive callbacks that appear to be
4528
+ * incidental — they provide only a snapshot value passed to a side-effecting
4529
+ * consumer such as a signal write or DOM imperative call — and therefore
4530
+ * probably should be wrapped in `untracked(...)`.
4531
+ *
4532
+ * Design principle: prefer false-negatives over false-positives.
4533
+ * This rule only reports patterns it can confidently identify as suspicious.
4534
+ *
4535
+ * Limitations
4536
+ * -----------
4537
+ * Whether a signal read *should* be tracked is a question of developer intent,
4538
+ * not syntax. The rule relies on heuristics (type-based signal detection,
4539
+ * structural pattern matching) and will inevitably produce:
4540
+ *
4541
+ * - False negatives: incidental reads that do not match the heuristics.
4542
+ * - False positives (rare): reads that look incidental but are intentional
4543
+ * dependencies (e.g. the developer intentionally re-runs the effect when
4544
+ * `mousePosition` changes to keep `position` in sync).
4545
+ *
4546
+ * Always review suggestions before accepting the fix. If the reported read
4547
+ * IS meant to be a tracked dependency, disable the rule for that line.
4548
+ */
4549
+ const CONSOLE_METHODS = new Set([
4550
+ 'assert',
4551
+ 'debug',
4552
+ 'dir',
4553
+ 'dirxml',
4554
+ 'error',
4555
+ 'group',
4556
+ 'groupCollapsed',
4557
+ 'groupEnd',
4558
+ 'info',
4559
+ 'log',
4560
+ 'table',
4561
+ 'trace',
4562
+ 'warn',
4563
+ ]);
4564
+ const HIGH_CONFIDENCE_DOM_METHODS = new Set(['requestFullscreen']);
4565
+ const LIB_DOM_FILE_PATTERN = /(?:^|[\\/])lib\.dom(?:\.[^\\/]+)?\.d\.ts$/;
4566
+ /** Returns the source text for `untracked(() => signalGetter())`. */
4567
+ function buildUntrackedWrap(expr, sourceCode, untrackedAlias) {
4568
+ return `${untrackedAlias}(() => ${sourceCode.getText(expr.callee)}())`;
4569
+ }
4570
+ function unwrapUsageExpression(node) {
4571
+ let current = node;
4572
+ while (current.parent &&
4573
+ (current.parent.type === AST_NODE_TYPES.ChainExpression ||
4574
+ current.parent.type === AST_NODE_TYPES.TSAsExpression ||
4575
+ current.parent.type === AST_NODE_TYPES.TSNonNullExpression ||
4576
+ current.parent.type === AST_NODE_TYPES.TSTypeAssertion)) {
4577
+ current = current.parent;
4578
+ }
4579
+ return current;
4580
+ }
4581
+ function isNodeInside(node, ancestor) {
4582
+ for (let current = node; current; current = current.parent) {
4583
+ if (current === ancestor) {
4584
+ return true;
4585
+ }
4586
+ }
4587
+ return false;
4588
+ }
4589
+ function isNodeInsideAny(node, ancestors) {
4590
+ return ancestors.some((ancestor) => isNodeInside(node, ancestor));
4591
+ }
4592
+ function isStatementPositionCall(node) {
4593
+ const usage = unwrapUsageExpression(node);
4594
+ if (usage.parent?.type === AST_NODE_TYPES.ExpressionStatement) {
4595
+ return true;
4596
+ }
4597
+ return (usage.parent?.type === AST_NODE_TYPES.AwaitExpression &&
4598
+ usage.parent.parent.type === AST_NODE_TYPES.ExpressionStatement);
4599
+ }
4600
+ function isConsoleMethodCall(node) {
4601
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression ||
4602
+ node.callee.object.type !== AST_NODE_TYPES.Identifier ||
4603
+ node.callee.object.name !== 'console' ||
4604
+ node.callee.property.type !== AST_NODE_TYPES.Identifier) {
4605
+ return false;
4606
+ }
4607
+ return CONSOLE_METHODS.has(node.callee.property.name);
4608
+ }
4609
+ function isDomImperativeCall(node, checker, esTreeNodeToTSNodeMap) {
4610
+ if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
4611
+ node.callee.property.type === AST_NODE_TYPES.Identifier &&
4612
+ HIGH_CONFIDENCE_DOM_METHODS.has(node.callee.property.name)) {
4613
+ return true;
4614
+ }
4615
+ const tsNode = esTreeNodeToTSNodeMap.get(node);
4616
+ if (!tsNode) {
4617
+ return false;
4618
+ }
4619
+ const signature = checker.getResolvedSignature(tsNode);
4620
+ const declaration = signature?.declaration;
4621
+ if (!declaration) {
4622
+ return false;
4623
+ }
4624
+ if (!LIB_DOM_FILE_PATTERN.test(declaration.getSourceFile().fileName)) {
4625
+ return false;
4626
+ }
4627
+ const returnType = checker.typeToString(checker.getReturnTypeOfSignature(signature));
4628
+ return returnType === 'void' || returnType === 'Promise<void>';
4629
+ }
4630
+ function isSuspiciousDomCallArgumentConsumer(node, checker, esTreeNodeToTSNodeMap, program) {
4631
+ if (isAngularUntrackedCall(node, program) ||
4632
+ isSignalReadCall(node, checker, esTreeNodeToTSNodeMap) ||
4633
+ isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap) ||
4634
+ !isStatementPositionCall(node) ||
4635
+ isConsoleMethodCall(node)) {
4636
+ return false;
4637
+ }
4638
+ return isDomImperativeCall(node, checker, esTreeNodeToTSNodeMap);
4639
+ }
4640
+ function isAliasDeclarationIdentifier(node) {
4641
+ return (node.type === AST_NODE_TYPES.Identifier &&
4642
+ node.parent.type === AST_NODE_TYPES.VariableDeclarator &&
4643
+ node.parent.id === node);
4644
+ }
4645
+ function aliasHasExternalUsage(alias, consumers, scope, checker, esTreeNodeToTSNodeMap) {
4646
+ const tsNode = esTreeNodeToTSNodeMap.get(alias);
4647
+ if (!tsNode) {
4648
+ return false;
4649
+ }
4650
+ const symbol = checker.getSymbolAtLocation(tsNode);
4651
+ if (!symbol) {
4652
+ return false;
4653
+ }
4654
+ let usedOutsideConsumers = false;
4655
+ walkSynchronousAst(scope.callback, (node) => {
4656
+ if (node.type !== AST_NODE_TYPES.Identifier ||
4657
+ node === alias ||
4658
+ isAliasDeclarationIdentifier(node) ||
4659
+ isNodeInsideAny(node, consumers)) {
4660
+ return;
4661
+ }
4662
+ const referenceTsNode = esTreeNodeToTSNodeMap.get(node);
4663
+ const referenceSymbol = referenceTsNode
4664
+ ? checker.getSymbolAtLocation(referenceTsNode)
4665
+ : null;
4666
+ if (referenceSymbol !== symbol) {
4667
+ return;
4668
+ }
4669
+ usedOutsideConsumers = true;
4670
+ return false;
4671
+ });
4672
+ return usedOutsideConsumers;
4673
+ }
4674
+ /**
4675
+ * Collects all signal reads that appear as direct arguments to a high-confidence
4676
+ * incidental-read consumer inside a reactive scope, such as a writable-signal
4677
+ * write method or a DOM side-effect call in statement position.
4678
+ *
4679
+ * Only the topmost, direct argument reads are considered suspicious.
4680
+ * Nested reads (e.g. inside ternaries, function calls) are skipped because
4681
+ * they are harder to reason about safely.
4682
+ */
4683
+ function resolveSignalReadAlias(expression, context, seen = new Set()) {
4684
+ const unwrapped = unwrapExpression(expression);
4685
+ if (unwrapped.type === AST_NODE_TYPES.CallExpression &&
4686
+ isSignalReadCall(unwrapped, context.checker, context.esTreeNodeToTSNodeMap)) {
4687
+ return unwrapped;
4688
+ }
4689
+ if (unwrapped.type !== AST_NODE_TYPES.Identifier) {
4690
+ return null;
4691
+ }
4692
+ const tsNode = context.esTreeNodeToTSNodeMap.get(unwrapped);
4693
+ if (!tsNode) {
4694
+ return null;
4695
+ }
4696
+ const symbol = context.checker.getSymbolAtLocation(tsNode);
4697
+ if (!symbol) {
4698
+ return null;
4699
+ }
4700
+ const symbolId = `${symbol.name}:${symbol.declarations?.[0]?.pos ?? -1}`;
4701
+ if (seen.has(symbolId)) {
4702
+ return null;
4703
+ }
4704
+ seen.add(symbolId);
4705
+ for (const declaration of symbol.declarations ?? []) {
4706
+ const estreeDeclaration = context.tsNodeToESTreeNodeMap.get(declaration);
4707
+ if (estreeDeclaration?.type !== AST_NODE_TYPES.VariableDeclarator) {
4708
+ continue;
4709
+ }
4710
+ const variableDeclaration = estreeDeclaration.parent;
4711
+ if (variableDeclaration.kind !== 'const' ||
4712
+ !estreeDeclaration.init ||
4713
+ estreeDeclaration.range[0] > unwrapped.range[0] ||
4714
+ !isNodeInsideSynchronousReactiveScope(estreeDeclaration, context.scope.callback)) {
4715
+ continue;
4716
+ }
4717
+ const initializer = unwrapExpression(estreeDeclaration.init);
4718
+ if (initializer.type === AST_NODE_TYPES.CallExpression &&
4719
+ isSignalReadCall(initializer, context.checker, context.esTreeNodeToTSNodeMap)) {
4720
+ return initializer;
4721
+ }
4722
+ if (initializer.type === AST_NODE_TYPES.Identifier) {
4723
+ return resolveSignalReadAlias(initializer, context, seen);
4724
+ }
4725
+ }
4726
+ return null;
4727
+ }
4728
+ function collectSuspiciousReads(scope, checker, esTreeNodeToTSNodeMap, tsNodeToESTreeNodeMap, program) {
4729
+ const suspicious = new Map();
4730
+ const aliasResolutionContext = {
4731
+ checker,
4732
+ esTreeNodeToTSNodeMap,
4733
+ scope,
4734
+ tsNodeToESTreeNodeMap,
4735
+ };
4736
+ walkSynchronousAst(scope.callback, (node) => {
4737
+ if (node.type !== AST_NODE_TYPES.CallExpression) {
4738
+ return;
4739
+ }
4740
+ // Skip already-untracked scopes
4741
+ if (isAngularUntrackedCall(node, program)) {
4742
+ return false;
4743
+ }
4744
+ if (!isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap) &&
4745
+ !isSuspiciousDomCallArgumentConsumer(node, checker, esTreeNodeToTSNodeMap, program)) {
4746
+ return;
4747
+ }
4748
+ // Inspect each direct argument of the consumer call for signal reads.
4749
+ // We intentionally do NOT recurse into nested expressions —
4750
+ // only top-level direct reads in the argument position are flagged.
4751
+ for (const arg of node.arguments) {
4752
+ if (arg.type === AST_NODE_TYPES.SpreadElement) {
4753
+ continue;
4754
+ }
4755
+ const read = resolveSignalReadAlias(arg, aliasResolutionContext);
4756
+ const unwrappedArg = unwrapExpression(arg);
4757
+ if (read) {
4758
+ const key = String(read.range);
4759
+ const existing = suspicious.get(key);
4760
+ const alias = unwrappedArg.type === AST_NODE_TYPES.Identifier ? unwrappedArg : null;
4761
+ if (existing) {
4762
+ if (!existing.consumers.includes(node)) {
4763
+ existing.consumers.push(node);
4764
+ }
4765
+ if (alias && !existing.aliases.includes(alias)) {
4766
+ existing.aliases.push(alias);
4767
+ }
4768
+ }
4769
+ else {
4770
+ suspicious.set(key, {
4771
+ aliases: alias ? [alias] : [],
4772
+ consumers: [node],
4773
+ read,
4774
+ });
4775
+ }
4776
+ }
4777
+ }
4778
+ return false;
4779
+ });
4780
+ return [...suspicious.values()];
4781
+ }
4782
+ const rule$3 = createUntrackedRule({
4783
+ create(context) {
4784
+ const parserServices = ESLintUtils.getParserServices(context);
4785
+ const checker = parserServices.program.getTypeChecker();
4786
+ const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
4787
+ const tsNodeToESTreeNodeMap = parserServices.tsNodeToESTreeNodeMap;
4788
+ const { sourceCode } = context;
4789
+ const program = sourceCode.ast;
4790
+ function buildFix(read) {
4791
+ const untrackedAlias = findUntrackedAlias(program);
4792
+ const alreadyHasUntracked = untrackedAlias !== null;
4793
+ const wrapped = buildUntrackedWrap(read, sourceCode, untrackedAlias ?? 'untracked');
4794
+ if (alreadyHasUntracked) {
4795
+ return (fixer) => [fixer.replaceText(read, wrapped)];
4796
+ }
4797
+ return (fixer) => [
4798
+ fixer.replaceText(read, wrapped),
4799
+ ...buildUntrackedImportFixes(program, fixer),
4800
+ ];
4801
+ }
4802
+ return {
4803
+ CallExpression(node) {
4804
+ for (const scope of getReactiveScopes(node, program)) {
4805
+ const suspicious = collectSuspiciousReads(scope, checker, esTreeNodeToTSNodeMap, tsNodeToESTreeNodeMap, program);
4806
+ const { reads: trackedReads } = collectSignalUsages(scope.callback, checker, esTreeNodeToTSNodeMap, program);
4807
+ const suspiciousReads = new Set(suspicious.map(({ read }) => read));
4808
+ for (const { aliases, consumers, read } of suspicious) {
4809
+ const hasTrackedDependency = consumers.some((consumer) => trackedReads.some((trackedRead) => !suspiciousReads.has(trackedRead) &&
4810
+ !isNodeInside(trackedRead, consumer)));
4811
+ const hasExternalAliasUsage = aliases.some((alias) => aliasHasExternalUsage(alias, consumers, scope, checker, esTreeNodeToTSNodeMap));
4812
+ if (!hasTrackedDependency || hasExternalAliasUsage) {
4813
+ continue;
4814
+ }
4815
+ context.report({
4816
+ data: { kind: scope.kind, name: sourceCode.getText(read) },
4817
+ fix: buildFix(read),
4818
+ messageId: 'incidentalRead',
4819
+ node: read,
4820
+ });
4821
+ }
4822
+ }
4823
+ },
4824
+ };
4825
+ },
4826
+ meta: {
4827
+ docs: {
4828
+ description: 'Suggest wrapping likely-incidental signal reads with `untracked()` when they are used only as snapshot values in reactive callbacks that already have another tracked dependency, such as writable-signal writes or DOM side-effect calls',
4829
+ url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
4830
+ },
4831
+ fixable: 'code',
4832
+ messages: {
4833
+ incidentalRead: '`{{ name }}` looks like an incidental signal read inside `{{ kind }}`. If it is only providing a snapshot value and should not trigger a re-run, wrap it with `untracked()`. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
4834
+ },
4835
+ schema: [],
4836
+ type: 'suggestion',
4837
+ },
4838
+ name: 'prefer-untracked-incidental-signal-reads',
4839
+ });
4840
+
4841
+ function getReturnedExpression(node) {
4842
+ if (node.body.type !== AST_NODE_TYPES.BlockStatement) {
4843
+ return unwrapExpression(node.body);
4844
+ }
4845
+ if (node.body.body.length !== 1) {
4846
+ return null;
4847
+ }
4848
+ const statement = node.body.body[0];
4849
+ if (statement?.type !== AST_NODE_TYPES.ReturnStatement || !statement.argument) {
4850
+ return null;
4851
+ }
4852
+ return unwrapExpression(statement.argument);
4853
+ }
4854
+ function getWrappedSignalGetter(node, checker, esTreeNodeToTSNodeMap) {
4855
+ const [arg] = node.arguments;
4856
+ if (!arg || arg.type === AST_NODE_TYPES.SpreadElement) {
4857
+ return null;
4858
+ }
4859
+ if (arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
4860
+ arg.type !== AST_NODE_TYPES.FunctionExpression) {
4861
+ return null;
4862
+ }
4863
+ const body = getReturnedExpression(arg);
4864
+ if (body?.type !== AST_NODE_TYPES.CallExpression) {
4865
+ return null;
4866
+ }
4867
+ if (!isSignalReadCall(body, checker, esTreeNodeToTSNodeMap)) {
4868
+ return null;
4869
+ }
4870
+ const getter = unwrapExpression(body.callee);
4871
+ if (getter.type === AST_NODE_TYPES.MemberExpression &&
4872
+ isGetterMemberAccess(getter, checker, esTreeNodeToTSNodeMap)) {
4873
+ return null;
4874
+ }
4875
+ return isSignalType(getter, checker, esTreeNodeToTSNodeMap) ? body.callee : null;
4876
+ }
4877
+ const rule$2 = createUntrackedRule({
4878
+ create(context) {
4879
+ const parserServices = ESLintUtils.getParserServices(context);
4880
+ const checker = parserServices.program.getTypeChecker();
4881
+ const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
4882
+ const { sourceCode } = context;
4883
+ const program = sourceCode.ast;
4884
+ return {
4885
+ CallExpression(node) {
4886
+ if (!isAngularUntrackedCall(node, program)) {
4887
+ return;
4888
+ }
4889
+ const getter = getWrappedSignalGetter(node, checker, esTreeNodeToTSNodeMap);
4890
+ if (!getter) {
4891
+ return;
4892
+ }
4893
+ context.report({
4894
+ fix: (fixer) => fixer.replaceText(node, `${sourceCode.getText(node.callee)}(${sourceCode.getText(getter)})`),
4895
+ messageId: 'preferGetterForm',
4896
+ node,
4897
+ });
4898
+ },
4899
+ };
4900
+ },
4901
+ meta: {
4902
+ docs: {
4903
+ description: 'Prefer `untracked(signalGetter)` over `untracked(() => signalGetter())` for a single signal getter',
4904
+ url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
4905
+ },
4906
+ fixable: 'code',
4907
+ messages: {
4908
+ preferGetterForm: 'Pass single signal getters directly as `untracked(signalGetter)` instead of wrapping them in `untracked(() => signalGetter())`. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
4909
+ },
4910
+ schema: [],
4911
+ type: 'suggestion',
4912
+ },
4913
+ name: 'prefer-untracked-signal-getter',
4914
+ });
4915
+
3397
4916
  function getImportedName(spec) {
3398
4917
  if (spec.imported.type === AST_NODE_TYPES.Identifier) {
3399
4918
  return spec.imported.name;
@@ -3869,19 +5388,25 @@ const plugin = {
3869
5388
  'decorator-key-sort': config$3,
3870
5389
  'flat-exports': flatExports,
3871
5390
  'html-logical-properties': config$2,
3872
- 'injection-token-description': rule$a,
3873
- 'no-deep-imports': rule$9,
5391
+ 'injection-token-description': rule$g,
5392
+ 'no-deep-imports': rule$f,
3874
5393
  'no-deep-imports-to-indexed-packages': noDeepImportsToIndexedPackages,
5394
+ 'no-fully-untracked-effect': rule$e,
3875
5395
  'no-href-with-router-link': config$1,
3876
- 'no-implicit-public': rule$8,
3877
- 'no-legacy-peer-deps': rule$7,
3878
- 'no-playwright-empty-fill': rule$6,
5396
+ 'no-implicit-public': rule$d,
5397
+ 'no-legacy-peer-deps': rule$c,
5398
+ 'no-playwright-empty-fill': rule$b,
3879
5399
  'no-project-as-in-ng-template': config,
3880
- 'no-redundant-type-annotation': rule$5,
3881
- 'no-string-literal-concat': rule$4,
3882
- 'object-single-line': rule$3,
5400
+ 'no-redundant-type-annotation': rule$a,
5401
+ 'no-signal-reads-after-await-in-reactive-context': rule$9,
5402
+ 'no-string-literal-concat': rule$8,
5403
+ 'no-untracked-outside-reactive-context': rule$7,
5404
+ 'no-useless-untracked': rule$6,
5405
+ 'object-single-line': rule$5,
3883
5406
  'prefer-deep-imports': preferDeepImports,
3884
- 'prefer-multi-arg-push': rule$2,
5407
+ 'prefer-multi-arg-push': rule$4,
5408
+ 'prefer-untracked-incidental-signal-reads': rule$3,
5409
+ 'prefer-untracked-signal-getter': rule$2,
3885
5410
  'short-tui-imports': rule$1,
3886
5411
  'standalone-imports-sort': standaloneImportsSort,
3887
5412
  'strict-tui-doc-example': rule,