@colrealpro/react-luau-doctor 0.20.1 → 0.20.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
@@ -3,7 +3,7 @@
3
3
 
4
4
  // src/cli.ts
5
5
  import fs11 from "fs";
6
- import os2 from "os";
6
+ import os3 from "os";
7
7
  import path13 from "path";
8
8
  // package.json
9
9
  var package_default = {
@@ -11,7 +11,7 @@ var package_default = {
11
11
  publishConfig: {
12
12
  access: "public"
13
13
  },
14
- version: "0.20.1",
14
+ version: "0.20.3",
15
15
  description: "Static analysis for React-Luau hooks, effects, rendering, and performance.",
16
16
  license: "MIT",
17
17
  type: "module",
@@ -34,10 +34,10 @@ var package_default = {
34
34
  check: "bunx tsc -p tsconfig.json --noEmit",
35
35
  "check:lsp": "npm --prefix editors/vscode run check",
36
36
  "test:lsp": "bun run scripts/build-lsp.ts && bun test tests/lsp.test.ts",
37
- test: "bun run build && bun test --parallel=4",
37
+ test: "bun run build && bun test --parallel",
38
38
  "doctor:example": "bun run src/cli.ts examples",
39
39
  prepack: "bun run build && bun run check",
40
- ci: "bun run build && bun run check && bun run check:lsp && bun run scripts/build-lsp.ts && bun test --parallel=4 && bun run scripts/verify-package.ts",
40
+ ci: "bun run build && bun run check && bun run check:lsp && bun run scripts/build-lsp.ts && bun test --parallel && bun run scripts/verify-package.ts",
41
41
  "verify:package": "bun run build && bun run scripts/verify-package.ts"
42
42
  },
43
43
  engines: {
@@ -45,7 +45,6 @@ var package_default = {
45
45
  },
46
46
  packageManager: "bun@1.4.0",
47
47
  dependencies: {
48
- "jsonc-parser": "3.3.1",
49
48
  "web-tree-sitter": "0.26.11"
50
49
  },
51
50
  devDependencies: {
@@ -53,6 +52,7 @@ var package_default = {
53
52
  "@types/bun": "1.4.0",
54
53
  "@vscode/vsce": "^4.0.0",
55
54
  esbuild: "^0.28.2",
55
+ "jsonc-parser": "3.3.1",
56
56
  "vscode-languageserver": "^10.1.2",
57
57
  typescript: "5.8.3"
58
58
  },
@@ -552,7 +552,13 @@ function isLazyRefInitialization(context, call) {
552
552
  }
553
553
  return false;
554
554
  }
555
- function isRefInitializer(context, call) {
555
+ var hookInitializerArguments = new Map([
556
+ ["React.useRef", 0],
557
+ ["React.useState", 0],
558
+ ["React.useBinding", 0],
559
+ ["React.useReducer", 1]
560
+ ]);
561
+ function isHookInitializer(context, call) {
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" && reactApiPath(context, hook) === "React.useRef" && context.callArguments(hook)[0]?.id === node.id)
564
- return true;
569
+ if (hook?.type === "function_call") {
570
+ const argumentIndex = hookInitializerArguments.get(reactApiPath(context, hook) ?? "");
571
+ if (argumentIndex !== undefined && context.callArguments(hook)[argumentIndex]?.id === node.id)
572
+ return true;
573
+ }
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 to initialize a ref",
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: "A snapshot used to initialize React.useRef is allowed because the initial value is intentionally preserved"
611
+ caveat: "Snapshots in the first argument to React.useRef, React.useState, or React.useBinding, or the second argument to React.useReducer, are allowed because those arguments are used only for initialization"
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) || isRefInitializer(context, call))
618
+ if (!owner || !owner.isComponent && !owner.isHook || name?.type !== "method_index_expression" || name.namedChildren.at(-1)?.text !== "getValue" || !receiver || !isBinding(context, receiver) || isHookInitializer(context, call))
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
- if (requested) {
8794
- if (requested.startsWith("-"))
8795
- throw new Error(`Invalid git ref: ${requested}`);
8796
- const base = mergeBase(repoRoot, requested);
8803
+ const gitLab = process.env.GITLAB_CI === "true";
8804
+ const baseRef = requested ?? (gitLab ? process.env.CI_MERGE_REQUEST_DIFF_BASE_SHA?.trim() : undefined);
8805
+ if (baseRef) {
8806
+ if (baseRef.startsWith("-"))
8807
+ throw new Error(`Invalid git ref: ${baseRef}`);
8808
+ const base = mergeBase(repoRoot, baseRef);
8797
8809
  if (base)
8798
8810
  return base;
8799
- const commit = verifyCommit(repoRoot, requested);
8811
+ const commit = verifyCommit(repoRoot, baseRef);
8800
8812
  if (!commit)
8801
- throw new Error(`Could not resolve git base ref: ${requested}`);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
9353
+ }
9354
+ function issueCount(count) {
9355
+ return `${count} ${count === 1 ? "issue" : "issues"}`;
9356
+ }
9357
+ function statusText(kind, text) {
9358
+ let rendered = kind === "fixed" ? `**${escapeMarkdown(text)}**` : escapeMarkdown(text);
9359
+ return rendered;
9360
+ }
9361
+ function renderReviewProgress(total, fixed, unknown, discussions) {
9362
+ const lines = [`**React-Luau Doctor found ${total} new ${total === 1 ? "issue" : "issues"}**`];
9363
+ const status = [];
9364
+ if (unknown > 0) {
9365
+ status.push(`${unknown} ${unknown === 1 ? "issue" : "issues"} awaiting verification`);
9366
+ }
9367
+ if (discussions === undefined && fixed > 0) {
9368
+ status.push("discussion status unavailable");
9369
+ }
9370
+ const discussion = discussions ? `${discussions} ${discussions === 1 ? "discussion" : "discussions"} awaiting resolution` : "";
9371
+ if (fixed > 0) {
9372
+ let fixedText = `${fixed} of these ${issueCount(total)} fixed`;
9373
+ if (fixed === total) {
9374
+ if (total === 1) {
9375
+ fixedText = "This issue fixed in later commits";
9376
+ } else if (total === 2) {
9377
+ fixedText = "Both issues fixed in later commits";
9378
+ } else {
9379
+ fixedText = `All ${total} issues fixed in later commits`;
9380
+ }
9381
+ }
9382
+ const remaining = total - fixed - unknown;
9383
+ if (remaining > 0) {
9384
+ status.unshift(`${remaining} still ${remaining === 1 ? "remains" : "remain"} in the PR`);
9385
+ }
9386
+ lines.push("", [
9387
+ `${SUCCESS_ICON} ${statusText("fixed", fixedText)}`,
9388
+ status.length ? statusText("remaining", `\xB7 ${status.join(" \xB7 ")}`) : "",
9389
+ discussion ? statusText("discussion", `\xB7 ${discussion}`) : ""
9390
+ ].filter(Boolean).join(" "));
9391
+ } else if (status.length || discussion) {
9392
+ lines.push("", [
9393
+ status.length ? statusText("remaining", status.join(" \xB7 ")) : "",
9394
+ discussion ? statusText("discussion", `${status.length ? "\xB7 " : ""}${discussion}`) : ""
9395
+ ].filter(Boolean).join(" "));
9396
+ }
9397
+ return lines.join(`
9398
+ `);
9399
+ }
9400
+ function ordered(diagnostics) {
9401
+ return [...diagnostics].sort((a, b) => a.file.localeCompare(b.file) || a.location.line - b.location.line || a.rule.localeCompare(b.rule) || a.message.localeCompare(b.message));
9402
+ }
9403
+ function diagnosticDetails(diagnostic) {
9404
+ const details = [diagnostic.help, diagnostic.explanation, diagnostic.caveat].filter((value) => Boolean(value) && value !== diagnostic.message);
9405
+ return details.length ? `
9406
+ ${[...new Set(details)].map(escapeMarkdown).join(" \xB7 ")}` : "";
9407
+ }
9408
+ function renderSummaryComment(report, options) {
9409
+ const total = report.diagnostics.length;
9410
+ const files = new Set(report.diagnostics.map((diagnostic) => diagnostic.file)).size;
9411
+ const newLabel = report.scope === "changed" ? "new" : "reported";
9412
+ const severity = [
9413
+ `${report.counts.error} ${report.counts.error === 1 ? "error" : "errors"}`,
9414
+ `${report.counts.warning} ${report.counts.warning === 1 ? "warning" : "warnings"}`,
9415
+ `${report.counts.suggestion} ${report.counts.suggestion === 1 ? "suggestion" : "suggestions"}`
9416
+ ].join(" & ");
9417
+ const info = options.incomplete ? `score unavailable \xB7 fixes unverified \xB7 vs ${inlineCode(options.baseBranch)}` : `score ${report.score}/100 (${scoreLabel(report.score)}) \xB7 ${options.fixed.length} fixed \xB7 vs ${inlineCode(options.baseBranch)}`;
9418
+ let headline;
9419
+ if (options.incomplete) {
9420
+ headline = `**React-Luau Doctor analysis incomplete \xB7 ${issueCount(total)} reported so far**` + `
9421
+
9422
+ Findings may be missing and fixes have not been verified`;
9423
+ } else if (options.skipped || total === 0) {
9424
+ headline = `**React-Luau Doctor found no ${newLabel} issues${newLabel === "new" ? ". \uD83C\uDF89" : ""}**`;
9425
+ if (options.skipped) {
9426
+ headline += `
9427
+
9428
+ No eligible React-Luau files changed in this pull request \xB7 scan skipped`;
9429
+ }
9430
+ } else {
9431
+ headline = `**React-Luau Doctor found ${total} ${newLabel} ${total === 1 ? "issue" : "issues"}` + ` in ${files} ${files === 1 ? "file" : "files"} \xB7 ${severity} \xB7 ${info}**`;
9432
+ }
9433
+ const lines = [SUMMARY_MARKER, headline];
9434
+ if (options.incomplete) {
9435
+ lines.push("", `${severity} \xB7 ${info}`);
9436
+ }
9437
+ const sections = [
9438
+ {
9439
+ title: "Errors",
9440
+ diagnostics: report.diagnostics.filter((d) => d.severity === "error"),
9441
+ icon: "\u274C",
9442
+ grouped: false,
9443
+ baseline: false
9444
+ },
9445
+ {
9446
+ title: `\u26A0\uFE0F ${report.counts.warning} ${report.counts.warning === 1 ? "warning" : "warnings"}`,
9447
+ diagnostics: report.diagnostics.filter((d) => d.severity === "warning"),
9448
+ icon: "\u26A0\uFE0F",
9449
+ grouped: true,
9450
+ baseline: false
9451
+ },
9452
+ {
9453
+ title: `\uD83D\uDCA1 ${report.counts.suggestion} ${report.counts.suggestion === 1 ? "suggestion" : "suggestions"}`,
9454
+ diagnostics: report.diagnostics.filter((d) => d.severity === "suggestion"),
9455
+ icon: "\uD83D\uDCA1",
9456
+ grouped: true,
9457
+ baseline: false
9458
+ },
9459
+ {
9460
+ title: `${options.fixed.length} fixed vs <code>${escapeHtml(options.baseBranch)}</code>`,
9461
+ diagnostics: options.incomplete ? [] : options.fixed,
9462
+ icon: SUCCESS_ICON,
9463
+ grouped: true,
9464
+ baseline: true
9465
+ }
9466
+ ];
9467
+ const inlineNote = total > 0 && options.inlineCount > 0 ? " \xB7 See inline comments for fixes" : "";
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))}${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 = { provider: filename.endsWith(".gitlab-ci.yml") ? "gitlab" : "github" };
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", ["pr", "create", "--title", title, "--body", "Adds React-Luau Doctor pull request analysis."]);
14230
+ const url = runCommand(cwd, "gh", [
14231
+ "pr",
14232
+ "create",
14233
+ "--title",
14234
+ title,
14235
+ "--body",
14236
+ "Adds React-Luau Doctor pull request analysis."
14237
+ ]);
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
- async function scanForCi(settings, eventName, base, onFixed) {
13503
- const directory = path11.resolve(process.cwd(), settings.directory);
13504
- const isPullRequest = eventName === "pull_request";
13505
- const scope = isPullRequest ? settings.scope : "full";
14271
+ function gitResult(directory, args) {
14272
+ const result = spawnSync2(gitExecutable, args, {
14273
+ cwd: directory,
14274
+ encoding: "utf8",
14275
+ windowsHide: true,
14276
+ maxBuffer: 64 * 1024 * 1024
14277
+ });
14278
+ return result.status === 0 ? result.stdout : null;
14279
+ }
14280
+ function renameResolver(directory, head) {
14281
+ const cache = new Map;
14282
+ return (file, commit) => {
14283
+ if (!commit)
14284
+ return file;
14285
+ if (!cache.has(commit)) {
14286
+ const diff = gitResult(directory, [
14287
+ "diff",
14288
+ "--name-status",
14289
+ "-z",
14290
+ "--find-renames",
14291
+ commit,
14292
+ head
14293
+ ]);
14294
+ const renames = new Map;
14295
+ if (diff !== null) {
14296
+ const fields = diff.split("\x00");
14297
+ for (let i = 0;i < fields.length && fields[i]; ) {
14298
+ const status = fields[i++];
14299
+ const oldPath = fields[i++];
14300
+ if (/^[RC]/.test(status)) {
14301
+ const newPath = fields[i++];
14302
+ if (status.startsWith("R"))
14303
+ renames.set(oldPath, newPath);
14304
+ }
14305
+ }
14306
+ }
14307
+ cache.set(commit, diff === null ? null : renames);
14308
+ }
14309
+ const renames = cache.get(commit);
14310
+ return renames ? renames.get(file) ?? file : null;
14311
+ };
14312
+ }
14313
+ async function scanForCi(settings, isPullRequest, base, head, snapshot) {
14314
+ const directory = path11.resolve(settings.directory);
14315
+ const repoRoot = isPullRequest ? findGitRoot(directory) : directory;
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
- for (const projectRoot of roots) {
13509
- const config = loadConfigWithSource(projectRoot).config;
13510
- const report = await scanProjectWithScope(projectRoot, {
13511
- scope,
14337
+ const comparisons = [];
14338
+ const currentDiagnostics = [];
14339
+ const fixed = [];
14340
+ const verifiedFiles = new Set;
14341
+ const deletedFiles = new Set;
14342
+ const uncertainFiles = new Set;
14343
+ const unverifiedFiles = new Set;
14344
+ const verificationSkippedFiles = new Set;
14345
+ const configs = roots.map((root) => ({ root, config: loadConfigWithSource(root).config }));
14346
+ const signature = createHash2("sha256").update(JSON.stringify({
14347
+ version: package_default.version,
14348
+ configs: configs.map(({ root, config }) => ({
14349
+ root: path11.relative(directory, root),
14350
+ config
14351
+ }))
14352
+ })).digest("hex");
14353
+ let complete = true;
14354
+ let potentiallyRelocated = false;
14355
+ const normalize = (file) => file.split(path11.sep).join("/");
14356
+ const relativeFile = (file, root) => normalize(path11.relative(directory, path11.resolve(root, file)));
14357
+ const rebaseDiagnostic = (diagnostic, root) => ({
14358
+ ...diagnostic,
14359
+ file: relativeFile(diagnostic.file, root)
14360
+ });
14361
+ for (const { root: projectRoot, config } of configs) {
14362
+ const sources = new Map;
14363
+ const eligibleFiles = new Set;
14364
+ const scope = isPullRequest ? settings.scope : "full";
14365
+ const comparisonOptions = {
14366
+ scope: "changed",
13512
14367
  base,
14368
+ config,
13513
14369
  minSeverity: "suggestion",
13514
14370
  categories: config.categories,
13515
14371
  respectInlineDisables: config.respectInlineDisables ?? true,
13516
- onDelta: onFixed ? (current, baseline) => onFixed(countDifference(baseline, current)) : undefined
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
- return roots.length === 1 && roots[0] === directory ? reports[0].report : aggregateReports(directory, reports);
13521
- }
13522
- async function findNewReviewDiagnostics(settings, eventName, event, currentReport) {
13523
- if (event.action === "synchronize" && event.before) {
13524
- const report = await scanForCi({ ...settings, scope: "changed" }, eventName, event.before);
13525
- return { diagnostics: report.diagnostics, base: event.before };
13526
- }
13527
- if (event.action && event.action !== "opened")
13528
- return { diagnostics: [] };
13529
- return { diagnostics: currentReport.diagnostics };
14489
+ const report = roots.length === 1 && roots[0] === directory ? reports[0].report : aggregateReports(directory, reports);
14490
+ const comparison = comparisons.length ? aggregateReports(directory, comparisons) : report;
14491
+ const sourceCache = new Map;
14492
+ const toFinding = (diagnostic) => {
14493
+ const file = repoRelativeDiagnosticPath(directory, diagnostic.file, repoRoot);
14494
+ const absolute = path11.resolve(repoRoot, file);
14495
+ if (!sourceCache.has(file)) {
14496
+ sourceCache.set(file, fs9.existsSync(absolute) ? fs9.readFileSync(absolute, "utf8").split(/\r?\n/) : []);
14497
+ }
14498
+ return {
14499
+ file,
14500
+ rule: diagnostic.rule,
14501
+ severity: diagnostic.severity,
14502
+ message: diagnostic.message,
14503
+ line: diagnostic.location.line,
14504
+ column: diagnostic.location.column,
14505
+ diagnosticId: diagnostic.id,
14506
+ anchor: createHash2("sha256").update(sourceCache.get(file)?.[diagnostic.location.line - 1]?.trim() ?? "").digest("hex").slice(0, 16),
14507
+ status: "active"
14508
+ };
14509
+ };
14510
+ return {
14511
+ repoRoot,
14512
+ report: {
14513
+ ...report,
14514
+ partial: Boolean(report.partial) || !complete,
14515
+ unverifiedFiles: unverifiedFiles.size ? [...unverifiedFiles].sort() : undefined,
14516
+ skippedFiles: [
14517
+ ...new Set([...report.skippedFiles ?? [], ...verificationSkippedFiles])
14518
+ ].sort(),
14519
+ notes: potentiallyRelocated ? [
14520
+ ...report.notes ?? [],
14521
+ "Some removed findings may have moved to other files and were not counted as fixed"
14522
+ ] : report.notes
14523
+ },
14524
+ comparison,
14525
+ fixed: complete ? fixed : [],
14526
+ analysis: {
14527
+ signature,
14528
+ complete,
14529
+ current: currentDiagnostics.map(toFinding),
14530
+ introduced: comparison.diagnostics.map((diagnostic) => ({
14531
+ diagnostic,
14532
+ finding: toFinding(diagnostic)
14533
+ })),
14534
+ verifiedFiles,
14535
+ deletedFiles,
14536
+ canonicalFile,
14537
+ eligible: (finding) => !uncertainFiles.has(finding.file) && configs.some(({ root, config }) => {
14538
+ const relative = normalize(path11.relative(root, path11.resolve(repoRoot, finding.file)));
14539
+ return !relative.startsWith("../") && !path11.isAbsolute(relative) && isSelectedLuauPath(relative, config) && config.rules?.[finding.rule] !== "off";
14540
+ })
14541
+ }
14542
+ };
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 githubRetryDelay(response, responseText, attempt) {
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}#L${diagnostic.location.line}`;
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;
13721
- }
13722
- function reviewCommentBody(directory, diagnostic) {
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("; "));
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}`}`;
13874
14589
  }
13875
- async function publishCommitStatus(repo, sha, report, blocking) {
14590
+ async function publishCommitStatus(client, sha, report, blocking) {
13876
14591
  const blocked = shouldBlock(report, blocking);
13877
- await githubApi(repo, `/statuses/${sha}`, {
13878
- method: "POST",
13879
- body: {
13880
- state: blocked ? "failure" : "success",
13881
- context: "React-Luau Doctor",
13882
- description: `Score ${report.score}/100 \xB7 ${report.counts.error} errors \xB7 ${report.counts.warning} warnings`,
13883
- target_url: githubRunUrl() ?? undefined
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
- let fixedFromScan;
13932
- const report = await scanForCi(settings, eventName, base, (count) => {
13933
- fixedFromScan = (fixedFromScan ?? 0) + count;
13934
- });
13935
- const reviewChanges = isPullRequest ? await findNewReviewDiagnostics(settings, eventName, event, report) : { diagnostics: [] };
13936
- const fixed = isPullRequest && base ? fixedFromScan ?? (await Promise.all(resolveCiProjectRoots(path11.resolve(settings.directory), settings.project).map((root) => fixedIssueCount(root, base)))).reduce((sum, count) => sum + count, 0) : 0;
13937
- const skipped = isPullRequest && report.scannedFiles === 0 && fixed === 0;
14650
+ const repo = githubRepo(event);
14651
+ const client = repo ? new GitHubClient(repo, event.pull_request?.number ?? 0, {
14652
+ head,
14653
+ base: base ?? "",
14654
+ runId: process.env.GITHUB_RUN_ID ?? "",
14655
+ attempt: process.env.GITHUB_RUN_ATTEMPT ?? "1"
14656
+ }) : undefined;
14657
+ let snapshot;
14658
+ if (client && isPullRequest && settings.reviewComments) {
14659
+ try {
14660
+ snapshot = await reviewSnapshot(client);
14661
+ } catch (error) {
14662
+ process.stderr.write(`react-luau-doctor: could not update inline review comments: ${errorMessage(error)}
14663
+ `);
14664
+ }
14665
+ }
14666
+ const scan = await scanForCi(settings, isPullRequest, base, head, snapshot);
14667
+ const { report, fixed, analysis } = scan;
14668
+ const baselineCommit = scan.comparison.base ?? base;
14669
+ const skipped = isPullRequest && report.scannedFiles === 0 && fixed.length === 0 && report.diagnostics.length === 0 && !report.partial;
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,105 @@ 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
- const repo = githubRepo(event);
13952
- if (repo && isPullRequest) {
13953
- const pullNumber = event.pull_request.number;
13954
- if (settings.comment) {
14684
+ writeOutput("analysis-complete", report.partial ? "false" : "true");
14685
+ const render = (inlineCount, limit) => renderSummaryComment(report, {
14686
+ baseBranch: event.pull_request?.base.ref ?? "baseline",
14687
+ fixed,
14688
+ sha: head,
14689
+ inlineCount,
14690
+ incomplete: report.partial,
14691
+ skipped,
14692
+ runUrl: githubRunUrl(),
14693
+ limit,
14694
+ includeDiagnosticDetails: limit === Number.POSITIVE_INFINITY,
14695
+ url: (diagnostic, baseline) => githubFileUrl(repo ?? "unknown/repository", baseline ? baselineCommit ?? "HEAD" : head, diagnostic, settings.directory, scan.repoRoot)
14696
+ });
14697
+ const fullReport = render(0, Number.POSITIVE_INFINITY);
14698
+ if (process.env.GITHUB_STEP_SUMMARY) {
14699
+ if (Buffer.byteLength(fullReport) < 900000) {
14700
+ fs9.appendFileSync(process.env.GITHUB_STEP_SUMMARY, fullReport);
14701
+ } else {
14702
+ fs9.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${render(0)}
14703
+ The complete diagnostic report exceeds the workflow summary limit and is available in the report artifact and logs
14704
+ `);
14705
+ }
14706
+ }
14707
+ const reportFile = path11.join(fs9.mkdtempSync(path11.join(process.env.RUNNER_TEMP ?? os2.tmpdir(), "react-luau-doctor-report-")), "report.json");
14708
+ fs9.writeFileSync(reportFile, JSON.stringify({
14709
+ ...report,
14710
+ fixedDiagnostics: fixed,
14711
+ baseBranch: event.pull_request?.base.ref,
14712
+ baseCommit: baselineCommit,
14713
+ baseBranchCommit: base,
14714
+ headCommit: head
14715
+ }, null, 2) + `
14716
+ `);
14717
+ writeOutput("report-path", reportFile);
14718
+ process.stdout.write(`Full diagnostic report
14719
+ ${JSON.stringify({ ...report, fixedDiagnostics: fixed })}
14720
+ `);
14721
+ if (client && isPullRequest) {
14722
+ let inlineCount = snapshot?.comments.filter((comment) => comment.user?.type === "Bot" && comment.body?.startsWith("<!-- react-luau-doctor:review -->")).length ?? 0;
14723
+ let stale = false;
14724
+ const publishSummary = async () => {
14725
+ if (!settings.comment || stale)
14726
+ return;
13955
14727
  try {
13956
- await updateStickyComment(repo, pullNumber, renderSummaryComment(report, repo, head, fixed, skipped, settings.directory), !skipped);
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
- if (settings.reviewComments && base) {
14734
+ };
14735
+ await publishSummary();
14736
+ if (settings.reviewComments && snapshot && base && !stale) {
13963
14737
  try {
13964
- await manageReviewComments(repo, pullNumber, report, reviewChanges.diagnostics, path11.resolve(settings.directory), reviewChanges.base ?? base, base);
14738
+ const reviewBase = event.action === "synchronize" ? event.before : base;
14739
+ if (event.action === "synchronize" && (!reviewBase || !/^[a-f0-9]{40}$/i.test(reviewBase) || gitResult(scan.repoRoot, ["merge-base", "--is-ancestor", reviewBase, head]) === null)) {
14740
+ throw new Error("Previous PR head is unavailable or history was rewritten; new inline comments were skipped");
14741
+ }
14742
+ const lines = createGitScopePlan(path11.resolve(settings.directory), {
14743
+ scope: "lines",
14744
+ base: reviewBase
14745
+ }).changedLines;
14746
+ const canInline = (diagnostic) => (lines.get(diagnostic.file) ?? []).some((range) => diagnostic.location.line >= range.start && diagnostic.location.line <= range.end);
14747
+ let introduced = !event.action || event.action === "opened" ? analysis.introduced : [];
14748
+ if (analysis.complete && event.action === "synchronize" && analysis.introduced.some(({ diagnostic }) => canInline(diagnostic))) {
14749
+ const update = await scanForCi({ ...settings, scope: "changed" }, true, reviewBase, head);
14750
+ if (!update.analysis.complete) {
14751
+ throw new Error("Previous PR head analysis was incomplete; new inline comments were skipped");
14752
+ }
14753
+ const fresh = new Set(update.comparison.diagnostics.map((diagnostic) => diagnostic.file + "\x00" + diagnostic.id));
14754
+ introduced = analysis.introduced.filter(({ diagnostic }) => fresh.has(diagnostic.file + "\x00" + diagnostic.id));
14755
+ }
14756
+ const latestSnapshot = await reviewSnapshot(client);
14757
+ inlineCount = await reconcileReviews(client, latestSnapshot, {
14758
+ ...analysis,
14759
+ introduced
14760
+ }, canInline);
13965
14761
  } catch (error) {
14762
+ stale ||= error instanceof StalePublication;
13966
14763
  process.stderr.write(`react-luau-doctor: could not update inline review comments: ${errorMessage(error)}
13967
14764
  `);
13968
14765
  }
14766
+ await publishSummary();
13969
14767
  }
13970
14768
  }
13971
- if (repo && settings.commitStatus) {
14769
+ if (client && settings.commitStatus) {
13972
14770
  try {
13973
- await publishCommitStatus(repo, head, report, effectiveBlocking);
14771
+ await publishCommitStatus(client, head, report, effectiveBlocking);
13974
14772
  } catch (error) {
13975
14773
  process.stderr.write(`react-luau-doctor: could not publish the commit status: ${errorMessage(error)}
13976
14774
  `);
13977
14775
  }
13978
14776
  }
13979
- if (shouldBlock(report, effectiveBlocking))
14777
+ if (report.partial || shouldBlock(report, effectiveBlocking))
13980
14778
  process.exitCode = 1;
13981
14779
  }
13982
14780
  async function runCiCommand(argv) {
@@ -15849,7 +16647,7 @@ async function main() {
15849
16647
  process.stdout.write(`React-Luau Doctor ${VERSION2}
15850
16648
  Bun ${Bun.version}
15851
16649
  ${process.platform} ${process.arch}
15852
- ${os2.release()}
16650
+ ${os3.release()}
15853
16651
  `);
15854
16652
  return;
15855
16653
  }
@@ -15923,5 +16721,5 @@ ${renderUpdateNotice(update.current, update.latest, colorized)}
15923
16721
  }
15924
16722
  main();
15925
16723
 
15926
- //# debugId=3EFE794093E582F464756E2164756E21
16724
+ //# debugId=1B34073C2F3EACBB64756E2164756E21
15927
16725
  //# sourceMappingURL=cli.js.map