@colrealpro/react-luau-doctor 0.20.0 → 0.20.2

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
@@ -3,7 +3,7 @@
3
3
 
4
4
  // src/cli.ts
5
5
  import fs11 from "fs";
6
- import os2 from "os";
6
+ import os3 from "os";
7
7
  import path13 from "path";
8
8
  // package.json
9
9
  var package_default = {
@@ -11,7 +11,7 @@ var package_default = {
11
11
  publishConfig: {
12
12
  access: "public"
13
13
  },
14
- version: "0.20.0",
14
+ version: "0.20.2",
15
15
  description: "Static analysis for React-Luau hooks, effects, rendering, and performance.",
16
16
  license: "MIT",
17
17
  type: "module",
@@ -34,10 +34,10 @@ var package_default = {
34
34
  check: "bunx tsc -p tsconfig.json --noEmit",
35
35
  "check:lsp": "npm --prefix editors/vscode run check",
36
36
  "test:lsp": "bun run scripts/build-lsp.ts && bun test tests/lsp.test.ts",
37
- test: "bun run build && bun test --parallel=4",
37
+ test: "bun run build && bun test --parallel",
38
38
  "doctor:example": "bun run src/cli.ts examples",
39
39
  prepack: "bun run build && bun run check",
40
- ci: "bun run build && bun run check && bun run check:lsp && bun run scripts/build-lsp.ts && bun test --parallel=4 && bun run scripts/verify-package.ts",
40
+ ci: "bun run build && bun run check && bun run check:lsp && bun run scripts/build-lsp.ts && bun test --parallel && bun run scripts/verify-package.ts",
41
41
  "verify:package": "bun run build && bun run scripts/verify-package.ts"
42
42
  },
43
43
  engines: {
@@ -45,7 +45,6 @@ var package_default = {
45
45
  },
46
46
  packageManager: "bun@1.4.0",
47
47
  dependencies: {
48
- "jsonc-parser": "3.3.1",
49
48
  "web-tree-sitter": "0.26.11"
50
49
  },
51
50
  devDependencies: {
@@ -53,6 +52,7 @@ var package_default = {
53
52
  "@types/bun": "1.4.0",
54
53
  "@vscode/vsce": "^4.0.0",
55
54
  esbuild: "^0.28.2",
55
+ "jsonc-parser": "3.3.1",
56
56
  "vscode-languageserver": "^10.1.2",
57
57
  typescript: "5.8.3"
58
58
  },
@@ -121,6 +121,12 @@ function sameNode(left, right) {
121
121
  // src/ast/local-values.ts
122
122
  var valueCache = new WeakMap;
123
123
  var apiCache = new WeakMap;
124
+ function isUnboundIdentifier(context, node) {
125
+ return node.type === "identifier" && visibleValue(context, node) === undefined;
126
+ }
127
+ function localValueBinding(context, node) {
128
+ return node.type === "identifier" ? visibleValue(context, node) : null;
129
+ }
124
130
  function unwrapExpression(node) {
125
131
  while (["parenthesized_expression", "cast_expression"].includes(node.type) && node.namedChildren[0]) {
126
132
  node = node.namedChildren[0];
@@ -546,7 +552,13 @@ function isLazyRefInitialization(context, call) {
546
552
  }
547
553
  return false;
548
554
  }
549
- function isRefInitializer(context, call) {
555
+ var hookInitializerArguments = new Map([
556
+ ["React.useRef", 0],
557
+ ["React.useState", 0],
558
+ ["React.useBinding", 0],
559
+ ["React.useReducer", 1]
560
+ ]);
561
+ function isHookInitializer(context, call) {
550
562
  let node = call;
551
563
  while (node.parent) {
552
564
  const parent = node.parent;
@@ -554,8 +566,11 @@ function isRefInitializer(context, call) {
554
566
  return false;
555
567
  if (parent.type === "arguments") {
556
568
  const hook = parent.parent;
557
- if (hook?.type === "function_call" && reactApiPath(context, hook) === "React.useRef" && context.callArguments(hook)[0]?.id === node.id)
558
- return true;
569
+ if (hook?.type === "function_call") {
570
+ const argumentIndex = hookInitializerArguments.get(reactApiPath(context, hook) ?? "");
571
+ if (argumentIndex !== undefined && context.callArguments(hook)[argumentIndex]?.id === node.id)
572
+ return true;
573
+ }
559
574
  }
560
575
  node = parent;
561
576
  }
@@ -589,18 +604,18 @@ var noBindingGetValueInRender = {
589
604
  id: "react-luau/no-binding-getvalue-in-render",
590
605
  category: "Correctness",
591
606
  severity: "warning",
592
- description: "Do not read binding snapshots during render except to initialize a ref",
607
+ description: "Do not read binding snapshots during render except in hook initialization arguments",
593
608
  guidance: {
594
609
  explanation: "getValue returns a snapshot without subscribing \u2014 later binding updates do not rerender the component or update props built from that snapshot",
595
610
  help: "Pass the binding directly to the prop or use binding:map(function(value) return ... end)",
596
- caveat: "A snapshot used to initialize React.useRef is allowed because the initial value is intentionally preserved"
611
+ caveat: "Snapshots in the first argument to React.useRef, React.useState, or React.useBinding, or the second argument to React.useReducer, are allowed because those arguments are used only for initialization"
597
612
  },
598
613
  run(context) {
599
614
  return context.findCalls().flatMap((call) => {
600
615
  const owner = context.nearestFunction(call);
601
616
  const name = call.childForFieldName("name");
602
617
  const receiver = name?.namedChildren[0];
603
- if (!owner || !owner.isComponent && !owner.isHook || name?.type !== "method_index_expression" || name.namedChildren.at(-1)?.text !== "getValue" || !receiver || !isBinding(context, receiver) || isRefInitializer(context, call))
618
+ if (!owner || !owner.isComponent && !owner.isHook || name?.type !== "method_index_expression" || name.namedChildren.at(-1)?.text !== "getValue" || !receiver || !isBinding(context, receiver) || isHookInitializer(context, call))
604
619
  return [];
605
620
  return [
606
621
  {
@@ -2421,6 +2436,242 @@ var effectNeedsCleanup = {
2421
2436
  }
2422
2437
  };
2423
2438
 
2439
+ // src/api-reference.ts
2440
+ var functions = new Map;
2441
+ function register(namespace, members, reference) {
2442
+ for (const member of members)
2443
+ functions.set(`${namespace}.${member}`, reference);
2444
+ }
2445
+ for (const type of [
2446
+ "Instance",
2447
+ "Random",
2448
+ "RaycastParams",
2449
+ "OverlapParams",
2450
+ "CatalogSearchParams",
2451
+ "SharedTable"
2452
+ ]) {
2453
+ register(type, ["new"], {
2454
+ returnType: type,
2455
+ allocation: "new",
2456
+ equality: "reference"
2457
+ });
2458
+ }
2459
+ register("table", ["clone", "create", "pack"], {
2460
+ returnType: "table",
2461
+ allocation: "new",
2462
+ equality: "reference"
2463
+ });
2464
+ register("SharedTable", ["clone", "cloneAndFreeze"], {
2465
+ returnType: "SharedTable",
2466
+ allocation: "new",
2467
+ equality: "reference"
2468
+ });
2469
+ register("string", ["split"], {
2470
+ returnType: "table",
2471
+ allocation: "new",
2472
+ equality: "reference"
2473
+ });
2474
+ register("buffer", ["create", "fromstring"], {
2475
+ returnType: "buffer",
2476
+ allocation: "new",
2477
+ equality: "reference"
2478
+ });
2479
+ register("coroutine", ["create"], {
2480
+ returnType: "thread",
2481
+ allocation: "new",
2482
+ equality: "reference"
2483
+ });
2484
+ register("coroutine", ["wrap"], {
2485
+ returnType: "function",
2486
+ allocation: "new",
2487
+ equality: "reference"
2488
+ });
2489
+ register("table", ["freeze"], {
2490
+ returnType: "table",
2491
+ allocation: "argument",
2492
+ equality: "reference",
2493
+ argumentIndex: 0
2494
+ });
2495
+ for (const [type, members] of Object.entries({
2496
+ Color3: ["new", "fromRGB", "fromHSV", "fromHex"],
2497
+ ColorSequence: ["new"],
2498
+ ColorSequenceKeypoint: ["new"],
2499
+ NumberSequence: ["new"],
2500
+ NumberSequenceKeypoint: ["new"],
2501
+ NumberRange: ["new"],
2502
+ UDim: ["new"],
2503
+ UDim2: ["new", "fromScale", "fromOffset"],
2504
+ Vector2: ["new"],
2505
+ Vector3: ["new", "fromNormalId", "fromAxis"],
2506
+ CFrame: [
2507
+ "new",
2508
+ "Angles",
2509
+ "fromEulerAnglesXYZ",
2510
+ "fromEulerAnglesYXZ",
2511
+ "fromOrientation",
2512
+ "fromAxisAngle",
2513
+ "fromMatrix",
2514
+ "lookAt",
2515
+ "lookAlong"
2516
+ ],
2517
+ Rect: ["new"],
2518
+ BrickColor: ["new", "palette"]
2519
+ })) {
2520
+ register(type, members, {
2521
+ returnType: type,
2522
+ allocation: "new",
2523
+ equality: "value"
2524
+ });
2525
+ }
2526
+ var instanceMethods = new Map([
2527
+ ...["GetChildren", "GetDescendants", "GetAttributes", "GetTags"].map((member) => [
2528
+ member,
2529
+ { returnType: "table", allocation: "new", equality: "reference" }
2530
+ ]),
2531
+ [
2532
+ "Clone",
2533
+ { returnType: "Instance", allocation: "new", equality: "reference" }
2534
+ ]
2535
+ ]);
2536
+ function apiFunctionReference(path) {
2537
+ return functions.get(path) ?? null;
2538
+ }
2539
+ function globalPath(context, expression, seen = new Set) {
2540
+ const node = unwrapExpression(expression);
2541
+ if (seen.has(node.id))
2542
+ return null;
2543
+ seen.add(node.id);
2544
+ if (node.type === "identifier") {
2545
+ if (isUnboundIdentifier(context, node))
2546
+ return node.text;
2547
+ const binding = localValueBinding(context, node);
2548
+ return binding?.returnIndex === 0 ? globalPath(context, binding.value, seen) : null;
2549
+ }
2550
+ if (node.type !== "dot_index_expression")
2551
+ return null;
2552
+ const namespace = node.childForFieldName("table");
2553
+ const member = node.childForFieldName("field");
2554
+ const path = namespace && globalPath(context, namespace, seen);
2555
+ return path && member ? `${path}.${member.text}` : null;
2556
+ }
2557
+ function isInstanceReceiver(context, expression, seen = new Set) {
2558
+ const node = unwrapExpression(expression);
2559
+ if (seen.has(node.id))
2560
+ return false;
2561
+ seen.add(node.id);
2562
+ if (node.type === "identifier") {
2563
+ if (isUnboundIdentifier(context, node))
2564
+ return ["game", "workspace", "script"].includes(node.text);
2565
+ const binding = localValueBinding(context, node);
2566
+ return binding?.returnIndex === 0 && isInstanceReceiver(context, binding.value, seen);
2567
+ }
2568
+ if (node.type === "dot_index_expression") {
2569
+ const receiver = node.childForFieldName("table");
2570
+ return Boolean(receiver && isInstanceReceiver(context, receiver, seen));
2571
+ }
2572
+ if (node.type !== "function_call")
2573
+ return false;
2574
+ const name = node.childForFieldName("name");
2575
+ if (!name)
2576
+ return false;
2577
+ if (globalPath(context, name) === "Instance.new")
2578
+ return true;
2579
+ if (name.type !== "method_index_expression")
2580
+ return false;
2581
+ const receiver = name.childForFieldName("table");
2582
+ const member = name.childForFieldName("method")?.text;
2583
+ return Boolean(receiver && member && [
2584
+ "GetService",
2585
+ "WaitForChild",
2586
+ "FindFirstChild",
2587
+ "FindFirstAncestor",
2588
+ "Clone"
2589
+ ].includes(member) && isInstanceReceiver(context, receiver, seen));
2590
+ }
2591
+ function apiReferenceForCall(context, call) {
2592
+ const name = call.childForFieldName("name");
2593
+ if (!name)
2594
+ return null;
2595
+ const path = globalPath(context, name);
2596
+ if (path)
2597
+ return apiFunctionReference(path);
2598
+ if (name.type !== "method_index_expression")
2599
+ return null;
2600
+ const receiver = name.childForFieldName("table");
2601
+ const member = name.childForFieldName("method")?.text;
2602
+ return receiver && member && isInstanceReceiver(context, receiver) ? instanceMethods.get(member) ?? null : null;
2603
+ }
2604
+
2605
+ // src/ast/value-identity.ts
2606
+ function freshValueKind(context, expression, lifetime, seen = new Set, returnIndex = 0, lifetimes = new Set([lifetime])) {
2607
+ if (!expression)
2608
+ return null;
2609
+ const node = unwrapExpression(expression);
2610
+ if (seen.has(node.id) || seen.size >= 64)
2611
+ return null;
2612
+ const visited = new Set(seen).add(node.id);
2613
+ const analyze = (value, owner = lifetime, index = 0) => freshValueKind(context, value, owner, visited, index, new Set(lifetimes).add(owner));
2614
+ if (node.type === "identifier") {
2615
+ const binding = resolveLocalValue(context, node);
2616
+ const owner = binding && context.nearestFunction(binding.value);
2617
+ if (!binding || !owner || !lifetimes.has(owner))
2618
+ return null;
2619
+ return analyze(binding.value, lifetime, binding.returnIndex);
2620
+ }
2621
+ if (returnIndex === 0) {
2622
+ if (node.type === "table_constructor")
2623
+ return "table";
2624
+ if (node.type === "function_definition" || node.type === "function_declaration")
2625
+ return "function";
2626
+ if (node.type === "binary_expression") {
2627
+ const operator = node.children.find((child) => ["and", "or"].includes(child.type))?.type;
2628
+ if (operator === "and")
2629
+ return analyze(node.childForFieldName("right"));
2630
+ if (operator === "or")
2631
+ return analyze(node.childForFieldName("left")) ?? analyze(node.childForFieldName("right"));
2632
+ }
2633
+ if (node.type === "if_expression") {
2634
+ for (const branch of [
2635
+ node,
2636
+ ...node.namedChildren.filter((child) => ["elseif_clause", "else_clause"].includes(child.type))
2637
+ ]) {
2638
+ const kind = analyze(branch.childForFieldName("consequence"));
2639
+ if (kind)
2640
+ return kind;
2641
+ }
2642
+ }
2643
+ }
2644
+ if (node.type !== "function_call")
2645
+ return null;
2646
+ const api = apiReferenceForCall(context, node);
2647
+ if (api && returnIndex === 0) {
2648
+ if (api.allocation === "argument")
2649
+ return analyze(context.callArguments(node)[api.argumentIndex ?? 0] ?? null);
2650
+ if (api.equality !== "reference")
2651
+ return null;
2652
+ return api.returnType === "table" || api.returnType === "function" ? api.returnType : "object";
2653
+ }
2654
+ const name = node.childForFieldName("name");
2655
+ const factory = name && resolveLocalFunction(context, name);
2656
+ if (!factory?.body)
2657
+ return null;
2658
+ for (const statement of context.walk(factory.body)) {
2659
+ if (statement.type !== "return_statement" || context.nearestFunction(statement) !== factory)
2660
+ continue;
2661
+ const values = statement.namedChildren.find((child) => child.type === "expression_list")?.namedChildren ?? [];
2662
+ const value = values[returnIndex] ?? values.at(-1);
2663
+ if (!value)
2664
+ continue;
2665
+ const index = Math.max(0, returnIndex - values.length + 1);
2666
+ if (index > 0 && value.type !== "function_call")
2667
+ continue;
2668
+ const kind = analyze(value, factory, index);
2669
+ if (kind)
2670
+ return kind;
2671
+ }
2672
+ return null;
2673
+ }
2674
+
2424
2675
  // src/rules/effect-state-rules.ts
2425
2676
  var HOOKS_WITH_DEPS = new Set([
2426
2677
  "React.useEffect",
@@ -2484,23 +2735,6 @@ function hasExternalSubscription(node, context) {
2484
2735
  }
2485
2736
  return false;
2486
2737
  }
2487
- function directLocalInitializer(owner, name) {
2488
- if (!owner.body)
2489
- return null;
2490
- for (const statement of owner.body.namedChildren) {
2491
- if (statement.type !== "variable_declaration")
2492
- continue;
2493
- const assignment = statement.namedChildren.find((child) => child.type === "assignment_statement");
2494
- const variableList = assignment?.namedChildren.find((child) => child.type === "variable_list");
2495
- const expressionList = assignment?.namedChildren.find((child) => child.type === "expression_list");
2496
- const names = variableList?.namedChildren.filter((child) => child.type === "identifier") ?? [];
2497
- const expressions = expressionList?.namedChildren ?? [];
2498
- const index = names.findIndex((child) => child.text === name);
2499
- if (index >= 0)
2500
- return expressions[index] ?? expressions[0] ?? null;
2501
- }
2502
- return null;
2503
- }
2504
2738
  function setterCalledOutsideCallback(context, owner, callback, setterName) {
2505
2739
  if (!owner.body)
2506
2740
  return false;
@@ -2540,20 +2774,6 @@ function guaranteedRepeatedStateChange(argument, stateName, context) {
2540
2774
  }
2541
2775
  return false;
2542
2776
  }
2543
- function freshDependencyKind(node, owner) {
2544
- if (node.type === "table_constructor")
2545
- return "table";
2546
- if (node.type === "function_definition")
2547
- return "function";
2548
- if (node.type === "identifier") {
2549
- const initializer = directLocalInitializer(owner, node.text);
2550
- if (initializer?.type === "table_constructor")
2551
- return "table";
2552
- if (initializer?.type === "function_definition")
2553
- return "function";
2554
- }
2555
- return null;
2556
- }
2557
2777
  var noDerivedStateEffect = {
2558
2778
  id: "react-luau/no-derived-state-effect",
2559
2779
  category: "Effects",
@@ -2665,7 +2885,7 @@ var noEffectWithFreshDeps = {
2665
2885
  id: "react-luau/no-effect-with-fresh-deps",
2666
2886
  category: "Hooks",
2667
2887
  severity: "error",
2668
- description: "Dependency tables should not contain tables or functions recreated on every render.",
2888
+ description: "Dependency tables should not contain reference values recreated during render",
2669
2889
  run(context) {
2670
2890
  const diagnostics = [];
2671
2891
  for (const call of context.findCalls()) {
@@ -2677,14 +2897,14 @@ var noEffectWithFreshDeps = {
2677
2897
  if (!owner || !parts)
2678
2898
  continue;
2679
2899
  for (const dependency of dependencyExpressions(parts.deps)) {
2680
- const kind = freshDependencyKind(dependency, owner);
2900
+ const kind = freshValueKind(context, dependency, owner);
2681
2901
  if (!kind)
2682
2902
  continue;
2683
2903
  const label = dependency.type === "identifier" ? ` ${dependency.text}` : "";
2684
2904
  diagnostics.push({
2685
2905
  node: dependency,
2686
- message: `${path} depends on${label || " an inline value"} that is a new ${kind} on every render.`,
2687
- help: "Move the value inside the hook callback and depend on its simple inputs, or intentionally stabilize the value with useMemo/useCallback when identity is part of the contract."
2906
+ message: `${path} depends on${label || " an inline value"} that can be a new ${kind} when the component renders`,
2907
+ help: "Move the value inside the hook callback and depend on its simple inputs, or intentionally stabilize the value with useMemo/useCallback when identity is part of the contract"
2688
2908
  });
2689
2909
  }
2690
2910
  }
@@ -4791,40 +5011,32 @@ function componentRenderCost(context, owner) {
4791
5011
  parts.push(`${discovery} Instance discovery call${discovery === 1 ? "" : "s"}`);
4792
5012
  return parts;
4793
5013
  }
4794
- function memoizedImports(context) {
4795
- const result = new Map;
4796
- for (const node of context.walk(context.root)) {
4797
- if (node.type !== "variable_declaration")
4798
- continue;
4799
- const match = node.text.match(/^\s*local\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*require\s*\((.*?)\)\s*$/s);
4800
- if (!match)
4801
- continue;
4802
- const localName = match[1];
4803
- const memoKind = resolveModuleReference(normalizeRequireTarget(match[2]), context.project.memoizedModules);
4804
- if (memoKind)
4805
- result.set(localName, memoKind);
5014
+ function memoizedComponentKind(context, expression) {
5015
+ const binding = resolveLocalValue(context, expression);
5016
+ if (!binding || binding.returnIndex !== 0 || binding.value.type !== "function_call")
5017
+ return null;
5018
+ const call = binding.value;
5019
+ if (reactApiPath(context, call) === "React.memo") {
5020
+ return context.callArguments(call).length > 1 ? "custom" : "shallow";
4806
5021
  }
4807
- return result;
5022
+ const target = context.callArguments(call)[0];
5023
+ if (context.getCallPath(call) !== "require" || !target)
5024
+ return null;
5025
+ return resolveModuleReference(normalizeRequireTarget(target.text), context.project.memoizedModules) ?? null;
4808
5026
  }
4809
5027
  function fieldLabel(field) {
4810
5028
  return fieldName(field) ?? field.childForFieldName("name")?.text ?? "prop";
4811
5029
  }
4812
- function isInlineFreshValue(node) {
4813
- return node?.type === "table_constructor" || node?.type === "function_definition";
4814
- }
4815
5030
  var rerenderUnstableMemoProps = {
4816
5031
  id: "react-luau/rerender-unstable-memo-props",
4817
5032
  category: "Performance",
4818
5033
  severity: "warning",
4819
- description: "Warn when fresh table or function props defeat shallow React.memo comparisons.",
5034
+ description: "Warn when props created during render can defeat shallow React.memo comparisons",
4820
5035
  guidance: {
4821
- explanation: "A new function or table has a new identity, so a shallow memo comparison cannot skip this child.",
4822
- help: "Stabilize props that change each render if this child is expensive, move construction into the child, or remove ineffective memoization."
5036
+ explanation: "New tables, functions, and reference objects compare unequal even when their contents match",
5037
+ help: "Stabilize props that change each render if this child is expensive, move construction into the child, or remove ineffective memoization"
4823
5038
  },
4824
5039
  run(context) {
4825
- const imports = memoizedImports(context);
4826
- if (imports.size === 0)
4827
- return [];
4828
5040
  const diagnostics = [];
4829
5041
  for (const call of context.findCalls()) {
4830
5042
  if (context.resolveCallPath(context.getCallPath(call) ?? "") !== "React.createElement")
@@ -4834,12 +5046,13 @@ var rerenderUnstableMemoProps = {
4834
5046
  continue;
4835
5047
  const args = context.callArguments(call);
4836
5048
  const childName = args[0]?.text.trim() ?? "";
4837
- if (imports.get(childName) !== "shallow")
5049
+ if (!args[0] || memoizedComponentKind(context, args[0]) !== "shallow")
4838
5050
  continue;
4839
- const props = args[1];
4840
- if (!props || props.type !== "table_constructor")
5051
+ const propsBinding = args[1] && resolveLocalValue(context, args[1]);
5052
+ const props = propsBinding?.returnIndex === 0 ? propsBinding.value : null;
5053
+ if (!props || props.type !== "table_constructor" || context.nearestFunction(props) !== component)
4841
5054
  continue;
4842
- const unstableFields = props.namedChildren.filter((node) => node.type === "field").filter((field) => isInlineFreshValue(fieldValue(field)));
5055
+ const unstableFields = props.namedChildren.filter((node) => node.type === "field").filter((field) => freshValueKind(context, fieldValue(field), component));
4843
5056
  if (unstableFields.length === 0)
4844
5057
  continue;
4845
5058
  const unstable = unstableFields.map(fieldLabel);
@@ -4848,13 +5061,13 @@ var rerenderUnstableMemoProps = {
4848
5061
  node: highlights[0],
4849
5062
  highlights,
4850
5063
  severity: "warning",
4851
- message: `Memoized ${childName} receives fresh ${unstable.join(", ")} prop${unstable.length === 1 ? "" : "s"} on every parent render.`,
4852
- summary: `${childName} gets ${unstable.length} fresh ${unstable.length === 1 ? "prop" : "props"} each render. Memoization cannot skip it.`,
5064
+ message: `Memoized ${childName} can receive fresh ${unstable.join(", ")} prop${unstable.length === 1 ? "" : "s"} when its parent renders`,
5065
+ summary: `${childName} gets ${unstable.length} ${unstable.length === 1 ? "prop" : "props"} created during render that can prevent memoization from skipping it`,
4853
5066
  editorRanges: highlights.map((node, index) => ({
4854
5067
  node,
4855
- summary: `${childName} gets a fresh ${unstable[index]} prop each render. Memoization cannot skip it.`
5068
+ summary: `${childName} gets a ${unstable[index]} prop created during render that can prevent memoization from skipping it`
4856
5069
  })),
4857
- help: "Fresh table or function identity prevents the shallow memo comparison from skipping this child. Stabilize the prop only when the parent rerenders frequently, move construction into the child, or remove ineffective memoization."
5070
+ help: "Fresh reference identity can prevent the shallow memo comparison from skipping this child. Stabilize the prop when the parent rerenders frequently, move construction into the child, or remove ineffective memoization"
4858
5071
  });
4859
5072
  }
4860
5073
  return diagnostics;
@@ -7028,30 +7241,32 @@ function currentConditionalHookMode(context) {
7028
7241
  }
7029
7242
  return null;
7030
7243
  }
7031
- function controlledParameterIndex(controlFlow, fn, summary) {
7244
+ function controlledParameterIndex(controlFlow, fn, summary, node) {
7032
7245
  if (controlFlow.type !== "if_statement")
7033
7246
  return null;
7034
- const header = (controlFlow.text.split(/\bthen\b/s, 1)[0] ?? "").trim();
7035
- for (const [conditionName, index] of Object.entries(summary.conditionVariables)) {
7036
- const escaped = conditionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7037
- const patterns = [
7038
- new RegExp(`^if\\s+${escaped}\\s*$`),
7039
- new RegExp(`^if\\s+not\\s+${escaped}\\s*$`),
7040
- new RegExp(`^if\\s+${escaped}\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`),
7041
- new RegExp(`^if\\s+(?:true|false|nil)\\s*(?:==|~=)\\s*${escaped}\\s*$`)
7042
- ];
7043
- if (patterns.some((pattern) => pattern.test(header)))
7044
- return index;
7045
- }
7247
+ const conditions = guardConditions(controlFlow, node);
7248
+ if (conditions.length === 0)
7249
+ return null;
7250
+ const variables = { ...summary.conditionVariables };
7046
7251
  for (const index of summary.parameterIndexes) {
7047
7252
  const name = fn.parameters[index];
7048
- if (!name)
7049
- continue;
7050
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7051
- if (new RegExp(`^if\\s+(?:not\\s+)?${escaped}\\s*$`).test(header))
7052
- return index;
7253
+ if (name)
7254
+ variables[name] ??= index;
7053
7255
  }
7054
- return null;
7256
+ const indexes = conditions.map((condition) => {
7257
+ for (const [name, index] of Object.entries(variables)) {
7258
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7259
+ const patterns = [
7260
+ new RegExp(`^(?:not\\s+)?${escaped}\\s*$`),
7261
+ new RegExp(`^${escaped}\\s*(?:==|~=)\\s*(?:true|false|nil)\\s*$`),
7262
+ new RegExp(`^(?:true|false|nil)\\s*(?:==|~=)\\s*${escaped}\\s*$`)
7263
+ ];
7264
+ if (patterns.some((pattern) => pattern.test(condition.text.trim())))
7265
+ return index;
7266
+ }
7267
+ return null;
7268
+ });
7269
+ return indexes.every((index) => index !== null) ? indexes[0] : null;
7055
7270
  }
7056
7271
  function dynamicHookModeFixPreview(hookName, argumentText) {
7057
7272
  return {
@@ -7253,28 +7468,104 @@ function staticModuleTables(context) {
7253
7468
  }
7254
7469
  return candidates;
7255
7470
  }
7256
- function simpleConditionRoot(controlFlow) {
7257
- if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
7258
- return null;
7471
+ function containsNode(container, node) {
7472
+ return node.startIndex >= container.startIndex && node.endIndex <= container.endIndex;
7473
+ }
7474
+ function branchContaining(controlFlow, node) {
7475
+ return controlFlow.namedChildren.find((child) => containsNode(child, node)) ?? null;
7476
+ }
7477
+ function guardConditions(controlFlow, node) {
7259
7478
  const condition = controlFlow.namedChildren[0];
7260
7479
  if (!condition)
7261
- return null;
7480
+ return [];
7481
+ if (controlFlow.type !== "if_statement" && controlFlow.type !== "if_expression")
7482
+ return [condition];
7483
+ if (containsNode(condition, node))
7484
+ return [];
7485
+ const conditions = [condition];
7486
+ for (const clause of controlFlow.namedChildren.slice(1)) {
7487
+ if (clause.startIndex > node.startIndex)
7488
+ break;
7489
+ if (clause.type !== "elseif_statement" && clause.type !== "elseif_clause")
7490
+ continue;
7491
+ const clauseCondition = clause.namedChildren[0];
7492
+ if (clauseCondition && !containsNode(clauseCondition, node))
7493
+ conditions.push(clauseCondition);
7494
+ }
7495
+ return conditions;
7496
+ }
7497
+ function conditionallyContains(controlFlow, node) {
7498
+ if (controlFlow.type === "if_statement" || controlFlow.type === "if_expression")
7499
+ return guardConditions(controlFlow, node).length > 0;
7500
+ if (controlFlow.type === "for_statement")
7501
+ return controlFlow.namedChildren.some((child) => child.type === "block" && containsNode(child, node));
7502
+ if (CONDITIONAL_TYPES.has(controlFlow.type))
7503
+ return true;
7504
+ if (controlFlow.type !== "binary_expression" || !controlFlow.children.some((child) => child.type === "and" || child.type === "or"))
7505
+ return false;
7506
+ const right = controlFlow.childForFieldName("right") ?? controlFlow.namedChildren[1];
7507
+ return Boolean(right && containsNode(right, node));
7508
+ }
7509
+ function simpleConditionRoot(condition) {
7262
7510
  const text = condition.text.trim();
7263
7511
  const match = text.match(/^(?:not\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:==|~=)\s*(?:true|false|nil))?$/);
7264
7512
  return match?.[1] ?? null;
7265
7513
  }
7266
- function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants) {
7267
- const condition = controlFlow.namedChildren[0];
7268
- if (!condition)
7514
+ var unstableLocalNamesCache = new WeakMap;
7515
+ function unstableLocalNames(fn, context) {
7516
+ const cached = unstableLocalNamesCache.get(fn);
7517
+ if (cached)
7518
+ return cached;
7519
+ const declared = new Set;
7520
+ const unstable = new Set;
7521
+ for (const node of context.walk(fn.node)) {
7522
+ if (node.type === "variable_declaration" && context.nearestFunction(node) === fn) {
7523
+ for (const name of declarationNames(node)) {
7524
+ if (declared.has(name))
7525
+ unstable.add(name);
7526
+ declared.add(name);
7527
+ }
7528
+ }
7529
+ if (node.type !== "assignment_statement" || node.parent?.type === "variable_declaration")
7530
+ continue;
7531
+ const variables = node.namedChildren.find((child) => child.type === "variable_list");
7532
+ for (const target of variables?.namedChildren ?? []) {
7533
+ if (target.type === "identifier")
7534
+ unstable.add(target.text);
7535
+ }
7536
+ }
7537
+ unstableLocalNamesCache.set(fn, unstable);
7538
+ return unstable;
7539
+ }
7540
+ function moduleNameIsUnshadowed(name, node, fn, context) {
7541
+ let owner = fn;
7542
+ while (owner) {
7543
+ if (owner.parameters.includes(name) || isBindingShadowedBetween(node, owner, name))
7544
+ return false;
7545
+ owner = context.nearestFunction(owner.node);
7546
+ }
7547
+ return true;
7548
+ }
7549
+ function conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants, node = controlFlow) {
7550
+ if (!["if_statement", "if_expression", "binary_expression"].includes(controlFlow.type))
7269
7551
  return false;
7270
- if (["true", "false", "nil", "number", "string"].includes(condition.type))
7271
- return true;
7272
- const root = simpleConditionRoot(controlFlow);
7273
- if (!root)
7552
+ const conditions = guardConditions(controlFlow, node);
7553
+ if (conditions.length === 0)
7274
7554
  return false;
7275
- if (moduleInvariants.has(root))
7276
- return true;
7277
- return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
7555
+ return conditions.every((condition) => {
7556
+ if (["true", "false", "nil", "number", "string"].includes(condition.type))
7557
+ return true;
7558
+ const root = simpleConditionRoot(condition);
7559
+ if (!root)
7560
+ return false;
7561
+ if (moduleInvariants.has(root) && moduleNameIsUnshadowed(root, condition, fn, context))
7562
+ return true;
7563
+ if (isNameShadowedBetween(condition, fn, root))
7564
+ return false;
7565
+ if (unstableLocalNames(fn, context).has(root))
7566
+ return false;
7567
+ return Boolean(context.model.stableVariablesByFunction.get(nodeKey(fn.node))?.has(root));
7568
+ });
7278
7569
  }
7279
7570
  function branchHookSequence(branch, fn, context) {
7280
7571
  const hooks = [];
@@ -7289,28 +7580,45 @@ function branchHookSequence(branch, fn, context) {
7289
7580
  const path = context.resolveCallPath(rawPath);
7290
7581
  if (!isHookPath(path))
7291
7582
  continue;
7292
- const nestedControl = findAncestorBetween(node, branch, (ancestor) => CONDITIONAL_TYPES.has(ancestor.type));
7293
- if (nestedControl)
7583
+ const nestedControl = node.id === branch.id ? null : findAncestorBetween(node, branch, (ancestor) => conditionallyContains(ancestor, node));
7584
+ if (nestedControl || conditionallyContains(branch, node))
7294
7585
  return null;
7295
7586
  hooks.push(path);
7296
7587
  }
7297
7588
  return hooks;
7298
7589
  }
7299
7590
  function ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context) {
7300
- if (controlFlow.type !== "if_statement")
7591
+ const isStatement = controlFlow.type === "if_statement";
7592
+ if (!isStatement && controlFlow.type !== "if_expression")
7301
7593
  return false;
7302
- const thenBlock = controlFlow.namedChildren.find((child) => child.type === "block");
7303
- const elseStatement = controlFlow.namedChildren.find((child) => child.type === "else_statement");
7304
- const elseBlock = elseStatement?.namedChildren.find((child) => child.type === "block");
7305
- if (!thenBlock || !elseBlock)
7594
+ const firstBranch = isStatement ? controlFlow.namedChildren.find((child) => child.type === "block") : controlFlow.namedChildren[1];
7595
+ const elseClause = controlFlow.namedChildren.find((child) => child.type === "else_statement" || child.type === "else_clause");
7596
+ const lastBranch = isStatement ? elseClause?.namedChildren.find((child) => child.type === "block") : elseClause?.namedChildren[0];
7597
+ if (!firstBranch || !lastBranch)
7306
7598
  return false;
7307
- const thenHooks = branchHookSequence(thenBlock, fn, context);
7308
- const elseHooks = branchHookSequence(elseBlock, fn, context);
7309
- if (!thenHooks || !elseHooks || thenHooks.length !== elseHooks.length)
7599
+ const branches = [firstBranch];
7600
+ for (const clause of controlFlow.namedChildren) {
7601
+ if (clause.type !== "elseif_statement" && clause.type !== "elseif_clause")
7602
+ continue;
7603
+ const condition = clause.namedChildren[0];
7604
+ if (!condition)
7605
+ return false;
7606
+ const conditionHooks = branchHookSequence(condition, fn, context);
7607
+ if (!conditionHooks || conditionHooks.length > 0)
7608
+ return false;
7609
+ const branch = isStatement ? clause.namedChildren.find((child) => child.type === "block") : clause.namedChildren[1];
7610
+ if (!branch)
7611
+ return false;
7612
+ branches.push(branch);
7613
+ }
7614
+ branches.push(lastBranch);
7615
+ const sequences = branches.map((branch) => branchHookSequence(branch, fn, context));
7616
+ const first = sequences[0];
7617
+ if (!first)
7310
7618
  return false;
7311
- if (!thenHooks.every((path, index) => path === elseHooks[index]))
7619
+ if (!sequences.every((sequence) => sequence && sequence.length === first.length && sequence.every((path, index) => path === first[index])))
7312
7620
  return false;
7313
- return thenHooks.every((path) => path.startsWith("React."));
7621
+ return first.every((path) => path.startsWith("React."));
7314
7622
  }
7315
7623
  function stateModeStability(name, owner, context) {
7316
7624
  const binding = context.model.stateBindings.find((candidate) => candidate.owner === owner && candidate.valueName === name);
@@ -7415,7 +7723,21 @@ function controlledAccessPaths(summary, parameterIndex) {
7415
7723
  paths.add("");
7416
7724
  return [...paths];
7417
7725
  }
7418
- function loopIterationIsProvablyStable(loop, imports, owner, stableShapes, moduleTables) {
7726
+ function loopIterationIsProvablyStable(loop, imports, owner, stableShapes, moduleTables, context, moduleInvariants) {
7727
+ for (const node of context.walk(loop)) {
7728
+ if (!["break_statement", "continue_statement", "return_statement"].includes(node.type))
7729
+ continue;
7730
+ if (context.nearestFunction(node) !== owner)
7731
+ continue;
7732
+ if (node.type !== "return_statement") {
7733
+ const targetLoop = findAncestorBetween(node, owner.node, (ancestor) => ["for_statement", "while_statement", "repeat_statement"].includes(ancestor.type));
7734
+ if (targetLoop?.id !== loop.id)
7735
+ continue;
7736
+ }
7737
+ const dynamicGuard = findAncestorBetween(node, loop, (ancestor) => conditionallyContains(ancestor, node) && !conditionIsProvablyInvariant(ancestor, owner, context, moduleInvariants, node));
7738
+ if (dynamicGuard)
7739
+ return false;
7740
+ }
7419
7741
  const numeric = loop.namedChildren.find((child) => child.type === "for_numeric_clause");
7420
7742
  if (numeric) {
7421
7743
  const text = numeric.text;
@@ -7488,23 +7810,46 @@ function reachableReturnsBefore(call, fn, context, cache) {
7488
7810
  returns = [...context.walk(fn.body)].filter((node) => node.type === "return_statement" && !isNestedInsideFunction(node, fn.node) && !impossibleNilGuardReturn(node, fn, parameterTypes));
7489
7811
  cache.set(key, returns);
7490
7812
  }
7491
- return returns.filter((node) => node.endIndex <= call.startIndex);
7813
+ return returns.filter((node) => {
7814
+ if (node.endIndex > call.startIndex)
7815
+ return false;
7816
+ return !findAncestorBetween(node, fn.node, (ancestor) => {
7817
+ if (ancestor.type !== "if_statement")
7818
+ return false;
7819
+ const returnBranch = branchContaining(ancestor, node);
7820
+ const callBranch = branchContaining(ancestor, call);
7821
+ return Boolean(returnBranch && callBranch && returnBranch.id !== callBranch.id);
7822
+ });
7823
+ });
7492
7824
  }
7493
7825
  function returnIsControlledByStableTopologyMode(node, fn, summary, context, moduleInvariants) {
7494
7826
  let current = node.parent;
7827
+ let sawStableGuard = false;
7495
7828
  while (current && current !== fn.node) {
7496
- if (current.type === "if_statement" || current.type === "if_expression") {
7497
- if (summary && controlledParameterIndex(current, fn, summary) !== null)
7498
- return true;
7499
- if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants))
7500
- return true;
7829
+ if (["for_statement", "while_statement", "repeat_statement"].includes(current.type))
7501
7830
  return false;
7831
+ if (current.type === "if_statement" || current.type === "if_expression") {
7832
+ if (summary && controlledParameterIndex(current, fn, summary, node) !== null)
7833
+ sawStableGuard = true;
7834
+ else if (conditionIsProvablyInvariant(current, fn, context, moduleInvariants, node))
7835
+ sawStableGuard = true;
7836
+ else
7837
+ return false;
7502
7838
  }
7503
7839
  current = current.parent;
7504
7840
  }
7505
- return false;
7841
+ return sawStableGuard;
7506
7842
  }
7507
7843
  function conditionalFixPreview(kind, hookName) {
7844
+ if (kind === "short-circuit expression") {
7845
+ return {
7846
+ kind: "pattern",
7847
+ before: `local result = enabled and renderChild(${hookName}())`,
7848
+ after: `local value = ${hookName}()
7849
+ local result = enabled and renderChild(value)`,
7850
+ note: "Call the hook before the short-circuit expression, then branch on its result"
7851
+ };
7852
+ }
7508
7853
  if (hookName === "React.useEffect" && kind === "if statement") {
7509
7854
  return {
7510
7855
  kind: "pattern",
@@ -7583,6 +7928,34 @@ end`,
7583
7928
  note: "Call hooks before any reachable early return so every render executes the same hook sequence."
7584
7929
  };
7585
7930
  }
7931
+ function callbackFunctions(context) {
7932
+ const callbacks = new Set;
7933
+ const addCallback = (expression) => {
7934
+ const fn = resolveLocalFunction(context, expression);
7935
+ if (fn)
7936
+ callbacks.add(fn.node.id);
7937
+ };
7938
+ for (const call of context.findCalls()) {
7939
+ const path = reactApiPath(context, call);
7940
+ const args = context.callArguments(call);
7941
+ for (const [index, argument] of args.entries()) {
7942
+ if (index === 0 && ["React.memo", "React.forwardRef", "React.createElement"].includes(path ?? ""))
7943
+ continue;
7944
+ if (path?.startsWith("React.use") || path === "React.memo" || unwrapExpression(argument).type === "function_definition")
7945
+ addCallback(argument);
7946
+ }
7947
+ if (path !== "React.createElement" || args[0]?.type !== "string" || args[1]?.type !== "table_constructor")
7948
+ continue;
7949
+ for (const field of args[1].namedChildren) {
7950
+ if (field.type !== "field")
7951
+ continue;
7952
+ const value = field.namedChildren.at(-1);
7953
+ if (value)
7954
+ addCallback(value);
7955
+ }
7956
+ }
7957
+ return callbacks;
7958
+ }
7586
7959
  var rulesOfHooks = {
7587
7960
  id: "react-luau/rules-of-hooks",
7588
7961
  category: "Hooks",
@@ -7603,6 +7976,7 @@ var rulesOfHooks = {
7603
7976
  const modeImports = conditionalHookModeImports(context);
7604
7977
  const ignoredHookImports = nonReactHookImports(context);
7605
7978
  const currentModeSummary = currentConditionalHookMode(context);
7979
+ const callbacks = callbackFunctions(context);
7606
7980
  const reachableReturns = new Map;
7607
7981
  for (const call of context.findCalls()) {
7608
7982
  const rawPath = context.getCallPath(call);
@@ -7614,11 +7988,11 @@ var rulesOfHooks = {
7614
7988
  if (!isHookPath(path))
7615
7989
  continue;
7616
7990
  const fn = context.nearestFunction(call);
7617
- if (!fn || !fn.isComponent && !fn.isHook) {
7991
+ if (!fn || !fn.isComponent && !fn.isHook || callbacks.has(fn.node.id)) {
7618
7992
  diagnostics.push({
7619
7993
  node: callNameNode(call),
7620
- message: `Hook ${path} is called outside a React component or custom hook.`,
7621
- help: "Move the hook into a component or a custom hook whose name starts with use.",
7994
+ message: `Hook ${path} is called outside a React component or custom hook`,
7995
+ help: "Move the hook into a component or a custom hook whose name starts with use",
7622
7996
  fixPreview: {
7623
7997
  kind: "pattern",
7624
7998
  before: `local value = ${path}()
@@ -7630,7 +8004,7 @@ end`,
7630
8004
  local value = ${path}()
7631
8005
  return value
7632
8006
  end`,
7633
- note: "Hooks need a React-owned component or custom-hook call stack."
8007
+ note: "Hooks need a React-owned component or custom-hook call stack"
7634
8008
  }
7635
8009
  });
7636
8010
  continue;
@@ -7661,24 +8035,28 @@ end`,
7661
8035
  });
7662
8036
  }
7663
8037
  }
7664
- const controlFlow = findAncestorBetween(call, fn.node, (node) => CONDITIONAL_TYPES.has(node.type));
8038
+ const controlFlow = findAncestorBetween(call, fn.node, (node) => {
8039
+ if (!conditionallyContains(node, call))
8040
+ return false;
8041
+ if (currentModeSummary && controlledParameterIndex(node, fn, currentModeSummary, call) !== null)
8042
+ return false;
8043
+ if (conditionIsProvablyInvariant(node, fn, context, moduleInvariants, call))
8044
+ return false;
8045
+ if (ifBranchesHaveEquivalentBuiltInTopology(node, fn, context))
8046
+ return false;
8047
+ if (node.type === "for_statement" && loopIterationIsProvablyStable(node, staticImports, fn, stableShapes, moduleTables, context, moduleInvariants))
8048
+ return false;
8049
+ return true;
8050
+ });
7665
8051
  if (controlFlow) {
7666
- if (currentModeSummary && controlledParameterIndex(controlFlow, fn, currentModeSummary) !== null)
7667
- continue;
7668
- if (conditionIsProvablyInvariant(controlFlow, fn, context, moduleInvariants))
7669
- continue;
7670
- if (ifBranchesHaveEquivalentBuiltInTopology(controlFlow, fn, context))
7671
- continue;
7672
- if (controlFlow.type === "for_statement" && loopIterationIsProvablyStable(controlFlow, staticImports, fn, stableShapes, moduleTables))
7673
- continue;
7674
- const kind = controlFlow.type.replaceAll("_", " ");
8052
+ const kind = controlFlow.type === "binary_expression" ? "short-circuit expression" : controlFlow.type.replaceAll("_", " ");
7675
8053
  const loopLike = controlFlow.type === "for_statement" || controlFlow.type === "while_statement" || controlFlow.type === "repeat_statement";
7676
8054
  diagnostics.push({
7677
8055
  node: callNameNode(call),
7678
- message: loopLike ? `Hook ${path} is called inside ${kind} whose size or iteration order may change between renders. If that happens, React will see a different hook sequence.` : `Hook ${path} is called inside ${kind}, so the hook may be skipped on some renders.`,
7679
- summary: loopLike ? `${path} may run a different number of times between renders.` : `${path} may be skipped on some renders.`,
7680
- explanation: loopLike ? `This ${kind} may change how many times ${path} is called.` : `This ${kind} can skip ${path} on some renders.`,
7681
- help: loopLike ? "Keep hook count independent of runtime collection size or loop iterations. A common fix is to render a child component per item and call the hook inside that child." : "Call the hook unconditionally at the top level, then branch on the returned value.",
8056
+ message: loopLike ? `Hook ${path} is called inside ${kind} whose size or iteration order may change between renders. If that happens, React will see a different hook sequence` : `Hook ${path} is called inside ${kind}, so the hook may be skipped on some renders`,
8057
+ summary: loopLike ? `${path} may run a different number of times between renders` : `${path} may be skipped on some renders`,
8058
+ explanation: loopLike ? `This ${kind} may change how many times ${path} is called` : `This ${kind} can skip ${path} on some renders`,
8059
+ help: loopLike ? "Keep hook count independent of runtime collection size or loop iterations. A common fix is to render a child component per item and call the hook inside that child" : "Call the hook unconditionally at the top level, then branch on the returned value",
7682
8060
  fixPreview: conditionalFixPreview(kind, path)
7683
8061
  });
7684
8062
  continue;
@@ -8051,7 +8429,7 @@ var unstableContextValue = {
8051
8429
  id: "react-luau/unstable-context-value",
8052
8430
  category: "Performance",
8053
8431
  severity: "warning",
8054
- description: "Avoid recreating context value tables on every provider render when identity matters.",
8432
+ description: "Avoid recreating context reference values during render when identity matters",
8055
8433
  run(context) {
8056
8434
  const diagnostics = [];
8057
8435
  for (const call of context.findCalls()) {
@@ -8066,13 +8444,13 @@ var unstableContextValue = {
8066
8444
  const props = args[1];
8067
8445
  if (!/\.Provider$/.test(element) || !props || props.type !== "table_constructor")
8068
8446
  continue;
8069
- const valueField = props.namedChildren.find((field) => field.type === "field" && fieldName(field) === "value" && fieldValue(field)?.type === "table_constructor");
8447
+ const valueField = props.namedChildren.find((field) => field.type === "field" && fieldName(field) === "value" && freshValueKind(context, fieldValue(field), component));
8070
8448
  if (!valueField)
8071
8449
  continue;
8072
8450
  diagnostics.push({
8073
8451
  node: fieldNameNode(valueField),
8074
- message: "Context Provider receives a new inline value table on every render.",
8075
- help: "Memoize the provider value from its real dependencies when consumer updates depend on object identity."
8452
+ message: "Context Provider can receive a new reference value when the component renders",
8453
+ help: "Memoize the provider value from its real dependencies when consumer updates depend on object identity"
8076
8454
  });
8077
8455
  }
8078
8456
  return diagnostics;
@@ -8247,11 +8625,12 @@ function normalizeCategory(value) {
8247
8625
  return null;
8248
8626
  }
8249
8627
 
8250
- // src/ci.ts
8628
+ // src/ci/index.ts
8251
8629
  import { spawnSync as spawnSync2 } from "child_process";
8252
- import { createHash } from "crypto";
8630
+ import { createHash as createHash2 } from "crypto";
8253
8631
  import fs9 from "fs";
8254
8632
  import path11 from "path";
8633
+ import os2 from "os";
8255
8634
  import readline from "readline/promises";
8256
8635
  import { stdin as input, stdout as output } from "process";
8257
8636
 
@@ -8421,18 +8800,20 @@ function mergeBase(repoRoot, left, right = "HEAD") {
8421
8800
  return result.status === 0 ? result.stdout.trim() : null;
8422
8801
  }
8423
8802
  function resolveGitBase(repoRoot, requested) {
8424
- if (requested) {
8425
- if (requested.startsWith("-"))
8426
- throw new Error(`Invalid git ref: ${requested}`);
8427
- const base = mergeBase(repoRoot, requested);
8803
+ const gitLab = process.env.GITLAB_CI === "true";
8804
+ const baseRef = requested ?? (gitLab ? process.env.CI_MERGE_REQUEST_DIFF_BASE_SHA?.trim() : undefined);
8805
+ if (baseRef) {
8806
+ if (baseRef.startsWith("-"))
8807
+ throw new Error(`Invalid git ref: ${baseRef}`);
8808
+ const base = mergeBase(repoRoot, baseRef);
8428
8809
  if (base)
8429
8810
  return base;
8430
- const commit = verifyCommit(repoRoot, requested);
8811
+ const commit = verifyCommit(repoRoot, baseRef);
8431
8812
  if (!commit)
8432
- throw new Error(`Could not resolve git base ref: ${requested}`);
8813
+ throw new Error(`Could not resolve git base ref: ${baseRef}`);
8433
8814
  return commit;
8434
8815
  }
8435
- const environmentBase = process.env.GITHUB_BASE_REF?.trim();
8816
+ const environmentBase = process.env.GITHUB_BASE_REF?.trim() || (gitLab ? process.env.CI_DEFAULT_BRANCH?.trim() : undefined);
8436
8817
  const candidates = [
8437
8818
  environmentBase ? `origin/${environmentBase}` : null,
8438
8819
  environmentBase ?? null,
@@ -8622,6 +9003,7 @@ function createGitScopePlan(scanRootInput, options, relativeRootInput = scanRoot
8622
9003
  const baselineFiles = [];
8623
9004
  const changedLines = new Map;
8624
9005
  const changedFiles = [];
9006
+ const baselinePaths = new Map;
8625
9007
  const stagedSources = options.staged ? readGitObjects(repoRoot, repoFiles.map((file) => `:${file.currentPath}`)) : new Map;
8626
9008
  const baselineSources = options.scope === "changed" ? readGitObjects(repoRoot, repoFiles.filter((file) => file.baselinePath !== null).map((file) => `${base}:${file.baselinePath}`)) : new Map;
8627
9009
  for (const repoFile of repoFiles) {
@@ -8633,10 +9015,16 @@ function createGitScopePlan(scanRootInput, options, relativeRootInput = scanRoot
8633
9015
  continue;
8634
9016
  const source = options.staged ? stagedSources.get(`:${repoRelativePath}`) ?? null : currentSource(repoRoot, repoRelativePath, false);
8635
9017
  const relativePath = normalize2(path4.relative(relativeRoot, absolutePath));
9018
+ if (repoFile.baselinePath !== null) {
9019
+ baselinePaths.set(relativePath, normalize2(path4.relative(relativeRoot, path4.resolve(repoRoot, repoFile.baselinePath))));
9020
+ }
8636
9021
  changedFiles.push(relativePath);
8637
9022
  if (source !== null)
8638
9023
  currentFiles.push({ absolutePath, relativePath, source, forceScan: false });
8639
9024
  const baseline = options.scope !== "changed" || repoFile.baselinePath === null ? null : baselineSources.get(`${base}:${repoFile.baselinePath}`) ?? null;
9025
+ if (options.scope === "changed" && repoFile.baselinePath !== null && baseline === null) {
9026
+ throw new Error(`Could not read baseline source for ${repoFile.baselinePath}`);
9027
+ }
8640
9028
  if (baseline !== null)
8641
9029
  baselineFiles.push({ absolutePath, relativePath, source: baseline, forceScan: false });
8642
9030
  if (options.scope === "lines" && source !== null) {
@@ -8650,7 +9038,8 @@ function createGitScopePlan(scanRootInput, options, relativeRootInput = scanRoot
8650
9038
  changedFiles: [...new Set(changedFiles)].sort(),
8651
9039
  currentFiles,
8652
9040
  baselineFiles,
8653
- changedLines
9041
+ changedLines,
9042
+ baselinePaths
8654
9043
  };
8655
9044
  }
8656
9045
 
@@ -8945,34 +9334,775 @@ function renderAnnotations(report) {
8945
9334
  `);
8946
9335
  }
8947
9336
 
8948
- // src/scope.ts
8949
- import path10 from "path";
8950
-
8951
- // src/scanner.ts
8952
- import fs8 from "fs";
8953
- import path9 from "path";
8954
-
8955
- // src/cache.ts
8956
- import crypto from "crypto";
8957
- import fs4 from "fs";
8958
- import os from "os";
8959
- import path5 from "path";
8960
- import { fileURLToPath } from "url";
8961
- var MAX_CACHED_REPORTS = 8;
8962
- function normalizeRelative2(value) {
8963
- return value.split(path5.sep).join("/");
9337
+ // src/ci/report.ts
9338
+ var SUMMARY_MARKER = "<!-- react-luau-doctor:summary -->";
9339
+ var REVIEW_MARKER = "<!-- react-luau-doctor:review -->";
9340
+ var SUCCESS_ICON = '<img src="https://raw.githubusercontent.com/ColRealPro/react-luau-doctor/main/assets/ci/check-circle.svg" width="14" height="14" alt="">';
9341
+ var MAX_COMMENT_LENGTH = 60000;
9342
+ function escapeMarkdown(value) {
9343
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/[\\`*_{}\[\]()!|#$~]/g, "\\$&").replace(/\r?\n/g, " ");
8964
9344
  }
8965
- function hashText(value) {
8966
- return crypto.createHash("sha256").update(value).digest("hex");
9345
+ function inlineCode(value) {
9346
+ const text = value.replace(/\r?\n/g, " ");
9347
+ const fence = "`".repeat(Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length)) + 1);
9348
+ const padding = text.startsWith("`") || text.endsWith("`") || text.startsWith(" ") && text.endsWith(" ") ? " " : "";
9349
+ return `${fence}${padding}${text}${padding}${fence}`;
8967
9350
  }
8968
- var analyzerHashes = new Map;
8969
- function hashAnalyzerFile(hash, root, filename) {
8970
- if (!fs4.existsSync(filename))
8971
- return;
8972
- hash.update(normalizeRelative2(path5.relative(root, filename)));
8973
- hash.update("\x00");
8974
- hash.update(fs4.readFileSync(filename));
8975
- hash.update("\x00");
9351
+ function escapeHtml(value) {
9352
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
9353
+ }
9354
+ function issueCount(count) {
9355
+ return `${count} ${count === 1 ? "issue" : "issues"}`;
9356
+ }
9357
+ function statusText(kind, text) {
9358
+ let rendered = kind === "fixed" ? `**${escapeMarkdown(text)}**` : escapeMarkdown(text);
9359
+ return rendered;
9360
+ }
9361
+ function renderReviewProgress(total, fixed, unknown, discussions) {
9362
+ const lines = [`**React-Luau Doctor found ${total} new ${total === 1 ? "issue" : "issues"}**`];
9363
+ const status = [];
9364
+ if (unknown > 0) {
9365
+ status.push(`${unknown} ${unknown === 1 ? "issue" : "issues"} awaiting verification`);
9366
+ }
9367
+ if (discussions === undefined && fixed > 0) {
9368
+ status.push("discussion status unavailable");
9369
+ }
9370
+ const discussion = discussions ? `${discussions} ${discussions === 1 ? "discussion" : "discussions"} awaiting resolution` : "";
9371
+ if (fixed > 0) {
9372
+ let fixedText = `${fixed} of these ${issueCount(total)} fixed`;
9373
+ if (fixed === total) {
9374
+ if (total === 1) {
9375
+ fixedText = "This issue fixed in later commits";
9376
+ } else if (total === 2) {
9377
+ fixedText = "Both issues fixed in later commits";
9378
+ } else {
9379
+ fixedText = `All ${total} issues fixed in later commits`;
9380
+ }
9381
+ }
9382
+ const remaining = total - fixed - unknown;
9383
+ if (remaining > 0) {
9384
+ status.unshift(`${remaining} still ${remaining === 1 ? "remains" : "remain"} in the PR`);
9385
+ }
9386
+ lines.push("", [
9387
+ `${SUCCESS_ICON} ${statusText("fixed", fixedText)}`,
9388
+ status.length ? statusText("remaining", `\xB7 ${status.join(" \xB7 ")}`) : "",
9389
+ discussion ? statusText("discussion", `\xB7 ${discussion}`) : ""
9390
+ ].filter(Boolean).join(" "));
9391
+ } else if (status.length || discussion) {
9392
+ lines.push("", [
9393
+ status.length ? statusText("remaining", status.join(" \xB7 ")) : "",
9394
+ discussion ? statusText("discussion", `${status.length ? "\xB7 " : ""}${discussion}`) : ""
9395
+ ].filter(Boolean).join(" "));
9396
+ }
9397
+ return lines.join(`
9398
+ `);
9399
+ }
9400
+ function ordered(diagnostics) {
9401
+ return [...diagnostics].sort((a, b) => a.file.localeCompare(b.file) || a.location.line - b.location.line || a.rule.localeCompare(b.rule) || a.message.localeCompare(b.message));
9402
+ }
9403
+ function diagnosticDetails(diagnostic) {
9404
+ const details = [diagnostic.help, diagnostic.explanation, diagnostic.caveat].filter((value) => Boolean(value) && value !== diagnostic.message);
9405
+ return details.length ? `
9406
+ ${[...new Set(details)].map(escapeMarkdown).join(" \xB7 ")}` : "";
9407
+ }
9408
+ function renderSummaryComment(report, options) {
9409
+ const total = report.diagnostics.length;
9410
+ const files = new Set(report.diagnostics.map((diagnostic) => diagnostic.file)).size;
9411
+ const newLabel = report.scope === "changed" ? "new" : "reported";
9412
+ const severity = [
9413
+ `${report.counts.error} ${report.counts.error === 1 ? "error" : "errors"}`,
9414
+ `${report.counts.warning} ${report.counts.warning === 1 ? "warning" : "warnings"}`,
9415
+ `${report.counts.suggestion} ${report.counts.suggestion === 1 ? "suggestion" : "suggestions"}`
9416
+ ].join(" & ");
9417
+ const info = options.incomplete ? `score unavailable \xB7 fixes unverified \xB7 vs ${inlineCode(options.baseBranch)}` : `score ${report.score}/100 (${scoreLabel(report.score)}) \xB7 ${options.fixed.length} fixed \xB7 vs ${inlineCode(options.baseBranch)}`;
9418
+ let headline;
9419
+ if (options.incomplete) {
9420
+ headline = `**React-Luau Doctor analysis incomplete \xB7 ${issueCount(total)} reported so far**` + `
9421
+
9422
+ Findings may be missing and fixes have not been verified`;
9423
+ } else if (options.skipped || total === 0) {
9424
+ headline = `**React-Luau Doctor found no ${newLabel} issues${newLabel === "new" ? ". \uD83C\uDF89" : ""}**`;
9425
+ if (options.skipped) {
9426
+ headline += `
9427
+
9428
+ No eligible React-Luau files changed in this pull request \xB7 scan skipped`;
9429
+ }
9430
+ } else {
9431
+ headline = `**React-Luau Doctor found ${total} ${newLabel} ${total === 1 ? "issue" : "issues"}` + ` in ${files} ${files === 1 ? "file" : "files"} \xB7 ${severity} \xB7 ${info}**`;
9432
+ }
9433
+ const lines = [SUMMARY_MARKER, headline];
9434
+ if (options.incomplete) {
9435
+ lines.push("", `${severity} \xB7 ${info}`);
9436
+ }
9437
+ const sections = [
9438
+ {
9439
+ title: "Errors",
9440
+ diagnostics: report.diagnostics.filter((d) => d.severity === "error"),
9441
+ icon: "\u274C",
9442
+ grouped: false,
9443
+ baseline: false
9444
+ },
9445
+ {
9446
+ title: `\u26A0\uFE0F ${report.counts.warning} ${report.counts.warning === 1 ? "warning" : "warnings"}`,
9447
+ diagnostics: report.diagnostics.filter((d) => d.severity === "warning"),
9448
+ icon: "\u26A0\uFE0F",
9449
+ grouped: true,
9450
+ baseline: false
9451
+ },
9452
+ {
9453
+ title: `\uD83D\uDCA1 ${report.counts.suggestion} ${report.counts.suggestion === 1 ? "suggestion" : "suggestions"}`,
9454
+ diagnostics: report.diagnostics.filter((d) => d.severity === "suggestion"),
9455
+ icon: "\uD83D\uDCA1",
9456
+ grouped: true,
9457
+ baseline: false
9458
+ },
9459
+ {
9460
+ title: `${options.fixed.length} fixed vs <code>${escapeHtml(options.baseBranch)}</code>`,
9461
+ diagnostics: options.incomplete ? [] : options.fixed,
9462
+ icon: SUCCESS_ICON,
9463
+ grouped: true,
9464
+ baseline: true
9465
+ }
9466
+ ];
9467
+ const inlineNote = options.inlineCount > 0 ? "See inline comments for fixes" : "See this report for findings";
9468
+ const footer = `
9469
+
9470
+ <sub>Reviewed by [React-Luau Doctor](https://github.com/ColRealPro/react-luau-doctor)` + ` for commit ${inlineCode(options.sha.slice(0, 7))} \xB7 ${inlineNote}</sub>
9471
+ `;
9472
+ const reportLink = options.runUrl ? `[the full workflow report](${options.runUrl})` : "the workflow summary and logs";
9473
+ const overflow = `
9474
+
9475
+ Additional findings were omitted from this comment \xB7 See ` + `${reportLink} for all findings and diagnostic details
9476
+ `;
9477
+ const fullLink = options.runUrl && (total > 0 || options.fixed.length > 0 || options.incomplete) ? `
9478
+
9479
+ [View full workflow report](${options.runUrl})` : "";
9480
+ const limit = options.limit ?? MAX_COMMENT_LENGTH;
9481
+ const reserve = footer.length + Math.max(overflow.length, fullLink.length);
9482
+ let omitted = false;
9483
+ let body = lines.join(`
9484
+ `);
9485
+ for (const note of report.notes ?? []) {
9486
+ const text = `
9487
+
9488
+ ${escapeMarkdown(note)}`;
9489
+ if (body.length + text.length + reserve > limit) {
9490
+ omitted = true;
9491
+ continue;
9492
+ }
9493
+ body += text;
9494
+ }
9495
+ for (const section of sections) {
9496
+ if (!section.diagnostics.length)
9497
+ continue;
9498
+ const start = section.grouped ? `
9499
+
9500
+ <details>
9501
+ <summary>${section.title}</summary>
9502
+ ` : `
9503
+
9504
+ **Errors**
9505
+
9506
+ `;
9507
+ const end = section.grouped ? `
9508
+ </details>` : "";
9509
+ let content = "";
9510
+ let file = "";
9511
+ for (const diagnostic of ordered(section.diagnostics)) {
9512
+ const group = section.grouped && file !== diagnostic.file ? `
9513
+ **${inlineCode(diagnostic.file)}**
9514
+ ` : "";
9515
+ const uncertainLocation = section.baseline && diagnostic.baselineLocationUncertain;
9516
+ const location = uncertainLocation ? "File" : section.grouped ? `L${diagnostic.location.line}` : inlineCode(`${diagnostic.file}:${diagnostic.location.line}`);
9517
+ const message = escapeMarkdown(diagnostic.message);
9518
+ const description = section.baseline ? statusText("fixed", diagnostic.message) : !section.grouped ? `**${message}**` : message;
9519
+ const entry = `${group}${group ? `
9520
+ ` : ""}- ${section.icon} [${location}](${options.url(diagnostic, section.baseline)})` + ` ${description} ${inlineCode(diagnostic.rule)}` + `${options.includeDiagnosticDetails ? diagnosticDetails(diagnostic) : ""}
9521
+ `;
9522
+ if (body.length + start.length + content.length + entry.length + end.length + reserve > limit) {
9523
+ omitted = true;
9524
+ continue;
9525
+ }
9526
+ content += entry;
9527
+ file = diagnostic.file;
9528
+ }
9529
+ if (content)
9530
+ body += start + content + end;
9531
+ }
9532
+ return body + (omitted ? overflow : fullLink) + footer;
9533
+ }
9534
+ function diagnosticDifference(left, right, anchors) {
9535
+ const fingerprint = (d) => `${d.file}\x00${d.rule}\x00${d.severity}\x00${d.message}`;
9536
+ const groups = new Map;
9537
+ for (const diagnostic of ordered(left)) {
9538
+ groups.set(fingerprint(diagnostic), [
9539
+ ...groups.get(fingerprint(diagnostic)) ?? [],
9540
+ diagnostic
9541
+ ]);
9542
+ }
9543
+ const result = [];
9544
+ for (const [key, baseline] of groups) {
9545
+ const current = right.filter((diagnostic) => fingerprint(diagnostic) === key);
9546
+ const matched = new Set;
9547
+ const used = new Set;
9548
+ if (anchors) {
9549
+ for (const diagnostic of baseline) {
9550
+ const anchor = anchors.left(diagnostic);
9551
+ if (!anchor || baseline.filter((entry) => anchors.left(entry) === anchor).length !== 1) {
9552
+ continue;
9553
+ }
9554
+ const candidates = current.filter((entry) => !used.has(entry) && anchors.right(entry) === anchor);
9555
+ if (candidates.length !== 1)
9556
+ continue;
9557
+ matched.add(diagnostic);
9558
+ used.add(candidates[0]);
9559
+ }
9560
+ }
9561
+ const remaining = baseline.filter((entry) => !matched.has(entry));
9562
+ const count = current.length - used.size;
9563
+ const uncertain = count > 0 && count < remaining.length;
9564
+ for (const diagnostic of remaining.slice(count)) {
9565
+ result.push(uncertain ? { ...diagnostic, baselineLocationUncertain: true } : diagnostic);
9566
+ }
9567
+ }
9568
+ return ordered(result);
9569
+ }
9570
+
9571
+ // src/ci/github.ts
9572
+ import { createHash } from "crypto";
9573
+ var STATE_PREFIX = "<!-- react-luau-doctor:state:";
9574
+ var MAX_REVIEW_COMMENTS = 20;
9575
+ function assignFindings(records, analysis) {
9576
+ const groups = new Map;
9577
+ for (const record of records) {
9578
+ if (!record.file) {
9579
+ record.finding.status = "unknown";
9580
+ continue;
9581
+ }
9582
+ const key = findingFingerprint({ ...record.finding, file: record.file });
9583
+ groups.set(key, [...groups.get(key) ?? [], record]);
9584
+ }
9585
+ for (const [key, group] of groups) {
9586
+ const identities = [...new Set(group.map((entry) => entry.identity))];
9587
+ const originals = identities.map((identity) => group.find((entry) => entry.identity === identity));
9588
+ const current = analysis.current.filter((finding) => findingFingerprint(finding) === key).map((finding) => ({ finding, used: false }));
9589
+ const matches = new Map;
9590
+ for (const original of originals) {
9591
+ if (!original.finding.anchor || originals.filter((entry) => entry.finding.anchor === original.finding.anchor).length !== 1) {
9592
+ continue;
9593
+ }
9594
+ const candidates = current.filter((entry) => !entry.used && entry.finding.anchor === original.finding.anchor);
9595
+ if (candidates.length !== 1)
9596
+ continue;
9597
+ candidates[0].used = true;
9598
+ matches.set(original.identity, candidates[0].finding);
9599
+ }
9600
+ const unmatched = originals.filter((entry) => !matches.has(entry.identity));
9601
+ const remaining = current.filter((entry) => !entry.used);
9602
+ const ambiguous = remaining.length > 0 && remaining.length < unmatched.length;
9603
+ if (!ambiguous) {
9604
+ for (const original of unmatched) {
9605
+ const candidate = remaining.filter((entry) => !entry.used).sort((a, b) => Math.abs(a.finding.line - original.finding.line) - Math.abs(b.finding.line - original.finding.line))[0];
9606
+ if (!candidate)
9607
+ continue;
9608
+ candidate.used = true;
9609
+ matches.set(original.identity, candidate.finding);
9610
+ }
9611
+ }
9612
+ for (const record of group) {
9613
+ const match = matches.get(record.identity);
9614
+ const potentiallyRelocated = analysis.deletedFiles?.has(record.file) && analysis.current.some((finding) => finding.file !== record.file && finding.rule === record.finding.rule && finding.severity === record.finding.severity && finding.message === record.finding.message);
9615
+ if (match) {
9616
+ record.finding.status = "active";
9617
+ match.identity = record.identity;
9618
+ } else {
9619
+ record.finding.status = !ambiguous && !potentiallyRelocated && analysis.verifiedFiles.has(record.file) && analysis.eligible({ ...record.finding, file: record.file }) && record.state.signature === analysis.signature ? "fixed" : "unknown";
9620
+ }
9621
+ }
9622
+ }
9623
+ }
9624
+ function findingFingerprint(finding) {
9625
+ return createHash("sha256").update(`${finding.file}\x00${finding.rule}\x00${finding.severity}\x00${finding.message}`).digest("hex").slice(0, 32);
9626
+ }
9627
+ function readReviewState(body) {
9628
+ const encoded = body.match(/<!-- react-luau-doctor:state:([A-Za-z0-9+/=]+) -->/)?.[1];
9629
+ if (!encoded)
9630
+ return null;
9631
+ try {
9632
+ const state = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
9633
+ if (state.version !== 1 || typeof state.commit !== "string" || typeof state.signature !== "string" || !Array.isArray(state.findings) || state.findings.length === 0 || state.findings.length > MAX_REVIEW_COMMENTS) {
9634
+ return null;
9635
+ }
9636
+ if (state.findings.some((f) => !f || typeof f.file !== "string" || typeof f.rule !== "string" || typeof f.severity !== "string" || typeof f.message !== "string" || !Number.isSafeInteger(f.line) || f.line < 1 || !["active", "fixed", "unknown"].includes(f.status) || [f.anchor, f.identity, f.diagnosticId].some((value) => value !== undefined && typeof value !== "string"))) {
9637
+ return null;
9638
+ }
9639
+ return state;
9640
+ } catch {
9641
+ return null;
9642
+ }
9643
+ }
9644
+ function renderReviewBody(state, discussions) {
9645
+ const fixed = state.findings.filter((finding) => finding.status === "fixed").length;
9646
+ const unknown = state.findings.filter((finding) => finding.status === "unknown").length;
9647
+ const progress = renderReviewProgress(state.findings.length, fixed, unknown, discussions);
9648
+ const metadata = Buffer.from(JSON.stringify(state)).toString("base64");
9649
+ return `${REVIEW_MARKER}
9650
+ ${progress}
9651
+
9652
+ ${STATE_PREFIX}${metadata} -->`;
9653
+ }
9654
+
9655
+ class StalePublication extends Error {
9656
+ }
9657
+
9658
+ class GitHubClient {
9659
+ repo;
9660
+ pullNumber;
9661
+ publication;
9662
+ constructor(repo, pullNumber, publication) {
9663
+ this.repo = repo;
9664
+ this.pullNumber = pullNumber;
9665
+ this.publication = publication;
9666
+ }
9667
+ async request(endpoint, options = {}, graphql = false) {
9668
+ const token = process.env.GITHUB_TOKEN;
9669
+ if (!token)
9670
+ throw new Error("GITHUB_TOKEN is unavailable");
9671
+ const base = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
9672
+ const url = graphql ? `${base.replace(/\/api\/v3$/, "/api")}/graphql` : `${base}/repos/${this.repo}${endpoint}`;
9673
+ for (let attempt = 0;; attempt += 1) {
9674
+ if (attempt > 0 && options.guarded)
9675
+ await this.assertCurrent();
9676
+ const response = await fetch(url, {
9677
+ method: options.method ?? "GET",
9678
+ headers: {
9679
+ Accept: "application/vnd.github+json",
9680
+ Authorization: `Bearer ${token}`,
9681
+ "X-GitHub-Api-Version": "2022-11-28",
9682
+ "User-Agent": "react-luau-doctor",
9683
+ "Content-Type": "application/json"
9684
+ },
9685
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
9686
+ signal: AbortSignal.timeout(30000)
9687
+ });
9688
+ const responseText = response.status === 204 ? "" : await response.text();
9689
+ const result = response.ok && responseText ? JSON.parse(responseText) : undefined;
9690
+ const rateLimited = response.status === 429 || response.status === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || /rate limit|rate_limit|abuse detection/i.test(responseText)) || response.ok && graphql && result?.errors && /rate limit|rate_limit/i.test(responseText);
9691
+ if (response.ok && !rateLimited) {
9692
+ if (graphql && (result?.errors || !result?.data)) {
9693
+ throw new Error(`GitHub GraphQL: ${responseText}`);
9694
+ }
9695
+ return result;
9696
+ }
9697
+ const retryAfter = response.headers.get("retry-after");
9698
+ const reset = Number(response.headers.get("x-ratelimit-reset"));
9699
+ const delay = retryAfter ? /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now() : response.headers.get("x-ratelimit-remaining") === "0" && reset ? Math.max(0, reset * 1000 - Date.now() + 1000) : 60000;
9700
+ if (!rateLimited || attempt >= 2 || !Number.isFinite(delay) || delay > 60000) {
9701
+ throw new Error(`GitHub API ${response.status}: ${responseText}`);
9702
+ }
9703
+ process.stderr.write(`react-luau-doctor: GitHub API ${response.status}; retrying in ${Math.ceil(Math.max(0, delay) / 1000)} seconds
9704
+ `);
9705
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, delay)));
9706
+ }
9707
+ }
9708
+ async list(endpoint) {
9709
+ const all = [];
9710
+ for (let page = 1;; page++) {
9711
+ const batch = await this.request(`${endpoint}?per_page=100&page=${page}`);
9712
+ if (!Array.isArray(batch))
9713
+ throw new Error("GitHub returned an invalid paginated response");
9714
+ all.push(...batch);
9715
+ if (batch.length < 100)
9716
+ return all;
9717
+ }
9718
+ }
9719
+ async assertCurrent() {
9720
+ const pull = await this.request(`/pulls/${this.pullNumber}`);
9721
+ if (pull.head?.sha !== this.publication.head || pull.base?.sha !== this.publication.base || pull.state !== "open") {
9722
+ throw new StalePublication("PR head or baseline changed; skipping outdated publication");
9723
+ }
9724
+ const summaries = await this.list(`/issues/${this.pullNumber}/comments`);
9725
+ for (const summary of summaries.filter((c) => c.user?.type === "Bot" && c.body?.startsWith(SUMMARY_MARKER))) {
9726
+ const marker = summary.body?.match(/<!-- react-luau-doctor:run:(\d+):(\d+) -->/);
9727
+ if (marker && this.publication.runId && (BigInt(marker[1]) > BigInt(this.publication.runId) || marker[1] === this.publication.runId && Number(marker[2]) > Number(this.publication.attempt))) {
9728
+ throw new StalePublication("A newer workflow run already published; skipping outdated publication");
9729
+ }
9730
+ }
9731
+ if (!summaries.some((c) => c.user?.type === "Bot" && c.body?.startsWith(SUMMARY_MARKER))) {
9732
+ const reviews = await this.list(`/pulls/${this.pullNumber}/reviews`);
9733
+ for (const review of reviews.filter((r) => r.user?.type === "Bot")) {
9734
+ const state = readReviewState(review.body ?? "");
9735
+ if (state?.runId && /^\d+$/.test(state.runId) && this.publication.runId && (BigInt(state.runId) > BigInt(this.publication.runId) || state.runId === this.publication.runId && Number(state.attempt) > Number(this.publication.attempt))) {
9736
+ throw new StalePublication("A newer review run already published; skipping outdated publication");
9737
+ }
9738
+ }
9739
+ }
9740
+ }
9741
+ async mutate(endpoint, method, body) {
9742
+ await this.assertCurrent();
9743
+ return this.request(endpoint, { method, body, guarded: true });
9744
+ }
9745
+ async sticky(body, complete = true) {
9746
+ await this.assertCurrent();
9747
+ const comments = await this.list(`/issues/${this.pullNumber}/comments`);
9748
+ const previous = comments.find((c) => c.user?.type === "Bot" && c.body?.startsWith(SUMMARY_MARKER));
9749
+ const identity = `<!-- react-luau-doctor:commit:${this.publication.head}:${this.publication.base}:complete -->`;
9750
+ if (!complete && previous?.body?.includes(identity))
9751
+ return;
9752
+ const stamp = (this.publication.runId ? `
9753
+ <!-- react-luau-doctor:run:${this.publication.runId}:${this.publication.attempt} -->` : "") + `
9754
+ <!-- react-luau-doctor:commit:${this.publication.head}:${this.publication.base}:${complete ? "complete" : "incomplete"} -->`;
9755
+ const next = body + stamp;
9756
+ if (previous?.body === next)
9757
+ return;
9758
+ await this.mutate(previous ? `/issues/comments/${previous.id}` : `/issues/${this.pullNumber}/comments`, previous ? "PATCH" : "POST", { body: next });
9759
+ }
9760
+ async threads() {
9761
+ const [owner, name] = this.repo.split("/");
9762
+ const result = new Map;
9763
+ let cursor = null;
9764
+ for (;; ) {
9765
+ const response = await this.request("", {
9766
+ method: "POST",
9767
+ body: {
9768
+ query: `query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
9769
+ repository(owner: $owner, name: $name) {
9770
+ pullRequest(number: $number) {
9771
+ reviewThreads(first: 100, after: $cursor) {
9772
+ nodes {
9773
+ isResolved
9774
+ comments(first: 1) {
9775
+ nodes { databaseId }
9776
+ }
9777
+ }
9778
+ pageInfo {
9779
+ hasNextPage
9780
+ endCursor
9781
+ }
9782
+ }
9783
+ }
9784
+ }
9785
+ }`,
9786
+ variables: { owner, name, number: this.pullNumber, cursor }
9787
+ }
9788
+ }, true);
9789
+ const connection = response.data.repository?.pullRequest?.reviewThreads;
9790
+ if (!connection || !Array.isArray(connection.nodes)) {
9791
+ throw new Error("GitHub review-thread state is unavailable");
9792
+ }
9793
+ for (const thread of connection.nodes) {
9794
+ const root = thread.comments.nodes[0]?.databaseId;
9795
+ if (typeof thread.isResolved !== "boolean") {
9796
+ throw new Error("GitHub returned an invalid review-thread resolution state");
9797
+ }
9798
+ if (root)
9799
+ result.set(root, { resolved: thread.isResolved });
9800
+ }
9801
+ if (!connection.pageInfo.hasNextPage)
9802
+ return result;
9803
+ if (!connection.pageInfo.endCursor || connection.pageInfo.endCursor === cursor) {
9804
+ throw new Error("GitHub review-thread pagination did not advance");
9805
+ }
9806
+ cursor = connection.pageInfo.endCursor;
9807
+ }
9808
+ }
9809
+ }
9810
+ function commentFingerprint(comment) {
9811
+ if (comment.user?.type !== "Bot" || !comment.body?.startsWith(REVIEW_MARKER) || comment.in_reply_to_id) {
9812
+ return null;
9813
+ }
9814
+ const marker = comment.body.match(/<!-- react-luau-doctor:fingerprint:([a-f0-9]{32}) -->/i);
9815
+ if (marker)
9816
+ return marker[1].toLowerCase();
9817
+ const legacy = comment.body.match(/\*\*React-Luau Doctor\*\* \u00B7 `([^`]+)` \((error|warning|suggestion)\)\n\n([^\n]+)/);
9818
+ return legacy ? findingFingerprint({
9819
+ file: comment.path,
9820
+ rule: legacy[1],
9821
+ severity: legacy[2],
9822
+ message: legacy[3]
9823
+ }) : null;
9824
+ }
9825
+ function recoverReviewState(review, comments) {
9826
+ const count = Number(review.body?.match(/(?:^|\n)(?:\*\*)?React-Luau Doctor found (\d+) new issues?(?:\*\*)?(?:\n|$)/)?.[1]);
9827
+ const roots = comments.filter((comment) => comment.pull_request_review_id === review.id && !comment.in_reply_to_id);
9828
+ if (!count || count > MAX_REVIEW_COMMENTS || roots.length !== count)
9829
+ return null;
9830
+ const commit = review.commit_id ?? roots[0]?.original_commit_id ?? roots[0]?.commit_id;
9831
+ if (!commit)
9832
+ return null;
9833
+ const findings = [];
9834
+ for (const comment of roots) {
9835
+ const diagnostic = comment.body?.match(/\*\*React-Luau Doctor\*\* \u00B7 `([^`]+)` \((error|warning|suggestion)\)\n\n([^\n]+)/);
9836
+ const line = comment.original_line ?? comment.line;
9837
+ if (!diagnostic || !Number.isSafeInteger(line) || line < 1)
9838
+ return null;
9839
+ const finding = {
9840
+ file: comment.path,
9841
+ rule: diagnostic[1],
9842
+ severity: diagnostic[2],
9843
+ message: diagnostic[3],
9844
+ line,
9845
+ identity: `legacy:${review.id}:${comment.id}`,
9846
+ status: "unknown"
9847
+ };
9848
+ if (commentFingerprint(comment) !== findingFingerprint(finding))
9849
+ return null;
9850
+ findings.push(finding);
9851
+ }
9852
+ return { version: 1, commit, signature: "", findings };
9853
+ }
9854
+ function prioritizedFindings(candidates) {
9855
+ const severity = { error: 0, warning: 1, suggestion: 2 };
9856
+ const counts = new Map;
9857
+ return [...candidates].sort((a, b) => a.finding.file.localeCompare(b.finding.file) || a.finding.line - b.finding.line || a.diagnostic.id.localeCompare(b.diagnostic.id)).map((candidate) => {
9858
+ const key = `${candidate.finding.severity}\x00${candidate.finding.file}`;
9859
+ const round = counts.get(key) ?? 0;
9860
+ counts.set(key, round + 1);
9861
+ return { candidate, round };
9862
+ }).sort((a, b) => severity[a.candidate.diagnostic.severity] - severity[b.candidate.diagnostic.severity] || a.round - b.round || a.candidate.finding.file.localeCompare(b.candidate.finding.file)).map(({ candidate }) => candidate);
9863
+ }
9864
+ async function reviewSnapshot(client) {
9865
+ const [reviews, comments] = await Promise.all([
9866
+ client.list(`/pulls/${client.pullNumber}/reviews`),
9867
+ client.list(`/pulls/${client.pullNumber}/comments`)
9868
+ ]);
9869
+ return { reviews, comments };
9870
+ }
9871
+ async function reconcileReviews(client, snapshot, analysis, canInline) {
9872
+ if (!analysis.complete)
9873
+ return snapshot.comments.filter((c) => commentFingerprint(c)).length;
9874
+ const ownedReviews = snapshot.reviews.filter((r) => r.user?.type === "Bot" && (r.body?.startsWith(REVIEW_MARKER) || /^(?:\*\*)?React-Luau Doctor found (?:\d+ )?new issues?(?:\*\*)?$/.test(r.body ?? "") || r.body?.startsWith("\u2705 React-Luau Doctor: findings from this review are no longer current")));
9875
+ let threads;
9876
+ try {
9877
+ threads = await client.threads();
9878
+ } catch (error) {
9879
+ process.stderr.write(`react-luau-doctor: could not read review threads; preserving discussions: ${error instanceof Error ? error.message : String(error)}
9880
+ `);
9881
+ }
9882
+ const states = new Map;
9883
+ const records = [];
9884
+ for (const review of ownedReviews) {
9885
+ const original = readReviewState(review.body ?? "") ?? recoverReviewState(review, snapshot.comments);
9886
+ if (!original)
9887
+ continue;
9888
+ const state = { ...original, findings: original.findings.map((finding) => ({ ...finding })) };
9889
+ states.set(review.id, state);
9890
+ for (const finding of state.findings) {
9891
+ const identity = finding.identity ?? `${state.commit}:${findingFingerprint(finding)}:${finding.line}`;
9892
+ finding.identity = identity;
9893
+ records.push({
9894
+ state,
9895
+ finding,
9896
+ identity,
9897
+ file: analysis.canonicalFile(finding.file, state.commit)
9898
+ });
9899
+ }
9900
+ }
9901
+ assignFindings(records, analysis);
9902
+ for (const state of states.values()) {
9903
+ if (state.signature !== "")
9904
+ continue;
9905
+ if (state.findings.every((finding) => {
9906
+ const file = analysis.canonicalFile(finding.file, state.commit);
9907
+ return finding.status === "active" && file && analysis.verifiedFiles.has(file) && analysis.eligible({ ...finding, file });
9908
+ })) {
9909
+ state.signature = analysis.signature;
9910
+ }
9911
+ }
9912
+ const represented = new Map;
9913
+ const representedIdentities = new Set;
9914
+ const failures = [];
9915
+ const updates = [];
9916
+ let inlineCount = snapshot.comments.filter((c) => commentFingerprint(c)).length;
9917
+ for (const review of ownedReviews.sort((a, b) => a.id - b.id)) {
9918
+ const state = states.get(review.id);
9919
+ if (!state) {
9920
+ const retained = snapshot.comments.filter((comment) => comment.pull_request_review_id === review.id && commentFingerprint(comment)).length;
9921
+ const body = `${REVIEW_MARKER}
9922
+ **React-Luau Doctor \xB7 earlier findings**
9923
+
9924
+ ` + `${retained ? `${issueCount(retained)} retained inline \xB7 ` : ""}` + "Original finding history unavailable \xB7 See the current PR report";
9925
+ updates.push({ review, body, cleanup: [] });
9926
+ continue;
9927
+ }
9928
+ let discussions = threads ? 0 : undefined;
9929
+ const ownedComments = snapshot.comments.filter((c) => c.pull_request_review_id === review.id && commentFingerprint(c));
9930
+ const consumedComments = new Set;
9931
+ const cleanup = [];
9932
+ for (const finding of state.findings) {
9933
+ const file = analysis.canonicalFile(finding.file, state.commit);
9934
+ const fingerprint = findingFingerprint(finding);
9935
+ const comments = ownedComments.filter((c) => !consumedComments.has(c.id) && commentFingerprint(c) === fingerprint).sort((a, b) => Math.abs((a.original_line ?? a.line ?? finding.line) - finding.line) - Math.abs((b.original_line ?? b.line ?? finding.line) - finding.line));
9936
+ const comment = comments[0];
9937
+ if (comment)
9938
+ consumedComments.add(comment.id);
9939
+ const replies = comment ? snapshot.comments.filter((c) => c.in_reply_to_id === comment.id) : [];
9940
+ const discussion = comment && replies.length > 0;
9941
+ if (discussion && threads) {
9942
+ const thread = threads.get(comment.id);
9943
+ if (!thread || typeof thread.resolved !== "boolean")
9944
+ discussions = undefined;
9945
+ else if (!thread.resolved && discussions !== undefined)
9946
+ discussions++;
9947
+ }
9948
+ const key = findingFingerprint({ ...finding, file: file ?? finding.file });
9949
+ if ((finding.status === "active" && comment || finding.status === "unknown") && !representedIdentities.has(finding.identity)) {
9950
+ represented.set(key, (represented.get(key) ?? 0) + 1);
9951
+ representedIdentities.add(finding.identity);
9952
+ }
9953
+ if (comment && finding.status === "fixed" && !discussion && threads?.has(comment.id)) {
9954
+ cleanup.push(comment);
9955
+ }
9956
+ }
9957
+ const body = renderReviewBody(state, discussions);
9958
+ if (body !== review.body) {
9959
+ state.runId = client.publication.runId;
9960
+ state.attempt = client.publication.attempt;
9961
+ }
9962
+ updates.push({
9963
+ review,
9964
+ body: body === review.body ? body : renderReviewBody(state, discussions),
9965
+ cleanup
9966
+ });
9967
+ }
9968
+ for (const comment of snapshot.comments) {
9969
+ if (!commentFingerprint(comment) || states.has(comment.pull_request_review_id))
9970
+ continue;
9971
+ const key = commentFingerprint(comment);
9972
+ represented.set(key, (represented.get(key) ?? 0) + 1);
9973
+ }
9974
+ const selected = [];
9975
+ const candidates = [];
9976
+ const alreadyPublished = [...states.values()].filter((state) => state.commit === client.publication.head).reduce((count, state) => count + state.findings.length, 0);
9977
+ for (const candidate of analysis.introduced) {
9978
+ const key = findingFingerprint(candidate.finding);
9979
+ const count = represented.get(key) ?? 0;
9980
+ if (count) {
9981
+ represented.set(key, count - 1);
9982
+ continue;
9983
+ }
9984
+ if (!canInline(candidate.diagnostic))
9985
+ continue;
9986
+ candidates.push(candidate);
9987
+ }
9988
+ for (const candidate of prioritizedFindings(candidates)) {
9989
+ const key = findingFingerprint(candidate.finding);
9990
+ if (selected.length + alreadyPublished >= MAX_REVIEW_COMMENTS)
9991
+ break;
9992
+ const current = analysis.current.find((finding) => findingFingerprint(finding) === key && (candidate.finding.diagnosticId ? finding.diagnosticId === candidate.finding.diagnosticId : finding.line === candidate.finding.line && finding.column === candidate.finding.column));
9993
+ candidate.finding.identity = current?.identity ?? `${client.publication.head}:${key}:${candidate.diagnostic.id}`;
9994
+ const nextState = {
9995
+ version: 1,
9996
+ signature: analysis.signature,
9997
+ commit: client.publication.head,
9998
+ findings: [...selected, candidate].map((entry) => entry.finding)
9999
+ };
10000
+ if (renderReviewBody(nextState, 0).length > MAX_COMMENT_LENGTH || inlineBody(candidate.finding, candidate.diagnostic).length > MAX_COMMENT_LENGTH) {
10001
+ continue;
10002
+ }
10003
+ selected.push(candidate);
10004
+ }
10005
+ let publicationSucceeded = true;
10006
+ if (selected.length) {
10007
+ const state = {
10008
+ version: 1,
10009
+ commit: client.publication.head,
10010
+ signature: analysis.signature,
10011
+ runId: client.publication.runId,
10012
+ attempt: client.publication.attempt,
10013
+ findings: selected.map((entry) => entry.finding)
10014
+ };
10015
+ const comments = selected.map(({ finding, diagnostic }) => ({
10016
+ path: finding.file,
10017
+ line: finding.line,
10018
+ side: "RIGHT",
10019
+ body: inlineBody(finding, diagnostic)
10020
+ }));
10021
+ try {
10022
+ await client.mutate(`/pulls/${client.pullNumber}/reviews`, "POST", {
10023
+ commit_id: client.publication.head,
10024
+ event: "COMMENT",
10025
+ body: renderReviewBody(state, 0),
10026
+ comments
10027
+ });
10028
+ inlineCount += comments.length;
10029
+ } catch (error) {
10030
+ if (error instanceof StalePublication)
10031
+ throw error;
10032
+ publicationSucceeded = false;
10033
+ failures.push(`could not create new review comments: ${String(error)}`);
10034
+ }
10035
+ }
10036
+ for (const { review, body, cleanup } of updates) {
10037
+ try {
10038
+ if (body !== review.body) {
10039
+ await client.mutate(`/pulls/${client.pullNumber}/reviews/${review.id}`, "PUT", { body });
10040
+ }
10041
+ if (!publicationSucceeded)
10042
+ continue;
10043
+ for (const comment of cleanup) {
10044
+ const latest = await client.list(`/pulls/${client.pullNumber}/comments`);
10045
+ if (latest.some((entry) => entry.in_reply_to_id === comment.id))
10046
+ continue;
10047
+ if (!latest.some((entry) => entry.id === comment.id))
10048
+ continue;
10049
+ await client.mutate(`/pulls/comments/${comment.id}`, "DELETE");
10050
+ inlineCount--;
10051
+ }
10052
+ } catch (error) {
10053
+ if (error instanceof StalePublication)
10054
+ throw error;
10055
+ failures.push(`could not update review ${review.id}: ${String(error)}`);
10056
+ }
10057
+ }
10058
+ if (failures.length) {
10059
+ process.stderr.write(`react-luau-doctor: could not update inline review comments: ${failures.join("; ")}
10060
+ `);
10061
+ }
10062
+ return inlineCount;
10063
+ }
10064
+ function inlineBody(finding, diagnostic) {
10065
+ const details = [diagnostic.help, diagnostic.explanation, diagnostic.caveat].filter((value) => Boolean(value) && value !== finding.message);
10066
+ const header = `${REVIEW_MARKER}
10067
+ <!-- react-luau-doctor:fingerprint:${findingFingerprint(finding)} -->`;
10068
+ const title = `**React-Luau Doctor** \xB7 ${inlineCode(finding.rule)} (${finding.severity})`;
10069
+ const guidance = [...new Set(details)].map((detail) => `
10070
+
10071
+ ${escapeMarkdown(detail)}`).join("");
10072
+ return `${header}
10073
+ ${title}
10074
+
10075
+ ${escapeMarkdown(finding.message)}${guidance}`;
10076
+ }
10077
+
10078
+ // src/scope.ts
10079
+ import path10 from "path";
10080
+
10081
+ // src/scanner.ts
10082
+ import fs8 from "fs";
10083
+ import path9 from "path";
10084
+
10085
+ // src/cache.ts
10086
+ import crypto from "crypto";
10087
+ import fs4 from "fs";
10088
+ import os from "os";
10089
+ import path5 from "path";
10090
+ import { fileURLToPath } from "url";
10091
+ var MAX_CACHED_REPORTS = 8;
10092
+ function normalizeRelative2(value) {
10093
+ return value.split(path5.sep).join("/");
10094
+ }
10095
+ function hashText(value) {
10096
+ return crypto.createHash("sha256").update(value).digest("hex");
10097
+ }
10098
+ var analyzerHashes = new Map;
10099
+ function hashAnalyzerFile(hash, root, filename) {
10100
+ if (!fs4.existsSync(filename))
10101
+ return;
10102
+ hash.update(normalizeRelative2(path5.relative(root, filename)));
10103
+ hash.update("\x00");
10104
+ hash.update(fs4.readFileSync(filename));
10105
+ hash.update("\x00");
8976
10106
  }
8977
10107
  function sourceFiles(directory) {
8978
10108
  if (!fs4.existsSync(directory))
@@ -9251,7 +10381,8 @@ function previousCachedReport(session, key) {
9251
10381
  return {
9252
10382
  scannedFiles: report.scannedFiles,
9253
10383
  candidateFiles: report.candidateFiles,
9254
- diagnostics: report.diagnostics
10384
+ diagnostics: report.diagnostics,
10385
+ unverifiedFiles: report.unverifiedFiles
9255
10386
  };
9256
10387
  }
9257
10388
  function cachedReport(session, key) {
@@ -9263,7 +10394,8 @@ function cachedReport(session, key) {
9263
10394
  return {
9264
10395
  scannedFiles: report.scannedFiles,
9265
10396
  candidateFiles: report.candidateFiles,
9266
- diagnostics: report.diagnostics
10397
+ diagnostics: report.diagnostics,
10398
+ unverifiedFiles: report.unverifiedFiles
9267
10399
  };
9268
10400
  }
9269
10401
  function cacheHasSameFileSet(session) {
@@ -10124,6 +11256,7 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
10124
11256
  project: input.project
10125
11257
  };
10126
11258
  const context = createRuleContext(file);
11259
+ const syntaxFindings = parsedTree.rootNode.hasError ? parseErrors.run(context) : [];
10127
11260
  const categorySet = input.categories && input.categories.length > 0 ? new Set(input.categories) : null;
10128
11261
  const isSuppressed = input.respectInlineDisables ? createInlineSuppressionChecker(input.source) : () => false;
10129
11262
  const diagnostics = [];
@@ -10135,7 +11268,7 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
10135
11268
  const severity = effectiveSeverity(rule.severity, rule.id, input.config);
10136
11269
  if (!severity)
10137
11270
  continue;
10138
- const findings = rule.run(context);
11271
+ const findings = rule.id === parseErrors.id ? syntaxFindings : rule.run(context);
10139
11272
  for (const finding of findings) {
10140
11273
  const configuredSeverity = input.config.rules?.[rule.id];
10141
11274
  const diagnostic = toDiagnostic(file, rule.id, rule.category, severity, {
@@ -10156,7 +11289,8 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
10156
11289
  relativePath: input.relativePath,
10157
11290
  isReactFile: model.isReactFile,
10158
11291
  scanned: true,
10159
- diagnostics: filteredDiagnostics
11292
+ diagnostics: filteredDiagnostics,
11293
+ unverified: syntaxFindings.length > 0
10160
11294
  };
10161
11295
  } finally {
10162
11296
  if (!tree)
@@ -12483,7 +13617,8 @@ async function scanPath(target = ".", options = {}) {
12483
13617
  counts: countDiagnostics(reportHit.diagnostics),
12484
13618
  diagnostics: reportHit.diagnostics,
12485
13619
  partial: false,
12486
- skippedFiles: []
13620
+ skippedFiles: [],
13621
+ unverifiedFiles: reportHit.unverifiedFiles
12487
13622
  };
12488
13623
  }
12489
13624
  }
@@ -12538,6 +13673,7 @@ async function scanPath(target = ".", options = {}) {
12538
13673
  options.analysisSession.effectModules = cacheSession.effectModules ?? previousEffectModules;
12539
13674
  const diagnostics = [];
12540
13675
  const skippedFiles = [];
13676
+ const unverifiedFiles = [];
12541
13677
  let scannedFiles = 0;
12542
13678
  let scanCandidates = candidates;
12543
13679
  let incrementalReuse = false;
@@ -12545,6 +13681,7 @@ async function scanPath(target = ".", options = {}) {
12545
13681
  if (canCacheWholeReport && previousReport && previousProject && cacheHasSameFileSet(cacheSession) && projectModelFeatureKey(previousProject) === projectModelFeatureKey(project)) {
12546
13682
  const affectedFiles = transitiveAffectedFiles(changedFiles, previousEffectModules, currentEffectModules);
12547
13683
  diagnostics.push(...previousReport.diagnostics.filter((diagnostic) => !affectedFiles.has(normalizeRelative4(diagnostic.file))));
13684
+ unverifiedFiles.push(...(previousReport.unverifiedFiles ?? []).filter((file) => !affectedFiles.has(normalizeRelative4(file))));
12548
13685
  scanCandidates = candidates.filter((candidate) => {
12549
13686
  const relativePath = normalizeRelative4(candidate.relativePath ?? path9.relative(root, candidate.absolutePath));
12550
13687
  return affectedFiles.has(relativePath);
@@ -12559,6 +13696,8 @@ async function scanPath(target = ".", options = {}) {
12559
13696
  if (result.scanned)
12560
13697
  scannedFiles += 1;
12561
13698
  diagnostics.push(...result.diagnostics);
13699
+ if (result.unverified)
13700
+ unverifiedFiles.push(result.relativePath);
12562
13701
  };
12563
13702
  const prepareCandidate = (candidate) => {
12564
13703
  const relativePath = normalizeRelative4(candidate.relativePath ?? path9.relative(root, candidate.absolutePath));
@@ -12644,13 +13783,15 @@ async function scanPath(target = ".", options = {}) {
12644
13783
  counts: countDiagnostics(diagnostics),
12645
13784
  diagnostics,
12646
13785
  partial: skippedFiles.length > 0,
12647
- skippedFiles
13786
+ skippedFiles,
13787
+ unverifiedFiles: unverifiedFiles.length ? [...new Set(unverifiedFiles)].sort() : undefined
12648
13788
  };
12649
13789
  if (canPersistCache)
12650
13790
  saveProjectCache(cacheSession, project, cacheSession.effectModules ?? cachedEffectModules(cacheSession), canCacheWholeReport && !report.partial ? reportCacheKey : undefined, canCacheWholeReport && !report.partial ? {
12651
13791
  scannedFiles: report.scannedFiles,
12652
13792
  candidateFiles: report.candidateFiles ?? candidates.length,
12653
- diagnostics: report.diagnostics
13793
+ diagnostics: report.diagnostics,
13794
+ unverifiedFiles: report.unverifiedFiles
12654
13795
  } : undefined);
12655
13796
  return report;
12656
13797
  } finally {
@@ -12732,24 +13873,29 @@ async function scanProjectWithScope(projectRoot, options) {
12732
13873
  let diagnostics = current.diagnostics;
12733
13874
  let partial = current.partial;
12734
13875
  let skippedFiles = current.skippedFiles;
13876
+ let unverifiedFiles = current.unverifiedFiles;
12735
13877
  if (effectiveScope === "changed") {
12736
13878
  const baseline = await scanPath(targetRoot, {
12737
13879
  ...sharedScanOptions(scanOptions, projectRoot, "baseline"),
12738
13880
  files: plan.baselineFiles
12739
13881
  });
12740
- options.onDelta?.(current.diagnostics, baseline.diagnostics);
13882
+ options.onDelta?.(current.diagnostics, baseline.diagnostics, { current, baseline, plan });
12741
13883
  const baselineSkipped = new Set(baseline.skippedFiles ?? []);
12742
13884
  const baselinePaths = new Set(plan.baselineFiles.map((file) => file.relativePath ?? ""));
12743
13885
  const comparableCurrent = current.diagnostics.filter((diagnostic) => !baselinePaths.has(diagnostic.file) || !baselineSkipped.has(diagnostic.file));
12744
13886
  diagnostics = introducedDiagnostics(comparableCurrent, baseline.diagnostics);
12745
13887
  partial = partial || Boolean(baseline.partial);
12746
13888
  skippedFiles = [...new Set([...current.skippedFiles ?? [], ...baseline.skippedFiles ?? []])].sort();
13889
+ unverifiedFiles = [
13890
+ ...new Set([...current.unverifiedFiles ?? [], ...baseline.unverifiedFiles ?? []])
13891
+ ].sort();
12747
13892
  } else if (effectiveScope === "lines") {
12748
13893
  diagnostics = current.diagnostics.filter((diagnostic) => touchesChangedLines(diagnostic, plan.changedLines.get(diagnostic.file) ?? []));
12749
13894
  }
12750
13895
  return rebuildReport(current, diagnostics, {
12751
13896
  partial,
12752
13897
  skippedFiles,
13898
+ unverifiedFiles,
12753
13899
  scope: options.staged ? "staged" : effectiveScope,
12754
13900
  base: plan.base,
12755
13901
  changedFiles: plan.changedFiles
@@ -12767,6 +13913,7 @@ function aggregateReports(root, reports) {
12767
13913
  const startedDuration = reports.reduce((total, entry) => total + entry.report.durationMs, 0);
12768
13914
  const diagnostics = [];
12769
13915
  const skippedFiles = [];
13916
+ const unverifiedFiles = [];
12770
13917
  const changedFiles = [];
12771
13918
  const notes = [];
12772
13919
  const projects = [];
@@ -12781,6 +13928,7 @@ function aggregateReports(root, reports) {
12781
13928
  partial = partial || Boolean(report.partial);
12782
13929
  diagnostics.push(...report.diagnostics.map((diagnostic) => prefixDiagnostic(diagnostic, prefix)));
12783
13930
  skippedFiles.push(...(report.skippedFiles ?? []).map((file) => prefix ? normalize3(path10.posix.join(prefix, file)) : file));
13931
+ unverifiedFiles.push(...(report.unverifiedFiles ?? []).map((file) => prefix ? normalize3(path10.posix.join(prefix, file)) : file));
12784
13932
  changedFiles.push(...(report.changedFiles ?? []).map((file) => prefix ? normalize3(path10.posix.join(prefix, file)) : file));
12785
13933
  notes.push(...report.notes ?? []);
12786
13934
  }
@@ -12801,6 +13949,7 @@ function aggregateReports(root, reports) {
12801
13949
  diagnostics,
12802
13950
  partial,
12803
13951
  skippedFiles: [...new Set(skippedFiles)].sort(),
13952
+ unverifiedFiles: unverifiedFiles.length ? [...new Set(unverifiedFiles)].sort() : undefined,
12804
13953
  scope: scopes.size === 1 ? [...scopes][0] : undefined,
12805
13954
  base: bases.size === 1 ? [...bases][0] : undefined,
12806
13955
  changedFiles: [...new Set(changedFiles)].sort(),
@@ -12809,7 +13958,7 @@ function aggregateReports(root, reports) {
12809
13958
  };
12810
13959
  }
12811
13960
 
12812
- // src/ci.ts
13961
+ // src/ci/index.ts
12813
13962
  var DEFAULT_SETTINGS = {
12814
13963
  provider: "github",
12815
13964
  blocking: "none",
@@ -12822,11 +13971,6 @@ var DEFAULT_SETTINGS = {
12822
13971
  };
12823
13972
  var GITHUB_WORKFLOW = path11.join(".github", "workflows", "react-luau-doctor.yml");
12824
13973
  var GITLAB_WORKFLOW = ".gitlab-ci.yml";
12825
- var SUMMARY_MARKER = "<!-- react-luau-doctor:summary -->";
12826
- var REVIEW_MARKER = "<!-- react-luau-doctor:review -->";
12827
- var MAX_REVIEW_COMMENTS = 20;
12828
- var MAX_GITHUB_RETRIES = 2;
12829
- var MAX_RATE_LIMIT_WAIT_MS = 60000;
12830
13974
  var PACKAGE_SPEC = `${package_default.name}@${package_default.version}`;
12831
13975
  function yamlString(value) {
12832
13976
  return JSON.stringify(value);
@@ -12855,6 +13999,8 @@ jobs:
12855
13999
  - uses: actions/checkout@v5
12856
14000
  with:
12857
14001
  fetch-depth: 0
14002
+ ref: \${{ github.event.pull_request.head.sha || github.sha }}
14003
+ persist-credentials: false
12858
14004
  - uses: oven-sh/setup-bun@v2
12859
14005
  with:
12860
14006
  bun-version: "1.4.0"
@@ -12874,6 +14020,12 @@ jobs:
12874
14020
  ${settings.comment ? "--comment" : "--no-comment"}
12875
14021
  ${settings.reviewComments ? "--review-comments" : "--no-review-comments"}
12876
14022
  ${settings.commitStatus ? "--commit-status" : "--no-commit-status"}
14023
+ - name: Upload full Doctor report
14024
+ if: always() && steps.doctor.outputs.report-path != ''
14025
+ uses: actions/upload-artifact@v4
14026
+ with:
14027
+ name: react-luau-doctor-report
14028
+ path: \${{ steps.doctor.outputs.report-path }}
12877
14029
  `;
12878
14030
  }
12879
14031
  function renderGitLabWorkflow(settings) {
@@ -12888,6 +14040,8 @@ react-luau-doctor:
12888
14040
  DOCTOR_DIRECTORY: ${yamlString(settings.directory)}
12889
14041
  DOCTOR_SCOPE: ${yamlString(settings.scope)}
12890
14042
  DOCTOR_BLOCKING: ${yamlString(settings.blocking)}
14043
+ before_script:
14044
+ - apt-get update -qq && apt-get install -y --no-install-recommends git
12891
14045
  script:
12892
14046
  - 'bunx --bun ${PACKAGE_SPEC} "$DOCTOR_DIRECTORY" --scope "$DOCTOR_SCOPE" --blocking "$DOCTOR_BLOCKING" --no-color'
12893
14047
  `;
@@ -12900,8 +14054,9 @@ function parseBoolean(value, name) {
12900
14054
  throw new Error(`${name} must be true or false`);
12901
14055
  }
12902
14056
  function parseBlocking(value) {
12903
- if (value !== "error" && value !== "warning" && value !== "none")
14057
+ if (value !== "error" && value !== "warning" && value !== "none") {
12904
14058
  throw new Error("--blocking must be error, warning, or none");
14059
+ }
12905
14060
  return value;
12906
14061
  }
12907
14062
  function parseScope(value) {
@@ -12911,8 +14066,9 @@ function parseScope(value) {
12911
14066
  return value;
12912
14067
  }
12913
14068
  function parseProvider(value) {
12914
- if (value !== "github" && value !== "gitlab")
14069
+ if (value !== "github" && value !== "gitlab") {
12915
14070
  throw new Error("--provider must be github or gitlab");
14071
+ }
12916
14072
  return value;
12917
14073
  }
12918
14074
  function nextValue(argv, index, flag) {
@@ -12969,7 +14125,9 @@ function parseManagedWorkflowSettings(filename) {
12969
14125
  return {};
12970
14126
  const text = fs9.readFileSync(filename, "utf8");
12971
14127
  const match = (name) => text.match(new RegExp(`^\\s*${name}:\\s*(.+?)\\s*$`, "m"))?.[1]?.replace(/^['"]|['"]$/g, "");
12972
- const result = { provider: filename.endsWith(".gitlab-ci.yml") ? "gitlab" : "github" };
14128
+ const result = {
14129
+ provider: filename.endsWith(".gitlab-ci.yml") ? "gitlab" : "github"
14130
+ };
12973
14131
  const blocking = match("DOCTOR_BLOCKING") ?? match("blocking");
12974
14132
  const scope = match("DOCTOR_SCOPE") ?? match("scope");
12975
14133
  const project = match("DOCTOR_PROJECT") ?? match("project");
@@ -13060,15 +14218,23 @@ function runCommand(cwd, command, args, allowFailure = false) {
13060
14218
  }
13061
14219
  function openPullRequest(cwd, files, action) {
13062
14220
  findGitRoot(cwd);
13063
- if (!runCommand(cwd, "gh", ["--version"], true))
14221
+ if (!runCommand(cwd, "gh", ["--version"], true)) {
13064
14222
  throw new Error("--pr requires the GitHub CLI (gh) to be installed and authenticated");
14223
+ }
13065
14224
  const branch = action === "install" ? "chore/add-react-luau-doctor-ci" : "chore/upgrade-react-luau-doctor-ci";
13066
14225
  const title = action === "install" ? "Add React-Luau Doctor CI" : "Upgrade React-Luau Doctor CI";
13067
14226
  runCommand(cwd, "git", ["switch", "-c", branch]);
13068
14227
  runCommand(cwd, "git", ["add", "--", ...files.map((file) => path11.relative(cwd, file))]);
13069
14228
  runCommand(cwd, "git", ["commit", "-m", title]);
13070
14229
  runCommand(cwd, "git", ["push", "-u", "origin", branch]);
13071
- const url = runCommand(cwd, "gh", ["pr", "create", "--title", title, "--body", "Adds React-Luau Doctor pull request analysis."]);
14230
+ const url = runCommand(cwd, "gh", [
14231
+ "pr",
14232
+ "create",
14233
+ "--title",
14234
+ title,
14235
+ "--body",
14236
+ "Adds React-Luau Doctor pull request analysis."
14237
+ ]);
13072
14238
  process.stdout.write(`${url}
13073
14239
  `);
13074
14240
  }
@@ -13084,80 +14250,296 @@ async function runCiManage(action, argv) {
13084
14250
  return;
13085
14251
  }
13086
14252
  const files = writeManagedCi(options.cwd, settings);
13087
- for (const filename of files)
14253
+ for (const filename of files) {
13088
14254
  process.stdout.write(`${action === "install" ? "Installed" : action === "config" ? "Updated" : "Upgraded"} ${path11.relative(options.cwd, filename)}
13089
14255
  `);
14256
+ }
13090
14257
  if (options.pr)
13091
14258
  openPullRequest(options.cwd, files, action === "install" ? "install" : "upgrade");
13092
14259
  }
13093
- function diagnosticFingerprint2(diagnostic) {
13094
- return `${diagnostic.file}\x00${diagnostic.rule}\x00${diagnostic.severity}\x00${diagnostic.message}`;
13095
- }
13096
- function countDifference(left, right) {
13097
- const counts = new Map;
13098
- for (const diagnostic of right) {
13099
- const key = diagnosticFingerprint2(diagnostic);
13100
- counts.set(key, (counts.get(key) ?? 0) + 1);
13101
- }
13102
- let difference = 0;
13103
- for (const diagnostic of left) {
13104
- const key = diagnosticFingerprint2(diagnostic);
13105
- const remaining = counts.get(key) ?? 0;
13106
- if (remaining > 0)
13107
- counts.set(key, remaining - 1);
13108
- else
13109
- difference += 1;
13110
- }
13111
- return difference;
13112
- }
13113
- async function fixedIssueCount(directory, base) {
13114
- const plan = createGitScopePlan(directory, { scope: "changed", base });
13115
- if (plan.currentFiles.length === 0 && plan.baselineFiles.length === 0)
13116
- return 0;
13117
- const config = loadConfigWithSource(directory).config;
13118
- const selected = (file) => isSelectedLuauPath(file.relativePath ?? "", config);
13119
- const current = await scanPath(directory, { files: plan.currentFiles.filter(selected), config, categories: config.categories });
13120
- const baseline = await scanPath(directory, { files: plan.baselineFiles.filter(selected), config, categories: config.categories });
13121
- return countDifference(baseline.diagnostics, current.diagnostics);
13122
- }
13123
14260
  function resolveCiProjectRoots(directory, project) {
13124
14261
  if (!project || project === "*")
13125
14262
  return [directory];
13126
14263
  return project.split(",").map((entry) => {
13127
14264
  const resolved = path11.resolve(directory, entry.trim());
13128
- if (!fs9.existsSync(resolved) || !fs9.statSync(resolved).isDirectory())
14265
+ if (!fs9.existsSync(resolved) || !fs9.statSync(resolved).isDirectory()) {
13129
14266
  throw new Error(`Could not find project: ${entry}`);
14267
+ }
13130
14268
  return resolved;
13131
14269
  });
13132
14270
  }
13133
- async function scanForCi(settings, eventName, base, onFixed) {
13134
- const directory = path11.resolve(process.cwd(), settings.directory);
13135
- const isPullRequest = eventName === "pull_request";
13136
- const scope = isPullRequest ? settings.scope : "full";
14271
+ function gitResult(directory, args) {
14272
+ const result = spawnSync2(gitExecutable, args, {
14273
+ cwd: directory,
14274
+ encoding: "utf8",
14275
+ windowsHide: true,
14276
+ maxBuffer: 64 * 1024 * 1024
14277
+ });
14278
+ return result.status === 0 ? result.stdout : null;
14279
+ }
14280
+ function renameResolver(directory, head) {
14281
+ const cache = new Map;
14282
+ return (file, commit) => {
14283
+ if (!commit)
14284
+ return file;
14285
+ if (!cache.has(commit)) {
14286
+ const diff = gitResult(directory, [
14287
+ "diff",
14288
+ "--name-status",
14289
+ "-z",
14290
+ "--find-renames",
14291
+ commit,
14292
+ head
14293
+ ]);
14294
+ const renames = new Map;
14295
+ if (diff !== null) {
14296
+ const fields = diff.split("\x00");
14297
+ for (let i = 0;i < fields.length && fields[i]; ) {
14298
+ const status = fields[i++];
14299
+ const oldPath = fields[i++];
14300
+ if (/^[RC]/.test(status)) {
14301
+ const newPath = fields[i++];
14302
+ if (status.startsWith("R"))
14303
+ renames.set(oldPath, newPath);
14304
+ }
14305
+ }
14306
+ }
14307
+ cache.set(commit, diff === null ? null : renames);
14308
+ }
14309
+ const renames = cache.get(commit);
14310
+ return renames ? renames.get(file) ?? file : null;
14311
+ };
14312
+ }
14313
+ async function scanForCi(settings, isPullRequest, base, head, snapshot) {
14314
+ const directory = path11.resolve(settings.directory);
14315
+ const repoRoot = isPullRequest ? findGitRoot(directory) : directory;
13137
14316
  const roots = resolveCiProjectRoots(directory, settings.project);
14317
+ const canonicalFile = renameResolver(repoRoot, head);
14318
+ const historicalFiles = new Set;
14319
+ for (const review of snapshot?.reviews ?? []) {
14320
+ const state = readReviewState(review.body ?? "");
14321
+ if (!state)
14322
+ continue;
14323
+ for (const finding of state.findings) {
14324
+ const file = canonicalFile(finding.file, state.commit);
14325
+ if (file)
14326
+ historicalFiles.add(file);
14327
+ }
14328
+ }
14329
+ for (const comment of snapshot?.comments ?? []) {
14330
+ if (comment.user?.type === "Bot" && comment.body?.startsWith("<!-- react-luau-doctor:review -->")) {
14331
+ const file = canonicalFile(comment.path, comment.commit_id ?? "");
14332
+ if (file)
14333
+ historicalFiles.add(file);
14334
+ }
14335
+ }
13138
14336
  const reports = [];
13139
- for (const projectRoot of roots) {
13140
- const config = loadConfigWithSource(projectRoot).config;
13141
- const report = await scanProjectWithScope(projectRoot, {
13142
- scope,
14337
+ const comparisons = [];
14338
+ const currentDiagnostics = [];
14339
+ const fixed = [];
14340
+ const verifiedFiles = new Set;
14341
+ const deletedFiles = new Set;
14342
+ const uncertainFiles = new Set;
14343
+ const unverifiedFiles = new Set;
14344
+ const verificationSkippedFiles = new Set;
14345
+ const configs = roots.map((root) => ({ root, config: loadConfigWithSource(root).config }));
14346
+ const signature = createHash2("sha256").update(JSON.stringify({
14347
+ version: package_default.version,
14348
+ configs: configs.map(({ root, config }) => ({
14349
+ root: path11.relative(directory, root),
14350
+ config
14351
+ }))
14352
+ })).digest("hex");
14353
+ let complete = true;
14354
+ let potentiallyRelocated = false;
14355
+ const normalize = (file) => file.split(path11.sep).join("/");
14356
+ const relativeFile = (file, root) => normalize(path11.relative(directory, path11.resolve(root, file)));
14357
+ const rebaseDiagnostic = (diagnostic, root) => ({
14358
+ ...diagnostic,
14359
+ file: relativeFile(diagnostic.file, root)
14360
+ });
14361
+ for (const { root: projectRoot, config } of configs) {
14362
+ const sources = new Map;
14363
+ const eligibleFiles = new Set;
14364
+ const scope = isPullRequest ? settings.scope : "full";
14365
+ const comparisonOptions = {
14366
+ scope: "changed",
13143
14367
  base,
14368
+ config,
13144
14369
  minSeverity: "suggestion",
13145
14370
  categories: config.categories,
13146
14371
  respectInlineDisables: config.respectInlineDisables ?? true,
13147
- onDelta: onFixed ? (current, baseline) => onFixed(countDifference(baseline, current)) : undefined
13148
- });
14372
+ onDelta: (current, baseline, context) => {
14373
+ complete &&= !context.current.partial && !context.baseline.partial && !context.current.unverifiedFiles?.length && !context.baseline.unverifiedFiles?.length;
14374
+ for (const file of [
14375
+ ...context.current.unverifiedFiles ?? [],
14376
+ ...context.baseline.unverifiedFiles ?? []
14377
+ ]) {
14378
+ unverifiedFiles.add(relativeFile(file, projectRoot));
14379
+ }
14380
+ const skipped = new Set([
14381
+ ...context.current.skippedFiles ?? [],
14382
+ ...context.baseline.skippedFiles ?? []
14383
+ ]);
14384
+ const currentFiles = new Set(context.plan.currentFiles.map((file) => file.relativePath));
14385
+ for (const file of context.plan.changedFiles) {
14386
+ const repoFile = repoRelativeDiagnosticPath(projectRoot, file, repoRoot);
14387
+ eligibleFiles.add(repoFile);
14388
+ if (!currentFiles.has(file))
14389
+ deletedFiles.add(repoFile);
14390
+ if (!skipped.has(file))
14391
+ verifiedFiles.add(repoFile);
14392
+ }
14393
+ for (const input of context.plan.currentFiles) {
14394
+ if (input.relativePath && input.source !== undefined) {
14395
+ sources.set(repoRelativeDiagnosticPath(projectRoot, input.relativePath, repoRoot), input.source);
14396
+ }
14397
+ }
14398
+ for (const diagnostic of current) {
14399
+ currentDiagnostics.push(rebaseDiagnostic(diagnostic, projectRoot));
14400
+ }
14401
+ if (complete) {
14402
+ const baselineSources = new Map(context.plan.baselineFiles.map((file) => [
14403
+ file.relativePath,
14404
+ file.source?.split(/\r?\n/) ?? []
14405
+ ]));
14406
+ const currentSources = new Map(context.plan.currentFiles.map((file) => [
14407
+ file.relativePath,
14408
+ file.source?.split(/\r?\n/) ?? []
14409
+ ]));
14410
+ for (const diagnostic of diagnosticDifference(baseline, current, {
14411
+ left: (entry) => baselineSources.get(entry.file)?.[entry.location.line - 1]?.trim() ?? "",
14412
+ right: (entry) => currentSources.get(entry.file)?.[entry.location.line - 1]?.trim() ?? ""
14413
+ })) {
14414
+ if (!currentFiles.has(diagnostic.file) && current.some((entry) => entry.file !== diagnostic.file && entry.rule === diagnostic.rule && entry.severity === diagnostic.severity && entry.message === diagnostic.message)) {
14415
+ potentiallyRelocated = true;
14416
+ continue;
14417
+ }
14418
+ const oldFile = context.plan.baselinePaths.get(diagnostic.file) ?? diagnostic.file;
14419
+ const oldSource = context.plan.baselineFiles.find((file) => file.relativePath === diagnostic.file)?.source ?? "";
14420
+ const newSource = sources.get(repoRelativeDiagnosticPath(projectRoot, diagnostic.file, repoRoot)) ?? "";
14421
+ const directives = (source) => source.split(/\r?\n/).filter((line) => /react-luau-doctor.*(?:disable|ignore)/i.test(line)).join(`
14422
+ `);
14423
+ if (directives(oldSource) !== directives(newSource))
14424
+ continue;
14425
+ fixed.push({
14426
+ ...rebaseDiagnostic({ ...diagnostic, file: oldFile }, projectRoot),
14427
+ ...diagnostic.baselineLocationUncertain ? { baselineLocationUncertain: true } : {}
14428
+ });
14429
+ }
14430
+ }
14431
+ }
14432
+ };
14433
+ const report = await scanProjectWithScope(projectRoot, { ...comparisonOptions, scope });
13149
14434
  reports.push({ projectRoot, report });
14435
+ for (const file of report.unverifiedFiles ?? []) {
14436
+ unverifiedFiles.add(relativeFile(file, projectRoot));
14437
+ }
14438
+ complete &&= !report.partial && !report.unverifiedFiles?.length;
14439
+ if (isPullRequest && base) {
14440
+ const comparison = scope === "changed" ? report : await scanProjectWithScope(projectRoot, comparisonOptions);
14441
+ comparisons.push({ projectRoot, report: comparison });
14442
+ const extraFiles = [];
14443
+ for (const file of historicalFiles) {
14444
+ const absolutePath = path11.resolve(repoRoot, file);
14445
+ const relativePath = normalize(path11.relative(projectRoot, absolutePath));
14446
+ if (relativePath.startsWith("../") || path11.isAbsolute(relativePath) || !isSelectedLuauPath(relativePath, config) || eligibleFiles.has(file)) {
14447
+ continue;
14448
+ }
14449
+ eligibleFiles.add(file);
14450
+ if (!fs9.existsSync(absolutePath)) {
14451
+ const existsAtHead = gitResult(repoRoot, ["ls-tree", "-z", head, "--", file]);
14452
+ if (existsAtHead === "") {
14453
+ verifiedFiles.add(file);
14454
+ deletedFiles.add(file);
14455
+ }
14456
+ continue;
14457
+ }
14458
+ const source = fs9.readFileSync(absolutePath, "utf8");
14459
+ sources.set(file, source);
14460
+ extraFiles.push({ absolutePath, relativePath, source });
14461
+ }
14462
+ if (extraFiles.length) {
14463
+ const extra = await scanPath(projectRoot, {
14464
+ files: extraFiles,
14465
+ projectRoot,
14466
+ config,
14467
+ categories: config.categories
14468
+ });
14469
+ complete &&= !extra.partial && !extra.unverifiedFiles?.length;
14470
+ for (const file of extra.unverifiedFiles ?? []) {
14471
+ unverifiedFiles.add(relativeFile(file, projectRoot));
14472
+ }
14473
+ for (const file of extra.skippedFiles ?? []) {
14474
+ verificationSkippedFiles.add(relativeFile(file, projectRoot));
14475
+ }
14476
+ for (const file of extraFiles) {
14477
+ if (!extra.skippedFiles?.includes(file.relativePath)) {
14478
+ verifiedFiles.add(repoRelativeDiagnosticPath(projectRoot, file.relativePath, repoRoot));
14479
+ }
14480
+ }
14481
+ currentDiagnostics.push(...extra.diagnostics.map((diagnostic) => rebaseDiagnostic(diagnostic, projectRoot)));
14482
+ }
14483
+ }
14484
+ for (const [file, source] of sources) {
14485
+ if (/react-luau-doctor.*(?:disable|ignore)/i.test(source))
14486
+ uncertainFiles.add(file);
14487
+ }
13150
14488
  }
13151
- return roots.length === 1 && roots[0] === directory ? reports[0].report : aggregateReports(directory, reports);
13152
- }
13153
- async function findNewReviewDiagnostics(settings, eventName, event, currentReport) {
13154
- if (event.action === "synchronize" && event.before) {
13155
- const report = await scanForCi({ ...settings, scope: "changed" }, eventName, event.before);
13156
- return { diagnostics: report.diagnostics, base: event.before };
13157
- }
13158
- if (event.action && event.action !== "opened")
13159
- return { diagnostics: [] };
13160
- return { diagnostics: currentReport.diagnostics };
14489
+ const report = roots.length === 1 && roots[0] === directory ? reports[0].report : aggregateReports(directory, reports);
14490
+ const comparison = comparisons.length ? aggregateReports(directory, comparisons) : report;
14491
+ const sourceCache = new Map;
14492
+ const toFinding = (diagnostic) => {
14493
+ const file = repoRelativeDiagnosticPath(directory, diagnostic.file, repoRoot);
14494
+ const absolute = path11.resolve(repoRoot, file);
14495
+ if (!sourceCache.has(file)) {
14496
+ sourceCache.set(file, fs9.existsSync(absolute) ? fs9.readFileSync(absolute, "utf8").split(/\r?\n/) : []);
14497
+ }
14498
+ return {
14499
+ file,
14500
+ rule: diagnostic.rule,
14501
+ severity: diagnostic.severity,
14502
+ message: diagnostic.message,
14503
+ line: diagnostic.location.line,
14504
+ column: diagnostic.location.column,
14505
+ diagnosticId: diagnostic.id,
14506
+ anchor: createHash2("sha256").update(sourceCache.get(file)?.[diagnostic.location.line - 1]?.trim() ?? "").digest("hex").slice(0, 16),
14507
+ status: "active"
14508
+ };
14509
+ };
14510
+ return {
14511
+ repoRoot,
14512
+ report: {
14513
+ ...report,
14514
+ partial: Boolean(report.partial) || !complete,
14515
+ unverifiedFiles: unverifiedFiles.size ? [...unverifiedFiles].sort() : undefined,
14516
+ skippedFiles: [
14517
+ ...new Set([...report.skippedFiles ?? [], ...verificationSkippedFiles])
14518
+ ].sort(),
14519
+ notes: potentiallyRelocated ? [
14520
+ ...report.notes ?? [],
14521
+ "Some removed findings may have moved to other files and were not counted as fixed"
14522
+ ] : report.notes
14523
+ },
14524
+ comparison,
14525
+ fixed: complete ? fixed : [],
14526
+ analysis: {
14527
+ signature,
14528
+ complete,
14529
+ current: currentDiagnostics.map(toFinding),
14530
+ introduced: comparison.diagnostics.map((diagnostic) => ({
14531
+ diagnostic,
14532
+ finding: toFinding(diagnostic)
14533
+ })),
14534
+ verifiedFiles,
14535
+ deletedFiles,
14536
+ canonicalFile,
14537
+ eligible: (finding) => !uncertainFiles.has(finding.file) && configs.some(({ root, config }) => {
14538
+ const relative = normalize(path11.relative(root, path11.resolve(repoRoot, finding.file)));
14539
+ return !relative.startsWith("../") && !path11.isAbsolute(relative) && isSelectedLuauPath(relative, config) && config.rules?.[finding.rule] !== "off";
14540
+ })
14541
+ }
14542
+ };
13161
14543
  }
13162
14544
  function shouldBlock(report, level) {
13163
14545
  if (level === "none")
@@ -13191,329 +14573,32 @@ function githubRunUrl() {
13191
14573
  const runId = process.env.GITHUB_RUN_ID;
13192
14574
  return server && repository && runId ? `${server}/${repository}/actions/runs/${runId}` : null;
13193
14575
  }
13194
- function wait(milliseconds) {
13195
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
13196
- }
13197
14576
  function errorMessage(error) {
13198
14577
  return error instanceof Error ? error.message : String(error);
13199
14578
  }
13200
- function githubRetryDelay(response, responseText, attempt) {
13201
- const remaining = response.headers.get("x-ratelimit-remaining");
13202
- const rateLimited = response.status === 429 || response.status === 403 && (remaining === "0" || /rate limit|rate_limit|abuse detection/i.test(responseText)) || response.status === 200 && /"errors"\s*:/.test(responseText) && /rate limit|rate_limit/i.test(responseText);
13203
- if (!rateLimited)
13204
- return null;
13205
- const retryAfter = response.headers.get("retry-after");
13206
- if (retryAfter) {
13207
- const seconds = Number(retryAfter);
13208
- if (Number.isFinite(seconds) && seconds >= 0)
13209
- return seconds * 1000;
13210
- const date = Date.parse(retryAfter);
13211
- if (Number.isFinite(date))
13212
- return Math.max(0, date - Date.now());
13213
- }
13214
- if (remaining === "0") {
13215
- const reset = Number(response.headers.get("x-ratelimit-reset"));
13216
- if (Number.isFinite(reset))
13217
- return Math.max(0, reset * 1000 - Date.now() + 1000);
13218
- }
13219
- return 60000 * 2 ** attempt;
13220
- }
13221
- async function githubRequest(url, options = {}) {
13222
- const token = process.env.GITHUB_TOKEN;
13223
- if (!token)
13224
- throw new Error("GITHUB_TOKEN is unavailable");
13225
- for (let attempt = 0;; attempt += 1) {
13226
- const response = await fetch(url, {
13227
- method: options.method ?? "GET",
13228
- headers: {
13229
- Accept: "application/vnd.github+json",
13230
- Authorization: `Bearer ${token}`,
13231
- "X-GitHub-Api-Version": "2022-11-28",
13232
- "User-Agent": "react-luau-doctor",
13233
- "Content-Type": "application/json"
13234
- },
13235
- body: options.body === undefined ? undefined : JSON.stringify(options.body)
13236
- });
13237
- const responseText = response.status === 204 ? "" : await response.text();
13238
- const delay = githubRetryDelay(response, responseText, attempt);
13239
- if (response.ok && delay === null)
13240
- return responseText ? JSON.parse(responseText) : undefined;
13241
- if (delay === null || attempt >= MAX_GITHUB_RETRIES || delay > MAX_RATE_LIMIT_WAIT_MS) {
13242
- const retryDetail = delay !== null && delay > MAX_RATE_LIMIT_WAIT_MS ? ` Retry after ${Math.ceil(delay / 1000)} seconds.` : "";
13243
- throw new Error(`GitHub API ${response.status}: ${responseText}${retryDetail}`);
13244
- }
13245
- process.stderr.write(`react-luau-doctor: GitHub API ${response.status}; retrying in ${Math.ceil(delay / 1000)} seconds
13246
- `);
13247
- await wait(delay);
13248
- }
13249
- }
13250
- async function githubApi(repo, endpoint, options = {}) {
13251
- const apiBase = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
13252
- return githubRequest(`${apiBase}/repos/${repo}${endpoint}`, options);
13253
- }
13254
- function repoRelativeDiagnosticPath(directory, diagnosticFile) {
14579
+ function repoRelativeDiagnosticPath(directory, diagnosticFile, repoRoot) {
13255
14580
  const absoluteDirectory = path11.resolve(directory);
13256
- const repoRoot = findGitRoot(absoluteDirectory);
13257
14581
  const prefix = path11.relative(repoRoot, absoluteDirectory).split(path11.sep).join("/");
13258
14582
  return prefix && prefix !== "." ? path11.posix.join(prefix, diagnosticFile) : diagnosticFile;
13259
14583
  }
13260
- function githubFileUrl(repo, sha, diagnostic, directory) {
14584
+ function githubFileUrl(repo, sha, diagnostic, directory, repoRoot) {
13261
14585
  const server = process.env.GITHUB_SERVER_URL ?? "https://github.com";
13262
- const repoPath = repoRelativeDiagnosticPath(directory, diagnostic.file);
13263
- const encoded = repoPath.split("/").map(encodeURIComponent).join("/");
13264
- return `${server}/${repo}/blob/${sha}/${encoded}#L${diagnostic.location.line}`;
13265
- }
13266
- function renderSummaryComment(report, repo, sha, fixed, skipped, directory) {
13267
- if (skipped) {
13268
- return `${SUMMARY_MARKER}
13269
- ## React-Luau Doctor
13270
-
13271
- No eligible React-Luau files changed in this pull request, so the scan was skipped.
13272
- `;
13273
- }
13274
- const lines = [
13275
- SUMMARY_MARKER,
13276
- "## React-Luau Doctor",
13277
- "",
13278
- `**${report.counts.error} errors \xB7 ${report.counts.warning} warnings \xB7 ${report.counts.suggestion} suggestions \xB7 Score ${report.score}/100**`,
13279
- "",
13280
- `${report.diagnostics.length} ${report.scope === "changed" ? "introduced finding" : "reported finding"}${report.diagnostics.length === 1 ? "" : "s"} \xB7 ${fixed} fixed`
13281
- ];
13282
- const errors = report.diagnostics.filter((diagnostic) => diagnostic.severity === "error").slice(0, 20);
13283
- if (errors.length > 0) {
13284
- lines.push("", "### Errors");
13285
- for (const diagnostic of errors) {
13286
- lines.push(`- [\`${diagnostic.file}:${diagnostic.location.line}\`](${githubFileUrl(repo, sha, diagnostic, directory)}) **${diagnostic.rule}**: ${diagnostic.message}`);
13287
- }
13288
- }
13289
- const warningsByFile = new Map;
13290
- for (const diagnostic of report.diagnostics.filter((entry) => entry.severity === "warning").slice(0, 50)) {
13291
- const entries = warningsByFile.get(diagnostic.file) ?? [];
13292
- entries.push(diagnostic);
13293
- warningsByFile.set(diagnostic.file, entries);
13294
- }
13295
- if (warningsByFile.size > 0) {
13296
- lines.push("", "### Warnings");
13297
- for (const [file, diagnostics] of warningsByFile) {
13298
- lines.push(`- **${file}**`);
13299
- for (const diagnostic of diagnostics)
13300
- lines.push(` - [line ${diagnostic.location.line}](${githubFileUrl(repo, sha, diagnostic, directory)}): ${diagnostic.message}`);
13301
- }
13302
- }
13303
- if (report.diagnostics.length > errors.length + [...warningsByFile.values()].reduce((sum, entries) => sum + entries.length, 0)) {
13304
- lines.push("", "Additional findings are available in the workflow logs.");
13305
- }
13306
- const runUrl = githubRunUrl();
13307
- if (runUrl)
13308
- lines.push("", `[View full workflow run](${runUrl})`);
13309
- lines.push("", `<sub>Reviewed commit \`${sha.slice(0, 7)}\`</sub>`);
13310
- return `${lines.join(`
13311
- `)}
13312
- `;
13313
- }
13314
- async function listComments(repo, endpoint) {
13315
- const comments = [];
13316
- for (let page = 1;; page += 1) {
13317
- const batch = await githubApi(repo, `${endpoint}?per_page=100&page=${page}`);
13318
- comments.push(...batch);
13319
- if (batch.length < 100)
13320
- return comments;
13321
- }
13322
- }
13323
- function isDoctorComment(comment, marker) {
13324
- return comment.user?.type === "Bot" && comment.body?.startsWith(marker) === true;
13325
- }
13326
- async function updateStickyComment(repo, pullNumber, body, createIfMissing = true) {
13327
- const comments = await listComments(repo, `/issues/${pullNumber}/comments`);
13328
- const previous = comments.find((comment) => isDoctorComment(comment, SUMMARY_MARKER));
13329
- if (previous)
13330
- await githubApi(repo, `/issues/comments/${previous.id}`, { method: "PATCH", body: { body } });
13331
- else if (createIfMissing)
13332
- await githubApi(repo, `/issues/${pullNumber}/comments`, { method: "POST", body: { body } });
13333
- }
13334
- function changedLineMap(directory, base) {
13335
- return createGitScopePlan(directory, { scope: "lines", base }).changedLines;
13336
- }
13337
- function touchesChangedLine(diagnostic, ranges) {
13338
- return ranges.some((range) => diagnostic.location.line >= range.start && diagnostic.location.line <= range.end);
13339
- }
13340
- function reviewFingerprint(pathname, rule, severity, message) {
13341
- return createHash("sha256").update(`${pathname}\x00${rule}\x00${severity}\x00${message}`).digest("hex").slice(0, 32);
13342
- }
13343
- function diagnosticReviewFingerprint(directory, diagnostic) {
13344
- return reviewFingerprint(repoRelativeDiagnosticPath(directory, diagnostic.file), diagnostic.rule, diagnostic.severity, diagnostic.message);
14586
+ const repoPath = repoRelativeDiagnosticPath(directory, diagnostic.file, repoRoot);
14587
+ const encoded = repoPath.split("/").map((segment) => encodeURIComponent(segment).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16)}`)).join("/");
14588
+ return `${server}/${repo}/blob/${sha}/${encoded}${diagnostic.baselineLocationUncertain ? "" : `#L${diagnostic.location.line}`}`;
13345
14589
  }
13346
- function fingerprintFromReviewBody(body, pathname) {
13347
- const marker = body.match(/<!-- react-luau-doctor:fingerprint:([a-f0-9]{32}) -->/i);
13348
- if (marker)
13349
- return marker[1].toLowerCase();
13350
- const legacy = body.match(/\*\*React-Luau Doctor\*\* \u00B7 `([^`]+)` \((error|warning|suggestion)\)\n\n([^\n]+)/);
13351
- return legacy ? reviewFingerprint(pathname, legacy[1], legacy[2], legacy[3]) : null;
13352
- }
13353
- function reviewCommentBody(directory, diagnostic) {
13354
- const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
13355
- return `${REVIEW_MARKER}
13356
- <!-- react-luau-doctor:fingerprint:${fingerprint} -->
13357
- **React-Luau Doctor** \xB7 \`${diagnostic.rule}\` (${diagnostic.severity})
13358
-
13359
- ${diagnostic.message}${diagnostic.help ? `
13360
-
13361
- ${diagnostic.help}` : ""}`;
13362
- }
13363
- 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.";
13364
- async function listReviewComments(repo, pullNumber) {
13365
- const comments = [];
13366
- for (let page = 1;; page += 1) {
13367
- const batch = await githubApi(repo, `/pulls/${pullNumber}/comments?per_page=100&page=${page}`);
13368
- comments.push(...batch);
13369
- if (batch.length < 100)
13370
- return comments;
13371
- }
13372
- }
13373
- async function listReviews(repo, pullNumber) {
13374
- const reviews = [];
13375
- for (let page = 1;; page += 1) {
13376
- const batch = await githubApi(repo, `/pulls/${pullNumber}/reviews?per_page=100&page=${page}`);
13377
- reviews.push(...batch);
13378
- if (batch.length < 100)
13379
- return reviews;
13380
- }
13381
- }
13382
- function isDoctorReview(review) {
13383
- return review.user?.type === "Bot" && (review.body?.includes("React-Luau Doctor") ?? false);
13384
- }
13385
- function doctorReviewCommentFingerprint(comment) {
13386
- if (comment.user?.type !== "Bot" || !comment.body.startsWith(REVIEW_MARKER))
13387
- return null;
13388
- return fingerprintFromReviewBody(comment.body, comment.path);
13389
- }
13390
- function takeCount(counts, key) {
13391
- const count = counts.get(key) ?? 0;
13392
- if (count === 0)
13393
- return false;
13394
- counts.set(key, count - 1);
13395
- return true;
13396
- }
13397
- function incrementCount(counts, key) {
13398
- counts.set(key, (counts.get(key) ?? 0) + 1);
13399
- }
13400
- async function manageReviewComments(repo, pullNumber, currentReport, newDiagnostics, directory, newBase, pullBase) {
13401
- const [allReviewComments, reviews] = await Promise.all([
13402
- listReviewComments(repo, pullNumber),
13403
- listReviews(repo, pullNumber)
13404
- ]);
13405
- const reviewComments = allReviewComments.map((comment) => ({ comment, fingerprint: doctorReviewCommentFingerprint(comment) })).filter((entry) => entry.fingerprint !== null);
13406
- const activeCounts = new Map;
13407
- for (const diagnostic of currentReport.diagnostics)
13408
- incrementCount(activeCounts, diagnosticReviewFingerprint(directory, diagnostic));
13409
- const newCounts = new Map;
13410
- for (const diagnostic of newDiagnostics)
13411
- incrementCount(newCounts, diagnosticReviewFingerprint(directory, diagnostic));
13412
- const replacementCounts = new Map;
13413
- const staleComments = [];
13414
- const orderedComments = reviewComments.slice().sort((left, right) => Number(left.comment.position === null) - Number(right.comment.position === null));
13415
- for (const { comment, fingerprint } of orderedComments) {
13416
- if (comment.position !== null && takeCount(activeCounts, fingerprint)) {
13417
- takeCount(newCounts, fingerprint);
13418
- continue;
13419
- }
13420
- const needsReplacement = takeCount(activeCounts, fingerprint);
13421
- if (needsReplacement) {
13422
- incrementCount(replacementCounts, fingerprint);
13423
- takeCount(newCounts, fingerprint);
13424
- }
13425
- staleComments.push({ comment, fingerprint, needsReplacement });
13426
- }
13427
- const newLineMap = newCounts.size > 0 || newBase === pullBase && replacementCounts.size > 0 ? changedLineMap(directory, newBase) : new Map;
13428
- const pullLineMap = newBase === pullBase ? newLineMap : replacementCounts.size > 0 ? changedLineMap(directory, pullBase) : new Map;
13429
- const candidates = [];
13430
- for (const diagnostic of newDiagnostics) {
13431
- if (!touchesChangedLine(diagnostic, newLineMap.get(diagnostic.file) ?? []))
13432
- continue;
13433
- const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
13434
- if (!takeCount(newCounts, fingerprint))
13435
- continue;
13436
- candidates.push({ diagnostic, replacement: false });
13437
- }
13438
- for (const diagnostic of currentReport.diagnostics) {
13439
- if (!touchesChangedLine(diagnostic, pullLineMap.get(diagnostic.file) ?? []))
13440
- continue;
13441
- const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
13442
- if (!takeCount(replacementCounts, fingerprint))
13443
- continue;
13444
- candidates.push({ diagnostic, replacement: true });
13445
- }
13446
- const selected = candidates.slice(0, MAX_REVIEW_COMMENTS);
13447
- const comments = selected.map(({ diagnostic }) => ({
13448
- path: repoRelativeDiagnosticPath(directory, diagnostic.file),
13449
- line: diagnostic.location.line,
13450
- side: "RIGHT",
13451
- body: reviewCommentBody(directory, diagnostic)
13452
- }));
13453
- const failures = [];
13454
- let postedNewComments = true;
13455
- if (comments.length > 0) {
13456
- try {
13457
- await githubApi(repo, `/pulls/${pullNumber}/reviews`, {
13458
- method: "POST",
13459
- body: { event: "COMMENT", body: "React-Luau Doctor found new issues", comments }
13460
- });
13461
- } catch (error) {
13462
- postedNewComments = false;
13463
- failures.push(`could not create new review comments: ${errorMessage(error)}`);
13464
- }
13465
- }
13466
- if (postedNewComments) {
13467
- const postedReplacementCounts = new Map;
13468
- for (const candidate of selected) {
13469
- if (candidate.replacement)
13470
- incrementCount(postedReplacementCounts, diagnosticReviewFingerprint(directory, candidate.diagnostic));
13471
- }
13472
- const remainingByReview = new Map;
13473
- for (const { comment } of reviewComments) {
13474
- remainingByReview.set(comment.pull_request_review_id, (remainingByReview.get(comment.pull_request_review_id) ?? 0) + 1);
13475
- }
13476
- for (const stale of staleComments) {
13477
- if (stale.needsReplacement && !takeCount(postedReplacementCounts, stale.fingerprint))
13478
- continue;
13479
- try {
13480
- await githubApi(repo, `/pulls/comments/${stale.comment.id}`, { method: "DELETE" });
13481
- const reviewId = stale.comment.pull_request_review_id;
13482
- const remaining = Math.max(0, (remainingByReview.get(reviewId) ?? 1) - 1);
13483
- remainingByReview.set(reviewId, remaining);
13484
- } catch (error) {
13485
- failures.push(`could not delete stale review comment ${stale.comment.id}: ${errorMessage(error)}`);
13486
- }
13487
- }
13488
- for (const review of reviews) {
13489
- if (!isDoctorReview(review) || review.body === ARCHIVED_REVIEW_BODY)
13490
- continue;
13491
- if ((remainingByReview.get(review.id) ?? 0) > 0)
13492
- continue;
13493
- try {
13494
- await githubApi(repo, `/pulls/${pullNumber}/reviews/${review.id}`, {
13495
- method: "PUT",
13496
- body: { body: ARCHIVED_REVIEW_BODY }
13497
- });
13498
- } catch (error) {
13499
- failures.push(`could not archive empty review ${review.id}: ${errorMessage(error)}`);
13500
- }
13501
- }
13502
- }
13503
- if (failures.length > 0)
13504
- throw new Error(failures.join("; "));
13505
- }
13506
- async function publishCommitStatus(repo, sha, report, blocking) {
14590
+ async function publishCommitStatus(client, sha, report, blocking) {
13507
14591
  const blocked = shouldBlock(report, blocking);
13508
- await githubApi(repo, `/statuses/${sha}`, {
13509
- method: "POST",
13510
- body: {
13511
- state: blocked ? "failure" : "success",
13512
- context: "React-Luau Doctor",
13513
- description: `Score ${report.score}/100 \xB7 ${report.counts.error} errors \xB7 ${report.counts.warning} warnings`,
13514
- target_url: githubRunUrl() ?? undefined
13515
- }
13516
- });
14592
+ const body = {
14593
+ state: report.partial ? "error" : blocked ? "failure" : "success",
14594
+ context: "React-Luau Doctor",
14595
+ description: report.partial ? "Analysis incomplete; findings and fixes are unverified" : `Score ${report.score}/100 \xB7 ${report.counts.error} errors \xB7 ${report.counts.warning} warnings`,
14596
+ target_url: githubRunUrl() ?? undefined
14597
+ };
14598
+ if (client.pullNumber)
14599
+ await client.mutate(`/statuses/${sha}`, "POST", body);
14600
+ else
14601
+ await client.request(`/statuses/${sha}`, { method: "POST", body });
13517
14602
  }
13518
14603
  function parseCiRunOptions(argv) {
13519
14604
  const settings = { ...DEFAULT_SETTINGS };
@@ -13555,17 +14640,33 @@ function parseCiRunOptions(argv) {
13555
14640
  async function runCiJob(argv) {
13556
14641
  const settings = parseCiRunOptions(argv);
13557
14642
  const eventName = process.env.GITHUB_EVENT_NAME ?? "";
14643
+ if (eventName === "pull_request_target") {
14644
+ throw new Error("ci run does not execute PR analysis under pull_request_target; use pull_request with least-privilege permissions");
14645
+ }
13558
14646
  const event = githubEvent();
13559
14647
  const isPullRequest = eventName === "pull_request" && event.pull_request !== undefined;
13560
14648
  const base = isPullRequest ? event.pull_request.base.sha : undefined;
13561
14649
  const head = isPullRequest ? event.pull_request.head.sha : process.env.GITHUB_SHA ?? "HEAD";
13562
- let fixedFromScan;
13563
- const report = await scanForCi(settings, eventName, base, (count) => {
13564
- fixedFromScan = (fixedFromScan ?? 0) + count;
13565
- });
13566
- const reviewChanges = isPullRequest ? await findNewReviewDiagnostics(settings, eventName, event, report) : { diagnostics: [] };
13567
- const fixed = isPullRequest && base ? fixedFromScan ?? (await Promise.all(resolveCiProjectRoots(path11.resolve(settings.directory), settings.project).map((root) => fixedIssueCount(root, base)))).reduce((sum, count) => sum + count, 0) : 0;
13568
- const skipped = isPullRequest && report.scannedFiles === 0 && fixed === 0;
14650
+ const repo = githubRepo(event);
14651
+ const client = repo ? new GitHubClient(repo, event.pull_request?.number ?? 0, {
14652
+ head,
14653
+ base: base ?? "",
14654
+ runId: process.env.GITHUB_RUN_ID ?? "",
14655
+ attempt: process.env.GITHUB_RUN_ATTEMPT ?? "1"
14656
+ }) : undefined;
14657
+ let snapshot;
14658
+ if (client && isPullRequest && settings.reviewComments) {
14659
+ try {
14660
+ snapshot = await reviewSnapshot(client);
14661
+ } catch (error) {
14662
+ process.stderr.write(`react-luau-doctor: could not update inline review comments: ${errorMessage(error)}
14663
+ `);
14664
+ }
14665
+ }
14666
+ const scan = await scanForCi(settings, isPullRequest, base, head, snapshot);
14667
+ const { report, fixed, analysis } = scan;
14668
+ const baselineCommit = scan.comparison.base ?? base;
14669
+ const skipped = isPullRequest && report.scannedFiles === 0 && fixed.length === 0 && report.diagnostics.length === 0 && !report.partial;
13569
14670
  const effectiveBlocking = isPullRequest ? settings.blocking : "none";
13570
14671
  process.stdout.write(`${renderTextReport(report, true, false, false, process.stdout.columns ?? 120)}
13571
14672
  `);
@@ -13575,39 +14676,88 @@ async function runCiJob(argv) {
13575
14676
  `);
13576
14677
  writeOutput("score", report.score);
13577
14678
  writeOutput("total-issues", report.diagnostics.length);
13578
- writeOutput("fixed-issues", fixed);
14679
+ writeOutput("fixed-issues", fixed.length);
13579
14680
  writeOutput("error-count", report.counts.error);
13580
14681
  writeOutput("warning-count", report.counts.warning);
14682
+ writeOutput("suggestion-count", report.counts.suggestion);
13581
14683
  writeOutput("affected-files", affectedFiles(report));
13582
- const repo = githubRepo(event);
13583
- if (repo && isPullRequest) {
13584
- const pullNumber = event.pull_request.number;
13585
- if (settings.comment) {
14684
+ writeOutput("analysis-complete", report.partial ? "false" : "true");
14685
+ const render = (inlineCount, limit) => renderSummaryComment(report, {
14686
+ baseBranch: event.pull_request?.base.ref ?? "baseline",
14687
+ fixed,
14688
+ sha: head,
14689
+ inlineCount,
14690
+ incomplete: report.partial,
14691
+ skipped,
14692
+ runUrl: githubRunUrl(),
14693
+ limit,
14694
+ includeDiagnosticDetails: limit === Number.POSITIVE_INFINITY,
14695
+ url: (diagnostic, baseline) => githubFileUrl(repo ?? "unknown/repository", baseline ? baselineCommit ?? "HEAD" : head, diagnostic, settings.directory, scan.repoRoot)
14696
+ });
14697
+ const fullReport = render(0, Number.POSITIVE_INFINITY);
14698
+ if (process.env.GITHUB_STEP_SUMMARY) {
14699
+ if (Buffer.byteLength(fullReport) < 900000) {
14700
+ fs9.appendFileSync(process.env.GITHUB_STEP_SUMMARY, fullReport);
14701
+ } else {
14702
+ fs9.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${render(0)}
14703
+ The complete diagnostic report exceeds the workflow summary limit and is available in the report artifact and logs
14704
+ `);
14705
+ }
14706
+ }
14707
+ const reportFile = path11.join(fs9.mkdtempSync(path11.join(process.env.RUNNER_TEMP ?? os2.tmpdir(), "react-luau-doctor-report-")), "report.json");
14708
+ fs9.writeFileSync(reportFile, JSON.stringify({
14709
+ ...report,
14710
+ fixedDiagnostics: fixed,
14711
+ baseBranch: event.pull_request?.base.ref,
14712
+ baseCommit: baselineCommit,
14713
+ baseBranchCommit: base,
14714
+ headCommit: head
14715
+ }, null, 2) + `
14716
+ `);
14717
+ writeOutput("report-path", reportFile);
14718
+ process.stdout.write(`Full diagnostic report
14719
+ ${JSON.stringify({ ...report, fixedDiagnostics: fixed })}
14720
+ `);
14721
+ if (client && isPullRequest) {
14722
+ let inlineCount = snapshot?.comments.filter((comment) => comment.user?.type === "Bot" && comment.body?.startsWith("<!-- react-luau-doctor:review -->")).length ?? 0;
14723
+ let stale = false;
14724
+ const publishSummary = async () => {
14725
+ if (!settings.comment || stale)
14726
+ return;
13586
14727
  try {
13587
- await updateStickyComment(repo, pullNumber, renderSummaryComment(report, repo, head, fixed, skipped, settings.directory), !skipped);
14728
+ await client.sticky(render(inlineCount), !report.partial);
13588
14729
  } catch (error) {
14730
+ stale ||= error instanceof StalePublication;
13589
14731
  process.stderr.write(`react-luau-doctor: could not update the sticky PR comment: ${errorMessage(error)}
13590
14732
  `);
13591
14733
  }
13592
- }
13593
- if (settings.reviewComments && base) {
14734
+ };
14735
+ await publishSummary();
14736
+ if (settings.reviewComments && snapshot && base && !stale) {
13594
14737
  try {
13595
- await manageReviewComments(repo, pullNumber, report, reviewChanges.diagnostics, path11.resolve(settings.directory), reviewChanges.base ?? base, base);
14738
+ const lines = createGitScopePlan(path11.resolve(settings.directory), {
14739
+ scope: "lines",
14740
+ base
14741
+ }).changedLines;
14742
+ const latestSnapshot = await reviewSnapshot(client);
14743
+ inlineCount = await reconcileReviews(client, latestSnapshot, analysis, (diagnostic) => (lines.get(diagnostic.file) ?? []).some((range) => diagnostic.location.line >= range.start && diagnostic.location.line <= range.end));
13596
14744
  } catch (error) {
14745
+ stale ||= error instanceof StalePublication;
13597
14746
  process.stderr.write(`react-luau-doctor: could not update inline review comments: ${errorMessage(error)}
13598
14747
  `);
13599
14748
  }
14749
+ await publishSummary();
13600
14750
  }
13601
14751
  }
13602
- if (repo && settings.commitStatus) {
14752
+ if (client && settings.commitStatus) {
13603
14753
  try {
13604
- await publishCommitStatus(repo, head, report, effectiveBlocking);
14754
+ await publishCommitStatus(client, head, report, effectiveBlocking);
13605
14755
  } catch (error) {
13606
14756
  process.stderr.write(`react-luau-doctor: could not publish the commit status: ${errorMessage(error)}
13607
14757
  `);
13608
14758
  }
13609
14759
  }
13610
- if (shouldBlock(report, effectiveBlocking))
14760
+ if (report.partial || shouldBlock(report, effectiveBlocking))
13611
14761
  process.exitCode = 1;
13612
14762
  }
13613
14763
  async function runCiCommand(argv) {
@@ -15480,7 +16630,7 @@ async function main() {
15480
16630
  process.stdout.write(`React-Luau Doctor ${VERSION2}
15481
16631
  Bun ${Bun.version}
15482
16632
  ${process.platform} ${process.arch}
15483
- ${os2.release()}
16633
+ ${os3.release()}
15484
16634
  `);
15485
16635
  return;
15486
16636
  }
@@ -15554,5 +16704,5 @@ ${renderUpdateNotice(update.current, update.latest, colorized)}
15554
16704
  }
15555
16705
  main();
15556
16706
 
15557
- //# debugId=9D947B614FF21B3A64756E2164756E21
16707
+ //# debugId=72F30B6744C80DF864756E2164756E21
15558
16708
  //# sourceMappingURL=cli.js.map