@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 CHANGED
@@ -11,7 +11,7 @@ var package_default = {
11
11
  publishConfig: {
12
12
  access: "public"
13
13
  },
14
- version: "0.17.2",
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
- async function githubApi(repo, endpoint, options = {}) {
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
- const apiBase = process.env.GITHUB_API_URL ?? "https://api.github.com";
9377
- const response = await fetch(`${apiBase}/repos/${repo}${endpoint}`, {
9378
- method: options.method ?? "GET",
9379
- headers: {
9380
- Accept: "application/vnd.github+json",
9381
- Authorization: `Bearer ${token}`,
9382
- "X-GitHub-Api-Version": "2022-11-28",
9383
- "User-Agent": "react-luau-doctor",
9384
- "Content-Type": "application/json"
9385
- },
9386
- body: options.body === undefined ? undefined : JSON.stringify(options.body)
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 (!response.ok)
9389
- throw new Error(`GitHub API ${response.status}: ${await response.text()}`);
9390
- if (response.status === 204)
9391
- return;
9392
- return await response.json();
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
- async function replaceReviewComments(repo, pullNumber, report, directory, base) {
9481
- const lineMap = changedLineMap(directory, base);
9482
- const comments = report.diagnostics.filter((diagnostic) => touchesChangedLine(diagnostic, lineMap.get(diagnostic.file) ?? [])).slice(0, MAX_REVIEW_COMMENTS).map((diagnostic) => ({
9483
- path: repoRelativeDiagnosticPath(directory, diagnostic.file),
9484
- line: diagnostic.location.line,
9485
- side: "RIGHT",
9486
- body: `${REVIEW_MARKER}
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 previous = await listComments(repo, `/pulls/${pullNumber}/comments`);
9667
+ const failures = [];
9494
9668
  if (comments.length > 0) {
9495
- await githubApi(repo, `/pulls/${pullNumber}/reviews`, {
9496
- method: "POST",
9497
- body: { event: "COMMENT", body: "React-Luau Doctor review", comments }
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
- for (const comment of previous.filter((entry) => isDoctorComment(entry, REVIEW_MARKER))) {
9678
+ if (resolvedThreadIds.length > 0) {
9501
9679
  try {
9502
- await githubApi(repo, `/pulls/comments/${comment.id}`, { method: "DELETE" });
9680
+ await resolveReviewThreads(resolvedThreadIds);
9503
9681
  } catch (error) {
9504
- process.stderr.write(`react-luau-doctor: could not remove an earlier review comment: ${error instanceof Error ? error.message : String(error)}
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: ${error instanceof Error ? error.message : String(error)}
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 replaceReviewComments(repo, pullNumber, report, path11.resolve(settings.directory), base);
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: ${error instanceof Error ? error.message : String(error)}
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: ${error instanceof Error ? error.message : String(error)}
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=5A3899945A5E839764756E2164756E21
11581
+ //# debugId=0C8033468B7931C864756E2164756E21
11402
11582
  //# sourceMappingURL=cli.js.map