@colrealpro/react-luau-doctor 0.18.3 → 0.19.1

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.
@@ -19,6 +19,19 @@ function walk(node) {
19
19
  }
20
20
  return result;
21
21
  }
22
+ function parameterBindingNames(parameters) {
23
+ if (!parameters)
24
+ return [];
25
+ const result = [];
26
+ for (const parameter of parameters.namedChildren) {
27
+ if (parameter.type !== "parameter")
28
+ continue;
29
+ const identifier = parameter.namedChildren.find((child) => child.type === "identifier");
30
+ if (identifier)
31
+ result.push(identifier.text);
32
+ }
33
+ return [...new Set(result)];
34
+ }
22
35
  function rootIdentifier(text) {
23
36
  const match = text.trim().match(/^([A-Za-z_][A-Za-z0-9_]*)/);
24
37
  return match?.[1] ?? null;
@@ -147,6 +160,63 @@ function createRuleContext(file) {
147
160
  };
148
161
  }
149
162
 
163
+ // src/module-resolution.ts
164
+ import path from "path";
165
+ function normalizeRelative(value) {
166
+ return value.split(path.sep).join("/");
167
+ }
168
+ function moduleKeys(relativePath) {
169
+ let normalized = normalizeRelative(relativePath).replace(/\.(?:lua|luau)$/i, "");
170
+ if (normalized.endsWith("/init"))
171
+ normalized = normalized.slice(0, -"/init".length);
172
+ const segments = normalized.split("/").filter(Boolean);
173
+ if (segments[0]?.toLowerCase() === "src")
174
+ segments.shift();
175
+ const keys = new Set;
176
+ for (let index = 0;index < segments.length; index += 1) {
177
+ const suffix = segments.slice(index).join(".").toLowerCase();
178
+ if (suffix)
179
+ keys.add(suffix);
180
+ }
181
+ return [...keys];
182
+ }
183
+ function normalizeRequireTarget(text) {
184
+ return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
185
+ }
186
+ function buildUniqueFeatureAliases(modules, featureByModuleId) {
187
+ const owners = new Map;
188
+ for (const module of modules) {
189
+ for (const key of module.keys) {
190
+ const ids = owners.get(key) ?? new Set;
191
+ ids.add(module.id);
192
+ owners.set(key, ids);
193
+ }
194
+ }
195
+ const aliases = new Map;
196
+ for (const module of modules) {
197
+ const value = featureByModuleId.get(module.id);
198
+ if (value === undefined)
199
+ continue;
200
+ for (const key of module.keys) {
201
+ const ids = owners.get(key);
202
+ if (ids?.size === 1 && ids.has(module.id))
203
+ aliases.set(key, value);
204
+ }
205
+ }
206
+ return aliases;
207
+ }
208
+ function resolveModuleReference(target, aliases) {
209
+ let candidate = target;
210
+ while (true) {
211
+ if (aliases.has(candidate))
212
+ return aliases.get(candidate);
213
+ const separator = candidate.indexOf(".");
214
+ if (separator < 0)
215
+ return null;
216
+ candidate = candidate.slice(separator + 1);
217
+ }
218
+ }
219
+
150
220
  // src/ast/react-model.ts
