@colrealpro/react-luau-doctor 0.19.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/cli.js +881 -251
- package/dist/cli.js.map +13 -13
- package/dist/scan-worker.js +761 -242
- package/dist/scan-worker.js.map +11 -11
- package/package.json +1 -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];
|
|
@@ -1572,8 +1752,8 @@ function sourceHasHighFrequencyRobloxEvent(source) {
|
|
|
1572
1752
|
}
|
|
1573
1753
|
return false;
|
|
1574
1754
|
}
|
|
1575
|
-
function knownYieldReason(
|
|
1576
|
-
const normalized =
|
|
1755
|
+
function knownYieldReason(path4, call, context) {
|
|
1756
|
+
const normalized = path4.replace(/\s+/g, "");
|
|
1577
1757
|
if (normalized === "task.wait")
|
|
1578
1758
|
return "task.wait";
|
|
1579
1759
|
if (normalized === "coroutine.yield")
|
|
@@ -1593,15 +1773,15 @@ function functionYieldPoint(node, context) {
|
|
|
1593
1773
|
const owner = context.model.functionByNode.get(node.id);
|
|
1594
1774
|
if (owner && nearest !== owner)
|
|
1595
1775
|
continue;
|
|
1596
|
-
const
|
|
1597
|
-
const reason = knownYieldReason(
|
|
1776
|
+
const path4 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
|
|
1777
|
+
const reason = knownYieldReason(path4, candidate, context);
|
|
1598
1778
|
if (reason)
|
|
1599
1779
|
return { call: candidate, reason };
|
|
1600
1780
|
}
|
|
1601
1781
|
return null;
|
|
1602
1782
|
}
|
|
1603
|
-
function mutableDependencyBase(
|
|
1604
|
-
const normalized =
|
|
1783
|
+
function mutableDependencyBase(path4, externallyMutableRoots) {
|
|
1784
|
+
const normalized = path4.replace(/\s+/g, "");
|
|
1605
1785
|
if (/\.current(?:\.|$)/.test(normalized))
|
|
1606
1786
|
return "";
|
|
1607
1787
|
const segments = normalized.split(".");
|
|
@@ -1627,8 +1807,8 @@ var CONDITIONAL_TYPES = new Set([
|
|
|
1627
1807
|
"while_statement",
|
|
1628
1808
|
"repeat_statement"
|
|
1629
1809
|
]);
|
|
1630
|
-
function isHookPath(
|
|
1631
|
-
const final =
|
|
1810
|
+
function isHookPath(path4) {
|
|
1811
|
+
const final = path4.split(/[.:]/).at(-1) ?? "";
|
|
1632
1812
|
return /^use[A-Z0-9_]/.test(final);
|
|
1633
1813
|
}
|
|
1634
1814
|
function assignmentTargetNode(statement) {
|
|
@@ -1704,8 +1884,8 @@ function functionInfoForNode(context, node) {
|
|
|
1704
1884
|
function stateBindingsFor(context, owner) {
|
|
1705
1885
|
return context.model.stateBindings.filter((binding) => binding.owner === owner);
|
|
1706
1886
|
}
|
|
1707
|
-
function isEffectPath(
|
|
1708
|
-
return
|
|
1887
|
+
function isEffectPath(path4) {
|
|
1888
|
+
return path4 === "React.useEffect" || path4 === "React.useLayoutEffect";
|
|
1709
1889
|
}
|
|
1710
1890
|
function dependencyExpressions(node) {
|
|
1711
1891
|
if (!node || node.type !== "table_constructor")
|
|
@@ -1960,11 +2140,11 @@ function isManagedByCleanupContainer(call, context, callback) {
|
|
|
1960
2140
|
let current = call.parent;
|
|
1961
2141
|
while (current && !sameNode(current, callback.node)) {
|
|
1962
2142
|
if (current.type === "function_call") {
|
|
1963
|
-
const
|
|
1964
|
-
if (ALWAYS_MANAGED_METHOD.test(
|
|
2143
|
+
const path4 = context.getCallPath(current) ?? "";
|
|
2144
|
+
if (ALWAYS_MANAGED_METHOD.test(path4))
|
|
1965
2145
|
return true;
|
|
1966
|
-
if (GENERIC_MANAGER_METHOD.test(
|
|
1967
|
-
const receiver =
|
|
2146
|
+
if (GENERIC_MANAGER_METHOD.test(path4)) {
|
|
2147
|
+
const receiver = path4.split(":", 1)[0] ?? "";
|
|
1968
2148
|
if (MANAGER_ROOT.test(receiver))
|
|
1969
2149
|
return true;
|
|
1970
2150
|
}
|
|
@@ -2069,10 +2249,10 @@ function isCleanupOnlyTask(callbackNode, context) {
|
|
|
2069
2249
|
return false;
|
|
2070
2250
|
return calls.every((call) => /:(?:Destroy|Disconnect)$/.test(context.getCallPath(call) ?? ""));
|
|
2071
2251
|
}
|
|
2072
|
-
function taskSeverity(
|
|
2073
|
-
if (
|
|
2252
|
+
function taskSeverity(path4, call, taskCallback, yieldPoint, context) {
|
|
2253
|
+
if (path4 === "task.defer" && !yieldPoint)
|
|
2074
2254
|
return "suggestion";
|
|
2075
|
-
if (
|
|
2255
|
+
if (path4 === "task.delay" && !yieldPoint) {
|
|
2076
2256
|
if (isCleanupOnlyTask(taskCallback, context))
|
|
2077
2257
|
return "suggestion";
|
|
2078
2258
|
const delay = numericTaskDelay(call, context);
|
|
@@ -2122,10 +2302,10 @@ end`,
|
|
|
2122
2302
|
};
|
|
2123
2303
|
}
|
|
2124
2304
|
if ([...labels].every((label) => label.includes("task"))) {
|
|
2125
|
-
const
|
|
2126
|
-
const taskCall =
|
|
2305
|
+
const path4 = context.resolveCallPath(context.getCallPath(first.call) ?? "task.delay");
|
|
2306
|
+
const taskCall = path4 === "task.defer" ? `task.defer(function()
|
|
2127
2307
|
doWork()
|
|
2128
|
-
end)` :
|
|
2308
|
+
end)` : path4 === "task.spawn" ? `task.spawn(function()
|
|
2129
2309
|
doWork()
|
|
2130
2310
|
end)` : `task.delay(delaySeconds, function()
|
|
2131
2311
|
doWork()
|
|
@@ -2178,9 +2358,9 @@ end`,
|
|
|
2178
2358
|
}
|
|
2179
2359
|
function classifyResource(call, context, callback) {
|
|
2180
2360
|
const rawPath = context.getCallPath(call) ?? "";
|
|
2181
|
-
const
|
|
2361
|
+
const path4 = context.resolveCallPath(rawPath);
|
|
2182
2362
|
const variable = assignedVariableName(call, callback);
|
|
2183
|
-
const finalMember =
|
|
2363
|
+
const finalMember = path4.split(/[.:]/).at(-1) ?? path4;
|
|
2184
2364
|
if (/:Connect$/.test(rawPath)) {
|
|
2185
2365
|
const ownerExpression = connectionOwnerExpression(rawPath);
|
|
2186
2366
|
const ownerIsInstance = isProvenInstanceExpression(ownerExpression, call, context, callback);
|
|
@@ -2249,7 +2429,7 @@ function classifyResource(call, context, callback) {
|
|
|
2249
2429
|
}
|
|
2250
2430
|
};
|
|
2251
2431
|
}
|
|
2252
|
-
if (
|
|
2432
|
+
if (path4 === "ReactRoblox.createRoot") {
|
|
2253
2433
|
return {
|
|
2254
2434
|
call,
|
|
2255
2435
|
label: "React root",
|
|
@@ -2259,17 +2439,17 @@ function classifyResource(call, context, callback) {
|
|
|
2259
2439
|
}
|
|
2260
2440
|
};
|
|
2261
2441
|
}
|
|
2262
|
-
if (
|
|
2442
|
+
if (path4 === "task.delay" || path4 === "task.defer" || path4 === "task.spawn") {
|
|
2263
2443
|
const taskCallback = taskCallbackNode(call, context, callback);
|
|
2264
2444
|
const yieldPoint = taskCallback ? functionYieldPoint(taskCallback, context) : null;
|
|
2265
|
-
const outlivesEffect =
|
|
2445
|
+
const outlivesEffect = path4 !== "task.spawn" || Boolean(yieldPoint);
|
|
2266
2446
|
if (!outlivesEffect)
|
|
2267
2447
|
return null;
|
|
2268
2448
|
return {
|
|
2269
2449
|
call,
|
|
2270
|
-
label:
|
|
2450
|
+
label: path4 === "task.spawn" ? "yielding spawned task" : path4 === "task.defer" ? "deferred task" : "delayed task",
|
|
2271
2451
|
detail: yieldPoint ? `The task can outlive the effect because it yields at ${yieldPoint.reason}.` : "The task is scheduled to run after the effect callback can finish.",
|
|
2272
|
-
severity: taskSeverity(
|
|
2452
|
+
severity: taskSeverity(path4, call, taskCallback, yieldPoint, context),
|
|
2273
2453
|
variable,
|
|
2274
2454
|
cleanupMatches(text, name) {
|
|
2275
2455
|
if (name) {
|
|
@@ -2296,8 +2476,8 @@ var effectNeedsCleanup = {
|
|
|
2296
2476
|
const rawPath = context.getCallPath(call);
|
|
2297
2477
|
if (!rawPath)
|
|
2298
2478
|
continue;
|
|
2299
|
-
const
|
|
2300
|
-
if (
|
|
2479
|
+
const path4 = context.resolveCallPath(rawPath);
|
|
2480
|
+
if (path4 !== "React.useEffect" && path4 !== "React.useLayoutEffect")
|
|
2301
2481
|
continue;
|
|
2302
2482
|
const callbackNode = firstFunctionArgument(context, call);
|
|
2303
2483
|
const callback = functionInfoForNode(context, callbackNode);
|
|
@@ -2351,10 +2531,22 @@ function ownerForHook(context, call) {
|
|
|
2351
2531
|
const owner = context.nearestFunction(call);
|
|
2352
2532
|
return owner && (owner.isComponent || owner.isHook) ? owner : null;
|
|
2353
2533
|
}
|
|
2534
|
+
function effectCallbackWithoutDeps(context, call) {
|
|
2535
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
2536
|
+
if (!isEffectPath(path4))
|
|
2537
|
+
return null;
|
|
2538
|
+
const args = context.callArguments(call);
|
|
2539
|
+
const callback = args[0];
|
|
2540
|
+
if (callback?.type !== "function_definition")
|
|
2541
|
+
return null;
|
|
2542
|
+
if (args.length < 2 || args[1]?.type === "nil")
|
|
2543
|
+
return callback;
|
|
2544
|
+
return null;
|
|
2545
|
+
}
|
|
2354
2546
|
function callbackAndDeps(context, call) {
|
|
2355
|
-
const
|
|
2547
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
2356
2548
|
const args = context.callArguments(call);
|
|
2357
|
-
if (
|
|
2549
|
+
if (path4 === "React.useImperativeHandle") {
|
|
2358
2550
|
const callback2 = args[1];
|
|
2359
2551
|
const deps2 = args[2];
|
|
2360
2552
|
if (callback2?.type === "function_definition" && deps2?.type === "table_constructor")
|
|
@@ -2384,8 +2576,8 @@ function hasExternalSubscription(node, context) {
|
|
|
2384
2576
|
for (const child of context.walk(node)) {
|
|
2385
2577
|
if (child.type !== "function_call")
|
|
2386
2578
|
continue;
|
|
2387
|
-
const
|
|
2388
|
-
if (/:Connect$/.test(
|
|
2579
|
+
const path4 = context.getCallPath(child) ?? "";
|
|
2580
|
+
if (/:Connect$/.test(path4) || /BindToRenderStep$/.test(path4) || /:BindAction(?:AtPriority)?$/.test(path4))
|
|
2389
2581
|
return true;
|
|
2390
2582
|
}
|
|
2391
2583
|
return false;
|
|
@@ -2419,6 +2611,33 @@ function setterCalledOutsideCallback(context, owner, callback, setterName) {
|
|
|
2419
2611
|
}
|
|
2420
2612
|
return false;
|
|
2421
2613
|
}
|
|
2614
|
+
function guaranteedRepeatedStateChange(argument, stateName, context) {
|
|
2615
|
+
if (argument.type === "table_constructor")
|
|
2616
|
+
return true;
|
|
2617
|
+
if (argument.type === "function_definition") {
|
|
2618
|
+
const updater = functionInfoForNode(context, argument);
|
|
2619
|
+
const previousName = updater?.parameters[0];
|
|
2620
|
+
const returnStatement = updater?.body?.namedChildren.find((child) => child.type === "return_statement");
|
|
2621
|
+
const returnValue = returnStatement?.namedChildren[0];
|
|
2622
|
+
if (previousName && returnValue && guaranteedRepeatedStateChange(returnValue, previousName, context))
|
|
2623
|
+
return true;
|
|
2624
|
+
return false;
|
|
2625
|
+
}
|
|
2626
|
+
const text = argument.text.trim();
|
|
2627
|
+
const escaped = stateName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2628
|
+
if (new RegExp(`^not\\s+${escaped}$`).test(text))
|
|
2629
|
+
return true;
|
|
2630
|
+
if (new RegExp(`^${escaped}\\s*[+-]\\s*(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)$`).test(text))
|
|
2631
|
+
return true;
|
|
2632
|
+
if (new RegExp(`^(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)\\s*\\+\\s*${escaped}$`).test(text))
|
|
2633
|
+
return true;
|
|
2634
|
+
if (argument.type === "function_call") {
|
|
2635
|
+
const path4 = context.resolveCallPath(context.getCallPath(argument) ?? "");
|
|
2636
|
+
if (path4 === "table.clone" || path4 === "table.create" || path4 === "table.pack")
|
|
2637
|
+
return true;
|
|
2638
|
+
}
|
|
2639
|
+
return false;
|
|
2640
|
+
}
|
|
2422
2641
|
function freshDependencyKind(node, owner) {
|
|
2423
2642
|
if (node.type === "table_constructor")
|
|
2424
2643
|
return "table";
|
|
@@ -2441,8 +2660,8 @@ var noDerivedStateEffect = {
|
|
|
2441
2660
|
run(context) {
|
|
2442
2661
|
const diagnostics = [];
|
|
2443
2662
|
for (const call of context.findCalls()) {
|
|
2444
|
-
const
|
|
2445
|
-
if (!isEffectPath(
|
|
2663
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
2664
|
+
if (!isEffectPath(path4))
|
|
2446
2665
|
continue;
|
|
2447
2666
|
const owner = ownerForHook(context, call);
|
|
2448
2667
|
const parts = callbackAndDeps(context, call);
|
|
@@ -2480,28 +2699,48 @@ var noSelfUpdatingEffect = {
|
|
|
2480
2699
|
id: "react-luau/no-self-updating-effect",
|
|
2481
2700
|
category: "Effects",
|
|
2482
2701
|
severity: "warning",
|
|
2483
|
-
description: "Effects should not unconditionally update state
|
|
2702
|
+
description: "Effects should not unconditionally update state in a way that schedules themselves again.",
|
|
2484
2703
|
run(context) {
|
|
2485
2704
|
const diagnostics = [];
|
|
2486
2705
|
for (const call of context.findCalls()) {
|
|
2487
|
-
const
|
|
2488
|
-
if (!isEffectPath(
|
|
2706
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
2707
|
+
if (!isEffectPath(path4))
|
|
2489
2708
|
continue;
|
|
2490
2709
|
const owner = ownerForHook(context, call);
|
|
2710
|
+
if (!owner)
|
|
2711
|
+
continue;
|
|
2491
2712
|
const parts = callbackAndDeps(context, call);
|
|
2492
|
-
if (
|
|
2713
|
+
if (parts) {
|
|
2714
|
+
const deps = parseDependencyRoots(parts.deps);
|
|
2715
|
+
for (const binding of stateBindingsFor(context, owner)) {
|
|
2716
|
+
if (!deps.has(binding.valueName))
|
|
2717
|
+
continue;
|
|
2718
|
+
for (const setterCall of directTopLevelCalls(context, parts.callback)) {
|
|
2719
|
+
if (context.getCallPath(setterCall) !== binding.setterName)
|
|
2720
|
+
continue;
|
|
2721
|
+
diagnostics.push({
|
|
2722
|
+
node: callNameNode(setterCall),
|
|
2723
|
+
message: `${binding.setterName}() updates ${binding.valueName}, which is also in this effect's dependency table.`,
|
|
2724
|
+
help: "Guard the update so it converges, move it to the originating event, or derive the value during render."
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
continue;
|
|
2729
|
+
}
|
|
2730
|
+
const callback = effectCallbackWithoutDeps(context, call);
|
|
2731
|
+
if (!callback)
|
|
2493
2732
|
continue;
|
|
2494
|
-
const deps = parseDependencyRoots(parts.deps);
|
|
2495
2733
|
for (const binding of stateBindingsFor(context, owner)) {
|
|
2496
|
-
|
|
2497
|
-
continue;
|
|
2498
|
-
for (const setterCall of directTopLevelCalls(context, parts.callback)) {
|
|
2734
|
+
for (const setterCall of directTopLevelCalls(context, callback)) {
|
|
2499
2735
|
if (context.getCallPath(setterCall) !== binding.setterName)
|
|
2500
2736
|
continue;
|
|
2737
|
+
const argument = context.callArguments(setterCall)[0];
|
|
2738
|
+
if (!argument || !guaranteedRepeatedStateChange(argument, binding.valueName, context))
|
|
2739
|
+
continue;
|
|
2501
2740
|
diagnostics.push({
|
|
2502
2741
|
node: callNameNode(setterCall),
|
|
2503
|
-
message: `${binding.setterName}()
|
|
2504
|
-
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."
|
|
2505
2744
|
});
|
|
2506
2745
|
}
|
|
2507
2746
|
}
|
|
@@ -2517,8 +2756,8 @@ var noEffectWithFreshDeps = {
|
|
|
2517
2756
|
run(context) {
|
|
2518
2757
|
const diagnostics = [];
|
|
2519
2758
|
for (const call of context.findCalls()) {
|
|
2520
|
-
const
|
|
2521
|
-
if (!HOOKS_WITH_DEPS.has(
|
|
2759
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
2760
|
+
if (!HOOKS_WITH_DEPS.has(path4))
|
|
2522
2761
|
continue;
|
|
2523
2762
|
const owner = ownerForHook(context, call);
|
|
2524
2763
|
const parts = callbackAndDeps(context, call);
|
|
@@ -2531,7 +2770,7 @@ var noEffectWithFreshDeps = {
|
|
|
2531
2770
|
const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
|
|
2532
2771
|
diagnostics.push({
|
|
2533
2772
|
node: dependency,
|
|
2534
|
-
message: `${
|
|
2773
|
+
message: `${path4} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
|
|
2535
2774
|
help: "Move the value inside the hook callback and depend on its simple inputs, or intentionally stabilize the value with useMemo/useCallback when identity is part of the contract."
|
|
2536
2775
|
});
|
|
2537
2776
|
}
|
|
@@ -2547,8 +2786,8 @@ var noMutableInDeps = {
|
|
|
2547
2786
|
run(context) {
|
|
2548
2787
|
const diagnostics = [];
|
|
2549
2788
|
for (const call of context.findCalls()) {
|
|
2550
|
-
const
|
|
2551
|
-
if (!HOOKS_WITH_DEPS.has(
|
|
2789
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
2790
|
+
if (!HOOKS_WITH_DEPS.has(path4))
|
|
2552
2791
|
continue;
|
|
2553
2792
|
const owner = ownerForHook(context, call);
|
|
2554
2793
|
const parts = callbackAndDeps(context, call);
|
|
@@ -2641,23 +2880,61 @@ var HOOK_ARGUMENTS = new Map([
|
|
|
2641
2880
|
["React.useImperativeHandle", { callbackIndex: 1, depsIndex: 2 }]
|
|
2642
2881
|
]);
|
|
2643
2882
|
function parameterNames2(functionNode) {
|
|
2644
|
-
|
|
2645
|
-
const parameters = functionNode.childForFieldName("parameters");
|
|
2646
|
-
if (!parameters)
|
|
2647
|
-
return names;
|
|
2648
|
-
for (const node of parameters.namedChildren) {
|
|
2649
|
-
for (const candidate of descendants(node)) {
|
|
2650
|
-
if (candidate.type === "identifier")
|
|
2651
|
-
names.add(candidate.text);
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
return names;
|
|
2883
|
+
return new Set(parameterBindingNames(functionNode.childForFieldName("parameters")));
|
|
2655
2884
|
}
|
|
2656
2885
|
function* descendants(node) {
|
|
2657
2886
|
yield node;
|
|
2658
2887
|
for (const child of node.namedChildren)
|
|
2659
2888
|
yield* descendants(child);
|
|
2660
2889
|
}
|
|
2890
|
+
var TYPE_ONLY_NODES = new Set([
|
|
2891
|
+
"type_definition",
|
|
2892
|
+
"type_annotation",
|
|
2893
|
+
"type_parameters",
|
|
2894
|
+
"generic_type_list",
|
|
2895
|
+
"generic_type",
|
|
2896
|
+
"function_type",
|
|
2897
|
+
"optional_type",
|
|
2898
|
+
"object_type",
|
|
2899
|
+
"union_type",
|
|
2900
|
+
"intersection_type",
|
|
2901
|
+
"tuple_type",
|
|
2902
|
+
"singleton_type",
|
|
2903
|
+
"variadic_type",
|
|
2904
|
+
"variadic_type_pack"
|
|
2905
|
+
]);
|
|
2906
|
+
function isTypeOnlyChild(parent, child) {
|
|
2907
|
+
if (TYPE_ONLY_NODES.has(child.type))
|
|
2908
|
+
return true;
|
|
2909
|
+
if (TYPE_ONLY_NODES.has(parent.type))
|
|
2910
|
+
return true;
|
|
2911
|
+
if (parent.type === "parameter") {
|
|
2912
|
+
return !sameNode(parent.namedChildren.find((candidate) => candidate.type === "identifier"), child);
|
|
2913
|
+
}
|
|
2914
|
+
if (parent.type === "cast_expression" || parent.type === "type_cast_expression") {
|
|
2915
|
+
return !sameNode(parent.namedChildren[0], child);
|
|
2916
|
+
}
|
|
2917
|
+
if (parent.type === "function_definition" || parent.type === "function_declaration") {
|
|
2918
|
+
const name = parent.childForFieldName("name");
|
|
2919
|
+
const parameters = parent.childForFieldName("parameters");
|
|
2920
|
+
const body = parent.childForFieldName("body");
|
|
2921
|
+
if (sameNode(child, name) || sameNode(child, parameters) || sameNode(child, body))
|
|
2922
|
+
return false;
|
|
2923
|
+
return true;
|
|
2924
|
+
}
|
|
2925
|
+
if (parent.type === "variable_list" && parent.parent?.parent?.type === "variable_declaration") {
|
|
2926
|
+
return true;
|
|
2927
|
+
}
|
|
2928
|
+
return false;
|
|
2929
|
+
}
|
|
2930
|
+
function* runtimeDescendants(node) {
|
|
2931
|
+
yield node;
|
|
2932
|
+
for (const child of node.namedChildren) {
|
|
2933
|
+
if (isTypeOnlyChild(node, child))
|
|
2934
|
+
continue;
|
|
2935
|
+
yield* runtimeDescendants(child);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2661
2938
|
function directBlockBindingNames(block, beforeIndex) {
|
|
2662
2939
|
const names = new Set;
|
|
2663
2940
|
for (const child of block.namedChildren) {
|
|
@@ -2733,8 +3010,8 @@ function rootIdentifierNode(node, root) {
|
|
|
2733
3010
|
}
|
|
2734
3011
|
return null;
|
|
2735
3012
|
}
|
|
2736
|
-
function normalizeCapturedPath(
|
|
2737
|
-
const normalized = normalizeExpressionText(
|
|
3013
|
+
function normalizeCapturedPath(path4, stableVariables, externallyMutableRoots) {
|
|
3014
|
+
const normalized = normalizeExpressionText(path4);
|
|
2738
3015
|
const root = rootIdentifier(normalized);
|
|
2739
3016
|
if (!root)
|
|
2740
3017
|
return normalized;
|
|
@@ -2751,7 +3028,7 @@ function normalizeCapturedPath(path3, stableVariables, externallyMutableRoots) {
|
|
|
2751
3028
|
}
|
|
2752
3029
|
function capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots) {
|
|
2753
3030
|
const captured = new Set;
|
|
2754
|
-
for (const node of
|
|
3031
|
+
for (const node of runtimeDescendants(callback)) {
|
|
2755
3032
|
if (node.type === "dot_index_expression" && outermostDotPath(node)) {
|
|
2756
3033
|
const root = rootIdentifier(node.text);
|
|
2757
3034
|
if (!root || !available.has(root) || GLOBALS.has(root) || stableVariables.has(root))
|
|
@@ -2820,8 +3097,81 @@ function expandLocalFunctionCaptures(captured, owner, available, stableVariables
|
|
|
2820
3097
|
addCapture(capture);
|
|
2821
3098
|
return result;
|
|
2822
3099
|
}
|
|
3100
|
+
function dependencyCoveragePath(node) {
|
|
3101
|
+
if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
|
|
3102
|
+
const expression = node.namedChildren[0];
|
|
3103
|
+
if (expression)
|
|
3104
|
+
return dependencyCoveragePath(expression);
|
|
3105
|
+
}
|
|
3106
|
+
if (node.type === "binary_expression") {
|
|
3107
|
+
const left = node.childForFieldName("left") ?? node.namedChildren[0];
|
|
3108
|
+
const right = node.childForFieldName("right") ?? node.namedChildren[1];
|
|
3109
|
+
const isOr = node.children.some((child) => child.type === "or");
|
|
3110
|
+
if (isOr && left && right?.type === "false")
|
|
3111
|
+
return dependencyCoveragePath(left);
|
|
3112
|
+
}
|
|
3113
|
+
return normalizeExpressionText(node.text);
|
|
3114
|
+
}
|
|
2823
3115
|
function dependencyPaths(node) {
|
|
2824
|
-
return new Set(dependencyExpressions(node).map(
|
|
3116
|
+
return new Set(dependencyExpressions(node).map(dependencyCoveragePath));
|
|
3117
|
+
}
|
|
3118
|
+
function transparentAliasPath(node) {
|
|
3119
|
+
if (node.type === "cast_expression" || node.type === "type_cast_expression" || node.type === "parenthesized_expression") {
|
|
3120
|
+
const expression = node.namedChildren[0];
|
|
3121
|
+
return expression ? transparentAliasPath(expression) : null;
|
|
3122
|
+
}
|
|
3123
|
+
if (node.type !== "identifier" && node.type !== "dot_index_expression")
|
|
3124
|
+
return null;
|
|
3125
|
+
return normalizeExpressionText(node.text);
|
|
3126
|
+
}
|
|
3127
|
+
function immutableTransparentAliases(owner, context) {
|
|
3128
|
+
const aliases = new Map;
|
|
3129
|
+
if (!owner.body)
|
|
3130
|
+
return aliases;
|
|
3131
|
+
const mutated = mutatedOwnerLocals(owner, context);
|
|
3132
|
+
for (const statement of owner.body.namedChildren) {
|
|
3133
|
+
if (statement.type !== "variable_declaration")
|
|
3134
|
+
continue;
|
|
3135
|
+
const names = declarationNames2(statement);
|
|
3136
|
+
const expressions = declarationExpressions3(statement);
|
|
3137
|
+
for (let index = 0;index < names.length; index += 1) {
|
|
3138
|
+
const name = names[index];
|
|
3139
|
+
const expression = expressions[index];
|
|
3140
|
+
if (!expression || mutated.has(name))
|
|
3141
|
+
continue;
|
|
3142
|
+
const source = transparentAliasPath(expression);
|
|
3143
|
+
if (!source || source === name)
|
|
3144
|
+
continue;
|
|
3145
|
+
const sourceRoot = rootIdentifier(source);
|
|
3146
|
+
if (!sourceRoot || mutated.has(sourceRoot))
|
|
3147
|
+
continue;
|
|
3148
|
+
aliases.set(name, source);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
return aliases;
|
|
3152
|
+
}
|
|
3153
|
+
function resolveAliasPath(path4, aliases) {
|
|
3154
|
+
let current = path4;
|
|
3155
|
+
const visiting = new Set;
|
|
3156
|
+
while (true) {
|
|
3157
|
+
const root = rootIdentifier(current);
|
|
3158
|
+
if (!root || visiting.has(root))
|
|
3159
|
+
return current;
|
|
3160
|
+
const source = aliases.get(root);
|
|
3161
|
+
if (!source)
|
|
3162
|
+
return current;
|
|
3163
|
+
visiting.add(root);
|
|
3164
|
+
current = `${source}${current.slice(root.length)}`;
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
function dependencyPathsWithAliases(node, aliases) {
|
|
3168
|
+
const dependencies = dependencyPaths(node);
|
|
3169
|
+
for (const dependency of [...dependencies]) {
|
|
3170
|
+
const resolved = resolveAliasPath(dependency, aliases);
|
|
3171
|
+
if (resolved !== dependency)
|
|
3172
|
+
dependencies.add(resolved);
|
|
3173
|
+
}
|
|
3174
|
+
return dependencies;
|
|
2825
3175
|
}
|
|
2826
3176
|
var PURE_DERIVED_CALL = /^(?:math\.(?!random(?:seed)?$)[A-Za-z_][A-Za-z0-9_]*|string\.[A-Za-z_][A-Za-z0-9_]*|table\.(?:find|concat|clone|create|isfrozen)|(?:Color3|Vector2|Vector3|UDim|UDim2|CFrame|BrickColor|Rect|NumberRange|NumberSequence|ColorSequence|TweenInfo|Font|Ray|Region3|PhysicalProperties)\.[A-Za-z_][A-Za-z0-9_]*|[^:]+:(?:ToHSV|Lerp|Dot|Cross|FuzzyEq|Inverse|ToObjectSpace|ToWorldSpace))$/;
|
|
2827
3177
|
function isPureDerivedExpression(node, context) {
|
|
@@ -2833,8 +3183,8 @@ function isPureDerivedExpression(node, context) {
|
|
|
2833
3183
|
const rawPath = context.getCallPath(candidate);
|
|
2834
3184
|
if (!rawPath)
|
|
2835
3185
|
return false;
|
|
2836
|
-
const
|
|
2837
|
-
if (!PURE_DERIVED_CALL.test(
|
|
3186
|
+
const path4 = normalizeExpressionText(context.resolveCallPath(rawPath));
|
|
3187
|
+
if (!PURE_DERIVED_CALL.test(path4))
|
|
2838
3188
|
return false;
|
|
2839
3189
|
}
|
|
2840
3190
|
return true;
|
|
@@ -2913,8 +3263,8 @@ function hasDynamicUnpackDependencies(node, context) {
|
|
|
2913
3263
|
const rawPath = context.getCallPath(candidate);
|
|
2914
3264
|
if (!rawPath)
|
|
2915
3265
|
continue;
|
|
2916
|
-
const
|
|
2917
|
-
if (
|
|
3266
|
+
const path4 = normalizeExpressionText(context.resolveCallPath(rawPath));
|
|
3267
|
+
if (path4 === "unpack" || path4 === "table.unpack")
|
|
2918
3268
|
return true;
|
|
2919
3269
|
}
|
|
2920
3270
|
}
|
|
@@ -2939,8 +3289,8 @@ function memoizedProducerDependencies(owner, context) {
|
|
|
2939
3289
|
const rawPath = context.getCallPath(expression);
|
|
2940
3290
|
if (!rawPath)
|
|
2941
3291
|
continue;
|
|
2942
|
-
const
|
|
2943
|
-
if (
|
|
3292
|
+
const path4 = context.resolveCallPath(rawPath);
|
|
3293
|
+
if (path4 !== "React.useMemo" && path4 !== "React.useCallback")
|
|
2944
3294
|
continue;
|
|
2945
3295
|
const args = context.callArguments(expression);
|
|
2946
3296
|
const deps = args[1];
|
|
@@ -2999,9 +3349,9 @@ function directCallsInExpression(node) {
|
|
|
2999
3349
|
return calls;
|
|
3000
3350
|
}
|
|
3001
3351
|
function isCustomHookCall(call, context) {
|
|
3002
|
-
const
|
|
3003
|
-
const final =
|
|
3004
|
-
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);
|
|
3005
3355
|
}
|
|
3006
3356
|
var HANDLE_VALUE_NAME = /(?:ref|binding|tween|spring|motion|animation|controller|handle|connection|signal)$/i;
|
|
3007
3357
|
var HANDLE_HOOK_NAME = /(?:Tween|Spring|Binding|Motion|Animated|Animation|Sound)$/i;
|
|
@@ -3011,8 +3361,8 @@ function isCalledAsFunction(owner, name, context) {
|
|
|
3011
3361
|
for (const call of context.walk(owner.body)) {
|
|
3012
3362
|
if (call.type !== "function_call")
|
|
3013
3363
|
continue;
|
|
3014
|
-
const
|
|
3015
|
-
if (
|
|
3364
|
+
const path4 = normalizeExpressionText(context.getCallPath(call) ?? "");
|
|
3365
|
+
if (path4 === name)
|
|
3016
3366
|
return true;
|
|
3017
3367
|
}
|
|
3018
3368
|
return false;
|
|
@@ -3042,8 +3392,8 @@ function customHookDependencyHints(owner, context) {
|
|
|
3042
3392
|
continue;
|
|
3043
3393
|
const directHook = calls.find((call) => isCustomHookCall(call, context));
|
|
3044
3394
|
if (directHook && expression.type === "function_call") {
|
|
3045
|
-
const
|
|
3046
|
-
const final =
|
|
3395
|
+
const path4 = context.resolveCallPath(context.getCallPath(directHook) ?? "");
|
|
3396
|
+
const final = path4.split(/[.:]/).at(-1) ?? "";
|
|
3047
3397
|
const likelyHandle = HANDLE_VALUE_NAME.test(name) || HANDLE_HOOK_NAME.test(final) || isCalledAsFunction(owner, name, context);
|
|
3048
3398
|
if (likelyHandle) {
|
|
3049
3399
|
hints.memberHandleRoots.add(name);
|
|
@@ -3053,14 +3403,14 @@ function customHookDependencyHints(owner, context) {
|
|
|
3053
3403
|
}
|
|
3054
3404
|
if (expression.type === "table_constructor") {
|
|
3055
3405
|
const meaningfulCalls = calls.filter((call) => {
|
|
3056
|
-
const
|
|
3057
|
-
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);
|
|
3058
3408
|
});
|
|
3059
3409
|
const allHandleHooks = meaningfulCalls.length > 0 && meaningfulCalls.every((call) => {
|
|
3060
3410
|
if (!isCustomHookCall(call, context))
|
|
3061
3411
|
return false;
|
|
3062
|
-
const
|
|
3063
|
-
const final =
|
|
3412
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3413
|
+
const final = path4.split(/[.:]/).at(-1) ?? "";
|
|
3064
3414
|
return HANDLE_HOOK_NAME.test(final);
|
|
3065
3415
|
});
|
|
3066
3416
|
if (allHandleHooks)
|
|
@@ -3090,12 +3440,13 @@ var exhaustiveDeps = {
|
|
|
3090
3440
|
const derivedDependenciesByOwner = new Map;
|
|
3091
3441
|
const dependencyHintsByOwner = new Map;
|
|
3092
3442
|
const memoizedProducersByOwner = new Map;
|
|
3443
|
+
const transparentAliasesByOwner = new Map;
|
|
3093
3444
|
for (const call of context.findCalls()) {
|
|
3094
3445
|
const rawPath = context.getCallPath(call);
|
|
3095
3446
|
if (!rawPath)
|
|
3096
3447
|
continue;
|
|
3097
|
-
const
|
|
3098
|
-
const shape = HOOK_ARGUMENTS.get(
|
|
3448
|
+
const path4 = context.resolveCallPath(rawPath);
|
|
3449
|
+
const shape = HOOK_ARGUMENTS.get(path4);
|
|
3099
3450
|
if (!shape)
|
|
3100
3451
|
continue;
|
|
3101
3452
|
const args = context.callArguments(call);
|
|
@@ -3111,7 +3462,12 @@ var exhaustiveDeps = {
|
|
|
3111
3462
|
const stableVariables = context.model.stableVariablesByFunction.get(ownerKey) ?? new Set;
|
|
3112
3463
|
const externallyMutableRoots = context.model.externalMutableVariablesByFunction.get(ownerKey) ?? new Set;
|
|
3113
3464
|
const functionExpandedCaptures = expandLocalFunctionCaptures(capturedDependencyPaths(callback, available, stableVariables, externallyMutableRoots), owner, available, stableVariables, externallyMutableRoots, context);
|
|
3114
|
-
|
|
3465
|
+
let transparentAliases = transparentAliasesByOwner.get(ownerKey);
|
|
3466
|
+
if (!transparentAliases) {
|
|
3467
|
+
transparentAliases = immutableTransparentAliases(owner, context);
|
|
3468
|
+
transparentAliasesByOwner.set(ownerKey, transparentAliases);
|
|
3469
|
+
}
|
|
3470
|
+
const dependencies = dependencyPathsWithAliases(deps, transparentAliases);
|
|
3115
3471
|
let derivedDependencies = derivedDependenciesByOwner.get(ownerKey);
|
|
3116
3472
|
if (!derivedDependencies) {
|
|
3117
3473
|
derivedDependencies = derivedLocalDependencies(owner, available, stableVariables, externallyMutableRoots, context);
|
|
@@ -3125,7 +3481,7 @@ var exhaustiveDeps = {
|
|
|
3125
3481
|
diagnostics.push({
|
|
3126
3482
|
node: tableStartNode(deps),
|
|
3127
3483
|
severity: "suggestion",
|
|
3128
|
-
message: `${
|
|
3484
|
+
message: `${path4} uses dynamic unpack(...) dependencies, so exhaustive dependency analysis is partial.`,
|
|
3129
3485
|
help: "The analyzer cannot prove which values unpack(...) contributes. Review the explicit dependencies and the custom hook contract."
|
|
3130
3486
|
});
|
|
3131
3487
|
continue;
|
|
@@ -3146,7 +3502,7 @@ var exhaustiveDeps = {
|
|
|
3146
3502
|
if (reactiveMissing.length > 0) {
|
|
3147
3503
|
diagnostics.push({
|
|
3148
3504
|
node: tableStartNode(deps),
|
|
3149
|
-
message: `${
|
|
3505
|
+
message: `${path4} dependency table is missing ${reactiveMissing.join(", ")}.`,
|
|
3150
3506
|
help: `Add ${reactiveMissing.join(", ")} to the dependency table, or stop capturing ${reactiveMissing.length === 1 ? "that value" : "those values"} in the callback.`
|
|
3151
3507
|
});
|
|
3152
3508
|
}
|
|
@@ -3154,7 +3510,7 @@ var exhaustiveDeps = {
|
|
|
3154
3510
|
diagnostics.push({
|
|
3155
3511
|
node: tableStartNode(deps),
|
|
3156
3512
|
severity: "suggestion",
|
|
3157
|
-
message: `${
|
|
3513
|
+
message: `${path4} captures memoized value ${transitivelyCoveredMemoized.join(", ")} without listing ${transitivelyCoveredMemoized.length === 1 ? "it directly" : "them directly"}, but ${transitivelyCoveredMemoized.length === 1 ? "its memo dependencies are" : "their memo dependencies are"} already covered.`,
|
|
3158
3514
|
help: "The producing memo dependencies are already covered. Listing the memoized value directly can make the dependency relationship clearer."
|
|
3159
3515
|
});
|
|
3160
3516
|
}
|
|
@@ -3162,7 +3518,7 @@ var exhaustiveDeps = {
|
|
|
3162
3518
|
diagnostics.push({
|
|
3163
3519
|
node: tableStartNode(deps),
|
|
3164
3520
|
severity: "suggestion",
|
|
3165
|
-
message: `${
|
|
3521
|
+
message: `${path4} captures custom-hook handle ${uncertainHandles.join(", ")} without listing ${uncertainHandles.length === 1 ? "it as a dependency" : "them as dependencies"}.`,
|
|
3166
3522
|
help: "The analyzer cannot prove this custom-hook return is stable. Check the hook contract before changing the dependency table."
|
|
3167
3523
|
});
|
|
3168
3524
|
}
|
|
@@ -3196,8 +3552,8 @@ function createElementCallsInLoop(context, loop) {
|
|
|
3196
3552
|
for (const node of context.walk(loop)) {
|
|
3197
3553
|
if (node.type !== "function_call")
|
|
3198
3554
|
continue;
|
|
3199
|
-
const
|
|
3200
|
-
if (
|
|
3555
|
+
const path4 = context.resolveCallPath(context.getCallPath(node) ?? "");
|
|
3556
|
+
if (path4 === "React.createElement")
|
|
3201
3557
|
result.push(node);
|
|
3202
3558
|
}
|
|
3203
3559
|
return result;
|
|
@@ -3253,8 +3609,8 @@ var noCreateContextInRender = {
|
|
|
3253
3609
|
run(context) {
|
|
3254
3610
|
const diagnostics = [];
|
|
3255
3611
|
for (const call of context.findCalls()) {
|
|
3256
|
-
const
|
|
3257
|
-
if (
|
|
3612
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3613
|
+
if (path4 !== "React.createContext")
|
|
3258
3614
|
continue;
|
|
3259
3615
|
const owner = context.nearestFunction(call);
|
|
3260
3616
|
if (!owner || !owner.isComponent && !owner.isHook)
|
|
@@ -3290,8 +3646,8 @@ function isRenderedBy(context, component, name) {
|
|
|
3290
3646
|
continue;
|
|
3291
3647
|
if (context.nearestFunction(call) !== component)
|
|
3292
3648
|
continue;
|
|
3293
|
-
const
|
|
3294
|
-
if (
|
|
3649
|
+
const path4 = context.resolveCallPath(context.getCallPath(call) ?? "");
|
|
3650
|
+
if (path4 !== "React.createElement")
|
|
3295
3651
|
continue;
|
|
3296
3652
|
if (context.callArguments(call)[0]?.text.trim() === name)
|
|
3297
3653
|
return true;
|
|
@@ -3335,63 +3691,6 @@ var noNestedComponentDefinition = {
|
|
|
3335
3691
|
}
|
|
3336
3692
|
};
|
|
3337
3693
|
|
|
3338
|
-
// src/module-resolution.ts
|
|
3339
|
-
import path3 from "path";
|
|
3340
|
-
function normalizeRelative(value) {
|
|
3341
|
-
return value.split(path3.sep).join("/");
|
|
3342
|
-
}
|
|
3343
|
-
function moduleKeys(relativePath) {
|
|
3344
|
-
let normalized = normalizeRelative(relativePath).replace(/\.(?:lua|luau)$/i, "");
|
|
3345
|
-
if (normalized.endsWith("/init"))
|
|
3346
|
-
normalized = normalized.slice(0, -"/init".length);
|
|
3347
|
-
const segments = normalized.split("/").filter(Boolean);
|
|
3348
|
-
if (segments[0]?.toLowerCase() === "src")
|
|
3349
|
-
segments.shift();
|
|
3350
|
-
const keys = new Set;
|
|
3351
|
-
for (let index = 0;index < segments.length; index += 1) {
|
|
3352
|
-
const suffix = segments.slice(index).join(".").toLowerCase();
|
|
3353
|
-
if (suffix)
|
|
3354
|
-
keys.add(suffix);
|
|
3355
|
-
}
|
|
3356
|
-
return [...keys];
|
|
3357
|
-
}
|
|
3358
|
-
function normalizeRequireTarget(text) {
|
|
3359
|
-
return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
|
|
3360
|
-
}
|
|
3361
|
-
function buildUniqueFeatureAliases(modules, featureByModuleId) {
|
|
3362
|
-
const owners = new Map;
|
|
3363
|
-
for (const module of modules) {
|
|
3364
|
-
for (const key of module.keys) {
|
|
3365
|
-
const ids = owners.get(key) ?? new Set;
|
|
3366
|
-
ids.add(module.id);
|
|
3367
|
-
owners.set(key, ids);
|
|
3368
|
-
}
|
|
3369
|
-
}
|
|
3370
|
-
const aliases = new Map;
|
|
3371
|
-
for (const module of modules) {
|
|
3372
|
-
const value = featureByModuleId.get(module.id);
|
|
3373
|
-
if (value === undefined)
|
|
3374
|
-
continue;
|
|
3375
|
-
for (const key of module.keys) {
|
|
3376
|
-
const ids = owners.get(key);
|
|
3377
|
-
if (ids?.size === 1 && ids.has(module.id))
|
|
3378
|
-
aliases.set(key, value);
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
return aliases;
|
|
3382
|
-
}
|
|
3383
|
-
function resolveModuleReference(target, aliases) {
|
|
3384
|
-
let candidate = target;
|
|
3385
|
-
while (true) {
|
|
3386
|
-
if (aliases.has(candidate))
|
|
3387
|
-
return aliases.get(candidate);
|
|
3388
|
-
const separator = candidate.indexOf(".");
|
|
3389
|
-
if (separator < 0)
|
|
3390
|
-
return null;
|
|
3391
|
-
candidate = candidate.slice(separator + 1);
|
|
3392
|
-
}
|
|
3393
|
-
}
|
|
3394
|
-
|
|
3395
3694
|
// src/rules/parameter-mutations.ts
|
|
3396
3695
|
var analysisCache = new WeakMap;
|
|
3397
3696
|
var EMPTY_INDEXES = new Set;
|
|
@@ -6114,6 +6413,20 @@ function staticIterationImports(context) {
|
|
|
6114
6413
|
}
|
|
6115
6414
|
return result;
|
|
6116
6415
|
}
|
|
6416
|
+
function nonReactHookImports(context) {
|
|
6417
|
+
const result = new Set;
|
|
6418
|
+
for (const node of context.walk(context.root)) {
|
|
6419
|
+
if (node.type !== "variable_declaration")
|
|
6420
|
+
continue;
|
|
6421
|
+
const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
|
|
6422
|
+
if (!match || !/^use[A-Z0-9_]/.test(match[1]))
|
|
6423
|
+
continue;
|
|
6424
|
+
const kind = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.reactHookModules);
|
|
6425
|
+
if (kind === false)
|
|
6426
|
+
result.add(match[1]);
|
|
6427
|
+
}
|
|
6428
|
+
return result;
|
|
6429
|
+
}
|
|
6117
6430
|
function conditionalHookModeImports(context) {
|
|
6118
6431
|
const result = new Map;
|
|
6119
6432
|
for (const node of context.walk(context.root)) {
|
|
@@ -6161,30 +6474,12 @@ function controlledParameterIndex(controlFlow, fn, summary) {
|
|
|
6161
6474
|
}
|
|
6162
6475
|
return null;
|
|
6163
6476
|
}
|
|
6164
|
-
function isStableModeArgument(node, context) {
|
|
6165
|
-
if (!node)
|
|
6166
|
-
return true;
|
|
6167
|
-
const text = node.text.trim();
|
|
6168
|
-
if (["true", "false", "nil"].includes(text))
|
|
6169
|
-
return true;
|
|
6170
|
-
if ([
|
|
6171
|
-
"number",
|
|
6172
|
-
"string",
|
|
6173
|
-
"string_content",
|
|
6174
|
-
"table_constructor",
|
|
6175
|
-
"function_definition"
|
|
6176
|
-
].includes(node.type))
|
|
6177
|
-
return true;
|
|
6178
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text) && context.model.stableVariables.has(text))
|
|
6179
|
-
return true;
|
|
6180
|
-
return false;
|
|
6181
|
-
}
|
|
6182
6477
|
function dynamicHookModeFixPreview(hookName, argumentText) {
|
|
6183
6478
|
return {
|
|
6184
6479
|
kind: "pattern",
|
|
6185
6480
|
before: `local value = ${hookName}(${argumentText})`,
|
|
6186
|
-
after: `local value = ${hookName}(
|
|
6187
|
-
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."
|
|
6188
6483
|
};
|
|
6189
6484
|
}
|
|
6190
6485
|
function unwrapIteratorExpression(text) {
|
|
@@ -6204,6 +6499,17 @@ function declarationExpressions4(node) {
|
|
|
6204
6499
|
function isEmptyTable(node) {
|
|
6205
6500
|
return Boolean(node?.type === "table_constructor" && node.namedChildren.every((child) => child.type !== "field"));
|
|
6206
6501
|
}
|
|
6502
|
+
function tableConstructorHasStableArity(node) {
|
|
6503
|
+
if (node.type !== "table_constructor")
|
|
6504
|
+
return false;
|
|
6505
|
+
const fields = node.namedChildren.filter((child) => child.type === "field");
|
|
6506
|
+
if (fields.length === 0)
|
|
6507
|
+
return true;
|
|
6508
|
+
if (fields.some((field) => [...field.namedChildren].some((child) => child.type === "vararg_expression")))
|
|
6509
|
+
return false;
|
|
6510
|
+
const last = fields.at(-1);
|
|
6511
|
+
return !last?.namedChildren.some((child) => child.type === "function_call");
|
|
6512
|
+
}
|
|
6207
6513
|
function stableShapeVariablesByFunction(context) {
|
|
6208
6514
|
const stable = new Map;
|
|
6209
6515
|
const functionsByName = new Map;
|
|
@@ -6230,7 +6536,7 @@ function stableShapeVariablesByFunction(context) {
|
|
|
6230
6536
|
const expression = expressions[index] ?? expressions[0];
|
|
6231
6537
|
if (!expression)
|
|
6232
6538
|
continue;
|
|
6233
|
-
if (expression.type === "table_constructor") {
|
|
6539
|
+
if (expression.type === "table_constructor" && tableConstructorHasStableArity(expression)) {
|
|
6234
6540
|
namesFor(owner).add(names[index]);
|
|
6235
6541
|
continue;
|
|
6236
6542
|
}
|
|
@@ -6257,7 +6563,7 @@ function stableShapeVariablesByFunction(context) {
|
|
|
6257
6563
|
if (!expression)
|
|
6258
6564
|
return false;
|
|
6259
6565
|
if (expression.type === "table_constructor")
|
|
6260
|
-
return
|
|
6566
|
+
return tableConstructorHasStableArity(expression);
|
|
6261
6567
|
const text = expression.text.trim();
|
|
6262
6568
|
if (!owner || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(text))
|
|
6263
6569
|
return false;
|
|
@@ -6310,6 +6616,187 @@ function stableShapeVariablesByFunction(context) {
|
|
|
6310
6616
|
}
|
|
6311
6617
|
return stable;
|
|
6312
6618
|
}
|
|
6619
|
+
function moduleInvariantVariables(context) {
|
|
6620
|
+
const declarations = new Map;
|
|
6621
|
+
const reassigned = new Set;
|
|
6622
|
+
for (const node of context.walk(context.root)) {
|
|
6623
|
+
if (node.type === "variable_declaration" && !context.nearestFunction(node)) {
|
|
6624
|
+
for (const name of declarationNames2(node))
|
|
6625
|
+
declarations.set(name, node);
|
|
6626
|
+
continue;
|
|
6627
|
+
}
|
|
6628
|
+
if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
|
|
6629
|
+
continue;
|
|
6630
|
+
const variableList = node.namedChildren.find((child) => child.type === "variable_list");
|
|
6631
|
+
for (const target of variableList?.namedChildren ?? []) {
|
|
6632
|
+
if (target.type === "identifier")
|
|
6633
|
+
reassigned.add(target.text);
|
|
6634
|
+
}
|
|
6635
|
+
}
|
|
6636
|
+
return new Set([...declarations.keys()].filter((name) => !reassigned.has(name)));
|
|
6637
|
+
}
|
|
6638
|
+
function simpleConditionRoot(controlFlow) {
|
|
6639
|
+
if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
|
|
6640
|
+
return null;
|
|
6641
|
+
const condition = controlFlow.namedChildren[0];
|
|
6642
|
+
if (!condition)
|
|
6643
|
+
return null;
|
|
6644
|
+
const text = condition.text.trim();
|
|
6645
|
+
const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
|
|
6646
|
+
return match?.[1] ?? null;
|
|
6647
|
+
}
|
|
6648
|
+
function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants) {
|
|
6649
|
+
const condition = controlFlow.namedChildren[0];
|
|
6650
|
+
if (!condition)
|
|
6651
|
+
return false;
|
|
6652
|
+
if (["true", "false", "nil", "number", "string"].includes(condition.type))
|
|
6653
|
+
return true;
|
|
6654
|
+
const root = simpleConditionRoot(controlFlow);
|
|
6655
|
+
if (!root)
|
|
6656
|
+
return false;
|
|
6657
|
+
if (moduleInvariants.has(root))
|
|
6658
|
+
return true;
|
|
6659
|
+
return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
|
|
6660
|
+
}
|
|
6661
|
+
function branchHookSequence(branch, fn, context) {
|
|
6662
|
+
const hooks = [];
|
|
6663
|
+
for (const node of context.walk(branch)) {
|
|
6664
|
+
if (node.type !== "function_call")
|
|
6665
|
+
continue;
|
|
6666
|
+
if (context.nearestFunction(node) !== fn)
|
|
6667
|
+
continue;
|
|
6668
|
+
const rawPath = context.getCallPath(node);
|
|
6669
|
+
if (!rawPath)
|
|
6670
|
+
continue;
|
|
6671
|
+
const path4 = context.resolveCallPath(rawPath);
|
|
6672
|
+
if (!isHookPath(path4))
|
|
6673
|
+
continue;
|
|
6674
|
+
const nestedControl = findAncestorBetween(node, branch, (ancestor) => CONDITIONAL_TYPES.has(ancestor.type));
|
|
6675
|
+
if (nestedControl)
|
|
6676
|
+
return null;
|
|
6677
|
+
hooks.push(path4);
|
|
6678
|
+
}
|
|
6679
|
+
return hooks;
|
|
6680
|
+
}
|
|
6681
|
+
function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
|
|
6682
|
+
if (controlFlow.type !== "if_statement")
|
|
6683
|
+
return false;
|
|
6684
|
+
const thenBlock = controlFlow.namedChildren.find((child) => child.type === "block");
|
|
6685
|
+
const elseStatement = controlFlow.namedChildren.find((child) => child.type === "else_statement");
|
|
6686
|
+
const elseBlock = elseStatement?.namedChildren.find((child) => child.type === "block");
|
|
6687
|
+
if (!thenBlock || !elseBlock)
|
|
6688
|
+
return false;
|
|
6689
|
+
const thenHooks = branchHookSequence(thenBlock, fn, context);
|
|
6690
|
+
const elseHooks = branchHookSequence(elseBlock, fn, context);
|
|
6691
|
+
if (!thenHooks || !elseHooks || thenHooks.length !== elseHooks.length)
|
|
6692
|
+
return false;
|
|
6693
|
+
if (!thenHooks.every((path4, index) => path4 === elseHooks[index]))
|
|
6694
|
+
return false;
|
|
6695
|
+
return thenHooks.every((path4) => path4.startsWith("React."));
|
|
6696
|
+
}
|
|
6697
|
+
function stateModeStability(name, owner, context) {
|
|
6698
|
+
const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
|
|
6699
|
+
if (!binding || !owner.body)
|
|
6700
|
+
return null;
|
|
6701
|
+
const initializerText = binding.initializer?.text.trim() ?? "nil";
|
|
6702
|
+
const stableLiteral = /^(?:true|false|nil|-?\d+(?:\.\d+)?|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/;
|
|
6703
|
+
let sawIndirectSetterReference = false;
|
|
6704
|
+
for (const node of context.walk(owner.body)) {
|
|
6705
|
+
if (node.type === "function_call" && context.getCallPath(node) === binding.setterName) {
|
|
6706
|
+
const argument = context.callArguments(node)[0];
|
|
6707
|
+
if (!argument || !stableLiteral.test(initializerText) || argument.text.trim() !== initializerText)
|
|
6708
|
+
return "unstable";
|
|
6709
|
+
continue;
|
|
6710
|
+
}
|
|
6711
|
+
if (node.type !== "identifier" || node.text !== binding.setterName)
|
|
6712
|
+
continue;
|
|
6713
|
+
if (node.startIndex >= binding.declaration.startIndex && node.endIndex <= binding.declaration.endIndex)
|
|
6714
|
+
continue;
|
|
6715
|
+
if (node.parent?.type === "function_call")
|
|
6716
|
+
continue;
|
|
6717
|
+
sawIndirectSetterReference = true;
|
|
6718
|
+
}
|
|
6719
|
+
if (sawIndirectSetterReference)
|
|
6720
|
+
return "unknown";
|
|
6721
|
+
return "stable";
|
|
6722
|
+
}
|
|
6723
|
+
function localAliasExpression(name, owner, context) {
|
|
6724
|
+
if (!owner.body)
|
|
6725
|
+
return null;
|
|
6726
|
+
let result = null;
|
|
6727
|
+
for (const node of context.walk(owner.body)) {
|
|
6728
|
+
if (node.type !== "variable_declaration" || context.nearestFunction(node) !== owner)
|
|
6729
|
+
continue;
|
|
6730
|
+
const names = declarationNames2(node);
|
|
6731
|
+
const index = names.indexOf(name);
|
|
6732
|
+
if (index < 0)
|
|
6733
|
+
continue;
|
|
6734
|
+
const expressions = declarationExpressions4(node);
|
|
6735
|
+
const expression = expressions[index] ?? expressions[0] ?? null;
|
|
6736
|
+
if (result)
|
|
6737
|
+
return null;
|
|
6738
|
+
result = expression;
|
|
6739
|
+
}
|
|
6740
|
+
return result;
|
|
6741
|
+
}
|
|
6742
|
+
function modeValueStability(node, owner, context, moduleInvariants, depth = 0) {
|
|
6743
|
+
if (!node)
|
|
6744
|
+
return "stable";
|
|
6745
|
+
if (depth > 4)
|
|
6746
|
+
return "unknown";
|
|
6747
|
+
const text = node.text.trim();
|
|
6748
|
+
if (["true", "false", "nil"].includes(text))
|
|
6749
|
+
return "stable";
|
|
6750
|
+
if (["number", "string", "string_content", "function_definition"].includes(node.type))
|
|
6751
|
+
return "stable";
|
|
6752
|
+
const root = text.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
|
|
6753
|
+
if (!root)
|
|
6754
|
+
return "unknown";
|
|
6755
|
+
const stateStability = stateModeStability(root, owner, context);
|
|
6756
|
+
if (stateStability)
|
|
6757
|
+
return stateStability;
|
|
6758
|
+
if (moduleInvariants.has(root))
|
|
6759
|
+
return "stable";
|
|
6760
|
+
if (context.model.stableVariablesByFunction.get(nodeKey(owner.node))?.has(root))
|
|
6761
|
+
return "stable";
|
|
6762
|
+
if (text === root) {
|
|
6763
|
+
const alias = localAliasExpression(root, owner, context);
|
|
6764
|
+
if (alias && alias.text.trim() !== root)
|
|
6765
|
+
return modeValueStability(alias, owner, context, moduleInvariants, depth + 1);
|
|
6766
|
+
}
|
|
6767
|
+
return "unknown";
|
|
6768
|
+
}
|
|
6769
|
+
function tableFieldValue(table, accessPath) {
|
|
6770
|
+
if (!table || accessPath === "")
|
|
6771
|
+
return table;
|
|
6772
|
+
if (table.type !== "table_constructor")
|
|
6773
|
+
return;
|
|
6774
|
+
let current = table;
|
|
6775
|
+
for (const part of accessPath.split(".")) {
|
|
6776
|
+
if (!current || current.type !== "table_constructor")
|
|
6777
|
+
return;
|
|
6778
|
+
const field = current.namedChildren.find((child) => {
|
|
6779
|
+
if (child.type !== "field")
|
|
6780
|
+
return false;
|
|
6781
|
+
return child.namedChildren[0]?.type === "identifier" && child.namedChildren[0]?.text === part;
|
|
6782
|
+
});
|
|
6783
|
+
if (!field)
|
|
6784
|
+
return;
|
|
6785
|
+
current = field.namedChildren[1];
|
|
6786
|
+
}
|
|
6787
|
+
return current;
|
|
6788
|
+
}
|
|
6789
|
+
function controlledAccessPaths(summary, parameterIndex) {
|
|
6790
|
+
const paths = new Set;
|
|
6791
|
+
for (const [conditionName, index] of Object.entries(summary.conditionVariables)) {
|
|
6792
|
+
if (index !== parameterIndex)
|
|
6793
|
+
continue;
|
|
6794
|
+
paths.add(summary.conditionAccessPaths?.[conditionName] ?? "");
|
|
6795
|
+
}
|
|
6796
|
+
if (paths.size === 0)
|
|
6797
|
+
paths.add("");
|
|
6798
|
+
return [...paths];
|
|
6799
|
+
}
|
|
6313
6800
|
function loopIterationIsProvablyStable(loop, imports, owner, stableShapes) {
|
|
6314
6801
|
const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
|
|
6315
6802
|
if (numeric) {
|
|
@@ -6371,9 +6858,9 @@ function impossibleNilGuardReturn(node, fn, parameterTypes) {
|
|
|
6371
6858
|
}
|
|
6372
6859
|
return false;
|
|
6373
6860
|
}
|
|
6374
|
-
function
|
|
6861
|
+
function reachableReturnsBefore(call, fn, context, cache) {
|
|
6375
6862
|
if (!fn.body)
|
|
6376
|
-
return
|
|
6863
|
+
return [];
|
|
6377
6864
|
const key = nodeKey(fn.node);
|
|
6378
6865
|
let returns = cache.get(key);
|
|
6379
6866
|
if (!returns) {
|
|
@@ -6381,7 +6868,21 @@ function hasReachableReturnBefore(call, fn, context, cache) {
|
|
|
6381
6868
|
returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
|
|
6382
6869
|
cache.set(key, returns);
|
|
6383
6870
|
}
|
|
6384
|
-
return returns.
|
|
6871
|
+
return returns.filter((node) => node.endIndex <= call.startIndex);
|
|
6872
|
+
}
|
|
6873
|
+
function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
|
|
6874
|
+
let current = node.parent;
|
|
6875
|
+
while (current && current !== fn.node) {
|
|
6876
|
+
if (current.type === "if_statement" || current.type === "if_expression") {
|
|
6877
|
+
if (summary && controlledParameterIndex(current, fn, summary) !== null)
|
|
6878
|
+
return true;
|
|
6879
|
+
if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
|
|
6880
|
+
return true;
|
|
6881
|
+
return false;
|
|
6882
|
+
}
|
|
6883
|
+
current = current.parent;
|
|
6884
|
+
}
|
|
6885
|
+
return false;
|
|
6385
6886
|
}
|
|
6386
6887
|
function conditionalFixPreview(kind, hookName) {
|
|
6387
6888
|
if (kind === "for statement") {
|
|
@@ -6453,15 +6954,21 @@ var rulesOfHooks = {
|
|
|
6453
6954
|
description: "Hooks must run in the same order on every render of a React component or custom hook.",
|
|
6454
6955
|
run(context) {
|
|
6455
6956
|
const diagnostics = [];
|
|
6957
|
+
if (!context.model.isReactFile)
|
|
6958
|
+
return diagnostics;
|
|
6456
6959
|
const staticImports = staticIterationImports(context);
|
|
6457
6960
|
const stableShapes = stableShapeVariablesByFunction(context);
|
|
6961
|
+
const moduleInvariants = moduleInvariantVariables(context);
|
|
6458
6962
|
const modeImports = conditionalHookModeImports(context);
|
|
6963
|
+
const ignoredHookImports = nonReactHookImports(context);
|
|
6459
6964
|
const currentModeSummary = currentConditionalHookMode(context);
|
|
6460
6965
|
const reachableReturns = new Map;
|
|
6461
6966
|
for (const call of context.findCalls()) {
|
|
6462
6967
|
const rawPath = context.getCallPath(call);
|
|
6463
6968
|
if (!rawPath)
|
|
6464
6969
|
continue;
|
|
6970
|
+
if (ignoredHookImports.has(rawPath))
|
|
6971
|
+
continue;
|
|
6465
6972
|
const path4 = context.resolveCallPath(rawPath);
|
|
6466
6973
|
if (!isHookPath(path4))
|
|
6467
6974
|
continue;
|
|
@@ -6490,25 +6997,36 @@ end`,
|
|
|
6490
6997
|
const importedMode = modeImports.get(rawPath);
|
|
6491
6998
|
if (importedMode) {
|
|
6492
6999
|
const args = context.callArguments(call);
|
|
6493
|
-
const
|
|
6494
|
-
|
|
6495
|
-
const
|
|
6496
|
-
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;
|
|
6497
7010
|
const parameterOffset = importedMode.parameterIndexes.indexOf(firstIndex);
|
|
6498
|
-
const
|
|
6499
|
-
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>";
|
|
6500
7014
|
diagnostics.push({
|
|
6501
|
-
node: argument ?? callNameNode(call),
|
|
6502
|
-
highlights:
|
|
6503
|
-
message: `Hook ${rawPath} receives
|
|
6504
|
-
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.`,
|
|
6505
7019
|
fixPreview: dynamicHookModeFixPreview(rawPath, argumentText)
|
|
6506
7020
|
});
|
|
6507
7021
|
}
|
|
6508
7022
|
}
|
|
6509
7023
|
const controlFlow = findAncestorBetween(call, fn.node, (node) => CONDITIONAL_TYPES.has(node.type));
|
|
6510
7024
|
if (controlFlow) {
|
|
6511
|
-
if (currentModeSummary &&
|
|
7025
|
+
if (currentModeSummary && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
|
|
7026
|
+
continue;
|
|
7027
|
+
if (conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants))
|
|
7028
|
+
continue;
|
|
7029
|
+
if (ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context))
|
|
6512
7030
|
continue;
|
|
6513
7031
|
if (controlFlow.type === "for_statement" && loopIterationIsProvablyStable(controlFlow, staticImports, fn, stableShapes))
|
|
6514
7032
|
continue;
|
|
@@ -6522,7 +7040,8 @@ end`,
|
|
|
6522
7040
|
});
|
|
6523
7041
|
continue;
|
|
6524
7042
|
}
|
|
6525
|
-
|
|
7043
|
+
const earlierReturns = reachableReturnsBefore(call, fn, context, reachableReturns);
|
|
7044
|
+
if (earlierReturns.length > 0 && !earlierReturns.every((node) => returnIsControlledByStableTopologyMode(node, fn, currentModeSummary, context, moduleInvariants))) {
|
|
6526
7045
|
diagnostics.push({
|
|
6527
7046
|
node: callNameNode(call),
|
|
6528
7047
|
message: `Hook ${path4} may run after an earlier reachable return, so some renders can execute fewer hooks.`,
|
|
@@ -6995,7 +7514,7 @@ function toDiagnostic(file, ruleId, category, severity, input) {
|
|
|
6995
7514
|
}
|
|
6996
7515
|
async function analyzeReactFile(input, tree) {
|
|
6997
7516
|
const parsedTree = tree ?? await parseLuau(input.source);
|
|
6998
|
-
const model = buildReactModel(parsedTree.rootNode);
|
|
7517
|
+
const model = buildReactModel(parsedTree.rootNode, input.project);
|
|
6999
7518
|
if (!input.forceScan && !model.isReactFile) {
|
|
7000
7519
|
return { relativePath: input.relativePath, isReactFile: false, scanned: false, diagnostics: [] };
|
|
7001
7520
|
}
|
|
@@ -8010,5 +8529,5 @@ parentPort.on("message", async (request) => {
|
|
|
8010
8529
|
}
|
|
8011
8530
|
});
|
|
8012
8531
|
|
|
8013
|
-
//# debugId=
|
|
8532
|
+
//# debugId=3696D560D93E93D364756E2164756E21
|
|
8014
8533
|
//# sourceMappingURL=scan-worker.js.map
|