@colrealpro/react-luau-doctor 0.20.1 → 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 +11 -0
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/dist/cli.js +1207 -426
- package/dist/cli.js.map +13 -11
- package/dist/config.schema.json +1 -1
- package/dist/scan-worker.js +20 -9
- package/dist/scan-worker.js.map +4 -4
- 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
|
},
|
|
@@ -552,7 +552,13 @@ function isLazyRefInitialization(context, call) {
|
|
|
552
552
|
}
|
|
553
553
|
return false;
|
|
554
554
|
}
|
|
555
|
-
|
|
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) {
|
|
556
562
|
let node = call;
|
|
557
563
|
while (node.parent) {
|
|
558
564
|
const parent = node.parent;
|
|
@@ -560,8 +566,11 @@ function isRefInitializer(context, call) {
|
|
|
560
566
|
return false;
|
|
561
567
|
if (parent.type === "arguments") {
|
|
562
568
|
const hook = parent.parent;
|
|
563
|
-
if (hook?.type === "function_call"
|
|
564
|
-
|
|
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
|
+
}
|
|
565
574
|
}
|
|
566
575
|
node = parent;
|
|
567
576
|
}
|
|
@@ -595,18 +604,18 @@ var noBindingGetValueInRender = {
|
|
|
595
604
|
id: "react-luau/no-binding-getvalue-in-render",
|
|
596
605
|
category: "Correctness",
|
|
597
606
|
severity: "warning",
|
|
598
|
-
description: "Do not read binding snapshots during render except
|
|
607
|
+
description: "Do not read binding snapshots during render except in hook initialization arguments",
|
|
599
608
|
guidance: {
|
|
600
609
|
explanation: "getValue returns a snapshot without subscribing \u2014 later binding updates do not rerender the component or update props built from that snapshot",
|
|
601
610
|
help: "Pass the binding directly to the prop or use binding:map(function(value) return ... end)",
|
|
602
|
-
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"
|
|
603
612
|
},
|
|
604
613
|
run(context) {
|
|
605
614
|
return context.findCalls().flatMap((call) => {
|
|
606
615
|
const owner = context.nearestFunction(call);
|
|
607
616
|
const name = call.childForFieldName("name");
|
|
608
617
|
const receiver = name?.namedChildren[0];
|
|
609
|
-
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))
|
|
610
619
|
return [];
|
|
611
620
|
return [
|
|
612
621
|
{
|
|
@@ -8616,11 +8625,12 @@ function normalizeCategory(value) {
|
|
|
8616
8625
|
return null;
|
|
8617
8626
|
}
|
|
8618
8627
|
|
|
8619
|
-
// src/ci.ts
|
|
8628
|
+
// src/ci/index.ts
|
|
8620
8629
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
8621
|
-
import { createHash } from "crypto";
|
|
8630
|
+
import { createHash as createHash2 } from "crypto";
|
|
8622
8631
|
import fs9 from "fs";
|
|
8623
8632
|
import path11 from "path";
|
|
8633
|
+
import os2 from "os";
|
|
8624
8634
|
import readline from "readline/promises";
|
|
8625
8635
|
import { stdin as input, stdout as output } from "process";
|
|
8626
8636
|
|
|
@@ -8790,18 +8800,20 @@ function mergeBase(repoRoot, left, right = "HEAD") {
|
|
|
8790
8800
|
return result.status === 0 ? result.stdout.trim() : null;
|
|
8791
8801
|
}
|
|
8792
8802
|
function resolveGitBase(repoRoot, requested) {
|
|
8793
|
-
|
|
8794
|
-
|
|
8795
|
-
|
|
8796
|
-
|
|
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);
|
|
8797
8809
|
if (base)
|
|
8798
8810
|
return base;
|
|
8799
|
-
const commit = verifyCommit(repoRoot,
|
|
8811
|
+
const commit = verifyCommit(repoRoot, baseRef);
|
|
8800
8812
|
if (!commit)
|
|
8801
|
-
throw new Error(`Could not resolve git base ref: ${
|
|
8813
|
+
throw new Error(`Could not resolve git base ref: ${baseRef}`);
|
|
8802
8814
|
return commit;
|
|
8803
8815
|
}
|
|
8804
|
-
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);
|
|
8805
8817
|
const candidates = [
|
|
8806
8818
|
environmentBase ? `origin/${environmentBase}` : null,
|
|
8807
8819
|
environmentBase ?? null,
|
|
@@ -8991,6 +9003,7 @@ function createGitScopePlan(scanRootInput, options, relativeRootInput = scanRoot
|
|
|
8991
9003
|
const baselineFiles = [];
|
|
8992
9004
|
const changedLines = new Map;
|
|
8993
9005
|
const changedFiles = [];
|
|
9006
|
+
const baselinePaths = new Map;
|
|
8994
9007
|
const stagedSources = options.staged ? readGitObjects(repoRoot, repoFiles.map((file) => `:${file.currentPath}`)) : new Map;
|
|
8995
9008
|
const baselineSources = options.scope === "changed" ? readGitObjects(repoRoot, repoFiles.filter((file) => file.baselinePath !== null).map((file) => `${base}:${file.baselinePath}`)) : new Map;
|
|
8996
9009
|
for (const repoFile of repoFiles) {
|
|
@@ -9002,10 +9015,16 @@ function createGitScopePlan(scanRootInput, options, relativeRootInput = scanRoot
|
|
|
9002
9015
|
continue;
|
|
9003
9016
|
const source = options.staged ? stagedSources.get(`:${repoRelativePath}`) ?? null : currentSource(repoRoot, repoRelativePath, false);
|
|
9004
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
|
+
}
|
|
9005
9021
|
changedFiles.push(relativePath);
|
|
9006
9022
|
if (source !== null)
|
|
9007
9023
|
currentFiles.push({ absolutePath, relativePath, source, forceScan: false });
|
|
9008
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
|
+
}
|
|
9009
9028
|
if (baseline !== null)
|
|
9010
9029
|
baselineFiles.push({ absolutePath, relativePath, source: baseline, forceScan: false });
|
|
9011
9030
|
if (options.scope === "lines" && source !== null) {
|
|
@@ -9019,7 +9038,8 @@ function createGitScopePlan(scanRootInput, options, relativeRootInput = scanRoot
|
|
|
9019
9038
|
changedFiles: [...new Set(changedFiles)].sort(),
|
|
9020
9039
|
currentFiles,
|
|
9021
9040
|
baselineFiles,
|
|
9022
|
-
changedLines
|
|
9041
|
+
changedLines,
|
|
9042
|
+
baselinePaths
|
|
9023
9043
|
};
|
|
9024
9044
|
}
|
|
9025
9045
|
|
|
@@ -9314,6 +9334,747 @@ function renderAnnotations(report) {
|
|
|
9314
9334
|
`);
|
|
9315
9335
|
}
|
|
9316
9336
|
|
|
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, " ");
|
|
9344
|
+
}
|
|
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}`;
|
|
9350
|
+
}
|
|
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
|
+
|
|
9317
10078
|
// src/scope.ts
|
|
9318
10079
|
import path10 from "path";
|
|
9319
10080
|
|
|
@@ -9620,7 +10381,8 @@ function previousCachedReport(session, key) {
|
|
|
9620
10381
|
return {
|
|
9621
10382
|
scannedFiles: report.scannedFiles,
|
|
9622
10383
|
candidateFiles: report.candidateFiles,
|
|
9623
|
-
diagnostics: report.diagnostics
|
|
10384
|
+
diagnostics: report.diagnostics,
|
|
10385
|
+
unverifiedFiles: report.unverifiedFiles
|
|
9624
10386
|
};
|
|
9625
10387
|
}
|
|
9626
10388
|
function cachedReport(session, key) {
|
|
@@ -9632,7 +10394,8 @@ function cachedReport(session, key) {
|
|
|
9632
10394
|
return {
|
|
9633
10395
|
scannedFiles: report.scannedFiles,
|
|
9634
10396
|
candidateFiles: report.candidateFiles,
|
|
9635
|
-
diagnostics: report.diagnostics
|
|
10397
|
+
diagnostics: report.diagnostics,
|
|
10398
|
+
unverifiedFiles: report.unverifiedFiles
|
|
9636
10399
|
};
|
|
9637
10400
|
}
|
|
9638
10401
|
function cacheHasSameFileSet(session) {
|
|
@@ -10493,6 +11256,7 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
|
|
|
10493
11256
|
project: input.project
|
|
10494
11257
|
};
|
|
10495
11258
|
const context = createRuleContext(file);
|
|
11259
|
+
const syntaxFindings = parsedTree.rootNode.hasError ? parseErrors.run(context) : [];
|
|
10496
11260
|
const categorySet = input.categories && input.categories.length > 0 ? new Set(input.categories) : null;
|
|
10497
11261
|
const isSuppressed = input.respectInlineDisables ? createInlineSuppressionChecker(input.source) : () => false;
|
|
10498
11262
|
const diagnostics = [];
|
|
@@ -10504,7 +11268,7 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
|
|
|
10504
11268
|
const severity = effectiveSeverity(rule.severity, rule.id, input.config);
|
|
10505
11269
|
if (!severity)
|
|
10506
11270
|
continue;
|
|
10507
|
-
const findings = rule.run(context);
|
|
11271
|
+
const findings = rule.id === parseErrors.id ? syntaxFindings : rule.run(context);
|
|
10508
11272
|
for (const finding of findings) {
|
|
10509
11273
|
const configuredSeverity = input.config.rules?.[rule.id];
|
|
10510
11274
|
const diagnostic = toDiagnostic(file, rule.id, rule.category, severity, {
|
|
@@ -10525,7 +11289,8 @@ async function analyzeReactFile(input, tree, selectedRules = rules) {
|
|
|
10525
11289
|
relativePath: input.relativePath,
|
|
10526
11290
|
isReactFile: model.isReactFile,
|
|
10527
11291
|
scanned: true,
|
|
10528
|
-
diagnostics: filteredDiagnostics
|
|
11292
|
+
diagnostics: filteredDiagnostics,
|
|
11293
|
+
unverified: syntaxFindings.length > 0
|
|
10529
11294
|
};
|
|
10530
11295
|
} finally {
|
|
10531
11296
|
if (!tree)
|
|
@@ -12852,7 +13617,8 @@ async function scanPath(target = ".", options = {}) {
|
|
|
12852
13617
|
counts: countDiagnostics(reportHit.diagnostics),
|
|
12853
13618
|
diagnostics: reportHit.diagnostics,
|
|
12854
13619
|
partial: false,
|
|
12855
|
-
skippedFiles: []
|
|
13620
|
+
skippedFiles: [],
|
|
13621
|
+
unverifiedFiles: reportHit.unverifiedFiles
|
|
12856
13622
|
};
|
|
12857
13623
|
}
|
|
12858
13624
|
}
|
|
@@ -12907,6 +13673,7 @@ async function scanPath(target = ".", options = {}) {
|
|
|
12907
13673
|
options.analysisSession.effectModules = cacheSession.effectModules ?? previousEffectModules;
|
|
12908
13674
|
const diagnostics = [];
|
|
12909
13675
|
const skippedFiles = [];
|
|
13676
|
+
const unverifiedFiles = [];
|
|
12910
13677
|
let scannedFiles = 0;
|
|
12911
13678
|
let scanCandidates = candidates;
|
|
12912
13679
|
let incrementalReuse = false;
|
|
@@ -12914,6 +13681,7 @@ async function scanPath(target = ".", options = {}) {
|
|
|
12914
13681
|
if (canCacheWholeReport && previousReport && previousProject && cacheHasSameFileSet(cacheSession) && projectModelFeatureKey(previousProject) === projectModelFeatureKey(project)) {
|
|
12915
13682
|
const affectedFiles = transitiveAffectedFiles(changedFiles, previousEffectModules, currentEffectModules);
|
|
12916
13683
|
diagnostics.push(...previousReport.diagnostics.filter((diagnostic) => !affectedFiles.has(normalizeRelative4(diagnostic.file))));
|
|
13684
|
+
unverifiedFiles.push(...(previousReport.unverifiedFiles ?? []).filter((file) => !affectedFiles.has(normalizeRelative4(file))));
|
|
12917
13685
|
scanCandidates = candidates.filter((candidate) => {
|
|
12918
13686
|
const relativePath = normalizeRelative4(candidate.relativePath ?? path9.relative(root, candidate.absolutePath));
|
|
12919
13687
|
return affectedFiles.has(relativePath);
|
|
@@ -12928,6 +13696,8 @@ async function scanPath(target = ".", options = {}) {
|
|
|
12928
13696
|
if (result.scanned)
|
|
12929
13697
|
scannedFiles += 1;
|
|
12930
13698
|
diagnostics.push(...result.diagnostics);
|
|
13699
|
+
if (result.unverified)
|
|
13700
|
+
unverifiedFiles.push(result.relativePath);
|
|
12931
13701
|
};
|
|
12932
13702
|
const prepareCandidate = (candidate) => {
|
|
12933
13703
|
const relativePath = normalizeRelative4(candidate.relativePath ?? path9.relative(root, candidate.absolutePath));
|
|
@@ -13013,13 +13783,15 @@ async function scanPath(target = ".", options = {}) {
|
|
|
13013
13783
|
counts: countDiagnostics(diagnostics),
|
|
13014
13784
|
diagnostics,
|
|
13015
13785
|
partial: skippedFiles.length > 0,
|
|
13016
|
-
skippedFiles
|
|
13786
|
+
skippedFiles,
|
|
13787
|
+
unverifiedFiles: unverifiedFiles.length ? [...new Set(unverifiedFiles)].sort() : undefined
|
|
13017
13788
|
};
|
|
13018
13789
|
if (canPersistCache)
|
|
13019
13790
|
saveProjectCache(cacheSession, project, cacheSession.effectModules ?? cachedEffectModules(cacheSession), canCacheWholeReport && !report.partial ? reportCacheKey : undefined, canCacheWholeReport && !report.partial ? {
|
|
13020
13791
|
scannedFiles: report.scannedFiles,
|
|
13021
13792
|
candidateFiles: report.candidateFiles ?? candidates.length,
|
|
13022
|
-
diagnostics: report.diagnostics
|
|
13793
|
+
diagnostics: report.diagnostics,
|
|
13794
|
+
unverifiedFiles: report.unverifiedFiles
|
|
13023
13795
|
} : undefined);
|
|
13024
13796
|
return report;
|
|
13025
13797
|
} finally {
|
|
@@ -13101,24 +13873,29 @@ async function scanProjectWithScope(projectRoot, options) {
|
|
|
13101
13873
|
let diagnostics = current.diagnostics;
|
|
13102
13874
|
let partial = current.partial;
|
|
13103
13875
|
let skippedFiles = current.skippedFiles;
|
|
13876
|
+
let unverifiedFiles = current.unverifiedFiles;
|
|
13104
13877
|
if (effectiveScope === "changed") {
|
|
13105
13878
|
const baseline = await scanPath(targetRoot, {
|
|
13106
13879
|
...sharedScanOptions(scanOptions, projectRoot, "baseline"),
|
|
13107
13880
|
files: plan.baselineFiles
|
|
13108
13881
|
});
|
|
13109
|
-
options.onDelta?.(current.diagnostics, baseline.diagnostics);
|
|
13882
|
+
options.onDelta?.(current.diagnostics, baseline.diagnostics, { current, baseline, plan });
|
|
13110
13883
|
const baselineSkipped = new Set(baseline.skippedFiles ?? []);
|
|
13111
13884
|
const baselinePaths = new Set(plan.baselineFiles.map((file) => file.relativePath ?? ""));
|
|
13112
13885
|
const comparableCurrent = current.diagnostics.filter((diagnostic) => !baselinePaths.has(diagnostic.file) || !baselineSkipped.has(diagnostic.file));
|
|
13113
13886
|
diagnostics = introducedDiagnostics(comparableCurrent, baseline.diagnostics);
|
|
13114
13887
|
partial = partial || Boolean(baseline.partial);
|
|
13115
13888
|
skippedFiles = [...new Set([...current.skippedFiles ?? [], ...baseline.skippedFiles ?? []])].sort();
|
|
13889
|
+
unverifiedFiles = [
|
|
13890
|
+
...new Set([...current.unverifiedFiles ?? [], ...baseline.unverifiedFiles ?? []])
|
|
13891
|
+
].sort();
|
|
13116
13892
|
} else if (effectiveScope === "lines") {
|
|
13117
13893
|
diagnostics = current.diagnostics.filter((diagnostic) => touchesChangedLines(diagnostic, plan.changedLines.get(diagnostic.file) ?? []));
|
|
13118
13894
|
}
|
|
13119
13895
|
return rebuildReport(current, diagnostics, {
|
|
13120
13896
|
partial,
|
|
13121
13897
|
skippedFiles,
|
|
13898
|
+
unverifiedFiles,
|
|
13122
13899
|
scope: options.staged ? "staged" : effectiveScope,
|
|
13123
13900
|
base: plan.base,
|
|
13124
13901
|
changedFiles: plan.changedFiles
|
|
@@ -13136,6 +13913,7 @@ function aggregateReports(root, reports) {
|
|
|
13136
13913
|
const startedDuration = reports.reduce((total, entry) => total + entry.report.durationMs, 0);
|
|
13137
13914
|
const diagnostics = [];
|
|
13138
13915
|
const skippedFiles = [];
|
|
13916
|
+
const unverifiedFiles = [];
|
|
13139
13917
|
const changedFiles = [];
|
|
13140
13918
|
const notes = [];
|
|
13141
13919
|
const projects = [];
|
|
@@ -13150,6 +13928,7 @@ function aggregateReports(root, reports) {
|
|
|
13150
13928
|
partial = partial || Boolean(report.partial);
|
|
13151
13929
|
diagnostics.push(...report.diagnostics.map((diagnostic) => prefixDiagnostic(diagnostic, prefix)));
|
|
13152
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));
|
|
13153
13932
|
changedFiles.push(...(report.changedFiles ?? []).map((file) => prefix ? normalize3(path10.posix.join(prefix, file)) : file));
|
|
13154
13933
|
notes.push(...report.notes ?? []);
|
|
13155
13934
|
}
|
|
@@ -13170,6 +13949,7 @@ function aggregateReports(root, reports) {
|
|
|
13170
13949
|
diagnostics,
|
|
13171
13950
|
partial,
|
|
13172
13951
|
skippedFiles: [...new Set(skippedFiles)].sort(),
|
|
13952
|
+
unverifiedFiles: unverifiedFiles.length ? [...new Set(unverifiedFiles)].sort() : undefined,
|
|
13173
13953
|
scope: scopes.size === 1 ? [...scopes][0] : undefined,
|
|
13174
13954
|
base: bases.size === 1 ? [...bases][0] : undefined,
|
|
13175
13955
|
changedFiles: [...new Set(changedFiles)].sort(),
|
|
@@ -13178,7 +13958,7 @@ function aggregateReports(root, reports) {
|
|
|
13178
13958
|
};
|
|
13179
13959
|
}
|
|
13180
13960
|
|
|
13181
|
-
// src/ci.ts
|
|
13961
|
+
// src/ci/index.ts
|
|
13182
13962
|
var DEFAULT_SETTINGS = {
|
|
13183
13963
|
provider: "github",
|
|
13184
13964
|
blocking: "none",
|
|
@@ -13191,11 +13971,6 @@ var DEFAULT_SETTINGS = {
|
|
|
13191
13971
|
};
|
|
13192
13972
|
var GITHUB_WORKFLOW = path11.join(".github", "workflows", "react-luau-doctor.yml");
|
|
13193
13973
|
var GITLAB_WORKFLOW = ".gitlab-ci.yml";
|
|
13194
|
-
var SUMMARY_MARKER = "<!-- react-luau-doctor:summary -->";
|
|
13195
|
-
var REVIEW_MARKER = "<!-- react-luau-doctor:review -->";
|
|
13196
|
-
var MAX_REVIEW_COMMENTS = 20;
|
|
13197
|
-
var MAX_GITHUB_RETRIES = 2;
|
|
13198
|
-
var MAX_RATE_LIMIT_WAIT_MS = 60000;
|
|
13199
13974
|
var PACKAGE_SPEC = `${package_default.name}@${package_default.version}`;
|
|
13200
13975
|
function yamlString(value) {
|
|
13201
13976
|
return JSON.stringify(value);
|
|
@@ -13224,6 +13999,8 @@ jobs:
|
|
|
13224
13999
|
- uses: actions/checkout@v5
|
|
13225
14000
|
with:
|
|
13226
14001
|
fetch-depth: 0
|
|
14002
|
+
ref: \${{ github.event.pull_request.head.sha || github.sha }}
|
|
14003
|
+
persist-credentials: false
|
|
13227
14004
|
- uses: oven-sh/setup-bun@v2
|
|
13228
14005
|
with:
|
|
13229
14006
|
bun-version: "1.4.0"
|
|
@@ -13243,6 +14020,12 @@ jobs:
|
|
|
13243
14020
|
${settings.comment ? "--comment" : "--no-comment"}
|
|
13244
14021
|
${settings.reviewComments ? "--review-comments" : "--no-review-comments"}
|
|
13245
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 }}
|
|
13246
14029
|
`;
|
|
13247
14030
|
}
|
|
13248
14031
|
function renderGitLabWorkflow(settings) {
|
|
@@ -13257,6 +14040,8 @@ react-luau-doctor:
|
|
|
13257
14040
|
DOCTOR_DIRECTORY: ${yamlString(settings.directory)}
|
|
13258
14041
|
DOCTOR_SCOPE: ${yamlString(settings.scope)}
|
|
13259
14042
|
DOCTOR_BLOCKING: ${yamlString(settings.blocking)}
|
|
14043
|
+
before_script:
|
|
14044
|
+
- apt-get update -qq && apt-get install -y --no-install-recommends git
|
|
13260
14045
|
script:
|
|
13261
14046
|
- 'bunx --bun ${PACKAGE_SPEC} "$DOCTOR_DIRECTORY" --scope "$DOCTOR_SCOPE" --blocking "$DOCTOR_BLOCKING" --no-color'
|
|
13262
14047
|
`;
|
|
@@ -13269,8 +14054,9 @@ function parseBoolean(value, name) {
|
|
|
13269
14054
|
throw new Error(`${name} must be true or false`);
|
|
13270
14055
|
}
|
|
13271
14056
|
function parseBlocking(value) {
|
|
13272
|
-
if (value !== "error" && value !== "warning" && value !== "none")
|
|
14057
|
+
if (value !== "error" && value !== "warning" && value !== "none") {
|
|
13273
14058
|
throw new Error("--blocking must be error, warning, or none");
|
|
14059
|
+
}
|
|
13274
14060
|
return value;
|
|
13275
14061
|
}
|
|
13276
14062
|
function parseScope(value) {
|
|
@@ -13280,8 +14066,9 @@ function parseScope(value) {
|
|
|
13280
14066
|
return value;
|
|
13281
14067
|
}
|
|
13282
14068
|
function parseProvider(value) {
|
|
13283
|
-
if (value !== "github" && value !== "gitlab")
|
|
14069
|
+
if (value !== "github" && value !== "gitlab") {
|
|
13284
14070
|
throw new Error("--provider must be github or gitlab");
|
|
14071
|
+
}
|
|
13285
14072
|
return value;
|
|
13286
14073
|
}
|
|
13287
14074
|
function nextValue(argv, index, flag) {
|
|
@@ -13338,7 +14125,9 @@ function parseManagedWorkflowSettings(filename) {
|
|
|
13338
14125
|
return {};
|
|
13339
14126
|
const text = fs9.readFileSync(filename, "utf8");
|
|
13340
14127
|
const match = (name) => text.match(new RegExp(`^\\s*${name}:\\s*(.+?)\\s*$`, "m"))?.[1]?.replace(/^['"]|['"]$/g, "");
|
|
13341
|
-
const result = {
|
|
14128
|
+
const result = {
|
|
14129
|
+
provider: filename.endsWith(".gitlab-ci.yml") ? "gitlab" : "github"
|
|
14130
|
+
};
|
|
13342
14131
|
const blocking = match("DOCTOR_BLOCKING") ?? match("blocking");
|
|
13343
14132
|
const scope = match("DOCTOR_SCOPE") ?? match("scope");
|
|
13344
14133
|
const project = match("DOCTOR_PROJECT") ?? match("project");
|
|
@@ -13429,15 +14218,23 @@ function runCommand(cwd, command, args, allowFailure = false) {
|
|
|
13429
14218
|
}
|
|
13430
14219
|
function openPullRequest(cwd, files, action) {
|
|
13431
14220
|
findGitRoot(cwd);
|
|
13432
|
-
if (!runCommand(cwd, "gh", ["--version"], true))
|
|
14221
|
+
if (!runCommand(cwd, "gh", ["--version"], true)) {
|
|
13433
14222
|
throw new Error("--pr requires the GitHub CLI (gh) to be installed and authenticated");
|
|
14223
|
+
}
|
|
13434
14224
|
const branch = action === "install" ? "chore/add-react-luau-doctor-ci" : "chore/upgrade-react-luau-doctor-ci";
|
|
13435
14225
|
const title = action === "install" ? "Add React-Luau Doctor CI" : "Upgrade React-Luau Doctor CI";
|
|
13436
14226
|
runCommand(cwd, "git", ["switch", "-c", branch]);
|
|
13437
14227
|
runCommand(cwd, "git", ["add", "--", ...files.map((file) => path11.relative(cwd, file))]);
|
|
13438
14228
|
runCommand(cwd, "git", ["commit", "-m", title]);
|
|
13439
14229
|
runCommand(cwd, "git", ["push", "-u", "origin", branch]);
|
|
13440
|
-
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
|
+
]);
|
|
13441
14238
|
process.stdout.write(`${url}
|
|
13442
14239
|
`);
|
|
13443
14240
|
}
|
|
@@ -13453,80 +14250,296 @@ async function runCiManage(action, argv) {
|
|
|
13453
14250
|
return;
|
|
13454
14251
|
}
|
|
13455
14252
|
const files = writeManagedCi(options.cwd, settings);
|
|
13456
|
-
for (const filename of files)
|
|
14253
|
+
for (const filename of files) {
|
|
13457
14254
|
process.stdout.write(`${action === "install" ? "Installed" : action === "config" ? "Updated" : "Upgraded"} ${path11.relative(options.cwd, filename)}
|
|
13458
14255
|
`);
|
|
14256
|
+
}
|
|
13459
14257
|
if (options.pr)
|
|
13460
14258
|
openPullRequest(options.cwd, files, action === "install" ? "install" : "upgrade");
|
|
13461
14259
|
}
|
|
13462
|
-
function diagnosticFingerprint2(diagnostic) {
|
|
13463
|
-
return `${diagnostic.file}\x00${diagnostic.rule}\x00${diagnostic.severity}\x00${diagnostic.message}`;
|
|
13464
|
-
}
|
|
13465
|
-
function countDifference(left, right) {
|
|
13466
|
-
const counts = new Map;
|
|
13467
|
-
for (const diagnostic of right) {
|
|
13468
|
-
const key = diagnosticFingerprint2(diagnostic);
|
|
13469
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
13470
|
-
}
|
|
13471
|
-
let difference = 0;
|
|
13472
|
-
for (const diagnostic of left) {
|
|
13473
|
-
const key = diagnosticFingerprint2(diagnostic);
|
|
13474
|
-
const remaining = counts.get(key) ?? 0;
|
|
13475
|
-
if (remaining > 0)
|
|
13476
|
-
counts.set(key, remaining - 1);
|
|
13477
|
-
else
|
|
13478
|
-
difference += 1;
|
|
13479
|
-
}
|
|
13480
|
-
return difference;
|
|
13481
|
-
}
|
|
13482
|
-
async function fixedIssueCount(directory, base) {
|
|
13483
|
-
const plan = createGitScopePlan(directory, { scope: "changed", base });
|
|
13484
|
-
if (plan.currentFiles.length === 0 && plan.baselineFiles.length === 0)
|
|
13485
|
-
return 0;
|
|
13486
|
-
const config = loadConfigWithSource(directory).config;
|
|
13487
|
-
const selected = (file) => isSelectedLuauPath(file.relativePath ?? "", config);
|
|
13488
|
-
const current = await scanPath(directory, { files: plan.currentFiles.filter(selected), config, categories: config.categories });
|
|
13489
|
-
const baseline = await scanPath(directory, { files: plan.baselineFiles.filter(selected), config, categories: config.categories });
|
|
13490
|
-
return countDifference(baseline.diagnostics, current.diagnostics);
|
|
13491
|
-
}
|
|
13492
14260
|
function resolveCiProjectRoots(directory, project) {
|
|
13493
14261
|
if (!project || project === "*")
|
|
13494
14262
|
return [directory];
|
|
13495
14263
|
return project.split(",").map((entry) => {
|
|
13496
14264
|
const resolved = path11.resolve(directory, entry.trim());
|
|
13497
|
-
if (!fs9.existsSync(resolved) || !fs9.statSync(resolved).isDirectory())
|
|
14265
|
+
if (!fs9.existsSync(resolved) || !fs9.statSync(resolved).isDirectory()) {
|
|
13498
14266
|
throw new Error(`Could not find project: ${entry}`);
|
|
14267
|
+
}
|
|
13499
14268
|
return resolved;
|
|
13500
14269
|
});
|
|
13501
14270
|
}
|
|
13502
|
-
|
|
13503
|
-
const
|
|
13504
|
-
|
|
13505
|
-
|
|
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;
|
|
13506
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
|
+
}
|
|
13507
14336
|
const reports = [];
|
|
13508
|
-
|
|
13509
|
-
|
|
13510
|
-
|
|
13511
|
-
|
|
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",
|
|
13512
14367
|
base,
|
|
14368
|
+
config,
|
|
13513
14369
|
minSeverity: "suggestion",
|
|
13514
14370
|
categories: config.categories,
|
|
13515
14371
|
respectInlineDisables: config.respectInlineDisables ?? true,
|
|
13516
|
-
onDelta:
|
|
13517
|
-
|
|
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 });
|
|
13518
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
|
+
}
|
|
13519
14488
|
}
|
|
13520
|
-
|
|
13521
|
-
|
|
13522
|
-
|
|
13523
|
-
|
|
13524
|
-
const
|
|
13525
|
-
|
|
13526
|
-
|
|
13527
|
-
|
|
13528
|
-
|
|
13529
|
-
|
|
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
|
+
};
|
|
13530
14543
|
}
|
|
13531
14544
|
function shouldBlock(report, level) {
|
|
13532
14545
|
if (level === "none")
|
|
@@ -13560,329 +14573,32 @@ function githubRunUrl() {
|
|
|
13560
14573
|
const runId = process.env.GITHUB_RUN_ID;
|
|
13561
14574
|
return server && repository && runId ? `${server}/${repository}/actions/runs/${runId}` : null;
|
|
13562
14575
|
}
|
|
13563
|
-
function wait(milliseconds) {
|
|
13564
|
-
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
13565
|
-
}
|
|
13566
14576
|
function errorMessage(error) {
|
|
13567
14577
|
return error instanceof Error ? error.message : String(error);
|
|
13568
14578
|
}
|
|
13569
|
-
function
|
|
13570
|
-
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
13571
|
-
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);
|
|
13572
|
-
if (!rateLimited)
|
|
13573
|
-
return null;
|
|
13574
|
-
const retryAfter = response.headers.get("retry-after");
|
|
13575
|
-
if (retryAfter) {
|
|
13576
|
-
const seconds = Number(retryAfter);
|
|
13577
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
13578
|
-
return seconds * 1000;
|
|
13579
|
-
const date = Date.parse(retryAfter);
|
|
13580
|
-
if (Number.isFinite(date))
|
|
13581
|
-
return Math.max(0, date - Date.now());
|
|
13582
|
-
}
|
|
13583
|
-
if (remaining === "0") {
|
|
13584
|
-
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
13585
|
-
if (Number.isFinite(reset))
|
|
13586
|
-
return Math.max(0, reset * 1000 - Date.now() + 1000);
|
|
13587
|
-
}
|
|
13588
|
-
return 60000 * 2 ** attempt;
|
|
13589
|
-
}
|
|
13590
|
-
async function githubRequest(url, options = {}) {
|
|
13591
|
-
const token = process.env.GITHUB_TOKEN;
|
|
13592
|
-
if (!token)
|
|
13593
|
-
throw new Error("GITHUB_TOKEN is unavailable");
|
|
13594
|
-
for (let attempt = 0;; attempt += 1) {
|
|
13595
|
-
const response = await fetch(url, {
|
|
13596
|
-
method: options.method ?? "GET",
|
|
13597
|
-
headers: {
|
|
13598
|
-
Accept: "application/vnd.github+json",
|
|
13599
|
-
Authorization: `Bearer ${token}`,
|
|
13600
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
13601
|
-
"User-Agent": "react-luau-doctor",
|
|
13602
|
-
"Content-Type": "application/json"
|
|
13603
|
-
},
|
|
13604
|
-
body: options.body === undefined ? undefined : JSON.stringify(options.body)
|
|
13605
|
-
});
|
|
13606
|
-
const responseText = response.status === 204 ? "" : await response.text();
|
|
13607
|
-
const delay = githubRetryDelay(response, responseText, attempt);
|
|
13608
|
-
if (response.ok && delay === null)
|
|
13609
|
-
return responseText ? JSON.parse(responseText) : undefined;
|
|
13610
|
-
if (delay === null || attempt >= MAX_GITHUB_RETRIES || delay > MAX_RATE_LIMIT_WAIT_MS) {
|
|
13611
|
-
const retryDetail = delay !== null && delay > MAX_RATE_LIMIT_WAIT_MS ? ` Retry after ${Math.ceil(delay / 1000)} seconds.` : "";
|
|
13612
|
-
throw new Error(`GitHub API ${response.status}: ${responseText}${retryDetail}`);
|
|
13613
|
-
}
|
|
13614
|
-
process.stderr.write(`react-luau-doctor: GitHub API ${response.status}; retrying in ${Math.ceil(delay / 1000)} seconds
|
|
13615
|
-
`);
|
|
13616
|
-
await wait(delay);
|
|
13617
|
-
}
|
|
13618
|
-
}
|
|
13619
|
-
async function githubApi(repo, endpoint, options = {}) {
|
|
13620
|
-
const apiBase = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
|
|
13621
|
-
return githubRequest(`${apiBase}/repos/${repo}${endpoint}`, options);
|
|
13622
|
-
}
|
|
13623
|
-
function repoRelativeDiagnosticPath(directory, diagnosticFile) {
|
|
14579
|
+
function repoRelativeDiagnosticPath(directory, diagnosticFile, repoRoot) {
|
|
13624
14580
|
const absoluteDirectory = path11.resolve(directory);
|
|
13625
|
-
const repoRoot = findGitRoot(absoluteDirectory);
|
|
13626
14581
|
const prefix = path11.relative(repoRoot, absoluteDirectory).split(path11.sep).join("/");
|
|
13627
14582
|
return prefix && prefix !== "." ? path11.posix.join(prefix, diagnosticFile) : diagnosticFile;
|
|
13628
14583
|
}
|
|
13629
|
-
function githubFileUrl(repo, sha, diagnostic, directory) {
|
|
14584
|
+
function githubFileUrl(repo, sha, diagnostic, directory, repoRoot) {
|
|
13630
14585
|
const server = process.env.GITHUB_SERVER_URL ?? "https://github.com";
|
|
13631
|
-
const repoPath = repoRelativeDiagnosticPath(directory, diagnostic.file);
|
|
13632
|
-
const encoded = repoPath.split("/").map(encodeURIComponent).join("/");
|
|
13633
|
-
return `${server}/${repo}/blob/${sha}/${encoded}
|
|
13634
|
-
}
|
|
13635
|
-
function renderSummaryComment(report, repo, sha, fixed, skipped, directory) {
|
|
13636
|
-
if (skipped) {
|
|
13637
|
-
return `${SUMMARY_MARKER}
|
|
13638
|
-
## React-Luau Doctor
|
|
13639
|
-
|
|
13640
|
-
No eligible React-Luau files changed in this pull request, so the scan was skipped.
|
|
13641
|
-
`;
|
|
13642
|
-
}
|
|
13643
|
-
const lines = [
|
|
13644
|
-
SUMMARY_MARKER,
|
|
13645
|
-
"## React-Luau Doctor",
|
|
13646
|
-
"",
|
|
13647
|
-
`**${report.counts.error} errors \xB7 ${report.counts.warning} warnings \xB7 ${report.counts.suggestion} suggestions \xB7 Score ${report.score}/100**`,
|
|
13648
|
-
"",
|
|
13649
|
-
`${report.diagnostics.length} ${report.scope === "changed" ? "introduced finding" : "reported finding"}${report.diagnostics.length === 1 ? "" : "s"} \xB7 ${fixed} fixed`
|
|
13650
|
-
];
|
|
13651
|
-
const errors = report.diagnostics.filter((diagnostic) => diagnostic.severity === "error").slice(0, 20);
|
|
13652
|
-
if (errors.length > 0) {
|
|
13653
|
-
lines.push("", "### Errors");
|
|
13654
|
-
for (const diagnostic of errors) {
|
|
13655
|
-
lines.push(`- [\`${diagnostic.file}:${diagnostic.location.line}\`](${githubFileUrl(repo, sha, diagnostic, directory)}) **${diagnostic.rule}**: ${diagnostic.message}`);
|
|
13656
|
-
}
|
|
13657
|
-
}
|
|
13658
|
-
const warningsByFile = new Map;
|
|
13659
|
-
for (const diagnostic of report.diagnostics.filter((entry) => entry.severity === "warning").slice(0, 50)) {
|
|
13660
|
-
const entries = warningsByFile.get(diagnostic.file) ?? [];
|
|
13661
|
-
entries.push(diagnostic);
|
|
13662
|
-
warningsByFile.set(diagnostic.file, entries);
|
|
13663
|
-
}
|
|
13664
|
-
if (warningsByFile.size > 0) {
|
|
13665
|
-
lines.push("", "### Warnings");
|
|
13666
|
-
for (const [file, diagnostics] of warningsByFile) {
|
|
13667
|
-
lines.push(`- **${file}**`);
|
|
13668
|
-
for (const diagnostic of diagnostics)
|
|
13669
|
-
lines.push(` - [line ${diagnostic.location.line}](${githubFileUrl(repo, sha, diagnostic, directory)}): ${diagnostic.message}`);
|
|
13670
|
-
}
|
|
13671
|
-
}
|
|
13672
|
-
if (report.diagnostics.length > errors.length + [...warningsByFile.values()].reduce((sum, entries) => sum + entries.length, 0)) {
|
|
13673
|
-
lines.push("", "Additional findings are available in the workflow logs.");
|
|
13674
|
-
}
|
|
13675
|
-
const runUrl = githubRunUrl();
|
|
13676
|
-
if (runUrl)
|
|
13677
|
-
lines.push("", `[View full workflow run](${runUrl})`);
|
|
13678
|
-
lines.push("", `<sub>Reviewed commit \`${sha.slice(0, 7)}\`</sub>`);
|
|
13679
|
-
return `${lines.join(`
|
|
13680
|
-
`)}
|
|
13681
|
-
`;
|
|
13682
|
-
}
|
|
13683
|
-
async function listComments(repo, endpoint) {
|
|
13684
|
-
const comments = [];
|
|
13685
|
-
for (let page = 1;; page += 1) {
|
|
13686
|
-
const batch = await githubApi(repo, `${endpoint}?per_page=100&page=${page}`);
|
|
13687
|
-
comments.push(...batch);
|
|
13688
|
-
if (batch.length < 100)
|
|
13689
|
-
return comments;
|
|
13690
|
-
}
|
|
13691
|
-
}
|
|
13692
|
-
function isDoctorComment(comment, marker) {
|
|
13693
|
-
return comment.user?.type === "Bot" && comment.body?.startsWith(marker) === true;
|
|
13694
|
-
}
|
|
13695
|
-
async function updateStickyComment(repo, pullNumber, body, createIfMissing = true) {
|
|
13696
|
-
const comments = await listComments(repo, `/issues/${pullNumber}/comments`);
|
|
13697
|
-
const previous = comments.find((comment) => isDoctorComment(comment, SUMMARY_MARKER));
|
|
13698
|
-
if (previous)
|
|
13699
|
-
await githubApi(repo, `/issues/comments/${previous.id}`, { method: "PATCH", body: { body } });
|
|
13700
|
-
else if (createIfMissing)
|
|
13701
|
-
await githubApi(repo, `/issues/${pullNumber}/comments`, { method: "POST", body: { body } });
|
|
13702
|
-
}
|
|
13703
|
-
function changedLineMap(directory, base) {
|
|
13704
|
-
return createGitScopePlan(directory, { scope: "lines", base }).changedLines;
|
|
13705
|
-
}
|
|
13706
|
-
function touchesChangedLine(diagnostic, ranges) {
|
|
13707
|
-
return ranges.some((range) => diagnostic.location.line >= range.start && diagnostic.location.line <= range.end);
|
|
13708
|
-
}
|
|
13709
|
-
function reviewFingerprint(pathname, rule, severity, message) {
|
|
13710
|
-
return createHash("sha256").update(`${pathname}\x00${rule}\x00${severity}\x00${message}`).digest("hex").slice(0, 32);
|
|
13711
|
-
}
|
|
13712
|
-
function diagnosticReviewFingerprint(directory, diagnostic) {
|
|
13713
|
-
return reviewFingerprint(repoRelativeDiagnosticPath(directory, diagnostic.file), diagnostic.rule, diagnostic.severity, diagnostic.message);
|
|
13714
|
-
}
|
|
13715
|
-
function fingerprintFromReviewBody(body, pathname) {
|
|
13716
|
-
const marker = body.match(/<!-- react-luau-doctor:fingerprint:([a-f0-9]{32}) -->/i);
|
|
13717
|
-
if (marker)
|
|
13718
|
-
return marker[1].toLowerCase();
|
|
13719
|
-
const legacy = body.match(/\*\*React-Luau Doctor\*\* \u00B7 `([^`]+)` \((error|warning|suggestion)\)\n\n([^\n]+)/);
|
|
13720
|
-
return legacy ? reviewFingerprint(pathname, legacy[1], legacy[2], legacy[3]) : null;
|
|
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}`}`;
|
|
13721
14589
|
}
|
|
13722
|
-
function
|
|
13723
|
-
const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
|
|
13724
|
-
return `${REVIEW_MARKER}
|
|
13725
|
-
<!-- react-luau-doctor:fingerprint:${fingerprint} -->
|
|
13726
|
-
**React-Luau Doctor** \xB7 \`${diagnostic.rule}\` (${diagnostic.severity})
|
|
13727
|
-
|
|
13728
|
-
${diagnostic.message}${diagnostic.help ? `
|
|
13729
|
-
|
|
13730
|
-
${diagnostic.help}` : ""}`;
|
|
13731
|
-
}
|
|
13732
|
-
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.";
|
|
13733
|
-
async function listReviewComments(repo, pullNumber) {
|
|
13734
|
-
const comments = [];
|
|
13735
|
-
for (let page = 1;; page += 1) {
|
|
13736
|
-
const batch = await githubApi(repo, `/pulls/${pullNumber}/comments?per_page=100&page=${page}`);
|
|
13737
|
-
comments.push(...batch);
|
|
13738
|
-
if (batch.length < 100)
|
|
13739
|
-
return comments;
|
|
13740
|
-
}
|
|
13741
|
-
}
|
|
13742
|
-
async function listReviews(repo, pullNumber) {
|
|
13743
|
-
const reviews = [];
|
|
13744
|
-
for (let page = 1;; page += 1) {
|
|
13745
|
-
const batch = await githubApi(repo, `/pulls/${pullNumber}/reviews?per_page=100&page=${page}`);
|
|
13746
|
-
reviews.push(...batch);
|
|
13747
|
-
if (batch.length < 100)
|
|
13748
|
-
return reviews;
|
|
13749
|
-
}
|
|
13750
|
-
}
|
|
13751
|
-
function isDoctorReview(review) {
|
|
13752
|
-
return review.user?.type === "Bot" && (review.body?.includes("React-Luau Doctor") ?? false);
|
|
13753
|
-
}
|
|
13754
|
-
function doctorReviewCommentFingerprint(comment) {
|
|
13755
|
-
if (comment.user?.type !== "Bot" || !comment.body.startsWith(REVIEW_MARKER))
|
|
13756
|
-
return null;
|
|
13757
|
-
return fingerprintFromReviewBody(comment.body, comment.path);
|
|
13758
|
-
}
|
|
13759
|
-
function takeCount(counts, key) {
|
|
13760
|
-
const count = counts.get(key) ?? 0;
|
|
13761
|
-
if (count === 0)
|
|
13762
|
-
return false;
|
|
13763
|
-
counts.set(key, count - 1);
|
|
13764
|
-
return true;
|
|
13765
|
-
}
|
|
13766
|
-
function incrementCount(counts, key) {
|
|
13767
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
13768
|
-
}
|
|
13769
|
-
async function manageReviewComments(repo, pullNumber, currentReport, newDiagnostics, directory, newBase, pullBase) {
|
|
13770
|
-
const [allReviewComments, reviews] = await Promise.all([
|
|
13771
|
-
listReviewComments(repo, pullNumber),
|
|
13772
|
-
listReviews(repo, pullNumber)
|
|
13773
|
-
]);
|
|
13774
|
-
const reviewComments = allReviewComments.map((comment) => ({ comment, fingerprint: doctorReviewCommentFingerprint(comment) })).filter((entry) => entry.fingerprint !== null);
|
|
13775
|
-
const activeCounts = new Map;
|
|
13776
|
-
for (const diagnostic of currentReport.diagnostics)
|
|
13777
|
-
incrementCount(activeCounts, diagnosticReviewFingerprint(directory, diagnostic));
|
|
13778
|
-
const newCounts = new Map;
|
|
13779
|
-
for (const diagnostic of newDiagnostics)
|
|
13780
|
-
incrementCount(newCounts, diagnosticReviewFingerprint(directory, diagnostic));
|
|
13781
|
-
const replacementCounts = new Map;
|
|
13782
|
-
const staleComments = [];
|
|
13783
|
-
const orderedComments = reviewComments.slice().sort((left, right) => Number(left.comment.position === null) - Number(right.comment.position === null));
|
|
13784
|
-
for (const { comment, fingerprint } of orderedComments) {
|
|
13785
|
-
if (comment.position !== null && takeCount(activeCounts, fingerprint)) {
|
|
13786
|
-
takeCount(newCounts, fingerprint);
|
|
13787
|
-
continue;
|
|
13788
|
-
}
|
|
13789
|
-
const needsReplacement = takeCount(activeCounts, fingerprint);
|
|
13790
|
-
if (needsReplacement) {
|
|
13791
|
-
incrementCount(replacementCounts, fingerprint);
|
|
13792
|
-
takeCount(newCounts, fingerprint);
|
|
13793
|
-
}
|
|
13794
|
-
staleComments.push({ comment, fingerprint, needsReplacement });
|
|
13795
|
-
}
|
|
13796
|
-
const newLineMap = newCounts.size > 0 || newBase === pullBase && replacementCounts.size > 0 ? changedLineMap(directory, newBase) : new Map;
|
|
13797
|
-
const pullLineMap = newBase === pullBase ? newLineMap : replacementCounts.size > 0 ? changedLineMap(directory, pullBase) : new Map;
|
|
13798
|
-
const candidates = [];
|
|
13799
|
-
for (const diagnostic of newDiagnostics) {
|
|
13800
|
-
if (!touchesChangedLine(diagnostic, newLineMap.get(diagnostic.file) ?? []))
|
|
13801
|
-
continue;
|
|
13802
|
-
const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
|
|
13803
|
-
if (!takeCount(newCounts, fingerprint))
|
|
13804
|
-
continue;
|
|
13805
|
-
candidates.push({ diagnostic, replacement: false });
|
|
13806
|
-
}
|
|
13807
|
-
for (const diagnostic of currentReport.diagnostics) {
|
|
13808
|
-
if (!touchesChangedLine(diagnostic, pullLineMap.get(diagnostic.file) ?? []))
|
|
13809
|
-
continue;
|
|
13810
|
-
const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
|
|
13811
|
-
if (!takeCount(replacementCounts, fingerprint))
|
|
13812
|
-
continue;
|
|
13813
|
-
candidates.push({ diagnostic, replacement: true });
|
|
13814
|
-
}
|
|
13815
|
-
const selected = candidates.slice(0, MAX_REVIEW_COMMENTS);
|
|
13816
|
-
const comments = selected.map(({ diagnostic }) => ({
|
|
13817
|
-
path: repoRelativeDiagnosticPath(directory, diagnostic.file),
|
|
13818
|
-
line: diagnostic.location.line,
|
|
13819
|
-
side: "RIGHT",
|
|
13820
|
-
body: reviewCommentBody(directory, diagnostic)
|
|
13821
|
-
}));
|
|
13822
|
-
const failures = [];
|
|
13823
|
-
let postedNewComments = true;
|
|
13824
|
-
if (comments.length > 0) {
|
|
13825
|
-
try {
|
|
13826
|
-
await githubApi(repo, `/pulls/${pullNumber}/reviews`, {
|
|
13827
|
-
method: "POST",
|
|
13828
|
-
body: { event: "COMMENT", body: "React-Luau Doctor found new issues", comments }
|
|
13829
|
-
});
|
|
13830
|
-
} catch (error) {
|
|
13831
|
-
postedNewComments = false;
|
|
13832
|
-
failures.push(`could not create new review comments: ${errorMessage(error)}`);
|
|
13833
|
-
}
|
|
13834
|
-
}
|
|
13835
|
-
if (postedNewComments) {
|
|
13836
|
-
const postedReplacementCounts = new Map;
|
|
13837
|
-
for (const candidate of selected) {
|
|
13838
|
-
if (candidate.replacement)
|
|
13839
|
-
incrementCount(postedReplacementCounts, diagnosticReviewFingerprint(directory, candidate.diagnostic));
|
|
13840
|
-
}
|
|
13841
|
-
const remainingByReview = new Map;
|
|
13842
|
-
for (const { comment } of reviewComments) {
|
|
13843
|
-
remainingByReview.set(comment.pull_request_review_id, (remainingByReview.get(comment.pull_request_review_id) ?? 0) + 1);
|
|
13844
|
-
}
|
|
13845
|
-
for (const stale of staleComments) {
|
|
13846
|
-
if (stale.needsReplacement && !takeCount(postedReplacementCounts, stale.fingerprint))
|
|
13847
|
-
continue;
|
|
13848
|
-
try {
|
|
13849
|
-
await githubApi(repo, `/pulls/comments/${stale.comment.id}`, { method: "DELETE" });
|
|
13850
|
-
const reviewId = stale.comment.pull_request_review_id;
|
|
13851
|
-
const remaining = Math.max(0, (remainingByReview.get(reviewId) ?? 1) - 1);
|
|
13852
|
-
remainingByReview.set(reviewId, remaining);
|
|
13853
|
-
} catch (error) {
|
|
13854
|
-
failures.push(`could not delete stale review comment ${stale.comment.id}: ${errorMessage(error)}`);
|
|
13855
|
-
}
|
|
13856
|
-
}
|
|
13857
|
-
for (const review of reviews) {
|
|
13858
|
-
if (!isDoctorReview(review) || review.body === ARCHIVED_REVIEW_BODY)
|
|
13859
|
-
continue;
|
|
13860
|
-
if ((remainingByReview.get(review.id) ?? 0) > 0)
|
|
13861
|
-
continue;
|
|
13862
|
-
try {
|
|
13863
|
-
await githubApi(repo, `/pulls/${pullNumber}/reviews/${review.id}`, {
|
|
13864
|
-
method: "PUT",
|
|
13865
|
-
body: { body: ARCHIVED_REVIEW_BODY }
|
|
13866
|
-
});
|
|
13867
|
-
} catch (error) {
|
|
13868
|
-
failures.push(`could not archive empty review ${review.id}: ${errorMessage(error)}`);
|
|
13869
|
-
}
|
|
13870
|
-
}
|
|
13871
|
-
}
|
|
13872
|
-
if (failures.length > 0)
|
|
13873
|
-
throw new Error(failures.join("; "));
|
|
13874
|
-
}
|
|
13875
|
-
async function publishCommitStatus(repo, sha, report, blocking) {
|
|
14590
|
+
async function publishCommitStatus(client, sha, report, blocking) {
|
|
13876
14591
|
const blocked = shouldBlock(report, blocking);
|
|
13877
|
-
|
|
13878
|
-
|
|
13879
|
-
|
|
13880
|
-
|
|
13881
|
-
|
|
13882
|
-
|
|
13883
|
-
|
|
13884
|
-
}
|
|
13885
|
-
|
|
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 });
|
|
13886
14602
|
}
|
|
13887
14603
|
function parseCiRunOptions(argv) {
|
|
13888
14604
|
const settings = { ...DEFAULT_SETTINGS };
|
|
@@ -13924,17 +14640,33 @@ function parseCiRunOptions(argv) {
|
|
|
13924
14640
|
async function runCiJob(argv) {
|
|
13925
14641
|
const settings = parseCiRunOptions(argv);
|
|
13926
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
|
+
}
|
|
13927
14646
|
const event = githubEvent();
|
|
13928
14647
|
const isPullRequest = eventName === "pull_request" && event.pull_request !== undefined;
|
|
13929
14648
|
const base = isPullRequest ? event.pull_request.base.sha : undefined;
|
|
13930
14649
|
const head = isPullRequest ? event.pull_request.head.sha : process.env.GITHUB_SHA ?? "HEAD";
|
|
13931
|
-
|
|
13932
|
-
const
|
|
13933
|
-
|
|
13934
|
-
|
|
13935
|
-
|
|
13936
|
-
|
|
13937
|
-
|
|
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;
|
|
13938
14670
|
const effectiveBlocking = isPullRequest ? settings.blocking : "none";
|
|
13939
14671
|
process.stdout.write(`${renderTextReport(report, true, false, false, process.stdout.columns ?? 120)}
|
|
13940
14672
|
`);
|
|
@@ -13944,39 +14676,88 @@ async function runCiJob(argv) {
|
|
|
13944
14676
|
`);
|
|
13945
14677
|
writeOutput("score", report.score);
|
|
13946
14678
|
writeOutput("total-issues", report.diagnostics.length);
|
|
13947
|
-
writeOutput("fixed-issues", fixed);
|
|
14679
|
+
writeOutput("fixed-issues", fixed.length);
|
|
13948
14680
|
writeOutput("error-count", report.counts.error);
|
|
13949
14681
|
writeOutput("warning-count", report.counts.warning);
|
|
14682
|
+
writeOutput("suggestion-count", report.counts.suggestion);
|
|
13950
14683
|
writeOutput("affected-files", affectedFiles(report));
|
|
13951
|
-
|
|
13952
|
-
|
|
13953
|
-
|
|
13954
|
-
|
|
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;
|
|
13955
14727
|
try {
|
|
13956
|
-
await
|
|
14728
|
+
await client.sticky(render(inlineCount), !report.partial);
|
|
13957
14729
|
} catch (error) {
|
|
14730
|
+
stale ||= error instanceof StalePublication;
|
|
13958
14731
|
process.stderr.write(`react-luau-doctor: could not update the sticky PR comment: ${errorMessage(error)}
|
|
13959
14732
|
`);
|
|
13960
14733
|
}
|
|
13961
|
-
}
|
|
13962
|
-
|
|
14734
|
+
};
|
|
14735
|
+
await publishSummary();
|
|
14736
|
+
if (settings.reviewComments && snapshot && base && !stale) {
|
|
13963
14737
|
try {
|
|
13964
|
-
|
|
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));
|
|
13965
14744
|
} catch (error) {
|
|
14745
|
+
stale ||= error instanceof StalePublication;
|
|
13966
14746
|
process.stderr.write(`react-luau-doctor: could not update inline review comments: ${errorMessage(error)}
|
|
13967
14747
|
`);
|
|
13968
14748
|
}
|
|
14749
|
+
await publishSummary();
|
|
13969
14750
|
}
|
|
13970
14751
|
}
|
|
13971
|
-
if (
|
|
14752
|
+
if (client && settings.commitStatus) {
|
|
13972
14753
|
try {
|
|
13973
|
-
await publishCommitStatus(
|
|
14754
|
+
await publishCommitStatus(client, head, report, effectiveBlocking);
|
|
13974
14755
|
} catch (error) {
|
|
13975
14756
|
process.stderr.write(`react-luau-doctor: could not publish the commit status: ${errorMessage(error)}
|
|
13976
14757
|
`);
|
|
13977
14758
|
}
|
|
13978
14759
|
}
|
|
13979
|
-
if (shouldBlock(report, effectiveBlocking))
|
|
14760
|
+
if (report.partial || shouldBlock(report, effectiveBlocking))
|
|
13980
14761
|
process.exitCode = 1;
|
|
13981
14762
|
}
|
|
13982
14763
|
async function runCiCommand(argv) {
|
|
@@ -15849,7 +16630,7 @@ async function main() {
|
|
|
15849
16630
|
process.stdout.write(`React-Luau Doctor ${VERSION2}
|
|
15850
16631
|
Bun ${Bun.version}
|
|
15851
16632
|
${process.platform} ${process.arch}
|
|
15852
|
-
${
|
|
16633
|
+
${os3.release()}
|
|
15853
16634
|
`);
|
|
15854
16635
|
return;
|
|
15855
16636
|
}
|
|
@@ -15923,5 +16704,5 @@ ${renderUpdateNotice(update.current, update.latest, colorized)}
|
|
|
15923
16704
|
}
|
|
15924
16705
|
main();
|
|
15925
16706
|
|
|
15926
|
-
//# debugId=
|
|
16707
|
+
//# debugId=72F30B6744C80DF864756E2164756E21
|
|
15927
16708
|
//# sourceMappingURL=cli.js.map
|