151
221
  var REACT_HOOKS = new Set([
152
222
  "useState",
@@ -177,18 +247,7 @@ var INSTANCE_FACTORY_MEMBERS = new Set([
177
247
  ]);
178
248
  var STABLE_ENGINE_FACTORY_MEMBERS = new Set(["GetMouse", "GetService"]);
179
249
  function identifiersFromParameters(node) {
180
- if (!node)
181
- return [];
182
- const result = [];
183
- for (const child of walk(node)) {
184
- if (child.type !== "identifier")
185
- continue;
186
- const parentType = child.parent?.type;
187
- if (parentType === "parameters" || parentType === "parameter" || parentType === "typed_identifier") {
188
- result.push(child.text);
189
- }
190
- }
191
- return [...new Set(result)];
250
+ return parameterBindingNames(node);
192
251
  }
193
252
  function getFunctionName(node) {
194
253
  if (node.type === "function_declaration") {
@@ -242,15 +301,19 @@ function directCalls(node) {
242
301
  function hasDirectCreateElementCall(node, aliases, reactNamespaces, reactRobloxNamespaces) {
243
302
  return directCalls(node).some((call) => {
244
303
  const name = call.childForFieldName("name")?.text ?? "";
245
- const path = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
246
- return path === "React.createElement" || path === "ReactRoblox.createPortal";
304
+ const path2 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
305
+ return path2 === "React.createElement" || path2 === "ReactRoblox.createPortal";
247
306
  });
248
307
  }
249
- function hasDirectHookCall(node, aliases, reactNamespaces, reactRobloxNamespaces) {
308
+ function hasDirectHookCall(node, aliases, reactNamespaces, reactRobloxNamespaces, importedHookKinds) {
250
309
  return directCalls(node).some((call) => {
251
310
  const name = call.childForFieldName("name")?.text ?? "";
252
- const path = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
253
- const final = path.split(/[.:]/).at(-1) ?? "";
311
+ const path2 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
312
+ const normalizedName = normalizeExpressionText(name);
313
+ const importedKind = importedHookKinds.get(normalizedName);
314
+ if (importedKind !== undefined)
315
+ return importedKind;
316
+ const final = path2.split(/[.:]/).at(-1) ?? "";
254
317
  return /^use[A-Z0-9_]/.test(final);
255
318
  });
256
319
  }
@@ -336,8 +399,8 @@ function isSyntacticallyStableInitializer(node) {
336
399
  return false;
337
400
  return ["nil", "true", "false", "number", "string", "string_content"].includes(node.type) || /^(?:nil|true|false|-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)$/i.test(node.text.trim());
338
401
  }
339
- function finalCallMember(path) {
340
- return path.split(/[.:]/).at(-1) ?? path;
402
+ function finalCallMember(path2) {
403
+ return path2.split(/[.:]/).at(-1) ?? path2;
341
404
  }
342
405
  function expressionCreatesInstance(node, aliases, reactNamespaces, reactRobloxNamespaces) {
343
406
  if (!node)
@@ -349,8 +412,8 @@ function expressionCreatesInstance(node, aliases, reactNamespaces, reactRobloxNa
349
412
  if (candidate.type !== "function_call")
350
413
  continue;
351
414
  const rawPath = candidate.childForFieldName("name")?.text ?? "";
352
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
353
- if (path === "Instance.new" || INSTANCE_FACTORY_MEMBERS.has(finalCallMember(path)))
415
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
416
+ if (path2 === "Instance.new" || INSTANCE_FACTORY_MEMBERS.has(finalCallMember(path2)))
354
417
  return true;
355
418
  }
356
419
  return false;
@@ -362,8 +425,8 @@ function expressionIsStableEngineObject(node, aliases, reactNamespaces, reactRob
362
425
  if (calls.length !== 1)
363
426
  return false;
364
427
  const rawPath = calls[0].childForFieldName("name")?.text ?? "";
365
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
366
- return STABLE_ENGINE_FACTORY_MEMBERS.has(finalCallMember(path));
428
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
429
+ return STABLE_ENGINE_FACTORY_MEMBERS.has(finalCallMember(path2));
367
430
  }
368
431
  function memoReturnsInstance(callback, aliases, reactNamespaces, reactRobloxNamespaces) {
369
432
  if (!callback || callback.type !== "function_definition")
@@ -379,7 +442,21 @@ function memoReturnsInstance(callback, aliases, reactNamespaces, reactRobloxName
379
442
  function isEmptyDependencyTable(node) {
380
443
  return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
381
444
  }
382
- function buildReactModel(root) {
445
+ function importedHookKinds(declarations, project) {
446
+ const result = new Map;
447
+ if (!project)
448
+ return result;
449
+ for (const declaration of declarations) {
450
+ const match = declaration.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
451
+ if (!match || !/^use[A-Z0-9_]/.test(match[1]))
452
+ continue;
453
+ const kind = resolveModuleReference(normalizeRequireTarget(match[2]), project.reactHookModules);
454
+ if (kind !== null)
455
+ result.set(match[1], kind);
456
+ }
457
+ return result;
458
+ }
459
+ function buildReactModel(root, project) {
383
460
  const reactNamespaces = new Set;
384
461
  const reactRobloxNamespaces = new Set;
385
462
  const aliases = new Map;
@@ -395,6 +472,7 @@ function buildReactModel(root) {
395
472
  const instanceVariablesByFunction = new Map;
396
473
  const allNodes = [...walk(root)];
397
474
  const declarations = allNodes.filter((node) => node.type === "variable_declaration");
475
+ const projectHookKinds = importedHookKinds(declarations, project);
398
476
  let isReactFile = false;
399
477
  for (const declaration of declarations) {
400
478
  const text = declaration.text;
@@ -469,7 +547,7 @@ function buildReactModel(root) {
469
547
  if (!isReactFile || !info.body || info.isHook)
470
548
  continue;
471
549
  const simplePascalName = Boolean(info.name && /^[A-Z][A-Za-z0-9_]*$/.test(info.name));
472
- const hasHooks = hasDirectHookCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces);
550
+ const hasHooks = hasDirectHookCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces, projectHookKinds);
473
551
  const renders = hasDirectCreateElementCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces);
474
552
  if (hasHooks || isMemoWrappedFunction(info.node, aliases, reactNamespaces) || simplePascalName && renders) {
475
553
  info.isComponent = true;
@@ -481,8 +559,8 @@ function buildReactModel(root) {
481
559
  if (call.type !== "function_call")
482
560
  continue;
483
561
  const rawPath = call.childForFieldName("name")?.text ?? "";
484
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
485
- if (path !== "React.createElement")
562
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
563
+ if (path2 !== "React.createElement")
486
564
  continue;
487
565
  const element = call.childForFieldName("arguments")?.namedChildren[0]?.text.trim() ?? "";
488
566
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(element))
@@ -517,13 +595,13 @@ function buildReactModel(root) {
517
595
  if (!call)
518
596
  continue;
519
597
  const rawPath = call.childForFieldName("name")?.text ?? "";
520
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
598
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
521
599
  const args = call.childForFieldName("arguments")?.namedChildren ?? [];
522
- if (path === "React.joinBindings" && names.length >= 1) {
600
+ if (path2 === "React.joinBindings" && names.length >= 1) {
523
601
  stableVariables.add(names[0]);
524
602
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
525
603
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
526
- } else if (path === "React.useState" && names.length >= 2) {
604
+ } else if (path2 === "React.useState" && names.length >= 2) {
527
605
  const [valueName, setterName] = names;
528
606
  stateSetters.set(setterName, valueName);
529
607
  stateValues.set(valueName, setterName);
@@ -537,10 +615,10 @@ function buildReactModel(root) {
537
615
  initializer: args[0] ?? null,
538
616
  owner
539
617
  });
540
- } else if (path === "React.useReducer" && names.length >= 2) {
618
+ } else if (path2 === "React.useReducer" && names.length >= 2) {
541
619
  stableVariables.add(names[1]);
542
620
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
543
- } else if (path === "React.useBinding" && names.length >= 1) {
621
+ } else if (path2 === "React.useBinding" && names.length >= 1) {
544
622
  stableVariables.add(names[0]);
545
623
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
546
624
  if (names.length >= 2) {
@@ -548,7 +626,7 @@ function buildReactModel(root) {
548
626
  bindingSetters.add(names[1]);
549
627
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
550
628
  }
551
- } else if (path === "React.useMemo" && names.length >= 1) {
629
+ } else if (path2 === "React.useMemo" && names.length >= 1) {
552
630
  if (isEmptyDependencyTable(args[1])) {
553
631
  stableVariables.add(names[0]);
554
632
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
@@ -557,16 +635,16 @@ function buildReactModel(root) {
557
635
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
558
636
  addScopedVariable(instanceVariablesByFunction, owner, names[0]);
559
637
  }
560
- } else if (path === "React.useCallback" && names.length >= 1 && isEmptyDependencyTable(args[1])) {
638
+ } else if (path2 === "React.useCallback" && names.length >= 1 && isEmptyDependencyTable(args[1])) {
561
639
  stableVariables.add(names[0]);
562
640
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
563
- } else if (path === "React.useRef" && names.length >= 1) {
641
+ } else if (path2 === "React.useRef" && names.length >= 1) {
564
642
  stableVariables.add(names[0]);
565
643
  refVariables.add(names[0]);
566
644
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
567
645
  addScopedVariable(refVariablesByFunction, owner, names[0]);
568
646
  } else {
569
- const final = path.split(/[.:]/).at(-1) ?? "";
647
+ const final = path2.split(/[.:]/).at(-1) ?? "";
570
648
  if (/^use[A-Z0-9_]/.test(final)) {
571
649
  for (const name of names.slice(1)) {
572
650
  if (!STABLE_CUSTOM_HOOK_RETURN.test(name))
@@ -590,11 +668,11 @@ function buildReactModel(root) {
590
668
  if (!expression || expression.type !== "function_call")
591
669
  continue;
592
670
  const rawPath = expression.childForFieldName("name")?.text ?? "";
593
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
594
- if (path === "React.useState" && names.length >= 2) {
671
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
672
+ if (path2 === "React.useState" && names.length >= 2) {
595
673
  stableVariables.add(names[1]);
596
674
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
597
- } else if (path === "React.useBinding" && names.length >= 1) {
675
+ } else if (path2 === "React.useBinding" && names.length >= 1) {
598
676
  stableVariables.add(names[0]);
599
677
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
600
678
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
@@ -654,14 +732,14 @@ function buildReactModel(root) {
654
732
 
655
733
  // src/config.ts
656
734
  import fs from "fs";
657
- import path from "path";
735
+ import path2 from "path";
658
736
  var CONFIG_NAME = "react-luau-doctor.config.json";
659
737
  var SEVERITIES = new Set(["off", "error", "warning", "suggestion"]);
660
738
  var SCOPES = new Set(["full", "files", "changed", "lines"]);
661
739
  var BLOCKING_LEVELS = new Set(["error", "warning", "none"]);
662
740
  var CATEGORIES = new Set(["Correctness", "Hooks", "Effects", "Performance", "Roblox", "Architecture"]);
663
741
  function loadConfigWithSource(root) {
664
- const candidate = path.join(root, CONFIG_NAME);
742
+ const candidate = path2.join(root, CONFIG_NAME);
665
743
  if (!fs.existsSync(candidate))
666
744
  return { config: {}, filename: null };
667
745
  const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
@@ -721,14 +799,14 @@ function effectiveSeverity(defaultSeverity, ruleId, config) {
721
799
  }
722
800
  function configPathForWrite(root) {
723
801
  const loaded = loadConfigWithSource(root);
724
- return loaded.filename ?? path.join(root, CONFIG_NAME);
802
+ return loaded.filename ?? path2.join(root, CONFIG_NAME);
725
803
  }
726
804
  function writeConfig(root, update) {
727
805
  const loaded = loadConfigWithSource(root);
728
806
  const next = update(structuredClone(loaded.config));
729
807
  validateConfig(next, loaded.filename ?? CONFIG_NAME);
730
808
  const filename = loaded.filename ?? configPathForWrite(root);
731
- fs.mkdirSync(path.dirname(filename), { recursive: true });
809
+ fs.mkdirSync(path2.dirname(filename), { recursive: true });
732
810
  fs.writeFileSync(filename, `${JSON.stringify(next, null, 2)}
733
811
  `);
734
812
  return filename;
@@ -808,7 +886,7 @@ function createInlineSuppressionChecker(source) {
808
886
 
809
887
  // src/parser.ts
810
888
  import fs2 from "fs";
811
- import path2 from "path";
889
+ import path3 from "path";
812
890
  import { Language, Parser } from "web-tree-sitter";
813
891
 
814
892
  // src/parser-compat.ts
@@ -947,6 +1025,23 @@ function isCode(mask, start, end = start + 1) {
947
1025
  }
948
1026
  return true;
949
1027
  }
1028
+ function matchingCodeDelimiterEnd(source, mask, start, opener, closer) {
1029
+ if (source[start] !== opener || !isCode(mask, start))
1030
+ return null;
1031
+ let depth = 0;
1032
+ for (let cursor = start;cursor < source.length; cursor += 1) {
1033
+ if (!isCode(mask, cursor))
1034
+ continue;
1035
+ if (source[cursor] === opener)
1036
+ depth += 1;
1037
+ else if (source[cursor] === closer) {
1038
+ depth -= 1;
1039
+ if (depth === 0)
1040
+ return cursor + 1;
1041
+ }
1042
+ }
1043
+ return null;
1044
+ }
950
1045
  function maskTypeAliases(source, mask) {
951
1046
  const { lines, offsets } = lineData(source);
952
1047
  const ranges = [];
@@ -1154,6 +1249,89 @@ function genericFunctionParameterRanges(source, mask) {
1154
1249
  }
1155
1250
  return ranges;
1156
1251
  }
1252
+ function genericFunctionReturnPrefixRanges(source, mask) {
1253
+ const ranges = [];
1254
+ const functionPattern = /\bfunction\b/g;
1255
+ for (const match of source.matchAll(functionPattern)) {
1256
+ const start = match.index;
1257
+ if (!isCode(mask, start, start + match[0].length))
1258
+ continue;
1259
+ let cursor = start + match[0].length;
1260
+ let genericDepth = 0;
1261
+ while (cursor < source.length) {
1262
+ if (!isCode(mask, cursor)) {
1263
+ cursor += 1;
1264
+ continue;
1265
+ }
1266
+ if (source[cursor] === "<")
1267
+ genericDepth += 1;
1268
+ else if (source[cursor] === ">" && genericDepth > 0)
1269
+ genericDepth -= 1;
1270
+ else if (source[cursor] === "(" && genericDepth === 0)
1271
+ break;
1272
+ cursor += 1;
1273
+ }
1274
+ if (source[cursor] !== "(")
1275
+ continue;
1276
+ const parameterEnd = matchingCodeDelimiterEnd(source, mask, cursor, "(", ")");
1277
+ if (parameterEnd === null)
1278
+ continue;
1279
+ cursor = parameterEnd;
1280
+ while (cursor < source.length && /\s/.test(source[cursor]))
1281
+ cursor += 1;
1282
+ if (source[cursor] !== ":" || !isCode(mask, cursor))
1283
+ continue;
1284
+ cursor += 1;
1285
+ while (cursor < source.length && /\s/.test(source[cursor]))
1286
+ cursor += 1;
1287
+ if (source[cursor] !== "<")
1288
+ continue;
1289
+ const rangeStart = cursor;
1290
+ const genericEnd = matchingCodeDelimiterEnd(source, mask, cursor, "<", ">");
1291
+ if (genericEnd === null)
1292
+ continue;
1293
+ cursor = genericEnd;
1294
+ while (cursor < source.length && /\s/.test(source[cursor]))
1295
+ cursor += 1;
1296
+ const typeParametersEnd = matchingCodeDelimiterEnd(source, mask, cursor, "(", ")");
1297
+ if (typeParametersEnd === null)
1298
+ continue;
1299
+ cursor = typeParametersEnd;
1300
+ while (cursor < source.length && /\s/.test(source[cursor]))
1301
+ cursor += 1;
1302
+ if (source.slice(cursor, cursor + 2) !== "->" || !isCode(mask, cursor, cursor + 2))
1303
+ continue;
1304
+ cursor += 2;
1305
+ while (cursor < source.length && /[ \t]/.test(source[cursor]))
1306
+ cursor += 1;
1307
+ ranges.push({ start: rangeStart, end: cursor });
1308
+ }
1309
+ return ranges;
1310
+ }
1311
+ function normalizeLocalTypeofAnnotations(source, mask) {
1312
+ const replacements = [];
1313
+ const pattern = /\blocal\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*typeof\s*\(/g;
1314
+ for (const match of source.matchAll(pattern)) {
1315
+ const matchStart = match.index;
1316
+ if (!isCode(mask, matchStart, matchStart + match[0].length))
1317
+ continue;
1318
+ const typeofStart = matchStart + match[0].lastIndexOf("typeof");
1319
+ const open = source.indexOf("(", typeofStart + 6);
1320
+ if (open < 0)
1321
+ continue;
1322
+ const end = matchingCodeDelimiterEnd(source, mask, open, "(", ")");
1323
+ if (end === null)
1324
+ continue;
1325
+ const original = source.slice(typeofStart, end);
1326
+ const padding = blankPreservingUtf8Bytes(original).slice(3);
1327
+ replacements.push({ start: typeofStart, end, value: `any${padding}` });
1328
+ }
1329
+ let output = source;
1330
+ for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
1331
+ output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
1332
+ }
1333
+ return output;
1334
+ }
1157
1335
  function normalizeGenericTypePackUses(source, mask) {
1158
1336
  const declarationRanges = genericFunctionParameterRanges(source, mask);
1159
1337
  const replacements = [];
@@ -1214,13 +1392,15 @@ function parserCompatibleSource(source) {
1214
1392
  const keywordCompatible = replaceContextualKeywords(source, mask);
1215
1393
  const literalCompatible = normalizeIntegerLiteralSuffixes(keywordCompatible, mask);
1216
1394
  const packCompatible = normalizeGenericTypePackUses(literalCompatible, mask);
1395
+ const typeofCompatible = normalizeLocalTypeofAnnotations(packCompatible, mask);
1217
1396
  const ranges = [
1218
1397
  ...maskTypeAliases(source, mask),
1219
1398
  ...maskTypeLevelBlocks(source, mask),
1220
1399
  ...attributeRanges(source, mask),
1221
- ...explicitTypeArgumentRanges(source, mask)
1400
+ ...explicitTypeArgumentRanges(source, mask),
1401
+ ...genericFunctionReturnPrefixRanges(source, mask)
1222
1402
  ];
1223
- const masked = applyMasks(packCompatible, ranges);
1403
+ const masked = applyMasks(typeofCompatible, ranges);
1224
1404
  return masked.replace(/^(\s*)type(?=\s*(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=(?!=)))/gm, "$1_typ");
1225
1405
  }
1226
1406
 
@@ -1228,7 +1408,7 @@ function parserCompatibleSource(source) {
1228
1408
  var parserPromise = null;
1229
1409
  async function createParser() {
1230
1410
  await Parser.init();
1231
- const grammarPath = path2.resolve(import.meta.dir, "../vendor/tree-sitter-luau.wasm");
1411
+ const grammarPath = path3.resolve(import.meta.dir, "../vendor/tree-sitter-luau.wasm");
1232
1412
  const grammar = new Uint8Array(fs2.readFileSync(grammarPath));
1233
1413
  const language = await Language.load(grammar);
1234
1414
  const parser = new Parser;
@@ -1353,8 +1533,8 @@ var INSTANCE_FACTORY_MEMBERS2 = new Set([
1353
1533
  "GetService",
1354
1534
  "WaitForChild"
1355
1535
  ]);
1356
- function finalCallMember2(path3) {
1357
- return path3.split(/[.:]/).at(-1) ?? path3;
1536
+ function finalCallMember2(path4) {
1537
+ return path4.split(/[.:]/).at(-1) ?? path4;
1358
1538
  }
1359
1539
  function declarationInitializer(node, name) {
1360
1540
  if (node.type !== "variable_declaration")
@@ -1422,8 +1602,8 @@ function expressionHasRobloxOrigin(expression, from, context, seen) {
1422
1602
  seen.add(root);
1423
1603
  return expressionHasRobloxOrigin(visibleInitializer(root, from, context), from, context, seen);
1424
1604
  }
1425
- function hasRobloxReceiver(path3, call, context) {
1426
- const normalized = path3.replace(/\s+/g, "");
1605
+ function hasRobloxReceiver(path4, call, context) {
1606
+ const normalized = path4.replace(/\s+/g, "");
1427
1607
  if (/^(?:game|workspace|script)(?:[.:]|$)/.test(normalized))
1428
1608
  return true;
1429
1609
  const root = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
@@ -1433,6 +1613,8 @@ function hasRobloxReceiver(path3, call, context) {
1433
1613
  }
1434
1614
  var runServiceCallCache = new WeakMap;
1435
1615
  var runServiceExpressionCache = new WeakMap;
1616
+ var highFrequencyRobloxCallCache = new WeakMap;
1617
+ var highFrequencyRobloxExpressionCache = new WeakMap;
1436
1618
  function cachedNodeBoolean(cache, context, node, compute) {
1437
1619
  let entries = cache.get(context);
1438
1620
  if (!entries) {
@@ -1497,6 +1679,33 @@ function isHighFrequencyRunServiceCall(call, context) {
1497
1679
  return Boolean(bind && rootIsRunService(bind[1], call, context));
1498
1680
  });
1499
1681
  }
1682
+ var HIGH_FREQUENCY_ROBLOX_EVENTS = new Set([
1683
+ "InputChanged",
1684
+ "TouchMoved",
1685
+ "Move"
1686
+ ]);
1687
+ function isHighFrequencyRobloxExpression(node, context) {
1688
+ return cachedNodeBoolean(highFrequencyRobloxExpressionCache, context, node, () => {
1689
+ if (isHighFrequencyRunServiceExpression(node, context))
1690
+ return true;
1691
+ const normalized = node.text.replace(/\s+/g, "");
1692
+ const match = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move)$/);
1693
+ if (!match || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(match[2]))
1694
+ return false;
1695
+ return hasRobloxReceiver(match[1], node, context);
1696
+ });
1697
+ }
1698
+ function isHighFrequencyRobloxCall(call, context) {
1699
+ return cachedNodeBoolean(highFrequencyRobloxCallCache, context, call, () => {
1700
+ if (isHighFrequencyRunServiceCall(call, context))
1701
+ return true;
1702
+ const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
1703
+ const event = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move):Connect$/);
1704
+ if (!event || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(event[2]))
1705
+ return false;
1706
+ return hasRobloxReceiver(event[1], call, context);
1707
+ });
1708
+ }
1500
1709
  function sourceHasHighFrequencyRunService(source) {
1501
1710
  if (/game\s*:\s*GetService\s*\(\s*["']RunService["']\s*\)\s*(?:\.\s*(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)\s*:\s*Connect|:\s*(?:BindToRenderStep|BindToSimulation))/.test(source)) {
1502
1711
  return true;
@@ -1513,8 +1722,38 @@ function sourceHasHighFrequencyRunService(source) {
1513
1722
  }
1514
1723
  return false;
1515
1724
  }
1516
- function knownYieldReason(path3, call, context) {
1517
- const normalized = path3.replace(/\s+/g, "");
1725
+ function sourceHasHighFrequencyRobloxEvent(source) {
1726
+ if (sourceHasHighFrequencyRunService(source))
1727
+ return true;
1728
+ if (/game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)\s*\.\s*(?:InputChanged|TouchMoved)\s*:\s*Connect/.test(source)) {
1729
+ return true;
1730
+ }
1731
+ const serviceAliases = new Set;
1732
+ for (const match of source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)/g)) {
1733
+ serviceAliases.add(match[1]);
1734
+ }
1735
+ for (const alias of serviceAliases) {
1736
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1737
+ if (new RegExp(`\\b${escaped}\\s*\\.\\s*(?:InputChanged|TouchMoved)\\s*:\\s*Connect`).test(source)) {
1738
+ return true;
1739
+ }
1740
+ }
1741
+ const playersAliases = new Set;
1742
+ for (const match of source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*game\s*:\s*GetService\s*\(\s*["']Players["']\s*\)/g)) {
1743
+ playersAliases.add(match[1]);
1744
+ }
1745
+ for (const players of playersAliases) {
1746
+ const escapedPlayers = players.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1747
+ for (const match of source.matchAll(new RegExp(`\\blocal\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapedPlayers}\\s*\\.\\s*LocalPlayer\\s*:\\s*GetMouse\\s*\\(\\s*\\)`, "g"))) {
1748
+ const mouse = match[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1749
+ if (new RegExp(`\\b${mouse}\\s*\\.\\s*Move\\s*:\\s*Connect`).test(source))
1750
+ return true;
1751
+ }
1752
+ }
1753
+ return false;
1754
+ }
1755
+ function knownYieldReason(path4, call, context) {
1756
+ const normalized = path4.replace(/\s+/g, "");
1518
1757
  if (normalized === "task.wait")
1519
1758
  return "task.wait";
1520
1759
  if (normalized === "coroutine.yield")
@@ -1534,15 +1773,15 @@ function functionYieldPoint(node, context) {
1534
1773
  const owner = context.model.functionByNode.get(node.id);
1535
1774
  if (owner && nearest !== owner)
1536
1775
  continue;
1537
- const path3 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
1538
- const reason = knownYieldReason(path3, candidate, context);
1776
+ const path4 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
1777
+ const reason = knownYieldReason(path4, candidate, context);
1539
1778
  if (reason)
1540
1779
  return { call: candidate, reason };
1541
1780
  }
1542
1781
  return null;
1543
1782
  }
1544
- function mutableDependencyBase(path3, externallyMutableRoots) {
1545
- const normalized = path3.replace(/\s+/g, "");
1783
+ function mutableDependencyBase(path4, externallyMutableRoots) {
1784
+ const normalized = path4.replace(/\s+/g, "");
1546
1785
  if (/\.current(?:\.|$)/.test(normalized))
1547
1786
  return "";
1548
1787
  const segments = normalized.split(".");
@@ -1568,8 +1807,8 @@ var CONDITIONAL_TYPES = new Set([
1568
1807
  "while_statement",
1569
1808
  "repeat_statement"
1570
1809
  ]);
1571
- function isHookPath(path3) {
1572
- const final = path3.split(/[.:]/).at(-1) ?? "";
1810
+ function isHookPath(path4) {
1811
+ const final = path4.split(/[.:]/).at(-1) ?? "";
1573
1812
  return /^use[A-Z0-9_]/.test(final);
1574
1813
  }
1575
1814
  function assignmentTargetNode(statement) {
@@ -1645,8 +1884,8 @@ function functionInfoForNode(context, node) {
1645
1884
  function stateBindingsFor(context, owner) {
1646
1885
  return context.model.stateBindings.filter((binding) => binding.owner === owner);
1647
1886
  }
1648
- function isEffectPath(path3) {
1649
- return path3 === "React.useEffect" || path3 === "React.useLayoutEffect";
1887
+ function isEffectPath(path4) {
1888
+ return path4 === "React.useEffect" || path4 === "React.useLayoutEffect";
1650
1889
  }
1651
1890
  function dependencyExpressions(node) {
1652
1891
  if (!node || node.type !== "table_constructor")
@@ -1901,11 +2140,11 @@ function isManagedByCleanupContainer(call, context, callback) {
1901
2140
  let current = call.parent;
1902
2141
  while (current && !sameNode(current, callback.node)) {
1903
2142
  if (current.type === "function_call") {
1904
- const path3 = context.getCallPath(current) ?? "";
1905
- if (ALWAYS_MANAGED_METHOD.test(path3))
2143
+ const path4 = context.getCallPath(current) ?? "";
2144
+ if (ALWAYS_MANAGED_METHOD.test(path4))
1906
2145
  return true;
1907
- if (GENERIC_MANAGER_METHOD.test(path3)) {
1908
- const receiver = path3.split(":", 1)[0] ?? "";
2146
+ if (GENERIC_MANAGER_METHOD.test(path4)) {
2147
+ const receiver = path4.split(":", 1)[0] ?? "";
1909
2148
  if (MANAGER_ROOT.test(receiver))
1910
2149
  return true;
1911
2150
  }
@@ -2010,10 +2249,10 @@ function isCleanupOnlyTask(callbackNode, context) {
2010
2249
  return false;
2011
2250
  return calls.every((call) => /:(?:Destroy|Disconnect)$/.test(context.getCallPath(call) ?? ""));
2012
2251
  }
2013
- function taskSeverity(path3, call, taskCallback, yieldPoint, context) {
2014
- if (path3 === "task.defer" && !yieldPoint)
2252
+ function taskSeverity(path4, call, taskCallback, yieldPoint, context) {
2253
+ if (path4 === "task.defer" && !yieldPoint)
2015
2254
  return "suggestion";
2016
- if (path3 === "task.delay" && !yieldPoint) {
2255
+ if (path4 === "task.delay" && !yieldPoint) {
2017
2256
  if (isCleanupOnlyTask(taskCallback, context))
2018
2257
  return "suggestion";
2019
2258
  const delay = numericTaskDelay(call, context);
@@ -2063,10 +2302,10 @@ end`,
2063
2302
  };
2064
2303
  }
2065
2304
  if ([...labels].every((label) => label.includes("task"))) {
2066
- const path3 = context.resolveCallPath(context.getCallPath(first.call) ?? "task.delay");
2067
- const taskCall = path3 === "task.defer" ? `task.defer(function()
2305
+ const path4 = context.resolveCallPath(context.getCallPath(first.call) ?? "task.delay");
2306
+ const taskCall = path4 === "task.defer" ? `task.defer(function()
2068
2307
  doWork()
2069
- end)` : path3 === "task.spawn" ? `task.spawn(function()
2308
+ end)` : path4 === "task.spawn" ? `task.spawn(function()
2070
2309
  doWork()
2071
2310
  end)` : `task.delay(delaySeconds, function()
2072
2311
  doWork()
@@ -2119,9 +2358,9 @@ end`,
2119
2358
  }
2120
2359
  function classifyResource(call, context, callback) {
2121
2360
  const rawPath = context.getCallPath(call) ?? "";
2122
- const path3 = context.resolveCallPath(rawPath);
2361
+ const path4 = context.resolveCallPath(rawPath);
2123
2362
  const variable = assignedVariableName(call, callback);
2124
- const finalMember = path3.split(/[.:]/).at(-1) ?? path3;
2363
+ const finalMember = path4.split(/[.:]/).at(-1) ?? path4;
2125
2364
  if (/:Connect$/.test(rawPath)) {
2126
2365
  const ownerExpression = connectionOwnerExpression(rawPath);
2127
2366
  const ownerIsInstance = isProvenInstanceExpression(ownerExpression, call, context, callback);
@@ -2190,7 +2429,7 @@ function classifyResource(call, context, callback) {
2190
2429
  }
2191
2430
  };
2192
2431
  }
2193
- if (path3 === "ReactRoblox.createRoot") {
2432
+ if (path4 === "ReactRoblox.createRoot") {
2194
2433
  return {
2195
2434
  call,
2196
2435
  label: "React root",
@@ -2200,17 +2439,17 @@ function classifyResource(call, context, callback) {
2200
2439
  }
2201
2440
  };
2202
2441
  }
2203
- if (path3 === "task.delay" || path3 === "task.defer" || path3 === "task.spawn") {
2442
+ if (path4 === "task.delay" || path4 === "task.defer" || path4 === "task.spawn") {
2204
2443
  const taskCallback = taskCallbackNode(call, context, callback);
2205
2444
  const yieldPoint = taskCallback ? functionYieldPoint(taskCallback, context) : null;
2206
- const outlivesEffect = path3 !== "task.spawn" || Boolean(yieldPoint);
2445
+ const outlivesEffect = path4 !== "task.spawn" || Boolean(yieldPoint);
2207
2446
  if (!outlivesEffect)
2208
2447
  return null;
2209
2448
  return {
2210
2449
  call,
2211
- label: path3 === "task.spawn" ? "yielding spawned task" : path3 === "task.defer" ? "deferred task" : "delayed task",
2450
+ label: path4 === "task.spawn" ? "yielding spawned task" : path4 === "task.defer" ? "deferred task" : "delayed task",
2212
2451
  detail: yieldPoint ? `The task can outlive the effect because it yields at ${yieldPoint.reason}.` : "The task is scheduled to run after the effect callback can finish.",
2213
- severity: taskSeverity(path3, call, taskCallback, yieldPoint, context),
2452
+ severity: taskSeverity(path4, call, taskCallback, yieldPoint, context),
2214
2453
  variable,
2215
2454
  cleanupMatches(text, name) {
2216
2455
  if (name) {
@@ -2237,8 +2476,8 @@ var effectNeedsCleanup = {
2237
2476
  const rawPath = context.getCallPath(call);
2238
2477
  if (!rawPath)
2239
2478
  continue;
2240
- const path3 = context.resolveCallPath(rawPath);
2241
- if (path3 !== "React.useEffect" && path3 !== "React.useLayoutEffect")
2479
+ const path4 = context.resolveCallPath(rawPath);
2480
+ if (path4 !== "React.useEffect" && path4 !== "React.useLayoutEffect")
2242
2481
  continue;
2243
2482
  const callbackNode = firstFunctionArgument(context, call);
2244
2483
  const callback = functionInfoForNode(context, callbackNode);
@@ -2292,10 +2531,22 @@ function ownerForHook(context, call) {
2292
2531
  const owner = context.nearestFunction(call);
2293
2532
  return owner && (owner.isComponent || owner.isHook) ? owner : null;
2294
2533
  }
2534
+ function effectCallbackWithoutDeps(context, call) {
2535
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2536
+ if (!isEffectPath(path4))
2537
+ return null;
2538
+ const args = context.callArguments(call);
2539
+ const callback = args[0];
2540
+ if (callback?.type !== "function_definition")
2541
+ return null;
2542
+ if (args.length < 2 || args[1]?.type === "nil")
2543
+ return callback;
2544
+ return null;
2545
+ }
2295
2546
  function callbackAndDeps(context, call) {
2296
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2547
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2297
2548
  const args = context.callArguments(call);
2298
- if (path3 === "React.useImperativeHandle") {
2549
+ if (path4 === "React.useImperativeHandle") {
2299
2550
  const callback2 = args[1];
2300
2551
  const deps2 = args[2];
2301
2552
  if (callback2?.type === "function_definition" && deps2?.type === "table_constructor")
@@ -2325,8 +2576,8 @@ function hasExternalSubscription(node, context) {
2325
2576
  for (const child of context.walk(node)) {
2326
2577
  if (child.type !== "function_call")
2327
2578
  continue;
2328
- const path3 = context.getCallPath(child) ?? "";
2329
- if (/:Connect$/.test(path3) || /BindToRenderStep$/.test(path3) || /:BindAction(?:AtPriority)?$/.test(path3))
2579
+ const path4 = context.getCallPath(child) ?? "";
2580
+ if (/:Connect$/.test(path4) || /BindToRenderStep$/.test(path4) || /:BindAction(?:AtPriority)?$/.test(path4))
2330
2581
  return true;
2331
2582
  }
2332
2583
  return false;
@@ -2360,6 +2611,33 @@ function setterCalledOutsideCallback(context, owner, callback, setterName) {
2360
2611
  }
2361
2612
  return false;
2362
2613
  }
2614
+ function guaranteedRepeatedStateChange(argument, stateName, context) {
2615
+ if (argument.type === "table_constructor")
2616
+ return true;
2617
+ if (argument.type === "function_definition") {
2618
+ const updater = functionInfoForNode(context, argument);
2619
+ const previousName = updater?.parameters[0];
2620
+ const returnStatement = updater?.body?.namedChildren.find((child) => child.type === "return_statement");
2621
+ const returnValue = returnStatement?.namedChildren[0];
2622
+ if (previousName && returnValue && guaranteedRepeatedStateChange(returnValue, previousName, context))
2623
+ return true;
2624
+ return false;
2625
+ }
2626
+ const text = argument.text.trim();
2627
+ const escaped = stateName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2628
+ if (new RegExp(`^not\\s+${escaped}$`).test(text))
2629
+ return true;
2630
+ if (new RegExp(`^${escaped}\\s*[+-]\\s*(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)$`).test(text))
2631
+ return true;
2632
+ if (new RegExp(`^(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)\\s*\\+\\s*${escaped}$`).test(text))
2633
+ return true;
2634
+ if (argument.type === "function_call") {
2635
+ const path4 = context.resolveCallPath(context.getCallPath(argument) ?? "");
2636
+ if (path4 === "table.clone" || path4 === "table.create" || path4 === "table.pack")
2637
+ return true;
2638
+ }
2639
+ return false;
2640
+ }
2363
2641
  function freshDependencyKind(node, owner) {
2364
2642
  if (node.type === "table_constructor")
2365
2643
  return "table";
@@ -2382,8 +2660,8 @@ var noDerivedStateEffect = {
2382
2660
  run(context) {
2383
2661
  const diagnostics = [];
2384
2662
  for (const call of context.findCalls()) {
2385
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2386
- if (!isEffectPath(path3))
2663
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2664
+ if (!isEffectPath(path4))
2387
2665
  continue;
2388
2666
  const owner = ownerForHook(context, call);
2389
2667
  const parts = callbackAndDeps(context, call);
@@ -2421,28 +2699,48 @@ var noSelfUpdatingEffect = {
2421
2699
  id: "react-luau/no-self-updating-effect",
2422
2700
  category: "Effects",
2423
2701
  severity: "warning",
2424
- description: "Effects should not unconditionally update state that is also one of their dependencies.",
2702
+ description: "Effects should not unconditionally update state in a way that schedules themselves again.",
2425
2703
  run(context) {
2426
2704
  const diagnostics = [];
2427
2705
  for (const call of context.findCalls()) {
2428
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2429
- if (!isEffectPath(path3))
2706
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2707
+ if (!isEffectPath(path4))
2430
2708
  continue;
2431
2709
  const owner = ownerForHook(context, call);
2710
+ if (!owner)
2711
+ continue;
2432
2712
  const parts = callbackAndDeps(context, call);
2433
- if (!owner || !parts)
2713
+ if (parts) {
2714
+ const deps = parseDependencyRoots(parts.deps);
2715
+ for (const binding of stateBindingsFor(context, owner)) {
2716
+ if (!deps.has(binding.valueName))
2717
+ continue;
2718
+ for (const setterCall of directTopLevelCalls(context, parts.callback)) {
2719
+ if (context.getCallPath(setterCall) !== binding.setterName)
2720
+ continue;
2721
+ diagnostics.push({
2722
+ node: callNameNode(setterCall),
2723
+ message: `${binding.setterName}() updates ${binding.valueName}, which is also in this effect's dependency table.`,
2724
+ help: "Guard the update so it converges, move it to the originating event, or derive the value during render."
2725
+ });
2726
+ }
2727
+ }
2728
+ continue;
2729
+ }
2730
+ const callback = effectCallbackWithoutDeps(context, call);
2731
+ if (!callback)
2434
2732
  continue;
2435
- const deps = parseDependencyRoots(parts.deps);
2436
2733
  for (const binding of stateBindingsFor(context, owner)) {
2437
- if (!deps.has(binding.valueName))
2438
- continue;
2439
- for (const setterCall of directTopLevelCalls(context, parts.callback)) {
2734
+ for (const setterCall of directTopLevelCalls(context, callback)) {
2440
2735
  if (context.getCallPath(setterCall) !== binding.setterName)
2441
2736
  continue;
2737
+ const argument = context.callArguments(setterCall)[0];
2738
+ if (!argument || !guaranteedRepeatedStateChange(argument, binding.valueName, context))
2739
+ continue;
2442
2740
  diagnostics.push({
2443
2741
  node: callNameNode(setterCall),
2444
- message: `${binding.setterName}() updates ${binding.valueName}, which is also in this effect's dependency table.`,
2445
- help: "Guard the update so it converges, move it to the originating event, or derive the value during render."
2742
+ message: `${binding.setterName}() unconditionally changes ${binding.valueName} in an effect that runs after every render.`,
2743
+ help: "Add a dependency table or convergence guard, move the update to the event that owns it, or derive the value during render."
2446
2744
  });
2447
2745
  }
2448
2746
  }
@@ -2458,8 +2756,8 @@ var noEffectWithFreshDeps = {
2458
2756
  run(context) {
2459
2757
  const diagnostics = [];
2460
2758
  for (const call of context.findCalls()) {
2461
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2462
- if (!HOOKS_WITH_DEPS.has(path3))
2759
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2760
+ if (!HOOKS_WITH_DEPS.has(path4))
2463
2761
  continue;
2464
2762
  const owner = ownerForHook(context, call);
2465
2763
  const parts = callbackAndDeps(context, call);
@@ -2472,7 +2770,7 @@ var noEffectWithFreshDeps = {
2472
2770
  const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
2473
2771
  diagnostics.push({
2474
2772
  node: dependency,
2475
- message: `${path3} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
2773
+ message: `${path4} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
2476
2774
  help: "Move the value inside the hook callback and depend on its simple inputs, or intentionally stabilize the value with useMemo/useCallback when identity is part of the contract."
2477
2775
  });
2478
2776
  }
@@ -2488,8 +2786,8 @@ var noMutableInDeps = {
2488
2786
  run(context) {
2489
2787
  const diagnostics = [];
2490
2788
  for (const call of context.findCalls()) {
2491
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2492
- if (!HOOKS_WITH_DEPS.has(path3))
2789
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2790
+ if (!HOOKS_WITH_DEPS.has(path4))
2493
2791
  continue;
2494
2792
  const owner = ownerForHook(context, call);
2495
2793
  const parts = callbackAndDeps(context, call);
@@ -2582,23 +2880,61 @@ var HOOK_ARGUMENTS = new Map([
2582
2880
  ["React.useImperativeHandle", { callbackIndex: 1, depsIndex: 2 }]
2583
2881
  ]);
2584
2882
  function parameterNames2(functionNode) {
2585
- const names = new Set;
2586
- const parameters = functionNode.childForFieldName("parameters");
2587
- if (!parameters)
2588
- return names;
2589
- for (const node of parameters.namedChildren) {
2590
- for (const candidate of descendants(node)) {
2591
- if (candidate.type === "identifier")
2592
- names.add(candidate.text);
2593
- }
2594
- }
2595
- return names;
2883
+ return new Set(parameterBindingNames(functionNode.childForFieldName("parameters")));
2596
2884
  }
2597
2885
  function* descendants(node) {
2598
2886
  yield node;
2599
2887
  for (const child of node.namedChildren)
2600
2888
  yield* descendants(child);
2601
2889
  }
2890
+ var TYPE_ONLY_NODES = new Set([
2891
+ "type_definition",
2892
+ "type_annotation",
2893
+ "type_parameters",
2894
+ "generic_type_list",
2895
+ "generic_type",
2896
+ "function_type",
2897
+ "optional_type",
2898
+ "object_type",
2899
+ "union_type",
2900
+ "intersection_type",
2901
+ "tuple_type",
2902
+ "singleton_type",
2903
+ "variadic_type",
2904
+ "variadic_type_pack"
2905
+ ]);
2906
+ function isTypeOnlyChild(parent, child) {
2907
+ if (TYPE_ONLY_NODES.has(child.type))
2908
+ return true;
2909
+ if (TYPE_ONLY_NODES.has(parent.type))
2910
+ return true;
2911
+ if (parent.type === "parameter") {
2912
+ return !sameNode(parent.namedChildren.find((candidate) => candidate.type === "identifier"), child);
2913
+ }
2914
+ if (parent.type === "cast_expression" || parent.type === "type_cast_expression") {
2915
+ return !sameNode(parent.namedChildren[0], child);
2916
+ }
2917
+ if (parent.type === "function_definition" || parent.type === "function_declaration") {
2918
+ const name = parent.childForFieldName("name");
2919
+ const parameters = parent.childForFieldName("parameters");
2920
+ const body = parent.childForFieldName("body");
2921
+ if (sameNode(child, name) || sameNode(child, parameters) || sameNode(child, body))
2922
+ return false;
2923
+ return true;
2924
+ }
2925
+ if (parent.type === "variable_list" && parent.parent?.parent?.type === "variable_declaration") {
2926
+ return true;
2927
+ }
2928
+ return false;
2929
+ }
2930
+ function* runtimeDescendants(node) {
2931
+ yield node;
2932
+ for (const child of node.namedChildren) {
2933
+ if (isTypeOnlyChild(node, child))
2934
+ continue;
2935
+ yield* runtimeDescendants(child);
2936
+ }
2937
+ }
2602
2938
  function directBlockBindingNames(block, beforeIndex) {
2603
2939
  const names = new Set;
2604
2940
  for (const child of block.namedChildren) {
@@ -2674,8 +3010,8 @@ function rootIdentifierNode(node, root) {
2674
3010
  }
2675
3011
  return null;
2676
3012
  }
2677
- function normalizeCapturedPath(path3, stableVariables, externallyMutableRoots) {
2678
- const normalized = normalizeExpressionText(path3);
3013
+ function normalizeCapturedPath(path4, stableVariables, externallyMutableRoots) {
3014
+ const normalized = normalizeExpressionText(path4);
2679
3015
  const root = rootIdentifier(normalized);
2680
3016
  if (!root)
2681
3017
  return normalized;
@@ -2692,7 +3028,7 @@ function normalizeCapturedPath(path3, stableVariables, externallyMutableRoots) {
2692
3028
  }
2693
3029
  function capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots) {
2694
3030
  const captured = new Set;
2695
- for (const node of descendants(callback)) {
3031
+ for (const node of runtimeDescendants(callback)) {
2696
3032
  if (node.type === "dot_index_expression" && outermostDotPath(node)) {
2697
3033
  const root = rootIdentifier(node.text);
2698
3034
  if (!root || !available.has(root) || GLOBALS.has(root) || stableVariables.has(root))
@@ -2761,33 +3097,106 @@ function expandLocalFunctionCaptures(captured, owner, available, stableVariables
2761
3097
  addCapture(capture);
2762
3098
  return result;
2763
3099
  }
3100
+ function dependencyCoveragePath(node) {
3101
+ if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
3102
+ const expression = node.namedChildren[0];
3103
+ if (expression)
3104
+ return dependencyCoveragePath(expression);
3105
+ }
3106
+ if (node.type === "binary_expression") {
3107
+ const left = node.childForFieldName("left") ?? node.namedChildren[0];
3108
+ const right = node.childForFieldName("right") ?? node.namedChildren[1];
3109
+ const isOr = node.children.some((child) => child.type === "or");
3110
+ if (isOr && left && right?.type === "false")
3111
+ return dependencyCoveragePath(left);
3112
+ }
3113
+ return normalizeExpressionText(node.text);
3114
+ }
2764
3115
  function dependencyPaths(node) {
2765
- return new Set(dependencyExpressions(node).map((dependency) => normalizeExpressionText(dependency.text)));
3116
+ return new Set(dependencyExpressions(node).map(dependencyCoveragePath));
2766
3117
  }
2767
- var PURE_DERIVED_CALL = /^(?:math\.(?!random(?:seed)?$)[A-Za-z_][A-Za-z0-9_]*|string\.[A-Za-z_][A-Za-z0-9_]*|table\.(?:find|concat|clone|create|isfrozen)|(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|BrickColor|Rect|NumberRange|NumberSequence|ColorSequence|TweenInfo|Font|Ray|Region3|PhysicalProperties)\.[A-Za-z_][A-Za-z0-9_]*|[^:]+:(?:ToHSV|Lerp|Dot|Cross|FuzzyEq|Inverse|ToObjectSpace|ToWorldSpace))$/;
2768
- function isPureDerivedExpression(node, context) {
2769
- for (const candidate of context.walk(node)) {
2770
- if (candidate !== node && (candidate.type === "function_definition" || candidate.type === "function_declaration"))
2771
- return false;
2772
- if (candidate.type !== "function_call")
2773
- continue;
2774
- const rawPath = context.getCallPath(candidate);
2775
- if (!rawPath)
2776
- return false;
2777
- const path3 = normalizeExpressionText(context.resolveCallPath(rawPath));
2778
- if (!PURE_DERIVED_CALL.test(path3))
2779
- return false;
3118
+ function transparentAliasPath(node) {
3119
+ if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
3120
+ const expression = node.namedChildren[0];
3121
+ return expression ? transparentAliasPath(expression) : null;
2780
3122
  }
2781
- return true;
3123
+ if (node.type !== "identifier" && node.type !== "dot_index_expression")
3124
+ return null;
3125
+ return normalizeExpressionText(node.text);
2782
3126
  }
2783
- function mutatedOwnerLocals(owner, context) {
2784
- const mutated = new Set;
3127
+ function immutableTransparentAliases(owner, context) {
3128
+ const aliases = new Map;
2785
3129
  if (!owner.body)
2786
- return mutated;
2787
- for (const node of context.walk(owner.body)) {
2788
- if (node.type !== "assignment_statement" && node.type !== "update_statement")
2789
- continue;
2790
- if (node.parent?.type === "variable_declaration")
3130
+ return aliases;
3131
+ const mutated = mutatedOwnerLocals(owner, context);
3132
+ for (const statement of owner.body.namedChildren) {
3133
+ if (statement.type !== "variable_declaration")
3134
+ continue;
3135
+ const names = declarationNames2(statement);
3136
+ const expressions = declarationExpressions3(statement);
3137
+ for (let index = 0;index < names.length; index += 1) {
3138
+ const name = names[index];
3139
+ const expression = expressions[index];
3140
+ if (!expression || mutated.has(name))
3141
+ continue;
3142
+ const source = transparentAliasPath(expression);
3143
+ if (!source || source === name)
3144
+ continue;
3145
+ const sourceRoot = rootIdentifier(source);
3146
+ if (!sourceRoot || mutated.has(sourceRoot))
3147
+ continue;
3148
+ aliases.set(name, source);
3149
+ }
3150
+ }
3151
+ return aliases;
3152
+ }
3153
+ function resolveAliasPath(path4, aliases) {
3154
+ let current = path4;
3155
+ const visiting = new Set;
3156
+ while (true) {
3157
+ const root = rootIdentifier(current);
3158
+ if (!root || visiting.has(root))
3159
+ return current;
3160
+ const source = aliases.get(root);
3161
+ if (!source)
3162
+ return current;
3163
+ visiting.add(root);
3164
+ current = `${source}${current.slice(root.length)}`;
3165
+ }
3166
+ }
3167
+ function dependencyPathsWithAliases(node, aliases) {
3168
+ const dependencies = dependencyPaths(node);
3169
+ for (const dependency of [...dependencies]) {
3170
+ const resolved = resolveAliasPath(dependency, aliases);
3171
+ if (resolved !== dependency)
3172
+ dependencies.add(resolved);
3173
+ }
3174
+ return dependencies;
3175
+ }
3176
+ var PURE_DERIVED_CALL = /^(?:math\.(?!random(?:seed)?$)[A-Za-z_][A-Za-z0-9_]*|string\.[A-Za-z_][A-Za-z0-9_]*|table\.(?:find|concat|clone|create|isfrozen)|(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|BrickColor|Rect|NumberRange|NumberSequence|ColorSequence|TweenInfo|Font|Ray|Region3|PhysicalProperties)\.[A-Za-z_][A-Za-z0-9_]*|[^:]+:(?:ToHSV|Lerp|Dot|Cross|FuzzyEq|Inverse|ToObjectSpace|ToWorldSpace))$/;
3177
+ function isPureDerivedExpression(node, context) {
3178
+ for (const candidate of context.walk(node)) {
3179
+ if (candidate !== node && (candidate.type === "function_definition" || candidate.type === "function_declaration"))
3180
+ return false;
3181
+ if (candidate.type !== "function_call")
3182
+ continue;
3183
+ const rawPath = context.getCallPath(candidate);
3184
+ if (!rawPath)
3185
+ return false;
3186
+ const path4 = normalizeExpressionText(context.resolveCallPath(rawPath));
3187
+ if (!PURE_DERIVED_CALL.test(path4))
3188
+ return false;
3189
+ }
3190
+ return true;
3191
+ }
3192
+ function mutatedOwnerLocals(owner, context) {
3193
+ const mutated = new Set;
3194
+ if (!owner.body)
3195
+ return mutated;
3196
+ for (const node of context.walk(owner.body)) {
3197
+ if (node.type !== "assignment_statement" && node.type !== "update_statement")
3198
+ continue;
3199
+ if (node.parent?.type === "variable_declaration")
2791
3200
  continue;
2792
3201
  const variables = node.namedChildren.find((child) => child.type === "variable_list");
2793
3202
  if (!variables)
@@ -2854,8 +3263,8 @@ function hasDynamicUnpackDependencies(node, context) {
2854
3263
  const rawPath = context.getCallPath(candidate);
2855
3264
  if (!rawPath)
2856
3265
  continue;
2857
- const path3 = normalizeExpressionText(context.resolveCallPath(rawPath));
2858
- if (path3 === "unpack" || path3 === "table.unpack")
3266
+ const path4 = normalizeExpressionText(context.resolveCallPath(rawPath));
3267
+ if (path4 === "unpack" || path4 === "table.unpack")
2859
3268
  return true;
2860
3269
  }
2861
3270
  }
@@ -2880,8 +3289,8 @@ function memoizedProducerDependencies(owner, context) {
2880
3289
  const rawPath = context.getCallPath(expression);
2881
3290
  if (!rawPath)
2882
3291
  continue;
2883
- const path3 = context.resolveCallPath(rawPath);
2884
- if (path3 !== "React.useMemo" && path3 !== "React.useCallback")
3292
+ const path4 = context.resolveCallPath(rawPath);
3293
+ if (path4 !== "React.useMemo" && path4 !== "React.useCallback")
2885
3294
  continue;
2886
3295
  const args = context.callArguments(expression);
2887
3296
  const deps = args[1];
@@ -2940,9 +3349,9 @@ function directCallsInExpression(node) {
2940
3349
  return calls;
2941
3350
  }
2942
3351
  function isCustomHookCall(call, context) {
2943
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2944
- const final = path3.split(/[.:]/).at(-1) ?? "";
2945
- return !path3.startsWith("React.") && /^use[A-Z0-9_]/.test(final);
3352
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3353
+ const final = path4.split(/[.:]/).at(-1) ?? "";
3354
+ return !path4.startsWith("React.") && /^use[A-Z0-9_]/.test(final);
2946
3355
  }
2947
3356
  var HANDLE_VALUE_NAME = /(?:ref|binding|tween|spring|motion|animation|controller|handle|connection|signal)$/i;
2948
3357
  var HANDLE_HOOK_NAME = /(?:Tween|Spring|Binding|Motion|Animated|Animation|Sound)$/i;
@@ -2952,8 +3361,8 @@ function isCalledAsFunction(owner, name, context) {
2952
3361
  for (const call of context.walk(owner.body)) {
2953
3362
  if (call.type !== "function_call")
2954
3363
  continue;
2955
- const path3 = normalizeExpressionText(context.getCallPath(call) ?? "");
2956
- if (path3 === name)
3364
+ const path4 = normalizeExpressionText(context.getCallPath(call) ?? "");
3365
+ if (path4 === name)
2957
3366
  return true;
2958
3367
  }
2959
3368
  return false;
@@ -2983,8 +3392,8 @@ function customHookDependencyHints(owner, context) {
2983
3392
  continue;
2984
3393
  const directHook = calls.find((call) => isCustomHookCall(call, context));
2985
3394
  if (directHook && expression.type === "function_call") {
2986
- const path3 = context.resolveCallPath(context.getCallPath(directHook) ?? "");
2987
- const final = path3.split(/[.:]/).at(-1) ?? "";
3395
+ const path4 = context.resolveCallPath(context.getCallPath(directHook) ?? "");
3396
+ const final = path4.split(/[.:]/).at(-1) ?? "";
2988
3397
  const likelyHandle = HANDLE_VALUE_NAME.test(name) || HANDLE_HOOK_NAME.test(final) || isCalledAsFunction(owner, name, context);
2989
3398
  if (likelyHandle) {
2990
3399
  hints.memberHandleRoots.add(name);
@@ -2994,14 +3403,14 @@ function customHookDependencyHints(owner, context) {
2994
3403
  }
2995
3404
  if (expression.type === "table_constructor") {
2996
3405
  const meaningfulCalls = calls.filter((call) => {
2997
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2998
- return !/^(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|TweenInfo|NumberSequence|ColorSequence)\./.test(path3);
3406
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3407
+ return !/^(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|TweenInfo|NumberSequence|ColorSequence)\./.test(path4);
2999
3408
  });
3000
3409
  const allHandleHooks = meaningfulCalls.length > 0 && meaningfulCalls.every((call) => {
3001
3410
  if (!isCustomHookCall(call, context))
3002
3411
  return false;
3003
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
3004
- const final = path3.split(/[.:]/).at(-1) ?? "";
3412
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3413
+ const final = path4.split(/[.:]/).at(-1) ?? "";
3005
3414
  return HANDLE_HOOK_NAME.test(final);
3006
3415
  });
3007
3416
  if (allHandleHooks)
@@ -3031,12 +3440,13 @@ var exhaustiveDeps = {
3031
3440
  const derivedDependenciesByOwner = new Map;
3032
3441
  const dependencyHintsByOwner = new Map;
3033
3442
  const memoizedProducersByOwner = new Map;
3443
+ const transparentAliasesByOwner = new Map;
3034
3444
  for (const call of context.findCalls()) {
3035
3445
  const rawPath = context.getCallPath(call);
3036
3446
  if (!rawPath)
3037
3447
  continue;
3038
- const path3 = context.resolveCallPath(rawPath);
3039
- const shape = HOOK_ARGUMENTS.get(path3);
3448
+ const path4 = context.resolveCallPath(rawPath);
3449
+ const shape = HOOK_ARGUMENTS.get(path4);
3040
3450
  if (!shape)
3041
3451
  continue;
3042
3452
  const args = context.callArguments(call);
@@ -3052,7 +3462,12 @@ var exhaustiveDeps = {
3052
3462
  const stableVariables = context.model.stableVariablesByFunction.get(ownerKey) ?? new Set;
3053
3463
  const externallyMutableRoots = context.model.externalMutableVariablesByFunction.get(ownerKey) ?? new Set;
3054
3464
  const functionExpandedCaptures = expandLocalFunctionCaptures(capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots), owner, available, stableVariables, externallyMutableRoots, context);
3055
- const dependencies = dependencyPaths(deps);
3465
+ let transparentAliases = transparentAliasesByOwner.get(ownerKey);
3466
+ if (!transparentAliases) {
3467
+ transparentAliases = immutableTransparentAliases(owner, context);
3468
+ transparentAliasesByOwner.set(ownerKey, transparentAliases);
3469
+ }
3470
+ const dependencies = dependencyPathsWithAliases(deps, transparentAliases);
3056
3471
  let derivedDependencies = derivedDependenciesByOwner.get(ownerKey);
3057
3472
  if (!derivedDependencies) {
3058
3473
  derivedDependencies = derivedLocalDependencies(owner, available, stableVariables, externallyMutableRoots, context);
@@ -3066,7 +3481,7 @@ var exhaustiveDeps = {
3066
3481
  diagnostics.push({
3067
3482
  node: tableStartNode(deps),
3068
3483
  severity: "suggestion",
3069
- message: `${path3} uses dynamic unpack(...) dependencies, so exhaustive dependency analysis is partial.`,
3484
+ message: `${path4} uses dynamic unpack(...) dependencies, so exhaustive dependency analysis is partial.`,
3070
3485
  help: "The analyzer cannot prove which values unpack(...) contributes. Review the explicit dependencies and the custom hook contract."
3071
3486
  });
3072
3487
  continue;
@@ -3087,7 +3502,7 @@ var exhaustiveDeps = {
3087
3502
  if (reactiveMissing.length > 0) {
3088
3503
  diagnostics.push({
3089
3504
  node: tableStartNode(deps),
3090
- message: `${path3} dependency table is missing ${reactiveMissing.join(", ")}.`,
3505
+ message: `${path4} dependency table is missing ${reactiveMissing.join(", ")}.`,
3091
3506
  help: `Add ${reactiveMissing.join(", ")} to the dependency table, or stop capturing ${reactiveMissing.length === 1 ? "that value" : "those values"} in the callback.`
3092
3507
  });
3093
3508
  }
@@ -3095,7 +3510,7 @@ var exhaustiveDeps = {
3095
3510
  diagnostics.push({
3096
3511
  node: tableStartNode(deps),
3097
3512
  severity: "suggestion",
3098
- message: `${path3} captures memoized value ${transitivelyCoveredMemoized.join(", ")} without listing ${transitivelyCoveredMemoized.length === 1 ? "it directly" : "them directly"}, but ${transitivelyCoveredMemoized.length === 1 ? "its memo dependencies are" : "their memo dependencies are"} already covered.`,
3513
+ message: `${path4} captures memoized value ${transitivelyCoveredMemoized.join(", ")} without listing ${transitivelyCoveredMemoized.length === 1 ? "it directly" : "them directly"}, but ${transitivelyCoveredMemoized.length === 1 ? "its memo dependencies are" : "their memo dependencies are"} already covered.`,
3099
3514
  help: "The producing memo dependencies are already covered. Listing the memoized value directly can make the dependency relationship clearer."
3100
3515
  });
3101
3516
  }
@@ -3103,7 +3518,7 @@ var exhaustiveDeps = {
3103
3518
  diagnostics.push({
3104
3519
  node: tableStartNode(deps),
3105
3520
  severity: "suggestion",
3106
- message: `${path3} captures custom-hook handle ${uncertainHandles.join(", ")} without listing ${uncertainHandles.length === 1 ? "it as a dependency" : "them as dependencies"}.`,
3521
+ message: `${path4} captures custom-hook handle ${uncertainHandles.join(", ")} without listing ${uncertainHandles.length === 1 ? "it as a dependency" : "them as dependencies"}.`,
3107
3522
  help: "The analyzer cannot prove this custom-hook return is stable. Check the hook contract before changing the dependency table."
3108
3523
  });
3109
3524
  }
@@ -3137,8 +3552,8 @@ function createElementCallsInLoop(context, loop) {
3137
3552
  for (const node of context.walk(loop)) {
3138
3553
  if (node.type !== "function_call")
3139
3554
  continue;
3140
- const path3 = context.resolveCallPath(context.getCallPath(node) ?? "");
3141
- if (path3 === "React.createElement")
3555
+ const path4 = context.resolveCallPath(context.getCallPath(node) ?? "");
3556
+ if (path4 === "React.createElement")
3142
3557
  result.push(node);
3143
3558
  }
3144
3559
  return result;
@@ -3194,8 +3609,8 @@ var noCreateContextInRender = {
3194
3609
  run(context) {
3195
3610
  const diagnostics = [];
3196
3611
  for (const call of context.findCalls()) {
3197
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
3198
- if (path3 !== "React.createContext")
3612
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3613
+ if (path4 !== "React.createContext")
3199
3614
  continue;
3200
3615
  const owner = context.nearestFunction(call);
3201
3616
  if (!owner || !owner.isComponent && !owner.isHook)
@@ -3231,8 +3646,8 @@ function isRenderedBy(context, component, name) {
3231
3646
  continue;
3232
3647
  if (context.nearestFunction(call) !== component)
3233
3648
  continue;
3234
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
3235
- if (path3 !== "React.createElement")
3649
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3650
+ if (path4 !== "React.createElement")
3236
3651
  continue;
3237
3652
  if (context.callArguments(call)[0]?.text.trim() === name)
3238
3653
  return true;
@@ -3276,63 +3691,6 @@ var noNestedComponentDefinition = {
3276
3691
  }
3277
3692
  };
3278
3693
 
3279
- // src/module-resolution.ts
3280
- import path3 from "path";
3281
- function normalizeRelative(value) {
3282
- return value.split(path3.sep).join("/");
3283
- }
3284
- function moduleKeys(relativePath) {
3285
- let normalized = normalizeRelative(relativePath).replace(/\.(?:lua|luau)$/i, "");
3286
- if (normalized.endsWith("/init"))
3287
- normalized = normalized.slice(0, -"/init".length);
3288
- const segments = normalized.split("/").filter(Boolean);
3289
- if (segments[0]?.toLowerCase() === "src")
3290
- segments.shift();
3291
- const keys = new Set;
3292
- for (let index = 0;index < segments.length; index += 1) {
3293
- const suffix = segments.slice(index).join(".").toLowerCase();
3294
- if (suffix)
3295
- keys.add(suffix);
3296
- }
3297
- return [...keys];
3298
- }
3299
- function normalizeRequireTarget(text) {
3300
- return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
3301
- }
3302
- function buildUniqueFeatureAliases(modules, featureByModuleId) {
3303
- const owners = new Map;
3304
- for (const module of modules) {
3305
- for (const key of module.keys) {
3306
- const ids = owners.get(key) ?? new Set;
3307
- ids.add(module.id);
3308
- owners.set(key, ids);
3309
- }
3310
- }
3311
- const aliases = new Map;
3312
- for (const module of modules) {
3313
- const value = featureByModuleId.get(module.id);
3314
- if (value === undefined)
3315
- continue;
3316
- for (const key of module.keys) {
3317
- const ids = owners.get(key);
3318
- if (ids?.size === 1 && ids.has(module.id))
3319
- aliases.set(key, value);
3320
- }
3321
- }
3322
- return aliases;
3323
- }
3324
- function resolveModuleReference(target, aliases) {
3325
- let candidate = target;
3326
- while (true) {
3327
- if (aliases.has(candidate))
3328
- return aliases.get(candidate);
3329
- const separator = candidate.indexOf(".");
3330
- if (separator < 0)
3331
- return null;
3332
- candidate = candidate.slice(separator + 1);
3333
- }
3334
- }
3335
-
3336
3694
  // src/rules/parameter-mutations.ts
3337
3695
  var analysisCache = new WeakMap;
3338
3696
  var EMPTY_INDEXES = new Set;
@@ -4059,7 +4417,7 @@ var rerenderHighFrequencyState = {
4059
4417
  seen.add(key);
4060
4418
  const renderCost = componentRenderCost(context, component);
4061
4419
  diagnostics.push({
4062
- node: callNameNode(call),
4420
+ node: callNameNode(binding.call),
4063
4421
  severity: "warning",
4064
4422
  message: `${binding.valueName} has a state setter called from a per-frame callback and may cause frequent rerenders when its value changes.`,
4065
4423
  help: renderCost.length > 0 ? `This render also performs ${renderCost.join(" and ")}. Isolate or throttle the high-frequency state, or use Bindings for host-property-only updates.` : "Isolate or throttle the high-frequency state. Use React.useBinding when the value only drives host Instance properties."
@@ -4336,7 +4694,9 @@ var preferUseRefForMutableCell = {
4336
4694
  };
4337
4695
 
4338
4696
  // src/rules/prefer-binding-over-state.ts
4339
- var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Once|Subscribe|Observe|Listen|Watch)$/i;
4697
+ var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
4698
+ var PERSISTENT_MIRROR_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
4699
+ var LIKELY_CONTINUOUS_CALLBACK = /(?:^|[.:])onStep$/i;
4340
4700
  var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
4341
4701
  function replaceWithinNode(container, target, replacement) {
4342
4702
  const start = target.startIndex - container.startIndex;
@@ -4459,13 +4819,64 @@ var TRANSPARENT_PARENTS = new Set([
4459
4819
  "string_interpolation",
4460
4820
  "expression_list",
4461
4821
  "assignment_statement",
4462
- "arguments",
4463
- "function_call",
4464
4822
  "dot_index_expression",
4465
4823
  "method_index_expression",
4466
4824
  "table_constructor",
4467
4825
  "field"
4468
4826
  ]);
4827
+ var PURE_BINDING_DERIVATION_CALL = /^(?:tostring|tonumber|type|typeof|math\.[A-Za-z_][A-Za-z0-9_]*|string\.[A-Za-z_][A-Za-z0-9_]*|utf8\.[A-Za-z_][A-Za-z0-9_]*|bit32\.[A-Za-z_][A-Za-z0-9_]*|(?:UDim|UDim2|Vector2|Vector3|Color3|CFrame|Rect|NumberRange|NumberSequence|ColorSequence|BrickColor|Font)\.[A-Za-z_][A-Za-z0-9_]*)$/;
4828
+ var HOST_FEEDBACK_PROPERTIES = new Set([
4829
+ "AbsoluteSize",
4830
+ "AbsolutePosition",
4831
+ "AbsoluteRotation",
4832
+ "AbsoluteContentSize",
4833
+ "AbsoluteCanvasSize",
4834
+ "AbsoluteCellSize",
4835
+ "AbsoluteCellCount",
4836
+ "TextBounds",
4837
+ "CanvasPosition"
4838
+ ]);
4839
+ var LIKELY_CONTINUOUS_PROPERTIES = new Set([
4840
+ "CFrame",
4841
+ "WorldCFrame",
4842
+ "Position",
4843
+ "WorldPosition",
4844
+ "Rotation",
4845
+ "Orientation",
4846
+ "CanvasPosition",
4847
+ "PlaybackLoudness",
4848
+ "TimePosition"
4849
+ ]);
4850
+ var SEMANTIC_SNAPSHOT_EXPRESSION = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
4851
+ function isBindingMapSafeCall(call, context) {
4852
+ if (call.type !== "function_call")
4853
+ return false;
4854
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "").replace(/\s+/g, "");
4855
+ return PURE_BINDING_DERIVATION_CALL.test(path4);
4856
+ }
4857
+ function isTransparentParent(parent, context) {
4858
+ if (parent.type === "function_call")
4859
+ return isBindingMapSafeCall(parent, context);
4860
+ if (parent.type === "arguments") {
4861
+ return parent.parent?.type === "function_call" && isBindingMapSafeCall(parent.parent, context);
4862
+ }
4863
+ return TRANSPARENT_PARENTS.has(parent.type);
4864
+ }
4865
+ function stringLiteralValue(node) {
4866
+ if (!node)
4867
+ return null;
4868
+ return node.text.trim().match(/^["']([^"']+)["']$/)?.[1] ?? null;
4869
+ }
4870
+ function observedPropertyForCall(summary, call, context) {
4871
+ if (summary.observedPropertyName)
4872
+ return summary.observedPropertyName;
4873
+ if (summary.observedPropertyParameterIndex === undefined)
4874
+ return null;
4875
+ return stringLiteralValue(context.callArguments(call)[summary.observedPropertyParameterIndex]);
4876
+ }
4877
+ function isStrongHostFeedbackProperty(property) {
4878
+ return Boolean(property && HOST_FEEDBACK_PROPERTIES.has(property));
4879
+ }
4469
4880
  function isInside(node, ancestor) {
4470
4881
  let current = node;
4471
4882
  while (current) {
@@ -4549,13 +4960,75 @@ function isTransparentUsage(node, context, compatibleComponentProps) {
4549
4960
  }
4550
4961
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4551
4962
  return false;
4552
- if (!TRANSPARENT_PARENTS.has(parent.type))
4963
+ if (!isTransparentParent(parent, context))
4553
4964
  return false;
4554
4965
  current = parent;
4555
4966
  }
4556
4967
  return false;
4557
4968
  }
4558
- function derivedLocalDeclarationForRead(node) {
4969
+ function isDirectBindingValueUsage(node, context, compatibleComponentProps) {
4970
+ let current = node;
4971
+ while (current) {
4972
+ const parent = current.parent;
4973
+ if (!parent)
4974
+ return false;
4975
+ if (parent.type === "parenthesized_expression" || parent.type === "cast_expression" || parent.type === "type_cast_expression") {
4976
+ current = parent;
4977
+ continue;
4978
+ }
4979
+ if (parent.type !== "field")
4980
+ return false;
4981
+ const value = fieldValue(parent);
4982
+ if (!value || !sameNode(value, current))
4983
+ return false;
4984
+ const property = fieldName(parent);
4985
+ if (!property || NON_BINDABLE_HOST_FIELDS.has(property))
4986
+ return false;
4987
+ const table = parent.parent;
4988
+ const args = table?.parent;
4989
+ const call = args?.parent;
4990
+ if (table?.type !== "table_constructor" || args?.type !== "arguments" || !call || !isCreateElementCall2(call, context)) {
4991
+ return false;
4992
+ }
4993
+ if (!sameNode(context.callArguments(call)[1], table))
4994
+ return false;
4995
+ if (isHostCreateElementCall(call, context))
4996
+ return true;
4997
+ const component = context.callArguments(call)[0]?.text.trim() ?? "";
4998
+ return compatibleComponentProps.get(component)?.has(property) ?? false;
4999
+ }
5000
+ return false;
5001
+ }
5002
+ function allReadsDirectlyBindingSubstitutable(valueName, owner, declaration, context, compatibleComponentProps) {
5003
+ if (!owner.body)
5004
+ return false;
5005
+ let reads = 0;
5006
+ for (const node of context.walk(owner.body)) {
5007
+ if (!isReadNode(node, valueName, owner, declaration))
5008
+ continue;
5009
+ reads += 1;
5010
+ if (!isDirectBindingValueUsage(node, context, compatibleComponentProps))
5011
+ return false;
5012
+ }
5013
+ return reads > 0;
5014
+ }
5015
+ function derivedLocalDeclarationForRead(node, context) {
5016
+ let current = node;
5017
+ while (current) {
5018
+ const parent = current.parent;
5019
+ if (!parent)
5020
+ return null;
5021
+ if (parent.type === "variable_declaration")
5022
+ return parent;
5023
+ if (parent.type === "function_definition" || parent.type === "function_declaration")
5024
+ return null;
5025
+ if (!isTransparentParent(parent, context))
5026
+ return null;
5027
+ current = parent;
5028
+ }
5029
+ return null;
5030
+ }
5031
+ function opaqueDerivedLocalDeclarationForRead(node, context) {
4559
5032
  let current = node;
4560
5033
  while (current) {
4561
5034
  const parent = current.parent;
@@ -4565,7 +5038,14 @@ function derivedLocalDeclarationForRead(node) {
4565
5038
  return parent;
4566
5039
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4567
5040
  return null;
4568
- if (!TRANSPARENT_PARENTS.has(parent.type))
5041
+ if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "for_statement" || parent.type === "for_in_statement" || parent.type === "return_statement")
5042
+ return null;
5043
+ if (parent.type === "function_call") {
5044
+ const path4 = context.resolveCallPath(context.getCallPath(parent) ?? "");
5045
+ if (/^React\.use(?:Memo|Callback|Effect|LayoutEffect)$/.test(path4))
5046
+ return null;
5047
+ }
5048
+ if (!TRANSPARENT_PARENTS.has(parent.type) && parent.type !== "arguments" && parent.type !== "function_call")
4569
5049
  return null;
4570
5050
  current = parent;
4571
5051
  }
@@ -4585,7 +5065,7 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
4585
5065
  reads += 1;
4586
5066
  if (isTransparentUsage(node, context, compatibleComponentProps))
4587
5067
  continue;
4588
- const derivedDeclaration = derivedLocalDeclarationForRead(node);
5068
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
4589
5069
  const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
4590
5070
  if (derivedDeclaration && derivedNames.length === 1) {
4591
5071
  if (stateReadsAreBindingCompatible(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited)))
@@ -4595,12 +5075,46 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
4595
5075
  }
4596
5076
  return reads > 0;
4597
5077
  }
5078
+ function propertyChangedSignalProperty(text) {
5079
+ return text.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/)?.[1] ?? null;
5080
+ }
5081
+ function reactChangePropertyForCallback(node) {
5082
+ let current = node.parent;
5083
+ while (current && current.type !== "function_definition" && current.type !== "function_declaration") {
5084
+ if (current.type === "field") {
5085
+ return current.text.match(/React\s*\.\s*Change\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? null;
5086
+ }
5087
+ current = current.parent;
5088
+ }
5089
+ return null;
5090
+ }
5091
+ function evidenceForExternalSource(highFrequency, hostFeedbackProperty) {
5092
+ const hostFeedbackProperties = new Set;
5093
+ if (hostFeedbackProperty)
5094
+ hostFeedbackProperties.add(hostFeedbackProperty);
5095
+ return {
5096
+ highFrequency,
5097
+ external: true,
5098
+ hostFeedbackProperties,
5099
+ genericExternal: !highFrequency && !hostFeedbackProperty
5100
+ };
5101
+ }
5102
+ function mergeSourceEvidence(target, source) {
5103
+ target.highFrequency ||= source.highFrequency;
5104
+ target.external ||= source.external;
5105
+ target.genericExternal ||= source.genericExternal;
5106
+ for (const property of source.hostFeedbackProperties)
5107
+ target.hostFeedbackProperties.add(property);
5108
+ }
4598
5109
  function callbackSource(call, context) {
4599
5110
  const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
4600
- const highFrequency = isHighFrequencyRunServiceCall(call, context);
5111
+ const highFrequency = isHighFrequencyRobloxCall(call, context) || LIKELY_CONTINUOUS_CALLBACK.test(normalized);
5112
+ const hostFeedbackProperty = propertyChangedSignalProperty(call.text);
4601
5113
  const final = normalized.split(/[.:]/).at(-1) ?? normalized;
4602
- const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
4603
- return { highFrequency, external };
5114
+ const external = highFrequency || Boolean(hostFeedbackProperty) || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
5115
+ if (!external)
5116
+ return null;
5117
+ return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
4604
5118
  }
4605
5119
  function importedCallbackSource(call, callbackArgument, importedCallbacks, context) {
4606
5120
  const path4 = context.getCallPath(call) ?? "";
@@ -4611,12 +5125,14 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
4611
5125
  const callbackIndex = args.findIndex((arg) => sameNode(arg, callbackArgument));
4612
5126
  if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
4613
5127
  return null;
4614
- return {
4615
- highFrequency: summary.highFrequency || args.some((arg) => isHighFrequencyRunServiceExpression(arg, context)),
4616
- external: true
4617
- };
5128
+ const highFrequency = summary.highFrequency || args.some((arg) => isHighFrequencyRobloxExpression(arg, context));
5129
+ const hostFeedbackProperty = args.map((arg) => propertyChangedSignalProperty(arg.text)).find((property) => Boolean(property));
5130
+ return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
4618
5131
  }
4619
5132
  function directCallbackSource(node, importedCallbacks, context) {
5133
+ const reactChangeProperty = reactChangePropertyForCallback(node);
5134
+ if (reactChangeProperty)
5135
+ return evidenceForExternalSource(false, reactChangeProperty);
4620
5136
  const parent = node.parent;
4621
5137
  if (parent?.type !== "arguments" || parent.parent?.type !== "function_call")
4622
5138
  return null;
@@ -4624,14 +5140,17 @@ function directCallbackSource(node, importedCallbacks, context) {
4624
5140
  const imported = importedCallbackSource(call, node, importedCallbacks, context);
4625
5141
  if (imported)
4626
5142
  return imported;
4627
- const source = callbackSource(call, context);
4628
- return source.external ? source : null;
5143
+ return callbackSource(call, context);
4629
5144
  }
4630
5145
  function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
4631
5146
  if (!fn.name || !owner.body)
4632
5147
  return null;
4633
- let foundExternal = false;
4634
- let foundHighFrequency = false;
5148
+ const result = {
5149
+ highFrequency: false,
5150
+ external: false,
5151
+ hostFeedbackProperties: new Set,
5152
+ genericExternal: false
5153
+ };
4635
5154
  for (const call of context.walk(owner.body)) {
4636
5155
  if (call.type !== "function_call")
4637
5156
  continue;
@@ -4639,14 +5158,12 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
4639
5158
  const callbackArg = args.find((arg) => arg.type === "identifier" && arg.text === fn.name);
4640
5159
  if (!callbackArg)
4641
5160
  continue;
4642
- const imported = importedCallbackSource(call, callbackArg, importedCallbacks, context);
4643
- const source = imported ?? callbackSource(call, context);
4644
- if (!source.external)
5161
+ const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
5162
+ if (!source)
4645
5163
  continue;
4646
- foundExternal = true;
4647
- foundHighFrequency ||= source.highFrequency;
5164
+ mergeSourceEvidence(result, source);
4648
5165
  }
4649
- return foundExternal ? { highFrequency: foundHighFrequency, external: true } : null;
5166
+ return result.external ? result : null;
4650
5167
  }
4651
5168
  function setterCallbackSource(call, binding, importedCallbacks, context) {
4652
5169
  const owner = binding.owner;
@@ -4660,25 +5177,178 @@ function setterCallbackSource(call, binding, importedCallbacks, context) {
4660
5177
  return direct;
4661
5178
  return namedFunctionCallbackSource(nearest, owner, importedCallbacks, context);
4662
5179
  }
4663
- function bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context) {
5180
+ function mirrorConfidenceRank(confidence) {
5181
+ if (confidence === "strong")
5182
+ return 2;
5183
+ if (confidence === "possible")
5184
+ return 1;
5185
+ return 0;
5186
+ }
5187
+ function mergeMirrorConfidence(a, b) {
5188
+ return mirrorConfidenceRank(b) > mirrorConfidenceRank(a) ? b : a;
5189
+ }
5190
+ function isLiteralStateTransition(node) {
5191
+ if (!node)
5192
+ return true;
5193
+ const text = node.text.trim();
5194
+ return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
5195
+ }
5196
+ function isSemanticSnapshotExpression(node) {
5197
+ return Boolean(node && SEMANTIC_SNAPSHOT_EXPRESSION.test(node.text));
5198
+ }
5199
+ function expressionReferencesCallbackParameter(expression, callback, context) {
5200
+ if (callback.parameters.length === 0)
5201
+ return false;
5202
+ const parameters = new Set(callback.parameters);
5203
+ return [...context.walk(expression)].some((node) => node.type === "identifier" && parameters.has(node.text));
5204
+ }
5205
+ var PURE_EXPRESSION_ROOTS = new Set([
5206
+ "math",
5207
+ "string",
5208
+ "utf8",
5209
+ "bit32",
5210
+ "UDim",
5211
+ "UDim2",
5212
+ "Vector2",
5213
+ "Vector3",
5214
+ "Color3",
5215
+ "CFrame",
5216
+ "Rect",
5217
+ "NumberRange",
5218
+ "NumberSequence",
5219
+ "ColorSequence",
5220
+ "BrickColor",
5221
+ "Font",
5222
+ "Enum",
5223
+ "tostring",
5224
+ "tonumber",
5225
+ "type",
5226
+ "typeof"
5227
+ ]);
5228
+ function callbackLocalNamesBefore(callback, before, context) {
5229
+ const names = new Set;
5230
+ if (!callback.body)
5231
+ return names;
5232
+ for (const node of context.walk(callback.body)) {
5233
+ if (node.startIndex >= before.startIndex)
5234
+ continue;
5235
+ if (context.nearestFunction(node) !== callback || node.type !== "variable_declaration")
5236
+ continue;
5237
+ for (const name of declarationNames2(node))
5238
+ names.add(name);
5239
+ }
5240
+ return names;
5241
+ }
5242
+ function expressionHasExternalInput(expression, callback, setterCall, context) {
5243
+ const callbackLocals = callbackLocalNamesBefore(callback, setterCall, context);
5244
+ const callbackParameters = new Set(callback.parameters);
5245
+ for (const node of context.walk(expression)) {
5246
+ if (node.type !== "identifier" || isIdentifierPropertyName2(node))
5247
+ continue;
5248
+ const name = node.text;
5249
+ if (callbackParameters.has(name))
5250
+ return true;
5251
+ if (callbackLocals.has(name))
5252
+ continue;
5253
+ if (context.model.stateValues.has(name))
5254
+ continue;
5255
+ if (PURE_EXPRESSION_ROOTS.has(name))
5256
+ continue;
5257
+ return true;
5258
+ }
5259
+ return false;
5260
+ }
5261
+ function setterHasDistinctValuesInCallback(call, callback, context) {
5262
+ if (!callback.body)
5263
+ return false;
5264
+ const setterPath = context.getCallPath(call);
5265
+ const distinctValues = new Set;
5266
+ for (const sibling of context.walk(callback.body)) {
5267
+ if (sibling.type !== "function_call" || context.nearestFunction(sibling) !== callback)
5268
+ continue;
5269
+ if (context.getCallPath(sibling) !== setterPath)
5270
+ continue;
5271
+ const argument = context.callArguments(sibling)[0];
5272
+ if (argument)
5273
+ distinctValues.add(argument.text.trim());
5274
+ }
5275
+ return distinctValues.size > 1;
5276
+ }
5277
+ function setterCallMirrorConfidence(call, callback, source, context) {
5278
+ const value = context.callArguments(call)[0];
5279
+ if (!value || value.type === "function_definition")
5280
+ return "none";
5281
+ if (isLiteralStateTransition(value)) {
5282
+ return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
5283
+ }
5284
+ if (isSemanticSnapshotExpression(value))
5285
+ return "none";
5286
+ if (expressionReferencesCallbackParameter(value, callback, context))
5287
+ return "strong";
5288
+ if (source.hostFeedbackProperties.size > 0) {
5289
+ const text = value.text;
5290
+ if ([...source.hostFeedbackProperties].some((property) => new RegExp(`(?:\\.|["'])${property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text))) {
5291
+ return "strong";
5292
+ }
5293
+ }
5294
+ if (!expressionHasExternalInput(value, callback, call, context)) {
5295
+ return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
5296
+ }
5297
+ if (source.highFrequency)
5298
+ return "strong";
5299
+ return source.genericExternal ? "possible" : "strong";
5300
+ }
5301
+ function mergeBindingUpdateEvidence(target, source, mirrorConfidence) {
5302
+ if (mirrorConfidence === "none")
5303
+ return;
5304
+ mergeSourceEvidence(target, source);
5305
+ target.mirrorConfidence = mergeMirrorConfidence(target.mirrorConfidence, mirrorConfidence);
5306
+ }
5307
+ function bindingUpdateEvidence(binding, importedCallbacks, context) {
4664
5308
  const owner = binding.owner;
4665
5309
  if (!owner?.body)
4666
5310
  return null;
4667
- let highFrequency = false;
4668
- let external = false;
5311
+ const result = {
5312
+ highFrequency: false,
5313
+ external: false,
5314
+ hostFeedbackProperties: new Set,
5315
+ genericExternal: false,
5316
+ mirrorConfidence: "none"
5317
+ };
4669
5318
  for (const node of context.walk(owner.body)) {
4670
5319
  if (node.type !== "function_call")
4671
5320
  continue;
4672
5321
  const path4 = context.getCallPath(node);
4673
- if (path4 !== binding.setterName)
5322
+ if (path4 === binding.setterName) {
5323
+ const source2 = setterCallbackSource(node, binding, importedCallbacks, context);
5324
+ const callback = context.nearestFunction(node);
5325
+ if (!source2 || !callback || callback === owner)
5326
+ continue;
5327
+ mergeBindingUpdateEvidence(result, source2, setterCallMirrorConfidence(node, callback, source2, context));
5328
+ continue;
5329
+ }
5330
+ const args = context.callArguments(node);
5331
+ const setterArg = args.find((arg) => arg.type === "identifier" && arg.text === binding.setterName);
5332
+ if (!setterArg)
4674
5333
  continue;
4675
- const source = setterCallbackSource(node, binding, importedCallbacks, context);
5334
+ const normalized = (context.getCallPath(node) ?? "").replace(/\s+/g, "");
5335
+ const imported = importedCallbackSource(node, setterArg, importedCallbacks, context);
5336
+ const source = imported ?? (PERSISTENT_MIRROR_CALLBACK.test(normalized) ? callbackSource(node, context) : null);
4676
5337
  if (!source)
4677
5338
  continue;
4678
- highFrequency ||= source.highFrequency;
4679
- external ||= source.external;
5339
+ mergeBindingUpdateEvidence(result, source, "strong");
4680
5340
  }
4681
- return highFrequency || external ? { highFrequency, external } : null;
5341
+ return result.external && result.mirrorConfidence !== "none" ? result : null;
5342
+ }
5343
+ function hasStrongHostFeedbackEvidence(source) {
5344
+ if (source.genericExternal || source.hostFeedbackProperties.size === 0)
5345
+ return false;
5346
+ return [...source.hostFeedbackProperties].every((property) => isStrongHostFeedbackProperty(property));
5347
+ }
5348
+ function hasLikelyContinuousPropertyEvidence(source) {
5349
+ if (source.hostFeedbackProperties.size === 0)
5350
+ return false;
5351
+ return [...source.hostFeedbackProperties].every((property) => LIKELY_CONTINUOUS_PROPERTIES.has(property));
4682
5352
  }
4683
5353
  function returnedHookValues(binding, context) {
4684
5354
  const owner = binding.owner;
@@ -4756,14 +5426,8 @@ function callDefinitelyUsesBindingMode(summary, call, context) {
4756
5426
  return false;
4757
5427
  return truthy === summary.bindingWhenTruthy;
4758
5428
  }
4759
- function shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
4760
- if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
4761
- return false;
4762
- if (!hasAnyRead(localName, owner, declaration, context))
4763
- return false;
4764
- if (summary.sourceKind === "measurement")
4765
- return true;
4766
- return stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps);
5429
+ function hasExplicitBindingMode(summary) {
5430
+ return summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
4767
5431
  }
4768
5432
  var LIKELY_VISUAL_PROP = /(?:position|size|rotation|transparency|opacity|color|image|text|anchor|visible|enabled|layout|scale|offset|zindex|canvas|viewport|cframe|width|height|radius|stroke|padding|spacing|angle|alpha|progress)/i;
4769
5433
  function createElementPropUsage(node, context) {
@@ -4792,7 +5456,7 @@ function createElementPropUsage(node, context) {
4792
5456
  }
4793
5457
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4794
5458
  return null;
4795
- if (!TRANSPARENT_PARENTS.has(parent.type))
5459
+ if (!isTransparentParent(parent, context))
4796
5460
  return null;
4797
5461
  current = parent;
4798
5462
  }
@@ -4824,17 +5488,17 @@ function isEffectUsage(node, context) {
4824
5488
  }
4825
5489
  return false;
4826
5490
  }
4827
- function isStructuralUsage(node) {
5491
+ function isStructuralUsage(node, context) {
4828
5492
  let current = node;
4829
5493
  while (current) {
4830
5494
  const parent = current.parent;
4831
5495
  if (!parent)
4832
5496
  return false;
4833
- if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "return_statement")
5497
+ if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "for_statement" || parent.type === "for_in_statement" || parent.type === "return_statement")
4834
5498
  return true;
4835
5499
  if (parent.type === "function_definition" || parent.type === "function_declaration")
4836
5500
  return false;
4837
- if (!TRANSPARENT_PARENTS.has(parent.type))
5501
+ if (!isTransparentParent(parent, context))
4838
5502
  return false;
4839
5503
  current = parent;
4840
5504
  }
@@ -4847,11 +5511,11 @@ function classifyBindingUsage(node, context, compatibleComponentProps) {
4847
5511
  return "custom-component";
4848
5512
  if (isEffectUsage(node, context))
4849
5513
  return "effect";
4850
- if (isStructuralUsage(node))
5514
+ if (isStructuralUsage(node, context))
4851
5515
  return "structural";
4852
5516
  return "unknown";
4853
5517
  }
4854
- function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps) {
5518
+ function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps, visited = new Set) {
4855
5519
  const summary = {
4856
5520
  reads: 0,
4857
5521
  compatible: 0,
@@ -4863,9 +5527,45 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
4863
5527
  };
4864
5528
  if (!owner.body)
4865
5529
  return summary;
5530
+ const visitKey = `${declaration.startIndex}:${valueName}`;
5531
+ if (visited.has(visitKey))
5532
+ return summary;
5533
+ visited.add(visitKey);
4866
5534
  for (const node of context.walk(owner.body)) {
4867
5535
  if (!isReadNode(node, valueName, owner, declaration))
4868
5536
  continue;
5537
+ if (!isTransparentUsage(node, context, compatibleComponentProps)) {
5538
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
5539
+ const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
5540
+ if (derivedDeclaration && derivedNames.length === 1) {
5541
+ const derived = bindingUsageSummary(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited));
5542
+ if (derived.reads > 0) {
5543
+ summary.reads += derived.reads;
5544
+ summary.compatible += derived.compatible;
5545
+ summary.customComponent += derived.customComponent;
5546
+ summary.visualCustomComponent += derived.visualCustomComponent;
5547
+ summary.effect += derived.effect;
5548
+ summary.structural += derived.structural;
5549
+ summary.unknown += derived.unknown;
5550
+ continue;
5551
+ }
5552
+ }
5553
+ const opaqueDeclaration = opaqueDerivedLocalDeclarationForRead(node, context);
5554
+ const opaqueNames = opaqueDeclaration ? declarationNames2(opaqueDeclaration) : [];
5555
+ if (opaqueDeclaration && opaqueNames.length === 1) {
5556
+ const derived = bindingUsageSummary(opaqueNames[0], owner, opaqueDeclaration, context, compatibleComponentProps, new Set(visited));
5557
+ if (derived.reads > 0) {
5558
+ summary.reads += derived.reads + 1;
5559
+ summary.compatible += derived.compatible;
5560
+ summary.customComponent += derived.customComponent;
5561
+ summary.visualCustomComponent += derived.visualCustomComponent;
5562
+ summary.effect += derived.effect;
5563
+ summary.structural += derived.structural;
5564
+ summary.unknown += derived.unknown + 1;
5565
+ continue;
5566
+ }
5567
+ }
5568
+ }
4869
5569
  summary.reads += 1;
4870
5570
  const customComponentUsage = createElementPropUsage(node, context);
4871
5571
  const kind = classifyBindingUsage(node, context, compatibleComponentProps);
@@ -4888,11 +5588,94 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
4888
5588
  function hasCandidatePresentationEvidence(summary) {
4889
5589
  return summary.compatible > 0 || summary.visualCustomComponent > 0;
4890
5590
  }
5591
+ function isPresentationOnly(summary) {
5592
+ return summary.reads > 0 && summary.compatible === summary.reads;
5593
+ }
5594
+ function stateBindingForSetter(setterName, context) {
5595
+ const valueName = context.model.stateSetters.get(setterName);
5596
+ if (!valueName)
5597
+ return null;
5598
+ return context.model.stateBindings.find((binding) => binding.setterName === setterName && binding.valueName === valueName) ?? null;
5599
+ }
5600
+ function effectOnlyDerivesPresentation(valueName, owner, declaration, context, compatibleComponentProps) {
5601
+ if (!owner.body)
5602
+ return false;
5603
+ let foundRelevantEffect = false;
5604
+ for (const call of context.walk(owner.body)) {
5605
+ if (call.type !== "function_call" || context.nearestFunction(call) !== owner)
5606
+ continue;
5607
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
5608
+ if (path4 !== "React.useEffect" && path4 !== "React.useLayoutEffect")
5609
+ continue;
5610
+ const callbackNode = context.callArguments(call)[0];
5611
+ if (callbackNode?.type !== "function_definition")
5612
+ continue;
5613
+ const callback = context.model.functionByNode.get(callbackNode.id);
5614
+ if (!callback?.body)
5615
+ continue;
5616
+ const usesValue = [...context.walk(callback.body)].some((node) => context.nearestFunction(node) === callback && isReadNode(node, valueName, owner, declaration));
5617
+ if (!usesValue)
5618
+ continue;
5619
+ foundRelevantEffect = true;
5620
+ let presentationSetter = false;
5621
+ for (const nested of context.walk(callback.body)) {
5622
+ if (nested.type !== "function_call" || context.nearestFunction(nested) !== callback)
5623
+ continue;
5624
+ if (isBindingMapSafeCall(nested, context))
5625
+ continue;
5626
+ const nestedPath = context.resolveCallPath(context.getCallPath(nested) ?? "");
5627
+ if (nestedPath === "debug.profilebegin" || nestedPath === "debug.profileend")
5628
+ continue;
5629
+ if (context.model.bindingSetters.has(nestedPath)) {
5630
+ presentationSetter = true;
5631
+ continue;
5632
+ }
5633
+ const target = stateBindingForSetter(nestedPath, context);
5634
+ if (target?.owner && stateReadsAreBindingCompatible(target.valueName, target.owner, target.declaration, context, compatibleComponentProps)) {
5635
+ presentationSetter = true;
5636
+ continue;
5637
+ }
5638
+ return false;
5639
+ }
5640
+ if (!presentationSetter)
5641
+ return false;
5642
+ }
5643
+ return foundRelevantEffect;
5644
+ }
5645
+ function importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
5646
+ if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
5647
+ return null;
5648
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
5649
+ if (usage.reads === 0)
5650
+ return null;
5651
+ const presentationOnly = isPresentationOnly(usage);
5652
+ const explicitBindingMode = hasExplicitBindingMode(summary);
5653
+ const observedProperty = observedPropertyForCall(summary, call, context);
5654
+ const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
5655
+ const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
5656
+ const mirrorsExternalValue = summary.mirrorConfidence !== "none";
5657
+ if (presentationOnly && (summary.highFrequency || likelyContinuousProperty))
5658
+ return "warning";
5659
+ if (presentationOnly && strongHostFeedback && explicitBindingMode)
5660
+ return "warning";
5661
+ if (presentationOnly && (explicitBindingMode || strongHostFeedback || mirrorsExternalValue))
5662
+ return "candidate";
5663
+ if (strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps)) {
5664
+ return "candidate";
5665
+ }
5666
+ if (!hasCandidatePresentationEvidence(usage))
5667
+ return null;
5668
+ if (summary.highFrequency || likelyContinuousProperty)
5669
+ return "candidate";
5670
+ if (strongHostFeedback && explicitBindingMode && usage.effect === 0)
5671
+ return "candidate";
5672
+ return null;
5673
+ }
4891
5674
  var preferBindingOverState = {
4892
5675
  id: "react-luau/prefer-binding-over-state",
4893
5676
  category: "Performance",
4894
5677
  severity: "warning",
4895
- description: "Prefer React.useBinding when externally updated values do not need React reconciliation on every change.",
5678
+ description: "Prefer React.useBinding when proven external presentation streams do not need React reconciliation.",
4896
5679
  run(context) {
4897
5680
  const diagnostics = [];
4898
5681
  const seen = new Set;
@@ -4909,75 +5692,72 @@ var preferBindingOverState = {
4909
5692
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
4910
5693
  if (!declaration || !localName || !owner)
4911
5694
  continue;
4912
- if (!shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
5695
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "warning")
4913
5696
  continue;
4914
5697
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
4915
5698
  if (seen.has(key))
4916
5699
  continue;
4917
5700
  seen.add(key);
4918
- const hasBindingMode = summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
4919
- const message = summary.sourceKind === "measurement" ? hasBindingMode ? `${path4} is using its React state mode for an externally changing GUI measurement, so ${localName} rerenders this consumer when the measurement changes.` : `${path4} returns React state for an externally changing GUI measurement, so ${localName} rerenders this consumer when the measurement changes.` : summary.sourceKind === "derived-external-state" ? `${path4} derives React state from another externally updated reactive value, so ${localName} adds another rerendering state layer.` : `${path4} returns state updated${summary.highFrequency ? " from a high-frequency source" : " from an external callback"}, and ${localName} only flows into bindable host properties.`;
4920
- const bindingModeInstruction = hasBindingMode ? `Use the hook's Binding-returning mode${summary.bindingModeParameterName ? ` by passing ${summary.bindingWhenTruthy ? "true" : "false"} for ${summary.bindingModeParameterName}` : ""}. ` : "Prefer a Binding-returning measurement hook. ";
4921
- const help = summary.sourceKind === "measurement" ? `${bindingModeInstruction}Use Binding:map for derived visual values or a binding-aware effect for imperative reactions; keep React state only when the measurement truly needs to rebuild component structure.` : "Prefer a Binding-returning hook API so host properties can update without rerendering the consumer.";
5701
+ const hasBindingMode = hasExplicitBindingMode(summary);
5702
+ const observedProperty = observedPropertyForCall(summary, call, context);
5703
+ const hostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
5704
+ const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
5705
+ const message = hostFeedback ? `${path4} mirrors ${observedProperty} through React state even though every analyzed use is presentation-only, so the measurement rerenders this consumer without needing reconciliation.` : likelyContinuousProperty ? `${path4} mirrors continuously mutable ${observedProperty} through React state, and ${localName} only feeds presentation updates that do not need reconciliation.` : `${path4} returns state updated from a high-frequency source, and ${localName} only feeds presentation updates that do not need React reconciliation.`;
5706
+ const help = hasBindingMode ? `Use the hook's Binding-returning mode${summary.bindingModeParameterName ? ` by passing ${summary.bindingWhenTruthy ? "true" : "false"} for ${summary.bindingModeParameterName}` : ""}. Binding:map can keep pure visual derivations outside the render cycle.` : "Prefer a Binding-returning hook API so continuous presentation updates can reach host properties without rerendering the consumer.";
4922
5707
  diagnostics.push({
4923
5708
  node: callNameNode(call),
4924
5709
  message,
4925
5710
  help,
4926
- fixPreview: hasBindingMode ? bindingModeFixPreview(summary, call, context) : undefined
4927
- });
4928
- }
4929
- for (const call of context.findCalls()) {
4930
- const path4 = context.getCallPath(call) ?? "";
4931
- const member = path4.split(/[.:]/).at(-1) ?? path4;
4932
- const bindingAlternative = context.project.bindingApiAlternatives.get(member);
4933
- if (!bindingAlternative)
4934
- continue;
4935
- const declaration = declarationForCall2(call);
4936
- const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
4937
- const owner = context.containingComponent(call) ?? context.nearestFunction(call);
4938
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
4939
- continue;
4940
- if (!stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
4941
- continue;
4942
- const field = selectorFieldName(call, context);
4943
- const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
4944
- if (seen.has(key))
4945
- continue;
4946
- seen.add(key);
4947
- diagnostics.push({
4948
- node: callNameNode(call),
4949
- message: `${path4} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, but ${bindingAlternative} is available and every use of the value only updates bindable host properties.`,
4950
- help: `Use ${bindingAlternative} so the host property can update without rerendering the component.`,
4951
- fixPreview: callMemberFixPreview(call, bindingAlternative)
5711
+ fixPreview: hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
4952
5712
  });
4953
5713
  }
4954
5714
  for (const binding of context.model.stateBindings) {
4955
5715
  const owner = binding.owner;
4956
5716
  if (!owner || !owner.isComponent && !owner.isHook)
4957
5717
  continue;
4958
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
4959
- if (!updateKind)
5718
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
5719
+ if (!update)
4960
5720
  continue;
4961
5721
  const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
4962
5722
  const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
4963
- if (owner.isHook ? !hookCompatible : !localCompatible)
5723
+ const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
5724
+ const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
5725
+ const shouldWarn = owner.isHook ? hookCompatible && (update.highFrequency || likelyContinuousProperty) : localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty);
5726
+ if (!shouldWarn)
4964
5727
  continue;
4965
5728
  const key = `${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
4966
5729
  if (seen.has(key))
4967
5730
  continue;
4968
5731
  seen.add(key);
4969
5732
  if (owner.isHook && hookCompatible) {
5733
+ const properties = likelyContinuousProperty ? [...update.hostFeedbackProperties].sort().join(", ") : "";
5734
+ diagnostics.push({
5735
+ node: callNameNode(binding.call),
5736
+ message: likelyContinuousProperty && !update.highFrequency ? `Hook state ${binding.valueName} mirrors continuously mutable ${properties} and is only consumed by presentation properties at analyzed call sites.` : `Hook state ${binding.valueName} is updated from a high-frequency source and is only consumed by presentation properties at analyzed call sites.`,
5737
+ help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so continuous presentation updates do not rerender every consumer."
5738
+ });
5739
+ } else if (strongHostFeedback && !update.highFrequency) {
5740
+ const properties = [...update.hostFeedbackProperties].sort().join(", ");
5741
+ diagnostics.push({
5742
+ node: callNameNode(binding.call),
5743
+ message: `State ${binding.valueName} mirrors ${properties} and only feeds presentation updates, so React reconciliation is acting as an unnecessary host-property feedback step.`,
5744
+ help: "Prefer React.useBinding for this host feedback path. Use Binding:map for pure visual derivations and keep React state only when a change needs to affect component structure or React effects.",
5745
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
5746
+ });
5747
+ } else if (likelyContinuousProperty && !update.highFrequency) {
5748
+ const properties = [...update.hostFeedbackProperties].sort().join(", ");
4970
5749
  diagnostics.push({
4971
5750
  node: callNameNode(binding.call),
4972
- message: `Hook state ${binding.valueName} is updated${updateKind.highFrequency ? " from a high-frequency source" : " from an external callback"} and is only consumed by host properties at analyzed call sites.`,
4973
- help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so consumers can update host properties without rerendering."
5751
+ message: `State ${binding.valueName} mirrors continuously mutable ${properties} and only feeds presentation updates that do not need React reconciliation.`,
5752
+ help: "Prefer React.useBinding so external visual property changes can reach presentation without routing every update through component state.",
5753
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
4974
5754
  });
4975
5755
  } else {
4976
5756
  diagnostics.push({
4977
5757
  node: callNameNode(binding.call),
4978
- message: `State ${binding.valueName} is updated${updateKind.highFrequency ? " from a high-frequency callback" : " from an external callback"} and is read by the component render path.`,
4979
- help: "Prefer React.useBinding when the update is visual and does not need React to rebuild component structure.",
4980
- fixPreview: useBindingFixPreview(binding.call)
5758
+ message: `State ${binding.valueName} is updated from a high-frequency callback and only feeds presentation updates that do not need React reconciliation.`,
5759
+ help: "Prefer React.useBinding so continuous visual updates can reach host properties without rerendering the component.",
5760
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
4981
5761
  });
4982
5762
  }
4983
5763
  }
@@ -4998,27 +5778,49 @@ var preferBindingOverStateCandidate = {
4998
5778
  for (const call of context.findCalls()) {
4999
5779
  const path4 = context.getCallPath(call) ?? "";
5000
5780
  const summary = importedHooks.get(path4);
5001
- if (!summary || !summary.external || callDefinitelyUsesBindingMode(summary, call, context))
5781
+ if (!summary)
5002
5782
  continue;
5003
5783
  const declaration = declarationForCall2(call);
5004
5784
  const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
5005
5785
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
5006
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
5786
+ if (!declaration || !localName || !owner)
5007
5787
  continue;
5008
- if (shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
5788
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "candidate")
5009
5789
  continue;
5010
5790
  const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
5011
- const hasPresentationEvidence = hasCandidatePresentationEvidence(usage) || summary.sourceKind === "derived-external-state" && usage.effect > 0;
5012
- if (!summary.highFrequency && !hasPresentationEvidence)
5013
- continue;
5791
+ const presentationOnly = isPresentationOnly(usage);
5792
+ const observedProperty = observedPropertyForCall(summary, call, context);
5793
+ const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
5794
+ const hasBindingMode = hasExplicitBindingMode(summary);
5795
+ const mirrorsExternalValue = summary.mirrorConfidence !== "none";
5796
+ const visualEffectBridge = strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps);
5014
5797
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
5015
5798
  if (seen.has(key))
5016
5799
  continue;
5017
5800
  seen.add(key);
5801
+ let message;
5802
+ let help;
5803
+ if (presentationOnly && hasBindingMode) {
5804
+ message = `${path4} has a Binding-returning mode and ${localName} is only used for presentation, but Doctor cannot prove the updates are frequent enough to make the Binding version clearly preferable.`;
5805
+ help = "Consider the Binding mode when this value changes often enough that avoiding reconciliation matters; ordinary React state may be clearer for infrequent semantic updates.";
5806
+ } else if (presentationOnly && strongHostFeedback) {
5807
+ message = `${path4} mirrors ${observedProperty} through React state only for presentation, but the hook does not expose a proven low-friction Binding mode.`;
5808
+ help = "Consider a Binding-returning variant if this host feedback path is performance-sensitive. Keep state when the simpler API is more valuable than avoiding occasional rerenders.";
5809
+ } else if (presentationOnly && mirrorsExternalValue) {
5810
+ message = `${path4} mirrors an external reactive value through React state, and ${localName} is only used for presentation.`;
5811
+ help = "Consider a Binding-returning hook API if this source changes often enough that rerendering is unnecessary. Doctor cannot prove the source's update pressure, so ordinary state may still be the clearer choice.";
5812
+ } else if (visualEffectBridge) {
5813
+ message = `${path4} mirrors ${observedProperty} through React state, and the dependent React effect only derives presentation state or Bindings from ${localName}.`;
5814
+ help = hasBindingMode ? "Consider the hook's Binding mode and move the visual derivation into Binding:map/joinBindings so the measurement does not need an intermediate React rerender." : "Consider a Binding-returning hook variant if this visual feedback path is performance-sensitive; the current effect appears to use React state only as an intermediate presentation step.";
5815
+ } else {
5816
+ message = `${path4} updates ${localName} through React state and some consumers are presentation-only, but other consumers may still require React reconciliation.`;
5817
+ help = "Inspect whether the presentation branch can move to a Binding while structural or effect-driven consumers keep React state. Avoid replacing the value wholesale when React still needs to react to it.";
5818
+ }
5018
5819
  diagnostics.push({
5019
5820
  node: callNameNode(call),
5020
- message: `${path4} updates ${localName} through React state when outside data changes. Some uses of ${localName} may need a rerender, so switching it entirely to a Binding could change behavior.`,
5021
- help: "Inspect whether the value can become a Binding, use Binding:map for visual derivation, or split visual updates from the smaller part of the value that truly needs React state."
5821
+ message,
5822
+ help,
5823
+ fixPreview: presentationOnly && hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
5022
5824
  });
5023
5825
  }
5024
5826
  for (const call of context.findCalls()) {
@@ -5032,39 +5834,80 @@ var preferBindingOverStateCandidate = {
5032
5834
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
5033
5835
  if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
5034
5836
  continue;
5035
- if (stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
5837
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
5838
+ const presentationOnly = isPresentationOnly(usage);
5839
+ if (!presentationOnly)
5036
5840
  continue;
5841
+ const field = selectorFieldName(call, context);
5037
5842
  const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
5038
5843
  if (seen.has(key))
5039
5844
  continue;
5040
5845
  seen.add(key);
5041
5846
  diagnostics.push({
5042
5847
  node: callNameNode(call),
5043
- message: `${path4} uses React state even though ${bindingAlternative} is available. Some uses of ${localName} may need a rerender, so replacing it entirely with the Binding API could change behavior.`,
5044
- help: `Inspect whether ${bindingAlternative}, Binding:map, or splitting visual and structural consumers would avoid unnecessary rerenders.`,
5045
- fixPreview: callMemberFixPreview(call, bindingAlternative)
5848
+ message: `${path4} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, while ${bindingAlternative} is also available and every analyzed consumer is presentation-only.`,
5849
+ help: `Consider ${bindingAlternative} when this value updates frequently enough that avoiding reconciliation matters; state remains reasonable for ordinary semantic store updates.`,
5850
+ fixPreview: allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? callMemberFixPreview(call, bindingAlternative) : undefined
5046
5851
  });
5047
5852
  }
5048
5853
  for (const binding of context.model.stateBindings) {
5049
5854
  const owner = binding.owner;
5050
- if (!owner || !owner.isComponent || !owner.body)
5855
+ if (!owner || !owner.isComponent && !owner.isHook || !owner.body)
5051
5856
  continue;
5052
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
5053
- if (!updateKind)
5857
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
5858
+ if (!update)
5054
5859
  continue;
5055
- if (stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps))
5860
+ const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
5861
+ const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
5862
+ const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
5863
+ const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
5864
+ const mirrorsExternalValue = update.mirrorConfidence !== "none";
5865
+ if (owner.isHook) {
5866
+ if (hookCompatible && (update.highFrequency || likelyContinuousProperty))
5867
+ continue;
5868
+ if (!(hookCompatible && (strongHostFeedback || mirrorsExternalValue)))
5869
+ continue;
5870
+ const key2 = `hook-state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
5871
+ if (seen.has(key2))
5872
+ continue;
5873
+ seen.add(key2);
5874
+ const measurement = strongHostFeedback ? "a presentation measurement" : "an external reactive value";
5875
+ diagnostics.push({
5876
+ node: callNameNode(binding.call),
5877
+ message: `Hook state ${binding.valueName} mirrors ${measurement} and is only consumed by presentation properties at analyzed call sites, but converting it requires changing the hook API.`,
5878
+ help: "Consider adding a Binding-returning variant when this reactive path updates often enough that avoiding reconciliation matters; keeping the state API is reasonable when ordinary React state is simpler."
5879
+ });
5056
5880
  continue;
5881
+ }
5057
5882
  const usage = bindingUsageSummary(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
5058
5883
  if (!hasCandidatePresentationEvidence(usage))
5059
5884
  continue;
5885
+ if (localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty))
5886
+ continue;
5887
+ if (localCompatible && mirrorsExternalValue) {
5888
+ const key2 = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
5889
+ if (seen.has(key2))
5890
+ continue;
5891
+ seen.add(key2);
5892
+ diagnostics.push({
5893
+ node: callNameNode(binding.call),
5894
+ message: `State ${binding.valueName} mirrors an external reactive value and only feeds presentation updates, but Doctor cannot prove the source changes frequently enough for Binding to be clearly preferable.`,
5895
+ help: "Consider React.useBinding when this source is a stream of presentation values rather than semantic React state. Keep useState when the external event represents an infrequent UI state transition."
5896
+ });
5897
+ continue;
5898
+ }
5899
+ const mixedHighFrequency = update.highFrequency || likelyContinuousProperty;
5900
+ const mixedHostFeedback = strongHostFeedback && usage.structural > 0 && usage.effect === 0;
5901
+ if (!mixedHighFrequency && !mixedHostFeedback)
5902
+ continue;
5060
5903
  const key = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
5061
5904
  if (seen.has(key))
5062
5905
  continue;
5063
5906
  seen.add(key);
5064
5907
  diagnostics.push({
5065
5908
  node: callNameNode(binding.call),
5066
- message: `State ${binding.valueName} is updated${updateKind.highFrequency ? " from a high-frequency source" : " from an external callback"} and appears at least partly presentation-oriented, but some consumers may still require React state.`,
5067
- help: "Inspect whether visual consumers can move to React.useBinding while structural consumers keep a smaller state value. Splitting the responsibilities can remove rerenders without changing component behavior."
5909
+ message: `State ${binding.valueName} has presentation-oriented consumers, but other uses still appear to need React reconciliation.`,
5910
+ help: "Inspect whether the presentation branch can move to React.useBinding while structural consumers keep a smaller semantic state value. Splitting the responsibilities can remove hot-path rerenders without forcing Binding semantics onto the whole value."
5068
5911
  });
5069
5912
  }
5070
5913
  return diagnostics;
@@ -5570,6 +6413,20 @@ function staticIterationImports(context) {
5570
6413
  }
5571
6414
  return result;
5572
6415
  }
6416
+ function nonReactHookImports(context) {
6417
+ const result = new Set;
6418
+ for (const node of context.walk(context.root)) {
6419
+ if (node.type !== "variable_declaration")
6420
+ continue;
6421
+ const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
6422
+ if (!match || !/^use[A-Z0-9_]/.test(match[1]))
6423
+ continue;
6424
+ const kind = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.reactHookModules);
6425
+ if (kind === false)
6426
+ result.add(match[1]);
6427
+ }
6428
+ return result;
6429
+ }
5573
6430
  function conditionalHookModeImports(context) {
5574
6431
  const result = new Map;
5575
6432
  for (const node of context.walk(context.root)) {
@@ -5617,30 +6474,12 @@ function controlledParameterIndex(controlFlow, fn, summary) {
5617
6474
  }
5618
6475
  return null;
5619
6476
  }
5620
- function isStableModeArgument(node, context) {
5621
- if (!node)
5622
- return true;
5623
- const text = node.text.trim();
5624
- if (["true", "false", "nil"].includes(text))
5625
- return true;
5626
- if ([
5627
- "number",
5628
- "string",
5629
- "string_content",
5630
- "table_constructor",
5631
- "function_definition"
5632
- ].includes(node.type))
5633
- return true;
5634
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text) && context.model.stableVariables.has(text))
5635
- return true;
5636
- return false;
5637
- }
5638
6477
  function dynamicHookModeFixPreview(hookName, argumentText) {
5639
6478
  return {
5640
6479
  kind: "pattern",
5641
6480
  before: `local value = ${hookName}(${argumentText})`,
5642
- after: `local value = ${hookName}(true) -- or false; keep this mode fixed for this call site`,
5643
- note: "A custom hook may choose different internal hooks only when this mode is stable for the lifetime of the component instance."
6481
+ after: `local value = ${hookName}(${argumentText}) -- keep the hook-topology mode stable for this instance`,
6482
+ note: "A custom hook may choose different internal hooks only when the value controlling that choice stays stable for the lifetime of the component instance."
5644
6483
  };
5645
6484
  }
5646
6485
  function unwrapIteratorExpression(text) {
@@ -5660,6 +6499,17 @@ function declarationExpressions4(node) {
5660
6499
  function isEmptyTable(node) {
5661
6500
  return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
5662
6501
  }
6502
+ function tableConstructorHasStableArity(node) {
6503
+ if (node.type !== "table_constructor")
6504
+ return false;
6505
+ const fields = node.namedChildren.filter((child) => child.type === "field");
6506
+ if (fields.length === 0)
6507
+ return true;
6508
+ if (fields.some((field) => [...field.namedChildren].some((child) => child.type === "vararg_expression")))
6509
+ return false;
6510
+ const last = fields.at(-1);
6511
+ return !last?.namedChildren.some((child) => child.type === "function_call");
6512
+ }
5663
6513
  function stableShapeVariablesByFunction(context) {
5664
6514
  const stable = new Map;
5665
6515
  const functionsByName = new Map;
@@ -5686,7 +6536,7 @@ function stableShapeVariablesByFunction(context) {
5686
6536
  const expression = expressions[index] ?? expressions[0];
5687
6537
  if (!expression)
5688
6538
  continue;
5689
- if (expression.type === "table_constructor") {
6539
+ if (expression.type === "table_constructor" && tableConstructorHasStableArity(expression)) {
5690
6540
  namesFor(owner).add(names[index]);
5691
6541
  continue;
5692
6542
  }
@@ -5713,7 +6563,7 @@ function stableShapeVariablesByFunction(context) {
5713
6563
  if (!expression)
5714
6564
  return false;
5715
6565
  if (expression.type === "table_constructor")
5716
- return true;
6566
+ return tableConstructorHasStableArity(expression);
5717
6567
  const text = expression.text.trim();
5718
6568
  if (!owner || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(text))
5719
6569
  return false;
@@ -5766,6 +6616,187 @@ function stableShapeVariablesByFunction(context) {
5766
6616
  }
5767
6617
  return stable;
5768
6618
  }
6619
+ function moduleInvariantVariables(context) {
6620
+ const declarations = new Map;
6621
+ const reassigned = new Set;
6622
+ for (const node of context.walk(context.root)) {
6623
+ if (node.type === "variable_declaration" && !context.nearestFunction(node)) {
6624
+ for (const name of declarationNames2(node))
6625
+ declarations.set(name, node);
6626
+ continue;
6627
+ }
6628
+ if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
6629
+ continue;
6630
+ const variableList = node.namedChildren.find((child) => child.type === "variable_list");
6631
+ for (const target of variableList?.namedChildren ?? []) {
6632
+ if (target.type === "identifier")
6633
+ reassigned.add(target.text);
6634
+ }
6635
+ }
6636
+ return new Set([...declarations.keys()].filter((name) => !reassigned.has(name)));
6637
+ }
6638
+ function simpleConditionRoot(controlFlow) {
6639
+ if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
6640
+ return null;
6641
+ const condition = controlFlow.namedChildren[0];
6642
+ if (!condition)
6643
+ return null;
6644
+ const text = condition.text.trim();
6645
+ const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
6646
+ return match?.[1] ?? null;
6647
+ }
6648
+ function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants) {
6649
+ const condition = controlFlow.namedChildren[0];
6650
+ if (!condition)
6651
+ return false;
6652
+ if (["true", "false", "nil", "number", "string"].includes(condition.type))
6653
+ return true;
6654
+ const root = simpleConditionRoot(controlFlow);
6655
+ if (!root)
6656
+ return false;
6657
+ if (moduleInvariants.has(root))
6658
+ return true;
6659
+ return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
6660
+ }
6661
+ function branchHookSequence(branch, fn, context) {
6662
+ const hooks = [];
6663
+ for (const node of context.walk(branch)) {
6664
+ if (node.type !== "function_call")
6665
+ continue;
6666
+ if (context.nearestFunction(node) !== fn)
6667
+ continue;
6668
+ const rawPath = context.getCallPath(node);
6669
+ if (!rawPath)
6670
+ continue;
6671
+ const path4 = context.resolveCallPath(rawPath);
6672
+ if (!isHookPath(path4))
6673
+ continue;
6674
+ const nestedControl = findAncestorBetween(node, branch, (ancestor) => CONDITIONAL_TYPES.has(ancestor.type));
6675
+ if (nestedControl)
6676
+ return null;
6677
+ hooks.push(path4);
6678
+ }
6679
+ return hooks;
6680
+ }
6681
+ function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
6682
+ if (controlFlow.type !== "if_statement")
6683
+ return false;
6684
+ const thenBlock = controlFlow.namedChildren.find((child) => child.type === "block");
6685
+ const elseStatement = controlFlow.namedChildren.find((child) => child.type === "else_statement");
6686
+ const elseBlock = elseStatement?.namedChildren.find((child) => child.type === "block");
6687
+ if (!thenBlock || !elseBlock)
6688
+ return false;
6689
+ const thenHooks = branchHookSequence(thenBlock, fn, context);
6690
+ const elseHooks = branchHookSequence(elseBlock, fn, context);
6691
+ if (!thenHooks || !elseHooks || thenHooks.length !== elseHooks.length)
6692
+ return false;
6693
+ if (!thenHooks.every((path4, index) => path4 === elseHooks[index]))
6694
+ return false;
6695
+ return thenHooks.every((path4) => path4.startsWith("React."));
6696
+ }
6697
+ function stateModeStability(name, owner, context) {
6698
+ const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
6699
+ if (!binding || !owner.body)
6700
+ return null;
6701
+ const initializerText = binding.initializer?.text.trim() ?? "nil";
6702
+ const stableLiteral = /^(?:true|false|nil|-?\d+(?:\.\d+)?|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/;
6703
+ let sawIndirectSetterReference = false;
6704
+ for (const node of context.walk(owner.body)) {
6705
+ if (node.type === "function_call" && context.getCallPath(node) === binding.setterName) {
6706
+ const argument = context.callArguments(node)[0];
6707
+ if (!argument || !stableLiteral.test(initializerText) || argument.text.trim() !== initializerText)
6708
+ return "unstable";
6709
+ continue;
6710
+ }
6711
+ if (node.type !== "identifier" || node.text !== binding.setterName)
6712
+ continue;
6713
+ if (node.startIndex >= binding.declaration.startIndex && node.endIndex <= binding.declaration.endIndex)
6714
+ continue;
6715
+ if (node.parent?.type === "function_call")
6716
+ continue;
6717
+ sawIndirectSetterReference = true;
6718
+ }
6719
+ if (sawIndirectSetterReference)
6720
+ return "unknown";
6721
+ return "stable";
6722
+ }
6723
+ function localAliasExpression(name, owner, context) {
6724
+ if (!owner.body)
6725
+ return null;
6726
+ let result = null;
6727
+ for (const node of context.walk(owner.body)) {
6728
+ if (node.type !== "variable_declaration" || context.nearestFunction(node) !== owner)
6729
+ continue;
6730
+ const names = declarationNames2(node);
6731
+ const index = names.indexOf(name);
6732
+ if (index < 0)
6733
+ continue;
6734
+ const expressions = declarationExpressions4(node);
6735
+ const expression = expressions[index] ?? expressions[0] ?? null;
6736
+ if (result)
6737
+ return null;
6738
+ result = expression;
6739
+ }
6740
+ return result;
6741
+ }
6742
+ function modeValueStability(node, owner, context, moduleInvariants, depth = 0) {
6743
+ if (!node)
6744
+ return "stable";
6745
+ if (depth > 4)
6746
+ return "unknown";
6747
+ const text = node.text.trim();
6748
+ if (["true", "false", "nil"].includes(text))
6749
+ return "stable";
6750
+ if (["number", "string", "string_content", "function_definition"].includes(node.type))
6751
+ return "stable";
6752
+ const root = text.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
6753
+ if (!root)
6754
+ return "unknown";
6755
+ const stateStability = stateModeStability(root, owner, context);
6756
+ if (stateStability)
6757
+ return stateStability;
6758
+ if (moduleInvariants.has(root))
6759
+ return "stable";
6760
+ if (context.model.stableVariablesByFunction.get(nodeKey(owner.node))?.has(root))
6761
+ return "stable";
6762
+ if (text === root) {
6763
+ const alias = localAliasExpression(root, owner, context);
6764
+ if (alias && alias.text.trim() !== root)
6765
+ return modeValueStability(alias, owner, context, moduleInvariants, depth + 1);
6766
+ }
6767
+ return "unknown";
6768
+ }
6769
+ function tableFieldValue(table, accessPath) {
6770
+ if (!table || accessPath === "")
6771
+ return table;
6772
+ if (table.type !== "table_constructor")
6773
+ return;
6774
+ let current = table;
6775
+ for (const part of accessPath.split(".")) {
6776
+ if (!current || current.type !== "table_constructor")
6777
+ return;
6778
+ const field = current.namedChildren.find((child) => {
6779
+ if (child.type !== "field")
6780
+ return false;
6781
+ return child.namedChildren[0]?.type === "identifier" && child.namedChildren[0]?.text === part;
6782
+ });
6783
+ if (!field)
6784
+ return;
6785
+ current = field.namedChildren[1];
6786
+ }
6787
+ return current;
6788
+ }
6789
+ function controlledAccessPaths(summary, parameterIndex) {
6790
+ const paths = new Set;
6791
+ for (const [conditionName, index] of Object.entries(summary.conditionVariables)) {
6792
+ if (index !== parameterIndex)
6793
+ continue;
6794
+ paths.add(summary.conditionAccessPaths?.[conditionName] ?? "");
6795
+ }
6796
+ if (paths.size === 0)
6797
+ paths.add("");
6798
+ return [...paths];
6799
+ }
5769
6800
  function loopIterationIsProvablyStable(loop, imports, owner, stableShapes) {
5770
6801
  const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
5771
6802
  if (numeric) {
@@ -5827,9 +6858,9 @@ function impossibleNilGuardReturn(node, fn, parameterTypes) {
5827
6858
  }
5828
6859
  return false;
5829
6860
  }
5830
- function hasReachableReturnBefore(call, fn, context, cache) {
6861
+ function reachableReturnsBefore(call, fn, context, cache) {
5831
6862
  if (!fn.body)
5832
- return false;
6863
+ return [];
5833
6864
  const key = nodeKey(fn.node);
5834
6865
  let returns = cache.get(key);
5835
6866
  if (!returns) {
@@ -5837,7 +6868,21 @@ function hasReachableReturnBefore(call, fn, context, cache) {
5837
6868
  returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
5838
6869
  cache.set(key, returns);
5839
6870
  }
5840
- return returns.some((node) => node.endIndex <= call.startIndex);
6871
+ return returns.filter((node) => node.endIndex <= call.startIndex);
6872
+ }
6873
+ function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
6874
+ let current = node.parent;
6875
+ while (current && current !== fn.node) {
6876
+ if (current.type === "if_statement" || current.type === "if_expression") {
6877
+ if (summary && controlledParameterIndex(current, fn, summary) !== null)
6878
+ return true;
6879
+ if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
6880
+ return true;
6881
+ return false;
6882
+ }
6883
+ current = current.parent;
6884
+ }
6885
+ return false;
5841
6886
  }
5842
6887
  function conditionalFixPreview(kind, hookName) {
5843
6888
  if (kind === "for statement") {
@@ -5909,15 +6954,21 @@ var rulesOfHooks = {
5909
6954
  description: "Hooks must run in the same order on every render of a React component or custom hook.",
5910
6955
  run(context) {
5911
6956
  const diagnostics = [];
6957
+ if (!context.model.isReactFile)
6958
+ return diagnostics;
5912
6959
  const staticImports = staticIterationImports(context);
5913
6960
  const stableShapes = stableShapeVariablesByFunction(context);
6961
+ const moduleInvariants = moduleInvariantVariables(context);
5914
6962
  const modeImports = conditionalHookModeImports(context);
6963
+ const ignoredHookImports = nonReactHookImports(context);
5915
6964
  const currentModeSummary = currentConditionalHookMode(context);
5916
6965
  const reachableReturns = new Map;
5917
6966
  for (const call of context.findCalls()) {
5918
6967
  const rawPath = context.getCallPath(call);
5919
6968
  if (!rawPath)
5920
6969
  continue;
6970
+ if (ignoredHookImports.has(rawPath))
6971
+ continue;
5921
6972
  const path4 = context.resolveCallPath(rawPath);
5922
6973
  if (!isHookPath(path4))
5923
6974
  continue;
@@ -5946,25 +6997,36 @@ end`,
5946
6997
  const importedMode = modeImports.get(rawPath);
5947
6998
  if (importedMode) {
5948
6999
  const args = context.callArguments(call);
5949
- const dynamicIndexes = importedMode.parameterIndexes.filter((index) => !isStableModeArgument(args[index], context));
5950
- if (dynamicIndexes.length > 0) {
5951
- const firstIndex = dynamicIndexes[0];
5952
- const argument = args[firstIndex];
7000
+ const unstableControls = importedMode.parameterIndexes.flatMap((index) => controlledAccessPaths(importedMode, index).map((accessPath) => {
7001
+ const argument = args[index];
7002
+ const value = tableFieldValue(argument, accessPath);
7003
+ const stability = accessPath !== "" ? argument?.type === "table_constructor" ? value ? modeValueStability(value, fn, context, moduleInvariants) : "stable" : "unknown" : modeValueStability(argument, fn, context, moduleInvariants);
7004
+ return { index, accessPath, argument, value, stability };
7005
+ }).filter((control) => control.stability === "unstable"));
7006
+ if (unstableControls.length > 0) {
7007
+ const first = unstableControls[0];
7008
+ const firstIndex = first.index;
7009
+ const argument = first.argument;
5953
7010
  const parameterOffset = importedMode.parameterIndexes.indexOf(firstIndex);
5954
- const parameterName = importedMode.parameterNames[parameterOffset] ?? `argument ${firstIndex + 1}`;
5955
- const argumentText = argument?.text.trim() || "<omitted>";
7011
+ const baseParameterName = importedMode.parameterNames[parameterOffset] ?? `argument ${firstIndex + 1}`;
7012
+ const parameterName = first.accessPath ? `${baseParameterName}.${first.accessPath}` : baseParameterName;
7013
+ const argumentText = first.value?.text.trim() || argument?.text.trim() || "<omitted>";
5956
7014
  diagnostics.push({
5957
- node: argument ?? callNameNode(call),
5958
- highlights: dynamicIndexes.map((index) => args[index]).filter((node) => Boolean(node)),
5959
- message: `Hook ${rawPath} receives render-varying hook mode ${parameterName} from ${argumentText}; if it changes between renders, the custom hook can execute a different hook sequence.`,
5960
- help: `Pass a stable mode at this call site, or refactor ${rawPath} so it always calls the same hooks regardless of ${parameterName}.`,
7015
+ node: first.value ?? argument ?? callNameNode(call),
7016
+ highlights: unstableControls.map((control) => control.value ?? control.argument).filter((node) => Boolean(node)),
7017
+ message: `Hook ${rawPath} receives hook-topology mode ${parameterName} from ${argumentText}, which is updated between renders and can make the custom hook execute a different hook sequence.`,
7018
+ help: `Keep ${parameterName} stable for this hook instance, or refactor ${rawPath} so it always calls the same hooks regardless of that mode.`,
5961
7019
  fixPreview: dynamicHookModeFixPreview(rawPath, argumentText)
5962
7020
  });
5963
7021
  }
5964
7022
  }
5965
7023
  const controlFlow = findAncestorBetween(call, fn.node, (node) => CONDITIONAL_TYPES.has(node.type));
5966
7024
  if (controlFlow) {
5967
- if (currentModeSummary && currentModeSummary.knownCallSites > 0 && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
7025
+ if (currentModeSummary && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
7026
+ continue;
7027
+ if (conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants))
7028
+ continue;
7029
+ if (ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context))
5968
7030
  continue;
5969
7031
  if (controlFlow.type === "for_statement" && loopIterationIsProvablyStable(controlFlow, staticImports, fn, stableShapes))
5970
7032
  continue;
@@ -5978,7 +7040,8 @@ end`,
5978
7040
  });
5979
7041
  continue;
5980
7042
  }
5981
- if (hasReachableReturnBefore(call, fn, context, reachableReturns)) {
7043
+ const earlierReturns = reachableReturnsBefore(call, fn, context, reachableReturns);
7044
+ if (earlierReturns.length > 0 && !earlierReturns.every((node) => returnIsControlledByStableTopologyMode(node, fn, currentModeSummary, context, moduleInvariants))) {
5982
7045
  diagnostics.push({
5983
7046
  node: callNameNode(call),
5984
7047
  message: `Hook ${path4} may run after an earlier reachable return, so some renders can execute fewer hooks.`,
@@ -6423,6 +7486,16 @@ function nodeLocation(node) {
6423
7486
  endColumn: node.endPosition.column + 1
6424
7487
  };
6425
7488
  }
7489
+ function sameLocation(left, right) {
7490
+ return left.line === right.line && left.column === right.column && left.endLine === right.endLine && left.endColumn === right.endColumn;
7491
+ }
7492
+ function removeSupersededDiagnostics(diagnostics) {
7493
+ return diagnostics.filter((diagnostic) => {
7494
+ if (diagnostic.rule !== "react-luau/rerender-high-frequency-state")
7495
+ return true;
7496
+ return !diagnostics.some((candidate) => candidate.rule === "react-luau/prefer-binding-over-state" && sameLocation(candidate.location, diagnostic.location) && SEVERITY_RANK[candidate.severity] >= SEVERITY_RANK[diagnostic.severity]);
7497
+ });
7498
+ }
6426
7499
  function toDiagnostic(file, ruleId, category, severity, input) {
6427
7500
  const node = input.node;
6428
7501
  const highlights = input.highlights?.map(nodeLocation);
@@ -6441,7 +7514,7 @@ function toDiagnostic(file, ruleId, category, severity, input) {
6441
7514
  }
6442
7515
  async function analyzeReactFile(input, tree) {
6443
7516
  const parsedTree = tree ?? await parseLuau(input.source);
6444
- const model = buildReactModel(parsedTree.rootNode);
7517
+ const model = buildReactModel(parsedTree.rootNode, input.project);
6445
7518
  if (!input.forceScan && !model.isReactFile) {
6446
7519
  return { relativePath: input.relativePath, isReactFile: false, scanned: false, diagnostics: [] };
6447
7520
  }
@@ -6480,11 +7553,12 @@ async function analyzeReactFile(input, tree) {
6480
7553
  if (rule.id === "react-luau/parse-error" && findings.length > 0)
6481
7554
  break;
6482
7555
  }
7556
+ const filteredDiagnostics = removeSupersededDiagnostics(diagnostics);
6483
7557
  return {
6484
7558
  relativePath: input.relativePath,
6485
7559
  isReactFile: model.isReactFile,
6486
7560
  scanned: true,
6487
- diagnostics
7561
+ diagnostics: filteredDiagnostics
6488
7562
  };
6489
7563
  }
6490
7564
 
@@ -7455,5 +8529,5 @@ parentPort.on("message", async (request) => {
7455
8529
  }
7456
8530
  });
7457
8531
 
7458
- //# debugId=CD648BE1625A701464756E2164756E21
8532
+ //# debugId=3696D560D93E93D364756E2164756E21
7459
8533
  //# sourceMappingURL=scan-worker.js.map