@colrealpro/react-luau-doctor 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,19 @@ function walk(node) {
19
19
  }
20
20
  return result;
21
21
  }
22
+ function parameterBindingNames(parameters) {
23
+ if (!parameters)
24
+ return [];
25
+ const result = [];
26
+ for (const parameter of parameters.namedChildren) {
27
+ if (parameter.type !== "parameter")
28
+ continue;
29
+ const identifier = parameter.namedChildren.find((child) => child.type === "identifier");
30
+ if (identifier)
31
+ result.push(identifier.text);
32
+ }
33
+ return [...new Set(result)];
34
+ }
22
35
  function rootIdentifier(text) {
23
36
  const match = text.trim().match(/^([A-Za-z_][A-Za-z0-9_]*)/);
24
37
  return match?.[1] ?? null;
@@ -147,6 +160,63 @@ function createRuleContext(file) {
147
160
  };
148
161
  }
149
162
 
163
+ // src/module-resolution.ts
164
+ import path from "path";
165
+ function normalizeRelative(value) {
166
+ return value.split(path.sep).join("/");
167
+ }
168
+ function moduleKeys(relativePath) {
169
+ let normalized = normalizeRelative(relativePath).replace(/\.(?:lua|luau)$/i, "");
170
+ if (normalized.endsWith("/init"))
171
+ normalized = normalized.slice(0, -"/init".length);
172
+ const segments = normalized.split("/").filter(Boolean);
173
+ if (segments[0]?.toLowerCase() === "src")
174
+ segments.shift();
175
+ const keys = new Set;
176
+ for (let index = 0;index < segments.length; index += 1) {
177
+ const suffix = segments.slice(index).join(".").toLowerCase();
178
+ if (suffix)
179
+ keys.add(suffix);
180
+ }
181
+ return [...keys];
182
+ }
183
+ function normalizeRequireTarget(text) {
184
+ return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
185
+ }
186
+ function buildUniqueFeatureAliases(modules, featureByModuleId) {
187
+ const owners = new Map;
188
+ for (const module of modules) {
189
+ for (const key of module.keys) {
190
+ const ids = owners.get(key) ?? new Set;
191
+ ids.add(module.id);
192
+ owners.set(key, ids);
193
+ }
194
+ }
195
+ const aliases = new Map;
196
+ for (const module of modules) {
197
+ const value = featureByModuleId.get(module.id);
198
+ if (value === undefined)
199
+ continue;
200
+ for (const key of module.keys) {
201
+ const ids = owners.get(key);
202
+ if (ids?.size === 1 && ids.has(module.id))
203
+ aliases.set(key, value);
204
+ }
205
+ }
206
+ return aliases;
207
+ }
208
+ function resolveModuleReference(target, aliases) {
209
+ let candidate = target;
210
+ while (true) {
211
+ if (aliases.has(candidate))
212
+ return aliases.get(candidate);
213
+ const separator = candidate.indexOf(".");
214
+ if (separator < 0)
215
+ return null;
216
+ candidate = candidate.slice(separator + 1);
217
+ }
218
+ }
219
+
150
220
  // src/ast/react-model.ts
