@colrealpro/react-luau-doctor 0.17.6 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ var package_default = {
11
11
  publishConfig: {
12
12
  access: "public"
13
13
  },
14
- version: "0.17.6",
14
+ version: "0.18.1",
15
15
  description: "Static analysis for React-Luau hooks, effects, rendering, and performance.",
16
16
  license: "MIT",
17
17
  type: "module",
@@ -23,8 +23,7 @@ var package_default = {
23
23
  "vendor",
24
24
  "README.md",
25
25
  "LICENSE",
26
- "THIRD_PARTY_NOTICES.md",
27
- "docs"
26
+ "THIRD_PARTY_NOTICES.md"
28
27
  ],
29
28
  scripts: {
30
29
  build: "bun run scripts/build.ts",
@@ -996,6 +995,10 @@ function serializeSourceEffect(summary) {
996
995
  return {
997
996
  effectfulMembers: [...summary.effectfulMembers].sort(),
998
997
  effectfulExport: summary.effectfulExport,
998
+ mutatingMembers: [...summary.mutatingMembers].sort(),
999
+ mutatingExportParameters: [...summary.mutatingExportParameters].sort((a, b) => a - b),
1000
+ mutatingMemberParameters: [...summary.mutatingMemberParameters].map(([name, indexes]) => [name, [...indexes].sort((a, b) => a - b)]).sort(([left], [right]) => left.localeCompare(right)),
1001
+ localMutatingParameters: [...summary.localMutatingParameters].map(([name, indexes]) => [name, [...indexes].sort((a, b) => a - b)]).sort(([left], [right]) => left.localeCompare(right)),
999
1002
  instanceFactories: [...summary.instanceFactories].sort()
1000
1003
  };
1001
1004
  }
@@ -1003,6 +1006,10 @@ function deserializeSourceEffect(summary) {
1003
1006
  return {
1004
1007
  effectfulMembers: new Set(summary.effectfulMembers),
1005
1008
  effectfulExport: summary.effectfulExport,
1009
+ mutatingMembers: new Set(summary.mutatingMembers ?? []),
1010
+ mutatingExportParameters: new Set(summary.mutatingExportParameters ?? []),
1011
+ mutatingMemberParameters: new Map((summary.mutatingMemberParameters ?? []).map(([name, indexes]) => [name, new Set(indexes)])),
1012
+ localMutatingParameters: new Map((summary.localMutatingParameters ?? []).map(([name, indexes]) => [name, new Set(indexes)])),
1006
1013
  instanceFactories: new Set(summary.instanceFactories)
1007
1014
  };
1008
1015
  }
@@ -2315,16 +2322,175 @@ var INSTANCE_FACTORY_MEMBERS2 = new Set([
2315
2322
  function finalCallMember2(path6) {
2316
2323
  return path6.split(/[.:]/).at(-1) ?? path6;
2317
2324
  }
2318
- function knownYieldReason(path6) {
2325
+ function declarationInitializer(node, name) {
2326
+ if (node.type !== "variable_declaration")
2327
+ return;
2328
+ const assignment = node.namedChildren.find((child) => child.type === "assignment_statement");
2329
+ const variables = assignment?.namedChildren.find((child) => child.type === "variable_list") ?? node.namedChildren.find((child) => child.type === "variable_list");
2330
+ const expressions = assignment?.namedChildren.find((child) => child.type === "expression_list");
2331
+ const names = variables?.namedChildren.filter((child) => child.type === "identifier") ?? [];
2332
+ const index = names.findIndex((candidate) => candidate.text === name);
2333
+ if (index < 0)
2334
+ return;
2335
+ return expressions?.namedChildren[index] ?? expressions?.namedChildren[0] ?? null;
2336
+ }
2337
+ function functionBindsName(node, name) {
2338
+ if (node.type !== "function_definition" && node.type !== "function_declaration")
2339
+ return false;
2340
+ const parameters = node.childForFieldName("parameters");
2341
+ if (!parameters)
2342
+ return false;
2343
+ return parameters.namedChildren.some((parameter) => parameter.namedChildren.some((child) => child.type === "identifier" && child.text === name) || parameter.type === "identifier" && parameter.text === name);
2344
+ }
2345
+ function loopBindsName(node, name) {
2346
+ if (node.type !== "for_statement")
2347
+ return false;
2348
+ const header = node.text.split(/\bdo\b/s, 1)[0] ?? "";
2349
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2350
+ return new RegExp(`^\\s*for\\s+(?:${escaped}\\s*=|[^\\n]*\\b${escaped}\\b[^\\n]*\\bin\\b)`, "s").test(header);
2351
+ }
2352
+ function visibleInitializer(name, from, context) {
2353
+ let current = from.parent;
2354
+ while (current) {
2355
+ if (current.type === "block" || current.id === context.root.id) {
2356
+ const children = current.namedChildren;
2357
+ for (let index = children.length - 1;index >= 0; index -= 1) {
2358
+ const child = children[index];
2359
+ if (child.startIndex >= from.startIndex)
2360
+ continue;
2361
+ const initializer = declarationInitializer(child, name);
2362
+ if (initializer !== undefined)
2363
+ return initializer;
2364
+ if (child.type === "function_declaration") {
2365
+ const declared = child.childForFieldName("name")?.text.replace(/\s+/g, "") ?? "";
2366
+ if (declared === name)
2367
+ return null;
2368
+ }
2369
+ }
2370
+ }
2371
+ if (functionBindsName(current, name) || loopBindsName(current, name))
2372
+ return null;
2373
+ current = current.parent;
2374
+ }
2375
+ return;
2376
+ }
2377
+ function expressionHasRobloxOrigin(expression, from, context, seen) {
2378
+ if (!expression)
2379
+ return false;
2380
+ const normalized = expression.text.replace(/\s+/g, "");
2381
+ if (/^(?:game|workspace|script)(?:[.:]|$)/.test(normalized) || /^Instance\.new\(/.test(normalized))
2382
+ return true;
2383
+ const root = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
2384
+ if (!root || root === "require" || seen.has(root))
2385
+ return false;
2386
+ if (expression.type !== "identifier" && expression.type !== "function_call")
2387
+ return false;
2388
+ seen.add(root);
2389
+ return expressionHasRobloxOrigin(visibleInitializer(root, from, context), from, context, seen);
2390
+ }
2391
+ function hasRobloxReceiver(path6, call, context) {
2392
+ const normalized = path6.replace(/\s+/g, "");
2393
+ if (/^(?:game|workspace|script)(?:[.:]|$)/.test(normalized))
2394
+ return true;
2395
+ const root = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1];
2396
+ if (!root)
2397
+ return false;
2398
+ return expressionHasRobloxOrigin(visibleInitializer(root, call, context), call, context, new Set([root]));
2399
+ }
2400
+ var runServiceCallCache = new WeakMap;
2401
+ var runServiceExpressionCache = new WeakMap;
2402
+ function cachedNodeBoolean(cache, context, node, compute) {
2403
+ let entries = cache.get(context);
2404
+ if (!entries) {
2405
+ entries = new Map;
2406
+ cache.set(context, entries);
2407
+ }
2408
+ const cached = entries.get(node.id);
2409
+ if (cached !== undefined)
2410
+ return cached;
2411
+ const value = compute();
2412
+ entries.set(node.id, value);
2413
+ return value;
2414
+ }
2415
+ var RUN_SERVICE_EVENTS = new Set([
2416
+ "RenderStepped",
2417
+ "Heartbeat",
2418
+ "Stepped",
2419
+ "PreRender",
2420
+ "PreSimulation",
2421
+ "PostSimulation"
2422
+ ]);
2423
+ function expressionIsRunService(expression, from, context, seen) {
2424
+ if (!expression)
2425
+ return false;
2426
+ const normalized = expression.text.replace(/\s+/g, "");
2427
+ if (/^game:GetService\(["']RunService["']\)$/.test(normalized))
2428
+ return true;
2429
+ if (expression.type !== "identifier")
2430
+ return false;
2431
+ const name = expression.text;
2432
+ if (seen.has(name))
2433
+ return false;
2434
+ seen.add(name);
2435
+ return expressionIsRunService(visibleInitializer(name, from, context), from, context, seen);
2436
+ }
2437
+ function rootIsRunService(root, from, context) {
2438
+ if (root === "game")
2439
+ return false;
2440
+ return expressionIsRunService(visibleInitializer(root, from, context), from, context, new Set([root]));
2441
+ }
2442
+ function isHighFrequencyRunServiceExpression(node, context) {
2443
+ return cachedNodeBoolean(runServiceExpressionCache, context, node, () => {
2444
+ const normalized = node.text.replace(/\s+/g, "");
2445
+ if (/^game:GetService\(["']RunService["']\)\.(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)$/.test(normalized)) {
2446
+ return true;
2447
+ }
2448
+ const match = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)\.(RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)$/);
2449
+ return Boolean(match && RUN_SERVICE_EVENTS.has(match[2]) && rootIsRunService(match[1], node, context));
2450
+ });
2451
+ }
2452
+ function isHighFrequencyRunServiceCall(call, context) {
2453
+ return cachedNodeBoolean(runServiceCallCache, context, call, () => {
2454
+ const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
2455
+ const direct = call.text.replace(/\s+/g, "");
2456
+ if (/^game:GetService\(["']RunService["']\)(?:\.(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation):Connect|:BindToRenderStep|:BindToSimulation)\(/.test(direct)) {
2457
+ return true;
2458
+ }
2459
+ const event = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)\.(RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation):Connect$/);
2460
+ if (event)
2461
+ return rootIsRunService(event[1], call, context);
2462
+ const bind = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*):(?:BindToRenderStep|BindToSimulation)$/);
2463
+ return Boolean(bind && rootIsRunService(bind[1], call, context));
2464
+ });
2465
+ }
2466
+ function sourceHasHighFrequencyRunService(source) {
2467
+ 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)) {
2468
+ return true;
2469
+ }
2470
+ const aliases = new Set;
2471
+ for (const match of source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*game\s*:\s*GetService\s*\(\s*["']RunService["']\s*\)/g)) {
2472
+ aliases.add(match[1]);
2473
+ }
2474
+ for (const alias of aliases) {
2475
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2476
+ const pattern = new RegExp(`\\b${escaped}\\s*(?:\\.\\s*(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)\\s*:\\s*Connect|:\\s*(?:BindToRenderStep|BindToSimulation))`);
2477
+ if (pattern.test(source))
2478
+ return true;
2479
+ }
2480
+ return false;
2481
+ }
2482
+ function knownYieldReason(path6, call, context) {
2319
2483
  const normalized = path6.replace(/\s+/g, "");
2320
2484
  if (normalized === "task.wait")
2321
2485
  return "task.wait";
2322
2486
  if (normalized === "coroutine.yield")
2323
2487
  return "coroutine.yield";
2324
2488
  const member = finalCallMember2(normalized);
2325
- if (YIELDING_ENGINE_MEMBERS.has(member))
2326
- return member;
2327
- return null;
2489
+ if (!YIELDING_ENGINE_MEMBERS.has(member))
2490
+ return null;
2491
+ if (!call || !context)
2492
+ return null;
2493
+ return hasRobloxReceiver(normalized, call, context) ? member : null;
2328
2494
  }
2329
2495
  function functionYieldPoint(node, context) {
2330
2496
  for (const candidate of context.walk(node)) {
@@ -2335,7 +2501,7 @@ function functionYieldPoint(node, context) {
2335
2501
  if (owner && nearest !== owner)
2336
2502
  continue;
2337
2503
  const path6 = context.resolveCallPath(context.getCallPath(candidate) ?? "");
2338
- const reason = knownYieldReason(path6);
2504
+ const reason = knownYieldReason(path6, candidate, context);
2339
2505
  if (reason)
2340
2506
  return { call: candidate, reason };
2341
2507
  }
@@ -2536,6 +2702,38 @@ function isNameShadowedBetween(node, boundary, name) {
2536
2702
  }
2537
2703
  return false;
2538
2704
  }
2705
+ var topLevelBindingsCache = new WeakMap;
2706
+ function topLevelBindings(boundary) {
2707
+ const cached = topLevelBindingsCache.get(boundary);
2708
+ if (cached)
2709
+ return cached;
2710
+ const result = new Map;
2711
+ const add = (name, node) => {
2712
+ const existing = result.get(name) ?? [];
2713
+ existing.push(node);
2714
+ result.set(name, existing);
2715
+ };
2716
+ for (const child of boundary.body?.namedChildren ?? []) {
2717
+ if (child.type === "variable_declaration") {
2718
+ for (const name of declarationNames2(child))
2719
+ add(name, child);
2720
+ } else if (child.type === "function_declaration") {
2721
+ const declared = child.childForFieldName("name")?.text.replace(/\s+/g, "") ?? "";
2722
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(declared))
2723
+ add(declared, child);
2724
+ }
2725
+ }
2726
+ topLevelBindingsCache.set(boundary, result);
2727
+ return result;
2728
+ }
2729
+ function isBindingShadowedBetween(node, boundary, name, declaration = null) {
2730
+ if (isNameShadowedBetween(node, boundary, name))
2731
+ return true;
2732
+ if (!boundary.body)
2733
+ return false;
2734
+ const afterIndex = declaration?.endIndex ?? boundary.body.startIndex - 1;
2735
+ return (topLevelBindings(boundary).get(name) ?? []).some((binding) => binding.startIndex > afterIndex && binding.startIndex < node.startIndex);
2736
+ }
2539
2737
  function containsUnshadowedIdentifier(node, name, context, boundary) {
2540
2738
  if (!node)
2541
2739
  return false;
@@ -4044,6 +4242,343 @@ var noNestedComponentDefinition = {
4044
4242
  }
4045
4243
  };
4046
4244
 
4245
+ // src/module-resolution.ts
4246
+ import path6 from "path";
4247
+ function normalizeRelative2(value) {
4248
+ return value.split(path6.sep).join("/");
4249
+ }
4250
+ function moduleKeys(relativePath) {
4251
+ let normalized = normalizeRelative2(relativePath).replace(/\.(?:lua|luau)$/i, "");
4252
+ if (normalized.endsWith("/init"))
4253
+ normalized = normalized.slice(0, -"/init".length);
4254
+ const segments = normalized.split("/").filter(Boolean);
4255
+ if (segments[0]?.toLowerCase() === "src")
4256
+ segments.shift();
4257
+ const keys = new Set;
4258
+ for (let index = 0;index < segments.length; index += 1) {
4259
+ const suffix = segments.slice(index).join(".").toLowerCase();
4260
+ if (suffix)
4261
+ keys.add(suffix);
4262
+ }
4263
+ return [...keys];
4264
+ }
4265
+ function normalizeRequireTarget(text) {
4266
+ return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
4267
+ }
4268
+ function buildUniqueFeatureAliases(modules, featureByModuleId) {
4269
+ const owners = new Map;
4270
+ for (const module of modules) {
4271
+ for (const key of module.keys) {
4272
+ const ids = owners.get(key) ?? new Set;
4273
+ ids.add(module.id);
4274
+ owners.set(key, ids);
4275
+ }
4276
+ }
4277
+ const aliases = new Map;
4278
+ for (const module of modules) {
4279
+ const value = featureByModuleId.get(module.id);
4280
+ if (value === undefined)
4281
+ continue;
4282
+ for (const key of module.keys) {
4283
+ const ids = owners.get(key);
4284
+ if (ids?.size === 1 && ids.has(module.id))
4285
+ aliases.set(key, value);
4286
+ }
4287
+ }
4288
+ return aliases;
4289
+ }
4290
+ function resolveModuleReference(target, aliases) {
4291
+ let candidate = target;
4292
+ while (true) {
4293
+ if (aliases.has(candidate))
4294
+ return aliases.get(candidate);
4295
+ const separator = candidate.indexOf(".");
4296
+ if (separator < 0)
4297
+ return null;
4298
+ candidate = candidate.slice(separator + 1);
4299
+ }
4300
+ }
4301
+
4302
+ // src/rules/parameter-mutations.ts
4303
+ var analysisCache = new WeakMap;
4304
+ var EMPTY_INDEXES = new Set;
4305
+ function declarationParts(node) {
4306
+ if (node.type !== "variable_declaration")
4307
+ return { names: [], expressions: [] };
4308
+ const assignment = node.namedChildren.find((child) => child.type === "assignment_statement");
4309
+ const variables = assignment?.namedChildren.find((child) => child.type === "variable_list") ?? node.namedChildren.find((child) => child.type === "variable_list");
4310
+ const expressions = assignment?.namedChildren.find((child) => child.type === "expression_list");
4311
+ return {
4312
+ names: variables?.namedChildren.filter((child) => child.type === "identifier").map((child) => child.text) ?? [],
4313
+ expressions: expressions?.namedChildren ?? []
4314
+ };
4315
+ }
4316
+ function assignmentParts(node) {
4317
+ if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration") {
4318
+ return { names: [], expressions: [] };
4319
+ }
4320
+ const variables = node.namedChildren.find((child) => child.type === "variable_list");
4321
+ const expressions = node.namedChildren.find((child) => child.type === "expression_list");
4322
+ return {
4323
+ names: variables?.namedChildren.filter((child) => child.type === "identifier").map((child) => child.text) ?? [],
4324
+ expressions: expressions?.namedChildren ?? []
4325
+ };
4326
+ }
4327
+ function expressionForName(node, name) {
4328
+ const { names, expressions } = node.type === "variable_declaration" ? declarationParts(node) : assignmentParts(node);
4329
+ const index = names.indexOf(name);
4330
+ if (index < 0)
4331
+ return;
4332
+ return expressions[index] ?? expressions[0] ?? null;
4333
+ }
4334
+ function currentModuleSummary(context) {
4335
+ for (const key of moduleKeys(context.relativePath)) {
4336
+ const summary = context.project.sourceEffects.get(key);
4337
+ if (summary)
4338
+ return summary;
4339
+ }
4340
+ return null;
4341
+ }
4342
+ function topLevelImports(context) {
4343
+ const result = new Map;
4344
+ for (const node of context.root.namedChildren) {
4345
+ if (node.type !== "variable_declaration")
4346
+ continue;
4347
+ const { names, expressions } = declarationParts(node);
4348
+ for (let index = 0;index < names.length; index += 1) {
4349
+ const expression = expressions[index] ?? expressions[0];
4350
+ if (expression?.type !== "function_call")
4351
+ continue;
4352
+ const match = expression.text.match(/^\s*require\s*\((.*?)\)\s*$/s);
4353
+ if (!match)
4354
+ continue;
4355
+ const summary = resolveModuleReference(normalizeRequireTarget(match[1]), context.project.sourceEffects);
4356
+ if (summary)
4357
+ result.set(names[index], { declaration: node, summary });
4358
+ }
4359
+ }
4360
+ return result;
4361
+ }
4362
+ function analysisFor(context) {
4363
+ const cached = analysisCache.get(context);
4364
+ if (cached)
4365
+ return cached;
4366
+ const imports = topLevelImports(context);
4367
+ const mutatingFactoryMembers = new Set;
4368
+ for (const { summary } of imports.values()) {
4369
+ if (summary.instanceFactories.size === 0)
4370
+ continue;
4371
+ for (const member of summary.mutatingMemberParameters.keys())
4372
+ mutatingFactoryMembers.add(member);
4373
+ }
4374
+ const result = {
4375
+ currentSummary: currentModuleSummary(context),
4376
+ imports,
4377
+ mutatingFactoryMembers,
4378
+ callIndexes: new Map,
4379
+ originCache: new Map
4380
+ };
4381
+ analysisCache.set(context, result);
4382
+ return result;
4383
+ }
4384
+ function directChildContaining(block, node) {
4385
+ for (const child of block.namedChildren) {
4386
+ if (child.startIndex <= node.startIndex && child.endIndex >= node.endIndex)
4387
+ return child;
4388
+ }
4389
+ return null;
4390
+ }
4391
+ function visibleBinding(name, node, owner) {
4392
+ let current = node;
4393
+ while (current && !sameNode(current, owner.node)) {
4394
+ const parent = current.parent;
4395
+ if (parent?.type === "block") {
4396
+ const containing = directChildContaining(parent, node);
4397
+ const beforeIndex = containing?.startIndex ?? node.startIndex;
4398
+ const children = parent.namedChildren;
4399
+ for (let index = children.length - 1;index >= 0; index -= 1) {
4400
+ const child = children[index];
4401
+ if (child.startIndex >= beforeIndex)
4402
+ continue;
4403
+ if (child.type !== "variable_declaration" && child.type !== "assignment_statement")
4404
+ continue;
4405
+ const expression = expressionForName(child, name);
4406
+ if (expression !== undefined)
4407
+ return { node: child, expression };
4408
+ }
4409
+ }
4410
+ current = parent;
4411
+ }
4412
+ return null;
4413
+ }
4414
+ function isTopLevelFunctionVisible(context, name, owner) {
4415
+ for (const node of context.root.namedChildren) {
4416
+ if (node.startIndex >= owner.node.startIndex)
4417
+ break;
4418
+ if (node.type === "function_declaration") {
4419
+ const declared = node.childForFieldName("name")?.text.replace(/\s+/g, "") ?? "";
4420
+ if (declared === name)
4421
+ return true;
4422
+ continue;
4423
+ }
4424
+ if (node.type !== "variable_declaration")
4425
+ continue;
4426
+ const { names, expressions } = declarationParts(node);
4427
+ const index = names.indexOf(name);
4428
+ if (index < 0)
4429
+ continue;
4430
+ if ((expressions[index] ?? expressions[0])?.type === "function_definition")
4431
+ return true;
4432
+ }
4433
+ return false;
4434
+ }
4435
+ function moduleBindingVisible(binding, name, call, owner) {
4436
+ if (binding.declaration.startIndex >= owner.node.startIndex)
4437
+ return false;
4438
+ if (owner.parameters.includes(name))
4439
+ return false;
4440
+ return visibleBinding(name, call, owner) === null;
4441
+ }
4442
+ function builtinMutatedParameterIndexes(path7, argumentCount) {
4443
+ switch (path7) {
4444
+ case "rawset":
4445
+ case "setmetatable":
4446
+ case "table.clear":
4447
+ case "table.freeze":
4448
+ case "table.insert":
4449
+ case "table.remove":
4450
+ case "table.sort":
4451
+ return argumentCount > 0 ? new Set([0]) : EMPTY_INDEXES;
4452
+ case "table.move":
4453
+ return argumentCount >= 5 ? new Set([4]) : argumentCount > 0 ? new Set([0]) : EMPTY_INDEXES;
4454
+ default:
4455
+ return EMPTY_INDEXES;
4456
+ }
4457
+ }
4458
+ function importedFactorySummary(context, receiver, call, owner, analysis) {
4459
+ const binding = visibleBinding(receiver, call, owner);
4460
+ if (!binding?.expression)
4461
+ return null;
4462
+ const expression = binding.expression;
4463
+ if (expression.type === "identifier") {
4464
+ return importedFactorySummary(context, expression.text, expression, owner, analysis);
4465
+ }
4466
+ if (expression.type !== "function_call")
4467
+ return null;
4468
+ const path7 = normalizeExpressionText(context.getCallPath(expression) ?? "");
4469
+ const match = path7.match(/^([A-Za-z_][A-Za-z0-9_]*)[.:]([A-Za-z_][A-Za-z0-9_]*)$/);
4470
+ if (!match)
4471
+ return null;
4472
+ const imported = analysis.imports.get(match[1]);
4473
+ if (!imported || !moduleBindingVisible(imported, match[1], expression, owner))
4474
+ return null;
4475
+ return imported.summary.instanceFactories.has(match[2]) ? imported.summary : null;
4476
+ }
4477
+ function mightMutateParameters(context, call) {
4478
+ const path7 = normalizeExpressionText(context.getCallPath(call) ?? "");
4479
+ if (builtinMutatedParameterIndexes(path7, context.callArguments(call).length).size > 0)
4480
+ return true;
4481
+ const analysis = analysisFor(context);
4482
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(path7)) {
4483
+ if ((analysis.currentSummary?.localMutatingParameters.get(path7)?.size ?? 0) > 0)
4484
+ return true;
4485
+ return (analysis.imports.get(path7)?.summary.mutatingExportParameters.size ?? 0) > 0;
4486
+ }
4487
+ const member = path7.match(/^([A-Za-z_][A-Za-z0-9_]*)[.:]([A-Za-z_][A-Za-z0-9_]*)$/);
4488
+ if (!member)
4489
+ return false;
4490
+ if ((analysis.imports.get(member[1])?.summary.mutatingMemberParameters.get(member[2])?.size ?? 0) > 0)
4491
+ return true;
4492
+ return analysis.mutatingFactoryMembers.has(member[2]);
4493
+ }
4494
+ function mutatedParameterIndexesForCall(context, call, owner) {
4495
+ const analysis = analysisFor(context);
4496
+ const cached = analysis.callIndexes.get(call.id);
4497
+ if (cached)
4498
+ return cached;
4499
+ const path7 = normalizeExpressionText(context.getCallPath(call) ?? "");
4500
+ const builtin = builtinMutatedParameterIndexes(path7, context.callArguments(call).length);
4501
+ if (builtin.size > 0) {
4502
+ analysis.callIndexes.set(call.id, builtin);
4503
+ return builtin;
4504
+ }
4505
+ let result = EMPTY_INDEXES;
4506
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(path7)) {
4507
+ const importBinding = analysis.imports.get(path7);
4508
+ if (importBinding && moduleBindingVisible(importBinding, path7, call, owner)) {
4509
+ result = importBinding.summary.mutatingExportParameters;
4510
+ } else if (!owner.parameters.includes(path7) && visibleBinding(path7, call, owner) === null && isTopLevelFunctionVisible(context, path7, owner)) {
4511
+ result = analysis.currentSummary?.localMutatingParameters.get(path7) ?? EMPTY_INDEXES;
4512
+ }
4513
+ } else {
4514
+ const member = path7.match(/^([A-Za-z_][A-Za-z0-9_]*)([.:])([A-Za-z_][A-Za-z0-9_]*)$/);
4515
+ if (member) {
4516
+ const imported = analysis.imports.get(member[1]);
4517
+ if (imported && moduleBindingVisible(imported, member[1], call, owner)) {
4518
+ result = imported.summary.mutatingMemberParameters.get(member[3]) ?? EMPTY_INDEXES;
4519
+ } else {
4520
+ const factory = importedFactorySummary(context, member[1], call, owner, analysis);
4521
+ result = factory?.mutatingMemberParameters.get(member[3]) ?? EMPTY_INDEXES;
4522
+ }
4523
+ }
4524
+ }
4525
+ analysis.callIndexes.set(call.id, result);
4526
+ return result;
4527
+ }
4528
+ function freshExpression(context, expression) {
4529
+ if (expression.type === "table_constructor" || expression.type === "function_definition")
4530
+ return true;
4531
+ if (expression.type !== "function_call")
4532
+ return false;
4533
+ const path7 = normalizeExpressionText(context.getCallPath(expression) ?? "");
4534
+ if (path7 === "table.clone" || path7 === "table.create" || path7 === "table.pack")
4535
+ return true;
4536
+ if (path7 === "setmetatable") {
4537
+ const first = context.callArguments(expression)[0];
4538
+ return Boolean(first && freshExpression(context, first));
4539
+ }
4540
+ return false;
4541
+ }
4542
+ function stateBindingForName(context, owner, name, bindingNode) {
4543
+ return context.model.stateBindings.find((binding) => binding.owner === owner && binding.valueName === name && sameNode(binding.declaration, bindingNode)) ?? null;
4544
+ }
4545
+ function resolveNameOrigin(context, name, atNode, owner, seen) {
4546
+ const key = `${owner.node.id}:${name}:${atNode.startIndex}`;
4547
+ if (seen.has(key))
4548
+ return { kind: "unknown" };
4549
+ seen.add(key);
4550
+ const binding = visibleBinding(name, atNode, owner);
4551
+ if (binding) {
4552
+ const state = stateBindingForName(context, owner, name, binding.node);
4553
+ if (state)
4554
+ return { kind: "state", binding: state };
4555
+ if (!binding.expression)
4556
+ return { kind: "unknown" };
4557
+ return resolveExpressionOriginInternal(context, binding.expression, owner, seen);
4558
+ }
4559
+ if (owner.isComponent && owner.parameters[0] === name)
4560
+ return { kind: "props", name };
4561
+ return { kind: "unknown" };
4562
+ }
4563
+ function resolveExpressionOriginInternal(context, expression, owner, seen) {
4564
+ if (freshExpression(context, expression))
4565
+ return { kind: "fresh" };
4566
+ const root = rootIdentifier(expression.text);
4567
+ if (!root)
4568
+ return { kind: "unknown" };
4569
+ return resolveNameOrigin(context, root, expression, owner, seen);
4570
+ }
4571
+ function mutationOriginForExpression(context, expression, owner) {
4572
+ const analysis = analysisFor(context);
4573
+ const key = `${owner.node.id}:${expression.id}`;
4574
+ const cached = analysis.originCache.get(key);
4575
+ if (cached)
4576
+ return cached;
4577
+ const result = resolveExpressionOriginInternal(context, expression, owner, new Set);
4578
+ analysis.originCache.set(key, result);
4579
+ return result;
4580
+ }
4581
+
4047
4582
  // src/rules/no-prop-mutation.ts
4048
4583
  var noPropMutation = {
4049
4584
  id: "react-luau/no-prop-mutation",
@@ -4053,7 +4588,27 @@ var noPropMutation = {
4053
4588
  run(context) {
4054
4589
  const diagnostics = [];
4055
4590
  for (const node of context.walk()) {
4056
- if (node.type !== "assignment_statement" && node.type !== "update_statement")
4591
+ if (node.type === "assignment_statement" || node.type === "update_statement") {
4592
+ const component2 = context.containingComponent(node);
4593
+ if (!component2 || !context.isDirectlyExecutedInFunction(node, component2))
4594
+ continue;
4595
+ const propsName2 = component2.parameters[0];
4596
+ if (!propsName2)
4597
+ continue;
4598
+ if (isBindingShadowedBetween(node, component2, propsName2))
4599
+ continue;
4600
+ const left = assignmentLeft(node.text);
4601
+ const escaped = propsName2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4602
+ if (!new RegExp(`^${escaped}\\s*(?:\\.|\\[)`).test(left))
4603
+ continue;
4604
+ diagnostics.push({
4605
+ node: assignmentTargetNode(node),
4606
+ message: `Component mutates ${propsName2} directly.`,
4607
+ help: "Treat props as immutable. Derive a local value, clone a table you own, or update state in the owner instead."
4608
+ });
4609
+ continue;
4610
+ }
4611
+ if (node.type !== "function_call" || !mightMutateParameters(context, node))
4057
4612
  continue;
4058
4613
  const component = context.containingComponent(node);
4059
4614
  if (!component || !context.isDirectlyExecutedInFunction(node, component))
@@ -4061,16 +4616,27 @@ var noPropMutation = {
4061
4616
  const propsName = component.parameters[0];
4062
4617
  if (!propsName)
4063
4618
  continue;
4064
- if (isNameShadowedBetween(node, component, propsName))
4065
- continue;
4066
- const left = assignmentLeft(node.text);
4067
- const escaped = propsName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4068
- if (!new RegExp(`^${escaped}\\s*(?:\\.|\\[)`).test(left))
4619
+ const arguments_ = context.callArguments(node);
4620
+ const mutatedIndexes = mutatedParameterIndexesForCall(context, node, component);
4621
+ let mutatedArgument = null;
4622
+ for (const index of mutatedIndexes) {
4623
+ const argument = arguments_[index];
4624
+ if (!argument)
4625
+ continue;
4626
+ const origin = mutationOriginForExpression(context, argument, component);
4627
+ if (origin.kind !== "props" || origin.name !== propsName)
4628
+ continue;
4629
+ mutatedArgument = argument;
4630
+ break;
4631
+ }
4632
+ if (!mutatedArgument)
4069
4633
  continue;
4634
+ const path7 = context.resolveCallPath(context.getCallPath(node) ?? "") || "This call";
4070
4635
  diagnostics.push({
4071
- node: assignmentTargetNode(node),
4072
- message: `Component mutates ${propsName} directly.`,
4073
- help: "Treat props as immutable. Derive a local value, clone a table you own, or update state in the owner instead."
4636
+ node: callNameNode(node),
4637
+ highlights: [mutatedArgument],
4638
+ message: `${path7} mutates an argument derived from ${propsName}.`,
4639
+ help: "Treat props as immutable. Clone the table before passing it to a mutating helper, or move the mutation into the owner that controls the value."
4074
4640
  });
4075
4641
  }
4076
4642
  return diagnostics;
@@ -4094,9 +4660,9 @@ function freshDescription(node, context) {
4094
4660
  for (const child of context.walk(node)) {
4095
4661
  if (child.type !== "function_call")
4096
4662
  continue;
4097
- const path6 = context.getCallPath(child) ?? "";
4098
- if (FRESH_CALLS.some((pattern) => pattern.test(path6)) || /Random\.new\s*\([^)]*\)\s*:\s*Next(?:Integer|Number)/s.test(child.text)) {
4099
- return `${path6 || "fresh-value call"}()`;
4663
+ const path7 = context.getCallPath(child) ?? "";
4664
+ if (FRESH_CALLS.some((pattern) => pattern.test(path7)) || /Random\.new\s*\([^)]*\)\s*:\s*Next(?:Integer|Number)/s.test(child.text)) {
4665
+ return `${path7 || "fresh-value call"}()`;
4100
4666
  }
4101
4667
  }
4102
4668
  return null;
@@ -4151,18 +4717,20 @@ var noSetStateInRender = {
4151
4717
  for (const component of context.model.functions) {
4152
4718
  if (!component.isComponent || !component.body)
4153
4719
  continue;
4154
- const setters = new Set(stateBindingsFor(context, component).map((binding) => binding.setterName));
4720
+ const bindings = stateBindingsFor(context, component);
4721
+ const setters = new Map(bindings.map((binding) => [binding.setterName, binding]));
4155
4722
  if (setters.size === 0)
4156
4723
  continue;
4157
4724
  for (const statement of component.body.namedChildren) {
4158
4725
  if (statement.type !== "function_call")
4159
4726
  continue;
4160
- const path6 = context.getCallPath(statement);
4161
- if (!path6 || !setters.has(path6))
4727
+ const path7 = context.getCallPath(statement);
4728
+ const binding = path7 ? setters.get(path7) : undefined;
4729
+ if (!path7 || !binding || isBindingShadowedBetween(statement, component, path7, binding.declaration))
4162
4730
  continue;
4163
4731
  diagnostics.push({
4164
4732
  node: callNameNode(statement),
4165
- message: `${path6}() is called unconditionally during component render.`,
4733
+ message: `${path7}() is called unconditionally during component render.`,
4166
4734
  help: "Move the update to the event or effect that owns it, or derive the value during render. An unconditional render-phase state update can continuously trigger new renders."
4167
4735
  });
4168
4736
  }
@@ -4243,65 +4811,7 @@ var parseErrors = {
4243
4811
  }
4244
4812
  };
4245
4813
 
4246
- // src/module-resolution.ts
4247
- import path6 from "path";
4248
- function normalizeRelative2(value) {
4249
- return value.split(path6.sep).join("/");
4250
- }
4251
- function moduleKeys(relativePath) {
4252
- let normalized = normalizeRelative2(relativePath).replace(/\.(?:lua|luau)$/i, "");
4253
- if (normalized.endsWith("/init"))
4254
- normalized = normalized.slice(0, -"/init".length);
4255
- const segments = normalized.split("/").filter(Boolean);
4256
- if (segments[0]?.toLowerCase() === "src")
4257
- segments.shift();
4258
- const keys = new Set;
4259
- for (let index = 0;index < segments.length; index += 1) {
4260
- const suffix = segments.slice(index).join(".").toLowerCase();
4261
- if (suffix)
4262
- keys.add(suffix);
4263
- }
4264
- return [...keys];
4265
- }
4266
- function normalizeRequireTarget(text) {
4267
- return (text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).join(".").toLowerCase();
4268
- }
4269
- function buildUniqueFeatureAliases(modules, featureByModuleId) {
4270
- const owners = new Map;
4271
- for (const module of modules) {
4272
- for (const key of module.keys) {
4273
- const ids = owners.get(key) ?? new Set;
4274
- ids.add(module.id);
4275
- owners.set(key, ids);
4276
- }
4277
- }
4278
- const aliases = new Map;
4279
- for (const module of modules) {
4280
- const value = featureByModuleId.get(module.id);
4281
- if (value === undefined)
4282
- continue;
4283
- for (const key of module.keys) {
4284
- const ids = owners.get(key);
4285
- if (ids?.size === 1 && ids.has(module.id))
4286
- aliases.set(key, value);
4287
- }
4288
- }
4289
- return aliases;
4290
- }
4291
- function resolveModuleReference(target, aliases) {
4292
- let candidate = target;
4293
- while (true) {
4294
- if (aliases.has(candidate))
4295
- return aliases.get(candidate);
4296
- const separator = candidate.indexOf(".");
4297
- if (separator < 0)
4298
- return null;
4299
- candidate = candidate.slice(separator + 1);
4300
- }
4301
- }
4302
-
4303
4814
  // src/rules/performance-rules.ts
4304
- var HIGH_FREQUENCY = /(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)\s*:\s*Connect|BindToRenderStep|BindToSimulation/;
4305
4815
  var STATIC_DISCOVERY = /(?::|\.)(GetChildren|GetDescendants)$/;
4306
4816
  var PURE_TRIVIAL_CALLS = /^(?:tostring|tonumber|type|typeof|math\.[A-Za-z_][A-Za-z0-9_]*|string\.(?:lower|upper|format|len)|Color3\.(?:new|fromRGB|fromHSV)|Vector[23]\.new|UDim2?\.(?:new|fromOffset|fromScale)|CFrame\.new)$/;
4307
4817
  function declarationForCall(call) {
@@ -4339,7 +4849,7 @@ function identifierReferences(context, owner, name, declaration) {
4339
4849
  continue;
4340
4850
  if (isIdentifierPropertyName(node))
4341
4851
  continue;
4342
- if (isNameShadowedBetween(node, owner, name))
4852
+ if (isBindingShadowedBetween(node, owner, name, declaration))
4343
4853
  continue;
4344
4854
  result.push(node);
4345
4855
  }
@@ -4354,17 +4864,14 @@ function nearestAncestor(node, stop, type) {
4354
4864
  }
4355
4865
  return null;
4356
4866
  }
4357
- function highFrequencyCallback(node) {
4867
+ function highFrequencyCallback(node, context) {
4358
4868
  let current = node.parent;
4359
4869
  while (current) {
4360
4870
  if (current.type === "function_definition") {
4361
4871
  const argumentsNode = current.parent;
4362
4872
  const call = argumentsNode?.type === "arguments" ? argumentsNode.parent : null;
4363
- if (call?.type === "function_call") {
4364
- const name = call.childForFieldName("name")?.text ?? "";
4365
- if (HIGH_FREQUENCY.test(name))
4366
- return current;
4367
- }
4873
+ if (call?.type === "function_call" && isHighFrequencyRunServiceCall(call, context))
4874
+ return current;
4368
4875
  }
4369
4876
  current = current.parent;
4370
4877
  }
@@ -4502,7 +5009,7 @@ var rerenderHighFrequencyState = {
4502
5009
  const diagnostics = [];
4503
5010
  const seen = new Set;
4504
5011
  for (const call of context.findCalls()) {
4505
- const callback = highFrequencyCallback(call);
5012
+ const callback = highFrequencyCallback(call, context);
4506
5013
  if (!callback)
4507
5014
  continue;
4508
5015
  const component = context.containingComponent(call);
@@ -4795,7 +5302,6 @@ var preferUseRefForMutableCell = {
4795
5302
  };
4796
5303
 
4797
5304
  // src/rules/prefer-binding-over-state.ts
4798
- var HIGH_FREQUENCY2 = /(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)\s*:\s*Connect|BindToRenderStep|BindToSimulation/;
4799
5305
  var EXTERNAL_CALLBACK = /(?:^|[.:])(?:Connect|Once|Subscribe|Observe|Listen|Watch)$/i;
4800
5306
  var NON_BINDABLE_HOST_FIELDS = new Set(["ref", "key", "children"]);
4801
5307
  function replaceWithinNode(container, target, replacement) {
@@ -4950,7 +5456,7 @@ function isReadNode(node, valueName, owner, declaration) {
4950
5456
  return false;
4951
5457
  if (isIdentifierPropertyName2(node))
4952
5458
  return false;
4953
- if (isNameShadowedBetween(node, owner, valueName))
5459
+ if (isBindingShadowedBetween(node, owner, valueName, declaration))
4954
5460
  return false;
4955
5461
  const parent = node.parent;
4956
5462
  if (parent?.type === "variable_list" || parent?.type === "typed_identifier")
@@ -5055,9 +5561,9 @@ function stateReadsAreBindingCompatible(valueName, owner, declaration, context,
5055
5561
  }
5056
5562
  return reads > 0;
5057
5563
  }
5058
- function callbackSource(path7) {
5059
- const normalized = path7.replace(/\s+/g, "");
5060
- const highFrequency = HIGH_FREQUENCY2.test(normalized);
5564
+ function callbackSource(call, context) {
5565
+ const normalized = (context.getCallPath(call) ?? "").replace(/\s+/g, "");
5566
+ const highFrequency = isHighFrequencyRunServiceCall(call, context);
5061
5567
  const final = normalized.split(/[.:]/).at(-1) ?? normalized;
5062
5568
  const external = highFrequency || EXTERNAL_CALLBACK.test(normalized) || EXTERNAL_CALLBACK.test(final);
5063
5569
  return { highFrequency, external };
@@ -5072,7 +5578,7 @@ function importedCallbackSource(call, callbackArgument, importedCallbacks, conte
5072
5578
  if (callbackIndex < 0 || !summary.callbackParameterIndexes.includes(callbackIndex))
5073
5579
  return null;
5074
5580
  return {
5075
- highFrequency: summary.highFrequency || args.some((arg) => HIGH_FREQUENCY2.test(arg.text)),
5581
+ highFrequency: summary.highFrequency || args.some((arg) => isHighFrequencyRunServiceExpression(arg, context)),
5076
5582
  external: true
5077
5583
  };
5078
5584
  }
@@ -5084,8 +5590,7 @@ function directCallbackSource(node, importedCallbacks, context) {
5084
5590
  const imported = importedCallbackSource(call, node, importedCallbacks, context);
5085
5591
  if (imported)
5086
5592
  return imported;
5087
- const raw = call.childForFieldName("name")?.text ?? "";
5088
- const source = callbackSource(raw);
5593
+ const source = callbackSource(call, context);
5089
5594
  return source.external ? source : null;
5090
5595
  }
5091
5596
  function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
@@ -5101,7 +5606,7 @@ function namedFunctionCallbackSource(fn, owner, importedCallbacks, context) {
5101
5606
  if (!callbackArg)
5102
5607
  continue;
5103
5608
  const imported = importedCallbackSource(call, callbackArg, importedCallbacks, context);
5104
- const source = imported ?? callbackSource(context.getCallPath(call) ?? "");
5609
+ const source = imported ?? callbackSource(call, context);
5105
5610
  if (!source.external)
5106
5611
  continue;
5107
5612
  foundExternal = true;
@@ -5578,11 +6083,31 @@ function isNilGuardedLazyInit(node, refName) {
5578
6083
  return false;
5579
6084
  current = current.parent;
5580
6085
  }
5581
- return false;
5582
- }
5583
- function assignmentRight(text) {
5584
- const match = text.match(/^(?:.*?)(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=)\s*(.+)$/s);
5585
- return match?.[1]?.trim() ?? null;
6086
+ return false;
6087
+ }
6088
+ function assignmentRight(text) {
6089
+ const match = text.match(/^(?:.*?)(?:\+=|-=|\*=|\/=|%=|\^=|\.\.=|=)\s*(.+)$/s);
6090
+ return match?.[1]?.trim() ?? null;
6091
+ }
6092
+ function refDeclaration(owner, refName, context) {
6093
+ if (!owner.body)
6094
+ return null;
6095
+ for (const statement of owner.body.namedChildren) {
6096
+ if (statement.type !== "variable_declaration")
6097
+ continue;
6098
+ const names = declarationNames2(statement);
6099
+ const index = names.indexOf(refName);
6100
+ if (index === -1)
6101
+ continue;
6102
+ const assignment = statement.namedChildren.find((child) => child.type === "assignment_statement");
6103
+ const expressions = assignment?.namedChildren.find((child) => child.type === "expression_list")?.namedChildren ?? [];
6104
+ const expression = expressions[index] ?? expressions[0];
6105
+ if (!expression || expression.type !== "function_call")
6106
+ continue;
6107
+ if (context.resolveCallPath(context.getCallPath(expression) ?? "") === "React.useRef")
6108
+ return statement;
6109
+ }
6110
+ return null;
5586
6111
  }
5587
6112
  function refInitializer(owner, refName, context) {
5588
6113
  if (!owner.body)
@@ -5680,6 +6205,9 @@ var noRefCurrentInRender = {
5680
6205
  const refName = refRootFromTarget(target, refs);
5681
6206
  if (!refName || isNilGuardedLazyInit(node, refName))
5682
6207
  continue;
6208
+ const declaration = refDeclaration(owner, refName, context);
6209
+ if (isBindingShadowedBetween(node, owner, refName, declaration))
6210
+ continue;
5683
6211
  const latestValueMirror = isLatestValueMirror(node, owner, refName, context);
5684
6212
  diagnostics.push({
5685
6213
  node: assignmentTargetNode(node),
@@ -5693,7 +6221,7 @@ var noRefCurrentInRender = {
5693
6221
  };
5694
6222
 
5695
6223
  // src/rules/render-side-effects.ts
5696
- function declarationParts(node) {
6224
+ function declarationParts2(node) {
5697
6225
  if (node.type !== "variable_declaration")
5698
6226
  return { names: [], expressions: [] };
5699
6227
  const assignment = node.namedChildren.find((child) => child.type === "assignment_statement");
@@ -5704,17 +6232,103 @@ function declarationParts(node) {
5704
6232
  expressions: expressions?.namedChildren ?? []
5705
6233
  };
5706
6234
  }
6235
+ function assignmentParts2(node) {
6236
+ if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
6237
+ return { names: [], expressions: [] };
6238
+ const variables = node.namedChildren.find((child) => child.type === "variable_list");
6239
+ const expressions = node.namedChildren.find((child) => child.type === "expression_list");
6240
+ return {
6241
+ names: variables?.namedChildren.filter((child) => child.type === "identifier").map((child) => child.text) ?? [],
6242
+ expressions: expressions?.namedChildren ?? []
6243
+ };
6244
+ }
6245
+ function bindingParts(node) {
6246
+ return node.type === "variable_declaration" ? declarationParts2(node) : assignmentParts2(node);
6247
+ }
6248
+ function addSourceBinding(bindings, name, declaration, value) {
6249
+ const existing = bindings.get(name) ?? [];
6250
+ existing.push({ declaration, value });
6251
+ bindings.set(name, existing);
6252
+ }
6253
+ function nearestScopeContainer(node, context) {
6254
+ const owner = context.nearestFunction(node);
6255
+ let current = node.parent;
6256
+ while (current) {
6257
+ if (current.type === "block")
6258
+ return current;
6259
+ if (owner && current.id === owner.node.id)
6260
+ return owner.body ?? owner.node;
6261
+ current = current.parent;
6262
+ }
6263
+ return context.root;
6264
+ }
6265
+ function declarationVisibleAt(declaration, node, context) {
6266
+ if (declaration.startIndex >= node.startIndex)
6267
+ return false;
6268
+ const container = nearestScopeContainer(declaration, context);
6269
+ return node.startIndex >= container.startIndex && node.endIndex <= container.endIndex;
6270
+ }
6271
+ function functionParameterNames(node) {
6272
+ const result = new Set;
6273
+ const parameters = node.childForFieldName("parameters") ?? node.namedChildren.find((child) => child.type === "parameters");
6274
+ for (const parameter of parameters?.namedChildren ?? []) {
6275
+ if (parameter.type === "identifier")
6276
+ result.add(parameter.text);
6277
+ for (const child of parameter.namedChildren) {
6278
+ if (child.type === "identifier")
6279
+ result.add(child.text);
6280
+ }
6281
+ }
6282
+ return result;
6283
+ }
6284
+ function loopBindsName2(node, name) {
6285
+ if (node.type !== "for_statement")
6286
+ return false;
6287
+ const header = node.text.split(/\bdo\b/s, 1)[0] ?? "";
6288
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6289
+ return new RegExp(`^\\s*for\\s+(?:${escaped}\\s*=|[^\\n]*\\b${escaped}\\b[^\\n]*\\bin\\b)`, "s").test(header);
6290
+ }
6291
+ function bindingShadowedAfterDeclaration(declaration, node, name) {
6292
+ let current = node.parent;
6293
+ while (current) {
6294
+ if (declaration.startIndex >= current.startIndex && declaration.endIndex <= current.endIndex)
6295
+ return false;
6296
+ if ((current.type === "function_definition" || current.type === "function_declaration") && functionParameterNames(current).has(name))
6297
+ return true;
6298
+ if (loopBindsName2(current, name))
6299
+ return true;
6300
+ current = current.parent;
6301
+ }
6302
+ return false;
6303
+ }
6304
+ function resolveSourceBinding(bindings, name, node, context) {
6305
+ const candidates = bindings.get(name);
6306
+ if (!candidates)
6307
+ return null;
6308
+ for (let index = candidates.length - 1;index >= 0; index -= 1) {
6309
+ const candidate = candidates[index];
6310
+ if (!declarationVisibleAt(candidate.declaration, node, context))
6311
+ continue;
6312
+ if (bindingShadowedAfterDeclaration(candidate.declaration, node, name))
6313
+ return null;
6314
+ return candidate.value;
6315
+ }
6316
+ return null;
6317
+ }
5707
6318
  function sourceEffectImports(context) {
5708
6319
  const result = new Map;
5709
6320
  for (const node of context.walk(context.root)) {
5710
- if (node.type !== "variable_declaration")
6321
+ if (node.type !== "variable_declaration" && node.type !== "assignment_statement")
5711
6322
  continue;
5712
- const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
5713
- if (!match)
6323
+ if (node.type === "assignment_statement" && node.parent?.type === "variable_declaration")
5714
6324
  continue;
5715
- const summary = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.sourceEffects);
5716
- if (summary)
5717
- result.set(match[1], summary);
6325
+ const { names, expressions } = bindingParts(node);
6326
+ for (let index = 0;index < names.length; index += 1) {
6327
+ const expression = expressions[index] ?? expressions[0];
6328
+ const requireMatch = expression?.type === "function_call" ? expression.text.match(/^\s*require\s*\((.*?)\)\s*$/s) : null;
6329
+ const summary = requireMatch ? resolveModuleReference(normalizeRequireTarget(requireMatch[1]), context.project.sourceEffects) : null;
6330
+ addSourceBinding(result, names[index], node, summary);
6331
+ }
5718
6332
  }
5719
6333
  return result;
5720
6334
  }
@@ -5723,7 +6337,7 @@ function factorySummaryFromCall(call, context, imports) {
5723
6337
  const match = path7.match(/^([A-Za-z_][A-Za-z0-9_]*)[.:]([A-Za-z_][A-Za-z0-9_]*)$/);
5724
6338
  if (!match)
5725
6339
  return null;
5726
- const summary = imports.get(match[1]);
6340
+ const summary = resolveSourceBinding(imports, match[1], call, context);
5727
6341
  if (!summary?.instanceFactories.has(match[2]))
5728
6342
  return null;
5729
6343
  return summary;
@@ -5744,61 +6358,57 @@ function returnedFactorySummary(callback, context, imports) {
5744
6358
  function sourceEffectInstances(context, imports) {
5745
6359
  const result = new Map;
5746
6360
  for (const node of context.walk(context.root)) {
5747
- if (node.type !== "variable_declaration")
6361
+ if (node.type !== "variable_declaration" && node.type !== "assignment_statement")
5748
6362
  continue;
5749
- const { names, expressions } = declarationParts(node);
6363
+ if (node.type === "assignment_statement" && node.parent?.type === "variable_declaration")
6364
+ continue;
6365
+ const { names, expressions } = bindingParts(node);
5750
6366
  for (let index = 0;index < names.length; index += 1) {
5751
6367
  const expression = expressions[index] ?? expressions[0];
5752
- if (!expression)
5753
- continue;
5754
- let summary = null;
5755
- if (expression.type === "function_call") {
5756
- summary = factorySummaryFromCall(expression, context, imports);
5757
- if (!summary && context.resolveCallPath(context.getCallPath(expression) ?? "") === "React.useMemo") {
6368
+ let instance = null;
6369
+ if (expression?.type === "function_call") {
6370
+ const directFactory = factorySummaryFromCall(expression, context, imports);
6371
+ if (directFactory) {
6372
+ instance = {
6373
+ summary: directFactory,
6374
+ persistent: context.nearestFunction(node) === null
6375
+ };
6376
+ } else if (context.resolveCallPath(context.getCallPath(expression) ?? "") === "React.useMemo") {
5758
6377
  const callback = context.callArguments(expression)[0];
5759
- if (callback?.type === "function_definition")
5760
- summary = returnedFactorySummary(callback, context, imports);
6378
+ if (callback?.type === "function_definition") {
6379
+ const memoizedFactory = returnedFactorySummary(callback, context, imports);
6380
+ if (memoizedFactory)
6381
+ instance = { summary: memoizedFactory, persistent: true };
6382
+ }
5761
6383
  }
5762
- } else if (expression.type === "identifier") {
5763
- summary = result.get(expression.text) ?? null;
5764
- }
5765
- if (summary)
5766
- result.set(names[index], summary);
5767
- }
5768
- }
5769
- let changed = true;
5770
- while (changed) {
5771
- changed = false;
5772
- for (const node of context.walk(context.root)) {
5773
- if (node.type !== "variable_declaration")
5774
- continue;
5775
- const { names, expressions } = declarationParts(node);
5776
- for (let index = 0;index < names.length; index += 1) {
5777
- if (result.has(names[index]))
5778
- continue;
5779
- const expression = expressions[index] ?? expressions[0];
5780
- if (expression?.type !== "identifier")
5781
- continue;
5782
- const summary = result.get(expression.text);
5783
- if (!summary)
5784
- continue;
5785
- result.set(names[index], summary);
5786
- changed = true;
6384
+ } else if (expression?.type === "identifier") {
6385
+ instance = resolveSourceBinding(result, expression.text, expression, context);
5787
6386
  }
6387
+ addSourceBinding(result, names[index], node, instance);
5788
6388
  }
5789
6389
  }
5790
6390
  return result;
5791
6391
  }
5792
6392
  function sourceInferredEffectCall(call, context, imports, instances) {
5793
6393
  const path7 = context.getCallPath(call)?.replace(/\s+/g, "") ?? "";
5794
- const direct = imports.get(path7);
5795
- if (direct?.effectfulExport)
5796
- return true;
6394
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(path7)) {
6395
+ const direct = resolveSourceBinding(imports, path7, call, context);
6396
+ if (direct?.effectfulExport)
6397
+ return true;
6398
+ }
5797
6399
  const member = path7.match(/^([A-Za-z_][A-Za-z0-9_]*)[.:]([A-Za-z_][A-Za-z0-9_]*)$/);
5798
6400
  if (!member)
5799
6401
  return false;
5800
- const summary = imports.get(member[1]) ?? instances.get(member[1]);
5801
- return summary?.effectfulMembers.has(member[2]) ?? false;
6402
+ const instance = resolveSourceBinding(instances, member[1], call, context);
6403
+ if (instance) {
6404
+ if (instance.summary.effectfulMembers.has(member[2]))
6405
+ return true;
6406
+ return instance.persistent && instance.summary.mutatingMembers.has(member[2]);
6407
+ }
6408
+ const imported = resolveSourceBinding(imports, member[1], call, context);
6409
+ if (!imported)
6410
+ return false;
6411
+ return imported.effectfulMembers.has(member[2]) || imported.mutatingMembers.has(member[2]);
5802
6412
  }
5803
6413
  var RENDER_EFFECT_PATTERNS = [
5804
6414
  /^Instance\.new$/,
@@ -5810,7 +6420,6 @@ var RENDER_EFFECT_PATTERNS = [
5810
6420
  /BindToRenderStep$/,
5811
6421
  /BindToSimulation$/,
5812
6422
  /:Destroy$/,
5813
- /:render$/,
5814
6423
  /:unmount$/
5815
6424
  ];
5816
6425
  function hasNestedKnownRenderSideEffect(call, context) {
@@ -5822,7 +6431,7 @@ function hasNestedKnownRenderSideEffect(call, context) {
5822
6431
  const path7 = context.resolveCallPath(context.getCallPath(node) ?? "");
5823
6432
  if (path7 === "task.spawn" || path7 === "task.defer" || path7 === "task.delay")
5824
6433
  return true;
5825
- if (knownYieldReason(path7) || RENDER_EFFECT_PATTERNS.some((pattern) => pattern.test(path7)))
6434
+ if (knownYieldReason(path7, node, context) || RENDER_EFFECT_PATTERNS.some((pattern) => pattern.test(path7)))
5826
6435
  return true;
5827
6436
  }
5828
6437
  return false;
@@ -5839,7 +6448,7 @@ var noYieldInRender = {
5839
6448
  if (!component || !context.isDirectlyExecutedInFunction(call, component))
5840
6449
  continue;
5841
6450
  const path7 = context.resolveCallPath(context.getCallPath(call) ?? "");
5842
- const reason = knownYieldReason(path7);
6451
+ const reason = knownYieldReason(path7, call, context);
5843
6452
  if (!reason)
5844
6453
  continue;
5845
6454
  diagnostics.push({
@@ -6472,7 +7081,7 @@ function directlyReadsValue(context, owner, binding) {
6472
7081
  continue;
6473
7082
  if (isNestedFunctionFromOwner(context, node, owner))
6474
7083
  continue;
6475
- if (isNameShadowedBetween(node, owner, binding.valueName))
7084
+ if (isBindingShadowedBetween(node, owner, binding.valueName, binding.declaration))
6476
7085
  continue;
6477
7086
  return true;
6478
7087
  }
@@ -6480,7 +7089,7 @@ function directlyReadsValue(context, owner, binding) {
6480
7089
  if (!fn.body)
6481
7090
  continue;
6482
7091
  for (const node of context.walk(fn.body)) {
6483
- if (isIdentifierRead(node, binding.valueName) && !isNameShadowedBetween(node, owner, binding.valueName))
7092
+ if (isIdentifierRead(node, binding.valueName) && !isBindingShadowedBetween(node, owner, binding.valueName, binding.declaration))
6484
7093
  return true;
6485
7094
  }
6486
7095
  }
@@ -6660,13 +7269,20 @@ var noDirectStateMutation = {
6660
7269
  let mutates = false;
6661
7270
  if (node.type === "assignment_statement" || node.type === "update_statement") {
6662
7271
  mutates = new RegExp(`^\\s*${escaped}\\s*(?:\\.|\\[)`).test(node.text);
6663
- } else if (node.type === "function_call") {
6664
- const path7 = context.getCallPath(node) ?? "";
6665
- if (["table.insert", "table.remove", "table.sort", "table.clear", "table.move"].includes(path7)) {
6666
- mutates = context.callArguments(node)[0]?.text.trim() === binding.valueName;
7272
+ } else if (node.type === "function_call" && mightMutateParameters(context, node)) {
7273
+ const arguments_ = context.callArguments(node);
7274
+ for (const index of mutatedParameterIndexesForCall(context, node, owner)) {
7275
+ const argument = arguments_[index];
7276
+ if (!argument)
7277
+ continue;
7278
+ const origin = mutationOriginForExpression(context, argument, owner);
7279
+ if (origin.kind === "state" && origin.binding === binding) {
7280
+ mutates = true;
7281
+ break;
7282
+ }
6667
7283
  }
6668
7284
  }
6669
- if (!mutates || isNameShadowedBetween(node, owner, binding.valueName))
7285
+ if (!mutates || isBindingShadowedBetween(node, owner, binding.valueName, binding.declaration))
6670
7286
  continue;
6671
7287
  const key = `${node.startIndex}:${binding.valueName}`;
6672
7288
  if (seen.has(key))
@@ -6993,7 +7609,6 @@ class AnalysisWorkerPool {
6993
7609
  // src/project-model.ts
6994
7610
  import fs6 from "fs";
6995
7611
  import path7 from "path";
6996
- var HIGH_FREQUENCY_SOURCE = /(?:RenderStepped|Heartbeat|Stepped|PreRender|PreSimulation|PostSimulation)\s*:\s*Connect|BindToRenderStep|BindToSimulation/;
6997
7612
  var EXTERNAL_UPDATE_SOURCE = /(?::|\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\s*\(|\b(?:subscribe|observe|listen|watch)[A-Za-z0-9_]*\s*\(|GetPropertyChangedSignal\s*\(|\.Changed\b/i;
6998
7613
  var CALLBACK_PARAMETER_NAME = /^(?:callback|handler|listener|subscriber|observer|effect|fn)$/i;
6999
7614
  function exportedFunctionName(source) {
@@ -7330,7 +7945,7 @@ function findExternalCallbackFunction(source) {
7330
7945
  return {
7331
7946
  name,
7332
7947
  callbackParameterIndexes,
7333
- highFrequency: HIGH_FREQUENCY_SOURCE.test(source)
7948
+ highFrequency: sourceHasHighFrequencyRunService(source)
7334
7949
  };
7335
7950
  }
7336
7951
  function findBindingCandidateHook(source) {
@@ -7354,7 +7969,7 @@ function findBindingCandidateHook(source) {
7354
7969
  const setterPassedToSubscription = new RegExp(`(?::|\\.)(?:Connect|Once|Subscribe|Observe|Listen|Watch)\\s*\\(\\s*${escapedSetter}\\b`, "i").test(source);
7355
7970
  if (!setterCalled && !setterPassedToSubscription)
7356
7971
  return null;
7357
- const highFrequency = HIGH_FREQUENCY_SOURCE.test(source);
7972
+ const highFrequency = sourceHasHighFrequencyRunService(source);
7358
7973
  const external = highFrequency || EXTERNAL_UPDATE_SOURCE.test(source);
7359
7974
  if (!external)
7360
7975
  return null;
@@ -7901,6 +8516,27 @@ function topLevelReturnName(root) {
7901
8516
  }
7902
8517
  return null;
7903
8518
  }
8519
+ function topLevelImports2(root, moduleAliases) {
8520
+ const imports = new Map;
8521
+ for (const node of root.namedChildren) {
8522
+ if (node.type !== "variable_declaration")
8523
+ continue;
8524
+ const { names, expressions } = declarationParts3(node);
8525
+ for (let index = 0;index < names.length; index += 1) {
8526
+ const expression = expressions[index] ?? expressions[0];
8527
+ if (expression?.type !== "function_call")
8528
+ continue;
8529
+ const text = expression.text.trim();
8530
+ const match = text.match(/^require\s*\((.*?)\)\s*$/s);
8531
+ if (!match)
8532
+ continue;
8533
+ const moduleId = resolveModuleReference(normalizeRequireTarget(match[1]), moduleAliases);
8534
+ if (moduleId)
8535
+ imports.set(names[index], moduleId);
8536
+ }
8537
+ }
8538
+ return imports;
8539
+ }
7904
8540
  function parameterNames3(node) {
7905
8541
  const parameters = child(node, "parameters");
7906
8542
  if (!parameters)
@@ -7915,7 +8551,7 @@ function parameterNames3(node) {
7915
8551
  }
7916
8552
  return result;
7917
8553
  }
7918
- function declarationParts2(node) {
8554
+ function declarationParts3(node) {
7919
8555
  if (node.type !== "variable_declaration")
7920
8556
  return { names: [], expressions: [] };
7921
8557
  const assignment = child(node, "assignment_statement");
@@ -7951,6 +8587,45 @@ function rootIdentifier2(node) {
7951
8587
  const text = node.text.trim();
7952
8588
  return text.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1] ?? null;
7953
8589
  }
8590
+ function callArguments(node) {
8591
+ if (node.type !== "function_call")
8592
+ return [];
8593
+ return node.childForFieldName("arguments")?.namedChildren ?? [];
8594
+ }
8595
+ function builtinMutatedArgumentIndexes(path9, argumentCount) {
8596
+ switch (path9.replace(/\s+/g, "")) {
8597
+ case "rawset":
8598
+ case "setmetatable":
8599
+ case "table.clear":
8600
+ case "table.freeze":
8601
+ case "table.insert":
8602
+ case "table.remove":
8603
+ case "table.sort":
8604
+ return argumentCount > 0 ? [0] : [];
8605
+ case "table.move":
8606
+ return argumentCount >= 5 ? [4] : argumentCount > 0 ? [0] : [];
8607
+ default:
8608
+ return [];
8609
+ }
8610
+ }
8611
+ function expressionCreatesOwnedValue(node, owned) {
8612
+ if (node.type === "table_constructor" || node.type === "function_definition")
8613
+ return true;
8614
+ if (node.type !== "function_call")
8615
+ return false;
8616
+ const path9 = callPath(node)?.replace(/\s+/g, "") ?? "";
8617
+ if (path9 === "table.clone" || path9 === "table.create" || path9 === "table.pack")
8618
+ return true;
8619
+ if (path9 !== "setmetatable" && path9 !== "table.freeze")
8620
+ return false;
8621
+ const first = callArguments(node)[0];
8622
+ if (!first)
8623
+ return false;
8624
+ if (first.type === "table_constructor")
8625
+ return true;
8626
+ const root = rootIdentifier2(first);
8627
+ return Boolean(root && owned.has(root));
8628
+ }
7954
8629
  function directNodes(body) {
7955
8630
  const result = [];
7956
8631
  const visit = (node) => {
@@ -7981,7 +8656,14 @@ function topLevelFunctionRecords(record) {
7981
8656
  body,
7982
8657
  parameters: parameterNames3(node),
7983
8658
  directEffect: false,
7984
- dependencies: new Set
8659
+ mutatesReceiver: false,
8660
+ mutatedParameterIndexes: new Set,
8661
+ dependencies: new Set,
8662
+ mutationCalls: [],
8663
+ parameterOrigins: new Map,
8664
+ externalRoots: new Set,
8665
+ ownedRoots: new Set,
8666
+ localNames: new Set
7985
8667
  });
7986
8668
  };
7987
8669
  for (const node of record.tree.rootNode.namedChildren) {
@@ -7998,7 +8680,7 @@ function topLevelFunctionRecords(record) {
7998
8680
  continue;
7999
8681
  }
8000
8682
  if (node.type === "variable_declaration") {
8001
- const { names, expressions } = declarationParts2(node);
8683
+ const { names, expressions } = declarationParts3(node);
8002
8684
  for (let index = 0;index < names.length; index += 1) {
8003
8685
  const expression = expressions[index] ?? expressions[0];
8004
8686
  if (expression?.type !== "function_definition")
@@ -8050,7 +8732,7 @@ function moduleLevelInstanceAliases(record, summariesByModuleId) {
8050
8732
  for (const node of record.tree.rootNode.namedChildren) {
8051
8733
  if (node.type !== "variable_declaration")
8052
8734
  continue;
8053
- const { names, expressions } = declarationParts2(node);
8735
+ const { names, expressions } = declarationParts3(node);
8054
8736
  for (let index = 0;index < names.length; index += 1) {
8055
8737
  const expression = expressions[index] ?? expressions[0];
8056
8738
  if (expression?.type !== "function_call")
@@ -8069,34 +8751,50 @@ function moduleLevelInstanceAliases(record, summariesByModuleId) {
8069
8751
  }
8070
8752
  function analyzeFunction(fn, record, localFunctions, memberFunctions, moduleSummaries, moduleInstances, exportedFunctions) {
8071
8753
  const parameters = new Set(fn.parameters);
8072
- if (fn.method)
8754
+ const parameterOrigins = new Map(fn.parameters.map((name, index) => [name, index]));
8755
+ if (fn.method) {
8073
8756
  parameters.add("self");
8757
+ parameterOrigins.set("self", -1);
8758
+ }
8074
8759
  const locals = new Set;
8075
8760
  const owned = new Set;
8076
8761
  const externalAliases = new Set;
8762
+ const refLocals = new Set;
8077
8763
  const instanceAliases = new Map(moduleInstances);
8078
8764
  const nodes = directNodes(fn.body);
8079
8765
  for (const node of nodes) {
8766
+ if (node.type === "function_declaration" && node.id !== fn.node.id) {
8767
+ const declared = node.childForFieldName("name")?.text.replace(/\s+/g, "") ?? "";
8768
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(declared))
8769
+ locals.add(declared);
8770
+ continue;
8771
+ }
8080
8772
  if (node.type !== "variable_declaration")
8081
8773
  continue;
8082
- const { names, expressions } = declarationParts2(node);
8774
+ const { names, expressions } = declarationParts3(node);
8083
8775
  for (let index = 0;index < names.length; index += 1) {
8084
8776
  const name = names[index];
8085
8777
  const expression = expressions[index] ?? expressions[0];
8086
8778
  locals.add(name);
8087
8779
  if (!expression)
8088
8780
  continue;
8089
- if (expression.type === "table_constructor" || expression.type === "function_definition")
8781
+ const createsOwnedValue = expressionCreatesOwnedValue(expression, owned);
8782
+ if (createsOwnedValue)
8090
8783
  owned.add(name);
8091
8784
  const root = rootIdentifier2(expression);
8092
- if (root && (parameters.has(root) || externalAliases.has(root) || !locals.has(root) && root !== name)) {
8785
+ const parameterOrigin = root ? parameterOrigins.get(root) : undefined;
8786
+ if (!createsOwnedValue && root && parameterOrigin !== undefined) {
8787
+ parameterOrigins.set(name, parameterOrigin);
8788
+ } else if (!createsOwnedValue && root && (externalAliases.has(root) || !locals.has(root) && root !== name)) {
8093
8789
  externalAliases.add(name);
8094
8790
  } else if (root && owned.has(root)) {
8095
8791
  owned.add(name);
8096
8792
  }
8097
8793
  if (expression.type === "function_call") {
8098
- const path9 = callPath(expression);
8099
- const match = path9?.match(/^([A-Za-z_][A-Za-z0-9_]*)[.:]([A-Za-z_][A-Za-z0-9_]*)$/);
8794
+ const path9 = callPath(expression)?.replace(/\s+/g, "") ?? "";
8795
+ if (path9 === "React.useRef")
8796
+ refLocals.add(name);
8797
+ const match = path9.match(/^([A-Za-z_][A-Za-z0-9_]*)[.:]([A-Za-z_][A-Za-z0-9_]*)$/);
8100
8798
  if (match) {
8101
8799
  const moduleId = record.imports.get(match[1]);
8102
8800
  const summary = moduleId ? moduleSummaries.get(moduleId) : null;
@@ -8106,6 +8804,10 @@ function analyzeFunction(fn, record, localFunctions, memberFunctions, moduleSumm
8106
8804
  }
8107
8805
  }
8108
8806
  }
8807
+ fn.parameterOrigins = parameterOrigins;
8808
+ fn.externalRoots = externalAliases;
8809
+ fn.ownedRoots = owned;
8810
+ fn.localNames = locals;
8109
8811
  for (const node of nodes) {
8110
8812
  if (node.type === "assignment_statement" && node.parent?.type !== "variable_declaration") {
8111
8813
  const { left } = assignmentSides(node);
@@ -8120,7 +8822,17 @@ function analyzeFunction(fn, record, localFunctions, memberFunctions, moduleSumm
8120
8822
  continue;
8121
8823
  if (owned.has(root))
8122
8824
  continue;
8123
- if (parameters.has(root) || externalAliases.has(root) || !locals.has(root))
8825
+ if (refLocals.has(root) && target.text.replace(/\s+/g, "") === `${root}.current`)
8826
+ continue;
8827
+ const parameterOrigin = parameterOrigins.get(root);
8828
+ if (parameterOrigin !== undefined) {
8829
+ if (parameterOrigin === -1)
8830
+ fn.mutatesReceiver = true;
8831
+ else
8832
+ fn.mutatedParameterIndexes.add(parameterOrigin);
8833
+ continue;
8834
+ }
8835
+ if (externalAliases.has(root) || !locals.has(root))
8124
8836
  fn.directEffect = true;
8125
8837
  }
8126
8838
  continue;
@@ -8130,39 +8842,174 @@ function analyzeFunction(fn, record, localFunctions, memberFunctions, moduleSumm
8130
8842
  const path9 = callPath(node);
8131
8843
  if (!path9)
8132
8844
  continue;
8133
- const localTarget = localFunctions.get(path9);
8134
- if (localTarget)
8845
+ const arguments_ = callArguments(node);
8846
+ for (const index of builtinMutatedArgumentIndexes(path9, arguments_.length)) {
8847
+ markMutationThroughRoot(fn, rootIdentifier2(arguments_[index]));
8848
+ }
8849
+ const localTarget = !locals.has(path9) && !parameters.has(path9) ? localFunctions.get(path9) : null;
8850
+ if (localTarget) {
8135
8851
  fn.dependencies.add(localTarget);
8136
- const sameMember = path9.match(/^(?:self|[A-Za-z_][A-Za-z0-9_]*)[:.]([A-Za-z_][A-Za-z0-9_]*)$/);
8852
+ fn.mutationCalls.push({
8853
+ targetId: localTarget,
8854
+ receiverRoot: null,
8855
+ argumentRoots: arguments_.map((argument) => rootIdentifier2(argument))
8856
+ });
8857
+ }
8858
+ const sameMember = path9.match(/^([A-Za-z_][A-Za-z0-9_]*)([:.])([A-Za-z_][A-Za-z0-9_]*)$/);
8137
8859
  if (sameMember) {
8138
- const receiver = path9.split(/[.:]/, 1)[0];
8139
- if (receiver === "self" || receiver === record.exportName) {
8140
- const target = memberFunctions.get(sameMember[1]);
8141
- if (target)
8860
+ const receiver = sameMember[1];
8861
+ if (receiver === "self" || receiver === record.exportName && !locals.has(receiver) && !parameters.has(receiver)) {
8862
+ const target = memberFunctions.get(sameMember[3]);
8863
+ if (target) {
8142
8864
  fn.dependencies.add(target);
8865
+ fn.mutationCalls.push({
8866
+ targetId: target,
8867
+ receiverRoot: sameMember[2] === ":" ? receiver : null,
8868
+ argumentRoots: arguments_.map((argument) => rootIdentifier2(argument))
8869
+ });
8870
+ }
8143
8871
  }
8144
8872
  }
8145
- const importedMember = path9.match(/^([A-Za-z_][A-Za-z0-9_]*)[.:]([A-Za-z_][A-Za-z0-9_]*)$/);
8873
+ const importedMember = path9.match(/^([A-Za-z_][A-Za-z0-9_]*)([.:])([A-Za-z_][A-Za-z0-9_]*)$/);
8146
8874
  if (importedMember) {
8147
- const moduleId = record.imports.get(importedMember[1]) ?? instanceAliases.get(importedMember[1]);
8148
- if (moduleId)
8149
- fn.dependencies.add(`${moduleId}::member:${importedMember[2]}`);
8150
- } else {
8875
+ const root = importedMember[1];
8876
+ const moduleId = instanceAliases.get(root) ?? (!locals.has(root) && !parameters.has(root) ? record.imports.get(root) : undefined);
8877
+ if (moduleId) {
8878
+ const target = `${moduleId}::member:${importedMember[3]}`;
8879
+ fn.dependencies.add(target);
8880
+ fn.mutationCalls.push({
8881
+ targetId: target,
8882
+ receiverRoot: importedMember[2] === ":" ? root : null,
8883
+ argumentRoots: arguments_.map((argument) => rootIdentifier2(argument))
8884
+ });
8885
+ }
8886
+ } else if (!locals.has(path9) && !parameters.has(path9)) {
8151
8887
  const moduleId = record.imports.get(path9);
8152
8888
  const target = moduleId ? exportedFunctions.get(moduleId) : null;
8153
- if (target)
8889
+ if (target) {
8154
8890
  fn.dependencies.add(target);
8891
+ fn.mutationCalls.push({
8892
+ targetId: target,
8893
+ receiverRoot: null,
8894
+ argumentRoots: arguments_.map((argument) => rootIdentifier2(argument))
8895
+ });
8896
+ }
8897
+ }
8898
+ }
8899
+ }
8900
+ function markMutationThroughRoot(fn, root) {
8901
+ if (!root)
8902
+ return false;
8903
+ const parameterOrigin = fn.parameterOrigins.get(root);
8904
+ if (parameterOrigin !== undefined) {
8905
+ if (parameterOrigin === -1) {
8906
+ if (fn.mutatesReceiver)
8907
+ return false;
8908
+ fn.mutatesReceiver = true;
8909
+ return true;
8910
+ }
8911
+ if (fn.mutatedParameterIndexes.has(parameterOrigin))
8912
+ return false;
8913
+ fn.mutatedParameterIndexes.add(parameterOrigin);
8914
+ return true;
8915
+ }
8916
+ if (fn.ownedRoots.has(root))
8917
+ return false;
8918
+ if (fn.externalRoots.has(root) || !fn.localNames.has(root)) {
8919
+ if (fn.directEffect)
8920
+ return false;
8921
+ fn.directEffect = true;
8922
+ return true;
8923
+ }
8924
+ if (fn.localNames.has(root))
8925
+ return false;
8926
+ return false;
8927
+ }
8928
+ function propagateLocalMutationEffects(functions) {
8929
+ const byId = new Map(functions.map((fn) => [fn.id, fn]));
8930
+ let changed = true;
8931
+ while (changed) {
8932
+ changed = false;
8933
+ for (const fn of functions) {
8934
+ for (const call of fn.mutationCalls) {
8935
+ const target = byId.get(call.targetId);
8936
+ if (!target)
8937
+ continue;
8938
+ if (target.mutatesReceiver && markMutationThroughRoot(fn, call.receiverRoot))
8939
+ changed = true;
8940
+ for (const index of target.mutatedParameterIndexes) {
8941
+ if (markMutationThroughRoot(fn, call.argumentRoots[index] ?? null))
8942
+ changed = true;
8943
+ }
8944
+ }
8945
+ }
8946
+ }
8947
+ }
8948
+ function mutationOriginForRoot(fn, root) {
8949
+ if (!root)
8950
+ return null;
8951
+ const parameterOrigin = fn.parameterOrigins.get(root);
8952
+ if (parameterOrigin === -1)
8953
+ return { kind: "receiver" };
8954
+ if (parameterOrigin !== undefined)
8955
+ return { kind: "parameter", index: parameterOrigin };
8956
+ if (fn.ownedRoots.has(root) || fn.localNames.has(root))
8957
+ return { kind: "local" };
8958
+ if (fn.externalRoots.has(root) || !fn.localNames.has(root))
8959
+ return { kind: "external" };
8960
+ return null;
8961
+ }
8962
+ function cachedMutationCalls(fn) {
8963
+ return fn.mutationCalls.map((call) => ({
8964
+ targetId: call.targetId,
8965
+ receiverOrigin: mutationOriginForRoot(fn, call.receiverRoot),
8966
+ argumentOrigins: call.argumentRoots.map((root) => mutationOriginForRoot(fn, root))
8967
+ }));
8968
+ }
8969
+ function applyCachedMutationOrigin(fn, origin) {
8970
+ if (!origin || origin.kind === "local")
8971
+ return false;
8972
+ if (origin.kind === "external") {
8973
+ if (fn.directEffect)
8974
+ return false;
8975
+ fn.directEffect = true;
8976
+ return true;
8977
+ }
8978
+ if (origin.kind === "receiver") {
8979
+ if (fn.mutatesReceiver)
8980
+ return false;
8981
+ fn.mutatesReceiver = true;
8982
+ return true;
8983
+ }
8984
+ if (fn.mutatedParameterIndexes.includes(origin.index))
8985
+ return false;
8986
+ fn.mutatedParameterIndexes.push(origin.index);
8987
+ fn.mutatedParameterIndexes.sort((a, b) => a - b);
8988
+ return true;
8989
+ }
8990
+ function propagateMutationEffects(functions) {
8991
+ const byId = new Map(functions.map((fn) => [fn.id, fn]));
8992
+ let changed = true;
8993
+ while (changed) {
8994
+ changed = false;
8995
+ for (const fn of functions) {
8996
+ for (const call of fn.mutationCalls) {
8997
+ const target = byId.get(call.targetId);
8998
+ if (!target)
8999
+ continue;
9000
+ if (target.mutatesReceiver && applyCachedMutationOrigin(fn, call.receiverOrigin))
9001
+ changed = true;
9002
+ for (const index of target.mutatedParameterIndexes) {
9003
+ if (applyCachedMutationOrigin(fn, call.argumentOrigins[index] ?? null))
9004
+ changed = true;
9005
+ }
9006
+ }
8155
9007
  }
8156
9008
  }
8157
9009
  }
8158
9010
  async function indexEffectModuleForWorker(input, moduleAliases) {
8159
9011
  const tree = await parseLuau(input.source);
8160
- const imports = new Map;
8161
- for (const match of input.source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)/gs)) {
8162
- const moduleId = resolveModuleReference(normalizeRequireTarget(match[2]), moduleAliases);
8163
- if (moduleId)
8164
- imports.set(match[1], moduleId);
8165
- }
9012
+ const imports = topLevelImports2(tree.rootNode, moduleAliases);
8166
9013
  const record = {
8167
9014
  id: input.id,
8168
9015
  keys: input.keys,
@@ -8203,6 +9050,7 @@ function analyzeEffectModuleForWorker(state, moduleSummaries, exportedFunctions)
8203
9050
  for (const fn of state.functions) {
8204
9051
  analyzeFunction(fn, state.record, localFunctions, memberFunctions, moduleSummaries, moduleInstances, exportedFunctions);
8205
9052
  }
9053
+ propagateLocalMutationEffects(state.functions);
8206
9054
  return {
8207
9055
  id: state.record.id,
8208
9056
  functions: state.functions.map((fn) => ({
@@ -8212,7 +9060,10 @@ function analyzeEffectModuleForWorker(state, moduleSummaries, exportedFunctions)
8212
9060
  memberName: fn.memberName,
8213
9061
  exported: fn.exported,
8214
9062
  directEffect: fn.directEffect,
8215
- dependencies: [...fn.dependencies].sort()
9063
+ mutatesReceiver: fn.mutatesReceiver,
9064
+ mutatedParameterIndexes: [...fn.mutatedParameterIndexes].sort((a, b) => a - b),
9065
+ dependencies: [...fn.dependencies].sort(),
9066
+ mutationCalls: cachedMutationCalls(fn)
8216
9067
  }))
8217
9068
  };
8218
9069
  }
@@ -8289,6 +9140,10 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8289
9140
  summariesByModuleId.set(identity.id, {
8290
9141
  effectfulMembers: new Set,
8291
9142
  effectfulExport: false,
9143
+ mutatingMembers: new Set,
9144
+ mutatingExportParameters: new Set,
9145
+ mutatingMemberParameters: new Map,
9146
+ localMutatingParameters: new Map,
8292
9147
  instanceFactories: new Set(indexed.instanceFactories)
8293
9148
  });
8294
9149
  if (indexed.exportedFunctionId)
@@ -8301,6 +9156,10 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8301
9156
  summariesByModuleId.set(identity.id, {
8302
9157
  effectfulMembers: new Set,
8303
9158
  effectfulExport: false,
9159
+ mutatingMembers: new Set,
9160
+ mutatingExportParameters: new Set,
9161
+ mutatingMemberParameters: new Map,
9162
+ localMutatingParameters: new Map,
8304
9163
  instanceFactories: new Set(cached?.instanceFactories ?? [])
8305
9164
  });
8306
9165
  const exported = functions2.find((fn) => fn.exported && !fn.memberName);
@@ -8336,12 +9195,7 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8336
9195
  const source = candidate.source ?? fs7.readFileSync(candidate.absolutePath, "utf8");
8337
9196
  const tree = await parseLuau(source);
8338
9197
  parseCache?.set(relativePath, { source, tree });
8339
- const imports = new Map;
8340
- for (const match of source.matchAll(/\blocal\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)/gs)) {
8341
- const moduleId = resolveModuleReference(normalizeRequireTarget(match[2]), moduleAliases);
8342
- if (moduleId)
8343
- imports.set(match[1], moduleId);
8344
- }
9198
+ const imports = topLevelImports2(tree.rootNode, moduleAliases);
8345
9199
  rawRecords.set(identity.id, {
8346
9200
  id: identity.id,
8347
9201
  keys: identity.keys,
@@ -8366,6 +9220,10 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8366
9220
  const summary = {
8367
9221
  effectfulMembers: new Set,
8368
9222
  effectfulExport: false,
9223
+ mutatingMembers: new Set,
9224
+ mutatingExportParameters: new Set,
9225
+ mutatingMemberParameters: new Map,
9226
+ localMutatingParameters: new Map,
8369
9227
  instanceFactories: new Set
8370
9228
  };
8371
9229
  for (const fn of functions2) {
@@ -8380,6 +9238,10 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8380
9238
  summariesByModuleId.set(identity.id, {
8381
9239
  effectfulMembers: new Set,
8382
9240
  effectfulExport: false,
9241
+ mutatingMembers: new Set,
9242
+ mutatingExportParameters: new Set,
9243
+ mutatingMemberParameters: new Map,
9244
+ localMutatingParameters: new Map,
8383
9245
  instanceFactories: new Set(cached?.instanceFactories ?? [])
8384
9246
  });
8385
9247
  }
@@ -8421,6 +9283,7 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8421
9283
  for (const fn of functions2) {
8422
9284
  analyzeFunction(fn, record, localFunctions, memberFunctions, summariesByModuleId, moduleInstances, exportedFunctions);
8423
9285
  }
9286
+ propagateLocalMutationEffects(functions2);
8424
9287
  analyzedFunctionStatesByModule.set(moduleId, functions2.map((fn) => ({
8425
9288
  id: fn.id,
8426
9289
  moduleId: fn.moduleId,
@@ -8428,7 +9291,10 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8428
9291
  memberName: fn.memberName,
8429
9292
  exported: fn.exported,
8430
9293
  directEffect: fn.directEffect,
8431
- dependencies: [...fn.dependencies].sort()
9294
+ mutatesReceiver: fn.mutatesReceiver,
9295
+ mutatedParameterIndexes: [...fn.mutatedParameterIndexes].sort((a, b) => a - b),
9296
+ dependencies: [...fn.dependencies].sort(),
9297
+ mutationCalls: cachedMutationCalls(fn)
8432
9298
  })));
8433
9299
  analyzedModuleCount += 1;
8434
9300
  if ((analyzedModuleCount & 31) === 0 || analyzedModuleCount === rawRecords.size) {
@@ -8442,7 +9308,13 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8442
9308
  const cacheModulesResult = {};
8443
9309
  let assembledCount = 0;
8444
9310
  for (const identity of identities) {
8445
- const states = (analyzedFunctionStatesByModule.get(identity.id) ?? cachedFunctionStates.get(identity.id) ?? []).map((fn) => ({ ...fn, dependencies: [...fn.dependencies] }));
9311
+ const states = (analyzedFunctionStatesByModule.get(identity.id) ?? cachedFunctionStates.get(identity.id) ?? []).map((fn) => ({
9312
+ ...fn,
9313
+ mutatesReceiver: fn.mutatesReceiver ?? false,
9314
+ mutatedParameterIndexes: [...fn.mutatedParameterIndexes ?? []],
9315
+ dependencies: [...fn.dependencies],
9316
+ mutationCalls: [...fn.mutationCalls ?? []]
9317
+ }));
8446
9318
  for (const fn of states)
8447
9319
  functionStates.set(fn.id, fn);
8448
9320
  const summary = summariesByModuleId.get(identity.id);
@@ -8459,6 +9331,7 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8459
9331
  onProgress?.({ phase: "assemble-graph", current: assembledCount, total: graphAssemblyTotal });
8460
9332
  }
8461
9333
  }
9334
+ propagateMutationEffects([...functionStates.values()]);
8462
9335
  const reverseDependencies = new Map;
8463
9336
  const functions = [...functionStates.values()];
8464
9337
  onProgress?.({ phase: "resolve", current: 0, total: functions.length });
@@ -8505,18 +9378,34 @@ async function buildProjectSourceEffects(root, candidates, parseCache, onProgres
8505
9378
  for (const summary of summariesByModuleId.values()) {
8506
9379
  summary.effectfulMembers.clear();
8507
9380
  summary.effectfulExport = false;
9381
+ summary.mutatingMembers.clear();
9382
+ summary.mutatingExportParameters.clear();
9383
+ summary.mutatingMemberParameters.clear();
9384
+ summary.localMutatingParameters.clear();
8508
9385
  }
8509
9386
  for (let index = 0;index < functions.length; index += 1) {
8510
9387
  const fn = functions[index];
8511
- if (effectful.has(fn.id)) {
8512
- const summary = summariesByModuleId.get(fn.moduleId);
8513
- if (summary) {
8514
- if (fn.memberName)
8515
- summary.effectfulMembers.add(fn.memberName);
8516
- if (fn.exported)
8517
- summary.effectfulExport = true;
9388
+ const summary = summariesByModuleId.get(fn.moduleId);
9389
+ if (summary && fn.memberName && fn.mutatesReceiver)
9390
+ summary.mutatingMembers.add(fn.memberName);
9391
+ if (summary && fn.mutatedParameterIndexes.length > 0) {
9392
+ if (fn.exported && !fn.memberName) {
9393
+ for (const parameterIndex of fn.mutatedParameterIndexes)
9394
+ summary.mutatingExportParameters.add(parameterIndex);
9395
+ }
9396
+ if (fn.memberName) {
9397
+ summary.mutatingMemberParameters.set(fn.memberName, new Set(fn.mutatedParameterIndexes));
9398
+ }
9399
+ if (fn.localName) {
9400
+ summary.localMutatingParameters.set(fn.localName, new Set(fn.mutatedParameterIndexes));
8518
9401
  }
8519
9402
  }
9403
+ if (summary && effectful.has(fn.id)) {
9404
+ if (fn.memberName)
9405
+ summary.effectfulMembers.add(fn.memberName);
9406
+ if (fn.exported)
9407
+ summary.effectfulExport = true;
9408
+ }
8520
9409
  if ((index & 127) === 127 || index + 1 === functions.length) {
8521
9410
  onProgress?.({ phase: "summarize", current: index + 1, total: summarizeTotal });
8522
9411
  }
@@ -9015,7 +9904,6 @@ var GITLAB_WORKFLOW = ".gitlab-ci.yml";
9015
9904
  var SUMMARY_MARKER = "<!-- react-luau-doctor:summary -->";
9016
9905
  var REVIEW_MARKER = "<!-- react-luau-doctor:review -->";
9017
9906
  var MAX_REVIEW_COMMENTS = 20;
9018
- var MAX_RESOLVE_THREADS_PER_REQUEST = 100;
9019
9907
  var MAX_GITHUB_RETRIES = 2;
9020
9908
  var MAX_RATE_LIMIT_WAIT_MS = 60000;
9021
9909
  var PACKAGE_SPEC = `${package_default.name}@${package_default.version}`;
@@ -9031,10 +9919,8 @@ on:
9031
9919
  branches: [main]
9032
9920
 
9033
9921
  permissions:
9034
- # GitHub currently requires Contents: write for the resolveReviewThread GraphQL mutation.
9035
- contents: ${settings.reviewComments ? "write" : "read"}
9922
+ contents: read
9036
9923
  pull-requests: write
9037
- issues: write
9038
9924
  statuses: write
9039
9925
 
9040
9926
  concurrency:
@@ -9443,23 +10329,6 @@ async function githubApi(repo, endpoint, options = {}) {
9443
10329
  const apiBase = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
9444
10330
  return githubRequest(`${apiBase}/repos/${repo}${endpoint}`, options);
9445
10331
  }
9446
- function githubGraphQLUrl() {
9447
- if (process.env.GITHUB_GRAPHQL_URL)
9448
- return process.env.GITHUB_GRAPHQL_URL;
9449
- const apiBase = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
9450
- return apiBase.endsWith("/api/v3") ? `${apiBase.slice(0, -"/v3".length)}/graphql` : `${apiBase}/graphql`;
9451
- }
9452
- async function githubGraphQL(query, variables) {
9453
- const response = await githubRequest(githubGraphQLUrl(), {
9454
- method: "POST",
9455
- body: { query, variables }
9456
- });
9457
- if (response.errors?.length)
9458
- throw new Error(`GitHub GraphQL: ${response.errors.map((error) => error.message ?? "unknown error").join("; ")}`);
9459
- if (!response.data)
9460
- throw new Error("GitHub GraphQL returned no data");
9461
- return response.data;
9462
- }
9463
10332
  function repoRelativeDiagnosticPath(directory, diagnosticFile) {
9464
10333
  const absoluteDirectory = path11.resolve(directory);
9465
10334
  const repoRoot = findGitRoot(absoluteDirectory);
@@ -9569,54 +10438,32 @@ ${diagnostic.message}${diagnostic.help ? `
9569
10438
 
9570
10439
  ${diagnostic.help}` : ""}`;
9571
10440
  }
9572
- async function listReviewThreads(repo, pullNumber) {
9573
- const separator = repo.indexOf("/");
9574
- if (separator < 1 || separator === repo.length - 1)
9575
- throw new Error(`Invalid GitHub repository name: ${repo}`);
9576
- const owner = repo.slice(0, separator);
9577
- const name = repo.slice(separator + 1);
9578
- const threads = [];
9579
- let cursor = null;
9580
- for (;; ) {
9581
- const data = await githubGraphQL(`
9582
- query DoctorReviewThreads($owner: String!, $name: String!, $pull: Int!, $cursor: String) {
9583
- repository(owner: $owner, name: $name) {
9584
- pullRequest(number: $pull) {
9585
- reviewThreads(first: 100, after: $cursor) {
9586
- nodes {
9587
- id
9588
- isResolved
9589
- isOutdated
9590
- path
9591
- comments(first: 1) {
9592
- nodes {
9593
- body
9594
- author { __typename }
9595
- }
9596
- }
9597
- }
9598
- pageInfo { hasNextPage endCursor }
9599
- }
9600
- }
9601
- }
9602
- }
9603
- `, { owner, name, pull: pullNumber, cursor });
9604
- const connection = data.repository?.pullRequest?.reviewThreads;
9605
- if (!connection)
9606
- throw new Error(`Could not load review threads for pull request ${pullNumber}`);
9607
- threads.push(...connection.nodes);
9608
- if (!connection.pageInfo.hasNextPage)
9609
- return threads;
9610
- cursor = connection.pageInfo.endCursor;
9611
- if (!cursor)
9612
- throw new Error("GitHub review thread pagination returned no cursor");
9613
- }
9614
- }
9615
- function doctorThreadFingerprint(thread) {
9616
- const comment = thread.comments.nodes[0];
9617
- if (comment?.author?.__typename !== "Bot" || !comment.body.startsWith(REVIEW_MARKER))
10441
+ var ARCHIVED_REVIEW_BODY = "\u2705 React-Luau Doctor: findings from this review are no longer current. Active findings, if any, are shown in newer reviews.";
10442
+ async function listReviewComments(repo, pullNumber) {
10443
+ const comments = [];
10444
+ for (let page = 1;; page += 1) {
10445
+ const batch = await githubApi(repo, `/pulls/${pullNumber}/comments?per_page=100&page=${page}`);
10446
+ comments.push(...batch);
10447
+ if (batch.length < 100)
10448
+ return comments;
10449
+ }
10450
+ }
10451
+ async function listReviews(repo, pullNumber) {
10452
+ const reviews = [];
10453
+ for (let page = 1;; page += 1) {
10454
+ const batch = await githubApi(repo, `/pulls/${pullNumber}/reviews?per_page=100&page=${page}`);
10455
+ reviews.push(...batch);
10456
+ if (batch.length < 100)
10457
+ return reviews;
10458
+ }
10459
+ }
10460
+ function isDoctorReview(review) {
10461
+ return review.user?.type === "Bot" && (review.body?.includes("React-Luau Doctor") ?? false);
10462
+ }
10463
+ function doctorReviewCommentFingerprint(comment) {
10464
+ if (comment.user?.type !== "Bot" || !comment.body.startsWith(REVIEW_MARKER))
9618
10465
  return null;
9619
- return fingerprintFromReviewBody(comment.body, thread.path);
10466
+ return fingerprintFromReviewBody(comment.body, comment.path);
9620
10467
  }
9621
10468
  function takeCount(counts, key) {
9622
10469
  const count = counts.get(key) ?? 0;
@@ -9625,58 +10472,64 @@ function takeCount(counts, key) {
9625
10472
  counts.set(key, count - 1);
9626
10473
  return true;
9627
10474
  }
9628
- async function resolveReviewThreads(threadIds) {
9629
- for (let offset = 0;offset < threadIds.length; offset += MAX_RESOLVE_THREADS_PER_REQUEST) {
9630
- const batch = threadIds.slice(offset, offset + MAX_RESOLVE_THREADS_PER_REQUEST);
9631
- const declarations = batch.map((_, index) => `$thread${index}: ID!`).join(", ");
9632
- const mutations = batch.map((_, index) => `thread${index}: resolveReviewThread(input: { threadId: $thread${index} }) { thread { id isResolved } }`).join(`
9633
- `);
9634
- const variables = Object.fromEntries(batch.map((id, index) => [`thread${index}`, id]));
9635
- await githubGraphQL(`mutation ResolveDoctorThreads(${declarations}) { ${mutations} }`, variables);
9636
- if (offset + batch.length < threadIds.length)
9637
- await wait(1000);
9638
- }
10475
+ function incrementCount(counts, key) {
10476
+ counts.set(key, (counts.get(key) ?? 0) + 1);
9639
10477
  }
9640
- async function manageReviewComments(repo, pullNumber, currentReport, newDiagnostics, directory, newBase) {
9641
- const threads = await listReviewThreads(repo, pullNumber);
10478
+ async function manageReviewComments(repo, pullNumber, currentReport, newDiagnostics, directory, newBase, pullBase) {
10479
+ const [allReviewComments, reviews] = await Promise.all([
10480
+ listReviewComments(repo, pullNumber),
10481
+ listReviews(repo, pullNumber)
10482
+ ]);
10483
+ const reviewComments = allReviewComments.map((comment) => ({ comment, fingerprint: doctorReviewCommentFingerprint(comment) })).filter((entry) => entry.fingerprint !== null);
9642
10484
  const activeCounts = new Map;
9643
- for (const diagnostic of currentReport.diagnostics) {
9644
- const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
9645
- activeCounts.set(fingerprint, (activeCounts.get(fingerprint) ?? 0) + 1);
9646
- }
10485
+ for (const diagnostic of currentReport.diagnostics)
10486
+ incrementCount(activeCounts, diagnosticReviewFingerprint(directory, diagnostic));
9647
10487
  const newCounts = new Map;
9648
- for (const diagnostic of newDiagnostics) {
9649
- const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
9650
- newCounts.set(fingerprint, (newCounts.get(fingerprint) ?? 0) + 1);
9651
- }
9652
- const resolvedThreadIds = [];
9653
- for (const thread of threads) {
9654
- if (thread.isResolved)
9655
- continue;
9656
- const fingerprint = doctorThreadFingerprint(thread);
9657
- if (!fingerprint)
9658
- continue;
9659
- if (thread.isOutdated && (newCounts.get(fingerprint) ?? 0) > 0) {
9660
- resolvedThreadIds.push(thread.id);
10488
+ for (const diagnostic of newDiagnostics)
10489
+ incrementCount(newCounts, diagnosticReviewFingerprint(directory, diagnostic));
10490
+ const replacementCounts = new Map;
10491
+ const staleComments = [];
10492
+ const orderedComments = reviewComments.slice().sort((left, right) => Number(left.comment.position === null) - Number(right.comment.position === null));
10493
+ for (const { comment, fingerprint } of orderedComments) {
10494
+ if (comment.position !== null && takeCount(activeCounts, fingerprint)) {
10495
+ takeCount(newCounts, fingerprint);
9661
10496
  continue;
9662
10497
  }
9663
- if (takeCount(activeCounts, fingerprint)) {
10498
+ const needsReplacement = takeCount(activeCounts, fingerprint);
10499
+ if (needsReplacement) {
10500
+ incrementCount(replacementCounts, fingerprint);
9664
10501
  takeCount(newCounts, fingerprint);
9665
- continue;
9666
10502
  }
9667
- resolvedThreadIds.push(thread.id);
10503
+ staleComments.push({ comment, fingerprint, needsReplacement });
9668
10504
  }
9669
- const lineMap = changedLineMap(directory, newBase);
9670
- const comments = newDiagnostics.filter((diagnostic) => touchesChangedLine(diagnostic, lineMap.get(diagnostic.file) ?? [])).filter((diagnostic) => {
10505
+ const newLineMap = changedLineMap(directory, newBase);
10506
+ const pullLineMap = newBase === pullBase ? newLineMap : changedLineMap(directory, pullBase);
10507
+ const candidates = [];
10508
+ for (const diagnostic of newDiagnostics) {
10509
+ if (!touchesChangedLine(diagnostic, newLineMap.get(diagnostic.file) ?? []))
10510
+ continue;
10511
+ const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
10512
+ if (!takeCount(newCounts, fingerprint))
10513
+ continue;
10514
+ candidates.push({ diagnostic, replacement: false });
10515
+ }
10516
+ for (const diagnostic of currentReport.diagnostics) {
10517
+ if (!touchesChangedLine(diagnostic, pullLineMap.get(diagnostic.file) ?? []))
10518
+ continue;
9671
10519
  const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
9672
- return takeCount(newCounts, fingerprint);
9673
- }).slice(0, MAX_REVIEW_COMMENTS).map((diagnostic) => ({
10520
+ if (!takeCount(replacementCounts, fingerprint))
10521
+ continue;
10522
+ candidates.push({ diagnostic, replacement: true });
10523
+ }
10524
+ const selected = candidates.slice(0, MAX_REVIEW_COMMENTS);
10525
+ const comments = selected.map(({ diagnostic }) => ({
9674
10526
  path: repoRelativeDiagnosticPath(directory, diagnostic.file),
9675
10527
  line: diagnostic.location.line,
9676
10528
  side: "RIGHT",
9677
10529
  body: reviewCommentBody(directory, diagnostic)
9678
10530
  }));
9679
10531
  const failures = [];
10532
+ let postedNewComments = true;
9680
10533
  if (comments.length > 0) {
9681
10534
  try {
9682
10535
  await githubApi(repo, `/pulls/${pullNumber}/reviews`, {
@@ -9684,14 +10537,45 @@ async function manageReviewComments(repo, pullNumber, currentReport, newDiagnost
9684
10537
  body: { event: "COMMENT", body: "React-Luau Doctor found new issues", comments }
9685
10538
  });
9686
10539
  } catch (error) {
10540
+ postedNewComments = false;
9687
10541
  failures.push(`could not create new review comments: ${errorMessage(error)}`);
9688
10542
  }
9689
10543
  }
9690
- if (resolvedThreadIds.length > 0) {
9691
- try {
9692
- await resolveReviewThreads(resolvedThreadIds);
9693
- } catch (error) {
9694
- failures.push(`could not resolve fixed review threads: ${errorMessage(error)}`);
10544
+ if (postedNewComments) {
10545
+ const postedReplacementCounts = new Map;
10546
+ for (const candidate of selected) {
10547
+ if (candidate.replacement)
10548
+ incrementCount(postedReplacementCounts, diagnosticReviewFingerprint(directory, candidate.diagnostic));
10549
+ }
10550
+ const remainingByReview = new Map;
10551
+ for (const { comment } of reviewComments) {
10552
+ remainingByReview.set(comment.pull_request_review_id, (remainingByReview.get(comment.pull_request_review_id) ?? 0) + 1);
10553
+ }
10554
+ for (const stale of staleComments) {
10555
+ if (stale.needsReplacement && !takeCount(postedReplacementCounts, stale.fingerprint))
10556
+ continue;
10557
+ try {
10558
+ await githubApi(repo, `/pulls/comments/${stale.comment.id}`, { method: "DELETE" });
10559
+ const reviewId = stale.comment.pull_request_review_id;
10560
+ const remaining = Math.max(0, (remainingByReview.get(reviewId) ?? 1) - 1);
10561
+ remainingByReview.set(reviewId, remaining);
10562
+ } catch (error) {
10563
+ failures.push(`could not delete stale review comment ${stale.comment.id}: ${errorMessage(error)}`);
10564
+ }
10565
+ }
10566
+ for (const review of reviews) {
10567
+ if (!isDoctorReview(review) || review.body === ARCHIVED_REVIEW_BODY)
10568
+ continue;
10569
+ if ((remainingByReview.get(review.id) ?? 0) > 0)
10570
+ continue;
10571
+ try {
10572
+ await githubApi(repo, `/pulls/${pullNumber}/reviews/${review.id}`, {
10573
+ method: "PUT",
10574
+ body: { body: ARCHIVED_REVIEW_BODY }
10575
+ });
10576
+ } catch (error) {
10577
+ failures.push(`could not archive empty review ${review.id}: ${errorMessage(error)}`);
10578
+ }
9695
10579
  }
9696
10580
  }
9697
10581
  if (failures.length > 0)
@@ -9783,7 +10667,7 @@ async function runCiJob(argv) {
9783
10667
  }
9784
10668
  if (settings.reviewComments && base) {
9785
10669
  try {
9786
- await manageReviewComments(repo, pullNumber, report, reviewChanges.diagnostics, path11.resolve(settings.directory), reviewChanges.base ?? base);
10670
+ await manageReviewComments(repo, pullNumber, report, reviewChanges.diagnostics, path11.resolve(settings.directory), reviewChanges.base ?? base, base);
9787
10671
  } catch (error) {
9788
10672
  process.stderr.write(`react-luau-doctor: could not update inline review comments: ${errorMessage(error)}
9789
10673
  `);
@@ -11590,5 +12474,5 @@ ${os2.release()}
11590
12474
  }
11591
12475
  main();
11592
12476
 
11593
- //# debugId=1572E28BBDF3756064756E2164756E21
12477
+ //# debugId=93BE5E0B263571E264756E2164756E21
11594
12478
  //# sourceMappingURL=cli.js.map