@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/CHANGELOG.md +18 -0
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/dist/cli.js +1742 -592
- package/dist/cli.js.map +20 -16
- package/dist/config.schema.json +4 -4
- package/dist/scan-worker.js +529 -149
- package/dist/scan-worker.js.map +12 -10
- package/package.json +66 -66
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
5
|
import fs11 from "fs";
|
|
6
|
-
import
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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"
|
|
558
|
-
|
|
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
|
|
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: "
|
|
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) ||
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
4795
|
-
const
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
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
|
-
|
|
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
|
|
5034
|
+
description: "Warn when props created during render can defeat shallow React.memo comparisons",
|
|
4820
5035
|
guidance: {
|
|
4821
|
-
explanation: "
|
|
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 (
|
|
5049
|
+
if (!args[0] || memoizedComponentKind(context, args[0]) !== "shallow")
|
|
4838
5050
|
continue;
|
|
4839
|
-
const
|
|
4840
|
-
|
|
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) =>
|
|
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}
|
|
4852
|
-
summary: `${childName} gets ${unstable.length}
|
|
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
|
|
5068
|
+
summary: `${childName} gets a ${unstable[index]} prop created during render that can prevent memoization from skipping it`
|
|
4856
5069
|
})),
|
|
4857
|
-
help: "Fresh
|
|
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
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
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 (
|
|
7049
|
-
|
|
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
|
-
|
|
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
|
|
7257
|
-
|
|
7258
|
-
|
|
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
|
|
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
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
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
|
-
|
|
7271
|
-
|
|
7272
|
-
const root = simpleConditionRoot(controlFlow);
|
|
7273
|
-
if (!root)
|
|
7552
|
+
const conditions = guardConditions(controlFlow, node);
|
|
7553
|
+
if (conditions.length === 0)
|
|
7274
7554
|
return false;
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
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) =>
|
|
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
|
-
|
|
7591
|
+
const isStatement = controlFlow.type === "if_statement";
|
|
7592
|
+
if (!isStatement && controlFlow.type !== "if_expression")
|
|
7301
7593
|
return false;
|
|
7302
|
-
const
|
|
7303
|
-
const
|
|
7304
|
-
const
|
|
7305
|
-
if (!
|
|
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
|
|
7308
|
-
const
|
|
7309
|
-
|
|
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 (!
|
|
7619
|
+
if (!sequences.every((sequence) => sequence && sequence.length === first.length && sequence.every((path, index) => path === first[index])))
|
|
7312
7620
|
return false;
|
|
7313
|
-
return
|
|
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) =>
|
|
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 (
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
7679
|
-
summary: loopLike ? `${path} may run a different number of times between renders
|
|
7680
|
-
explanation: loopLike ? `This ${kind} may change how many times ${path} is called
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
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,
|
|
8811
|
+
const commit = verifyCommit(repoRoot, baseRef);
|
|
8431
8812
|
if (!commit)
|
|
8432
|
-
throw new Error(`Could not resolve git base ref: ${
|
|
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/
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/[\\`*_{}\[\]()!|#$~]/g, "\\$&").replace(/\r?\n/g, " ");
|
|
8964
9344
|
}
|
|
8965
|
-
function
|
|
8966
|
-
|
|
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
|
-
|
|
8969
|
-
|
|
8970
|
-
|
|
8971
|
-
|
|
8972
|
-
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
9351
|
+
function escapeHtml(value) {
|
|
9352
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 = {
|
|
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", [
|
|
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
|
-
|
|
13134
|
-
const
|
|
13135
|
-
|
|
13136
|
-
|
|
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
|
-
|
|
13140
|
-
|
|
13141
|
-
|
|
13142
|
-
|
|
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:
|
|
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
|
-
|
|
13152
|
-
|
|
13153
|
-
|
|
13154
|
-
|
|
13155
|
-
const
|
|
13156
|
-
|
|
13157
|
-
|
|
13158
|
-
|
|
13159
|
-
|
|
13160
|
-
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
|
|
13509
|
-
|
|
13510
|
-
|
|
13511
|
-
|
|
13512
|
-
|
|
13513
|
-
|
|
13514
|
-
|
|
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
|
-
|
|
13563
|
-
const
|
|
13564
|
-
|
|
13565
|
-
|
|
13566
|
-
|
|
13567
|
-
|
|
13568
|
-
|
|
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
|
-
|
|
13583
|
-
|
|
13584
|
-
|
|
13585
|
-
|
|
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
|
|
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
|
-
|
|
14734
|
+
};
|
|
14735
|
+
await publishSummary();
|
|
14736
|
+
if (settings.reviewComments && snapshot && base && !stale) {
|
|
13594
14737
|
try {
|
|
13595
|
-
|
|
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 (
|
|
14752
|
+
if (client && settings.commitStatus) {
|
|
13603
14753
|
try {
|
|
13604
|
-
await publishCommitStatus(
|
|
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
|
-
${
|
|
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=
|
|
16707
|
+
//# debugId=72F30B6744C80DF864756E2164756E21
|
|
15558
16708
|
//# sourceMappingURL=cli.js.map
|