151
221
  var REACT_HOOKS = new Set([
152
222
  "useState",
@@ -177,18 +247,7 @@ var INSTANCE_FACTORY_MEMBERS = new Set([
177
247
  ]);
178
248
  var STABLE_ENGINE_FACTORY_MEMBERS = new Set(["GetMouse", "GetService"]);
179
249
  function identifiersFromParameters(node) {
180
- if (!node)
181
- return [];
182
- const result = [];
183
- for (const child of walk(node)) {
184
- if (child.type !== "identifier")
185
- continue;
186
- const parentType = child.parent?.type;
187
- if (parentType === "parameters" || parentType === "parameter" || parentType === "typed_identifier") {
188
- result.push(child.text);
189
- }
190
- }
191
- return [...new Set(result)];
250
+ return parameterBindingNames(node);
192
251
  }
193
252
  function getFunctionName(node) {
194
253
  if (node.type === "function_declaration") {
@@ -242,15 +301,19 @@ function directCalls(node) {
242
301
  function hasDirectCreateElementCall(node, aliases, reactNamespaces, reactRobloxNamespaces) {
243
302
  return directCalls(node).some((call) => {
244
303
  const name = call.childForFieldName("name")?.text ?? "";
245
- const path = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
246
- return path === "React.createElement" || path === "ReactRoblox.createPortal";
304
+ const path2 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
305
+ return path2 === "React.createElement" || path2 === "ReactRoblox.createPortal";
247
306
  });
248
307
  }
249
- function hasDirectHookCall(node, aliases, reactNamespaces, reactRobloxNamespaces) {
308
+ function hasDirectHookCall(node, aliases, reactNamespaces, reactRobloxNamespaces, importedHookKinds) {
250
309
  return directCalls(node).some((call) => {
251
310
  const name = call.childForFieldName("name")?.text ?? "";
252
- const path = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
253
- const final = path.split(/[.:]/).at(-1) ?? "";
311
+ const path2 = resolvePath(name, aliases, reactNamespaces, reactRobloxNamespaces);
312
+ const normalizedName = normalizeExpressionText(name);
313
+ const importedKind = importedHookKinds.get(normalizedName);
314
+ if (importedKind !== undefined)
315
+ return importedKind;
316
+ const final = path2.split(/[.:]/).at(-1) ?? "";
254
317
  return /^use[A-Z0-9_]/.test(final);
255
318
  });
256
319
  }
@@ -336,8 +399,8 @@ function isSyntacticallyStableInitializer(node) {
336
399
  return false;
337
400
  return ["nil", "true", "false", "number", "string", "string_content"].includes(node.type) || /^(?:nil|true|false|-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)$/i.test(node.text.trim());
338
401
  }
339
- function finalCallMember(path) {
340
- return path.split(/[.:]/).at(-1) ?? path;
402
+ function finalCallMember(path2) {
403
+ return path2.split(/[.:]/).at(-1) ?? path2;
341
404
  }
342
405
  function expressionCreatesInstance(node, aliases, reactNamespaces, reactRobloxNamespaces) {
343
406
  if (!node)
@@ -349,8 +412,8 @@ function expressionCreatesInstance(node, aliases, reactNamespaces, reactRobloxNa
349
412
  if (candidate.type !== "function_call")
350
413
  continue;
351
414
  const rawPath = candidate.childForFieldName("name")?.text ?? "";
352
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
353
- if (path === "Instance.new" || INSTANCE_FACTORY_MEMBERS.has(finalCallMember(path)))
415
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
416
+ if (path2 === "Instance.new" || INSTANCE_FACTORY_MEMBERS.has(finalCallMember(path2)))
354
417
  return true;
355
418
  }
356
419
  return false;
@@ -362,8 +425,8 @@ function expressionIsStableEngineObject(node, aliases, reactNamespaces, reactRob
362
425
  if (calls.length !== 1)
363
426
  return false;
364
427
  const rawPath = calls[0].childForFieldName("name")?.text ?? "";
365
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
366
- return STABLE_ENGINE_FACTORY_MEMBERS.has(finalCallMember(path));
428
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
429
+ return STABLE_ENGINE_FACTORY_MEMBERS.has(finalCallMember(path2));
367
430
  }
368
431
  function memoReturnsInstance(callback, aliases, reactNamespaces, reactRobloxNamespaces) {
369
432
  if (!callback || callback.type !== "function_definition")
@@ -379,7 +442,21 @@ function memoReturnsInstance(callback, aliases, reactNamespaces, reactRobloxName
379
442
  function isEmptyDependencyTable(node) {
380
443
  return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
381
444
  }
382
- function buildReactModel(root) {
445
+ function importedHookKinds(declarations, project) {
446
+ const result = new Map;
447
+ if (!project)
448
+ return result;
449
+ for (const declaration of declarations) {
450
+ const match = declaration.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
451
+ if (!match || !/^use[A-Z0-9_]/.test(match[1]))
452
+ continue;
453
+ const kind = resolveModuleReference(normalizeRequireTarget(match[2]), project.reactHookModules);
454
+ if (kind !== null)
455
+ result.set(match[1], kind);
456
+ }
457
+ return result;
458
+ }
459
+ function buildReactModel(root, project) {
383
460
  const reactNamespaces = new Set;
384
461
  const reactRobloxNamespaces = new Set;
385
462
  const aliases = new Map;
@@ -395,6 +472,7 @@ function buildReactModel(root) {
395
472
  const instanceVariablesByFunction = new Map;
396
473
  const allNodes = [...walk(root)];
397
474
  const declarations = allNodes.filter((node) => node.type === "variable_declaration");
475
+ const projectHookKinds = importedHookKinds(declarations, project);
398
476
  let isReactFile = false;
399
477
  for (const declaration of declarations) {
400
478
  const text = declaration.text;
@@ -469,7 +547,7 @@ function buildReactModel(root) {
469
547
  if (!isReactFile || !info.body || info.isHook)
470
548
  continue;
471
549
  const simplePascalName = Boolean(info.name && /^[A-Z][A-Za-z0-9_]*$/.test(info.name));
472
- const hasHooks = hasDirectHookCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces);
550
+ const hasHooks = hasDirectHookCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces, projectHookKinds);
473
551
  const renders = hasDirectCreateElementCall(info.body, aliases, reactNamespaces, reactRobloxNamespaces);
474
552
  if (hasHooks || isMemoWrappedFunction(info.node, aliases, reactNamespaces) || simplePascalName && renders) {
475
553
  info.isComponent = true;
@@ -481,8 +559,8 @@ function buildReactModel(root) {
481
559
  if (call.type !== "function_call")
482
560
  continue;
483
561
  const rawPath = call.childForFieldName("name")?.text ?? "";
484
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
485
- if (path !== "React.createElement")
562
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
563
+ if (path2 !== "React.createElement")
486
564
  continue;
487
565
  const element = call.childForFieldName("arguments")?.namedChildren[0]?.text.trim() ?? "";
488
566
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(element))
@@ -517,13 +595,13 @@ function buildReactModel(root) {
517
595
  if (!call)
518
596
  continue;
519
597
  const rawPath = call.childForFieldName("name")?.text ?? "";
520
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
598
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
521
599
  const args = call.childForFieldName("arguments")?.namedChildren ?? [];
522
- if (path === "React.joinBindings" && names.length >= 1) {
600
+ if (path2 === "React.joinBindings" && names.length >= 1) {
523
601
  stableVariables.add(names[0]);
524
602
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
525
603
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
526
- } else if (path === "React.useState" && names.length >= 2) {
604
+ } else if (path2 === "React.useState" && names.length >= 2) {
527
605
  const [valueName, setterName] = names;
528
606
  stateSetters.set(setterName, valueName);
529
607
  stateValues.set(valueName, setterName);
@@ -537,10 +615,10 @@ function buildReactModel(root) {
537
615
  initializer: args[0] ?? null,
538
616
  owner
539
617
  });
540
- } else if (path === "React.useReducer" && names.length >= 2) {
618
+ } else if (path2 === "React.useReducer" && names.length >= 2) {
541
619
  stableVariables.add(names[1]);
542
620
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
543
- } else if (path === "React.useBinding" && names.length >= 1) {
621
+ } else if (path2 === "React.useBinding" && names.length >= 1) {
544
622
  stableVariables.add(names[0]);
545
623
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
546
624
  if (names.length >= 2) {
@@ -548,7 +626,7 @@ function buildReactModel(root) {
548
626
  bindingSetters.add(names[1]);
549
627
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
550
628
  }
551
- } else if (path === "React.useMemo" && names.length >= 1) {
629
+ } else if (path2 === "React.useMemo" && names.length >= 1) {
552
630
  if (isEmptyDependencyTable(args[1])) {
553
631
  stableVariables.add(names[0]);
554
632
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
@@ -557,16 +635,16 @@ function buildReactModel(root) {
557
635
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
558
636
  addScopedVariable(instanceVariablesByFunction, owner, names[0]);
559
637
  }
560
- } else if (path === "React.useCallback" && names.length >= 1 && isEmptyDependencyTable(args[1])) {
638
+ } else if (path2 === "React.useCallback" && names.length >= 1 && isEmptyDependencyTable(args[1])) {
561
639
  stableVariables.add(names[0]);
562
640
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
563
- } else if (path === "React.useRef" && names.length >= 1) {
641
+ } else if (path2 === "React.useRef" && names.length >= 1) {
564
642
  stableVariables.add(names[0]);
565
643
  refVariables.add(names[0]);
566
644
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
567
645
  addScopedVariable(refVariablesByFunction, owner, names[0]);
568
646
  } else {
569
- const final = path.split(/[.:]/).at(-1) ?? "";
647
+ const final = path2.split(/[.:]/).at(-1) ?? "";
570
648
  if (/^use[A-Z0-9_]/.test(final)) {
571
649
  for (const name of names.slice(1)) {
572
650
  if (!STABLE_CUSTOM_HOOK_RETURN.test(name))
@@ -590,11 +668,11 @@ function buildReactModel(root) {
590
668
  if (!expression || expression.type !== "function_call")
591
669
  continue;
592
670
  const rawPath = expression.childForFieldName("name")?.text ?? "";
593
- const path = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
594
- if (path === "React.useState" && names.length >= 2) {
671
+ const path2 = resolvePath(rawPath, aliases, reactNamespaces, reactRobloxNamespaces);
672
+ if (path2 === "React.useState" && names.length >= 2) {
595
673
  stableVariables.add(names[1]);
596
674
  addScopedVariable(stableVariablesByFunction, owner, names[1]);
597
- } else if (path === "React.useBinding" && names.length >= 1) {
675
+ } else if (path2 === "React.useBinding" && names.length >= 1) {
598
676
  stableVariables.add(names[0]);
599
677
  addScopedVariable(stableVariablesByFunction, owner, names[0]);
600
678
  addScopedVariable(externalMutableVariablesByFunction, owner, names[0]);
@@ -654,14 +732,14 @@ function buildReactModel(root) {
654
732
 
655
733
  // src/config.ts
656
734
  import fs from "fs";
657
- import path from "path";
735
+ import path2 from "path";
658
736
  var CONFIG_NAME = "react-luau-doctor.config.json";
659
737
  var SEVERITIES = new Set(["off", "error", "warning", "suggestion"]);
660
738
  var SCOPES = new Set(["full", "files", "changed", "lines"]);
661
739
  var BLOCKING_LEVELS = new Set(["error", "warning", "none"]);
662
740
  var CATEGORIES = new Set(["Correctness", "Hooks", "Effects", "Performance", "Roblox", "Architecture"]);
663
741
  function loadConfigWithSource(root) {
664
- const candidate = path.join(root, CONFIG_NAME);
742
+ const candidate = path2.join(root, CONFIG_NAME);
665
743
  if (!fs.existsSync(candidate))
666
744
  return { config: {}, filename: null };
667
745
  const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
@@ -721,14 +799,14 @@ function effectiveSeverity(defaultSeverity, ruleId, config) {
721
799
  }
722
800
  function configPathForWrite(root) {
723
801
  const loaded = loadConfigWithSource(root);
724
- return loaded.filename ?? path.join(root, CONFIG_NAME);
802
+ return loaded.filename ?? path2.join(root, CONFIG_NAME);
725
803
  }
726
804
  function writeConfig(root, update) {
727
805
  const loaded = loadConfigWithSource(root);
728
806
  const next = update(structuredClone(loaded.config));
729
807
  validateConfig(next, loaded.filename ?? CONFIG_NAME);
730
808
  const filename = loaded.filename ?? configPathForWrite(root);
731
- fs.mkdirSync(path.dirname(filename), { recursive: true });
809
+ fs.mkdirSync(path2.dirname(filename), { recursive: true });
732
810
  fs.writeFileSync(filename, `${JSON.stringify(next, null, 2)}
733
811
  `);
734
812
  return filename;
@@ -808,7 +886,7 @@ function createInlineSuppressionChecker(source) {
808
886
 
809
887
  // src/parser.ts
810
888
  import fs2 from "fs";
811
- import path2 from "path";
889
+ import path3 from "path";
812
890
  import { Language, Parser } from "web-tree-sitter";
813
891
 
814
892
  // src/parser-compat.ts
@@ -947,6 +1025,23 @@ function isCode(mask, start, end = start + 1) {
947
1025
  }
948
1026
  return true;
949
1027
  }
1028
+ function matchingCodeDelimiterEnd(source, mask, start, opener, closer) {
1029
+ if (source[start] !== opener || !isCode(mask, start))
1030
+ return null;
1031
+ let depth = 0;
1032
+ for (let cursor = start;cursor < source.length; cursor += 1) {
1033
+ if (!isCode(mask, cursor))
1034
+ continue;
1035
+ if (source[cursor] === opener)
1036
+ depth += 1;
1037
+ else if (source[cursor] === closer) {
1038
+ depth -= 1;
1039
+ if (depth === 0)
1040
+ return cursor + 1;
1041
+ }
1042
+ }
1043
+ return null;
1044
+ }
950
1045
  function maskTypeAliases(source, mask) {
951
1046
  const { lines, offsets } = lineData(source);
952
1047
  const ranges = [];
@@ -1154,6 +1249,89 @@ function genericFunctionParameterRanges(source, mask) {
1154
1249
  }
1155
1250
  return ranges;
1156
1251
  }
1252
+ function genericFunctionReturnPrefixRanges(source, mask) {
1253
+ const ranges = [];
1254
+ const functionPattern = /\bfunction\b/g;
1255
+ for (const match of source.matchAll(functionPattern)) {
1256
+ const start = match.index;
1257
+ if (!isCode(mask, start, start + match[0].length))
1258
+ continue;
1259
+ let cursor = start + match[0].length;
1260
+ let genericDepth = 0;
1261
+ while (cursor < source.length) {
1262
+ if (!isCode(mask, cursor)) {
1263
+ cursor += 1;
1264
+ continue;
1265
+ }
1266
+ if (source[cursor] === "<")
1267
+ genericDepth += 1;
1268
+ else if (source[cursor] === ">" && genericDepth > 0)
1269
+ genericDepth -= 1;
1270
+ else if (source[cursor] === "(" && genericDepth === 0)
1271
+ break;
1272
+ cursor += 1;
1273
+ }
1274
+ if (source[cursor] !== "(")
1275
+ continue;
1276
+ const parameterEnd = matchingCodeDelimiterEnd(source, mask, cursor, "(", ")");
1277
+ if (parameterEnd === null)
1278
+ continue;
1279
+ cursor = parameterEnd;
1280
+ while (cursor < source.length && /\s/.test(source[cursor]))
1281
+ cursor += 1;
1282
+ if (source[cursor] !== ":" || !isCode(mask, cursor))
1283
+ continue;
1284
+ cursor += 1;
1285
+ while (cursor < source.length && /\s/.test(source[cursor]))
1286
+ cursor += 1;
1287
+ if (source[cursor] !== "<")
1288
+ continue;
1289
+ const rangeStart = cursor;
1290
+ const genericEnd = matchingCodeDelimiterEnd(source, mask, cursor, "<", ">");
1291
+ if (genericEnd === null)
1292
+ continue;
1293
+ cursor = genericEnd;
1294
+ while (cursor < source.length && /\s/.test(source[cursor]))
1295
+ cursor += 1;
1296
+ const typeParametersEnd = matchingCodeDelimiterEnd(source, mask, cursor, "(", ")");
1297
+ if (typeParametersEnd === null)
1298
+ continue;
1299
+ cursor = typeParametersEnd;
1300
+ while (cursor < source.length && /\s/.test(source[cursor]))
1301
+ cursor += 1;
1302
+ if (source.slice(cursor, cursor + 2) !== "->" || !isCode(mask, cursor, cursor + 2))
1303
+ continue;
1304
+ cursor += 2;
1305
+ while (cursor < source.length && /[ \t]/.test(source[cursor]))
1306
+ cursor += 1;
1307
+ ranges.push({ start: rangeStart, end: cursor });
1308
+ }
1309
+ return ranges;
1310
+ }
1311
+ function normalizeLocalTypeofAnnotations(source, mask) {
1312
+ const replacements = [];
1313
+ const pattern = /\blocal\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*typeof\s*\(/g;
1314
+ for (const match of source.matchAll(pattern)) {
1315
+ const matchStart = match.index;
1316
+ if (!isCode(mask, matchStart, matchStart + match[0].length))
1317
+ continue;
1318
+ const typeofStart = matchStart + match[0].lastIndexOf("typeof");
1319
+ const open = source.indexOf("(", typeofStart + 6);
1320
+ if (open < 0)
1321
+ continue;
1322
+ const end = matchingCodeDelimiterEnd(source, mask, open, "(", ")");
1323
+ if (end === null)
1324
+ continue;
1325
+ const original = source.slice(typeofStart, end);
1326
+ const padding = blankPreservingUtf8Bytes(original).slice(3);
1327
+ replacements.push({ start: typeofStart, end, value: `any${padding}` });
1328
+ }
1329
+ let output = source;
1330
+ for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
1331
+ output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
1332
+ }
1333
+ return output;
1334
+ }
1157
1335
  function normalizeGenericTypePackUses(source, mask) {
1158
1336
  const declarationRanges = genericFunctionParameterRanges(source, mask);
1159
1337
  const replacements = [];
@@ -1214,13 +1392,15 @@ function parserCompatibleSource(source) {
1214
1392
  const keywordCompatible = replaceContextualKeywords(source, mask);
1215
1393
  const literalCompatible = normalizeIntegerLiteralSuffixes(keywordCompatible, mask);
1216
1394
  const packCompatible = normalizeGenericTypePackUses(literalCompatible, mask);
1395
+ const typeofCompatible = normalizeLocalTypeofAnnotations(packCompatible, mask);
1217
1396
  const ranges = [
1218
1397
  ...maskTypeAliases(source, mask),
1219
1398
  ...maskTypeLevelBlocks(source, mask),
1220
1399
  ...attributeRanges(source, mask),
1221
- ...explicitTypeArgumentRanges(source, mask)
1400
+ ...explicitTypeArgumentRanges(source, mask),
1401
+ ...genericFunctionReturnPrefixRanges(source, mask)
1222
1402
  ];
1223
- const masked = applyMasks(packCompatible, ranges);
1403
+ const masked = applyMasks(typeofCompatible, ranges);
1224
1404
  return masked.replace(/^(\s*)type(?=\s*(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=(?!=)))/gm, "$1_typ");
1225
1405
  }
1226
1406
 
@@ -1228,7 +1408,7 @@ function parserCompatibleSource(source) {
1228
1408
  var parserPromise = null;
1229
1409
  async function createParser() {
1230
1410
  await Parser.init();
1231
- const grammarPath = path2.resolve(import.meta.dir, "../vendor/tree-sitter-luau.wasm");
1411
+ const grammarPath = path3.resolve(import.meta.dir, "../vendor/tree-sitter-luau.wasm");
1232
1412
  const grammar = new Uint8Array(fs2.readFileSync(grammarPath));
1233
1413
  const language = await Language.load(grammar);
1234
1414
  const parser = new Parser;
@@ -1353,8 +1533,8 @@ var INSTANCE_FACTORY_MEMBERS2 = new Set([
1353
1533
  "GetService",
1354
1534
  "WaitForChild"
1355
1535
  ]);
1356
- function finalCallMember2(path3) {
1357
- return path3.split(/[.:]/).at(-1) ?? path3;
1536
+ function finalCallMember2(path4) {
1537
+ return path4.split(/[.:]/).at(-1) ?? path4;
1358
1538
  }
1359
1539
  function declarationInitializer(node, name) {
1360
1540
  if (node.type !== "variable_declaration")
@@ -1422,8 +1602,8 @@ function expressionHasRobloxOrigin(expression, from, context, seen) {
1422
1602
  seen.add(root);
1423
1603
  return expressionHasRobloxOrigin(visibleInitializer(root, from, context), from, context, seen);
1424
1604
  }
1425
- function hasRobloxReceiver(path3, call, context) {
1426
- const normalized = path3.replace(/\s+/g, "");
1605
+ function hasRobloxReceiver(path4, call, context) {
1606
+ const normalized = path4.replace(/\s+/g, "");
1427
1607
  if (/^(?:game|workspace|script)(?:[.:]|$)/.test(normalized))
1428
1608
  return true;
1429
1609
  const root = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
@@ -1572,8 +1752,8 @@ function sourceHasHighFrequencyRobloxEvent(source) {
1572
1752
  }
1573
1753
  return false;
1574
1754
  }
1575
- function knownYieldReason(path3, call, context) {
1576
- const normalized = path3.replace(/\s+/g, "");
1755
+ function knownYieldReason(path4, call, context) {
1756
+ const normalized = path4.replace(/\s+/g, "");
1577
1757
  if (normalized === "task.wait")
1578
1758
  return "task.wait";
1579
1759
  if (normalized === "coroutine.yield")
@@ -1593,15 +1773,15 @@ function functionYieldPoint(node, context) {
1593
1773
  const owner = context.model.functionByNode.get(node.id);
1594
1774
  if (owner && nearest !== owner)
1595
1775
  continue;
1596
- const path3 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
1597
- const reason = knownYieldReason(path3, candidate, context);
1776
+ const path4 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
1777
+ const reason = knownYieldReason(path4, candidate, context);
1598
1778
  if (reason)
1599
1779
  return { call: candidate, reason };
1600
1780
  }
1601
1781
  return null;
1602
1782
  }
1603
- function mutableDependencyBase(path3, externallyMutableRoots) {
1604
- const normalized = path3.replace(/\s+/g, "");
1783
+ function mutableDependencyBase(path4, externallyMutableRoots) {
1784
+ const normalized = path4.replace(/\s+/g, "");
1605
1785
  if (/\.current(?:\.|$)/.test(normalized))
1606
1786
  return "";
1607
1787
  const segments = normalized.split(".");
@@ -1627,8 +1807,8 @@ var CONDITIONAL_TYPES = new Set([
1627
1807
  "while_statement",
1628
1808
  "repeat_statement"
1629
1809
  ]);
1630
- function isHookPath(path3) {
1631
- const final = path3.split(/[.:]/).at(-1) ?? "";
1810
+ function isHookPath(path4) {
1811
+ const final = path4.split(/[.:]/).at(-1) ?? "";
1632
1812
  return /^use[A-Z0-9_]/.test(final);
1633
1813
  }
1634
1814
  function assignmentTargetNode(statement) {
@@ -1704,8 +1884,8 @@ function functionInfoForNode(context, node) {
1704
1884
  function stateBindingsFor(context, owner) {
1705
1885
  return context.model.stateBindings.filter((binding) => binding.owner === owner);
1706
1886
  }
1707
- function isEffectPath(path3) {
1708
- return path3 === "React.useEffect" || path3 === "React.useLayoutEffect";
1887
+ function isEffectPath(path4) {
1888
+ return path4 === "React.useEffect" || path4 === "React.useLayoutEffect";
1709
1889
  }
1710
1890
  function dependencyExpressions(node) {
1711
1891
  if (!node || node.type !== "table_constructor")
@@ -1960,11 +2140,11 @@ function isManagedByCleanupContainer(call, context, callback) {
1960
2140
  let current = call.parent;
1961
2141
  while (current && !sameNode(current, callback.node)) {
1962
2142
  if (current.type === "function_call") {
1963
- const path3 = context.getCallPath(current) ?? "";
1964
- if (ALWAYS_MANAGED_METHOD.test(path3))
2143
+ const path4 = context.getCallPath(current) ?? "";
2144
+ if (ALWAYS_MANAGED_METHOD.test(path4))
1965
2145
  return true;
1966
- if (GENERIC_MANAGER_METHOD.test(path3)) {
1967
- const receiver = path3.split(":", 1)[0] ?? "";
2146
+ if (GENERIC_MANAGER_METHOD.test(path4)) {
2147
+ const receiver = path4.split(":", 1)[0] ?? "";
1968
2148
  if (MANAGER_ROOT.test(receiver))
1969
2149
  return true;
1970
2150
  }
@@ -2069,10 +2249,10 @@ function isCleanupOnlyTask(callbackNode, context) {
2069
2249
  return false;
2070
2250
  return calls.every((call) => /:(?:Destroy|Disconnect)$/.test(context.getCallPath(call) ?? ""));
2071
2251
  }
2072
- function taskSeverity(path3, call, taskCallback, yieldPoint, context) {
2073
- if (path3 === "task.defer" && !yieldPoint)
2252
+ function taskSeverity(path4, call, taskCallback, yieldPoint, context) {
2253
+ if (path4 === "task.defer" && !yieldPoint)
2074
2254
  return "suggestion";
2075
- if (path3 === "task.delay" && !yieldPoint) {
2255
+ if (path4 === "task.delay" && !yieldPoint) {
2076
2256
  if (isCleanupOnlyTask(taskCallback, context))
2077
2257
  return "suggestion";
2078
2258
  const delay = numericTaskDelay(call, context);
@@ -2122,10 +2302,10 @@ end`,
2122
2302
  };
2123
2303
  }
2124
2304
  if ([...labels].every((label) => label.includes("task"))) {
2125
- const path3 = context.resolveCallPath(context.getCallPath(first.call) ?? "task.delay");
2126
- const taskCall = path3 === "task.defer" ? `task.defer(function()
2305
+ const path4 = context.resolveCallPath(context.getCallPath(first.call) ?? "task.delay");
2306
+ const taskCall = path4 === "task.defer" ? `task.defer(function()
2127
2307
  doWork()
2128
- end)` : path3 === "task.spawn" ? `task.spawn(function()
2308
+ end)` : path4 === "task.spawn" ? `task.spawn(function()
2129
2309
  doWork()
2130
2310
  end)` : `task.delay(delaySeconds, function()
2131
2311
  doWork()
@@ -2178,9 +2358,9 @@ end`,
2178
2358
  }
2179
2359
  function classifyResource(call, context, callback) {
2180
2360
  const rawPath = context.getCallPath(call) ?? "";
2181
- const path3 = context.resolveCallPath(rawPath);
2361
+ const path4 = context.resolveCallPath(rawPath);
2182
2362
  const variable = assignedVariableName(call, callback);
2183
- const finalMember = path3.split(/[.:]/).at(-1) ?? path3;
2363
+ const finalMember = path4.split(/[.:]/).at(-1) ?? path4;
2184
2364
  if (/:Connect$/.test(rawPath)) {
2185
2365
  const ownerExpression = connectionOwnerExpression(rawPath);
2186
2366
  const ownerIsInstance = isProvenInstanceExpression(ownerExpression, call, context, callback);
@@ -2249,7 +2429,7 @@ function classifyResource(call, context, callback) {
2249
2429
  }
2250
2430
  };
2251
2431
  }
2252
- if (path3 === "ReactRoblox.createRoot") {
2432
+ if (path4 === "ReactRoblox.createRoot") {
2253
2433
  return {
2254
2434
  call,
2255
2435
  label: "React root",
@@ -2259,17 +2439,17 @@ function classifyResource(call, context, callback) {
2259
2439
  }
2260
2440
  };
2261
2441
  }
2262
- if (path3 === "task.delay" || path3 === "task.defer" || path3 === "task.spawn") {
2442
+ if (path4 === "task.delay" || path4 === "task.defer" || path4 === "task.spawn") {
2263
2443
  const taskCallback = taskCallbackNode(call, context, callback);
2264
2444
  const yieldPoint = taskCallback ? functionYieldPoint(taskCallback, context) : null;
2265
- const outlivesEffect = path3 !== "task.spawn" || Boolean(yieldPoint);
2445
+ const outlivesEffect = path4 !== "task.spawn" || Boolean(yieldPoint);
2266
2446
  if (!outlivesEffect)
2267
2447
  return null;
2268
2448
  return {
2269
2449
  call,
2270
- label: path3 === "task.spawn" ? "yielding spawned task" : path3 === "task.defer" ? "deferred task" : "delayed task",
2450
+ label: path4 === "task.spawn" ? "yielding spawned task" : path4 === "task.defer" ? "deferred task" : "delayed task",
2271
2451
  detail: yieldPoint ? `The task can outlive the effect because it yields at ${yieldPoint.reason}.` : "The task is scheduled to run after the effect callback can finish.",
2272
- severity: taskSeverity(path3, call, taskCallback, yieldPoint, context),
2452
+ severity: taskSeverity(path4, call, taskCallback, yieldPoint, context),
2273
2453
  variable,
2274
2454
  cleanupMatches(text, name) {
2275
2455
  if (name) {
@@ -2296,8 +2476,8 @@ var effectNeedsCleanup = {
2296
2476
  const rawPath = context.getCallPath(call);
2297
2477
  if (!rawPath)
2298
2478
  continue;
2299
- const path3 = context.resolveCallPath(rawPath);
2300
- if (path3 !== "React.useEffect" && path3 !== "React.useLayoutEffect")
2479
+ const path4 = context.resolveCallPath(rawPath);
2480
+ if (path4 !== "React.useEffect" && path4 !== "React.useLayoutEffect")
2301
2481
  continue;
2302
2482
  const callbackNode = firstFunctionArgument(context, call);
2303
2483
  const callback = functionInfoForNode(context, callbackNode);
@@ -2351,10 +2531,22 @@ function ownerForHook(context, call) {
2351
2531
  const owner = context.nearestFunction(call);
2352
2532
  return owner && (owner.isComponent || owner.isHook) ? owner : null;
2353
2533
  }
2534
+ function effectCallbackWithoutDeps(context, call) {
2535
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2536
+ if (!isEffectPath(path4))
2537
+ return null;
2538
+ const args = context.callArguments(call);
2539
+ const callback = args[0];
2540
+ if (callback?.type !== "function_definition")
2541
+ return null;
2542
+ if (args.length < 2 || args[1]?.type === "nil")
2543
+ return callback;
2544
+ return null;
2545
+ }
2354
2546
  function callbackAndDeps(context, call) {
2355
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2547
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2356
2548
  const args = context.callArguments(call);
2357
- if (path3 === "React.useImperativeHandle") {
2549
+ if (path4 === "React.useImperativeHandle") {
2358
2550
  const callback2 = args[1];
2359
2551
  const deps2 = args[2];
2360
2552
  if (callback2?.type === "function_definition" && deps2?.type === "table_constructor")
@@ -2384,8 +2576,8 @@ function hasExternalSubscription(node, context) {
2384
2576
  for (const child of context.walk(node)) {
2385
2577
  if (child.type !== "function_call")
2386
2578
  continue;
2387
- const path3 = context.getCallPath(child) ?? "";
2388
- if (/:Connect$/.test(path3) || /BindToRenderStep$/.test(path3) || /:BindAction(?:AtPriority)?$/.test(path3))
2579
+ const path4 = context.getCallPath(child) ?? "";
2580
+ if (/:Connect$/.test(path4) || /BindToRenderStep$/.test(path4) || /:BindAction(?:AtPriority)?$/.test(path4))
2389
2581
  return true;
2390
2582
  }
2391
2583
  return false;
@@ -2419,6 +2611,33 @@ function setterCalledOutsideCallback(context, owner, callback, setterName) {
2419
2611
  }
2420
2612
  return false;
2421
2613
  }
2614
+ function guaranteedRepeatedStateChange(argument, stateName, context) {
2615
+ if (argument.type === "table_constructor")
2616
+ return true;
2617
+ if (argument.type === "function_definition") {
2618
+ const updater = functionInfoForNode(context, argument);
2619
+ const previousName = updater?.parameters[0];
2620
+ const returnStatement = updater?.body?.namedChildren.find((child) => child.type === "return_statement");
2621
+ const returnValue = returnStatement?.namedChildren[0];
2622
+ if (previousName && returnValue && guaranteedRepeatedStateChange(returnValue, previousName, context))
2623
+ return true;
2624
+ return false;
2625
+ }
2626
+ const text = argument.text.trim();
2627
+ const escaped = stateName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2628
+ if (new RegExp(`^not\\s+${escaped}$`).test(text))
2629
+ return true;
2630
+ if (new RegExp(`^${escaped}\\s*[+-]\\s*(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)$`).test(text))
2631
+ return true;
2632
+ if (new RegExp(`^(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)\\s*\\+\\s*${escaped}$`).test(text))
2633
+ return true;
2634
+ if (argument.type === "function_call") {
2635
+ const path4 = context.resolveCallPath(context.getCallPath(argument) ?? "");
2636
+ if (path4 === "table.clone" || path4 === "table.create" || path4 === "table.pack")
2637
+ return true;
2638
+ }
2639
+ return false;
2640
+ }
2422
2641
  function freshDependencyKind(node, owner) {
2423
2642
  if (node.type === "table_constructor")
2424
2643
  return "table";
@@ -2441,8 +2660,8 @@ var noDerivedStateEffect = {
2441
2660
  run(context) {
2442
2661
  const diagnostics = [];
2443
2662
  for (const call of context.findCalls()) {
2444
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2445
- if (!isEffectPath(path3))
2663
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2664
+ if (!isEffectPath(path4))
2446
2665
  continue;
2447
2666
  const owner = ownerForHook(context, call);
2448
2667
  const parts = callbackAndDeps(context, call);
@@ -2480,28 +2699,48 @@ var noSelfUpdatingEffect = {
2480
2699
  id: "react-luau/no-self-updating-effect",
2481
2700
  category: "Effects",
2482
2701
  severity: "warning",
2483
- description: "Effects should not unconditionally update state that is also one of their dependencies.",
2702
+ description: "Effects should not unconditionally update state in a way that schedules themselves again.",
2484
2703
  run(context) {
2485
2704
  const diagnostics = [];
2486
2705
  for (const call of context.findCalls()) {
2487
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2488
- if (!isEffectPath(path3))
2706
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2707
+ if (!isEffectPath(path4))
2489
2708
  continue;
2490
2709
  const owner = ownerForHook(context, call);
2710
+ if (!owner)
2711
+ continue;
2491
2712
  const parts = callbackAndDeps(context, call);
2492
- if (!owner || !parts)
2713
+ if (parts) {
2714
+ const deps = parseDependencyRoots(parts.deps);
2715
+ for (const binding of stateBindingsFor(context, owner)) {
2716
+ if (!deps.has(binding.valueName))
2717
+ continue;
2718
+ for (const setterCall of directTopLevelCalls(context, parts.callback)) {
2719
+ if (context.getCallPath(setterCall) !== binding.setterName)
2720
+ continue;
2721
+ diagnostics.push({
2722
+ node: callNameNode(setterCall),
2723
+ message: `${binding.setterName}() updates ${binding.valueName}, which is also in this effect's dependency table.`,
2724
+ help: "Guard the update so it converges, move it to the originating event, or derive the value during render."
2725
+ });
2726
+ }
2727
+ }
2728
+ continue;
2729
+ }
2730
+ const callback = effectCallbackWithoutDeps(context, call);
2731
+ if (!callback)
2493
2732
  continue;
2494
- const deps = parseDependencyRoots(parts.deps);
2495
2733
  for (const binding of stateBindingsFor(context, owner)) {
2496
- if (!deps.has(binding.valueName))
2497
- continue;
2498
- for (const setterCall of directTopLevelCalls(context, parts.callback)) {
2734
+ for (const setterCall of directTopLevelCalls(context, callback)) {
2499
2735
  if (context.getCallPath(setterCall) !== binding.setterName)
2500
2736
  continue;
2737
+ const argument = context.callArguments(setterCall)[0];
2738
+ if (!argument || !guaranteedRepeatedStateChange(argument, binding.valueName, context))
2739
+ continue;
2501
2740
  diagnostics.push({
2502
2741
  node: callNameNode(setterCall),
2503
- message: `${binding.setterName}() updates ${binding.valueName}, which is also in this effect's dependency table.`,
2504
- help: "Guard the update so it converges, move it to the originating event, or derive the value during render."
2742
+ message: `${binding.setterName}() unconditionally changes ${binding.valueName} in an effect that runs after every render.`,
2743
+ help: "Add a dependency table or convergence guard, move the update to the event that owns it, or derive the value during render."
2505
2744
  });
2506
2745
  }
2507
2746
  }
@@ -2517,8 +2756,8 @@ var noEffectWithFreshDeps = {
2517
2756
  run(context) {
2518
2757
  const diagnostics = [];
2519
2758
  for (const call of context.findCalls()) {
2520
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2521
- if (!HOOKS_WITH_DEPS.has(path3))
2759
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2760
+ if (!HOOKS_WITH_DEPS.has(path4))
2522
2761
  continue;
2523
2762
  const owner = ownerForHook(context, call);
2524
2763
  const parts = callbackAndDeps(context, call);
@@ -2531,7 +2770,7 @@ var noEffectWithFreshDeps = {
2531
2770
  const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
2532
2771
  diagnostics.push({
2533
2772
  node: dependency,
2534
- message: `${path3} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
2773
+ message: `${path4} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
2535
2774
  help: "Move the value inside the hook callback and depend on its simple inputs, or intentionally stabilize the value with useMemo/useCallback when identity is part of the contract."
2536
2775
  });
2537
2776
  }
@@ -2547,8 +2786,8 @@ var noMutableInDeps = {
2547
2786
  run(context) {
2548
2787
  const diagnostics = [];
2549
2788
  for (const call of context.findCalls()) {
2550
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
2551
- if (!HOOKS_WITH_DEPS.has(path3))
2789
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
2790
+ if (!HOOKS_WITH_DEPS.has(path4))
2552
2791
  continue;
2553
2792
  const owner = ownerForHook(context, call);
2554
2793
  const parts = callbackAndDeps(context, call);
@@ -2641,23 +2880,61 @@ var HOOK_ARGUMENTS = new Map([
2641
2880
  ["React.useImperativeHandle", { callbackIndex: 1, depsIndex: 2 }]
2642
2881
  ]);
2643
2882
  function parameterNames2(functionNode) {
2644
- const names = new Set;
2645
- const parameters = functionNode.childForFieldName("parameters");
2646
- if (!parameters)
2647
- return names;
2648
- for (const node of parameters.namedChildren) {
2649
- for (const candidate of descendants(node)) {
2650
- if (candidate.type === "identifier")
2651
- names.add(candidate.text);
2652
- }
2653
- }
2654
- return names;
2883
+ return new Set(parameterBindingNames(functionNode.childForFieldName("parameters")));
2655
2884
  }
2656
2885
  function* descendants(node) {
2657
2886
  yield node;
2658
2887
  for (const child of node.namedChildren)
2659
2888
  yield* descendants(child);
2660
2889
  }
2890
+ var TYPE_ONLY_NODES = new Set([
2891
+ "type_definition",
2892
+ "type_annotation",
2893
+ "type_parameters",
2894
+ "generic_type_list",
2895
+ "generic_type",
2896
+ "function_type",
2897
+ "optional_type",
2898
+ "object_type",
2899
+ "union_type",
2900
+ "intersection_type",
2901
+ "tuple_type",
2902
+ "singleton_type",
2903
+ "variadic_type",
2904
+ "variadic_type_pack"
2905
+ ]);
2906
+ function isTypeOnlyChild(parent, child) {
2907
+ if (TYPE_ONLY_NODES.has(child.type))
2908
+ return true;
2909
+ if (TYPE_ONLY_NODES.has(parent.type))
2910
+ return true;
2911
+ if (parent.type === "parameter") {
2912
+ return !sameNode(parent.namedChildren.find((candidate) => candidate.type === "identifier"), child);
2913
+ }
2914
+ if (parent.type === "cast_expression" || parent.type === "type_cast_expression") {
2915
+ return !sameNode(parent.namedChildren[0], child);
2916
+ }
2917
+ if (parent.type === "function_definition" || parent.type === "function_declaration") {
2918
+ const name = parent.childForFieldName("name");
2919
+ const parameters = parent.childForFieldName("parameters");
2920
+ const body = parent.childForFieldName("body");
2921
+ if (sameNode(child, name) || sameNode(child, parameters) || sameNode(child, body))
2922
+ return false;
2923
+ return true;
2924
+ }
2925
+ if (parent.type === "variable_list" && parent.parent?.parent?.type === "variable_declaration") {
2926
+ return true;
2927
+ }
2928
+ return false;
2929
+ }
2930
+ function* runtimeDescendants(node) {
2931
+ yield node;
2932
+ for (const child of node.namedChildren) {
2933
+ if (isTypeOnlyChild(node, child))
2934
+ continue;
2935
+ yield* runtimeDescendants(child);
2936
+ }
2937
+ }
2661
2938
  function directBlockBindingNames(block, beforeIndex) {
2662
2939
  const names = new Set;
2663
2940
  for (const child of block.namedChildren) {
@@ -2733,8 +3010,8 @@ function rootIdentifierNode(node, root) {
2733
3010
  }
2734
3011
  return null;
2735
3012
  }
2736
- function normalizeCapturedPath(path3, stableVariables, externallyMutableRoots) {
2737
- const normalized = normalizeExpressionText(path3);
3013
+ function normalizeCapturedPath(path4, stableVariables, externallyMutableRoots) {
3014
+ const normalized = normalizeExpressionText(path4);
2738
3015
  const root = rootIdentifier(normalized);
2739
3016
  if (!root)
2740
3017
  return normalized;
@@ -2751,7 +3028,7 @@ function normalizeCapturedPath(path3, stableVariables, externallyMutableRoots) {
2751
3028
  }
2752
3029
  function capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots) {
2753
3030
  const captured = new Set;
2754
- for (const node of descendants(callback)) {
3031
+ for (const node of runtimeDescendants(callback)) {
2755
3032
  if (node.type === "dot_index_expression" && outermostDotPath(node)) {
2756
3033
  const root = rootIdentifier(node.text);
2757
3034
  if (!root || !available.has(root) || GLOBALS.has(root) || stableVariables.has(root))
@@ -2820,8 +3097,81 @@ function expandLocalFunctionCaptures(captured, owner, available, stableVariables
2820
3097
  addCapture(capture);
2821
3098
  return result;
2822
3099
  }
3100
+ function dependencyCoveragePath(node) {
3101
+ if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
3102
+ const expression = node.namedChildren[0];
3103
+ if (expression)
3104
+ return dependencyCoveragePath(expression);
3105
+ }
3106
+ if (node.type === "binary_expression") {
3107
+ const left = node.childForFieldName("left") ?? node.namedChildren[0];
3108
+ const right = node.childForFieldName("right") ?? node.namedChildren[1];
3109
+ const isOr = node.children.some((child) => child.type === "or");
3110
+ if (isOr && left && right?.type === "false")
3111
+ return dependencyCoveragePath(left);
3112
+ }
3113
+ return normalizeExpressionText(node.text);
3114
+ }
2823
3115
  function dependencyPaths(node) {
2824
- return new Set(dependencyExpressions(node).map((dependency) => normalizeExpressionText(dependency.text)));
3116
+ return new Set(dependencyExpressions(node).map(dependencyCoveragePath));
3117
+ }
3118
+ function transparentAliasPath(node) {
3119
+ if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
3120
+ const expression = node.namedChildren[0];
3121
+ return expression ? transparentAliasPath(expression) : null;
3122
+ }
3123
+ if (node.type !== "identifier" && node.type !== "dot_index_expression")
3124
+ return null;
3125
+ return normalizeExpressionText(node.text);
3126
+ }
3127
+ function immutableTransparentAliases(owner, context) {
3128
+ const aliases = new Map;
3129
+ if (!owner.body)
3130
+ return aliases;
3131
+ const mutated = mutatedOwnerLocals(owner, context);
3132
+ for (const statement of owner.body.namedChildren) {
3133
+ if (statement.type !== "variable_declaration")
3134
+ continue;
3135
+ const names = declarationNames2(statement);
3136
+ const expressions = declarationExpressions3(statement);
3137
+ for (let index = 0;index < names.length; index += 1) {
3138
+ const name = names[index];
3139
+ const expression = expressions[index];
3140
+ if (!expression || mutated.has(name))
3141
+ continue;
3142
+ const source = transparentAliasPath(expression);
3143
+ if (!source || source === name)
3144
+ continue;
3145
+ const sourceRoot = rootIdentifier(source);
3146
+ if (!sourceRoot || mutated.has(sourceRoot))
3147
+ continue;
3148
+ aliases.set(name, source);
3149
+ }
3150
+ }
3151
+ return aliases;
3152
+ }
3153
+ function resolveAliasPath(path4, aliases) {
3154
+ let current = path4;
3155
+ const visiting = new Set;
3156
+ while (true) {
3157
+ const root = rootIdentifier(current);
3158
+ if (!root || visiting.has(root))
3159
+ return current;
3160
+ const source = aliases.get(root);
3161
+ if (!source)
3162
+ return current;
3163
+ visiting.add(root);
3164
+ current = `${source}${current.slice(root.length)}`;
3165
+ }
3166
+ }
3167
+ function dependencyPathsWithAliases(node, aliases) {
3168
+ const dependencies = dependencyPaths(node);
3169
+ for (const dependency of [...dependencies]) {
3170
+ const resolved = resolveAliasPath(dependency, aliases);
3171
+ if (resolved !== dependency)
3172
+ dependencies.add(resolved);
3173
+ }
3174
+ return dependencies;
2825
3175
  }
2826
3176
  var PURE_DERIVED_CALL = /^(?:math\.(?!random(?:seed)?$)[A-Za-z_][A-Za-z0-9_]*|string\.[A-Za-z_][A-Za-z0-9_]*|table\.(?:find|concat|clone|create|isfrozen)|(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|BrickColor|Rect|NumberRange|NumberSequence|ColorSequence|TweenInfo|Font|Ray|Region3|PhysicalProperties)\.[A-Za-z_][A-Za-z0-9_]*|[^:]+:(?:ToHSV|Lerp|Dot|Cross|FuzzyEq|Inverse|ToObjectSpace|ToWorldSpace))$/;
2827
3177
  function isPureDerivedExpression(node, context) {
@@ -2833,8 +3183,8 @@ function isPureDerivedExpression(node, context) {
2833
3183
  const rawPath = context.getCallPath(candidate);
2834
3184
  if (!rawPath)
2835
3185
  return false;
2836
- const path3 = normalizeExpressionText(context.resolveCallPath(rawPath));
2837
- if (!PURE_DERIVED_CALL.test(path3))
3186
+ const path4 = normalizeExpressionText(context.resolveCallPath(rawPath));
3187
+ if (!PURE_DERIVED_CALL.test(path4))
2838
3188
  return false;
2839
3189
  }
2840
3190
  return true;
@@ -2913,8 +3263,8 @@ function hasDynamicUnpackDependencies(node, context) {
2913
3263
  const rawPath = context.getCallPath(candidate);
2914
3264
  if (!rawPath)
2915
3265
  continue;
2916
- const path3 = normalizeExpressionText(context.resolveCallPath(rawPath));
2917
- if (path3 === "unpack" || path3 === "table.unpack")
3266
+ const path4 = normalizeExpressionText(context.resolveCallPath(rawPath));
3267
+ if (path4 === "unpack" || path4 === "table.unpack")
2918
3268
  return true;
2919
3269
  }
2920
3270
  }
@@ -2939,8 +3289,8 @@ function memoizedProducerDependencies(owner, context) {
2939
3289
  const rawPath = context.getCallPath(expression);
2940
3290
  if (!rawPath)
2941
3291
  continue;
2942
- const path3 = context.resolveCallPath(rawPath);
2943
- if (path3 !== "React.useMemo" && path3 !== "React.useCallback")
3292
+ const path4 = context.resolveCallPath(rawPath);
3293
+ if (path4 !== "React.useMemo" && path4 !== "React.useCallback")
2944
3294
  continue;
2945
3295
  const args = context.callArguments(expression);
2946
3296
  const deps = args[1];
@@ -2999,9 +3349,9 @@ function directCallsInExpression(node) {
2999
3349
  return calls;
3000
3350
  }
3001
3351
  function isCustomHookCall(call, context) {
3002
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
3003
- const final = path3.split(/[.:]/).at(-1) ?? "";
3004
- return !path3.startsWith("React.") && /^use[A-Z0-9_]/.test(final);
3352
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3353
+ const final = path4.split(/[.:]/).at(-1) ?? "";
3354
+ return !path4.startsWith("React.") && /^use[A-Z0-9_]/.test(final);
3005
3355
  }
3006
3356
  var HANDLE_VALUE_NAME = /(?:ref|binding|tween|spring|motion|animation|controller|handle|connection|signal)$/i;
3007
3357
  var HANDLE_HOOK_NAME = /(?:Tween|Spring|Binding|Motion|Animated|Animation|Sound)$/i;
@@ -3011,8 +3361,8 @@ function isCalledAsFunction(owner, name, context) {
3011
3361
  for (const call of context.walk(owner.body)) {
3012
3362
  if (call.type !== "function_call")
3013
3363
  continue;
3014
- const path3 = normalizeExpressionText(context.getCallPath(call) ?? "");
3015
- if (path3 === name)
3364
+ const path4 = normalizeExpressionText(context.getCallPath(call) ?? "");
3365
+ if (path4 === name)
3016
3366
  return true;
3017
3367
  }
3018
3368
  return false;
@@ -3042,8 +3392,8 @@ function customHookDependencyHints(owner, context) {
3042
3392
  continue;
3043
3393
  const directHook = calls.find((call) => isCustomHookCall(call, context));
3044
3394
  if (directHook && expression.type === "function_call") {
3045
- const path3 = context.resolveCallPath(context.getCallPath(directHook) ?? "");
3046
- const final = path3.split(/[.:]/).at(-1) ?? "";
3395
+ const path4 = context.resolveCallPath(context.getCallPath(directHook) ?? "");
3396
+ const final = path4.split(/[.:]/).at(-1) ?? "";
3047
3397
  const likelyHandle = HANDLE_VALUE_NAME.test(name) || HANDLE_HOOK_NAME.test(final) || isCalledAsFunction(owner, name, context);
3048
3398
  if (likelyHandle) {
3049
3399
  hints.memberHandleRoots.add(name);
@@ -3053,14 +3403,14 @@ function customHookDependencyHints(owner, context) {
3053
3403
  }
3054
3404
  if (expression.type === "table_constructor") {
3055
3405
  const meaningfulCalls = calls.filter((call) => {
3056
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
3057
- return !/^(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|TweenInfo|NumberSequence|ColorSequence)\./.test(path3);
3406
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3407
+ return !/^(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|TweenInfo|NumberSequence|ColorSequence)\./.test(path4);
3058
3408
  });
3059
3409
  const allHandleHooks = meaningfulCalls.length > 0 && meaningfulCalls.every((call) => {
3060
3410
  if (!isCustomHookCall(call, context))
3061
3411
  return false;
3062
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
3063
- const final = path3.split(/[.:]/).at(-1) ?? "";
3412
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3413
+ const final = path4.split(/[.:]/).at(-1) ?? "";
3064
3414
  return HANDLE_HOOK_NAME.test(final);
3065
3415
  });
3066
3416
  if (allHandleHooks)
@@ -3090,12 +3440,13 @@ var exhaustiveDeps = {
3090
3440
  const derivedDependenciesByOwner = new Map;
3091
3441
  const dependencyHintsByOwner = new Map;
3092
3442
  const memoizedProducersByOwner = new Map;
3443
+ const transparentAliasesByOwner = new Map;
3093
3444
  for (const call of context.findCalls()) {
3094
3445
  const rawPath = context.getCallPath(call);
3095
3446
  if (!rawPath)
3096
3447
  continue;
3097
- const path3 = context.resolveCallPath(rawPath);
3098
- const shape = HOOK_ARGUMENTS.get(path3);
3448
+ const path4 = context.resolveCallPath(rawPath);
3449
+ const shape = HOOK_ARGUMENTS.get(path4);
3099
3450
  if (!shape)
3100
3451
  continue;
3101
3452
  const args = context.callArguments(call);
@@ -3111,7 +3462,12 @@ var exhaustiveDeps = {
3111
3462
  const stableVariables = context.model.stableVariablesByFunction.get(ownerKey) ?? new Set;
3112
3463
  const externallyMutableRoots = context.model.externalMutableVariablesByFunction.get(ownerKey) ?? new Set;
3113
3464
  const functionExpandedCaptures = expandLocalFunctionCaptures(capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots), owner, available, stableVariables, externallyMutableRoots, context);
3114
- const dependencies = dependencyPaths(deps);
3465
+ let transparentAliases = transparentAliasesByOwner.get(ownerKey);
3466
+ if (!transparentAliases) {
3467
+ transparentAliases = immutableTransparentAliases(owner, context);
3468
+ transparentAliasesByOwner.set(ownerKey, transparentAliases);
3469
+ }
3470
+ const dependencies = dependencyPathsWithAliases(deps, transparentAliases);
3115
3471
  let derivedDependencies = derivedDependenciesByOwner.get(ownerKey);
3116
3472
  if (!derivedDependencies) {
3117
3473
  derivedDependencies = derivedLocalDependencies(owner, available, stableVariables, externallyMutableRoots, context);
@@ -3125,7 +3481,7 @@ var exhaustiveDeps = {
3125
3481
  diagnostics.push({
3126
3482
  node: tableStartNode(deps),
3127
3483
  severity: "suggestion",
3128
- message: `${path3} uses dynamic unpack(...) dependencies, so exhaustive dependency analysis is partial.`,
3484
+ message: `${path4} uses dynamic unpack(...) dependencies, so exhaustive dependency analysis is partial.`,
3129
3485
  help: "The analyzer cannot prove which values unpack(...) contributes. Review the explicit dependencies and the custom hook contract."
3130
3486
  });
3131
3487
  continue;
@@ -3146,7 +3502,7 @@ var exhaustiveDeps = {
3146
3502
  if (reactiveMissing.length > 0) {
3147
3503
  diagnostics.push({
3148
3504
  node: tableStartNode(deps),
3149
- message: `${path3} dependency table is missing ${reactiveMissing.join(", ")}.`,
3505
+ message: `${path4} dependency table is missing ${reactiveMissing.join(", ")}.`,
3150
3506
  help: `Add ${reactiveMissing.join(", ")} to the dependency table, or stop capturing ${reactiveMissing.length === 1 ? "that value" : "those values"} in the callback.`
3151
3507
  });
3152
3508
  }
@@ -3154,7 +3510,7 @@ var exhaustiveDeps = {
3154
3510
  diagnostics.push({
3155
3511
  node: tableStartNode(deps),
3156
3512
  severity: "suggestion",
3157
- message: `${path3} captures memoized value ${transitivelyCoveredMemoized.join(", ")} without listing ${transitivelyCoveredMemoized.length === 1 ? "it directly" : "them directly"}, but ${transitivelyCoveredMemoized.length === 1 ? "its memo dependencies are" : "their memo dependencies are"} already covered.`,
3513
+ message: `${path4} captures memoized value ${transitivelyCoveredMemoized.join(", ")} without listing ${transitivelyCoveredMemoized.length === 1 ? "it directly" : "them directly"}, but ${transitivelyCoveredMemoized.length === 1 ? "its memo dependencies are" : "their memo dependencies are"} already covered.`,
3158
3514
  help: "The producing memo dependencies are already covered. Listing the memoized value directly can make the dependency relationship clearer."
3159
3515
  });
3160
3516
  }
@@ -3162,7 +3518,7 @@ var exhaustiveDeps = {
3162
3518
  diagnostics.push({
3163
3519
  node: tableStartNode(deps),
3164
3520
  severity: "suggestion",
3165
- message: `${path3} captures custom-hook handle ${uncertainHandles.join(", ")} without listing ${uncertainHandles.length === 1 ? "it as a dependency" : "them as dependencies"}.`,
3521
+ message: `${path4} captures custom-hook handle ${uncertainHandles.join(", ")} without listing ${uncertainHandles.length === 1 ? "it as a dependency" : "them as dependencies"}.`,
3166
3522
  help: "The analyzer cannot prove this custom-hook return is stable. Check the hook contract before changing the dependency table."
3167
3523
  });
3168
3524
  }
@@ -3196,8 +3552,8 @@ function createElementCallsInLoop(context, loop) {
3196
3552
  for (const node of context.walk(loop)) {
3197
3553
  if (node.type !== "function_call")
3198
3554
  continue;
3199
- const path3 = context.resolveCallPath(context.getCallPath(node) ?? "");
3200
- if (path3 === "React.createElement")
3555
+ const path4 = context.resolveCallPath(context.getCallPath(node) ?? "");
3556
+ if (path4 === "React.createElement")
3201
3557
  result.push(node);
3202
3558
  }
3203
3559
  return result;
@@ -3253,8 +3609,8 @@ var noCreateContextInRender = {
3253
3609
  run(context) {
3254
3610
  const diagnostics = [];
3255
3611
  for (const call of context.findCalls()) {
3256
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
3257
- if (path3 !== "React.createContext")
3612
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3613
+ if (path4 !== "React.createContext")
3258
3614
  continue;
3259
3615
  const owner = context.nearestFunction(call);
3260
3616
  if (!owner || !owner.isComponent && !owner.isHook)
@@ -3290,8 +3646,8 @@ function isRenderedBy(context, component, name) {
3290
3646
  continue;
3291
3647
  if (context.nearestFunction(call) !== component)
3292
3648
  continue;
3293
- const path3 = context.resolveCallPath(context.getCallPath(call) ?? "");
3294
- if (path3 !== "React.createElement")
3649
+ const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
3650
+ if (path4 !== "React.createElement")
3295
3651
  continue;
3296
3652
  if (context.callArguments(call)[0]?.text.trim() === name)
3297
3653
  return true;
@@ -3335,63 +3691,6 @@ var noNestedComponentDefinition = {
3335
3691
  }
3336
3692
  };
3337
3693
 
3338
- // src/module-resolution.ts
3339
- import path3 from "path";
3340
- function normalizeRelative(value) {
3341
- return value.split(path3.sep).join("/");
3342
- }
3343
- function moduleKeys(relativePath) {
3344
- let normalized = normalizeRelative(relativePath).replace(/\.(?:lua|luau)$/i, "");
3345
- if (normalized.endsWith("/init"))
3346
- normalized = normalized.slice(0, -"/init".length);
3347
- const segments = normalized.split("/").filter(Boolean);
3348
- if (segments[0]?.toLowerCase() === "src")
3349
- segments.shift();
3350
- const keys = new Set;
3351
- for (let index = 0;index < segments.length; index += 1) {
3352
- const suffix = segments.slice(index).join(".").toLowerCase();
3353
- if (suffix)
3354
- keys.add(suffix);
3355
- }
3356
- return [...keys];
3357
- }
3358
- function normalizeRequireTarget(text) {
3359
- return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
3360
- }
3361
- function buildUniqueFeatureAliases(modules, featureByModuleId) {
3362
- const owners = new Map;
3363
- for (const module of modules) {
3364
- for (const key of module.keys) {
3365
- const ids = owners.get(key) ?? new Set;
3366
- ids.add(module.id);
3367
- owners.set(key, ids);
3368
- }
3369
- }
3370
- const aliases = new Map;
3371
- for (const module of modules) {
3372
- const value = featureByModuleId.get(module.id);
3373
- if (value === undefined)
3374
- continue;
3375
- for (const key of module.keys) {
3376
- const ids = owners.get(key);
3377
- if (ids?.size === 1 && ids.has(module.id))
3378
- aliases.set(key, value);
3379
- }
3380
- }
3381
- return aliases;
3382
- }
3383
- function resolveModuleReference(target, aliases) {
3384
- let candidate = target;
3385
- while (true) {
3386
- if (aliases.has(candidate))
3387
- return aliases.get(candidate);
3388
- const separator = candidate.indexOf(".");
3389
- if (separator < 0)
3390
- return null;
3391
- candidate = candidate.slice(separator + 1);
3392
- }
3393
- }
3394
-
3395
3694
  // src/rules/parameter-mutations.ts
3396
3695
  var analysisCache = new WeakMap;
3397
3696
  var EMPTY_INDEXES = new Set;
@@ -6114,6 +6413,20 @@ function staticIterationImports(context) {
6114
6413
  }
6115
6414
  return result;
6116
6415
  }
6416
+ function nonReactHookImports(context) {
6417
+ const result = new Set;
6418
+ for (const node of context.walk(context.root)) {
6419
+ if (node.type !== "variable_declaration")
6420
+ continue;
6421
+ const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
6422
+ if (!match || !/^use[A-Z0-9_]/.test(match[1]))
6423
+ continue;
6424
+ const kind = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.reactHookModules);
6425
+ if (kind === false)
6426
+ result.add(match[1]);
6427
+ }
6428
+ return result;
6429
+ }
6117
6430
  function conditionalHookModeImports(context) {
6118
6431
  const result = new Map;
6119
6432
  for (const node of context.walk(context.root)) {
@@ -6161,30 +6474,12 @@ function controlledParameterIndex(controlFlow, fn, summary) {
6161
6474
  }
6162
6475
  return null;
6163
6476
  }
6164
- function isStableModeArgument(node, context) {
6165
- if (!node)
6166
- return true;
6167
- const text = node.text.trim();
6168
- if (["true", "false", "nil"].includes(text))
6169
- return true;
6170
- if ([
6171
- "number",
6172
- "string",
6173
- "string_content",
6174
- "table_constructor",
6175
- "function_definition"
6176
- ].includes(node.type))
6177
- return true;
6178
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text) && context.model.stableVariables.has(text))
6179
- return true;
6180
- return false;
6181
- }
6182
6477
  function dynamicHookModeFixPreview(hookName, argumentText) {
6183
6478
  return {
6184
6479
  kind: "pattern",
6185
6480
  before: `local value = ${hookName}(${argumentText})`,
6186
- after: `local value = ${hookName}(true) -- or false; keep this mode fixed for this call site`,
6187
- note: "A custom hook may choose different internal hooks only when this mode is stable for the lifetime of the component instance."
6481
+ after: `local value = ${hookName}(${argumentText}) -- keep the hook-topology mode stable for this instance`,
6482
+ note: "A custom hook may choose different internal hooks only when the value controlling that choice stays stable for the lifetime of the component instance."
6188
6483
  };
6189
6484
  }
6190
6485
  function unwrapIteratorExpression(text) {
@@ -6204,6 +6499,17 @@ function declarationExpressions4(node) {
6204
6499
  function isEmptyTable(node) {
6205
6500
  return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
6206
6501
  }
6502
+ function tableConstructorHasStableArity(node) {
6503
+ if (node.type !== "table_constructor")
6504
+ return false;
6505
+ const fields = node.namedChildren.filter((child) => child.type === "field");
6506
+ if (fields.length === 0)
6507
+ return true;
6508
+ if (fields.some((field) => [...field.namedChildren].some((child) => child.type === "vararg_expression")))
6509
+ return false;
6510
+ const last = fields.at(-1);
6511
+ return !last?.namedChildren.some((child) => child.type === "function_call");
6512
+ }
6207
6513
  function stableShapeVariablesByFunction(context) {
6208
6514
  const stable = new Map;
6209
6515
  const functionsByName = new Map;
@@ -6230,7 +6536,7 @@ function stableShapeVariablesByFunction(context) {
6230
6536
  const expression = expressions[index] ?? expressions[0];
6231
6537
  if (!expression)
6232
6538
  continue;
6233
- if (expression.type === "table_constructor") {
6539
+ if (expression.type === "table_constructor" && tableConstructorHasStableArity(expression)) {
6234
6540
  namesFor(owner).add(names[index]);
6235
6541
  continue;
6236
6542
  }
@@ -6257,7 +6563,7 @@ function stableShapeVariablesByFunction(context) {
6257
6563
  if (!expression)
6258
6564
  return false;
6259
6565
  if (expression.type === "table_constructor")
6260
- return true;
6566
+ return tableConstructorHasStableArity(expression);
6261
6567
  const text = expression.text.trim();
6262
6568
  if (!owner || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(text))
6263
6569
  return false;
@@ -6310,6 +6616,187 @@ function stableShapeVariablesByFunction(context) {
6310
6616
  }
6311
6617
  return stable;
6312
6618
  }
6619
+ function moduleInvariantVariables(context) {
6620
+ const declarations = new Map;
6621
+ const reassigned = new Set;
6622
+ for (const node of context.walk(context.root)) {
6623
+ if (node.type === "variable_declaration" && !context.nearestFunction(node)) {
6624
+ for (const name of declarationNames2(node))
6625
+ declarations.set(name, node);
6626
+ continue;
6627
+ }
6628
+ if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
6629
+ continue;
6630
+ const variableList = node.namedChildren.find((child) => child.type === "variable_list");
6631
+ for (const target of variableList?.namedChildren ?? []) {
6632
+ if (target.type === "identifier")
6633
+ reassigned.add(target.text);
6634
+ }
6635
+ }
6636
+ return new Set([...declarations.keys()].filter((name) => !reassigned.has(name)));
6637
+ }
6638
+ function simpleConditionRoot(controlFlow) {
6639
+ if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
6640
+ return null;
6641
+ const condition = controlFlow.namedChildren[0];
6642
+ if (!condition)
6643
+ return null;
6644
+ const text = condition.text.trim();
6645
+ const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
6646
+ return match?.[1] ?? null;
6647
+ }
6648
+ function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants) {
6649
+ const condition = controlFlow.namedChildren[0];
6650
+ if (!condition)
6651
+ return false;
6652
+ if (["true", "false", "nil", "number", "string"].includes(condition.type))
6653
+ return true;
6654
+ const root = simpleConditionRoot(controlFlow);
6655
+ if (!root)
6656
+ return false;
6657
+ if (moduleInvariants.has(root))
6658
+ return true;
6659
+ return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
6660
+ }
6661
+ function branchHookSequence(branch, fn, context) {
6662
+ const hooks = [];
6663
+ for (const node of context.walk(branch)) {
6664
+ if (node.type !== "function_call")
6665
+ continue;
6666
+ if (context.nearestFunction(node) !== fn)
6667
+ continue;
6668
+ const rawPath = context.getCallPath(node);
6669
+ if (!rawPath)
6670
+ continue;
6671
+ const path4 = context.resolveCallPath(rawPath);
6672
+ if (!isHookPath(path4))
6673
+ continue;
6674
+ const nestedControl = findAncestorBetween(node, branch, (ancestor) => CONDITIONAL_TYPES.has(ancestor.type));
6675
+ if (nestedControl)
6676
+ return null;
6677
+ hooks.push(path4);
6678
+ }
6679
+ return hooks;
6680
+ }
6681
+ function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
6682
+ if (controlFlow.type !== "if_statement")
6683
+ return false;
6684
+ const thenBlock = controlFlow.namedChildren.find((child) => child.type === "block");
6685
+ const elseStatement = controlFlow.namedChildren.find((child) => child.type === "else_statement");
6686
+ const elseBlock = elseStatement?.namedChildren.find((child) => child.type === "block");
6687
+ if (!thenBlock || !elseBlock)
6688
+ return false;
6689
+ const thenHooks = branchHookSequence(thenBlock, fn, context);
6690
+ const elseHooks = branchHookSequence(elseBlock, fn, context);
6691
+ if (!thenHooks || !elseHooks || thenHooks.length !== elseHooks.length)
6692
+ return false;
6693
+ if (!thenHooks.every((path4, index) => path4 === elseHooks[index]))
6694
+ return false;
6695
+ return thenHooks.every((path4) => path4.startsWith("React."));
6696
+ }
6697
+ function stateModeStability(name, owner, context) {
6698
+ const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
6699
+ if (!binding || !owner.body)
6700
+ return null;
6701
+ const initializerText = binding.initializer?.text.trim() ?? "nil";
6702
+ const stableLiteral = /^(?:true|false|nil|-?\d+(?:\.\d+)?|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/;
6703
+ let sawIndirectSetterReference = false;
6704
+ for (const node of context.walk(owner.body)) {
6705
+ if (node.type === "function_call" && context.getCallPath(node) === binding.setterName) {
6706
+ const argument = context.callArguments(node)[0];
6707
+ if (!argument || !stableLiteral.test(initializerText) || argument.text.trim() !== initializerText)
6708
+ return "unstable";
6709
+ continue;
6710
+ }
6711
+ if (node.type !== "identifier" || node.text !== binding.setterName)
6712
+ continue;
6713
+ if (node.startIndex >= binding.declaration.startIndex && node.endIndex <= binding.declaration.endIndex)
6714
+ continue;
6715
+ if (node.parent?.type === "function_call")
6716
+ continue;
6717
+ sawIndirectSetterReference = true;
6718
+ }
6719
+ if (sawIndirectSetterReference)
6720
+ return "unknown";
6721
+ return "stable";
6722
+ }
6723
+ function localAliasExpression(name, owner, context) {
6724
+ if (!owner.body)
6725
+ return null;
6726
+ let result = null;
6727
+ for (const node of context.walk(owner.body)) {
6728
+ if (node.type !== "variable_declaration" || context.nearestFunction(node) !== owner)
6729
+ continue;
6730
+ const names = declarationNames2(node);
6731
+ const index = names.indexOf(name);
6732
+ if (index < 0)
6733
+ continue;
6734
+ const expressions = declarationExpressions4(node);
6735
+ const expression = expressions[index] ?? expressions[0] ?? null;
6736
+ if (result)
6737
+ return null;
6738
+ result = expression;
6739
+ }
6740
+ return result;
6741
+ }
6742
+ function modeValueStability(node, owner, context, moduleInvariants, depth = 0) {
6743
+ if (!node)
6744
+ return "stable";
6745
+ if (depth > 4)
6746
+ return "unknown";
6747
+ const text = node.text.trim();
6748
+ if (["true", "false", "nil"].includes(text))
6749
+ return "stable";
6750
+ if (["number", "string", "string_content", "function_definition"].includes(node.type))
6751
+ return "stable";
6752
+ const root = text.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
6753
+ if (!root)
6754
+ return "unknown";
6755
+ const stateStability = stateModeStability(root, owner, context);
6756
+ if (stateStability)
6757
+ return stateStability;
6758
+ if (moduleInvariants.has(root))
6759
+ return "stable";
6760
+ if (context.model.stableVariablesByFunction.get(nodeKey(owner.node))?.has(root))
6761
+ return "stable";
6762
+ if (text === root) {
6763
+ const alias = localAliasExpression(root, owner, context);
6764
+ if (alias && alias.text.trim() !== root)
6765
+ return modeValueStability(alias, owner, context, moduleInvariants, depth + 1);
6766
+ }
6767
+ return "unknown";
6768
+ }
6769
+ function tableFieldValue(table, accessPath) {
6770
+ if (!table || accessPath === "")
6771
+ return table;
6772
+ if (table.type !== "table_constructor")
6773
+ return;
6774
+ let current = table;
6775
+ for (const part of accessPath.split(".")) {
6776
+ if (!current || current.type !== "table_constructor")
6777
+ return;
6778
+ const field = current.namedChildren.find((child) => {
6779
+ if (child.type !== "field")
6780
+ return false;
6781
+ return child.namedChildren[0]?.type === "identifier" && child.namedChildren[0]?.text === part;
6782
+ });
6783
+ if (!field)
6784
+ return;
6785
+ current = field.namedChildren[1];
6786
+ }
6787
+ return current;
6788
+ }
6789
+ function controlledAccessPaths(summary, parameterIndex) {
6790
+ const paths = new Set;
6791
+ for (const [conditionName, index] of Object.entries(summary.conditionVariables)) {
6792
+ if (index !== parameterIndex)
6793
+ continue;
6794
+ paths.add(summary.conditionAccessPaths?.[conditionName] ?? "");
6795
+ }
6796
+ if (paths.size === 0)
6797
+ paths.add("");
6798
+ return [...paths];
6799
+ }
6313
6800
  function loopIterationIsProvablyStable(loop, imports, owner, stableShapes) {
6314
6801
  const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
6315
6802
  if (numeric) {
@@ -6371,9 +6858,9 @@ function impossibleNilGuardReturn(node, fn, parameterTypes) {
6371
6858
  }
6372
6859
  return false;
6373
6860
  }
6374
- function hasReachableReturnBefore(call, fn, context, cache) {
6861
+ function reachableReturnsBefore(call, fn, context, cache) {
6375
6862
  if (!fn.body)
6376
- return false;
6863
+ return [];
6377
6864
  const key = nodeKey(fn.node);
6378
6865
  let returns = cache.get(key);
6379
6866
  if (!returns) {
@@ -6381,7 +6868,21 @@ function hasReachableReturnBefore(call, fn, context, cache) {
6381
6868
  returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
6382
6869
  cache.set(key, returns);
6383
6870
  }
6384
- return returns.some((node) => node.endIndex <= call.startIndex);
6871
+ return returns.filter((node) => node.endIndex <= call.startIndex);
6872
+ }
6873
+ function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
6874
+ let current = node.parent;
6875
+ while (current && current !== fn.node) {
6876
+ if (current.type === "if_statement" || current.type === "if_expression") {
6877
+ if (summary && controlledParameterIndex(current, fn, summary) !== null)
6878
+ return true;
6879
+ if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
6880
+ return true;
6881
+ return false;
6882
+ }
6883
+ current = current.parent;
6884
+ }
6885
+ return false;
6385
6886
  }
6386
6887
  function conditionalFixPreview(kind, hookName) {
6387
6888
  if (kind === "for statement") {
@@ -6453,15 +6954,21 @@ var rulesOfHooks = {
6453
6954
  description: "Hooks must run in the same order on every render of a React component or custom hook.",
6454
6955
  run(context) {
6455
6956
  const diagnostics = [];
6957
+ if (!context.model.isReactFile)
6958
+ return diagnostics;
6456
6959
  const staticImports = staticIterationImports(context);
6457
6960
  const stableShapes = stableShapeVariablesByFunction(context);
6961
+ const moduleInvariants = moduleInvariantVariables(context);
6458
6962
  const modeImports = conditionalHookModeImports(context);
6963
+ const ignoredHookImports = nonReactHookImports(context);
6459
6964
  const currentModeSummary = currentConditionalHookMode(context);
6460
6965
  const reachableReturns = new Map;
6461
6966
  for (const call of context.findCalls()) {
6462
6967
  const rawPath = context.getCallPath(call);
6463
6968
  if (!rawPath)
6464
6969
  continue;
6970
+ if (ignoredHookImports.has(rawPath))
6971
+ continue;
6465
6972
  const path4 = context.resolveCallPath(rawPath);
6466
6973
  if (!isHookPath(path4))
6467
6974
  continue;
@@ -6490,25 +6997,36 @@ end`,
6490
6997
  const importedMode = modeImports.get(rawPath);
6491
6998
  if (importedMode) {
6492
6999
  const args = context.callArguments(call);
6493
- const dynamicIndexes = importedMode.parameterIndexes.filter((index) => !isStableModeArgument(args[index], context));
6494
- if (dynamicIndexes.length > 0) {
6495
- const firstIndex = dynamicIndexes[0];
6496
- const argument = args[firstIndex];
7000
+ const unstableControls = importedMode.parameterIndexes.flatMap((index) => controlledAccessPaths(importedMode, index).map((accessPath) => {
7001
+ const argument = args[index];
7002
+ const value = tableFieldValue(argument, accessPath);
7003
+ const stability = accessPath !== "" ? argument?.type === "table_constructor" ? value ? modeValueStability(value, fn, context, moduleInvariants) : "stable" : "unknown" : modeValueStability(argument, fn, context, moduleInvariants);
7004
+ return { index, accessPath, argument, value, stability };
7005
+ }).filter((control) => control.stability === "unstable"));
7006
+ if (unstableControls.length > 0) {
7007
+ const first = unstableControls[0];
7008
+ const firstIndex = first.index;
7009
+ const argument = first.argument;
6497
7010
  const parameterOffset = importedMode.parameterIndexes.indexOf(firstIndex);
6498
- const parameterName = importedMode.parameterNames[parameterOffset] ?? `argument ${firstIndex + 1}`;
6499
- const argumentText = argument?.text.trim() || "<omitted>";
7011
+ const baseParameterName = importedMode.parameterNames[parameterOffset] ?? `argument ${firstIndex + 1}`;
7012
+ const parameterName = first.accessPath ? `${baseParameterName}.${first.accessPath}` : baseParameterName;
7013
+ const argumentText = first.value?.text.trim() || argument?.text.trim() || "<omitted>";
6500
7014
  diagnostics.push({
6501
- node: argument ?? callNameNode(call),
6502
- highlights: dynamicIndexes.map((index) => args[index]).filter((node) => Boolean(node)),
6503
- 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.`,
6504
- help: `Pass a stable mode at this call site, or refactor ${rawPath} so it always calls the same hooks regardless of ${parameterName}.`,
7015
+ node: first.value ?? argument ?? callNameNode(call),
7016
+ highlights: unstableControls.map((control) => control.value ?? control.argument).filter((node) => Boolean(node)),
7017
+ message: `Hook ${rawPath} receives hook-topology mode ${parameterName} from ${argumentText}, which is updated between renders and can make the custom hook execute a different hook sequence.`,
7018
+ help: `Keep ${parameterName} stable for this hook instance, or refactor ${rawPath} so it always calls the same hooks regardless of that mode.`,
6505
7019
  fixPreview: dynamicHookModeFixPreview(rawPath, argumentText)
6506
7020
  });
6507
7021
  }
6508
7022
  }
6509
7023
  const controlFlow = findAncestorBetween(call, fn.node, (node) => CONDITIONAL_TYPES.has(node.type));
6510
7024
  if (controlFlow) {
6511
- if (currentModeSummary && currentModeSummary.knownCallSites > 0 && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
7025
+ if (currentModeSummary && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
7026
+ continue;
7027
+ if (conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants))
7028
+ continue;
7029
+ if (ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context))
6512
7030
  continue;
6513
7031
  if (controlFlow.type === "for_statement" && loopIterationIsProvablyStable(controlFlow, staticImports, fn, stableShapes))
6514
7032
  continue;
@@ -6522,7 +7040,8 @@ end`,
6522
7040
  });
6523
7041
  continue;
6524
7042
  }
6525
- if (hasReachableReturnBefore(call, fn, context, reachableReturns)) {
7043
+ const earlierReturns = reachableReturnsBefore(call, fn, context, reachableReturns);
7044
+ if (earlierReturns.length > 0 && !earlierReturns.every((node) => returnIsControlledByStableTopologyMode(node, fn, currentModeSummary, context, moduleInvariants))) {
6526
7045
  diagnostics.push({
6527
7046
  node: callNameNode(call),
6528
7047
  message: `Hook ${path4} may run after an earlier reachable return, so some renders can execute fewer hooks.`,
@@ -6995,7 +7514,7 @@ function toDiagnostic(file, ruleId, category, severity, input) {
6995
7514
  }
6996
7515
  async function analyzeReactFile(input, tree) {
6997
7516
  const parsedTree = tree ?? await parseLuau(input.source);
6998
- const model = buildReactModel(parsedTree.rootNode);
7517
+ const model = buildReactModel(parsedTree.rootNode, input.project);
6999
7518
  if (!input.forceScan && !model.isReactFile) {
7000
7519
  return { relativePath: input.relativePath, isReactFile: false, scanned: false, diagnostics: [] };
7001
7520
  }
@@ -8010,5 +8529,5 @@ parentPort.on("message", async (request) => {
8010
8529
  }
8011
8530
  });
8012
8531
 
8013
- //# debugId=07C5601A473D8F7164756E2164756E21
8532
+ //# debugId=3696D560D93E93D364756E2164756E21
8014
8533
  //# sourceMappingURL=scan-worker.js.map