@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.
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ var package_default = {
11
11
  publishConfig: {
12
12
  access: "public"
13
13
  },
14
- version: "0.18.3",
14
+ version: "0.19.1",
15
15
  description: "Static analysis for React-Luau hooks, effects, rendering, and performance.",
16
16
  license: "MIT",
17
17
  type: "module",
@@ -22,6 +22,7 @@ var package_default = {
22
22
  "dist",
23
23
  "vendor",
24
24
  "README.md",
25
+ "CHANGELOG.md",
25
26
  "LICENSE",
26
27
  "THIRD_PARTY_NOTICES.md"
27
28
  ],
@@ -864,7 +865,7 @@ import crypto from "crypto";
864
865
  import fs4 from "fs";
865
866
  import os from "os";
866
867
  import path4 from "path";
867
- var CACHE_SCHEMA_VERSION = 1;
868
+ import { fileURLToPath } from "url";
868
869
  var MAX_CACHED_REPORTS = 8;
869
870
  function normalizeRelative(value) {
870
871
  return value.split(path4.sep).join("/");
@@ -872,6 +873,54 @@ function normalizeRelative(value) {
872
873
  function hashText(value) {
873
874
  return crypto.createHash("sha256").update(value).digest("hex");
874
875
  }
876
+ var analyzerHash = null;
877
+ function hashAnalyzerFile(hash, root, filename) {
878
+ if (!fs4.existsSync(filename))
879
+ return;
880
+ hash.update(normalizeRelative(path4.relative(root, filename)));
881
+ hash.update("\x00");
882
+ hash.update(fs4.readFileSync(filename));
883
+ hash.update("\x00");
884
+ }
885
+ function sourceFiles(directory) {
886
+ if (!fs4.existsSync(directory))
887
+ return [];
888
+ const files = [];
889
+ for (const entry of fs4.readdirSync(directory, { withFileTypes: true })) {
890
+ const filename = path4.join(directory, entry.name);
891
+ if (entry.isDirectory())
892
+ files.push(...sourceFiles(filename));
893
+ else if (entry.isFile() && entry.name.endsWith(".ts"))
894
+ files.push(filename);
895
+ }
896
+ return files;
897
+ }
898
+ function currentAnalyzerHash() {
899
+ if (analyzerHash)
900
+ return analyzerHash;
901
+ const hash = crypto.createHash("sha256");
902
+ let packageRoot = path4.resolve(path4.dirname(fileURLToPath(import.meta.url)), "..");
903
+ let bundledCli = null;
904
+ try {
905
+ const entrypoint = process.argv[1] ? fs4.realpathSync(process.argv[1]) : null;
906
+ if (entrypoint && path4.basename(entrypoint) === "cli.js" && path4.basename(path4.dirname(entrypoint)) === "dist") {
907
+ bundledCli = entrypoint;
908
+ packageRoot = path4.dirname(path4.dirname(entrypoint));
909
+ }
910
+ } catch {}
911
+ if (bundledCli) {
912
+ hashAnalyzerFile(hash, packageRoot, bundledCli);
913
+ hashAnalyzerFile(hash, packageRoot, path4.join(path4.dirname(bundledCli), "scan-worker.js"));
914
+ } else {
915
+ for (const filename of sourceFiles(path4.join(packageRoot, "src")).sort()) {
916
+ hashAnalyzerFile(hash, packageRoot, filename);
917
+ }
918
+ }
919
+ hashAnalyzerFile(hash, packageRoot, path4.join(packageRoot, "package.json"));
920
+ hashAnalyzerFile(hash, packageRoot, path4.join(packageRoot, "vendor", "tree-sitter-luau.wasm"));
921
+ analyzerHash = hash.digest("hex");
922
+ return analyzerHash;
923
+ }
875
924
  function stableValue(value) {
876
925
  if (Array.isArray(value))
877
926
  return value.map(stableValue);
@@ -901,7 +950,7 @@ function projectCacheFilename(root) {
901
950
  function readPayload(filename, root) {
902
951
  try {
903
952
  const parsed = JSON.parse(fs4.readFileSync(filename, "utf8"));
904
- if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION || parsed.analyzerVersion !== package_default.version || path4.resolve(parsed.root) !== path4.resolve(root) || !parsed.files || typeof parsed.fingerprint !== "string")
953
+ if (parsed.analyzerHash !== currentAnalyzerHash() || path4.resolve(parsed.root) !== path4.resolve(root) || !parsed.files || typeof parsed.fingerprint !== "string")
905
954
  return null;
906
955
  return parsed;
907
956
  } catch {
@@ -1022,6 +1071,7 @@ function serializeProjectModel(project) {
1022
1071
  bindingCompatibleComponentProps: [...project.bindingCompatibleComponentProps].map(([key, value]) => [key, [...value].sort()]),
1023
1072
  staticIterationTables: [...project.staticIterationTables].map(([key, value]) => [key, [...value].sort()]),
1024
1073
  conditionalHookModes: [...project.conditionalHookModes],
1074
+ reactHookModules: [...project.reactHookModules],
1025
1075
  sourceEffects: [...project.sourceEffects].map(([key, value]) => [key, serializeSourceEffect(value)])
1026
1076
  };
1027
1077
  }
@@ -1034,6 +1084,7 @@ function deserializeProjectModel(project) {
1034
1084
  bindingCompatibleComponentProps: new Map(project.bindingCompatibleComponentProps.map(([key, value]) => [key, new Set(value)])),
1035
1085
  staticIterationTables: new Map(project.staticIterationTables.map(([key, value]) => [key, new Set(value)])),
1036
1086
  conditionalHookModes: new Map(project.conditionalHookModes),
1087
+ reactHookModules: new Map(project.reactHookModules ?? []),
1037
1088
  sourceEffects: new Map(project.sourceEffects.map(([key, value]) => [key, deserializeSourceEffect(value)]))
1038
1089
  };
1039
1090
  }
@@ -1058,7 +1109,8 @@ function projectModelFeatureKey(project) {
1058
1109
  bindingApiAlternatives: serialized.bindingApiAlternatives,
1059
1110
  bindingCompatibleComponentProps: serialized.bindingCompatibleComponentProps,
1060
1111
  staticIterationTables: serialized.staticIterationTables,
1061
- conditionalHookModes: serialized.conditionalHookModes
1112
+ conditionalHookModes: serialized.conditionalHookModes,
1113
+ reactHookModules: serialized.reactHookModules
1062
1114
  });
1063
1115
  }
1064
1116
  function cachedProjectModel(session) {
@@ -1121,8 +1173,7 @@ function saveProjectCache(session, project, effectModules = session.previous?.ef
1121
1173
  reports[reportKey] = { ...report, lastUsedAt: Date.now() };
1122
1174
  const prunedReports = Object.fromEntries(Object.entries(reports).sort(([, left], [, right]) => right.lastUsedAt - left.lastUsedAt).slice(0, MAX_CACHED_REPORTS));
1123
1175
  writePayload(session.filename, {
1124
- schemaVersion: CACHE_SCHEMA_VERSION,
1125
- analyzerVersion: package_default.version,
1176
+ analyzerHash: currentAnalyzerHash(),
1126
1177
  root: path4.resolve(session.root),
1127
1178
  fingerprint: session.fingerprint,
1128
1179
  files: session.files,
@@ -1150,6 +1201,19 @@ function walk(node) {
1150
1201
  }
1151
1202
  return result;
1152
1203
  }
1204
+ function parameterBindingNames(parameters) {
1205
+ if (!parameters)
1206
+ return [];
1207
+ const result = [];
1208
+ for (const parameter of parameters.namedChildren) {
1209
+ if (parameter.type !== "parameter")
1210
+ continue;
1211
+ const identifier = parameter.namedChildren.find((child) => child.type === "identifier");
1212
+ if (identifier)
1213
+ result.push(identifier.text);
1214
+ }
1215
+ return [...new Set(result)];
1216
+ }
1153
1217
  function rootIdentifier(text) {
1154
1218
  const match = text.trim().match(/^([A-Za-z_][A-Za-z0-9_]*)/);
1155
1219
  return match?.[1] ?? null;
@@ -1278,6 +1342,63 @@ function createRuleContext(file) {
1278
1342
  };
1279
1343
  }
1280
1344
 
1345
+ // src/module-resolution.ts
1346
+ import path5 from "path";
1347
+ function normalizeRelative2(value) {
1348
+ return value.split(path5.sep).join("/");
1349
+ }
1350
+ function moduleKeys(relativePath) {
1351
+ let normalized = normalizeRelative2(relativePath).replace(/\.(?:lua|luau)$/i, "");
1352
+ if (normalized.endsWith("/init"))
1353
+ normalized = normalized.slice(0, -"/init".length);
1354
+ const segments = normalized.split("/").filter(Boolean);
1355
+ if (segments[0]?.toLowerCase() === "src")
1356
+ segments.shift();
1357
+ const keys = new Set;
1358
+ for (let index = 0;index < segments.length; index += 1) {
1359
+ const suffix = segments.slice(index).join(".").toLowerCase();
1360
+ if (suffix)
1361
+ keys.add(suffix);
1362
+ }
1363
+ return [...keys];
1364
+ }
1365
+ function normalizeRequireTarget(text) {
1366
+ return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
1367
+ }
1368
+ function buildUniqueFeatureAliases(modules, featureByModuleId) {
1369
+ const owners = new Map;
1370
+ for (const module of modules) {
1371
+ for (const key of module.keys) {
1372
+ const ids = owners.get(key) ?? new Set;
1373
+ ids.add(module.id);
1374
+ owners.set(key, ids);
1375
+ }
1376
+ }
1377
+ const aliases = new Map;
1378
+ for (const module of modules) {
1379
+ const value = featureByModuleId.get(module.id);
1380
+ if (value === undefined)
1381
+ continue;
1382
+ for (const key of module.keys) {
1383
+ const ids = owners.get(key);
1384
+ if (ids?.size === 1 && ids.has(module.id))
1385
+ aliases.set(key, value);
1386
+ }
1387
+ }
1388
+ return aliases;
1389
+ }
1390
+ function resolveModuleReference(target, aliases) {
1391
+ let candidate = target;
1392
+ while (true) {
1393
+ if (aliases.has(candidate))
1394
+ return aliases.get(candidate);
1395
+ const separator = candidate.indexOf(".");
1396
+ if (separator < 0)
1397
+ return null;
1398
+ candidate = candidate.slice(separator + 1);
1399
+ }
1400
+ }
1401
+
1281
1402
  // src/ast/react-model.ts
1282
1403
  var REACT_HOOKS = new Set([
1283
1404
  "useState",
@@ -1308,18 +1429,7 @@ var INSTANCE_FACTORY_MEMBERS = new Set([
1308
1429
  ]);
1309
1430
  var STABLE_ENGINE_FACTORY_MEMBERS = new Set(["GetMouse", "GetService"]);
1310
1431
  function identifiersFromParameters(node) {
1311
- if (!node)
1312
- return [];
1313
- const result = [];
1314
- for (const child of walk(node)) {
1315
- if (child.type !== "identifier")
1316
- continue;
1317
- const parentType = child.parent?.type;
1318
- if (parentType === "parameters" || parentType === "parameter" || parentType === "typed_identifier") {
1319
- result.push(child.text);
1320
- }
1321
- }
1322
- return [...new Set(result)];
1432
+ return parameterBindingNames(node);
1323
1433
  }
1324
1434
  function getFunctionName(node) {
1325
1435
  if (node.type === "function_declaration") {
@@ -1373,15 +1483,19 @@ function directCalls(node) {
1373
1483
  function hasDirectCreateElementCall(node, aliases, reactNamespaces, reactRobloxNamespaces) {
1374
1484
  return directCalls(node).some((call) => {
1375
1485
  const name = call.childForFieldName("name")?.text ?? "";
1376
- const path5 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
1377
- return path5 === "React.createElement" || path5 === "ReactRoblox.createPortal";
1486
+ const path6 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
1487
+ return path6 === "React.createElement" || path6 === "ReactRoblox.createPortal";
1378
1488
  });
1379
1489
  }
1380
- function hasDirectHookCall(node, aliases, reactNamespaces, reactRobloxNamespaces) {
1490
+ function hasDirectHookCall(node, aliases, reactNamespaces, reactRobloxNamespaces, importedHookKinds) {
1381
1491
  return directCalls(node).some((call) => {
1382
1492
  const name = call.childForFieldName("name")?.text ?? "";
1383
- const path5 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
1384
- const final = path5.split(/[.:]/).at(-1) ?? "";
1493
+ const path6 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
1494
+ const normalizedName = normalizeExpressionText(name);
1495
+ const importedKind = importedHookKinds.get(normalizedName);
1496
+ if (importedKind !== undefined)
1497
+ return importedKind;
1498
+ const final = path6.split(/[.:]/).at(-1) ?? "";
1385
1499
  return /^use[A-Z0-9_]/.test(final);
1386
1500
  });
1387
1501
  }
@@ -1467,8 +1581,8 @@ function isSyntacticallyStableInitializer(node) {
1467
1581
  return false;
1468
1582
  return ["nil", "true", "false", "number", "string", "string_content"].includes(node.type) || /^(?:nil|true|false|-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)$/i.test(node.text.trim());
1469
1583
  }
1470
- function finalCallMember(path5) {
1471
- return path5.split(/[.:]/).at(-1) ?? path5;
1584
+ function finalCallMember(path6) {
1585
+ return path6.split(/[.:]/).at(-1) ?? path6;
1472
1586
  }
1473
1587
  function expressionCreatesInstance(node, aliases, reactNamespaces, reactRobloxNamespaces) {
1474
1588
  if (!node)
@@ -1480,8 +1594,8 @@ function expressionCreatesInstance(node, aliases, reactNamespaces, reactRobloxNa
1480
1594
  if (candidate.type !== "function_call")
1481
1595
  continue;
1482
1596
  const rawPath = candidate.childForFieldName("name")?.text ?? "";
1483
- const path5 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1484
- if (path5 === "Instance.new" || INSTANCE_FACTORY_MEMBERS.has(finalCallMember(path5)))
1597
+ const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1598
+ if (path6 === "Instance.new" || INSTANCE_FACTORY_MEMBERS.has(finalCallMember(path6)))
1485
1599
  return true;
1486
1600
  }
1487
1601
  return false;
@@ -1493,8 +1607,8 @@ function expressionIsStableEngineObject(node, aliases, reactNamespaces, reactRob
1493
1607
  if (calls.length !== 1)
1494
1608
  return false;
1495
1609
  const rawPath = calls[0].childForFieldName("name")?.text ?? "";
1496
- const path5 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1497
- return STABLE_ENGINE_FACTORY_MEMBERS.has(finalCallMember(path5));
1610
+ const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1611
+ return STABLE_ENGINE_FACTORY_MEMBERS.has(finalCallMember(path6));
1498
1612
  }
1499
1613
  function memoReturnsInstance(callback, aliases, reactNamespaces, reactRobloxNamespaces) {
1500
1614
  if (!callback || callback.type !== "function_definition")
@@ -1510,7 +1624,21 @@ function memoReturnsInstance(callback, aliases, reactNamespaces, reactRobloxName
1510
1624
  function isEmptyDependencyTable(node) {
1511
1625
  return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
1512
1626
  }
1513
- function buildReactModel(root) {
1627
+ function importedHookKinds(declarations, project) {
1628
+ const result = new Map;
1629
+ if (!project)
1630
+ return result;
1631
+ for (const declaration of declarations) {
1632
+ const match = declaration.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
1633
+ if (!match || !/^use[A-Z0-9_]/.test(match[1]))
1634
+ continue;
1635
+ const kind = resolveModuleReference(normalizeRequireTarget(match[2]), project.reactHookModules);
1636
+ if (kind !== null)
1637
+ result.set(match[1], kind);
1638
+ }
1639
+ return result;
1640
+ }
1641
+ function buildReactModel(root, project) {
1514
1642
  const reactNamespaces = new Set;
1515
1643
  const reactRobloxNamespaces = new Set;
1516
1644
  const aliases = new Map;
@@ -1526,6 +1654,7 @@ function buildReactModel(root) {
1526
1654
  const instanceVariablesByFunction = new Map;
1527
1655
  const allNodes = [...walk(root)];
1528
1656
  const declarations = allNodes.filter((node) => node.type === "variable_declaration");
1657
+ const projectHookKinds = importedHookKinds(declarations, project);
1529
1658
  let isReactFile = false;
1530
1659
  for (const declaration of declarations) {
1531
1660
  const text = declaration.text;
@@ -1600,7 +1729,7 @@ function buildReactModel(root) {
1600
1729
  if (!isReactFile || !info.body || info.isHook)
1601
1730
  continue;
1602
1731
  const simplePascalName = Boolean(info.name && /^[A-Z][A-Za-z0-9_]*$/.test(info.name));
1603
- const hasHooks = hasDirectHookCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces);
1732
+ const hasHooks = hasDirectHookCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces, projectHookKinds);
1604
1733
  const renders = hasDirectCreateElementCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces);
1605
1734
  if (hasHooks || isMemoWrappedFunction(info.node, aliases, reactNamespaces) || simplePascalName && renders) {
1606
1735
  info.isComponent = true;
@@ -1612,8 +1741,8 @@ function buildReactModel(root) {
1612
1741
  if (call.type !== "function_call")
1613
1742
  continue;
1614
1743
  const rawPath = call.childForFieldName("name")?.text ?? "";
1615
- const path5 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1616
- if (path5 !== "React.createElement")
1744
+ const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1745
+ if (path6 !== "React.createElement")
1617
1746
  continue;
1618
1747
  const element = call.childForFieldName("arguments")?.namedChildren[0]?.text.trim() ?? "";
1619
1748
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(element))
@@ -1648,13 +1777,13 @@ function buildReactModel(root) {
1648
1777
  if (!call)
1649
1778
  continue;
1650
1779
  const rawPath = call.childForFieldName("name")?.text ?? "";
1651
- const path5 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1780
+ const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1652
1781
  const args = call.childForFieldName("arguments")?.namedChildren ?? [];
1653
- if (path5 === "React.joinBindings" && names.length >= 1) {
1782
+ if (path6 === "React.joinBindings" && names.length >= 1) {
1654
1783
  stableVariables.add(names[0]);
1655
1784
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
1656
1785
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
1657
- } else if (path5 === "React.useState" && names.length >= 2) {
1786
+ } else if (path6 === "React.useState" && names.length >= 2) {
1658
1787
  const [valueName, setterName] = names;
1659
1788
  stateSetters.set(setterName, valueName);
1660
1789
  stateValues.set(valueName, setterName);
@@ -1668,10 +1797,10 @@ function buildReactModel(root) {
1668
1797
  initializer: args[0] ?? null,
1669
1798
  owner
1670
1799
  });
1671
- } else if (path5 === "React.useReducer" && names.length >= 2) {
1800
+ } else if (path6 === "React.useReducer" && names.length >= 2) {
1672
1801
  stableVariables.add(names[1]);
1673
1802
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
1674
- } else if (path5 === "React.useBinding" && names.length >= 1) {
1803
+ } else if (path6 === "React.useBinding" && names.length >= 1) {
1675
1804
  stableVariables.add(names[0]);
1676
1805
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
1677
1806
  if (names.length >= 2) {
@@ -1679,7 +1808,7 @@ function buildReactModel(root) {
1679
1808
  bindingSetters.add(names[1]);
1680
1809
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
1681
1810
  }
1682
- } else if (path5 === "React.useMemo" && names.length >= 1) {
1811
+ } else if (path6 === "React.useMemo" && names.length >= 1) {
1683
1812
  if (isEmptyDependencyTable(args[1])) {
1684
1813
  stableVariables.add(names[0]);
1685
1814
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
@@ -1688,16 +1817,16 @@ function buildReactModel(root) {
1688
1817
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
1689
1818
  addScopedVariable(instanceVariablesByFunction, owner, names[0]);
1690
1819
  }
1691
- } else if (path5 === "React.useCallback" && names.length >= 1 && isEmptyDependencyTable(args[1])) {
1820
+ } else if (path6 === "React.useCallback" && names.length >= 1 && isEmptyDependencyTable(args[1])) {
1692
1821
  stableVariables.add(names[0]);
1693
1822
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
1694
- } else if (path5 === "React.useRef" && names.length >= 1) {
1823
+ } else if (path6 === "React.useRef" && names.length >= 1) {
1695
1824
  stableVariables.add(names[0]);
1696
1825
  refVariables.add(names[0]);
1697
1826
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
1698
1827
  addScopedVariable(refVariablesByFunction, owner, names[0]);
1699
1828
  } else {
1700
- const final = path5.split(/[.:]/).at(-1) ?? "";
1829
+ const final = path6.split(/[.:]/).at(-1) ?? "";
1701
1830
  if (/^use[A-Z0-9_]/.test(final)) {
1702
1831
  for (const name of names.slice(1)) {
1703
1832
  if (!STABLE_CUSTOM_HOOK_RETURN.test(name))
@@ -1721,11 +1850,11 @@ function buildReactModel(root) {
1721
1850
  if (!expression || expression.type !== "function_call")
1722
1851
  continue;
1723
1852
  const rawPath = expression.childForFieldName("name")?.text ?? "";
1724
- const path5 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1725
- if (path5 === "React.useState" && names.length >= 2) {
1853
+ const path6 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
1854
+ if (path6 === "React.useState" && names.length >= 2) {
1726
1855
  stableVariables.add(names[1]);
1727
1856
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
1728
- } else if (path5 === "React.useBinding" && names.length >= 1) {
1857
+ } else if (path6 === "React.useBinding" && names.length >= 1) {
1729
1858
  stableVariables.add(names[0]);
1730
1859
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
1731
1860
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
@@ -1840,7 +1969,7 @@ function createInlineSuppressionChecker(source) {
1840
1969
 
1841
1970
  // src/parser.ts
1842
1971
  import fs5 from "fs";
1843
- import path5 from "path";
1972
+ import path6 from "path";
1844
1973
  import { Language, Parser } from "web-tree-sitter";
1845
1974
 
1846
1975
  // src/parser-compat.ts
@@ -1979,6 +2108,23 @@ function isCode(mask, start, end = start + 1) {
1979
2108
  }
1980
2109
  return true;
1981
2110
  }
2111
+ function matchingCodeDelimiterEnd(source, mask, start, opener, closer) {
2112
+ if (source[start] !== opener || !isCode(mask, start))
2113
+ return null;
2114
+ let depth = 0;
2115
+ for (let cursor = start;cursor < source.length; cursor += 1) {
2116
+ if (!isCode(mask, cursor))
2117
+ continue;
2118
+ if (source[cursor] === opener)
2119
+ depth += 1;
2120
+ else if (source[cursor] === closer) {
2121
+ depth -= 1;
2122
+ if (depth === 0)
2123
+ return cursor + 1;
2124
+ }
2125
+ }
2126
+ return null;
2127
+ }
1982
2128
  function maskTypeAliases(source, mask) {
1983
2129
  const { lines: lines2, offsets } = lineData(source);
1984
2130
  const ranges = [];
@@ -2186,6 +2332,89 @@ function genericFunctionParameterRanges(source, mask) {
2186
2332
  }
2187
2333
  return ranges;
2188
2334
  }
2335
+ function genericFunctionReturnPrefixRanges(source, mask) {
2336
+ const ranges = [];
2337
+ const functionPattern = /\bfunction\b/g;
2338
+ for (const match of source.matchAll(functionPattern)) {
2339
+ const start = match.index;
2340
+ if (!isCode(mask, start, start + match[0].length))
2341
+ continue;
2342
+ let cursor = start + match[0].length;
2343
+ let genericDepth = 0;
2344
+ while (cursor < source.length) {
2345
+ if (!isCode(mask, cursor)) {
2346
+ cursor += 1;
2347
+ continue;
2348
+ }
2349
+ if (source[cursor] === "<")
2350
+ genericDepth += 1;
2351
+ else if (source[cursor] === ">" && genericDepth > 0)
2352
+ genericDepth -= 1;
2353
+ else if (source[cursor] === "(" && genericDepth === 0)
2354
+ break;
2355
+ cursor += 1;
2356
+ }
2357
+ if (source[cursor] !== "(")
2358
+ continue;
2359
+ const parameterEnd = matchingCodeDelimiterEnd(source, mask, cursor, "(", ")");
2360
+ if (parameterEnd === null)
2361
+ continue;
2362
+ cursor = parameterEnd;
2363
+ while (cursor < source.length && /\s/.test(source[cursor]))
2364
+ cursor += 1;
2365
+ if (source[cursor] !== ":" || !isCode(mask, cursor))
2366
+ continue;
2367
+ cursor += 1;
2368
+ while (cursor < source.length && /\s/.test(source[cursor]))
2369
+ cursor += 1;
2370
+ if (source[cursor] !== "<")
2371
+ continue;
2372
+ const rangeStart = cursor;
2373
+ const genericEnd = matchingCodeDelimiterEnd(source, mask, cursor, "<", ">");
2374
+ if (genericEnd === null)
2375
+ continue;
2376
+ cursor = genericEnd;
2377
+ while (cursor < source.length && /\s/.test(source[cursor]))
2378
+ cursor += 1;
2379
+ const typeParametersEnd = matchingCodeDelimiterEnd(source, mask, cursor, "(", ")");
2380
+ if (typeParametersEnd === null)
2381
+ continue;
2382
+ cursor = typeParametersEnd;
2383
+ while (cursor < source.length && /\s/.test(source[cursor]))
2384
+ cursor += 1;
2385
+ if (source.slice(cursor, cursor + 2) !== "->" || !isCode(mask, cursor, cursor + 2))
2386
+ continue;
2387
+ cursor += 2;
2388
+ while (cursor < source.length && /[ \t]/.test(source[cursor]))
2389
+ cursor += 1;
2390
+ ranges.push({ start: rangeStart, end: cursor });
2391
+ }
2392
+ return ranges;
2393
+ }
2394
+ function normalizeLocalTypeofAnnotations(source, mask) {
2395
+ const replacements = [];
2396
+ const pattern = /\blocal\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*typeof\s*\(/g;
2397
+ for (const match of source.matchAll(pattern)) {
2398
+ const matchStart = match.index;
2399
+ if (!isCode(mask, matchStart, matchStart + match[0].length))
2400
+ continue;
2401
+ const typeofStart = matchStart + match[0].lastIndexOf("typeof");
2402
+ const open = source.indexOf("(", typeofStart + 6);
2403
+ if (open < 0)
2404
+ continue;
2405
+ const end = matchingCodeDelimiterEnd(source, mask, open, "(", ")");
2406
+ if (end === null)
2407
+ continue;
2408
+ const original = source.slice(typeofStart, end);
2409
+ const padding = blankPreservingUtf8Bytes(original).slice(3);
2410
+ replacements.push({ start: typeofStart, end, value: `any${padding}` });
2411
+ }
2412
+ let output = source;
2413
+ for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
2414
+ output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
2415
+ }
2416
+ return output;
2417
+ }
2189
2418
  function normalizeGenericTypePackUses(source, mask) {
2190
2419
  const declarationRanges = genericFunctionParameterRanges(source, mask);
2191
2420
  const replacements = [];
@@ -2246,13 +2475,15 @@ function parserCompatibleSource(source) {
2246
2475
  const keywordCompatible = replaceContextualKeywords(source, mask);
2247
2476
  const literalCompatible = normalizeIntegerLiteralSuffixes(keywordCompatible, mask);
2248
2477
  const packCompatible = normalizeGenericTypePackUses(literalCompatible, mask);
2478
+ const typeofCompatible = normalizeLocalTypeofAnnotations(packCompatible, mask);
2249
2479
  const ranges = [
2250
2480
  ...maskTypeAliases(source, mask),
2251
2481
  ...maskTypeLevelBlocks(source, mask),
2252
2482
  ...attributeRanges(source, mask),
2253
- ...explicitTypeArgumentRanges(source, mask)
2483
+ ...explicitTypeArgumentRanges(source, mask),
2484
+ ...genericFunctionReturnPrefixRanges(source, mask)
2254
2485
  ];
2255
- const masked = applyMasks(packCompatible, ranges);
2486
+ const masked = applyMasks(typeofCompatible, ranges);
2256
2487
  return masked.replace(/^(\s*)type(?=\s*(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=(?!=)))/gm, "$1_typ");
2257
2488
  }
2258
2489
 
@@ -2260,7 +2491,7 @@ function parserCompatibleSource(source) {
2260
2491
  var parserPromise = null;
2261
2492
  async function createParser() {
2262
2493
  await Parser.init();
2263
- const grammarPath = path5.resolve(import.meta.dir, "../vendor/tree-sitter-luau.wasm");
2494
+ const grammarPath = path6.resolve(import.meta.dir, "../vendor/tree-sitter-luau.wasm");
2264
2495
  const grammar = new Uint8Array(fs5.readFileSync(grammarPath));
2265
2496
  const language = await Language.load(grammar);
2266
2497
  const parser = new Parser;
@@ -2385,8 +2616,8 @@ var INSTANCE_FACTORY_MEMBERS2 = new Set([
2385
2616
  "GetService",
2386
2617
  "WaitForChild"
2387
2618
  ]);
2388
- function finalCallMember2(path6) {
2389
- return path6.split(/[.:]/).at(-1) ?? path6;
2619
+ function finalCallMember2(path7) {
2620
+ return path7.split(/[.:]/).at(-1) ?? path7;
2390
2621
  }
2391
2622
  function declarationInitializer(node, name) {
2392
2623
  if (node.type !== "variable_declaration")
@@ -2454,8 +2685,8 @@ function expressionHasRobloxOrigin(expression, from, context, seen) {
2454
2685
  seen.add(root);
2455
2686
  return expressionHasRobloxOrigin(visibleInitializer(root, from, context), from, context, seen);
2456
2687
  }
2457
- function hasRobloxReceiver(path6, call, context) {
2458
- const normalized = path6.replace(/\s+/g, "");
2688
+ function hasRobloxReceiver(path7, call, context) {
2689
+ const normalized = path7.replace(/\s+/g, "");
2459
2690
  if (/^(?:game|workspace|script)(?:[.:]|$)/.test(normalized))
2460
2691
  return true;
2461
2692
  const root = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
@@ -2465,6 +2696,8 @@ function hasRobloxReceiver(path6, call, context) {
2465
2696
  }
2466
2697
  var runServiceCallCache = new WeakMap;
2467
2698
  var runServiceExpressionCache = new WeakMap;
2699
+ var highFrequencyRobloxCallCache = new WeakMap;
2700
+ var highFrequencyRobloxExpressionCache = new WeakMap;
2468
2701
  function cachedNodeBoolean(cache, context, node, compute) {
2469
2702
  let entries = cache.get(context);
2470
2703
  if (!entries) {
@@ -2529,6 +2762,33 @@ function isHighFrequencyRunServiceCall(call, context) {
2529
2762
  return Boolean(bind && rootIsRunService(bind[1], call, context));
2530
2763
  });
2531
2764
  }
2765
+ var HIGH_FREQUENCY_ROBLOX_EVENTS = new Set([
2766
+ "InputChanged",
2767
+ "TouchMoved",
2768
+ "Move"
2769
+ ]);
2770
+ function isHighFrequencyRobloxExpression(node, context) {
2771
+ return cachedNodeBoolean(highFrequencyRobloxExpressionCache, context, node, () => {
2772
+ if (isHighFrequencyRunServiceExpression(node, context))
2773
+ return true;
2774
+ const normalized = node.text.replace(/\s+/g, "");
2775
+ const match = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move)$/);
2776
+ if (!match || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(match[2]))
2777
+ return false;
2778
+ return hasRobloxReceiver(match[1], node, context);
2779
+ });
2780
+ }
2781
+ function isHighFrequencyRobloxCall(call, context) {
2782
+ return cachedNodeBoolean(highFrequencyRobloxCallCache, context, call, () => {
2783
+ if (isHighFrequencyRunServiceCall(call, context))
2784
+ return true;
2785
+ const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
2786
+ const event = normalized.match(/^(.+)\.(InputChanged|TouchMoved|Move):Connect$/);
2787
+ if (!event || !HIGH_FREQUENCY_ROBLOX_EVENTS.has(event[2]))
2788
+ return false;
2789
+ return hasRobloxReceiver(event[1], call, context);
2790
+ });
2791
+ }
2532
2792
  function sourceHasHighFrequencyRunService(source) {
2533
2793
  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)) {
2534
2794
  return true;
@@ -2545,8 +2805,38 @@ function sourceHasHighFrequencyRunService(source) {
2545
2805
  }
2546
2806
  return false;
2547
2807
  }
2548
- function knownYieldReason(path6, call, context) {
2549
- const normalized = path6.replace(/\s+/g, "");
2808
+ function sourceHasHighFrequencyRobloxEvent(source) {
2809
+ if (sourceHasHighFrequencyRunService(source))
2810
+ return true;
2811
+ if (/game\s*:\s*GetService\s*\(\s*["']UserInputService["']\s*\)\s*\.\s*(?:InputChanged|TouchMoved)\s*:\s*Connect/.test(source)) {
2812
+ return true;
2813
+ }
2814
+ const serviceAliases = new Set;
2815
+ 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)) {
2816
+ serviceAliases.add(match[1]);
2817
+ }
2818
+ for (const alias of serviceAliases) {
2819
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2820
+ if (new RegExp(`\\b${escaped}\\s*\\.\\s*(?:InputChanged|TouchMoved)\\s*:\\s*Connect`).test(source)) {
2821
+ return true;
2822
+ }
2823
+ }
2824
+ const playersAliases = new Set;
2825
+ 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)) {
2826
+ playersAliases.add(match[1]);
2827
+ }
2828
+ for (const players of playersAliases) {
2829
+ const escapedPlayers = players.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2830
+ 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"))) {
2831
+ const mouse = match[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2832
+ if (new RegExp(`\\b${mouse}\\s*\\.\\s*Move\\s*:\\s*Connect`).test(source))
2833
+ return true;
2834
+ }
2835
+ }
2836
+ return false;
2837
+ }
2838
+ function knownYieldReason(path7, call, context) {
2839
+ const normalized = path7.replace(/\s+/g, "");
2550
2840
  if (normalized === "task.wait")
2551
2841
  return "task.wait";
2552
2842
  if (normalized === "coroutine.yield")
@@ -2566,15 +2856,15 @@ function functionYieldPoint(node, context) {
2566
2856
  const owner = context.model.functionByNode.get(node.id);
2567
2857
  if (owner && nearest !== owner)
2568
2858
  continue;
2569
- const path6 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
2570
- const reason = knownYieldReason(path6, candidate, context);
2859
+ const path7 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
2860
+ const reason = knownYieldReason(path7, candidate, context);
2571
2861
  if (reason)
2572
2862
  return { call: candidate, reason };
2573
2863
  }
2574
2864
  return null;
2575
2865
  }
2576
- function mutableDependencyBase(path6, externallyMutableRoots) {
2577
- const normalized = path6.replace(/\s+/g, "");
2866
+ function mutableDependencyBase(path7, externallyMutableRoots) {
2867
+ const normalized = path7.replace(/\s+/g, "");
2578
2868
  if (/\.current(?:\.|$)/.test(normalized))
2579
2869
  return "";
2580
2870
  const segments = normalized.split(".");
@@ -2600,8 +2890,8 @@ var CONDITIONAL_TYPES = new Set([
2600
2890
  "while_statement",
2601
2891
  "repeat_statement"
2602
2892
  ]);
2603
- function isHookPath(path6) {
2604
- const final = path6.split(/[.:]/).at(-1) ?? "";
2893
+ function isHookPath(path7) {
2894
+ const final = path7.split(/[.:]/).at(-1) ?? "";
2605
2895
  return /^use[A-Z0-9_]/.test(final);
2606
2896
  }
2607
2897
  function assignmentTargetNode(statement) {
@@ -2677,8 +2967,8 @@ function functionInfoForNode(context, node) {
2677
2967
  function stateBindingsFor(context, owner) {
2678
2968
  return context.model.stateBindings.filter((binding) => binding.owner === owner);
2679
2969
  }
2680
- function isEffectPath(path6) {
2681
- return path6 === "React.useEffect" || path6 === "React.useLayoutEffect";
2970
+ function isEffectPath(path7) {
2971
+ return path7 === "React.useEffect" || path7 === "React.useLayoutEffect";
2682
2972
  }
2683
2973
  function dependencyExpressions(node) {
2684
2974
  if (!node || node.type !== "table_constructor")
@@ -2933,11 +3223,11 @@ function isManagedByCleanupContainer(call, context, callback) {
2933
3223
  let current = call.parent;
2934
3224
  while (current && !sameNode(current, callback.node)) {
2935
3225
  if (current.type === "function_call") {
2936
- const path6 = context.getCallPath(current) ?? "";
2937
- if (ALWAYS_MANAGED_METHOD.test(path6))
3226
+ const path7 = context.getCallPath(current) ?? "";
3227
+ if (ALWAYS_MANAGED_METHOD.test(path7))
2938
3228
  return true;
2939
- if (GENERIC_MANAGER_METHOD.test(path6)) {
2940
- const receiver = path6.split(":", 1)[0] ?? "";
3229
+ if (GENERIC_MANAGER_METHOD.test(path7)) {
3230
+ const receiver = path7.split(":", 1)[0] ?? "";
2941
3231
  if (MANAGER_ROOT.test(receiver))
2942
3232
  return true;
2943
3233
  }
@@ -3042,10 +3332,10 @@ function isCleanupOnlyTask(callbackNode, context) {
3042
3332
  return false;
3043
3333
  return calls.every((call) => /:(?:Destroy|Disconnect)$/.test(context.getCallPath(call) ?? ""));
3044
3334
  }
3045
- function taskSeverity(path6, call, taskCallback, yieldPoint, context) {
3046
- if (path6 === "task.defer" && !yieldPoint)
3335
+ function taskSeverity(path7, call, taskCallback, yieldPoint, context) {
3336
+ if (path7 === "task.defer" && !yieldPoint)
3047
3337
  return "suggestion";
3048
- if (path6 === "task.delay" && !yieldPoint) {
3338
+ if (path7 === "task.delay" && !yieldPoint) {
3049
3339
  if (isCleanupOnlyTask(taskCallback, context))
3050
3340
  return "suggestion";
3051
3341
  const delay = numericTaskDelay(call, context);
@@ -3095,10 +3385,10 @@ end`,
3095
3385
  };
3096
3386
  }
3097
3387
  if ([...labels].every((label) => label.includes("task"))) {
3098
- const path6 = context.resolveCallPath(context.getCallPath(first.call) ?? "task.delay");
3099
- const taskCall = path6 === "task.defer" ? `task.defer(function()
3388
+ const path7 = context.resolveCallPath(context.getCallPath(first.call) ?? "task.delay");
3389
+ const taskCall = path7 === "task.defer" ? `task.defer(function()
3100
3390
  doWork()
3101
- end)` : path6 === "task.spawn" ? `task.spawn(function()
3391
+ end)` : path7 === "task.spawn" ? `task.spawn(function()
3102
3392
  doWork()
3103
3393
  end)` : `task.delay(delaySeconds, function()
3104
3394
  doWork()
@@ -3151,9 +3441,9 @@ end`,
3151
3441
  }
3152
3442
  function classifyResource(call, context, callback) {
3153
3443
  const rawPath = context.getCallPath(call) ?? "";
3154
- const path6 = context.resolveCallPath(rawPath);
3444
+ const path7 = context.resolveCallPath(rawPath);
3155
3445
  const variable = assignedVariableName(call, callback);
3156
- const finalMember = path6.split(/[.:]/).at(-1) ?? path6;
3446
+ const finalMember = path7.split(/[.:]/).at(-1) ?? path7;
3157
3447
  if (/:Connect$/.test(rawPath)) {
3158
3448
  const ownerExpression = connectionOwnerExpression(rawPath);
3159
3449
  const ownerIsInstance = isProvenInstanceExpression(ownerExpression, call, context, callback);
@@ -3222,7 +3512,7 @@ function classifyResource(call, context, callback) {
3222
3512
  }
3223
3513
  };
3224
3514
  }
3225
- if (path6 === "ReactRoblox.createRoot") {
3515
+ if (path7 === "ReactRoblox.createRoot") {
3226
3516
  return {
3227
3517
  call,
3228
3518
  label: "React root",
@@ -3232,17 +3522,17 @@ function classifyResource(call, context, callback) {
3232
3522
  }
3233
3523
  };
3234
3524
  }
3235
- if (path6 === "task.delay" || path6 === "task.defer" || path6 === "task.spawn") {
3525
+ if (path7 === "task.delay" || path7 === "task.defer" || path7 === "task.spawn") {
3236
3526
  const taskCallback = taskCallbackNode(call, context, callback);
3237
3527
  const yieldPoint = taskCallback ? functionYieldPoint(taskCallback, context) : null;
3238
- const outlivesEffect = path6 !== "task.spawn" || Boolean(yieldPoint);
3528
+ const outlivesEffect = path7 !== "task.spawn" || Boolean(yieldPoint);
3239
3529
  if (!outlivesEffect)
3240
3530
  return null;
3241
3531
  return {
3242
3532
  call,
3243
- label: path6 === "task.spawn" ? "yielding spawned task" : path6 === "task.defer" ? "deferred task" : "delayed task",
3533
+ label: path7 === "task.spawn" ? "yielding spawned task" : path7 === "task.defer" ? "deferred task" : "delayed task",
3244
3534
  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.",
3245
- severity: taskSeverity(path6, call, taskCallback, yieldPoint, context),
3535
+ severity: taskSeverity(path7, call, taskCallback, yieldPoint, context),
3246
3536
  variable,
3247
3537
  cleanupMatches(text, name) {
3248
3538
  if (name) {
@@ -3269,8 +3559,8 @@ var effectNeedsCleanup = {
3269
3559
  const rawPath = context.getCallPath(call);
3270
3560
  if (!rawPath)
3271
3561
  continue;
3272
- const path6 = context.resolveCallPath(rawPath);
3273
- if (path6 !== "React.useEffect" && path6 !== "React.useLayoutEffect")
3562
+ const path7 = context.resolveCallPath(rawPath);
3563
+ if (path7 !== "React.useEffect" && path7 !== "React.useLayoutEffect")
3274
3564
  continue;
3275
3565
  const callbackNode = firstFunctionArgument(context, call);
3276
3566
  const callback = functionInfoForNode(context, callbackNode);
@@ -3324,10 +3614,22 @@ function ownerForHook(context, call) {
3324
3614
  const owner = context.nearestFunction(call);
3325
3615
  return owner && (owner.isComponent || owner.isHook) ? owner : null;
3326
3616
  }
3617
+ function effectCallbackWithoutDeps(context, call) {
3618
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
3619
+ if (!isEffectPath(path7))
3620
+ return null;
3621
+ const args = context.callArguments(call);
3622
+ const callback = args[0];
3623
+ if (callback?.type !== "function_definition")
3624
+ return null;
3625
+ if (args.length < 2 || args[1]?.type === "nil")
3626
+ return callback;
3627
+ return null;
3628
+ }
3327
3629
  function callbackAndDeps(context, call) {
3328
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
3630
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
3329
3631
  const args = context.callArguments(call);
3330
- if (path6 === "React.useImperativeHandle") {
3632
+ if (path7 === "React.useImperativeHandle") {
3331
3633
  const callback2 = args[1];
3332
3634
  const deps2 = args[2];
3333
3635
  if (callback2?.type === "function_definition" && deps2?.type === "table_constructor")
@@ -3357,8 +3659,8 @@ function hasExternalSubscription(node, context) {
3357
3659
  for (const child of context.walk(node)) {
3358
3660
  if (child.type !== "function_call")
3359
3661
  continue;
3360
- const path6 = context.getCallPath(child) ?? "";
3361
- if (/:Connect$/.test(path6) || /BindToRenderStep$/.test(path6) || /:BindAction(?:AtPriority)?$/.test(path6))
3662
+ const path7 = context.getCallPath(child) ?? "";
3663
+ if (/:Connect$/.test(path7) || /BindToRenderStep$/.test(path7) || /:BindAction(?:AtPriority)?$/.test(path7))
3362
3664
  return true;
3363
3665
  }
3364
3666
  return false;
@@ -3392,6 +3694,33 @@ function setterCalledOutsideCallback(context, owner, callback, setterName) {
3392
3694
  }
3393
3695
  return false;
3394
3696
  }
3697
+ function guaranteedRepeatedStateChange(argument, stateName, context) {
3698
+ if (argument.type === "table_constructor")
3699
+ return true;
3700
+ if (argument.type === "function_definition") {
3701
+ const updater = functionInfoForNode(context, argument);
3702
+ const previousName = updater?.parameters[0];
3703
+ const returnStatement = updater?.body?.namedChildren.find((child) => child.type === "return_statement");
3704
+ const returnValue = returnStatement?.namedChildren[0];
3705
+ if (previousName && returnValue && guaranteedRepeatedStateChange(returnValue, previousName, context))
3706
+ return true;
3707
+ return false;
3708
+ }
3709
+ const text = argument.text.trim();
3710
+ const escaped = stateName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3711
+ if (new RegExp(`^not\\s+${escaped}$`).test(text))
3712
+ return true;
3713
+ if (new RegExp(`^${escaped}\\s*[+-]\\s*(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)$`).test(text))
3714
+ return true;
3715
+ if (new RegExp(`^(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)\\s*\\+\\s*${escaped}$`).test(text))
3716
+ return true;
3717
+ if (argument.type === "function_call") {
3718
+ const path7 = context.resolveCallPath(context.getCallPath(argument) ?? "");
3719
+ if (path7 === "table.clone" || path7 === "table.create" || path7 === "table.pack")
3720
+ return true;
3721
+ }
3722
+ return false;
3723
+ }
3395
3724
  function freshDependencyKind(node, owner) {
3396
3725
  if (node.type === "table_constructor")
3397
3726
  return "table";
@@ -3414,8 +3743,8 @@ var noDerivedStateEffect = {
3414
3743
  run(context) {
3415
3744
  const diagnostics = [];
3416
3745
  for (const call of context.findCalls()) {
3417
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
3418
- if (!isEffectPath(path6))
3746
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
3747
+ if (!isEffectPath(path7))
3419
3748
  continue;
3420
3749
  const owner = ownerForHook(context, call);
3421
3750
  const parts = callbackAndDeps(context, call);
@@ -3453,28 +3782,48 @@ var noSelfUpdatingEffect = {
3453
3782
  id: "react-luau/no-self-updating-effect",
3454
3783
  category: "Effects",
3455
3784
  severity: "warning",
3456
- description: "Effects should not unconditionally update state that is also one of their dependencies.",
3785
+ description: "Effects should not unconditionally update state in a way that schedules themselves again.",
3457
3786
  run(context) {
3458
3787
  const diagnostics = [];
3459
3788
  for (const call of context.findCalls()) {
3460
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
3461
- if (!isEffectPath(path6))
3789
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
3790
+ if (!isEffectPath(path7))
3462
3791
  continue;
3463
3792
  const owner = ownerForHook(context, call);
3793
+ if (!owner)
3794
+ continue;
3464
3795
  const parts = callbackAndDeps(context, call);
3465
- if (!owner || !parts)
3796
+ if (parts) {
3797
+ const deps = parseDependencyRoots(parts.deps);
3798
+ for (const binding of stateBindingsFor(context, owner)) {
3799
+ if (!deps.has(binding.valueName))
3800
+ continue;
3801
+ for (const setterCall of directTopLevelCalls(context, parts.callback)) {
3802
+ if (context.getCallPath(setterCall) !== binding.setterName)
3803
+ continue;
3804
+ diagnostics.push({
3805
+ node: callNameNode(setterCall),
3806
+ message: `${binding.setterName}() updates ${binding.valueName}, which is also in this effect's dependency table.`,
3807
+ help: "Guard the update so it converges, move it to the originating event, or derive the value during render."
3808
+ });
3809
+ }
3810
+ }
3811
+ continue;
3812
+ }
3813
+ const callback = effectCallbackWithoutDeps(context, call);
3814
+ if (!callback)
3466
3815
  continue;
3467
- const deps = parseDependencyRoots(parts.deps);
3468
3816
  for (const binding of stateBindingsFor(context, owner)) {
3469
- if (!deps.has(binding.valueName))
3470
- continue;
3471
- for (const setterCall of directTopLevelCalls(context, parts.callback)) {
3817
+ for (const setterCall of directTopLevelCalls(context, callback)) {
3472
3818
  if (context.getCallPath(setterCall) !== binding.setterName)
3473
3819
  continue;
3820
+ const argument = context.callArguments(setterCall)[0];
3821
+ if (!argument || !guaranteedRepeatedStateChange(argument, binding.valueName, context))
3822
+ continue;
3474
3823
  diagnostics.push({
3475
3824
  node: callNameNode(setterCall),
3476
- message: `${binding.setterName}() updates ${binding.valueName}, which is also in this effect's dependency table.`,
3477
- help: "Guard the update so it converges, move it to the originating event, or derive the value during render."
3825
+ message: `${binding.setterName}() unconditionally changes ${binding.valueName} in an effect that runs after every render.`,
3826
+ help: "Add a dependency table or convergence guard, move the update to the event that owns it, or derive the value during render."
3478
3827
  });
3479
3828
  }
3480
3829
  }
@@ -3490,8 +3839,8 @@ var noEffectWithFreshDeps = {
3490
3839
  run(context) {
3491
3840
  const diagnostics = [];
3492
3841
  for (const call of context.findCalls()) {
3493
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
3494
- if (!HOOKS_WITH_DEPS.has(path6))
3842
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
3843
+ if (!HOOKS_WITH_DEPS.has(path7))
3495
3844
  continue;
3496
3845
  const owner = ownerForHook(context, call);
3497
3846
  const parts = callbackAndDeps(context, call);
@@ -3504,7 +3853,7 @@ var noEffectWithFreshDeps = {
3504
3853
  const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
3505
3854
  diagnostics.push({
3506
3855
  node: dependency,
3507
- message: `${path6} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
3856
+ message: `${path7} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
3508
3857
  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."
3509
3858
  });
3510
3859
  }
@@ -3520,8 +3869,8 @@ var noMutableInDeps = {
3520
3869
  run(context) {
3521
3870
  const diagnostics = [];
3522
3871
  for (const call of context.findCalls()) {
3523
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
3524
- if (!HOOKS_WITH_DEPS.has(path6))
3872
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
3873
+ if (!HOOKS_WITH_DEPS.has(path7))
3525
3874
  continue;
3526
3875
  const owner = ownerForHook(context, call);
3527
3876
  const parts = callbackAndDeps(context, call);
@@ -3614,23 +3963,61 @@ var HOOK_ARGUMENTS = new Map([
3614
3963
  ["React.useImperativeHandle", { callbackIndex: 1, depsIndex: 2 }]
3615
3964
  ]);
3616
3965
  function parameterNames2(functionNode) {
3617
- const names = new Set;
3618
- const parameters = functionNode.childForFieldName("parameters");
3619
- if (!parameters)
3620
- return names;
3621
- for (const node of parameters.namedChildren) {
3622
- for (const candidate of descendants(node)) {
3623
- if (candidate.type === "identifier")
3624
- names.add(candidate.text);
3625
- }
3626
- }
3627
- return names;
3966
+ return new Set(parameterBindingNames(functionNode.childForFieldName("parameters")));
3628
3967
  }
3629
3968
  function* descendants(node) {
3630
3969
  yield node;
3631
3970
  for (const child of node.namedChildren)
3632
3971
  yield* descendants(child);
3633
3972
  }
3973
+ var TYPE_ONLY_NODES = new Set([
3974
+ "type_definition",
3975
+ "type_annotation",
3976
+ "type_parameters",
3977
+ "generic_type_list",
3978
+ "generic_type",
3979
+ "function_type",
3980
+ "optional_type",
3981
+ "object_type",
3982
+ "union_type",
3983
+ "intersection_type",
3984
+ "tuple_type",
3985
+ "singleton_type",
3986
+ "variadic_type",
3987
+ "variadic_type_pack"
3988
+ ]);
3989
+ function isTypeOnlyChild(parent, child) {
3990
+ if (TYPE_ONLY_NODES.has(child.type))
3991
+ return true;
3992
+ if (TYPE_ONLY_NODES.has(parent.type))
3993
+ return true;
3994
+ if (parent.type === "parameter") {
3995
+ return !sameNode(parent.namedChildren.find((candidate) => candidate.type === "identifier"), child);
3996
+ }
3997
+ if (parent.type === "cast_expression" || parent.type === "type_cast_expression") {
3998
+ return !sameNode(parent.namedChildren[0], child);
3999
+ }
4000
+ if (parent.type === "function_definition" || parent.type === "function_declaration") {
4001
+ const name = parent.childForFieldName("name");
4002
+ const parameters = parent.childForFieldName("parameters");
4003
+ const body = parent.childForFieldName("body");
4004
+ if (sameNode(child, name) || sameNode(child, parameters) || sameNode(child, body))
4005
+ return false;
4006
+ return true;
4007
+ }
4008
+ if (parent.type === "variable_list" && parent.parent?.parent?.type === "variable_declaration") {
4009
+ return true;
4010
+ }
4011
+ return false;
4012
+ }
4013
+ function* runtimeDescendants(node) {
4014
+ yield node;
4015
+ for (const child of node.namedChildren) {
4016
+ if (isTypeOnlyChild(node, child))
4017
+ continue;
4018
+ yield* runtimeDescendants(child);
4019
+ }
4020
+ }
3634
4021
  function directBlockBindingNames(block, beforeIndex) {
3635
4022
  const names = new Set;
3636
4023
  for (const child of block.namedChildren) {
@@ -3706,8 +4093,8 @@ function rootIdentifierNode(node, root) {
3706
4093
  }
3707
4094
  return null;
3708
4095
  }
3709
- function normalizeCapturedPath(path6, stableVariables, externallyMutableRoots) {
3710
- const normalized = normalizeExpressionText(path6);
4096
+ function normalizeCapturedPath(path7, stableVariables, externallyMutableRoots) {
4097
+ const normalized = normalizeExpressionText(path7);
3711
4098
  const root = rootIdentifier(normalized);
3712
4099
  if (!root)
3713
4100
  return normalized;
@@ -3724,7 +4111,7 @@ function normalizeCapturedPath(path6, stableVariables, externallyMutableRoots) {
3724
4111
  }
3725
4112
  function capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots) {
3726
4113
  const captured = new Set;
3727
- for (const node of descendants(callback)) {
4114
+ for (const node of runtimeDescendants(callback)) {
3728
4115
  if (node.type === "dot_index_expression" && outermostDotPath(node)) {
3729
4116
  const root = rootIdentifier(node.text);
3730
4117
  if (!root || !available.has(root) || GLOBALS.has(root) || stableVariables.has(root))
@@ -3793,21 +4180,94 @@ function expandLocalFunctionCaptures(captured, owner, available, stableVariables
3793
4180
  addCapture(capture);
3794
4181
  return result;
3795
4182
  }
4183
+ function dependencyCoveragePath(node) {
4184
+ if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
4185
+ const expression = node.namedChildren[0];
4186
+ if (expression)
4187
+ return dependencyCoveragePath(expression);
4188
+ }
4189
+ if (node.type === "binary_expression") {
4190
+ const left = node.childForFieldName("left") ?? node.namedChildren[0];
4191
+ const right = node.childForFieldName("right") ?? node.namedChildren[1];
4192
+ const isOr = node.children.some((child) => child.type === "or");
4193
+ if (isOr && left && right?.type === "false")
4194
+ return dependencyCoveragePath(left);
4195
+ }
4196
+ return normalizeExpressionText(node.text);
4197
+ }
3796
4198
  function dependencyPaths(node) {
3797
- return new Set(dependencyExpressions(node).map((dependency) => normalizeExpressionText(dependency.text)));
4199
+ return new Set(dependencyExpressions(node).map(dependencyCoveragePath));
3798
4200
  }
3799
- 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))$/;
3800
- function isPureDerivedExpression(node, context) {
3801
- for (const candidate of context.walk(node)) {
3802
- if (candidate !== node && (candidate.type === "function_definition" || candidate.type === "function_declaration"))
3803
- return false;
3804
- if (candidate.type !== "function_call")
3805
- continue;
3806
- const rawPath = context.getCallPath(candidate);
4201
+ function transparentAliasPath(node) {
4202
+ if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
4203
+ const expression = node.namedChildren[0];
4204
+ return expression ? transparentAliasPath(expression) : null;
4205
+ }
4206
+ if (node.type !== "identifier" && node.type !== "dot_index_expression")
4207
+ return null;
4208
+ return normalizeExpressionText(node.text);
4209
+ }
4210
+ function immutableTransparentAliases(owner, context) {
4211
+ const aliases = new Map;
4212
+ if (!owner.body)
4213
+ return aliases;
4214
+ const mutated = mutatedOwnerLocals(owner, context);
4215
+ for (const statement of owner.body.namedChildren) {
4216
+ if (statement.type !== "variable_declaration")
4217
+ continue;
4218
+ const names = declarationNames2(statement);
4219
+ const expressions = declarationExpressions3(statement);
4220
+ for (let index = 0;index < names.length; index += 1) {
4221
+ const name = names[index];
4222
+ const expression = expressions[index];
4223
+ if (!expression || mutated.has(name))
4224
+ continue;
4225
+ const source = transparentAliasPath(expression);
4226
+ if (!source || source === name)
4227
+ continue;
4228
+ const sourceRoot = rootIdentifier(source);
4229
+ if (!sourceRoot || mutated.has(sourceRoot))
4230
+ continue;
4231
+ aliases.set(name, source);
4232
+ }
4233
+ }
4234
+ return aliases;
4235
+ }
4236
+ function resolveAliasPath(path7, aliases) {
4237
+ let current = path7;
4238
+ const visiting = new Set;
4239
+ while (true) {
4240
+ const root = rootIdentifier(current);
4241
+ if (!root || visiting.has(root))
4242
+ return current;
4243
+ const source = aliases.get(root);
4244
+ if (!source)
4245
+ return current;
4246
+ visiting.add(root);
4247
+ current = `${source}${current.slice(root.length)}`;
4248
+ }
4249
+ }
4250
+ function dependencyPathsWithAliases(node, aliases) {
4251
+ const dependencies = dependencyPaths(node);
4252
+ for (const dependency of [...dependencies]) {
4253
+ const resolved = resolveAliasPath(dependency, aliases);
4254
+ if (resolved !== dependency)
4255
+ dependencies.add(resolved);
4256
+ }
4257
+ return dependencies;
4258
+ }
4259
+ 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))$/;
4260
+ function isPureDerivedExpression(node, context) {
4261
+ for (const candidate of context.walk(node)) {
4262
+ if (candidate !== node && (candidate.type === "function_definition" || candidate.type === "function_declaration"))
4263
+ return false;
4264
+ if (candidate.type !== "function_call")
4265
+ continue;
4266
+ const rawPath = context.getCallPath(candidate);
3807
4267
  if (!rawPath)
3808
4268
  return false;
3809
- const path6 = normalizeExpressionText(context.resolveCallPath(rawPath));
3810
- if (!PURE_DERIVED_CALL.test(path6))
4269
+ const path7 = normalizeExpressionText(context.resolveCallPath(rawPath));
4270
+ if (!PURE_DERIVED_CALL.test(path7))
3811
4271
  return false;
3812
4272
  }
3813
4273
  return true;
@@ -3886,8 +4346,8 @@ function hasDynamicUnpackDependencies(node, context) {
3886
4346
  const rawPath = context.getCallPath(candidate);
3887
4347
  if (!rawPath)
3888
4348
  continue;
3889
- const path6 = normalizeExpressionText(context.resolveCallPath(rawPath));
3890
- if (path6 === "unpack" || path6 === "table.unpack")
4349
+ const path7 = normalizeExpressionText(context.resolveCallPath(rawPath));
4350
+ if (path7 === "unpack" || path7 === "table.unpack")
3891
4351
  return true;
3892
4352
  }
3893
4353
  }
@@ -3912,8 +4372,8 @@ function memoizedProducerDependencies(owner, context) {
3912
4372
  const rawPath = context.getCallPath(expression);
3913
4373
  if (!rawPath)
3914
4374
  continue;
3915
- const path6 = context.resolveCallPath(rawPath);
3916
- if (path6 !== "React.useMemo" && path6 !== "React.useCallback")
4375
+ const path7 = context.resolveCallPath(rawPath);
4376
+ if (path7 !== "React.useMemo" && path7 !== "React.useCallback")
3917
4377
  continue;
3918
4378
  const args = context.callArguments(expression);
3919
4379
  const deps = args[1];
@@ -3972,9 +4432,9 @@ function directCallsInExpression(node) {
3972
4432
  return calls;
3973
4433
  }
3974
4434
  function isCustomHookCall(call, context) {
3975
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
3976
- const final = path6.split(/[.:]/).at(-1) ?? "";
3977
- return !path6.startsWith("React.") && /^use[A-Z0-9_]/.test(final);
4435
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
4436
+ const final = path7.split(/[.:]/).at(-1) ?? "";
4437
+ return !path7.startsWith("React.") && /^use[A-Z0-9_]/.test(final);
3978
4438
  }
3979
4439
  var HANDLE_VALUE_NAME = /(?:ref|binding|tween|spring|motion|animation|controller|handle|connection|signal)$/i;
3980
4440
  var HANDLE_HOOK_NAME = /(?:Tween|Spring|Binding|Motion|Animated|Animation|Sound)$/i;
@@ -3984,8 +4444,8 @@ function isCalledAsFunction(owner, name, context) {
3984
4444
  for (const call of context.walk(owner.body)) {
3985
4445
  if (call.type !== "function_call")
3986
4446
  continue;
3987
- const path6 = normalizeExpressionText(context.getCallPath(call) ?? "");
3988
- if (path6 === name)
4447
+ const path7 = normalizeExpressionText(context.getCallPath(call) ?? "");
4448
+ if (path7 === name)
3989
4449
  return true;
3990
4450
  }
3991
4451
  return false;
@@ -4015,8 +4475,8 @@ function customHookDependencyHints(owner, context) {
4015
4475
  continue;
4016
4476
  const directHook = calls.find((call) => isCustomHookCall(call, context));
4017
4477
  if (directHook && expression.type === "function_call") {
4018
- const path6 = context.resolveCallPath(context.getCallPath(directHook) ?? "");
4019
- const final = path6.split(/[.:]/).at(-1) ?? "";
4478
+ const path7 = context.resolveCallPath(context.getCallPath(directHook) ?? "");
4479
+ const final = path7.split(/[.:]/).at(-1) ?? "";
4020
4480
  const likelyHandle = HANDLE_VALUE_NAME.test(name) || HANDLE_HOOK_NAME.test(final) || isCalledAsFunction(owner, name, context);
4021
4481
  if (likelyHandle) {
4022
4482
  hints.memberHandleRoots.add(name);
@@ -4026,14 +4486,14 @@ function customHookDependencyHints(owner, context) {
4026
4486
  }
4027
4487
  if (expression.type === "table_constructor") {
4028
4488
  const meaningfulCalls = calls.filter((call) => {
4029
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
4030
- return !/^(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|TweenInfo|NumberSequence|ColorSequence)\./.test(path6);
4489
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
4490
+ return !/^(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|TweenInfo|NumberSequence|ColorSequence)\./.test(path7);
4031
4491
  });
4032
4492
  const allHandleHooks = meaningfulCalls.length > 0 && meaningfulCalls.every((call) => {
4033
4493
  if (!isCustomHookCall(call, context))
4034
4494
  return false;
4035
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
4036
- const final = path6.split(/[.:]/).at(-1) ?? "";
4495
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
4496
+ const final = path7.split(/[.:]/).at(-1) ?? "";
4037
4497
  return HANDLE_HOOK_NAME.test(final);
4038
4498
  });
4039
4499
  if (allHandleHooks)
@@ -4063,12 +4523,13 @@ var exhaustiveDeps = {
4063
4523
  const derivedDependenciesByOwner = new Map;
4064
4524
  const dependencyHintsByOwner = new Map;
4065
4525
  const memoizedProducersByOwner = new Map;
4526
+ const transparentAliasesByOwner = new Map;
4066
4527
  for (const call of context.findCalls()) {
4067
4528
  const rawPath = context.getCallPath(call);
4068
4529
  if (!rawPath)
4069
4530
  continue;
4070
- const path6 = context.resolveCallPath(rawPath);
4071
- const shape = HOOK_ARGUMENTS.get(path6);
4531
+ const path7 = context.resolveCallPath(rawPath);
4532
+ const shape = HOOK_ARGUMENTS.get(path7);
4072
4533
  if (!shape)
4073
4534
  continue;
4074
4535
  const args = context.callArguments(call);
@@ -4084,7 +4545,12 @@ var exhaustiveDeps = {
4084
4545
  const stableVariables = context.model.stableVariablesByFunction.get(ownerKey) ?? new Set;
4085
4546
  const externallyMutableRoots = context.model.externalMutableVariablesByFunction.get(ownerKey) ?? new Set;
4086
4547
  const functionExpandedCaptures = expandLocalFunctionCaptures(capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots), owner, available, stableVariables, externallyMutableRoots, context);
4087
- const dependencies = dependencyPaths(deps);
4548
+ let transparentAliases = transparentAliasesByOwner.get(ownerKey);
4549
+ if (!transparentAliases) {
4550
+ transparentAliases = immutableTransparentAliases(owner, context);
4551
+ transparentAliasesByOwner.set(ownerKey, transparentAliases);
4552
+ }
4553
+ const dependencies = dependencyPathsWithAliases(deps, transparentAliases);
4088
4554
  let derivedDependencies = derivedDependenciesByOwner.get(ownerKey);
4089
4555
  if (!derivedDependencies) {
4090
4556
  derivedDependencies = derivedLocalDependencies(owner, available, stableVariables, externallyMutableRoots, context);
@@ -4098,7 +4564,7 @@ var exhaustiveDeps = {
4098
4564
  diagnostics.push({
4099
4565
  node: tableStartNode(deps),
4100
4566
  severity: "suggestion",
4101
- message: `${path6} uses dynamic unpack(...) dependencies, so exhaustive dependency analysis is partial.`,
4567
+ message: `${path7} uses dynamic unpack(...) dependencies, so exhaustive dependency analysis is partial.`,
4102
4568
  help: "The analyzer cannot prove which values unpack(...) contributes. Review the explicit dependencies and the custom hook contract."
4103
4569
  });
4104
4570
  continue;
@@ -4119,7 +4585,7 @@ var exhaustiveDeps = {
4119
4585
  if (reactiveMissing.length > 0) {
4120
4586
  diagnostics.push({
4121
4587
  node: tableStartNode(deps),
4122
- message: `${path6} dependency table is missing ${reactiveMissing.join(", ")}.`,
4588
+ message: `${path7} dependency table is missing ${reactiveMissing.join(", ")}.`,
4123
4589
  help: `Add ${reactiveMissing.join(", ")} to the dependency table, or stop capturing ${reactiveMissing.length === 1 ? "that value" : "those values"} in the callback.`
4124
4590
  });
4125
4591
  }
@@ -4127,7 +4593,7 @@ var exhaustiveDeps = {
4127
4593
  diagnostics.push({
4128
4594
  node: tableStartNode(deps),
4129
4595
  severity: "suggestion",
4130
- message: `${path6} 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.`,
4596
+ message: `${path7} 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.`,
4131
4597
  help: "The producing memo dependencies are already covered. Listing the memoized value directly can make the dependency relationship clearer."
4132
4598
  });
4133
4599
  }
@@ -4135,7 +4601,7 @@ var exhaustiveDeps = {
4135
4601
  diagnostics.push({
4136
4602
  node: tableStartNode(deps),
4137
4603
  severity: "suggestion",
4138
- message: `${path6} captures custom-hook handle ${uncertainHandles.join(", ")} without listing ${uncertainHandles.length === 1 ? "it as a dependency" : "them as dependencies"}.`,
4604
+ message: `${path7} captures custom-hook handle ${uncertainHandles.join(", ")} without listing ${uncertainHandles.length === 1 ? "it as a dependency" : "them as dependencies"}.`,
4139
4605
  help: "The analyzer cannot prove this custom-hook return is stable. Check the hook contract before changing the dependency table."
4140
4606
  });
4141
4607
  }
@@ -4169,8 +4635,8 @@ function createElementCallsInLoop(context, loop) {
4169
4635
  for (const node of context.walk(loop)) {
4170
4636
  if (node.type !== "function_call")
4171
4637
  continue;
4172
- const path6 = context.resolveCallPath(context.getCallPath(node) ?? "");
4173
- if (path6 === "React.createElement")
4638
+ const path7 = context.resolveCallPath(context.getCallPath(node) ?? "");
4639
+ if (path7 === "React.createElement")
4174
4640
  result.push(node);
4175
4641
  }
4176
4642
  return result;
@@ -4226,8 +4692,8 @@ var noCreateContextInRender = {
4226
4692
  run(context) {
4227
4693
  const diagnostics = [];
4228
4694
  for (const call of context.findCalls()) {
4229
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
4230
- if (path6 !== "React.createContext")
4695
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
4696
+ if (path7 !== "React.createContext")
4231
4697
  continue;
4232
4698
  const owner = context.nearestFunction(call);
4233
4699
  if (!owner || !owner.isComponent && !owner.isHook)
@@ -4263,8 +4729,8 @@ function isRenderedBy(context, component, name) {
4263
4729
  continue;
4264
4730
  if (context.nearestFunction(call) !== component)
4265
4731
  continue;
4266
- const path6 = context.resolveCallPath(context.getCallPath(call) ?? "");
4267
- if (path6 !== "React.createElement")
4732
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
4733
+ if (path7 !== "React.createElement")
4268
4734
  continue;
4269
4735
  if (context.callArguments(call)[0]?.text.trim() === name)
4270
4736
  return true;
@@ -4308,63 +4774,6 @@ var noNestedComponentDefinition = {
4308
4774
  }
4309
4775
  };
4310
4776
 
4311
- // src/module-resolution.ts
4312
- import path6 from "path";
4313
- function normalizeRelative2(value) {
4314
- return value.split(path6.sep).join("/");
4315
- }
4316
- function moduleKeys(relativePath) {
4317
- let normalized = normalizeRelative2(relativePath).replace(/\.(?:lua|luau)$/i, "");
4318
- if (normalized.endsWith("/init"))
4319
- normalized = normalized.slice(0, -"/init".length);
4320
- const segments = normalized.split("/").filter(Boolean);
4321
- if (segments[0]?.toLowerCase() === "src")
4322
- segments.shift();
4323
- const keys = new Set;
4324
- for (let index = 0;index < segments.length; index += 1) {
4325
- const suffix = segments.slice(index).join(".").toLowerCase();
4326
- if (suffix)
4327
- keys.add(suffix);
4328
- }
4329
- return [...keys];
4330
- }
4331
- function normalizeRequireTarget(text) {
4332
- return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
4333
- }
4334
- function buildUniqueFeatureAliases(modules, featureByModuleId) {
4335
- const owners = new Map;
4336
- for (const module of modules) {
4337
- for (const key of module.keys) {
4338
- const ids = owners.get(key) ?? new Set;
4339
- ids.add(module.id);
4340
- owners.set(key, ids);
4341
- }
4342
- }
4343
- const aliases = new Map;
4344
- for (const module of modules) {
4345
- const value = featureByModuleId.get(module.id);
4346
- if (value === undefined)
4347
- continue;
4348
- for (const key of module.keys) {
4349
- const ids = owners.get(key);
4350
- if (ids?.size === 1 && ids.has(module.id))
4351
- aliases.set(key, value);
4352
- }
4353
- }
4354
- return aliases;
4355
- }
4356
- function resolveModuleReference(target, aliases) {
4357
- let candidate = target;
4358
- while (true) {
4359
- if (aliases.has(candidate))
4360
- return aliases.get(candidate);
4361
- const separator = candidate.indexOf(".");
4362
- if (separator < 0)
4363
- return null;
4364
- candidate = candidate.slice(separator + 1);
4365
- }
4366
- }
4367
-
4368
4777
  // src/rules/parameter-mutations.ts
4369
4778
  var analysisCache = new WeakMap;
4370
4779
  var EMPTY_INDEXES = new Set;
@@ -5091,7 +5500,7 @@ var rerenderHighFrequencyState = {
5091
5500
  seen.add(key);
5092
5501
  const renderCost = componentRenderCost(context, component);
5093
5502
  diagnostics.push({
5094
- node: callNameNode(call),
5503
+ node: callNameNode(binding.call),
5095
5504
  severity: "warning",
5096
5505
  message: `${binding.valueName} has a state setter called from a per-frame callback and may cause frequent rerenders when its value changes.`,
5097
5506
  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."
@@ -5368,7 +5777,9 @@ var preferUseRefForMutableCell = {
5368
5777
  };
5369
5778
 
5370
5779
  // src/rules/prefer-binding-over-state.ts
5371
- var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Once|Subscribe|Observe|Listen|Watch)$/i;
5780
+ var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
5781
+ var PERSISTENT_MIRROR_CALLBACK = /(?:^|[.:])(?:Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)$/i;
5782
+ var LIKELY_CONTINUOUS_CALLBACK = /(?:^|[.:])onStep$/i;
5372
5783
  var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
5373
5784
  function replaceWithinNode(container, target, replacement) {
5374
5785
  const start = target.startIndex - container.startIndex;
@@ -5491,13 +5902,64 @@ var TRANSPARENT_PARENTS = new Set([
5491
5902
  "string_interpolation",
5492
5903
  "expression_list",
5493
5904
  "assignment_statement",
5494
- "arguments",
5495
- "function_call",
5496
5905
  "dot_index_expression",
5497
5906
  "method_index_expression",
5498
5907
  "table_constructor",
5499
5908
  "field"
5500
5909
  ]);
5910
+ 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_]*)$/;
5911
+ var HOST_FEEDBACK_PROPERTIES = new Set([
5912
+ "AbsoluteSize",
5913
+ "AbsolutePosition",
5914
+ "AbsoluteRotation",
5915
+ "AbsoluteContentSize",
5916
+ "AbsoluteCanvasSize",
5917
+ "AbsoluteCellSize",
5918
+ "AbsoluteCellCount",
5919
+ "TextBounds",
5920
+ "CanvasPosition"
5921
+ ]);
5922
+ var LIKELY_CONTINUOUS_PROPERTIES = new Set([
5923
+ "CFrame",
5924
+ "WorldCFrame",
5925
+ "Position",
5926
+ "WorldPosition",
5927
+ "Rotation",
5928
+ "Orientation",
5929
+ "CanvasPosition",
5930
+ "PlaybackLoudness",
5931
+ "TimePosition"
5932
+ ]);
5933
+ var SEMANTIC_SNAPSHOT_EXPRESSION = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
5934
+ function isBindingMapSafeCall(call, context) {
5935
+ if (call.type !== "function_call")
5936
+ return false;
5937
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "").replace(/\s+/g, "");
5938
+ return PURE_BINDING_DERIVATION_CALL.test(path7);
5939
+ }
5940
+ function isTransparentParent(parent, context) {
5941
+ if (parent.type === "function_call")
5942
+ return isBindingMapSafeCall(parent, context);
5943
+ if (parent.type === "arguments") {
5944
+ return parent.parent?.type === "function_call" && isBindingMapSafeCall(parent.parent, context);
5945
+ }
5946
+ return TRANSPARENT_PARENTS.has(parent.type);
5947
+ }
5948
+ function stringLiteralValue(node) {
5949
+ if (!node)
5950
+ return null;
5951
+ return node.text.trim().match(/^["']([^"']+)["']$/)?.[1] ?? null;
5952
+ }
5953
+ function observedPropertyForCall(summary, call, context) {
5954
+ if (summary.observedPropertyName)
5955
+ return summary.observedPropertyName;
5956
+ if (summary.observedPropertyParameterIndex === undefined)
5957
+ return null;
5958
+ return stringLiteralValue(context.callArguments(call)[summary.observedPropertyParameterIndex]);
5959
+ }
5960
+ function isStrongHostFeedbackProperty(property) {
5961
+ return Boolean(property && HOST_FEEDBACK_PROPERTIES.has(property));
5962
+ }
5501
5963
  function isInside2(node, ancestor) {
5502
5964
  let current = node;
5503
5965
  while (current) {
@@ -5581,13 +6043,59 @@ function isTransparentUsage(node, context, compatibleComponentProps) {
5581
6043
  }
5582
6044
  if (parent.type === "function_definition" || parent.type === "function_declaration")
5583
6045
  return false;
5584
- if (!TRANSPARENT_PARENTS.has(parent.type))
6046
+ if (!isTransparentParent(parent, context))
5585
6047
  return false;
5586
6048
  current = parent;
5587
6049
  }
5588
6050
  return false;
5589
6051
  }
5590
- function derivedLocalDeclarationForRead(node) {
6052
+ function isDirectBindingValueUsage(node, context, compatibleComponentProps) {
6053
+ let current = node;
6054
+ while (current) {
6055
+ const parent = current.parent;
6056
+ if (!parent)
6057
+ return false;
6058
+ if (parent.type === "parenthesized_expression" || parent.type === "cast_expression" || parent.type === "type_cast_expression") {
6059
+ current = parent;
6060
+ continue;
6061
+ }
6062
+ if (parent.type !== "field")
6063
+ return false;
6064
+ const value = fieldValue(parent);
6065
+ if (!value || !sameNode(value, current))
6066
+ return false;
6067
+ const property = fieldName(parent);
6068
+ if (!property || NON_BINDABLE_HOST_FIELDS.has(property))
6069
+ return false;
6070
+ const table = parent.parent;
6071
+ const args = table?.parent;
6072
+ const call = args?.parent;
6073
+ if (table?.type !== "table_constructor" || args?.type !== "arguments" || !call || !isCreateElementCall2(call, context)) {
6074
+ return false;
6075
+ }
6076
+ if (!sameNode(context.callArguments(call)[1], table))
6077
+ return false;
6078
+ if (isHostCreateElementCall(call, context))
6079
+ return true;
6080
+ const component = context.callArguments(call)[0]?.text.trim() ?? "";
6081
+ return compatibleComponentProps.get(component)?.has(property) ?? false;
6082
+ }
6083
+ return false;
6084
+ }
6085
+ function allReadsDirectlyBindingSubstitutable(valueName, owner, declaration, context, compatibleComponentProps) {
6086
+ if (!owner.body)
6087
+ return false;
6088
+ let reads = 0;
6089
+ for (const node of context.walk(owner.body)) {
6090
+ if (!isReadNode(node, valueName, owner, declaration))
6091
+ continue;
6092
+ reads += 1;
6093
+ if (!isDirectBindingValueUsage(node, context, compatibleComponentProps))
6094
+ return false;
6095
+ }
6096
+ return reads > 0;
6097
+ }
6098
+ function derivedLocalDeclarationForRead(node, context) {
5591
6099
  let current = node;
5592
6100
  while (current) {
5593
6101
  const parent = current.parent;
@@ -5597,7 +6105,30 @@ function derivedLocalDeclarationForRead(node) {
5597
6105
  return parent;
5598
6106
  if (parent.type === "function_definition" || parent.type === "function_declaration")
5599
6107
  return null;
5600
- if (!TRANSPARENT_PARENTS.has(parent.type))
6108
+ if (!isTransparentParent(parent, context))
6109
+ return null;
6110
+ current = parent;
6111
+ }
6112
+ return null;
6113
+ }
6114
+ function opaqueDerivedLocalDeclarationForRead(node, context) {
6115
+ let current = node;
6116
+ while (current) {
6117
+ const parent = current.parent;
6118
+ if (!parent)
6119
+ return null;
6120
+ if (parent.type === "variable_declaration")
6121
+ return parent;
6122
+ if (parent.type === "function_definition" || parent.type === "function_declaration")
6123
+ return null;
6124
+ 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")
6125
+ return null;
6126
+ if (parent.type === "function_call") {
6127
+ const path7 = context.resolveCallPath(context.getCallPath(parent) ?? "");
6128
+ if (/^React\.use(?:Memo|Callback|Effect|LayoutEffect)$/.test(path7))
6129
+ return null;
6130
+ }
6131
+ if (!TRANSPARENT_PARENTS.has(parent.type) && parent.type !== "arguments" && parent.type !== "function_call")
5601
6132
  return null;
5602
6133
  current = parent;
5603
6134
  }
@@ -5617,7 +6148,7 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
5617
6148
  reads += 1;
5618
6149
  if (isTransparentUsage(node, context, compatibleComponentProps))
5619
6150
  continue;
5620
- const derivedDeclaration = derivedLocalDeclarationForRead(node);
6151
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
5621
6152
  const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
5622
6153
  if (derivedDeclaration && derivedNames.length === 1) {
5623
6154
  if (stateReadsAreBindingCompatible(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited)))
@@ -5627,12 +6158,46 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
5627
6158
  }
5628
6159
  return reads > 0;
5629
6160
  }
6161
+ function propertyChangedSignalProperty(text) {
6162
+ return text.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/)?.[1] ?? null;
6163
+ }
6164
+ function reactChangePropertyForCallback(node) {
6165
+ let current = node.parent;
6166
+ while (current && current.type !== "function_definition" && current.type !== "function_declaration") {
6167
+ if (current.type === "field") {
6168
+ return current.text.match(/React\s*\.\s*Change\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? null;
6169
+ }
6170
+ current = current.parent;
6171
+ }
6172
+ return null;
6173
+ }
6174
+ function evidenceForExternalSource(highFrequency, hostFeedbackProperty) {
6175
+ const hostFeedbackProperties = new Set;
6176
+ if (hostFeedbackProperty)
6177
+ hostFeedbackProperties.add(hostFeedbackProperty);
6178
+ return {
6179
+ highFrequency,
6180
+ external: true,
6181
+ hostFeedbackProperties,
6182
+ genericExternal: !highFrequency && !hostFeedbackProperty
6183
+ };
6184
+ }
6185
+ function mergeSourceEvidence(target, source) {
6186
+ target.highFrequency ||= source.highFrequency;
6187
+ target.external ||= source.external;
6188
+ target.genericExternal ||= source.genericExternal;
6189
+ for (const property of source.hostFeedbackProperties)
6190
+ target.hostFeedbackProperties.add(property);
6191
+ }
5630
6192
  function callbackSource(call, context) {
5631
6193
  const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
5632
- const highFrequency = isHighFrequencyRunServiceCall(call, context);
6194
+ const highFrequency = isHighFrequencyRobloxCall(call, context) || LIKELY_CONTINUOUS_CALLBACK.test(normalized);
6195
+ const hostFeedbackProperty = propertyChangedSignalProperty(call.text);
5633
6196
  const final = normalized.split(/[.:]/).at(-1) ?? normalized;
5634
- const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
5635
- return { highFrequency, external };
6197
+ const external = highFrequency || Boolean(hostFeedbackProperty) || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
6198
+ if (!external)
6199
+ return null;
6200
+ return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
5636
6201
  }
5637
6202
  function importedCallbackSource(call, callbackArgument, importedCallbacks, context) {
5638
6203
  const path7 = context.getCallPath(call) ?? "";
@@ -5643,12 +6208,14 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
5643
6208
  const callbackIndex = args.findIndex((arg) => sameNode(arg, callbackArgument));
5644
6209
  if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
5645
6210
  return null;
5646
- return {
5647
- highFrequency: summary.highFrequency || args.some((arg) => isHighFrequencyRunServiceExpression(arg, context)),
5648
- external: true
5649
- };
6211
+ const highFrequency = summary.highFrequency || args.some((arg) => isHighFrequencyRobloxExpression(arg, context));
6212
+ const hostFeedbackProperty = args.map((arg) => propertyChangedSignalProperty(arg.text)).find((property) => Boolean(property));
6213
+ return evidenceForExternalSource(highFrequency, hostFeedbackProperty);
5650
6214
  }
5651
6215
  function directCallbackSource(node, importedCallbacks, context) {
6216
+ const reactChangeProperty = reactChangePropertyForCallback(node);
6217
+ if (reactChangeProperty)
6218
+ return evidenceForExternalSource(false, reactChangeProperty);
5652
6219
  const parent = node.parent;
5653
6220
  if (parent?.type !== "arguments" || parent.parent?.type !== "function_call")
5654
6221
  return null;
@@ -5656,14 +6223,17 @@ function directCallbackSource(node, importedCallbacks, context) {
5656
6223
  const imported = importedCallbackSource(call, node, importedCallbacks, context);
5657
6224
  if (imported)
5658
6225
  return imported;
5659
- const source = callbackSource(call, context);
5660
- return source.external ? source : null;
6226
+ return callbackSource(call, context);
5661
6227
  }
5662
6228
  function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
5663
6229
  if (!fn.name || !owner.body)
5664
6230
  return null;
5665
- let foundExternal = false;
5666
- let foundHighFrequency = false;
6231
+ const result = {
6232
+ highFrequency: false,
6233
+ external: false,
6234
+ hostFeedbackProperties: new Set,
6235
+ genericExternal: false
6236
+ };
5667
6237
  for (const call of context.walk(owner.body)) {
5668
6238
  if (call.type !== "function_call")
5669
6239
  continue;
@@ -5671,14 +6241,12 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
5671
6241
  const callbackArg = args.find((arg) => arg.type === "identifier" && arg.text === fn.name);
5672
6242
  if (!callbackArg)
5673
6243
  continue;
5674
- const imported = importedCallbackSource(call, callbackArg, importedCallbacks, context);
5675
- const source = imported ?? callbackSource(call, context);
5676
- if (!source.external)
6244
+ const source = importedCallbackSource(call, callbackArg, importedCallbacks, context) ?? callbackSource(call, context);
6245
+ if (!source)
5677
6246
  continue;
5678
- foundExternal = true;
5679
- foundHighFrequency ||= source.highFrequency;
6247
+ mergeSourceEvidence(result, source);
5680
6248
  }
5681
- return foundExternal ? { highFrequency: foundHighFrequency, external: true } : null;
6249
+ return result.external ? result : null;
5682
6250
  }
5683
6251
  function setterCallbackSource(call, binding, importedCallbacks, context) {
5684
6252
  const owner = binding.owner;
@@ -5692,25 +6260,178 @@ function setterCallbackSource(call, binding, importedCallbacks, context) {
5692
6260
  return direct;
5693
6261
  return namedFunctionCallbackSource(nearest, owner, importedCallbacks, context);
5694
6262
  }
5695
- function bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context) {
6263
+ function mirrorConfidenceRank(confidence) {
6264
+ if (confidence === "strong")
6265
+ return 2;
6266
+ if (confidence === "possible")
6267
+ return 1;
6268
+ return 0;
6269
+ }
6270
+ function mergeMirrorConfidence(a, b) {
6271
+ return mirrorConfidenceRank(b) > mirrorConfidenceRank(a) ? b : a;
6272
+ }
6273
+ function isLiteralStateTransition(node) {
6274
+ if (!node)
6275
+ return true;
6276
+ const text = node.text.trim();
6277
+ return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
6278
+ }
6279
+ function isSemanticSnapshotExpression(node) {
6280
+ return Boolean(node && SEMANTIC_SNAPSHOT_EXPRESSION.test(node.text));
6281
+ }
6282
+ function expressionReferencesCallbackParameter(expression, callback, context) {
6283
+ if (callback.parameters.length === 0)
6284
+ return false;
6285
+ const parameters = new Set(callback.parameters);
6286
+ return [...context.walk(expression)].some((node) => node.type === "identifier" && parameters.has(node.text));
6287
+ }
6288
+ var PURE_EXPRESSION_ROOTS = new Set([
6289
+ "math",
6290
+ "string",
6291
+ "utf8",
6292
+ "bit32",
6293
+ "UDim",
6294
+ "UDim2",
6295
+ "Vector2",
6296
+ "Vector3",
6297
+ "Color3",
6298
+ "CFrame",
6299
+ "Rect",
6300
+ "NumberRange",
6301
+ "NumberSequence",
6302
+ "ColorSequence",
6303
+ "BrickColor",
6304
+ "Font",
6305
+ "Enum",
6306
+ "tostring",
6307
+ "tonumber",
6308
+ "type",
6309
+ "typeof"
6310
+ ]);
6311
+ function callbackLocalNamesBefore(callback, before, context) {
6312
+ const names = new Set;
6313
+ if (!callback.body)
6314
+ return names;
6315
+ for (const node of context.walk(callback.body)) {
6316
+ if (node.startIndex >= before.startIndex)
6317
+ continue;
6318
+ if (context.nearestFunction(node) !== callback || node.type !== "variable_declaration")
6319
+ continue;
6320
+ for (const name of declarationNames2(node))
6321
+ names.add(name);
6322
+ }
6323
+ return names;
6324
+ }
6325
+ function expressionHasExternalInput(expression, callback, setterCall, context) {
6326
+ const callbackLocals = callbackLocalNamesBefore(callback, setterCall, context);
6327
+ const callbackParameters = new Set(callback.parameters);
6328
+ for (const node of context.walk(expression)) {
6329
+ if (node.type !== "identifier" || isIdentifierPropertyName2(node))
6330
+ continue;
6331
+ const name = node.text;
6332
+ if (callbackParameters.has(name))
6333
+ return true;
6334
+ if (callbackLocals.has(name))
6335
+ continue;
6336
+ if (context.model.stateValues.has(name))
6337
+ continue;
6338
+ if (PURE_EXPRESSION_ROOTS.has(name))
6339
+ continue;
6340
+ return true;
6341
+ }
6342
+ return false;
6343
+ }
6344
+ function setterHasDistinctValuesInCallback(call, callback, context) {
6345
+ if (!callback.body)
6346
+ return false;
6347
+ const setterPath = context.getCallPath(call);
6348
+ const distinctValues = new Set;
6349
+ for (const sibling of context.walk(callback.body)) {
6350
+ if (sibling.type !== "function_call" || context.nearestFunction(sibling) !== callback)
6351
+ continue;
6352
+ if (context.getCallPath(sibling) !== setterPath)
6353
+ continue;
6354
+ const argument = context.callArguments(sibling)[0];
6355
+ if (argument)
6356
+ distinctValues.add(argument.text.trim());
6357
+ }
6358
+ return distinctValues.size > 1;
6359
+ }
6360
+ function setterCallMirrorConfidence(call, callback, source, context) {
6361
+ const value = context.callArguments(call)[0];
6362
+ if (!value || value.type === "function_definition")
6363
+ return "none";
6364
+ if (isLiteralStateTransition(value)) {
6365
+ return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
6366
+ }
6367
+ if (isSemanticSnapshotExpression(value))
6368
+ return "none";
6369
+ if (expressionReferencesCallbackParameter(value, callback, context))
6370
+ return "strong";
6371
+ if (source.hostFeedbackProperties.size > 0) {
6372
+ const text = value.text;
6373
+ if ([...source.hostFeedbackProperties].some((property) => new RegExp(`(?:\\.|["'])${property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text))) {
6374
+ return "strong";
6375
+ }
6376
+ }
6377
+ if (!expressionHasExternalInput(value, callback, call, context)) {
6378
+ return source.highFrequency && setterHasDistinctValuesInCallback(call, callback, context) ? "strong" : "none";
6379
+ }
6380
+ if (source.highFrequency)
6381
+ return "strong";
6382
+ return source.genericExternal ? "possible" : "strong";
6383
+ }
6384
+ function mergeBindingUpdateEvidence(target, source, mirrorConfidence) {
6385
+ if (mirrorConfidence === "none")
6386
+ return;
6387
+ mergeSourceEvidence(target, source);
6388
+ target.mirrorConfidence = mergeMirrorConfidence(target.mirrorConfidence, mirrorConfidence);
6389
+ }
6390
+ function bindingUpdateEvidence(binding, importedCallbacks, context) {
5696
6391
  const owner = binding.owner;
5697
6392
  if (!owner?.body)
5698
6393
  return null;
5699
- let highFrequency = false;
5700
- let external = false;
6394
+ const result = {
6395
+ highFrequency: false,
6396
+ external: false,
6397
+ hostFeedbackProperties: new Set,
6398
+ genericExternal: false,
6399
+ mirrorConfidence: "none"
6400
+ };
5701
6401
  for (const node of context.walk(owner.body)) {
5702
6402
  if (node.type !== "function_call")
5703
6403
  continue;
5704
6404
  const path7 = context.getCallPath(node);
5705
- if (path7 !== binding.setterName)
6405
+ if (path7 === binding.setterName) {
6406
+ const source2 = setterCallbackSource(node, binding, importedCallbacks, context);
6407
+ const callback = context.nearestFunction(node);
6408
+ if (!source2 || !callback || callback === owner)
6409
+ continue;
6410
+ mergeBindingUpdateEvidence(result, source2, setterCallMirrorConfidence(node, callback, source2, context));
6411
+ continue;
6412
+ }
6413
+ const args = context.callArguments(node);
6414
+ const setterArg = args.find((arg) => arg.type === "identifier" && arg.text === binding.setterName);
6415
+ if (!setterArg)
5706
6416
  continue;
5707
- const source = setterCallbackSource(node, binding, importedCallbacks, context);
6417
+ const normalized = (context.getCallPath(node) ?? "").replace(/\s+/g, "");
6418
+ const imported = importedCallbackSource(node, setterArg, importedCallbacks, context);
6419
+ const source = imported ?? (PERSISTENT_MIRROR_CALLBACK.test(normalized) ? callbackSource(node, context) : null);
5708
6420
  if (!source)
5709
6421
  continue;
5710
- highFrequency ||= source.highFrequency;
5711
- external ||= source.external;
6422
+ mergeBindingUpdateEvidence(result, source, "strong");
5712
6423
  }
5713
- return highFrequency || external ? { highFrequency, external } : null;
6424
+ return result.external && result.mirrorConfidence !== "none" ? result : null;
6425
+ }
6426
+ function hasStrongHostFeedbackEvidence(source) {
6427
+ if (source.genericExternal || source.hostFeedbackProperties.size === 0)
6428
+ return false;
6429
+ return [...source.hostFeedbackProperties].every((property) => isStrongHostFeedbackProperty(property));
6430
+ }
6431
+ function hasLikelyContinuousPropertyEvidence(source) {
6432
+ if (source.hostFeedbackProperties.size === 0)
6433
+ return false;
6434
+ return [...source.hostFeedbackProperties].every((property) => LIKELY_CONTINUOUS_PROPERTIES.has(property));
5714
6435
  }
5715
6436
  function returnedHookValues(binding, context) {
5716
6437
  const owner = binding.owner;
@@ -5788,14 +6509,8 @@ function callDefinitelyUsesBindingMode(summary, call, context) {
5788
6509
  return false;
5789
6510
  return truthy === summary.bindingWhenTruthy;
5790
6511
  }
5791
- function shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
5792
- if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
5793
- return false;
5794
- if (!hasAnyRead(localName, owner, declaration, context))
5795
- return false;
5796
- if (summary.sourceKind === "measurement")
5797
- return true;
5798
- return stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps);
6512
+ function hasExplicitBindingMode(summary) {
6513
+ return summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
5799
6514
  }
5800
6515
  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;
5801
6516
  function createElementPropUsage(node, context) {
@@ -5824,7 +6539,7 @@ function createElementPropUsage(node, context) {
5824
6539
  }
5825
6540
  if (parent.type === "function_definition" || parent.type === "function_declaration")
5826
6541
  return null;
5827
- if (!TRANSPARENT_PARENTS.has(parent.type))
6542
+ if (!isTransparentParent(parent, context))
5828
6543
  return null;
5829
6544
  current = parent;
5830
6545
  }
@@ -5856,17 +6571,17 @@ function isEffectUsage(node, context) {
5856
6571
  }
5857
6572
  return false;
5858
6573
  }
5859
- function isStructuralUsage(node) {
6574
+ function isStructuralUsage(node, context) {
5860
6575
  let current = node;
5861
6576
  while (current) {
5862
6577
  const parent = current.parent;
5863
6578
  if (!parent)
5864
6579
  return false;
5865
- if (parent.type === "if_statement" || parent.type === "elseif_clause" || parent.type === "while_statement" || parent.type === "repeat_statement" || parent.type === "return_statement")
6580
+ 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")
5866
6581
  return true;
5867
6582
  if (parent.type === "function_definition" || parent.type === "function_declaration")
5868
6583
  return false;
5869
- if (!TRANSPARENT_PARENTS.has(parent.type))
6584
+ if (!isTransparentParent(parent, context))
5870
6585
  return false;
5871
6586
  current = parent;
5872
6587
  }
@@ -5879,11 +6594,11 @@ function classifyBindingUsage(node, context, compatibleComponentProps) {
5879
6594
  return "custom-component";
5880
6595
  if (isEffectUsage(node, context))
5881
6596
  return "effect";
5882
- if (isStructuralUsage(node))
6597
+ if (isStructuralUsage(node, context))
5883
6598
  return "structural";
5884
6599
  return "unknown";
5885
6600
  }
5886
- function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps) {
6601
+ function bindingUsageSummary(valueName, owner, declaration, context, compatibleComponentProps, visited = new Set) {
5887
6602
  const summary = {
5888
6603
  reads: 0,
5889
6604
  compatible: 0,
@@ -5895,9 +6610,45 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
5895
6610
  };
5896
6611
  if (!owner.body)
5897
6612
  return summary;
6613
+ const visitKey = `${declaration.startIndex}:${valueName}`;
6614
+ if (visited.has(visitKey))
6615
+ return summary;
6616
+ visited.add(visitKey);
5898
6617
  for (const node of context.walk(owner.body)) {
5899
6618
  if (!isReadNode(node, valueName, owner, declaration))
5900
6619
  continue;
6620
+ if (!isTransparentUsage(node, context, compatibleComponentProps)) {
6621
+ const derivedDeclaration = derivedLocalDeclarationForRead(node, context);
6622
+ const derivedNames = derivedDeclaration ? declarationNames2(derivedDeclaration) : [];
6623
+ if (derivedDeclaration && derivedNames.length === 1) {
6624
+ const derived = bindingUsageSummary(derivedNames[0], owner, derivedDeclaration, context, compatibleComponentProps, new Set(visited));
6625
+ if (derived.reads > 0) {
6626
+ summary.reads += derived.reads;
6627
+ summary.compatible += derived.compatible;
6628
+ summary.customComponent += derived.customComponent;
6629
+ summary.visualCustomComponent += derived.visualCustomComponent;
6630
+ summary.effect += derived.effect;
6631
+ summary.structural += derived.structural;
6632
+ summary.unknown += derived.unknown;
6633
+ continue;
6634
+ }
6635
+ }
6636
+ const opaqueDeclaration = opaqueDerivedLocalDeclarationForRead(node, context);
6637
+ const opaqueNames = opaqueDeclaration ? declarationNames2(opaqueDeclaration) : [];
6638
+ if (opaqueDeclaration && opaqueNames.length === 1) {
6639
+ const derived = bindingUsageSummary(opaqueNames[0], owner, opaqueDeclaration, context, compatibleComponentProps, new Set(visited));
6640
+ if (derived.reads > 0) {
6641
+ summary.reads += derived.reads + 1;
6642
+ summary.compatible += derived.compatible;
6643
+ summary.customComponent += derived.customComponent;
6644
+ summary.visualCustomComponent += derived.visualCustomComponent;
6645
+ summary.effect += derived.effect;
6646
+ summary.structural += derived.structural;
6647
+ summary.unknown += derived.unknown + 1;
6648
+ continue;
6649
+ }
6650
+ }
6651
+ }
5901
6652
  summary.reads += 1;
5902
6653
  const customComponentUsage = createElementPropUsage(node, context);
5903
6654
  const kind = classifyBindingUsage(node, context, compatibleComponentProps);
@@ -5920,11 +6671,94 @@ function bindingUsageSummary(valueName, owner, declaration, context, compatibleC
5920
6671
  function hasCandidatePresentationEvidence(summary) {
5921
6672
  return summary.compatible > 0 || summary.visualCustomComponent > 0;
5922
6673
  }
6674
+ function isPresentationOnly(summary) {
6675
+ return summary.reads > 0 && summary.compatible === summary.reads;
6676
+ }
6677
+ function stateBindingForSetter(setterName, context) {
6678
+ const valueName = context.model.stateSetters.get(setterName);
6679
+ if (!valueName)
6680
+ return null;
6681
+ return context.model.stateBindings.find((binding) => binding.setterName === setterName && binding.valueName === valueName) ?? null;
6682
+ }
6683
+ function effectOnlyDerivesPresentation(valueName, owner, declaration, context, compatibleComponentProps) {
6684
+ if (!owner.body)
6685
+ return false;
6686
+ let foundRelevantEffect = false;
6687
+ for (const call of context.walk(owner.body)) {
6688
+ if (call.type !== "function_call" || context.nearestFunction(call) !== owner)
6689
+ continue;
6690
+ const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
6691
+ if (path7 !== "React.useEffect" && path7 !== "React.useLayoutEffect")
6692
+ continue;
6693
+ const callbackNode = context.callArguments(call)[0];
6694
+ if (callbackNode?.type !== "function_definition")
6695
+ continue;
6696
+ const callback = context.model.functionByNode.get(callbackNode.id);
6697
+ if (!callback?.body)
6698
+ continue;
6699
+ const usesValue = [...context.walk(callback.body)].some((node) => context.nearestFunction(node) === callback && isReadNode(node, valueName, owner, declaration));
6700
+ if (!usesValue)
6701
+ continue;
6702
+ foundRelevantEffect = true;
6703
+ let presentationSetter = false;
6704
+ for (const nested of context.walk(callback.body)) {
6705
+ if (nested.type !== "function_call" || context.nearestFunction(nested) !== callback)
6706
+ continue;
6707
+ if (isBindingMapSafeCall(nested, context))
6708
+ continue;
6709
+ const nestedPath = context.resolveCallPath(context.getCallPath(nested) ?? "");
6710
+ if (nestedPath === "debug.profilebegin" || nestedPath === "debug.profileend")
6711
+ continue;
6712
+ if (context.model.bindingSetters.has(nestedPath)) {
6713
+ presentationSetter = true;
6714
+ continue;
6715
+ }
6716
+ const target = stateBindingForSetter(nestedPath, context);
6717
+ if (target?.owner && stateReadsAreBindingCompatible(target.valueName, target.owner, target.declaration, context, compatibleComponentProps)) {
6718
+ presentationSetter = true;
6719
+ continue;
6720
+ }
6721
+ return false;
6722
+ }
6723
+ if (!presentationSetter)
6724
+ return false;
6725
+ }
6726
+ return foundRelevantEffect;
6727
+ }
6728
+ function importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) {
6729
+ if (!summary.external || callDefinitelyUsesBindingMode(summary, call, context))
6730
+ return null;
6731
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
6732
+ if (usage.reads === 0)
6733
+ return null;
6734
+ const presentationOnly = isPresentationOnly(usage);
6735
+ const explicitBindingMode = hasExplicitBindingMode(summary);
6736
+ const observedProperty = observedPropertyForCall(summary, call, context);
6737
+ const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
6738
+ const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
6739
+ const mirrorsExternalValue = summary.mirrorConfidence !== "none";
6740
+ if (presentationOnly && (summary.highFrequency || likelyContinuousProperty))
6741
+ return "warning";
6742
+ if (presentationOnly && strongHostFeedback && explicitBindingMode)
6743
+ return "warning";
6744
+ if (presentationOnly && (explicitBindingMode || strongHostFeedback || mirrorsExternalValue))
6745
+ return "candidate";
6746
+ if (strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps)) {
6747
+ return "candidate";
6748
+ }
6749
+ if (!hasCandidatePresentationEvidence(usage))
6750
+ return null;
6751
+ if (summary.highFrequency || likelyContinuousProperty)
6752
+ return "candidate";
6753
+ if (strongHostFeedback && explicitBindingMode && usage.effect === 0)
6754
+ return "candidate";
6755
+ return null;
6756
+ }
5923
6757
  var preferBindingOverState = {
5924
6758
  id: "react-luau/prefer-binding-over-state",
5925
6759
  category: "Performance",
5926
6760
  severity: "warning",
5927
- description: "Prefer React.useBinding when externally updated values do not need React reconciliation on every change.",
6761
+ description: "Prefer React.useBinding when proven external presentation streams do not need React reconciliation.",
5928
6762
  run(context) {
5929
6763
  const diagnostics = [];
5930
6764
  const seen = new Set;
@@ -5941,75 +6775,72 @@ var preferBindingOverState = {
5941
6775
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
5942
6776
  if (!declaration || !localName || !owner)
5943
6777
  continue;
5944
- if (!shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
6778
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "warning")
5945
6779
  continue;
5946
6780
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
5947
6781
  if (seen.has(key))
5948
6782
  continue;
5949
6783
  seen.add(key);
5950
- const hasBindingMode = summary.bindingModeParameterIndex !== undefined && summary.bindingWhenTruthy !== undefined;
5951
- const message = summary.sourceKind === "measurement" ? hasBindingMode ? `${path7} is using its React state mode for an externally changing GUI measurement, so ${localName} rerenders this consumer when the measurement changes.` : `${path7} returns React state for an externally changing GUI measurement, so ${localName} rerenders this consumer when the measurement changes.` : summary.sourceKind === "derived-external-state" ? `${path7} derives React state from another externally updated reactive value, so ${localName} adds another rerendering state layer.` : `${path7} returns state updated${summary.highFrequency ? " from a high-frequency source" : " from an external callback"}, and ${localName} only flows into bindable host properties.`;
5952
- 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. ";
5953
- 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.";
6784
+ const hasBindingMode = hasExplicitBindingMode(summary);
6785
+ const observedProperty = observedPropertyForCall(summary, call, context);
6786
+ const hostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
6787
+ const likelyContinuousProperty = summary.sourceKind === "instance-property" && Boolean(observedProperty && LIKELY_CONTINUOUS_PROPERTIES.has(observedProperty));
6788
+ const message = hostFeedback ? `${path7} mirrors ${observedProperty} through React state even though every analyzed use is presentation-only, so the measurement rerenders this consumer without needing reconciliation.` : likelyContinuousProperty ? `${path7} mirrors continuously mutable ${observedProperty} through React state, and ${localName} only feeds presentation updates that do not need reconciliation.` : `${path7} returns state updated from a high-frequency source, and ${localName} only feeds presentation updates that do not need React reconciliation.`;
6789
+ 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.";
5954
6790
  diagnostics.push({
5955
6791
  node: callNameNode(call),
5956
6792
  message,
5957
6793
  help,
5958
- fixPreview: hasBindingMode ? bindingModeFixPreview(summary, call, context) : undefined
5959
- });
5960
- }
5961
- for (const call of context.findCalls()) {
5962
- const path7 = context.getCallPath(call) ?? "";
5963
- const member = path7.split(/[.:]/).at(-1) ?? path7;
5964
- const bindingAlternative = context.project.bindingApiAlternatives.get(member);
5965
- if (!bindingAlternative)
5966
- continue;
5967
- const declaration = declarationForCall2(call);
5968
- const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
5969
- const owner = context.containingComponent(call) ?? context.nearestFunction(call);
5970
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
5971
- continue;
5972
- if (!stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
5973
- continue;
5974
- const field = selectorFieldName(call, context);
5975
- const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
5976
- if (seen.has(key))
5977
- continue;
5978
- seen.add(key);
5979
- diagnostics.push({
5980
- node: callNameNode(call),
5981
- message: `${path7} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, but ${bindingAlternative} is available and every use of the value only updates bindable host properties.`,
5982
- help: `Use ${bindingAlternative} so the host property can update without rerendering the component.`,
5983
- fixPreview: callMemberFixPreview(call, bindingAlternative)
6794
+ fixPreview: hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
5984
6795
  });
5985
6796
  }
5986
6797
  for (const binding of context.model.stateBindings) {
5987
6798
  const owner = binding.owner;
5988
6799
  if (!owner || !owner.isComponent && !owner.isHook)
5989
6800
  continue;
5990
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
5991
- if (!updateKind)
6801
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
6802
+ if (!update)
5992
6803
  continue;
5993
6804
  const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
5994
6805
  const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
5995
- if (owner.isHook ? !hookCompatible : !localCompatible)
6806
+ const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
6807
+ const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
6808
+ const shouldWarn = owner.isHook ? hookCompatible && (update.highFrequency || likelyContinuousProperty) : localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty);
6809
+ if (!shouldWarn)
5996
6810
  continue;
5997
6811
  const key = `${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
5998
6812
  if (seen.has(key))
5999
6813
  continue;
6000
6814
  seen.add(key);
6001
6815
  if (owner.isHook && hookCompatible) {
6816
+ const properties = likelyContinuousProperty ? [...update.hostFeedbackProperties].sort().join(", ") : "";
6002
6817
  diagnostics.push({
6003
6818
  node: callNameNode(binding.call),
6004
- 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.`,
6005
- help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so consumers can update host properties without rerendering."
6819
+ 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.`,
6820
+ help: "Consider returning a Binding, or adding a Binding-returning variant of this hook, so continuous presentation updates do not rerender every consumer."
6821
+ });
6822
+ } else if (strongHostFeedback && !update.highFrequency) {
6823
+ const properties = [...update.hostFeedbackProperties].sort().join(", ");
6824
+ diagnostics.push({
6825
+ node: callNameNode(binding.call),
6826
+ message: `State ${binding.valueName} mirrors ${properties} and only feeds presentation updates, so React reconciliation is acting as an unnecessary host-property feedback step.`,
6827
+ 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.",
6828
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
6829
+ });
6830
+ } else if (likelyContinuousProperty && !update.highFrequency) {
6831
+ const properties = [...update.hostFeedbackProperties].sort().join(", ");
6832
+ diagnostics.push({
6833
+ node: callNameNode(binding.call),
6834
+ message: `State ${binding.valueName} mirrors continuously mutable ${properties} and only feeds presentation updates that do not need React reconciliation.`,
6835
+ help: "Prefer React.useBinding so external visual property changes can reach presentation without routing every update through component state.",
6836
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
6006
6837
  });
6007
6838
  } else {
6008
6839
  diagnostics.push({
6009
6840
  node: callNameNode(binding.call),
6010
- 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.`,
6011
- help: "Prefer React.useBinding when the update is visual and does not need React to rebuild component structure.",
6012
- fixPreview: useBindingFixPreview(binding.call)
6841
+ message: `State ${binding.valueName} is updated from a high-frequency callback and only feeds presentation updates that do not need React reconciliation.`,
6842
+ help: "Prefer React.useBinding so continuous visual updates can reach host properties without rerendering the component.",
6843
+ fixPreview: allReadsDirectlyBindingSubstitutable(binding.valueName, owner, binding.declaration, context, compatibleComponentProps) ? useBindingFixPreview(binding.call) : undefined
6013
6844
  });
6014
6845
  }
6015
6846
  }
@@ -6030,27 +6861,49 @@ var preferBindingOverStateCandidate = {
6030
6861
  for (const call of context.findCalls()) {
6031
6862
  const path7 = context.getCallPath(call) ?? "";
6032
6863
  const summary = importedHooks.get(path7);
6033
- if (!summary || !summary.external || callDefinitelyUsesBindingMode(summary, call, context))
6864
+ if (!summary)
6034
6865
  continue;
6035
6866
  const declaration = declarationForCall2(call);
6036
6867
  const localName = declaration ? declarationNames2(declaration)[0] ?? null : null;
6037
6868
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
6038
- if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
6869
+ if (!declaration || !localName || !owner)
6039
6870
  continue;
6040
- if (shouldRecommendImportedHook(summary, call, localName, owner, declaration, context, compatibleComponentProps))
6871
+ if (importedHookRecommendation(summary, call, localName, owner, declaration, context, compatibleComponentProps) !== "candidate")
6041
6872
  continue;
6042
6873
  const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
6043
- const hasPresentationEvidence = hasCandidatePresentationEvidence(usage) || summary.sourceKind === "derived-external-state" && usage.effect > 0;
6044
- if (!summary.highFrequency && !hasPresentationEvidence)
6045
- continue;
6874
+ const presentationOnly = isPresentationOnly(usage);
6875
+ const observedProperty = observedPropertyForCall(summary, call, context);
6876
+ const strongHostFeedback = summary.sourceKind === "instance-property" && isStrongHostFeedbackProperty(observedProperty);
6877
+ const hasBindingMode = hasExplicitBindingMode(summary);
6878
+ const mirrorsExternalValue = summary.mirrorConfidence !== "none";
6879
+ const visualEffectBridge = strongHostFeedback && usage.effect > 0 && effectOnlyDerivesPresentation(localName, owner, declaration, context, compatibleComponentProps);
6046
6880
  const key = `imported:${call.startIndex}:${call.endIndex}:${localName}`;
6047
6881
  if (seen.has(key))
6048
6882
  continue;
6049
6883
  seen.add(key);
6884
+ let message;
6885
+ let help;
6886
+ if (presentationOnly && hasBindingMode) {
6887
+ message = `${path7} 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.`;
6888
+ 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.";
6889
+ } else if (presentationOnly && strongHostFeedback) {
6890
+ message = `${path7} mirrors ${observedProperty} through React state only for presentation, but the hook does not expose a proven low-friction Binding mode.`;
6891
+ 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.";
6892
+ } else if (presentationOnly && mirrorsExternalValue) {
6893
+ message = `${path7} mirrors an external reactive value through React state, and ${localName} is only used for presentation.`;
6894
+ 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.";
6895
+ } else if (visualEffectBridge) {
6896
+ message = `${path7} mirrors ${observedProperty} through React state, and the dependent React effect only derives presentation state or Bindings from ${localName}.`;
6897
+ 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.";
6898
+ } else {
6899
+ message = `${path7} updates ${localName} through React state and some consumers are presentation-only, but other consumers may still require React reconciliation.`;
6900
+ 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.";
6901
+ }
6050
6902
  diagnostics.push({
6051
6903
  node: callNameNode(call),
6052
- message: `${path7} 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.`,
6053
- 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."
6904
+ message,
6905
+ help,
6906
+ fixPreview: presentationOnly && hasBindingMode && allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? bindingModeFixPreview(summary, call, context) : undefined
6054
6907
  });
6055
6908
  }
6056
6909
  for (const call of context.findCalls()) {
@@ -6064,39 +6917,80 @@ var preferBindingOverStateCandidate = {
6064
6917
  const owner = context.containingComponent(call) ?? context.nearestFunction(call);
6065
6918
  if (!declaration || !localName || !owner || !hasAnyRead(localName, owner, declaration, context))
6066
6919
  continue;
6067
- if (stateReadsAreBindingCompatible(localName, owner, declaration, context, compatibleComponentProps))
6920
+ const usage = bindingUsageSummary(localName, owner, declaration, context, compatibleComponentProps);
6921
+ const presentationOnly = isPresentationOnly(usage);
6922
+ if (!presentationOnly)
6068
6923
  continue;
6924
+ const field = selectorFieldName(call, context);
6069
6925
  const key = `state-api:${call.startIndex}:${call.endIndex}:${localName}`;
6070
6926
  if (seen.has(key))
6071
6927
  continue;
6072
6928
  seen.add(key);
6073
6929
  diagnostics.push({
6074
6930
  node: callNameNode(call),
6075
- message: `${path7} 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.`,
6076
- help: `Inspect whether ${bindingAlternative}, Binding:map, or splitting visual and structural consumers would avoid unnecessary rerenders.`,
6077
- fixPreview: callMemberFixPreview(call, bindingAlternative)
6931
+ message: `${path7} subscribes ${localName} through React state${field ? ` for ${field}` : ""}, while ${bindingAlternative} is also available and every analyzed consumer is presentation-only.`,
6932
+ help: `Consider ${bindingAlternative} when this value updates frequently enough that avoiding reconciliation matters; state remains reasonable for ordinary semantic store updates.`,
6933
+ fixPreview: allReadsDirectlyBindingSubstitutable(localName, owner, declaration, context, compatibleComponentProps) ? callMemberFixPreview(call, bindingAlternative) : undefined
6078
6934
  });
6079
6935
  }
6080
6936
  for (const binding of context.model.stateBindings) {
6081
6937
  const owner = binding.owner;
6082
- if (!owner || !owner.isComponent || !owner.body)
6938
+ if (!owner || !owner.isComponent && !owner.isHook || !owner.body)
6083
6939
  continue;
6084
- const updateKind = bindingHasExternalOrHighFrequencyUpdates(binding, importedCallbacks, context);
6085
- if (!updateKind)
6940
+ const update = bindingUpdateEvidence(binding, importedCallbacks, context);
6941
+ if (!update)
6086
6942
  continue;
6087
- if (stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps))
6943
+ const localCompatible = stateReadsAreBindingCompatible(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
6944
+ const hookCompatible = owner.isHook ? hookCallSitesAreBindingCompatible(binding, context, compatibleComponentProps) : false;
6945
+ const strongHostFeedback = hasStrongHostFeedbackEvidence(update);
6946
+ const likelyContinuousProperty = hasLikelyContinuousPropertyEvidence(update);
6947
+ const mirrorsExternalValue = update.mirrorConfidence !== "none";
6948
+ if (owner.isHook) {
6949
+ if (hookCompatible && (update.highFrequency || likelyContinuousProperty))
6950
+ continue;
6951
+ if (!(hookCompatible && (strongHostFeedback || mirrorsExternalValue)))
6952
+ continue;
6953
+ const key2 = `hook-state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
6954
+ if (seen.has(key2))
6955
+ continue;
6956
+ seen.add(key2);
6957
+ const measurement = strongHostFeedback ? "a presentation measurement" : "an external reactive value";
6958
+ diagnostics.push({
6959
+ node: callNameNode(binding.call),
6960
+ 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.`,
6961
+ 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."
6962
+ });
6088
6963
  continue;
6964
+ }
6089
6965
  const usage = bindingUsageSummary(binding.valueName, owner, binding.declaration, context, compatibleComponentProps);
6090
6966
  if (!hasCandidatePresentationEvidence(usage))
6091
6967
  continue;
6968
+ if (localCompatible && (update.highFrequency || strongHostFeedback || likelyContinuousProperty))
6969
+ continue;
6970
+ if (localCompatible && mirrorsExternalValue) {
6971
+ const key2 = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
6972
+ if (seen.has(key2))
6973
+ continue;
6974
+ seen.add(key2);
6975
+ diagnostics.push({
6976
+ node: callNameNode(binding.call),
6977
+ 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.`,
6978
+ 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."
6979
+ });
6980
+ continue;
6981
+ }
6982
+ const mixedHighFrequency = update.highFrequency || likelyContinuousProperty;
6983
+ const mixedHostFeedback = strongHostFeedback && usage.structural > 0 && usage.effect === 0;
6984
+ if (!mixedHighFrequency && !mixedHostFeedback)
6985
+ continue;
6092
6986
  const key = `state:${binding.call.startIndex}:${binding.call.endIndex}:${binding.valueName}`;
6093
6987
  if (seen.has(key))
6094
6988
  continue;
6095
6989
  seen.add(key);
6096
6990
  diagnostics.push({
6097
6991
  node: callNameNode(binding.call),
6098
- 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.`,
6099
- 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."
6992
+ message: `State ${binding.valueName} has presentation-oriented consumers, but other uses still appear to need React reconciliation.`,
6993
+ 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."
6100
6994
  });
6101
6995
  }
6102
6996
  return diagnostics;
@@ -6602,6 +7496,20 @@ function staticIterationImports(context) {
6602
7496
  }
6603
7497
  return result;
6604
7498
  }
7499
+ function nonReactHookImports(context) {
7500
+ const result = new Set;
7501
+ for (const node of context.walk(context.root)) {
7502
+ if (node.type !== "variable_declaration")
7503
+ continue;
7504
+ const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
7505
+ if (!match || !/^use[A-Z0-9_]/.test(match[1]))
7506
+ continue;
7507
+ const kind = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.reactHookModules);
7508
+ if (kind === false)
7509
+ result.add(match[1]);
7510
+ }
7511
+ return result;
7512
+ }
6605
7513
  function conditionalHookModeImports(context) {
6606
7514
  const result = new Map;
6607
7515
  for (const node of context.walk(context.root)) {
@@ -6649,30 +7557,12 @@ function controlledParameterIndex(controlFlow, fn, summary) {
6649
7557
  }
6650
7558
  return null;
6651
7559
  }
6652
- function isStableModeArgument(node, context) {
6653
- if (!node)
6654
- return true;
6655
- const text = node.text.trim();
6656
- if (["true", "false", "nil"].includes(text))
6657
- return true;
6658
- if ([
6659
- "number",
6660
- "string",
6661
- "string_content",
6662
- "table_constructor",
6663
- "function_definition"
6664
- ].includes(node.type))
6665
- return true;
6666
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text) && context.model.stableVariables.has(text))
6667
- return true;
6668
- return false;
6669
- }
6670
7560
  function dynamicHookModeFixPreview(hookName, argumentText) {
6671
7561
  return {
6672
7562
  kind: "pattern",
6673
7563
  before: `local value = ${hookName}(${argumentText})`,
6674
- after: `local value = ${hookName}(true) -- or false; keep this mode fixed for this call site`,
6675
- note: "A custom hook may choose different internal hooks only when this mode is stable for the lifetime of the component instance."
7564
+ after: `local value = ${hookName}(${argumentText}) -- keep the hook-topology mode stable for this instance`,
7565
+ 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."
6676
7566
  };
6677
7567
  }
6678
7568
  function unwrapIteratorExpression(text) {
@@ -6692,6 +7582,17 @@ function declarationExpressions4(node) {
6692
7582
  function isEmptyTable(node) {
6693
7583
  return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
6694
7584
  }
7585
+ function tableConstructorHasStableArity(node) {
7586
+ if (node.type !== "table_constructor")
7587
+ return false;
7588
+ const fields = node.namedChildren.filter((child) => child.type === "field");
7589
+ if (fields.length === 0)
7590
+ return true;
7591
+ if (fields.some((field) => [...field.namedChildren].some((child) => child.type === "vararg_expression")))
7592
+ return false;
7593
+ const last = fields.at(-1);
7594
+ return !last?.namedChildren.some((child) => child.type === "function_call");
7595
+ }
6695
7596
  function stableShapeVariablesByFunction(context) {
6696
7597
  const stable = new Map;
6697
7598
  const functionsByName = new Map;
@@ -6718,7 +7619,7 @@ function stableShapeVariablesByFunction(context) {
6718
7619
  const expression = expressions[index] ?? expressions[0];
6719
7620
  if (!expression)
6720
7621
  continue;
6721
- if (expression.type === "table_constructor") {
7622
+ if (expression.type === "table_constructor" && tableConstructorHasStableArity(expression)) {
6722
7623
  namesFor(owner).add(names[index]);
6723
7624
  continue;
6724
7625
  }
@@ -6745,7 +7646,7 @@ function stableShapeVariablesByFunction(context) {
6745
7646
  if (!expression)
6746
7647
  return false;
6747
7648
  if (expression.type === "table_constructor")
6748
- return true;
7649
+ return tableConstructorHasStableArity(expression);
6749
7650
  const text = expression.text.trim();
6750
7651
  if (!owner || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(text))
6751
7652
  return false;
@@ -6798,6 +7699,187 @@ function stableShapeVariablesByFunction(context) {
6798
7699
  }
6799
7700
  return stable;
6800
7701
  }
7702
+ function moduleInvariantVariables(context) {
7703
+ const declarations = new Map;
7704
+ const reassigned = new Set;
7705
+ for (const node of context.walk(context.root)) {
7706
+ if (node.type === "variable_declaration" && !context.nearestFunction(node)) {
7707
+ for (const name of declarationNames2(node))
7708
+ declarations.set(name, node);
7709
+ continue;
7710
+ }
7711
+ if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
7712
+ continue;
7713
+ const variableList = node.namedChildren.find((child) => child.type === "variable_list");
7714
+ for (const target of variableList?.namedChildren ?? []) {
7715
+ if (target.type === "identifier")
7716
+ reassigned.add(target.text);
7717
+ }
7718
+ }
7719
+ return new Set([...declarations.keys()].filter((name) => !reassigned.has(name)));
7720
+ }
7721
+ function simpleConditionRoot(controlFlow) {
7722
+ if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
7723
+ return null;
7724
+ const condition = controlFlow.namedChildren[0];
7725
+ if (!condition)
7726
+ return null;
7727
+ const text = condition.text.trim();
7728
+ const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
7729
+ return match?.[1] ?? null;
7730
+ }
7731
+ function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants) {
7732
+ const condition = controlFlow.namedChildren[0];
7733
+ if (!condition)
7734
+ return false;
7735
+ if (["true", "false", "nil", "number", "string"].includes(condition.type))
7736
+ return true;
7737
+ const root = simpleConditionRoot(controlFlow);
7738
+ if (!root)
7739
+ return false;
7740
+ if (moduleInvariants.has(root))
7741
+ return true;
7742
+ return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
7743
+ }
7744
+ function branchHookSequence(branch, fn, context) {
7745
+ const hooks = [];
7746
+ for (const node of context.walk(branch)) {
7747
+ if (node.type !== "function_call")
7748
+ continue;
7749
+ if (context.nearestFunction(node) !== fn)
7750
+ continue;
7751
+ const rawPath = context.getCallPath(node);
7752
+ if (!rawPath)
7753
+ continue;
7754
+ const path7 = context.resolveCallPath(rawPath);
7755
+ if (!isHookPath(path7))
7756
+ continue;
7757
+ const nestedControl = findAncestorBetween(node, branch, (ancestor) => CONDITIONAL_TYPES.has(ancestor.type));
7758
+ if (nestedControl)
7759
+ return null;
7760
+ hooks.push(path7);
7761
+ }
7762
+ return hooks;
7763
+ }
7764
+ function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
7765
+ if (controlFlow.type !== "if_statement")
7766
+ return false;
7767
+ const thenBlock = controlFlow.namedChildren.find((child) => child.type === "block");
7768
+ const elseStatement = controlFlow.namedChildren.find((child) => child.type === "else_statement");
7769
+ const elseBlock = elseStatement?.namedChildren.find((child) => child.type === "block");
7770
+ if (!thenBlock || !elseBlock)
7771
+ return false;
7772
+ const thenHooks = branchHookSequence(thenBlock, fn, context);
7773
+ const elseHooks = branchHookSequence(elseBlock, fn, context);
7774
+ if (!thenHooks || !elseHooks || thenHooks.length !== elseHooks.length)
7775
+ return false;
7776
+ if (!thenHooks.every((path7, index) => path7 === elseHooks[index]))
7777
+ return false;
7778
+ return thenHooks.every((path7) => path7.startsWith("React."));
7779
+ }
7780
+ function stateModeStability(name, owner, context) {
7781
+ const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
7782
+ if (!binding || !owner.body)
7783
+ return null;
7784
+ const initializerText = binding.initializer?.text.trim() ?? "nil";
7785
+ const stableLiteral = /^(?:true|false|nil|-?\d+(?:\.\d+)?|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/;
7786
+ let sawIndirectSetterReference = false;
7787
+ for (const node of context.walk(owner.body)) {
7788
+ if (node.type === "function_call" && context.getCallPath(node) === binding.setterName) {
7789
+ const argument = context.callArguments(node)[0];
7790
+ if (!argument || !stableLiteral.test(initializerText) || argument.text.trim() !== initializerText)
7791
+ return "unstable";
7792
+ continue;
7793
+ }
7794
+ if (node.type !== "identifier" || node.text !== binding.setterName)
7795
+ continue;
7796
+ if (node.startIndex >= binding.declaration.startIndex && node.endIndex <= binding.declaration.endIndex)
7797
+ continue;
7798
+ if (node.parent?.type === "function_call")
7799
+ continue;
7800
+ sawIndirectSetterReference = true;
7801
+ }
7802
+ if (sawIndirectSetterReference)
7803
+ return "unknown";
7804
+ return "stable";
7805
+ }
7806
+ function localAliasExpression(name, owner, context) {
7807
+ if (!owner.body)
7808
+ return null;
7809
+ let result = null;
7810
+ for (const node of context.walk(owner.body)) {
7811
+ if (node.type !== "variable_declaration" || context.nearestFunction(node) !== owner)
7812
+ continue;
7813
+ const names = declarationNames2(node);
7814
+ const index = names.indexOf(name);
7815
+ if (index < 0)
7816
+ continue;
7817
+ const expressions = declarationExpressions4(node);
7818
+ const expression = expressions[index] ?? expressions[0] ?? null;
7819
+ if (result)
7820
+ return null;
7821
+ result = expression;
7822
+ }
7823
+ return result;
7824
+ }
7825
+ function modeValueStability(node, owner, context, moduleInvariants, depth = 0) {
7826
+ if (!node)
7827
+ return "stable";
7828
+ if (depth > 4)
7829
+ return "unknown";
7830
+ const text = node.text.trim();
7831
+ if (["true", "false", "nil"].includes(text))
7832
+ return "stable";
7833
+ if (["number", "string", "string_content", "function_definition"].includes(node.type))
7834
+ return "stable";
7835
+ const root = text.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
7836
+ if (!root)
7837
+ return "unknown";
7838
+ const stateStability = stateModeStability(root, owner, context);
7839
+ if (stateStability)
7840
+ return stateStability;
7841
+ if (moduleInvariants.has(root))
7842
+ return "stable";
7843
+ if (context.model.stableVariablesByFunction.get(nodeKey(owner.node))?.has(root))
7844
+ return "stable";
7845
+ if (text === root) {
7846
+ const alias = localAliasExpression(root, owner, context);
7847
+ if (alias && alias.text.trim() !== root)
7848
+ return modeValueStability(alias, owner, context, moduleInvariants, depth + 1);
7849
+ }
7850
+ return "unknown";
7851
+ }
7852
+ function tableFieldValue(table, accessPath) {
7853
+ if (!table || accessPath === "")
7854
+ return table;
7855
+ if (table.type !== "table_constructor")
7856
+ return;
7857
+ let current = table;
7858
+ for (const part of accessPath.split(".")) {
7859
+ if (!current || current.type !== "table_constructor")
7860
+ return;
7861
+ const field = current.namedChildren.find((child) => {
7862
+ if (child.type !== "field")
7863
+ return false;
7864
+ return child.namedChildren[0]?.type === "identifier" && child.namedChildren[0]?.text === part;
7865
+ });
7866
+ if (!field)
7867
+ return;
7868
+ current = field.namedChildren[1];
7869
+ }
7870
+ return current;
7871
+ }
7872
+ function controlledAccessPaths(summary, parameterIndex) {
7873
+ const paths = new Set;
7874
+ for (const [conditionName, index] of Object.entries(summary.conditionVariables)) {
7875
+ if (index !== parameterIndex)
7876
+ continue;
7877
+ paths.add(summary.conditionAccessPaths?.[conditionName] ?? "");
7878
+ }
7879
+ if (paths.size === 0)
7880
+ paths.add("");
7881
+ return [...paths];
7882
+ }
6801
7883
  function loopIterationIsProvablyStable(loop, imports, owner, stableShapes) {
6802
7884
  const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
6803
7885
  if (numeric) {
@@ -6859,9 +7941,9 @@ function impossibleNilGuardReturn(node, fn, parameterTypes) {
6859
7941
  }
6860
7942
  return false;
6861
7943
  }
6862
- function hasReachableReturnBefore(call, fn, context, cache) {
7944
+ function reachableReturnsBefore(call, fn, context, cache) {
6863
7945
  if (!fn.body)
6864
- return false;
7946
+ return [];
6865
7947
  const key = nodeKey(fn.node);
6866
7948
  let returns = cache.get(key);
6867
7949
  if (!returns) {
@@ -6869,7 +7951,21 @@ function hasReachableReturnBefore(call, fn, context, cache) {
6869
7951
  returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
6870
7952
  cache.set(key, returns);
6871
7953
  }
6872
- return returns.some((node) => node.endIndex <= call.startIndex);
7954
+ return returns.filter((node) => node.endIndex <= call.startIndex);
7955
+ }
7956
+ function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
7957
+ let current = node.parent;
7958
+ while (current && current !== fn.node) {
7959
+ if (current.type === "if_statement" || current.type === "if_expression") {
7960
+ if (summary && controlledParameterIndex(current, fn, summary) !== null)
7961
+ return true;
7962
+ if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
7963
+ return true;
7964
+ return false;
7965
+ }
7966
+ current = current.parent;
7967
+ }
7968
+ return false;
6873
7969
  }
6874
7970
  function conditionalFixPreview(kind, hookName) {
6875
7971
  if (kind === "for statement") {
@@ -6941,15 +8037,21 @@ var rulesOfHooks = {
6941
8037
  description: "Hooks must run in the same order on every render of a React component or custom hook.",
6942
8038
  run(context) {
6943
8039
  const diagnostics = [];
8040
+ if (!context.model.isReactFile)
8041
+ return diagnostics;
6944
8042
  const staticImports = staticIterationImports(context);
6945
8043
  const stableShapes = stableShapeVariablesByFunction(context);
8044
+ const moduleInvariants = moduleInvariantVariables(context);
6946
8045
  const modeImports = conditionalHookModeImports(context);
8046
+ const ignoredHookImports = nonReactHookImports(context);
6947
8047
  const currentModeSummary = currentConditionalHookMode(context);
6948
8048
  const reachableReturns = new Map;
6949
8049
  for (const call of context.findCalls()) {
6950
8050
  const rawPath = context.getCallPath(call);
6951
8051
  if (!rawPath)
6952
8052
  continue;
8053
+ if (ignoredHookImports.has(rawPath))
8054
+ continue;
6953
8055
  const path7 = context.resolveCallPath(rawPath);
6954
8056
  if (!isHookPath(path7))
6955
8057
  continue;
@@ -6978,25 +8080,36 @@ end`,
6978
8080
  const importedMode = modeImports.get(rawPath);
6979
8081
  if (importedMode) {
6980
8082
  const args = context.callArguments(call);
6981
- const dynamicIndexes = importedMode.parameterIndexes.filter((index) => !isStableModeArgument(args[index], context));
6982
- if (dynamicIndexes.length > 0) {
6983
- const firstIndex = dynamicIndexes[0];
6984
- const argument = args[firstIndex];
8083
+ const unstableControls = importedMode.parameterIndexes.flatMap((index) => controlledAccessPaths(importedMode, index).map((accessPath) => {
8084
+ const argument = args[index];
8085
+ const value = tableFieldValue(argument, accessPath);
8086
+ const stability = accessPath !== "" ? argument?.type === "table_constructor" ? value ? modeValueStability(value, fn, context, moduleInvariants) : "stable" : "unknown" : modeValueStability(argument, fn, context, moduleInvariants);
8087
+ return { index, accessPath, argument, value, stability };
8088
+ }).filter((control) => control.stability === "unstable"));
8089
+ if (unstableControls.length > 0) {
8090
+ const first = unstableControls[0];
8091
+ const firstIndex = first.index;
8092
+ const argument = first.argument;
6985
8093
  const parameterOffset = importedMode.parameterIndexes.indexOf(firstIndex);
6986
- const parameterName = importedMode.parameterNames[parameterOffset] ?? `argument ${firstIndex + 1}`;
6987
- const argumentText = argument?.text.trim() || "<omitted>";
8094
+ const baseParameterName = importedMode.parameterNames[parameterOffset] ?? `argument ${firstIndex + 1}`;
8095
+ const parameterName = first.accessPath ? `${baseParameterName}.${first.accessPath}` : baseParameterName;
8096
+ const argumentText = first.value?.text.trim() || argument?.text.trim() || "<omitted>";
6988
8097
  diagnostics.push({
6989
- node: argument ?? callNameNode(call),
6990
- highlights: dynamicIndexes.map((index) => args[index]).filter((node) => Boolean(node)),
6991
- 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.`,
6992
- help: `Pass a stable mode at this call site, or refactor ${rawPath} so it always calls the same hooks regardless of ${parameterName}.`,
8098
+ node: first.value ?? argument ?? callNameNode(call),
8099
+ highlights: unstableControls.map((control) => control.value ?? control.argument).filter((node) => Boolean(node)),
8100
+ 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.`,
8101
+ help: `Keep ${parameterName} stable for this hook instance, or refactor ${rawPath} so it always calls the same hooks regardless of that mode.`,
6993
8102
  fixPreview: dynamicHookModeFixPreview(rawPath, argumentText)
6994
8103
  });
6995
8104
  }
6996
8105
  }
6997
8106
  const controlFlow = findAncestorBetween(call, fn.node, (node) => CONDITIONAL_TYPES.has(node.type));
6998
8107
  if (controlFlow) {
6999
- if (currentModeSummary && currentModeSummary.knownCallSites > 0 && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
8108
+ if (currentModeSummary && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
8109
+ continue;
8110
+ if (conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants))
8111
+ continue;
8112
+ if (ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context))
7000
8113
  continue;
7001
8114
  if (controlFlow.type === "for_statement" && loopIterationIsProvablyStable(controlFlow, staticImports, fn, stableShapes))
7002
8115
  continue;
@@ -7010,7 +8123,8 @@ end`,
7010
8123
  });
7011
8124
  continue;
7012
8125
  }
7013
- if (hasReachableReturnBefore(call, fn, context, reachableReturns)) {
8126
+ const earlierReturns = reachableReturnsBefore(call, fn, context, reachableReturns);
8127
+ if (earlierReturns.length > 0 && !earlierReturns.every((node) => returnIsControlledByStableTopologyMode(node, fn, currentModeSummary, context, moduleInvariants))) {
7014
8128
  diagnostics.push({
7015
8129
  node: callNameNode(call),
7016
8130
  message: `Hook ${path7} may run after an earlier reachable return, so some renders can execute fewer hooks.`,
@@ -7455,6 +8569,16 @@ function nodeLocation(node) {
7455
8569
  endColumn: node.endPosition.column + 1
7456
8570
  };
7457
8571
  }
8572
+ function sameLocation(left, right) {
8573
+ return left.line === right.line && left.column === right.column && left.endLine === right.endLine && left.endColumn === right.endColumn;
8574
+ }
8575
+ function removeSupersededDiagnostics(diagnostics) {
8576
+ return diagnostics.filter((diagnostic) => {
8577
+ if (diagnostic.rule !== "react-luau/rerender-high-frequency-state")
8578
+ return true;
8579
+ 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]);
8580
+ });
8581
+ }
7458
8582
  function toDiagnostic(file, ruleId, category, severity, input) {
7459
8583
  const node = input.node;
7460
8584
  const highlights = input.highlights?.map(nodeLocation);
@@ -7473,7 +8597,7 @@ function toDiagnostic(file, ruleId, category, severity, input) {
7473
8597
  }
7474
8598
  async function analyzeReactFile(input, tree) {
7475
8599
  const parsedTree = tree ?? await parseLuau(input.source);
7476
- const model = buildReactModel(parsedTree.rootNode);
8600
+ const model = buildReactModel(parsedTree.rootNode, input.project);
7477
8601
  if (!input.forceScan && !model.isReactFile) {
7478
8602
  return { relativePath: input.relativePath, isReactFile: false, scanned: false, diagnostics: [] };
7479
8603
  }
@@ -7512,11 +8636,12 @@ async function analyzeReactFile(input, tree) {
7512
8636
  if (rule.id === "react-luau/parse-error" && findings.length > 0)
7513
8637
  break;
7514
8638
  }
8639
+ const filteredDiagnostics = removeSupersededDiagnostics(diagnostics);
7515
8640
  return {
7516
8641
  relativePath: input.relativePath,
7517
8642
  isReactFile: model.isReactFile,
7518
8643
  scanned: true,
7519
- diagnostics
8644
+ diagnostics: filteredDiagnostics
7520
8645
  };
7521
8646
  }
7522
8647
 
@@ -7675,7 +8800,34 @@ class AnalysisWorkerPool {
7675
8800
  // src/project-model.ts
7676
8801
  import fs6 from "fs";
7677
8802
  import path7 from "path";
7678
- var EXTERNAL_UPDATE_SOURCE = /(?::|\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\s*\(|\b(?:subscribe|observe|listen|watch)[A-Za-z0-9_]*\s*\(|GetPropertyChangedSignal\s*\(|\.Changed\b/i;
8803
+ var EXTERNAL_UPDATE_SOURCE = /(?::|\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)\s*\(|\b(?:subscribe|observe|listen|watch|onStep|onUpdate|onChange|onChanged)[A-Za-z0-9_]*\s*\(|GetPropertyChangedSignal\s*\(|GetAttributeChangedSignal\s*\(|\.Changed\b/i;
8804
+ var SEMANTIC_SNAPSHOT_EXPRESSION2 = /\b(?:getState|getSnapshot|snapshot|selector|select|table\s*\.\s*(?:clone|freeze|move))\b|(?:\.|\[\s*["'])State(?:\b|["']\s*\])/i;
8805
+ var PURE_MIRROR_EXPRESSION_ROOTS = new Set([
8806
+ "math",
8807
+ "string",
8808
+ "utf8",
8809
+ "bit32",
8810
+ "UDim",
8811
+ "UDim2",
8812
+ "Vector2",
8813
+ "Vector3",
8814
+ "Color3",
8815
+ "CFrame",
8816
+ "Rect",
8817
+ "NumberRange",
8818
+ "NumberSequence",
8819
+ "ColorSequence",
8820
+ "BrickColor",
8821
+ "Font",
8822
+ "Enum",
8823
+ "tostring",
8824
+ "tonumber",
8825
+ "type",
8826
+ "typeof",
8827
+ "true",
8828
+ "false",
8829
+ "nil"
8830
+ ]);
7679
8831
  var CALLBACK_PARAMETER_NAME = /^(?:callback|handler|listener|subscriber|observer|effect|fn)$/i;
7680
8832
  function exportedFunctionName(source) {
7681
8833
  return source.match(/\breturn\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/)?.[1] ?? null;
@@ -7817,37 +8969,81 @@ function parameterName(parameter) {
7817
8969
  function branchContainsHookCall(text) {
7818
8970
  return /\b(?:[A-Za-z_][A-Za-z0-9_]*[.:])?use[A-Z0-9_][A-Za-z0-9_]*\s*\(/.test(text);
7819
8971
  }
8972
+ var BUILT_IN_REACT_HOOK_NAMES = new Set([
8973
+ "useState",
8974
+ "useEffect",
8975
+ "useLayoutEffect",
8976
+ "useMemo",
8977
+ "useCallback",
8978
+ "useContext",
8979
+ "useReducer",
8980
+ "useRef",
8981
+ "useImperativeHandle",
8982
+ "useBinding"
8983
+ ]);
8984
+ function simpleBranchHookPaths(text) {
8985
+ if (/\b(?:if|for|while|repeat|function)\b/.test(text))
8986
+ return null;
8987
+ return [...text.matchAll(/\b(React\.(use[A-Z0-9_][A-Za-z0-9_]*))\s*\(/g)].map((match) => match[2]);
8988
+ }
8989
+ function simpleIfHasEquivalentBuiltInTopology(source, ifStart) {
8990
+ if (ifStart === undefined)
8991
+ return false;
8992
+ const statement = source.slice(ifStart).match(/^if\b[\s\S]*?\bthen\b([\s\S]*?)\belse\b([\s\S]*?)\bend\b/);
8993
+ if (!statement)
8994
+ return false;
8995
+ const left = simpleBranchHookPaths(statement[1] ?? "");
8996
+ const right = simpleBranchHookPaths(statement[2] ?? "");
8997
+ if (!left || !right || left.length === 0 || left.length !== right.length)
8998
+ return false;
8999
+ return left.every((name, index) => name === right[index] && BUILT_IN_REACT_HOOK_NAMES.has(name));
9000
+ }
7820
9001
  function conditionAliasesForParameter(source, parameter) {
7821
- const aliases = new Set([parameter]);
9002
+ const aliases = new Map([[parameter, ""]]);
7822
9003
  const escaped = parameter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9004
+ for (const match of source.matchAll(new RegExp(`\\b${escaped}((?:\\.[A-Za-z_][A-Za-z0-9_]*)+)`, "g"))) {
9005
+ aliases.set(`${parameter}${match[1]}`, match[1].slice(1));
9006
+ }
7823
9007
  const directPatterns = [
7824
- new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escaped}\\s*$`, "gm"),
7825
- new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*not\\s+${escaped}\\s*$`, "gm"),
7826
- new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escaped}\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`, "gm"),
7827
- new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:true|false|nil)\\s*(?:==|~=)\\s*${escaped}\\s*$`, "gm"),
7828
- new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*if\\s+${escaped}\\s*(?:==|~=)\\s*nil\\s+then\\s+(?:true|false)\\s+else\\s+${escaped}\\s*$`, "gm"),
7829
- new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*if\\s+${escaped}\\s+then\\s+(?:true|false)\\s+else\\s+(?:true|false)\\s*$`, "gm")
9008
+ new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*$`, "gm"),
9009
+ new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*not\\s+(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*$`, "gm"),
9010
+ new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`, "gm"),
9011
+ new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:true|false|nil)\\s*(?:==|~=)\\s*(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*$`, "gm")
7830
9012
  ];
7831
9013
  for (const pattern of directPatterns) {
7832
- for (const match of source.matchAll(pattern))
7833
- aliases.add(match[1]);
9014
+ for (const match of source.matchAll(pattern)) {
9015
+ const sourcePath = match[2];
9016
+ const accessPath = sourcePath === parameter ? "" : sourcePath.slice(parameter.length + 1);
9017
+ aliases.set(match[1], accessPath);
9018
+ }
7834
9019
  }
7835
- return [...aliases];
9020
+ for (const match of source.matchAll(new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*if\\s+(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s*(?:==|~=)\\s*nil\\s+then\\s+(?:true|false)\\s+else\\s+\\2\\s*$`, "gm"))) {
9021
+ const sourcePath = match[2];
9022
+ aliases.set(match[1], sourcePath === parameter ? "" : sourcePath.slice(parameter.length + 1));
9023
+ }
9024
+ for (const match of source.matchAll(new RegExp(`^\\s*local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*if\\s+(${escaped}(?:\\.[A-Za-z_][A-Za-z0-9_]*)*)\\s+then\\s+(?:true|false)\\s+else\\s+(?:true|false)\\s*$`, "gm"))) {
9025
+ const sourcePath = match[2];
9026
+ aliases.set(match[1], sourcePath === parameter ? "" : sourcePath.slice(parameter.length + 1));
9027
+ }
9028
+ return aliases;
7836
9029
  }
7837
9030
  function findConditionalHookMode(source) {
7838
9031
  const exported = exportedFunctionParameters(source);
7839
9032
  if (!exported)
7840
9033
  return null;
9034
+ if (exported.name && !/^use[A-Z0-9_]/.test(exported.name))
9035
+ return null;
7841
9036
  const parameterNames3 = exported.parameters.map(parameterName);
7842
9037
  const controlledIndexes = [];
7843
9038
  const controlledNames = [];
7844
9039
  const conditionVariables = {};
9040
+ const conditionAccessPaths = {};
7845
9041
  for (const [index, name] of parameterNames3.entries()) {
7846
9042
  if (!name)
7847
9043
  continue;
7848
9044
  const aliases = conditionAliasesForParameter(source, name);
7849
9045
  let controlsHook = false;
7850
- for (const alias of aliases) {
9046
+ for (const [alias, accessPath] of aliases) {
7851
9047
  const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7852
9048
  const patterns = [
7853
9049
  new RegExp(`\\bif\\s+${escaped}\\s+then([\\s\\S]*?)(?=\\belse\\b|\\bend\\b)`, "g"),
@@ -7859,8 +9055,11 @@ function findConditionalHookMode(source) {
7859
9055
  for (const match of source.matchAll(pattern)) {
7860
9056
  if (!branchContainsHookCall(match[1] ?? ""))
7861
9057
  continue;
9058
+ if (simpleIfHasEquivalentBuiltInTopology(source, match.index))
9059
+ continue;
7862
9060
  controlsHook = true;
7863
9061
  conditionVariables[alias] = index;
9062
+ conditionAccessPaths[alias] = accessPath;
7864
9063
  break;
7865
9064
  }
7866
9065
  if (controlsHook)
@@ -7879,6 +9078,7 @@ function findConditionalHookMode(source) {
7879
9078
  parameterIndexes: controlledIndexes,
7880
9079
  parameterNames: controlledNames,
7881
9080
  conditionVariables,
9081
+ conditionAccessPaths,
7882
9082
  knownCallSites: 0,
7883
9083
  dynamicCallSites: 0
7884
9084
  };
@@ -8011,9 +9211,62 @@ function findExternalCallbackFunction(source) {
8011
9211
  return {
8012
9212
  name,
8013
9213
  callbackParameterIndexes,
8014
- highFrequency: sourceHasHighFrequencyRunService(source)
9214
+ highFrequency: sourceHasHighFrequencyRobloxEvent(source) || sourceHasLikelyContinuousSubscription(source)
8015
9215
  };
8016
9216
  }
9217
+ function sourceHasLikelyContinuousSubscription(source) {
9218
+ return /(?::|\.)onStep\s*\(/i.test(source);
9219
+ }
9220
+ function expressionReferencesName(expression, name) {
9221
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9222
+ return new RegExp(`\\b${escaped}\\b`).test(expression);
9223
+ }
9224
+ function isLiteralStateTransitionExpression(expression) {
9225
+ const text = expression.trim();
9226
+ return /^(?:true|false|nil|[-+]?\d+(?:\.\d+)?|["'][^"']*["'])$/.test(text);
9227
+ }
9228
+ function expressionHasLikelyExternalInput(expression) {
9229
+ const withoutMembers = expression.replace(/\.\s*[A-Za-z_][A-Za-z0-9_]*/g, "");
9230
+ for (const match of withoutMembers.matchAll(/\b[A-Za-z_][A-Za-z0-9_]*\b/g)) {
9231
+ if (!PURE_MIRROR_EXPRESSION_ROOTS.has(match[0]))
9232
+ return true;
9233
+ }
9234
+ return false;
9235
+ }
9236
+ function subscriptionMirrorConfidence(source, setterName) {
9237
+ const escapedSetter = setterName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9238
+ const directSetter = new RegExp(`(?::|\\.)(Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)\\s*\\(\\s*${escapedSetter}\\b`, "i");
9239
+ if (directSetter.test(source))
9240
+ return "strong";
9241
+ const callbackPattern = new RegExp(`(?::|\\.)(Connect|Subscribe|Observe|Listen|Watch|onStep|onUpdate|onChange|onChanged)\\s*\\(\\s*function\\s*\\(([^)]*)\\)[\\s\\S]{0,2400}?\\b${escapedSetter}\\s*\\(`, "gi");
9242
+ let possible = false;
9243
+ for (const match of source.matchAll(callbackPattern)) {
9244
+ if (match.index === undefined)
9245
+ continue;
9246
+ const callbackParameters = splitTopLevelParameters(match[2] ?? "").map(parameterName).filter((name) => Boolean(name));
9247
+ const setterOffset = match[0].lastIndexOf(setterName);
9248
+ if (setterOffset < 0)
9249
+ continue;
9250
+ const setterStart = match.index + setterOffset;
9251
+ const open = source.indexOf("(", setterStart + setterName.length);
9252
+ const argumentsText = extractCallArguments(source, open);
9253
+ if (argumentsText === null)
9254
+ continue;
9255
+ const setterArgs = splitTopLevelParameters(argumentsText);
9256
+ const expression = setterArgs[0]?.trim() ?? "";
9257
+ if (!expression || /^function\b/.test(expression) || isLiteralStateTransitionExpression(expression))
9258
+ continue;
9259
+ if (SEMANTIC_SNAPSHOT_EXPRESSION2.test(expression))
9260
+ continue;
9261
+ if (callbackParameters.some((name) => expressionReferencesName(expression, name))) {
9262
+ return "strong";
9263
+ }
9264
+ if (!expressionHasLikelyExternalInput(expression))
9265
+ continue;
9266
+ possible = true;
9267
+ }
9268
+ return possible ? "possible" : "none";
9269
+ }
8017
9270
  function findBindingCandidateHook(source) {
8018
9271
  const exported = source.match(/\breturn\s+(use[A-Z0-9_][A-Za-z0-9_]*)\s*$/)?.[1];
8019
9272
  if (!exported)
@@ -8035,18 +9288,35 @@ function findBindingCandidateHook(source) {
8035
9288
  const setterPassedToSubscription = new RegExp(`(?::|\\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\\s*\\(\\s*${escapedSetter}\\b`, "i").test(source);
8036
9289
  if (!setterCalled && !setterPassedToSubscription)
8037
9290
  return null;
8038
- const highFrequency = sourceHasHighFrequencyRunService(source);
9291
+ const highFrequency = sourceHasHighFrequencyRobloxEvent(source) || sourceHasLikelyContinuousSubscription(source);
8039
9292
  const external = highFrequency || EXTERNAL_UPDATE_SOURCE.test(source);
8040
9293
  if (!external)
8041
9294
  return null;
8042
- const propertySignal = source.match(/GetPropertyChangedSignal\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
8043
- const observedPropertyName = propertySignal?.[1] ?? null;
8044
- const measurement = Boolean(observedPropertyName && new RegExp(`\\[\\s*${observedPropertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`).test(source));
9295
+ const literalPropertySignal = source.match(/GetPropertyChangedSignal\s*\(\s*["']([^"']+)["']\s*\)/);
9296
+ const dynamicPropertySignal = literalPropertySignal ? null : source.match(/GetPropertyChangedSignal\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
9297
+ const literalPropertyName = literalPropertySignal?.[1] ?? null;
9298
+ const dynamicPropertyName = dynamicPropertySignal?.[1] ?? null;
9299
+ const readsObservedProperty = literalPropertyName ? new RegExp(`(?:\\.\\s*${literalPropertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b|\\[\\s*["']${literalPropertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*\\])`).test(source) : Boolean(dynamicPropertyName && new RegExp(`\\[\\s*${dynamicPropertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`).test(source));
9300
+ const instanceProperty = Boolean((literalPropertyName || dynamicPropertyName) && readsObservedProperty);
9301
+ const mirrorConfidence = instanceProperty ? "strong" : subscriptionMirrorConfidence(source, setterName);
9302
+ let observedPropertyParameterIndex;
9303
+ if (instanceProperty && dynamicPropertyName) {
9304
+ const parameters = findFunctionParameters(source, exported);
9305
+ if (parameters !== null) {
9306
+ const parameterNames3 = splitTopLevelParameters(parameters).map((parameter) => parameter.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? "");
9307
+ const index = parameterNames3.indexOf(dynamicPropertyName);
9308
+ if (index >= 0)
9309
+ observedPropertyParameterIndex = index;
9310
+ }
9311
+ }
8045
9312
  return {
8046
9313
  name: exported,
8047
9314
  highFrequency,
8048
9315
  external,
8049
- sourceKind: measurement ? "measurement" : "external-state",
9316
+ sourceKind: instanceProperty ? "instance-property" : "external-state",
9317
+ mirrorConfidence,
9318
+ ...instanceProperty && literalPropertyName ? { observedPropertyName: literalPropertyName } : {},
9319
+ ...observedPropertyParameterIndex !== undefined ? { observedPropertyParameterIndex } : {},
8050
9320
  ...dualMode ? {
8051
9321
  bindingModeParameterIndex: dualMode.bindingModeParameterIndex,
8052
9322
  bindingModeParameterName: dualMode.bindingModeParameterName,
@@ -8084,7 +9354,8 @@ function findDerivedBindingCandidateHook(source, bindingCandidateHooks) {
8084
9354
  name: exported,
8085
9355
  highFrequency: summary.highFrequency,
8086
9356
  external: true,
8087
- sourceKind: "derived-external-state"
9357
+ sourceKind: "derived-external-state",
9358
+ mirrorConfidence: summary.mirrorConfidence
8088
9359
  };
8089
9360
  }
8090
9361
  return null;
@@ -8448,12 +9719,73 @@ function findBindingCompatibleComponentProps(source) {
8448
9719
  }
8449
9720
  const compatible = new Set;
8450
9721
  for (const [property, offsets] of reads) {
8451
- if (offsets.length > 0 && offsets.every((offset) => ranges.some((range) => offset >= range.start && offset < range.end))) {
9722
+ if (offsets.length > 0 && offsets.every((offset) => ranges.some((range) => {
9723
+ if (offset < range.start || offset >= range.end)
9724
+ return false;
9725
+ return !/\bfunction\b/.test(source.slice(range.start, offset));
9726
+ }))) {
8452
9727
  compatible.add(property);
8453
9728
  }
8454
9729
  }
8455
9730
  return compatible.size > 0 ? compatible : null;
8456
9731
  }
9732
+ function exportedUseFunctionName(source) {
9733
+ const name = exportedFunctionName(source);
9734
+ return name && /^use[A-Z0-9_]/.test(name) ? name : null;
9735
+ }
9736
+ function directReactHookUsage(source) {
9737
+ const namespaces = new Set(["React"]);
9738
+ const requirePattern = /\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)/gs;
9739
+ for (const match of source.matchAll(requirePattern)) {
9740
+ const target = normalizeRequireTarget(match[2]);
9741
+ if (target.split(".").includes("react"))
9742
+ namespaces.add(match[1]);
9743
+ }
9744
+ for (const namespace of namespaces) {
9745
+ const escaped = namespace.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9746
+ if (new RegExp(`\\b${escaped}\\.use[A-Z0-9_][A-Za-z0-9_]*\\b`).test(source))
9747
+ return true;
9748
+ }
9749
+ return false;
9750
+ }
9751
+ function calledRequiredModules(source) {
9752
+ const result = [];
9753
+ const requirePattern = /\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)/gs;
9754
+ for (const match of source.matchAll(requirePattern)) {
9755
+ const localName = match[1];
9756
+ const escaped = localName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9757
+ if (!new RegExp(`\\b${escaped}\\s*\\(`).test(source.slice(match.index + match[0].length)))
9758
+ continue;
9759
+ result.push({ localName, target: normalizeRequireTarget(match[2]) });
9760
+ }
9761
+ return result;
9762
+ }
9763
+ function findReactHookModules(records) {
9764
+ const hookRecords = records.filter((record) => exportedUseFunctionName(record.source));
9765
+ const provenReact = new Map;
9766
+ for (const record of hookRecords) {
9767
+ if (directReactHookUsage(record.source))
9768
+ provenReact.set(record.id, true);
9769
+ }
9770
+ let changed = true;
9771
+ while (changed) {
9772
+ changed = false;
9773
+ const aliases = buildUniqueFeatureAliases(records, provenReact);
9774
+ for (const record of hookRecords) {
9775
+ if (provenReact.has(record.id))
9776
+ continue;
9777
+ const callsReactHookModule = calledRequiredModules(record.source).some(({ target }) => resolveModuleReference(target, aliases) === true);
9778
+ if (!callsReactHookModule)
9779
+ continue;
9780
+ provenReact.set(record.id, true);
9781
+ changed = true;
9782
+ }
9783
+ }
9784
+ const result = new Map;
9785
+ for (const record of hookRecords)
9786
+ result.set(record.id, provenReact.has(record.id));
9787
+ return result;
9788
+ }
8457
9789
  function findStaticIterationTables(source) {
8458
9790
  const match = /^return\s*\{/m.exec(source);
8459
9791
  if (!match || match.index === undefined)
@@ -8529,11 +9861,13 @@ function buildProjectModel(root, candidates, options = {}) {
8529
9861
  parameterIndexes: [...analysis.conditionalHookMode.parameterIndexes],
8530
9862
  parameterNames: [...analysis.conditionalHookMode.parameterNames],
8531
9863
  conditionVariables: { ...analysis.conditionalHookMode.conditionVariables },
9864
+ conditionAccessPaths: { ...analysis.conditionalHookMode.conditionAccessPaths },
8532
9865
  knownCallSites: 0,
8533
9866
  dynamicCallSites: 0
8534
9867
  });
8535
9868
  }
8536
9869
  const conditionalHookModes = buildUniqueFeatureAliases(records, conditionalHookModesByModule);
9870
+ const reactHookModules = buildUniqueFeatureAliases(records, findReactHookModules(records));
8537
9871
  collectConditionalHookModeCallSites(records, conditionalHookModes);
8538
9872
  let changed = true;
8539
9873
  while (changed) {
@@ -8557,6 +9891,7 @@ function buildProjectModel(root, candidates, options = {}) {
8557
9891
  bindingCompatibleComponentProps: buildUniqueFeatureAliases(records, bindingCompatibleComponentPropsByModule),
8558
9892
  staticIterationTables: buildUniqueFeatureAliases(records, staticIterationTablesByModule),
8559
9893
  conditionalHookModes,
9894
+ reactHookModules,
8560
9895
  sourceEffects: new Map
8561
9896
  };
8562
9897
  }
@@ -10771,6 +12106,7 @@ import path12 from "path";
10771
12106
  var UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000;
10772
12107
  var UPDATE_REQUEST_TIMEOUT_MS = 5000;
10773
12108
  var UPDATE_CACHE_FILENAME = "update-check.json";
12109
+ var CHANGELOG_URL = "https://raw.githubusercontent.com/ColRealPro/react-luau-doctor/refs/heads/main/CHANGELOG.md";
10774
12110
  function updateCacheFilename() {
10775
12111
  return path12.join(cacheBaseDirectory(), UPDATE_CACHE_FILENAME);
10776
12112
  }
@@ -10852,6 +12188,9 @@ function updateRegistryUrl() {
10852
12188
  const base = registry.endsWith("/") ? registry : `${registry}/`;
10853
12189
  return new URL(`${encodeURIComponent(package_default.name)}/latest`, base).toString();
10854
12190
  }
12191
+ function changelogUrl() {
12192
+ return process.env.REACT_LUAU_DOCTOR_CHANGELOG_URL ?? CHANGELOG_URL;
12193
+ }
10855
12194
  async function fetchLatestVersion() {
10856
12195
  const controller = new AbortController;
10857
12196
  const timer = setTimeout(() => controller.abort(), UPDATE_REQUEST_TIMEOUT_MS);
@@ -10872,6 +12211,50 @@ async function fetchLatestVersion() {
10872
12211
  clearTimeout(timer);
10873
12212
  }
10874
12213
  }
12214
+ function changelogReleasesBetween(markdown, currentVersion, latestVersion) {
12215
+ const releases = [];
12216
+ let active = null;
12217
+ const flush = () => {
12218
+ if (!active)
12219
+ return;
12220
+ const afterCurrent = compareVersions(active.version, currentVersion);
12221
+ const atOrBeforeLatest = compareVersions(active.version, latestVersion);
12222
+ if (afterCurrent !== null && atOrBeforeLatest !== null && afterCurrent > 0 && atOrBeforeLatest <= 0) {
12223
+ releases.push({ version: active.version, notes: active.lines.join(`
12224
+ `).trim() });
12225
+ }
12226
+ active = null;
12227
+ };
12228
+ for (const line of markdown.split(/\r?\n/)) {
12229
+ if (/^##\s+/.test(line.trim())) {
12230
+ flush();
12231
+ const match = /^##\s+\[?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-.*)?\s*$/.exec(line.trim());
12232
+ if (match)
12233
+ active = { version: match[1], lines: [] };
12234
+ continue;
12235
+ }
12236
+ active?.lines.push(line);
12237
+ }
12238
+ flush();
12239
+ releases.sort((left, right) => compareVersions(right.version, left.version) ?? 0);
12240
+ return releases;
12241
+ }
12242
+ async function fetchChangelogReleases(currentVersion, latestVersion) {
12243
+ const controller = new AbortController;
12244
+ const timer = setTimeout(() => controller.abort(), UPDATE_REQUEST_TIMEOUT_MS);
12245
+ timer.unref?.();
12246
+ try {
12247
+ const response = await fetch(changelogUrl(), {
12248
+ headers: { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" },
12249
+ signal: controller.signal
12250
+ });
12251
+ if (!response.ok)
12252
+ throw new Error(`changelog returned HTTP ${response.status}`);
12253
+ return changelogReleasesBetween(await response.text(), currentVersion, latestVersion);
12254
+ } finally {
12255
+ clearTimeout(timer);
12256
+ }
12257
+ }
10875
12258
  function updateCacheIsStale(now = Date.now()) {
10876
12259
  const cache = readUpdateCache();
10877
12260
  return !cache || now - cache.checkedAt >= UPDATE_CHECK_INTERVAL_MS;
@@ -10927,7 +12310,7 @@ async function checkForUpdatesNow(currentVersion) {
10927
12310
 
10928
12311
  // src/update-install.ts
10929
12312
  import { spawnSync as spawnSync3 } from "child_process";
10930
- import { fileURLToPath } from "url";
12313
+ import { fileURLToPath as fileURLToPath2 } from "url";
10931
12314
  function normalizedPath(filename) {
10932
12315
  return filename.replaceAll("\\", "/");
10933
12316
  }
@@ -10955,7 +12338,7 @@ function updateInstallCommandForPath(filename) {
10955
12338
  return null;
10956
12339
  }
10957
12340
  function currentUpdateInstallCommand() {
10958
- return updateInstallCommandForPath(fileURLToPath(import.meta.url));
12341
+ return updateInstallCommandForPath(fileURLToPath2(import.meta.url));
10959
12342
  }
10960
12343
  function installLatestVersion(command) {
10961
12344
  const result = spawnSync3(command.command, command.args, {
@@ -11063,10 +12446,12 @@ end, {})`,
11063
12446
  },
11064
12447
  "react-luau/prefer-binding-over-state-candidate": {
11065
12448
  before: `local value, setValue = React.useState(0)
11066
- -- value drives both visual and structural work`,
11067
- after: `local visualValue, setVisualValue = React.useBinding(0)
11068
- local structuralValue, setStructuralValue = React.useState(0)`,
11069
- note: "A common fix is to split high-frequency visual updates from the smaller value that truly needs reconciliation."
12449
+ signal:Connect(setValue)
12450
+ -- value is used for presentation`,
12451
+ after: `local value, setValue = React.useBinding(0)
12452
+ signal:Connect(setValue)
12453
+ -- keep separate React state too if structure depends on it`,
12454
+ note: "Use a Binding when this is really an external presentation stream. Keep or split React state when the event represents semantic UI state or needs reconciliation."
11070
12455
  },
11071
12456
  "react-luau/rerender-unstable-memo-props": {
11072
12457
  before: `return React.createElement(MemoizedButton, {
@@ -11498,7 +12883,7 @@ CI commands:
11498
12883
 
11499
12884
  Update commands:
11500
12885
  update Update the global installation to the latest release
11501
- update --check Check npm for a newer release without updating
12886
+ update --check Check npm and show release notes without updating
11502
12887
 
11503
12888
  Rules commands:
11504
12889
  rules list [--category <name>] [--configured] [--json]
@@ -11515,12 +12900,67 @@ Config:
11515
12900
  function automaticUpdateNoticeEnabled(options, machineReadable) {
11516
12901
  return !options.noUpdateCheck && !machineReadable && Boolean(process.stdout.isTTY) && !process.env.CI && process.env.NO_UPDATE_NOTIFIER === undefined && process.env.REACT_LUAU_DOCTOR_NO_UPDATE_CHECK === undefined;
11517
12902
  }
11518
- function renderUpdateNotice(current, latest, colorized) {
12903
+ function renderUpdateHeader(current, latest, colorized) {
11519
12904
  const label = whyPaint(colorized, "Update available:", WHY_ANSI.bold, WHY_ANSI.yellow);
11520
12905
  const oldVersion = whyPaint(colorized, `v${current}`, WHY_ANSI.dim);
11521
12906
  const newVersion = whyPaint(colorized, `v${latest}`, WHY_ANSI.bold);
11522
- return `${label} ${oldVersion} \u2192 ${newVersion}
11523
- Run \`react-luau-doctor update\` to update.`;
12907
+ return `${label} ${oldVersion} \u2192 ${newVersion}`;
12908
+ }
12909
+ function renderUpdateNotice(current, latest, colorized) {
12910
+ return `${renderUpdateHeader(current, latest, colorized)}
12911
+ Run \`react-luau-doctor update --check\` to see what's new.`;
12912
+ }
12913
+ function renderChangelogInline(value, colorized, ...baseCodes) {
12914
+ if (!colorized)
12915
+ return value;
12916
+ const parts = [];
12917
+ let offset = 0;
12918
+ for (const match of value.matchAll(/`([^`\n]+)`/g)) {
12919
+ const index = match.index ?? 0;
12920
+ if (index > offset)
12921
+ parts.push(whyPaint(true, value.slice(offset, index), ...baseCodes));
12922
+ parts.push(whyPaint(true, match[1], WHY_THEME.variable));
12923
+ offset = index + match[0].length;
12924
+ }
12925
+ if (parts.length === 0)
12926
+ return whyPaint(true, value, ...baseCodes);
12927
+ if (offset < value.length)
12928
+ parts.push(whyPaint(true, value.slice(offset), ...baseCodes));
12929
+ return parts.join("");
12930
+ }
12931
+ function renderChangelogNotes(notes, colorized) {
12932
+ return notes.split(/\r?\n/).map((line) => {
12933
+ const heading = /^#{3,6}\s+(.+)$/.exec(line.trim());
12934
+ if (heading)
12935
+ return renderChangelogInline(heading[1], colorized, WHY_ANSI.bold);
12936
+ const bullet = /^(\s*)[-*]\s+(.+)$/.exec(line);
12937
+ if (bullet)
12938
+ return `${bullet[1] || " "}\u2022 ${renderChangelogInline(bullet[2], colorized)}`;
12939
+ return renderChangelogInline(line, colorized);
12940
+ }).join(`
12941
+ `).trim();
12942
+ }
12943
+ function renderUpdateCheck(current, latest, releases, releaseNotesUnavailable, colorized) {
12944
+ const lines2 = [
12945
+ renderUpdateHeader(current, latest, colorized),
12946
+ "",
12947
+ whyPaint(colorized, "What's new", WHY_ANSI.bold)
12948
+ ];
12949
+ if (releaseNotesUnavailable) {
12950
+ lines2.push(" Release notes could not be loaded from GitHub.");
12951
+ } else if (releases.length === 0) {
12952
+ lines2.push(" No changelog entries were found between these versions.");
12953
+ } else {
12954
+ for (const release of releases) {
12955
+ lines2.push("", whyPaint(colorized, `v${release.version}`, WHY_ANSI.bold));
12956
+ const notes = renderChangelogNotes(release.notes, colorized);
12957
+ if (notes)
12958
+ lines2.push(notes);
12959
+ }
12960
+ }
12961
+ lines2.push("", "Run `react-luau-doctor update` to update.");
12962
+ return lines2.join(`
12963
+ `);
11524
12964
  }
11525
12965
  async function runUpdateCommand(argv) {
11526
12966
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -11539,7 +12979,14 @@ async function runUpdateCommand(argv) {
11539
12979
  }
11540
12980
  const colorized = shouldUseColor(false, false);
11541
12981
  if (argv[0] === "--check") {
11542
- process.stdout.write(`${renderUpdateNotice(VERSION2, result.latest, colorized)}
12982
+ let releases = [];
12983
+ let releaseNotesUnavailable = false;
12984
+ try {
12985
+ releases = await fetchChangelogReleases(VERSION2, result.latest);
12986
+ } catch {
12987
+ releaseNotesUnavailable = true;
12988
+ }
12989
+ process.stdout.write(`${renderUpdateCheck(VERSION2, result.latest, releases, releaseNotesUnavailable, colorized)}
11543
12990
  `);
11544
12991
  return;
11545
12992
  }
@@ -12476,14 +13923,14 @@ function renderWhyCodeFrame(filename, diagnostic, colorized) {
12476
13923
  }
12477
13924
  function whySeverityMeaning(severity) {
12478
13925
  if (severity === "error")
12479
- return "This can violate React or Luau correctness rules and should normally be fixed.";
13926
+ return "This can cause incorrect behavior or violate React's rules and should normally be fixed.";
12480
13927
  if (severity === "warning")
12481
13928
  return "This pattern is very likely to be a real issue in the code shown and is normally worth fixing.";
12482
13929
  return "This may be intentional. Doctor found a plausible improvement, but the right choice depends on how this value or pattern is meant to affect rendering.";
12483
13930
  }
12484
13931
  function whyRuleSpecificContext(diagnostic) {
12485
13932
  if (diagnostic.rule === "react-luau/prefer-binding-over-state-candidate") {
12486
- return "A Binding can update visual values without rerendering the component. Here, at least one use may be structural or otherwise depend on a rerender, so replacing all of the state with a Binding is not automatically safe.";
13933
+ return "A Binding can update visual values without rerendering the component. Doctor found an external reactive mirror or presentation branch, but it cannot fully prove the source's update pressure, conversion safety, or that every consumer can leave React state.";
12487
13934
  }
12488
13935
  if (diagnostic.rule === "react-luau/prefer-binding-over-state") {
12489
13936
  return "A Binding can update these values without rerendering the component, and every use of the value only needs that kind of update. React state is therefore doing extra reconciliation work here.";
@@ -12498,7 +13945,7 @@ function whyRuleSpecificContext(diagnostic) {
12498
13945
  }
12499
13946
  function whyRuleCaveat(diagnostic) {
12500
13947
  if (diagnostic.rule === "react-luau/prefer-binding-over-state-candidate") {
12501
- return "Keep React state when the value genuinely changes component structure, conditional children, hook inputs, or other behavior that must go through React reconciliation. Mixed cases often benefit from splitting visual Binding updates from a smaller structural state value.";
13948
+ return "Keep React state when the external event represents semantic UI state, changes component structure, drives hook/effect behavior, or is infrequent enough that a Binding would only make the code harder to follow. Mixed cases can split visual Binding updates from the smaller value that truly needs reconciliation.";
12502
13949
  }
12503
13950
  if (diagnostic.rule === "react-luau/prefer-binding-over-state") {
12504
13951
  return "Keep React state if changing the value must rebuild component structure rather than only update Roblox Instance properties or other Binding-aware consumers.";
@@ -12818,5 +14265,5 @@ ${renderUpdateNotice(update.current, update.latest, colorized)}
12818
14265
  }
12819
14266
  main();
12820
14267
 
12821
- //# debugId=32BB85E7FBDD457564756E2164756E21
14268
+ //# debugId=C71A438CE8D868EF64756E2164756E21
12822
14269
  //# sourceMappingURL=cli.js.map