@colrealpro/react-luau-doctor 0.17.2 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +220 -40
- package/dist/cli.js.map +3 -3
- package/docs/ci.md +4 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ var package_default = {
|
|
|
11
11
|
publishConfig: {
|
|
12
12
|
access: "public"
|
|
13
13
|
},
|
|
14
|
-
version: "0.17.
|
|
14
|
+
version: "0.17.3",
|
|
15
15
|
description: "Static analysis for React-Luau hooks, effects, rendering, and performance.",
|
|
16
16
|
license: "MIT",
|
|
17
17
|
type: "module",
|
|
@@ -164,6 +164,7 @@ function normalizeCategory(value) {
|
|
|
164
164
|
|
|
165
165
|
// src/ci.ts
|
|
166
166
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
167
|
+
import { createHash } from "crypto";
|
|
167
168
|
import fs9 from "fs";
|
|
168
169
|
import path11 from "path";
|
|
169
170
|
import readline from "readline/promises";
|
|
@@ -9014,6 +9015,9 @@ var GITLAB_WORKFLOW = ".gitlab-ci.yml";
|
|
|
9014
9015
|
var SUMMARY_MARKER = "<!-- react-luau-doctor:summary -->";
|
|
9015
9016
|
var REVIEW_MARKER = "<!-- react-luau-doctor:review -->";
|
|
9016
9017
|
var MAX_REVIEW_COMMENTS = 20;
|
|
9018
|
+
var MAX_RESOLVE_THREADS_PER_REQUEST = 100;
|
|
9019
|
+
var MAX_GITHUB_RETRIES = 2;
|
|
9020
|
+
var MAX_RATE_LIMIT_WAIT_MS = 60000;
|
|
9017
9021
|
var PACKAGE_SPEC = `${package_default.name}@${package_default.version}`;
|
|
9018
9022
|
function yamlString(value) {
|
|
9019
9023
|
return JSON.stringify(value);
|
|
@@ -9337,6 +9341,15 @@ async function scanForCi(settings, eventName, base) {
|
|
|
9337
9341
|
}
|
|
9338
9342
|
return roots.length === 1 && roots[0] === directory ? reports[0].report : aggregateReports(directory, reports);
|
|
9339
9343
|
}
|
|
9344
|
+
async function findNewReviewDiagnostics(settings, eventName, event, currentReport) {
|
|
9345
|
+
if (event.action === "synchronize" && event.before) {
|
|
9346
|
+
const report = await scanForCi({ ...settings, scope: "changed" }, eventName, event.before);
|
|
9347
|
+
return { diagnostics: report.diagnostics, base: event.before };
|
|
9348
|
+
}
|
|
9349
|
+
if (event.action && event.action !== "opened")
|
|
9350
|
+
return { diagnostics: [] };
|
|
9351
|
+
return { diagnostics: currentReport.diagnostics };
|
|
9352
|
+
}
|
|
9340
9353
|
function shouldBlock(report, level) {
|
|
9341
9354
|
if (level === "none")
|
|
9342
9355
|
return false;
|
|
@@ -9369,27 +9382,82 @@ function githubRunUrl() {
|
|
|
9369
9382
|
const runId = process.env.GITHUB_RUN_ID;
|
|
9370
9383
|
return server && repository && runId ? `${server}/${repository}/actions/runs/${runId}` : null;
|
|
9371
9384
|
}
|
|
9372
|
-
|
|
9385
|
+
function wait(milliseconds) {
|
|
9386
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
9387
|
+
}
|
|
9388
|
+
function errorMessage(error) {
|
|
9389
|
+
return error instanceof Error ? error.message : String(error);
|
|
9390
|
+
}
|
|
9391
|
+
function githubRetryDelay(response, responseText, attempt) {
|
|
9392
|
+
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
9393
|
+
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);
|
|
9394
|
+
if (!rateLimited)
|
|
9395
|
+
return null;
|
|
9396
|
+
const retryAfter = response.headers.get("retry-after");
|
|
9397
|
+
if (retryAfter) {
|
|
9398
|
+
const seconds = Number(retryAfter);
|
|
9399
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
9400
|
+
return seconds * 1000;
|
|
9401
|
+
const date = Date.parse(retryAfter);
|
|
9402
|
+
if (Number.isFinite(date))
|
|
9403
|
+
return Math.max(0, date - Date.now());
|
|
9404
|
+
}
|
|
9405
|
+
if (remaining === "0") {
|
|
9406
|
+
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
9407
|
+
if (Number.isFinite(reset))
|
|
9408
|
+
return Math.max(0, reset * 1000 - Date.now() + 1000);
|
|
9409
|
+
}
|
|
9410
|
+
return 60000 * 2 ** attempt;
|
|
9411
|
+
}
|
|
9412
|
+
async function githubRequest(url, options = {}) {
|
|
9373
9413
|
const token = process.env.GITHUB_TOKEN;
|
|
9374
9414
|
if (!token)
|
|
9375
9415
|
throw new Error("GITHUB_TOKEN is unavailable");
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9385
|
-
|
|
9386
|
-
|
|
9416
|
+
for (let attempt = 0;; attempt += 1) {
|
|
9417
|
+
const response = await fetch(url, {
|
|
9418
|
+
method: options.method ?? "GET",
|
|
9419
|
+
headers: {
|
|
9420
|
+
Accept: "application/vnd.github+json",
|
|
9421
|
+
Authorization: `Bearer ${token}`,
|
|
9422
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
9423
|
+
"User-Agent": "react-luau-doctor",
|
|
9424
|
+
"Content-Type": "application/json"
|
|
9425
|
+
},
|
|
9426
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body)
|
|
9427
|
+
});
|
|
9428
|
+
const responseText = response.status === 204 ? "" : await response.text();
|
|
9429
|
+
const delay = githubRetryDelay(response, responseText, attempt);
|
|
9430
|
+
if (response.ok && delay === null)
|
|
9431
|
+
return responseText ? JSON.parse(responseText) : undefined;
|
|
9432
|
+
if (delay === null || attempt >= MAX_GITHUB_RETRIES || delay > MAX_RATE_LIMIT_WAIT_MS) {
|
|
9433
|
+
const retryDetail = delay !== null && delay > MAX_RATE_LIMIT_WAIT_MS ? ` Retry after ${Math.ceil(delay / 1000)} seconds.` : "";
|
|
9434
|
+
throw new Error(`GitHub API ${response.status}: ${responseText}${retryDetail}`);
|
|
9435
|
+
}
|
|
9436
|
+
process.stderr.write(`react-luau-doctor: GitHub API ${response.status}; retrying in ${Math.ceil(delay / 1000)} seconds
|
|
9437
|
+
`);
|
|
9438
|
+
await wait(delay);
|
|
9439
|
+
}
|
|
9440
|
+
}
|
|
9441
|
+
async function githubApi(repo, endpoint, options = {}) {
|
|
9442
|
+
const apiBase = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
|
|
9443
|
+
return githubRequest(`${apiBase}/repos/${repo}${endpoint}`, options);
|
|
9444
|
+
}
|
|
9445
|
+
function githubGraphQLUrl() {
|
|
9446
|
+
if (process.env.GITHUB_GRAPHQL_URL)
|
|
9447
|
+
return process.env.GITHUB_GRAPHQL_URL;
|
|
9448
|
+
const apiBase = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
|
|
9449
|
+
return apiBase.endsWith("/api/v3") ? `${apiBase.slice(0, -3)}graphql` : `${apiBase}/graphql`;
|
|
9450
|
+
}
|
|
9451
|
+
async function githubGraphQL(query, variables) {
|
|
9452
|
+
const response = await githubRequest(githubGraphQLUrl(), {
|
|
9453
|
+
method: "POST",
|
|
9454
|
+
body: { query, variables }
|
|
9387
9455
|
});
|
|
9388
|
-
if (
|
|
9389
|
-
throw new Error(`GitHub
|
|
9390
|
-
if (response.
|
|
9391
|
-
|
|
9392
|
-
return
|
|
9456
|
+
if (response.errors?.length)
|
|
9457
|
+
throw new Error(`GitHub GraphQL: ${response.errors.map((error) => error.message ?? "unknown error").join("; ")}`);
|
|
9458
|
+
if (!response.data)
|
|
9459
|
+
throw new Error("GitHub GraphQL returned no data");
|
|
9460
|
+
return response.data;
|
|
9393
9461
|
}
|
|
9394
9462
|
function repoRelativeDiagnosticPath(directory, diagnosticFile) {
|
|
9395
9463
|
const absoluteDirectory = path11.resolve(directory);
|
|
@@ -9477,34 +9545,145 @@ function changedLineMap(directory, base) {
|
|
|
9477
9545
|
function touchesChangedLine(diagnostic, ranges) {
|
|
9478
9546
|
return ranges.some((range) => diagnostic.location.line >= range.start && diagnostic.location.line <= range.end);
|
|
9479
9547
|
}
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
9548
|
+
function reviewFingerprint(pathname, rule, severity, message) {
|
|
9549
|
+
return createHash("sha256").update(`${pathname}\x00${rule}\x00${severity}\x00${message}`).digest("hex").slice(0, 32);
|
|
9550
|
+
}
|
|
9551
|
+
function diagnosticReviewFingerprint(directory, diagnostic) {
|
|
9552
|
+
return reviewFingerprint(repoRelativeDiagnosticPath(directory, diagnostic.file), diagnostic.rule, diagnostic.severity, diagnostic.message);
|
|
9553
|
+
}
|
|
9554
|
+
function fingerprintFromReviewBody(body, pathname) {
|
|
9555
|
+
const marker = body.match(/<!-- react-luau-doctor:fingerprint:([a-f0-9]{32}) -->/i);
|
|
9556
|
+
if (marker)
|
|
9557
|
+
return marker[1].toLowerCase();
|
|
9558
|
+
const legacy = body.match(/\*\*React-Luau Doctor\*\* \u00B7 `([^`]+)` \((error|warning|suggestion)\)\n\n([^\n]+)/);
|
|
9559
|
+
return legacy ? reviewFingerprint(pathname, legacy[1], legacy[2], legacy[3]) : null;
|
|
9560
|
+
}
|
|
9561
|
+
function reviewCommentBody(directory, diagnostic) {
|
|
9562
|
+
const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
|
|
9563
|
+
return `${REVIEW_MARKER}
|
|
9564
|
+
<!-- react-luau-doctor:fingerprint:${fingerprint} -->
|
|
9487
9565
|
**React-Luau Doctor** \xB7 \`${diagnostic.rule}\` (${diagnostic.severity})
|
|
9488
9566
|
|
|
9489
9567
|
${diagnostic.message}${diagnostic.help ? `
|
|
9490
9568
|
|
|
9491
|
-
${diagnostic.help}` : ""}
|
|
9569
|
+
${diagnostic.help}` : ""}`;
|
|
9570
|
+
}
|
|
9571
|
+
async function listReviewThreads(repo, pullNumber) {
|
|
9572
|
+
const separator = repo.indexOf("/");
|
|
9573
|
+
if (separator < 1 || separator === repo.length - 1)
|
|
9574
|
+
throw new Error(`Invalid GitHub repository name: ${repo}`);
|
|
9575
|
+
const owner = repo.slice(0, separator);
|
|
9576
|
+
const name = repo.slice(separator + 1);
|
|
9577
|
+
const threads = [];
|
|
9578
|
+
let cursor = null;
|
|
9579
|
+
for (;; ) {
|
|
9580
|
+
const data = await githubGraphQL(`
|
|
9581
|
+
query DoctorReviewThreads($owner: String!, $name: String!, $pull: Int!, $cursor: String) {
|
|
9582
|
+
repository(owner: $owner, name: $name) {
|
|
9583
|
+
pullRequest(number: $pull) {
|
|
9584
|
+
reviewThreads(first: 100, after: $cursor) {
|
|
9585
|
+
nodes {
|
|
9586
|
+
id
|
|
9587
|
+
isResolved
|
|
9588
|
+
viewerCanResolve
|
|
9589
|
+
path
|
|
9590
|
+
comments(first: 1) {
|
|
9591
|
+
nodes {
|
|
9592
|
+
body
|
|
9593
|
+
author { __typename }
|
|
9594
|
+
}
|
|
9595
|
+
}
|
|
9596
|
+
}
|
|
9597
|
+
pageInfo { hasNextPage endCursor }
|
|
9598
|
+
}
|
|
9599
|
+
}
|
|
9600
|
+
}
|
|
9601
|
+
}
|
|
9602
|
+
`, { owner, name, pull: pullNumber, cursor });
|
|
9603
|
+
const connection = data.repository?.pullRequest?.reviewThreads;
|
|
9604
|
+
if (!connection)
|
|
9605
|
+
throw new Error(`Could not load review threads for pull request ${pullNumber}`);
|
|
9606
|
+
threads.push(...connection.nodes);
|
|
9607
|
+
if (!connection.pageInfo.hasNextPage)
|
|
9608
|
+
return threads;
|
|
9609
|
+
cursor = connection.pageInfo.endCursor;
|
|
9610
|
+
if (!cursor)
|
|
9611
|
+
throw new Error("GitHub review thread pagination returned no cursor");
|
|
9612
|
+
}
|
|
9613
|
+
}
|
|
9614
|
+
function doctorThreadFingerprint(thread) {
|
|
9615
|
+
const comment = thread.comments.nodes[0];
|
|
9616
|
+
if (comment?.author?.__typename !== "Bot" || !comment.body.startsWith(REVIEW_MARKER))
|
|
9617
|
+
return null;
|
|
9618
|
+
return fingerprintFromReviewBody(comment.body, thread.path);
|
|
9619
|
+
}
|
|
9620
|
+
function takeCount(counts, key) {
|
|
9621
|
+
const count = counts.get(key) ?? 0;
|
|
9622
|
+
if (count === 0)
|
|
9623
|
+
return false;
|
|
9624
|
+
counts.set(key, count - 1);
|
|
9625
|
+
return true;
|
|
9626
|
+
}
|
|
9627
|
+
async function resolveReviewThreads(threadIds) {
|
|
9628
|
+
for (let offset = 0;offset < threadIds.length; offset += MAX_RESOLVE_THREADS_PER_REQUEST) {
|
|
9629
|
+
const batch = threadIds.slice(offset, offset + MAX_RESOLVE_THREADS_PER_REQUEST);
|
|
9630
|
+
const declarations = batch.map((_, index) => `$thread${index}: ID!`).join(", ");
|
|
9631
|
+
const mutations = batch.map((_, index) => `thread${index}: resolveReviewThread(input: { threadId: $thread${index} }) { thread { id isResolved } }`).join(`
|
|
9632
|
+
`);
|
|
9633
|
+
const variables = Object.fromEntries(batch.map((id, index) => [`thread${index}`, id]));
|
|
9634
|
+
await githubGraphQL(`mutation ResolveDoctorThreads(${declarations}) { ${mutations} }`, variables);
|
|
9635
|
+
if (offset + batch.length < threadIds.length)
|
|
9636
|
+
await wait(1000);
|
|
9637
|
+
}
|
|
9638
|
+
}
|
|
9639
|
+
async function manageReviewComments(repo, pullNumber, currentReport, newDiagnostics, directory, newBase) {
|
|
9640
|
+
const threads = await listReviewThreads(repo, pullNumber);
|
|
9641
|
+
const activeCounts = new Map;
|
|
9642
|
+
for (const diagnostic of currentReport.diagnostics) {
|
|
9643
|
+
const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
|
|
9644
|
+
activeCounts.set(fingerprint, (activeCounts.get(fingerprint) ?? 0) + 1);
|
|
9645
|
+
}
|
|
9646
|
+
const resolvedThreadIds = [];
|
|
9647
|
+
for (const thread of threads) {
|
|
9648
|
+
if (thread.isResolved)
|
|
9649
|
+
continue;
|
|
9650
|
+
const fingerprint = doctorThreadFingerprint(thread);
|
|
9651
|
+
if (!fingerprint)
|
|
9652
|
+
continue;
|
|
9653
|
+
if (!takeCount(activeCounts, fingerprint) && thread.viewerCanResolve) {
|
|
9654
|
+
resolvedThreadIds.push(thread.id);
|
|
9655
|
+
}
|
|
9656
|
+
}
|
|
9657
|
+
const lineMap = changedLineMap(directory, newBase);
|
|
9658
|
+
const comments = newDiagnostics.filter((diagnostic) => touchesChangedLine(diagnostic, lineMap.get(diagnostic.file) ?? [])).filter((diagnostic) => {
|
|
9659
|
+
const fingerprint = diagnosticReviewFingerprint(directory, diagnostic);
|
|
9660
|
+
return takeCount(activeCounts, fingerprint);
|
|
9661
|
+
}).slice(0, MAX_REVIEW_COMMENTS).map((diagnostic) => ({
|
|
9662
|
+
path: repoRelativeDiagnosticPath(directory, diagnostic.file),
|
|
9663
|
+
line: diagnostic.location.line,
|
|
9664
|
+
side: "RIGHT",
|
|
9665
|
+
body: reviewCommentBody(directory, diagnostic)
|
|
9492
9666
|
}));
|
|
9493
|
-
const
|
|
9667
|
+
const failures = [];
|
|
9494
9668
|
if (comments.length > 0) {
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
|
|
9669
|
+
try {
|
|
9670
|
+
await githubApi(repo, `/pulls/${pullNumber}/reviews`, {
|
|
9671
|
+
method: "POST",
|
|
9672
|
+
body: { event: "COMMENT", body: "React-Luau Doctor found new issues", comments }
|
|
9673
|
+
});
|
|
9674
|
+
} catch (error) {
|
|
9675
|
+
failures.push(`could not create new review comments: ${errorMessage(error)}`);
|
|
9676
|
+
}
|
|
9499
9677
|
}
|
|
9500
|
-
|
|
9678
|
+
if (resolvedThreadIds.length > 0) {
|
|
9501
9679
|
try {
|
|
9502
|
-
await
|
|
9680
|
+
await resolveReviewThreads(resolvedThreadIds);
|
|
9503
9681
|
} catch (error) {
|
|
9504
|
-
|
|
9505
|
-
`);
|
|
9682
|
+
failures.push(`could not resolve fixed review threads: ${errorMessage(error)}`);
|
|
9506
9683
|
}
|
|
9507
9684
|
}
|
|
9685
|
+
if (failures.length > 0)
|
|
9686
|
+
throw new Error(failures.join("; "));
|
|
9508
9687
|
}
|
|
9509
9688
|
async function publishCommitStatus(repo, sha, report, blocking) {
|
|
9510
9689
|
const blocked = shouldBlock(report, blocking);
|
|
@@ -9563,6 +9742,7 @@ async function runCiJob(argv) {
|
|
|
9563
9742
|
const base = isPullRequest ? event.pull_request.base.sha : undefined;
|
|
9564
9743
|
const head = isPullRequest ? event.pull_request.head.sha : process.env.GITHUB_SHA ?? "HEAD";
|
|
9565
9744
|
const report = await scanForCi(settings, eventName, base);
|
|
9745
|
+
const reviewChanges = isPullRequest ? await findNewReviewDiagnostics(settings, eventName, event, report) : { diagnostics: [] };
|
|
9566
9746
|
const fixed = isPullRequest && base ? (await Promise.all(resolveCiProjectRoots(path11.resolve(settings.directory), settings.project).map((root) => fixedIssueCount(root, base)))).reduce((sum, count) => sum + count, 0) : 0;
|
|
9567
9747
|
const skipped = isPullRequest && report.scannedFiles === 0 && fixed === 0;
|
|
9568
9748
|
const effectiveBlocking = isPullRequest ? settings.blocking : "none";
|
|
@@ -9585,15 +9765,15 @@ async function runCiJob(argv) {
|
|
|
9585
9765
|
try {
|
|
9586
9766
|
await updateStickyComment(repo, pullNumber, renderSummaryComment(report, repo, head, fixed, skipped, settings.directory), !skipped);
|
|
9587
9767
|
} catch (error) {
|
|
9588
|
-
process.stderr.write(`react-luau-doctor: could not update the sticky PR comment: ${
|
|
9768
|
+
process.stderr.write(`react-luau-doctor: could not update the sticky PR comment: ${errorMessage(error)}
|
|
9589
9769
|
`);
|
|
9590
9770
|
}
|
|
9591
9771
|
}
|
|
9592
9772
|
if (settings.reviewComments && base) {
|
|
9593
9773
|
try {
|
|
9594
|
-
await
|
|
9774
|
+
await manageReviewComments(repo, pullNumber, report, reviewChanges.diagnostics, path11.resolve(settings.directory), reviewChanges.base ?? base);
|
|
9595
9775
|
} catch (error) {
|
|
9596
|
-
process.stderr.write(`react-luau-doctor: could not update inline review comments: ${
|
|
9776
|
+
process.stderr.write(`react-luau-doctor: could not update inline review comments: ${errorMessage(error)}
|
|
9597
9777
|
`);
|
|
9598
9778
|
}
|
|
9599
9779
|
}
|
|
@@ -9602,7 +9782,7 @@ async function runCiJob(argv) {
|
|
|
9602
9782
|
try {
|
|
9603
9783
|
await publishCommitStatus(repo, head, report, effectiveBlocking);
|
|
9604
9784
|
} catch (error) {
|
|
9605
|
-
process.stderr.write(`react-luau-doctor: could not publish the commit status: ${
|
|
9785
|
+
process.stderr.write(`react-luau-doctor: could not publish the commit status: ${errorMessage(error)}
|
|
9606
9786
|
`);
|
|
9607
9787
|
}
|
|
9608
9788
|
}
|
|
@@ -11398,5 +11578,5 @@ ${os2.release()}
|
|
|
11398
11578
|
}
|
|
11399
11579
|
main();
|
|
11400
11580
|
|
|
11401
|
-
//# debugId=
|
|
11581
|
+
//# debugId=0C8033468B7931C864756E2164756E21
|
|
11402
11582
|
//# sourceMappingURL=cli.js.map
|