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