react-doctor 0.8.1-dev.7818922 → 0.8.1-dev.7c9dbed

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
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="9e32582f-b296-5216-a028-7cc19ed83931")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f6c63ef7-3eca-54f6-80d8-221d85cea09b")}catch(e){}}();
3
3
  import { createRequire } from "node:module";
4
4
  import * as NodeChildProcess from "node:child_process";
5
5
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
@@ -37977,7 +37977,6 @@ const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
37977
37977
  "react/jsx-no-new-function-as-prop": "react-doctor/jsx-no-new-function-as-prop",
37978
37978
  "react/jsx-no-new-object-as-prop": "react-doctor/jsx-no-new-object-as-prop",
37979
37979
  "react/jsx-no-script-url": "react-doctor/jsx-no-script-url",
37980
- "react/jsx-no-target-blank": "react-doctor/jsx-no-target-blank",
37981
37980
  "react/jsx-no-undef": "react-doctor/jsx-no-undef",
37982
37981
  "react/jsx-no-useless-fragment": "react-doctor/jsx-no-useless-fragment",
37983
37982
  "react/jsx-pascal-case": "react-doctor/jsx-pascal-case",
@@ -42334,6 +42333,35 @@ const parseGithubViewerPermission = (stdout) => {
42334
42333
  return /^[A-Z_]+$/.test(value) ? value.toLowerCase() : null;
42335
42334
  };
42336
42335
  const splitNullSeparated = (value) => value.split("\0").filter((entry) => entry.length > 0);
42336
+ const parseBaselineDiffPlan = (value) => {
42337
+ const entries = splitNullSeparated(value);
42338
+ const baseFiles = /* @__PURE__ */ new Set();
42339
+ const headFiles = /* @__PURE__ */ new Set();
42340
+ for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 2) {
42341
+ const status = entries[entryIndex];
42342
+ const filePath = entries[entryIndex + 1];
42343
+ if (status === void 0 || filePath === void 0 || status.length !== 1) return null;
42344
+ if (status === "A") {
42345
+ headFiles.add(filePath);
42346
+ continue;
42347
+ }
42348
+ if (status === "D") {
42349
+ baseFiles.add(filePath);
42350
+ continue;
42351
+ }
42352
+ if (status === "M" || status === "T") {
42353
+ baseFiles.add(filePath);
42354
+ headFiles.add(filePath);
42355
+ continue;
42356
+ }
42357
+ return null;
42358
+ }
42359
+ return {
42360
+ baseFiles: [...baseFiles],
42361
+ headFiles: [...headFiles],
42362
+ untrackedFiles: []
42363
+ };
42364
+ };
42337
42365
  const UNTRACKED_FILE_LAST_LINE = Number.MAX_SAFE_INTEGER;
42338
42366
  /**
42339
42367
  * `Git` wraps every `git`-via-subprocess call react-doctor makes
@@ -42536,6 +42564,45 @@ var Git = class Git extends Service()("react-doctor/Git") {
42536
42564
  githubViewerPermission,
42537
42565
  branchExists,
42538
42566
  mergeBase,
42567
+ baselineDiffPlan: (input) => {
42568
+ if (!isSafeGitRevision(input.ref)) return succeed$2(null);
42569
+ return gen(function* () {
42570
+ const unmerged = yield* runGit(input.directory, [
42571
+ "diff",
42572
+ "--no-ext-diff",
42573
+ "-z",
42574
+ "--name-only",
42575
+ "--diff-filter=U",
42576
+ "--relative"
42577
+ ]);
42578
+ if (unmerged.status !== 0 || unmerged.stdout.length > 0) return null;
42579
+ const result = yield* runGit(input.directory, [
42580
+ "diff",
42581
+ "--no-ext-diff",
42582
+ "--no-textconv",
42583
+ "--no-renames",
42584
+ "-z",
42585
+ "--name-status",
42586
+ "--relative",
42587
+ input.ref
42588
+ ]);
42589
+ if (result.status !== 0) return null;
42590
+ const plan = parseBaselineDiffPlan(result.stdout);
42591
+ if (plan === null) return null;
42592
+ const untracked = yield* runGit(input.directory, [
42593
+ "ls-files",
42594
+ "--others",
42595
+ "--exclude-standard",
42596
+ "-z"
42597
+ ]);
42598
+ if (untracked.status !== 0) return null;
42599
+ return {
42600
+ baseFiles: plan.baseFiles,
42601
+ headFiles: plan.headFiles,
42602
+ untrackedFiles: splitNullSeparated(untracked.stdout)
42603
+ };
42604
+ }).pipe(catch_$1(() => succeed$2(null)), withSpan("Git.baselineDiffPlan"));
42605
+ },
42539
42606
  diffSelection: ({ directory, explicitBaseBranch, includeUntracked = false }) => gen(function* () {
42540
42607
  if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) return yield* fail$4(new ReactDoctorError({ reason: new GitBaseBranchInvalid({ detail: "Diff base branch cannot be empty." }) }));
42541
42608
  if (explicitBaseBranch !== void 0) {
@@ -42685,6 +42752,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
42685
42752
  githubViewerPermission: () => succeed$2(snapshot.githubViewerPermission ?? null),
42686
42753
  branchExists: (_directory, branch) => succeed$2(snapshot.branchExists?.get(branch) ?? false),
42687
42754
  mergeBase: ({ ref }) => succeed$2(snapshot.mergeBase?.get(ref) ?? null),
42755
+ baselineDiffPlan: () => succeed$2(snapshot.baselineDiffPlan ?? null),
42688
42756
  diffSelection: () => succeed$2(snapshot.diffSelection ?? null),
42689
42757
  stagedFilePaths: () => succeed$2(snapshot.stagedFiles ?? []),
42690
42758
  showStagedContent: (_directory, relativePath) => succeed$2(snapshot.stagedContent?.get(relativePath) ?? null),
@@ -44022,12 +44090,17 @@ const REACT_ERROR_BOUNDARY_MESSAGE = "This try/catch cannot catch errors thrown
44022
44090
  const REACT_COMPILER_IMPACT = "This component misses React Compiler's automatic memoization & re-renders more than it should";
44023
44091
  const REACT_COMPILER_ACTION = "Rewrite the flagged code so the compiler can optimize it.";
44024
44092
  const REACT_COMPILER_INCOMPATIBLE_LIBRARY_ACTION = "It's how the library works, not a bug in your code. Memoize values you pass from it into other memoized components, or suppress it with `// react-doctor-disable-next-line react-hooks-js/incompatible-library`.";
44093
+ const SET_STATE_IN_EFFECT_ACTION = "Prefer deriving or initializing the value before render. If the effect must read a browser API after mount, treat this as advisory or suppress it with `// react-doctor-disable-next-line react-hooks-js/set-state-in-effect`.";
44025
44094
  const REACT_COMPILER_GENERIC_MESSAGE = `${REACT_COMPILER_IMPACT}. ${REACT_COMPILER_ACTION}`;
44026
44095
  const buildReactCompilerMessage = (reasonSummary, action = REACT_COMPILER_ACTION) => {
44027
44096
  const normalizedSummary = reasonSummary.replace(TRAILING_PERIOD_PATTERN, "");
44028
44097
  if (!normalizedSummary) return `${REACT_COMPILER_IMPACT}. ${action}`;
44029
44098
  return `${REACT_COMPILER_IMPACT}: ${normalizedSummary}. ${action}`;
44030
44099
  };
44100
+ const buildSetStateInEffectMessage = (reasonSummary) => {
44101
+ const normalizedSummary = reasonSummary.replace(TRAILING_PERIOD_PATTERN, "");
44102
+ return `This synchronous effect update causes an extra render${normalizedSummary ? `: ${normalizedSummary}` : ""}. ${SET_STATE_IN_EFFECT_ACTION}`;
44103
+ };
44031
44104
  const PLUGIN_CATEGORY_MAP = {
44032
44105
  react: "Bugs",
44033
44106
  "react-hooks": "Bugs",
@@ -44085,6 +44158,10 @@ const resolveCleanedDiagnostic = (message, help, plugin, rule, project) => {
44085
44158
  message: REACT_ERROR_BOUNDARY_MESSAGE,
44086
44159
  help: reasonDetail || help
44087
44160
  };
44161
+ if (rule === "set-state-in-effect") return {
44162
+ message: buildSetStateInEffectMessage(reasonSummary.trim()),
44163
+ help: reasonDetail || help
44164
+ };
44088
44165
  return {
44089
44166
  message: buildReactCompilerMessage(reasonSummary.trim(), rule === "incompatible-library" ? REACT_COMPILER_INCOMPATIBLE_LIBRARY_ACTION : REACT_COMPILER_ACTION),
44090
44167
  help: appendReanimatedSharedValueHint(reasonDetail || help, rule, project)
@@ -46421,12 +46498,19 @@ const isPathInsideDirectory = (childAbsolutePath, parentAbsolutePath) => {
46421
46498
  */
46422
46499
  const materializeSourceTree = (input) => gen(function* () {
46423
46500
  const materializedFiles = [];
46501
+ const unmaterializedFiles = [];
46424
46502
  const resolvedTempDirectory = path.resolve(input.tempDirectory);
46425
46503
  for (const relativePath of input.files) {
46426
46504
  const content = yield* input.readContent(relativePath).pipe(orElseSucceed(() => null));
46427
- if (content === null) continue;
46505
+ if (content === null) {
46506
+ unmaterializedFiles.push(relativePath);
46507
+ continue;
46508
+ }
46428
46509
  const candidateTargetPath = path.resolve(resolvedTempDirectory, relativePath);
46429
- if (!isPathInsideDirectory(candidateTargetPath, resolvedTempDirectory)) continue;
46510
+ if (!isPathInsideDirectory(candidateTargetPath, resolvedTempDirectory)) {
46511
+ unmaterializedFiles.push(relativePath);
46512
+ continue;
46513
+ }
46430
46514
  yield* sync(() => {
46431
46515
  fs.mkdirSync(path.dirname(candidateTargetPath), { recursive: true });
46432
46516
  fs.writeFileSync(candidateTargetPath, content);
@@ -46443,6 +46527,7 @@ const materializeSourceTree = (input) => gen(function* () {
46443
46527
  return {
46444
46528
  tempDirectory: input.tempDirectory,
46445
46529
  materializedFiles,
46530
+ unmaterializedFiles,
46446
46531
  cleanup: () => {
46447
46532
  try {
46448
46533
  fs.rmSync(input.tempDirectory, {
@@ -46712,54 +46797,80 @@ const buildSkippedChecks = (input) => {
46712
46797
  skippedCheckReasons
46713
46798
  };
46714
46799
  };
46715
- const fingerprintDiagnostic = (diagnostic, lineText) => {
46800
+ const fingerprintText = (text) => createHash("sha256").update(text).digest("hex");
46801
+ const normalizeEvidence = (evidence) => evidence.replace(/\s+/g, " ").trim();
46802
+ const getDiagnosticMatchKeys = (diagnostic, evidence) => {
46716
46803
  const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
46717
- const snippet = lineText === null || diagnostic.matchByOccurrence ? "" : createHash("sha1").update(lineText.trim()).digest("hex");
46718
- return `${diagnostic.filePath}\u0000${ruleKey}\u0000${snippet}`;
46719
- };
46720
- /**
46721
- * Diffs a head scan against a base scan to isolate the diagnostics a change
46722
- * introduced (and count the ones it resolved). Matching is a multiset over a
46723
- * position-independent fingerprint — `(filePath, plugin/rule, hash(flagged
46724
- * line text))` — so inserting lines above an existing issue doesn't make it
46725
- * look new, while a genuinely new occurrence (new line text, or one more of
46726
- * the same) surfaces. Identical repeated findings are matched by count.
46727
- *
46728
- * Diagnostics carrying `matchByOccurrence` (resolved at diagnostic creation:
46729
- * every Accessibility-category finding, plus rules opting in via their
46730
- * `matchByOccurrence` metadata flag) drop the line-text snippet and match by
46731
- * `(filePath, plugin/rule)` occurrence count alone. Their identity is the
46732
- * flagged element, not the line's text, so editing the line (reindentation,
46733
- * prettier reflow, collapsing a multi-line JSX element) doesn't reclassify a
46734
- * pre-existing finding as new — while one MORE occurrence of the same rule in
46735
- * the file still surfaces. Expression-level rules keep the line-text snippet:
46736
- * there the flagged expression IS the finding, so changed text means new +
46737
- * fixed.
46738
- *
46739
- * v1 limitation: the fingerprint keys on the head-relative `filePath`, and base
46740
- * content is read at that same path. A file renamed by the change therefore has
46741
- * no base match, so its pre-existing findings are reported as new. This
46742
- * over-reports (never hides a real issue) and is rare; rename-aware base
46743
- * resolution is a follow-up.
46804
+ const messageFingerprint = fingerprintText(`${diagnostic.title ?? ""}\0${diagnostic.message}`);
46805
+ const normalizedEvidence = evidence === null ? "" : normalizeEvidence(evidence);
46806
+ const stableEvidenceKey = normalizedEvidence.length > 0 ? `evidence\0${ruleKey}\0${messageFingerprint}\0${fingerprintText(normalizedEvidence)}` : null;
46807
+ return {
46808
+ stableEvidenceKey,
46809
+ sameFileStableEvidenceKey: stableEvidenceKey === null ? null : `${diagnostic.filePath}\0${stableEvidenceKey}`,
46810
+ sameFileFallbackKey: diagnostic.matchByOccurrence || normalizedEvidence.length === 0 ? `fallback\0${diagnostic.filePath}\0${ruleKey}\0${messageFingerprint}` : null
46811
+ };
46812
+ };
46813
+ const addDiagnosticIndex = (buckets, key, diagnosticIndex) => {
46814
+ if (key === null) return;
46815
+ const diagnosticIndexes = buckets.get(key) ?? [];
46816
+ diagnosticIndexes.push(diagnosticIndex);
46817
+ buckets.set(key, diagnosticIndexes);
46818
+ };
46819
+ const takeMatchingDiagnosticIndex = (buckets, key, matchedDiagnosticIndexes) => {
46820
+ if (key === null) return null;
46821
+ for (const diagnosticIndex of buckets.get(key) ?? []) if (!matchedDiagnosticIndexes.has(diagnosticIndex)) return diagnosticIndex;
46822
+ return null;
46823
+ };
46824
+ const readDiagnosticEvidence = (diagnostic, readEvidence, readLine) => readEvidence?.(diagnostic) ?? readLine(diagnostic.filePath, diagnostic.line);
46825
+ const buildMatchCandidates = (diagnostics, readEvidence, readLine) => diagnostics.map((diagnostic, diagnosticIndex) => ({
46826
+ diagnosticIndex,
46827
+ ...getDiagnosticMatchKeys(diagnostic, readDiagnosticEvidence(diagnostic, readEvidence, readLine))
46828
+ }));
46829
+ /**
46830
+ * Diffs a head scan against a base scan using a multiset of construct-level
46831
+ * evidence. Stable identities combine plugin/rule, the diagnostic message,
46832
+ * and normalized diagnosed source, so unchanged findings can move across
46833
+ * files while changed constructs or messages remain new. Cardinality is
46834
+ * retained for identical findings. Diagnostics explicitly marked
46835
+ * `matchByOccurrence` may fall back to same-file plugin/rule/message matching
46836
+ * after same-file strict evidence matching. Cross-file evidence matching runs
46837
+ * last so a copy cannot consume a reformatted local occurrence. Unreadable
46838
+ * evidence uses the same conservative fallback rather than matching across
46839
+ * files without proof.
46744
46840
  */
46745
46841
  const computeDiagnosticDelta = (input) => {
46746
- const unmatchedBaseByFingerprint = /* @__PURE__ */ new Map();
46747
- for (const diagnostic of input.baseDiagnostics) {
46748
- const key = fingerprintDiagnostic(diagnostic, input.readBaseLine(diagnostic.filePath, diagnostic.line));
46749
- unmatchedBaseByFingerprint.set(key, (unmatchedBaseByFingerprint.get(key) ?? 0) + 1);
46750
- }
46751
- const newDiagnostics = [];
46752
- for (const diagnostic of input.headDiagnostics) {
46753
- const key = fingerprintDiagnostic(diagnostic, input.readHeadLine(diagnostic.filePath, diagnostic.line));
46754
- const availableMatches = unmatchedBaseByFingerprint.get(key) ?? 0;
46755
- if (availableMatches > 0) unmatchedBaseByFingerprint.set(key, availableMatches - 1);
46756
- else newDiagnostics.push(diagnostic);
46757
- }
46758
- let fixedCount = 0;
46759
- for (const remaining of unmatchedBaseByFingerprint.values()) fixedCount += remaining;
46842
+ const baseCandidates = buildMatchCandidates(input.baseDiagnostics, input.readBaseEvidence, input.readBaseLine);
46843
+ const headCandidates = buildMatchCandidates(input.headDiagnostics, input.readHeadEvidence, input.readHeadLine);
46844
+ const baseByStableEvidence = /* @__PURE__ */ new Map();
46845
+ const baseBySameFileStableEvidence = /* @__PURE__ */ new Map();
46846
+ const baseBySameFileFallback = /* @__PURE__ */ new Map();
46847
+ for (const candidate of baseCandidates) {
46848
+ addDiagnosticIndex(baseByStableEvidence, candidate.stableEvidenceKey, candidate.diagnosticIndex);
46849
+ addDiagnosticIndex(baseBySameFileStableEvidence, candidate.sameFileStableEvidenceKey, candidate.diagnosticIndex);
46850
+ addDiagnosticIndex(baseBySameFileFallback, candidate.sameFileFallbackKey, candidate.diagnosticIndex);
46851
+ }
46852
+ const matchedHeadDiagnosticIndexes = /* @__PURE__ */ new Set();
46853
+ const matchedBaseDiagnosticIndexes = /* @__PURE__ */ new Set();
46854
+ const matchCandidates = (baseBuckets, getKey, onMatch) => {
46855
+ for (const candidate of headCandidates) {
46856
+ if (matchedHeadDiagnosticIndexes.has(candidate.diagnosticIndex)) continue;
46857
+ const baseDiagnosticIndex = takeMatchingDiagnosticIndex(baseBuckets, getKey(candidate), matchedBaseDiagnosticIndexes);
46858
+ if (baseDiagnosticIndex === null) continue;
46859
+ matchedHeadDiagnosticIndexes.add(candidate.diagnosticIndex);
46860
+ matchedBaseDiagnosticIndexes.add(baseDiagnosticIndex);
46861
+ onMatch?.(candidate.diagnosticIndex, baseDiagnosticIndex);
46862
+ }
46863
+ };
46864
+ matchCandidates(baseBySameFileStableEvidence, (candidate) => candidate.sameFileStableEvidenceKey);
46865
+ matchCandidates(baseBySameFileFallback, (candidate) => candidate.sameFileFallbackKey);
46866
+ let crossFileMatchCount = 0;
46867
+ matchCandidates(baseByStableEvidence, (candidate) => candidate.stableEvidenceKey, (head, base) => {
46868
+ if (input.headDiagnostics[head]?.filePath !== input.baseDiagnostics[base]?.filePath) crossFileMatchCount += 1;
46869
+ });
46760
46870
  return {
46761
- newDiagnostics,
46762
- fixedCount
46871
+ newDiagnostics: input.headDiagnostics.filter((_diagnostic, diagnosticIndex) => !matchedHeadDiagnosticIndexes.has(diagnosticIndex)),
46872
+ fixedCount: input.baseDiagnostics.length - matchedBaseDiagnosticIndexes.size,
46873
+ crossFileMatchCount
46763
46874
  };
46764
46875
  };
46765
46876
  /**
@@ -46791,6 +46902,12 @@ const getDiffInfo = (directory, explicitBaseBranch, includeUntracked) => runProm
46791
46902
  };
46792
46903
  }).pipe(provide(Git.layerNode)));
46793
46904
  const filterSourceFiles = (filePaths) => filePaths.filter(isLintableSourceFile);
46905
+ const getBaselineDiffPlan = (directory, ref) => runPromise(gen(function* () {
46906
+ return yield* (yield* Git).baselineDiffPlan({
46907
+ directory,
46908
+ ref
46909
+ });
46910
+ }).pipe(provide(Git.layerNode)));
46794
46911
  /**
46795
46912
  * Programmatic façade over `Git.changedLineRanges` (the `lines` scope). Diffs
46796
46913
  * `files` with `--unified=0` against `baseRef` (or the index when `cached`),
@@ -47247,7 +47364,7 @@ const makeNoopConsole = () => ({
47247
47364
  });
47248
47365
  //#endregion
47249
47366
  //#region src/cli/utils/version.ts
47250
- const VERSION = "0.8.1-dev.7818922";
47367
+ const VERSION = "0.8.1-dev.7c9dbed";
47251
47368
  //#endregion
47252
47369
  //#region src/cli/utils/json-mode.ts
47253
47370
  let context = null;
@@ -47599,13 +47716,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
47599
47716
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
47600
47717
  * standard `SENTRY_RELEASE` override.
47601
47718
  */
47602
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.8.1-dev.7818922`;
47719
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.8.1-dev.7c9dbed`;
47603
47720
  /**
47604
47721
  * Deployment environment shown in Sentry's environment filter. Defaults to
47605
47722
  * `production` for tagged releases and `development` for dev/unbuilt versions,
47606
47723
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
47607
47724
  */
47608
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.8.1-dev.7818922") ? "development" : "production");
47725
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.8.1-dev.7c9dbed") ? "development" : "production");
47609
47726
  /**
47610
47727
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
47611
47728
  * (set to `0` to disable tracing) and falls back to
@@ -60228,6 +60345,7 @@ const buildOutcomeAttributes = (input) => {
60228
60345
  new: summary.totalDiagnosticCount,
60229
60346
  fixed: result.baselineDelta.fixedCount,
60230
60347
  baseTotal: result.baselineDelta.baseTotalCount,
60348
+ crossFileMatches: result.baselineDelta.crossFileMatchCount ?? null,
60231
60349
  degraded: false
60232
60350
  }));
60233
60351
  else if (input.gateExempt) attributes["baseline.degraded"] = true;
@@ -60341,28 +60459,49 @@ const diagnosticIntersectsLineRanges = (diagnostic, lineRanges) => {
60341
60459
  return lineRanges.some(([rangeStartLine, rangeEndLine]) => diagnostic.line <= rangeEndLine && diagnosticEndLine >= rangeStartLine);
60342
60460
  };
60343
60461
  //#endregion
60462
+ //#region src/cli/utils/is-materializable-git-source.ts
60463
+ const GIT_LFS_POINTER_HEADER = "version https://git-lfs.github.com/spec/v1";
60464
+ const isMaterializableGitSource = (content) => !content.includes("\0") && !content.startsWith(GIT_LFS_POINTER_HEADER);
60465
+ //#endregion
60344
60466
  //#region src/cli/utils/materialize-baseline-files.ts
60345
60467
  /**
60346
- * Materializes the `ref` version of `files` into `tempDirectory` (via
60347
- * `git show <ref>:<path>`), mirroring the project layout + head's config files
60348
- * so the base lints under the same rules as head. Reuses the shared,
60349
- * zip-slip-guarded `materializeSourceTree`. Per-file read misses (e.g. a file
60350
- * the PR added, absent at base) are skipped, which is exactly what we want:
60351
- * a brand-new file has no base counterpart, so all its findings are "new".
60468
+ * Materializes the base side of the `ref` → worktree diff into
60469
+ * `tempDirectory`, mirroring the project layout plus head's config files so
60470
+ * both sides lint under the same rules. Git rename heuristics are disabled by
60471
+ * the diff planner, so an old path is retained as a base deletion while its
60472
+ * new path is a head addition. Missing head-only additions are expected;
60473
+ * missing paths that the plan says must exist at base make `isComplete` false.
60352
60474
  */
60353
60475
  const materializeBaselineFiles = (input) => runPromise(gen(function* () {
60354
60476
  const git = yield* Git;
60355
- return yield* materializeSourceTree({
60477
+ const diffPlan = yield* git.baselineDiffPlan({
60356
60478
  directory: input.directory,
60357
- files: input.files,
60479
+ ref: input.ref
60480
+ });
60481
+ const baseFiles = input.baseFiles ?? diffPlan?.baseFiles;
60482
+ const headFiles = input.headFiles ?? diffPlan?.headFiles;
60483
+ const untrackedFiles = diffPlan?.untrackedFiles ?? [];
60484
+ if (baseFiles === void 0 || headFiles === void 0) return null;
60485
+ const files = [...new Set([...input.files, ...baseFiles])];
60486
+ const tree = yield* materializeSourceTree({
60487
+ directory: input.directory,
60488
+ files,
60358
60489
  tempDirectory: input.tempDirectory,
60359
60490
  readContent: (relativePath) => git.showRefContent({
60360
60491
  directory: input.directory,
60361
60492
  ref: input.ref,
60362
60493
  relativePath,
60363
60494
  options: { maxBufferBytes: GIT_SHOW_MAX_BUFFER_BYTES }
60364
- })
60495
+ }).pipe(map$3((content) => content !== null && isMaterializableGitSource(content) ? content : null))
60365
60496
  });
60497
+ const materializedFiles = new Set(tree.materializedFiles);
60498
+ return {
60499
+ ...tree,
60500
+ baseFiles,
60501
+ headFiles,
60502
+ isComplete: baseFiles.every((filePath) => materializedFiles.has(filePath)),
60503
+ untrackedFiles
60504
+ };
60366
60505
  }).pipe(provide(Git.layerNode)));
60367
60506
  /**
60368
60507
  * Resolves the commit a baseline scan should read base content from — the
@@ -60385,8 +60524,8 @@ const resolveMergeBaseRef = async (directory, ref) => {
60385
60524
  //#region src/cli/utils/read-source-line.ts
60386
60525
  /**
60387
60526
  * Builds a cached `(filePath, line) -> source text` reader rooted at
60388
- * `rootDirectory`, used to fingerprint a diagnostic by the content of its
60389
- * flagged line (see `computeDiagnosticDelta`). Wraps core's
60527
+ * `rootDirectory`, used as the fallback when construct-level diagnostic
60528
+ * evidence cannot be read. Wraps core's
60390
60529
  * `createNodeReadFileLinesSync` with per-file memoization + 1-indexed line
60391
60530
  * lookup; unreadable files / out-of-range lines return `null`.
60392
60531
  */
@@ -60401,6 +60540,338 @@ const createSourceLineReader = (rootDirectory) => {
60401
60540
  };
60402
60541
  };
60403
60542
  //#endregion
60543
+ //#region src/cli/utils/read-diagnostic-evidence.ts
60544
+ const SOURCE_EXTENSIONS = [
60545
+ ".ts",
60546
+ ".tsx",
60547
+ ".js",
60548
+ ".jsx",
60549
+ ".mts",
60550
+ ".cts",
60551
+ ".mjs",
60552
+ ".cjs"
60553
+ ];
60554
+ const COMPONENT_WRAPPER_NAMES = new Set(["memo", "forwardRef"]);
60555
+ const getScriptKind = (filePath) => {
60556
+ const extension = path.extname(filePath).toLowerCase();
60557
+ if (extension === ".tsx") return ts.ScriptKind.TSX;
60558
+ if (extension === ".jsx") return ts.ScriptKind.JSX;
60559
+ if (extension === ".js" || extension === ".mjs" || extension === ".cjs") return ts.ScriptKind.JS;
60560
+ return ts.ScriptKind.TS;
60561
+ };
60562
+ const resolveSafeSourcePath = (rootDirectory, filePath) => {
60563
+ try {
60564
+ const resolvedRootDirectory = fs.realpathSync(rootDirectory);
60565
+ const absolutePath = path.resolve(resolvedRootDirectory, filePath);
60566
+ const relativePath = path.relative(resolvedRootDirectory, absolutePath);
60567
+ if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return null;
60568
+ const stats = fs.lstatSync(absolutePath);
60569
+ if (!stats.isFile() || stats.isSymbolicLink()) return null;
60570
+ const realPath = fs.realpathSync(absolutePath);
60571
+ const realRelativePath = path.relative(resolvedRootDirectory, realPath);
60572
+ return realRelativePath !== ".." && !realRelativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(realRelativePath) ? realPath : null;
60573
+ } catch {
60574
+ return null;
60575
+ }
60576
+ };
60577
+ const readSourceRecord = (rootDirectory, filePath, state) => {
60578
+ const normalizedFilePath = filePath.replace(/\\/g, "/");
60579
+ if (state.recordByFilePath.has(normalizedFilePath)) return state.recordByFilePath.get(normalizedFilePath) ?? null;
60580
+ const absolutePath = resolveSafeSourcePath(rootDirectory, normalizedFilePath);
60581
+ if (absolutePath === null) {
60582
+ state.recordByFilePath.set(normalizedFilePath, null);
60583
+ return null;
60584
+ }
60585
+ try {
60586
+ const sourceText = fs.readFileSync(absolutePath, "utf-8");
60587
+ const record = {
60588
+ absolutePath,
60589
+ filePath: normalizedFilePath,
60590
+ sourceFile: ts.createSourceFile(absolutePath, sourceText, ts.ScriptTarget.Latest, true, getScriptKind(absolutePath)),
60591
+ sourceText
60592
+ };
60593
+ state.recordByFilePath.set(normalizedFilePath, record);
60594
+ return record;
60595
+ } catch {
60596
+ state.recordByFilePath.set(normalizedFilePath, null);
60597
+ return null;
60598
+ }
60599
+ };
60600
+ const listSourceRecords = (rootDirectory, state) => {
60601
+ if (state.sourceRecords !== null) return state.sourceRecords;
60602
+ state.sourceRecords = listSourceFiles(rootDirectory).flatMap((filePath) => {
60603
+ const record = readSourceRecord(rootDirectory, filePath, state);
60604
+ return record === null ? [] : [record];
60605
+ });
60606
+ return state.sourceRecords;
60607
+ };
60608
+ const unwrapExpression = (expression) => {
60609
+ let currentExpression = expression;
60610
+ while (ts.isParenthesizedExpression(currentExpression) || ts.isAsExpression(currentExpression) || ts.isTypeAssertionExpression(currentExpression) || ts.isNonNullExpression(currentExpression) || ts.isSatisfiesExpression(currentExpression)) currentExpression = currentExpression.expression;
60611
+ return currentExpression;
60612
+ };
60613
+ const getCalleeName = (expression) => {
60614
+ const unwrappedExpression = unwrapExpression(expression);
60615
+ if (ts.isIdentifier(unwrappedExpression)) return unwrappedExpression.text;
60616
+ if (ts.isPropertyAccessExpression(unwrappedExpression)) return unwrappedExpression.name.text;
60617
+ return null;
60618
+ };
60619
+ const isComponentWrapperCall = (node) => {
60620
+ const wrapperName = getCalleeName(node.expression);
60621
+ return wrapperName !== null && COMPONENT_WRAPPER_NAMES.has(wrapperName);
60622
+ };
60623
+ const getFunctionName = (node) => {
60624
+ if (ts.isFunctionDeclaration(node) && node.name !== void 0) return node.name.text;
60625
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
60626
+ let componentExpression = node;
60627
+ while (true) {
60628
+ const parent = componentExpression.parent;
60629
+ if (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent) || ts.isTypeAssertionExpression(parent) || ts.isNonNullExpression(parent) || ts.isSatisfiesExpression(parent)) {
60630
+ componentExpression = parent;
60631
+ continue;
60632
+ }
60633
+ if (ts.isCallExpression(parent) && parent.arguments[0] === componentExpression && isComponentWrapperCall(parent)) {
60634
+ componentExpression = parent;
60635
+ continue;
60636
+ }
60637
+ if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text;
60638
+ return node.name?.text ?? null;
60639
+ }
60640
+ }
60641
+ return null;
60642
+ };
60643
+ const isComponentFunction = (node) => ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node);
60644
+ const isDefaultExportedComponent = (record, node, componentName) => {
60645
+ if (ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true;
60646
+ return record.sourceFile.statements.some((statement) => ts.isExportAssignment(statement) && ts.isIdentifier(statement.expression) && statement.expression.text === componentName);
60647
+ };
60648
+ const findEnclosingComponent = (record, diagnostic) => {
60649
+ if (!Number.isInteger(diagnostic.line) || diagnostic.line < 1) return null;
60650
+ const lineIndex = diagnostic.line - 1;
60651
+ const lineStarts = record.sourceFile.getLineStarts();
60652
+ const lineStart = lineStarts[lineIndex];
60653
+ if (lineStart === void 0) return null;
60654
+ const nextLineStart = lineStarts[lineIndex + 1] ?? record.sourceFile.end;
60655
+ const lineEnd = Math.max(lineStart, nextLineStart - 1);
60656
+ const columnIndex = Number.isInteger(diagnostic.column) ? Math.max(0, diagnostic.column - 1) : 0;
60657
+ const diagnosticPosition = Math.min(lineStart + columnIndex, lineEnd);
60658
+ let component = null;
60659
+ let componentWidth = Number.POSITIVE_INFINITY;
60660
+ const visit = (node) => {
60661
+ if (diagnosticPosition < node.getStart(record.sourceFile) || diagnosticPosition > node.end) return;
60662
+ if (isComponentFunction(node)) {
60663
+ const name = getFunctionName(node);
60664
+ const width = node.end - node.getStart(record.sourceFile);
60665
+ if (name !== null && /^[A-Z]/.test(name) && width < componentWidth) {
60666
+ component = {
60667
+ isDefaultExport: isDefaultExportedComponent(record, node, name),
60668
+ name,
60669
+ node,
60670
+ record
60671
+ };
60672
+ componentWidth = width;
60673
+ }
60674
+ }
60675
+ ts.forEachChild(node, visit);
60676
+ };
60677
+ visit(record.sourceFile);
60678
+ return component;
60679
+ };
60680
+ const moduleSpecifierTargetsComponent = (callerRecord, moduleSpecifier, componentRecord) => {
60681
+ if (!moduleSpecifier.startsWith(".")) return false;
60682
+ const unresolvedPath = path.resolve(path.dirname(callerRecord.absolutePath), moduleSpecifier);
60683
+ const specifiedExtension = path.extname(unresolvedPath).toLowerCase();
60684
+ const sourcePathWithoutExtension = SOURCE_EXTENSIONS.includes(specifiedExtension) ? unresolvedPath.slice(0, -specifiedExtension.length) : unresolvedPath;
60685
+ return [
60686
+ unresolvedPath,
60687
+ ...SOURCE_EXTENSIONS.map((extension) => `${sourcePathWithoutExtension}${extension}`),
60688
+ ...sourcePathWithoutExtension === unresolvedPath ? SOURCE_EXTENSIONS.map((extension) => path.join(unresolvedPath, `index${extension}`)) : []
60689
+ ].includes(componentRecord.absolutePath);
60690
+ };
60691
+ const getComponentTagNames = (record, component) => {
60692
+ if (record.absolutePath === component.record.absolutePath) return new Set([component.name]);
60693
+ const tagNames = /* @__PURE__ */ new Set();
60694
+ for (const statement of record.sourceFile.statements) {
60695
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !moduleSpecifierTargetsComponent(record, statement.moduleSpecifier.text, component.record)) continue;
60696
+ const importClause = statement.importClause;
60697
+ if (component.isDefaultExport && importClause?.name !== void 0) tagNames.add(importClause.name.text);
60698
+ const namedBindings = importClause?.namedBindings;
60699
+ if (namedBindings !== void 0 && ts.isNamedImports(namedBindings)) {
60700
+ for (const element of namedBindings.elements) if ((element.propertyName?.text ?? element.name.text) === component.name) tagNames.add(element.name.text);
60701
+ }
60702
+ }
60703
+ return tagNames;
60704
+ };
60705
+ const getTransparentCall = (functionNode) => {
60706
+ if (functionNode.body === void 0) return null;
60707
+ if (!ts.isBlock(functionNode.body)) {
60708
+ const expression = unwrapExpression(functionNode.body);
60709
+ return ts.isCallExpression(expression) ? expression : null;
60710
+ }
60711
+ if (functionNode.body.statements.length !== 1) return null;
60712
+ const statement = functionNode.body.statements[0];
60713
+ if (statement === void 0) return null;
60714
+ const expression = ts.isExpressionStatement(statement) || ts.isReturnStatement(statement) ? statement.expression : void 0;
60715
+ if (expression === void 0) return null;
60716
+ const unwrappedExpression = unwrapExpression(expression);
60717
+ return ts.isCallExpression(unwrappedExpression) ? unwrappedExpression : null;
60718
+ };
60719
+ const resolveTransparentTarget = (functionNode) => {
60720
+ const call = getTransparentCall(functionNode);
60721
+ if (call === null) return null;
60722
+ const target = unwrapExpression(call.expression);
60723
+ if (!ts.isIdentifier(target) || call.arguments.length !== functionNode.parameters.length) return null;
60724
+ for (const [parameterIndex, parameter] of functionNode.parameters.entries()) {
60725
+ const argument = call.arguments[parameterIndex];
60726
+ const unwrappedArgument = argument === void 0 ? null : unwrapExpression(argument);
60727
+ if (!ts.isIdentifier(parameter.name) || unwrappedArgument === null || !ts.isIdentifier(unwrappedArgument) || unwrappedArgument.text !== parameter.name.text) return null;
60728
+ }
60729
+ return target.text;
60730
+ };
60731
+ const getForwardingFunction = (declaration) => {
60732
+ if (ts.isFunctionDeclaration(declaration)) return declaration;
60733
+ if (!ts.isVariableDeclaration(declaration) || declaration.initializer === void 0) return null;
60734
+ const initializer = unwrapExpression(declaration.initializer);
60735
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer;
60736
+ if (ts.isCallExpression(initializer) && getCalleeName(initializer.expression) === "useCallback") {
60737
+ const callback = initializer.arguments[0];
60738
+ if (callback !== void 0 && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) return callback;
60739
+ }
60740
+ return null;
60741
+ };
60742
+ const getDeclaredBindingName = (node) => {
60743
+ if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node) || ts.isFunctionDeclaration(node)) && node.name !== void 0 && ts.isIdentifier(node.name)) return node.name.text;
60744
+ if (ts.isImportClause(node)) return node.name?.text ?? null;
60745
+ if (ts.isImportSpecifier(node) || ts.isNamespaceImport(node)) return node.name.text;
60746
+ return null;
60747
+ };
60748
+ const findBindingScope = (node) => {
60749
+ let currentNode = node;
60750
+ while (currentNode !== void 0) {
60751
+ if (ts.isParameter(currentNode) && ts.isFunctionLike(currentNode.parent)) return currentNode.parent;
60752
+ if (ts.isBlock(currentNode) || ts.isModuleBlock(currentNode) || ts.isSourceFile(currentNode)) return currentNode;
60753
+ currentNode = currentNode.parent;
60754
+ }
60755
+ return node.getSourceFile();
60756
+ };
60757
+ const findBindingDeclaration = (record, binding) => {
60758
+ const usagePosition = binding.getStart(record.sourceFile);
60759
+ const candidates = [];
60760
+ const visit = (node) => {
60761
+ if (getDeclaredBindingName(node) === binding.text) {
60762
+ const scope = findBindingScope(node);
60763
+ const isVisibleScope = usagePosition >= scope.getStart(record.sourceFile) && usagePosition <= scope.end;
60764
+ const isVisibleDeclaration = ts.isFunctionDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node) || ts.isImportClause(node) || ts.isImportSpecifier(node) || ts.isNamespaceImport(node) || node.getStart(record.sourceFile) < usagePosition;
60765
+ if (isVisibleScope && isVisibleDeclaration) candidates.push({
60766
+ declaration: node,
60767
+ scope
60768
+ });
60769
+ }
60770
+ ts.forEachChild(node, visit);
60771
+ };
60772
+ visit(record.sourceFile);
60773
+ let nearestCandidate = null;
60774
+ let nearestScopeWidth = Number.POSITIVE_INFINITY;
60775
+ let isNearestScopeAmbiguous = false;
60776
+ for (const candidate of candidates) {
60777
+ const scopeWidth = candidate.scope.end - candidate.scope.getStart(record.sourceFile);
60778
+ if (scopeWidth < nearestScopeWidth) {
60779
+ nearestCandidate = candidate;
60780
+ nearestScopeWidth = scopeWidth;
60781
+ isNearestScopeAmbiguous = false;
60782
+ } else if (scopeWidth === nearestScopeWidth) isNearestScopeAmbiguous = true;
60783
+ }
60784
+ if (isNearestScopeAmbiguous) return null;
60785
+ const declaration = nearestCandidate?.declaration;
60786
+ return declaration !== void 0 && (ts.isVariableDeclaration(declaration) || ts.isFunctionDeclaration(declaration)) ? declaration : null;
60787
+ };
60788
+ const resolveHandlerBinding = (record, binding) => {
60789
+ const declaration = findBindingDeclaration(record, binding);
60790
+ if (declaration === null) return binding.text;
60791
+ const forwardingFunction = getForwardingFunction(declaration);
60792
+ const target = forwardingFunction === null ? null : resolveTransparentTarget(forwardingFunction);
60793
+ return target === null || target === binding.text ? binding.text : target;
60794
+ };
60795
+ const getJsxTagName = (node) => ts.isIdentifier(node.tagName) ? node.tagName.text : null;
60796
+ const getHandlerBinding = (node, propName) => {
60797
+ for (const property of node.attributes.properties) {
60798
+ if (!ts.isJsxAttribute(property) || property.name.getText() !== propName) continue;
60799
+ const initializer = property.initializer;
60800
+ if (initializer === void 0 || !ts.isJsxExpression(initializer)) return null;
60801
+ if (initializer.expression === void 0) return null;
60802
+ const expression = unwrapExpression(initializer.expression);
60803
+ return ts.isIdentifier(expression) ? expression : null;
60804
+ }
60805
+ return null;
60806
+ };
60807
+ const getForwardedHandlerAliases = (rootDirectory, component, propNames, state) => {
60808
+ const cacheKey = `${component.record.filePath}\0${component.name}\0${[...propNames].sort().join("\0")}`;
60809
+ const cachedAliases = state.aliasesByComponent.get(cacheKey);
60810
+ if (cachedAliases !== void 0) return cachedAliases;
60811
+ const bindingsByPropName = /* @__PURE__ */ new Map();
60812
+ const unresolvedPropNames = /* @__PURE__ */ new Set();
60813
+ let callsiteCount = 0;
60814
+ for (const record of listSourceRecords(rootDirectory, state)) {
60815
+ const tagNames = getComponentTagNames(record, component);
60816
+ if (tagNames.size === 0) continue;
60817
+ const visit = (node) => {
60818
+ if (record === component.record && node === component.node) return;
60819
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
60820
+ const tagName = getJsxTagName(node);
60821
+ if (tagName !== null && tagNames.has(tagName)) {
60822
+ callsiteCount += 1;
60823
+ for (const propName of propNames) {
60824
+ const binding = getHandlerBinding(node, propName);
60825
+ if (binding === null) {
60826
+ unresolvedPropNames.add(propName);
60827
+ continue;
60828
+ }
60829
+ const bindings = bindingsByPropName.get(propName) ?? /* @__PURE__ */ new Set();
60830
+ bindings.add(resolveHandlerBinding(record, binding));
60831
+ bindingsByPropName.set(propName, bindings);
60832
+ }
60833
+ }
60834
+ }
60835
+ ts.forEachChild(node, visit);
60836
+ };
60837
+ visit(record.sourceFile);
60838
+ }
60839
+ const aliases = /* @__PURE__ */ new Map();
60840
+ if (callsiteCount > 0) for (const [propName, bindings] of bindingsByPropName) {
60841
+ if (bindings.size !== 1 || unresolvedPropNames.has(propName)) continue;
60842
+ const [bindingName] = bindings;
60843
+ if (bindingName !== void 0) aliases.set(propName, bindingName);
60844
+ }
60845
+ state.aliasesByComponent.set(cacheKey, aliases);
60846
+ return aliases;
60847
+ };
60848
+ const normalizeForwardedHandlers = (evidence, aliases) => {
60849
+ let normalizedEvidence = evidence;
60850
+ for (const [propName, bindingName] of aliases) normalizedEvidence = normalizedEvidence.replace(new RegExp(`\\{\\s*${propName}\\s*\\}`, "g"), `{${bindingName}}`).replace(new RegExp(`=>\\s*${propName}\\s*\\(`, "g"), `=> ${bindingName}(`);
60851
+ return normalizedEvidence;
60852
+ };
60853
+ const createDiagnosticEvidenceReader = (rootDirectory, options = {}) => {
60854
+ const state = {
60855
+ aliasesByComponent: /* @__PURE__ */ new Map(),
60856
+ recordByFilePath: /* @__PURE__ */ new Map(),
60857
+ sourceRecords: null
60858
+ };
60859
+ return (diagnostic) => {
60860
+ const record = readSourceRecord(rootDirectory, diagnostic.filePath, state);
60861
+ if (record === null || !Number.isInteger(diagnostic.line)) return null;
60862
+ const sourceLines = record.sourceText.split(/\r?\n/);
60863
+ const startLineIndex = Math.max(0, diagnostic.line - 1);
60864
+ const endLineIndex = Math.max(startLineIndex, (diagnostic.endLine ?? diagnostic.line) - 1);
60865
+ const evidence = sourceLines.slice(startLineIndex, endLineIndex + 1).join("\n");
60866
+ if (!options.resolveForwardedHandlers) return evidence;
60867
+ const propNames = new Set(evidence.match(/\bon[A-Z]\w*\b/g) ?? []);
60868
+ if (propNames.size === 0) return evidence;
60869
+ const component = findEnclosingComponent(record, diagnostic);
60870
+ if (component === null) return evidence;
60871
+ return normalizeForwardedHandlers(evidence, getForwardedHandlerAliases(rootDirectory, component, propNames, state));
60872
+ };
60873
+ };
60874
+ //#endregion
60404
60875
  //#region src/cli/utils/build-no-score-message.ts
60405
60876
  const ENTERPRISE_CONTACT_HINT = `Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`;
60406
60877
  const buildNoScoreMessage = (isScoreDisabled) => {
@@ -61644,6 +62115,8 @@ const runBaselineComparison = async (params) => {
61644
62115
  directory: params.directory,
61645
62116
  ref: params.baselineRef,
61646
62117
  files: params.options.includePaths,
62118
+ baseFiles: params.baseFiles,
62119
+ headFiles: params.headFiles,
61647
62120
  tempDirectory
61648
62121
  }).catch((error) => {
61649
62122
  rmSync(tempDirectory, {
@@ -61652,7 +62125,24 @@ const runBaselineComparison = async (params) => {
61652
62125
  });
61653
62126
  throw error;
61654
62127
  });
62128
+ if (snapshot === null) {
62129
+ rmSync(tempDirectory, {
62130
+ recursive: true,
62131
+ force: true
62132
+ });
62133
+ return null;
62134
+ }
61655
62135
  try {
62136
+ if (!snapshot.isComplete) return null;
62137
+ const analyzedHeadFiles = new Set(params.headAnalyzedFiles.map(toForwardSlashes));
62138
+ const baseFiles = new Set(snapshot.baseFiles.map(toForwardSlashes));
62139
+ const trackedHeadFiles = new Set(snapshot.headFiles.map(toForwardSlashes));
62140
+ const expectedHeadFiles = new Set(trackedHeadFiles);
62141
+ for (const filePath of params.options.includePaths) {
62142
+ const normalizedFilePath = toForwardSlashes(filePath);
62143
+ if (!baseFiles.has(normalizedFilePath)) expectedHeadFiles.add(normalizedFilePath);
62144
+ }
62145
+ if (filterSourceFiles([...expectedHeadFiles]).some((filePath) => !analyzedHeadFiles.has(filePath))) return null;
61656
62146
  const baseLayers = buildRuntimeLayers({
61657
62147
  directory: snapshot.tempDirectory,
61658
62148
  hasConfigOverride: true,
@@ -61668,7 +62158,7 @@ const runBaselineComparison = async (params) => {
61668
62158
  });
61669
62159
  const baseOutput = await runPromise(restoreLegacyThrow(runInspect({
61670
62160
  directory: snapshot.tempDirectory,
61671
- includePaths: params.options.includePaths,
62161
+ includePaths: snapshot.materializedFiles,
61672
62162
  customRulesOnly: params.options.customRulesOnly,
61673
62163
  respectInlineDisables: params.options.respectInlineDisables,
61674
62164
  warnings: params.options.warnings,
@@ -61685,18 +62175,22 @@ const runBaselineComparison = async (params) => {
61685
62175
  deadlineEpochMs: params.deadlineEpochMs ?? void 0
61686
62176
  }, {}).pipe(provide(baseLayers), provideService$2(PerFileLintCacheEnabled, false), provideService$2(SidecarLintCacheEnabled, false), provideService$2(Console, silentConsole))));
61687
62177
  if (baseOutput.didLintFail || countIncompleteLintFiles(baseOutput.lintPartialFailures) > 0) return null;
62178
+ const hasUnscannedUntrackedSourceFiles = filterSourceFiles(snapshot.untrackedFiles.map(toForwardSlashes)).some((filePath) => !analyzedHeadFiles.has(filePath));
61688
62179
  const delta = computeDiagnosticDelta({
61689
62180
  headDiagnostics: params.headDiagnostics,
61690
62181
  baseDiagnostics: baseOutput.diagnostics,
61691
62182
  readHeadLine: createSourceLineReader(params.directory),
61692
- readBaseLine: createSourceLineReader(snapshot.tempDirectory)
62183
+ readBaseLine: createSourceLineReader(snapshot.tempDirectory),
62184
+ readHeadEvidence: createDiagnosticEvidenceReader(params.directory, { resolveForwardedHandlers: true }),
62185
+ readBaseEvidence: createDiagnosticEvidenceReader(snapshot.tempDirectory)
61693
62186
  });
61694
62187
  return {
61695
62188
  displayDiagnostics: delta.newDiagnostics,
61696
62189
  baselineDelta: {
61697
62190
  baseRef: params.baselineRef,
61698
- fixedCount: delta.fixedCount,
61699
- baseTotalCount: baseOutput.diagnostics.length
62191
+ fixedCount: hasUnscannedUntrackedSourceFiles ? 0 : delta.fixedCount,
62192
+ baseTotalCount: baseOutput.diagnostics.length,
62193
+ crossFileMatchCount: delta.crossFileMatchCount
61700
62194
  }
61701
62195
  };
61702
62196
  } finally {
@@ -61803,6 +62297,9 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
61803
62297
  headDiagnostics: output.diagnostics,
61804
62298
  resolvedNodeBinaryPath,
61805
62299
  baselineRef: options.baseline.ref,
62300
+ baseFiles: options.baseline.baseFiles,
62301
+ headFiles: options.baseline.headFiles,
62302
+ headAnalyzedFiles: output.analyzedFiles,
61806
62303
  deadlineEpochMs
61807
62304
  });
61808
62305
  if (comparison) {
@@ -64087,20 +64584,25 @@ const resolveProjectRelativeDirectory = (rootDirectory, projectDirectory) => {
64087
64584
  return relativeProjectDirectory;
64088
64585
  };
64089
64586
  //#endregion
64090
- //#region src/cli/utils/resolve-project-diff-include-paths.ts
64091
- const resolveProjectDiffIncludePaths = (rootDirectory, projectDirectory, diffInfo) => {
64587
+ //#region src/cli/utils/resolve-project-source-file-paths.ts
64588
+ const resolveProjectSourceFilePaths = (rootDirectory, projectDirectory, filePaths) => {
64092
64589
  const relativeProjectDirectory = resolveProjectRelativeDirectory(rootDirectory, projectDirectory);
64093
64590
  if (relativeProjectDirectory === null) return [];
64094
- const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
64095
- if (relativeProjectDirectory.length === 0) return changedSourceFiles;
64591
+ const sourceFilePaths = filterSourceFiles([...filePaths]);
64592
+ if (relativeProjectDirectory.length === 0) return sourceFilePaths;
64096
64593
  const projectPrefix = `${relativeProjectDirectory}/`;
64097
- return changedSourceFiles.flatMap((filePath) => {
64594
+ return sourceFilePaths.flatMap((filePath) => {
64098
64595
  const normalizedFilePath = toForwardSlashes(filePath);
64099
64596
  if (!normalizedFilePath.startsWith(projectPrefix)) return [];
64100
64597
  const projectRelativePath = normalizedFilePath.slice(projectPrefix.length);
64101
64598
  return projectRelativePath.length > 0 ? [projectRelativePath] : [];
64102
64599
  });
64103
64600
  };
64601
+ //#endregion
64602
+ //#region src/cli/utils/resolve-project-diff-include-paths.ts
64603
+ const resolveProjectDiffIncludePaths = (rootDirectory, projectDirectory, diffInfo) => {
64604
+ return resolveProjectSourceFilePaths(rootDirectory, projectDirectory, diffInfo.changedFiles);
64605
+ };
64104
64606
  /**
64105
64607
  * Re-keys repo-relative changed line ranges (from `getChangedLineRanges`) to
64106
64608
  * the scanned project, mirroring `resolveProjectDiffIncludePaths`'s boundary
@@ -64639,6 +65141,7 @@ const inspectAction = async (directory, flags) => {
64639
65141
  const isDiffMode = scope !== "full";
64640
65142
  const comparisonBaseRef = isDiffMode && diffInfo && !diffInfo.isCurrentChanges ? diffInfo.baseSha ? await resolveMergeBaseRef(resolvedDirectory, diffInfo.baseSha) : diffInfo.diffBaseRef ?? await resolveMergeBaseRef(resolvedDirectory, diffInfo.baseBranch) : null;
64641
65143
  const baselineRef = scope === "changed" ? comparisonBaseRef : null;
65144
+ const baselineDiffPlan = baselineRef === null ? null : await getBaselineDiffPlan(resolvedDirectory, baselineRef);
64642
65145
  const linesBaseRef = diffInfo?.isCurrentChanges ? "HEAD" : comparisonBaseRef;
64643
65146
  const changedLineRanges = scope === "lines" && diffInfo !== null && (diffInfo.isCurrentChanges || linesBaseRef !== null) && diffInfo !== null ? await getChangedLineRanges({
64644
65147
  directory: resolvedDirectory,
@@ -64669,10 +65172,13 @@ const inspectAction = async (directory, flags) => {
64669
65172
  const supplyChainEnabled = flags.supplyChain ?? projectConfig?.supplyChain?.enabled !== false;
64670
65173
  let includePaths;
64671
65174
  let supplyChainManifestChanged = false;
65175
+ const projectBaselineBaseFiles = baselineDiffPlan === null ? null : resolveProjectSourceFilePaths(resolvedDirectory, scanDirectory, baselineDiffPlan.baseFiles);
65176
+ const projectBaselineHeadFiles = baselineDiffPlan === null ? null : resolveProjectSourceFilePaths(resolvedDirectory, scanDirectory, baselineDiffPlan.headFiles);
64672
65177
  if (isDiffMode) {
64673
65178
  const changedSourceFiles = diffInfo === null ? [] : resolveProjectDiffIncludePaths(resolvedDirectory, scanDirectory, diffInfo);
64674
65179
  supplyChainManifestChanged = supplyChainEnabled && diffInfo !== null && projectManifestChanged(resolvedDirectory, scanDirectory, diffInfo);
64675
- if (changedSourceFiles.length === 0 && !supplyChainManifestChanged) {
65180
+ const hasBaselineOnlyFiles = (projectBaselineBaseFiles?.length ?? 0) > 0;
65181
+ if (changedSourceFiles.length === 0 && !supplyChainManifestChanged && !hasBaselineOnlyFiles) {
64676
65182
  if (!isQuiet) {
64677
65183
  cliLogger.dim(`No changed source files in ${scanDirectory}, skipping.`);
64678
65184
  cliLogger.break();
@@ -64680,6 +65186,7 @@ const inspectAction = async (directory, flags) => {
64680
65186
  return null;
64681
65187
  }
64682
65188
  includePaths = [...changedSourceFiles];
65189
+ if (includePaths.length === 0 && hasBaselineOnlyFiles) includePaths.push(...projectBaselineBaseFiles ?? []);
64683
65190
  if (supplyChainManifestChanged) includePaths.push("package.json");
64684
65191
  }
64685
65192
  if (!isQuiet && !isMultiProject) cliLogger.dim(" ");
@@ -64691,7 +65198,11 @@ const inspectAction = async (directory, flags) => {
64691
65198
  configSourceDirectory: projectConfigSourceDirectory ?? void 0,
64692
65199
  suppressRendering: isMultiProject,
64693
65200
  concurrentScan: isMultiProject,
64694
- baseline: baselineRef ? { ref: baselineRef } : void 0,
65201
+ baseline: baselineRef !== null && projectBaselineBaseFiles !== null && projectBaselineHeadFiles !== null ? {
65202
+ ref: baselineRef,
65203
+ baseFiles: projectBaselineBaseFiles,
65204
+ headFiles: projectBaselineHeadFiles
65205
+ } : void 0,
64695
65206
  changedLineRanges: scope === "lines" && changedLineRanges !== null ? resolveProjectChangedLineRanges(resolvedDirectory, scanDirectory, changedLineRanges) : void 0,
64696
65207
  supplyChainManifestChanged
64697
65208
  });
@@ -65891,4 +66402,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
65891
66402
  export {};
65892
66403
 
65893
66404
  //# sourceMappingURL=cli.js.map
65894
- //# debugId=9e32582f-b296-5216-a028-7cc19ed83931
66405
+ //# debugId=f6c63ef7-3eca-54f6-80d8-221d85cea09b
package/dist/index.d.ts CHANGED
@@ -9030,11 +9030,12 @@ interface Diagnostic {
9030
9030
  * Set when the finding's identity is the flagged element itself (a missing
9031
9031
  * attribute, a wrong element) rather than the flagged line's text — every
9032
9032
  * Accessibility-category finding, plus rules that opt in via their
9033
- * `matchByOccurrence` metadata flag. `computeDiagnosticDelta` matches these
9034
- * by `(filePath, plugin/rule)` occurrence count, so reformatting the flagged
9035
- * line doesn't reclassify a pre-existing finding as new. Resolved at
9036
- * diagnostic creation, where rule metadata is available. Absent means
9037
- * line-text-sensitive matching (the default).
9033
+ * `matchByOccurrence` metadata flag. After strict evidence matching,
9034
+ * `computeDiagnosticDelta` may match these by same-file `(plugin/rule,
9035
+ * message)` occurrence count, so reformatting the flagged element doesn't
9036
+ * reclassify a pre-existing finding as new. Resolved at diagnostic creation,
9037
+ * where rule metadata is available. Absent means diagnosed-source-sensitive
9038
+ * matching (the default).
9038
9039
  */
9039
9040
  matchByOccurrence?: boolean;
9040
9041
  /**
@@ -9847,7 +9848,8 @@ interface InspectResult {
9847
9848
  baselineDelta?: {
9848
9849
  /** The commit the base content was read from (resolved merge-base). */baseRef: string; /** Findings present at base but gone at head — resolved by the change. */
9849
9850
  fixedCount: number; /** Total findings at base (over the same files), for context. */
9850
- baseTotalCount: number;
9851
+ baseTotalCount: number; /** Pre-existing findings matched after moving to a different file. */
9852
+ crossFileMatchCount?: number;
9851
9853
  };
9852
9854
  }
9853
9855
  /**
@@ -10263,15 +10265,7 @@ declare const buildJsonReport: (input: BuildJsonReportInput) => JsonReportV3$1;
10263
10265
  * `GitBaseBranchMissing`) as clean user errors instead of crashes.
10264
10266
  */
10265
10267
  declare const getDiffInfo: (directory: string, explicitBaseBranch?: string, includeUntracked?: boolean) => Promise<DiffInfo | null>;
10266
- declare const filterSourceFiles: (filePaths: string[]) => string[];
10267
- /**
10268
- * Programmatic façade over `Git.changedLineRanges` (the `lines` scope). Diffs
10269
- * `files` with `--unified=0` against `baseRef` (or the index when `cached`),
10270
- * returning per-file changed line ranges relative to `directory`. Returns
10271
- * `null` when the ranges can't be computed (git unavailable / unsafe ref /
10272
- * non-zero exit) so the caller degrades to file-level scope; an empty array
10273
- * means git succeeded but the files added no lines.
10274
- */
10268
+ declare const filterSourceFiles: (filePaths: ReadonlyArray<string>) => string[];
10275
10269
  //#endregion
10276
10270
  //#region src/summarize-diagnostics.d.ts
10277
10271
  declare const summarizeDiagnostics: (diagnostics: Diagnostic[], worstScore?: number | null, worstScoreLabel?: string | null) => JsonReportSummary; //#endregion
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3121a4e4-8a4a-55ea-ade8-47d0bf576a2f")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f412a400-e7a3-5cfa-a67f-1192f6a38df2")}catch(e){}}();
3
3
  import { r as __toESM$1, t as __commonJSMin$1 } from "./chunk-N93fKeF6.js";
4
4
  import { createRequire } from "node:module";
5
5
  import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, CROSS_FILE_DEPENDENCY_COLLECTORS, CROSS_FILE_RULE_IDS, FRAMEWORK_SPECIFIC_RULE_KEYS, FRAMEWORK_TOKENS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES, classifySecurityScanFile, collectCrossFileDependencyProbes, resetManifestCaches, shouldReadSecurityScanContent } from "oxlint-plugin-react-doctor";
@@ -34784,7 +34784,6 @@ const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
34784
34784
  "react/jsx-no-new-function-as-prop": "react-doctor/jsx-no-new-function-as-prop",
34785
34785
  "react/jsx-no-new-object-as-prop": "react-doctor/jsx-no-new-object-as-prop",
34786
34786
  "react/jsx-no-script-url": "react-doctor/jsx-no-script-url",
34787
- "react/jsx-no-target-blank": "react-doctor/jsx-no-target-blank",
34788
34787
  "react/jsx-no-undef": "react-doctor/jsx-no-undef",
34789
34788
  "react/jsx-no-useless-fragment": "react-doctor/jsx-no-useless-fragment",
34790
34789
  "react/jsx-pascal-case": "react-doctor/jsx-pascal-case",
@@ -39090,6 +39089,35 @@ const parseGithubViewerPermission = (stdout) => {
39090
39089
  return /^[A-Z_]+$/.test(value) ? value.toLowerCase() : null;
39091
39090
  };
39092
39091
  const splitNullSeparated = (value) => value.split("\0").filter((entry) => entry.length > 0);
39092
+ const parseBaselineDiffPlan = (value) => {
39093
+ const entries = splitNullSeparated(value);
39094
+ const baseFiles = /* @__PURE__ */ new Set();
39095
+ const headFiles = /* @__PURE__ */ new Set();
39096
+ for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 2) {
39097
+ const status = entries[entryIndex];
39098
+ const filePath = entries[entryIndex + 1];
39099
+ if (status === void 0 || filePath === void 0 || status.length !== 1) return null;
39100
+ if (status === "A") {
39101
+ headFiles.add(filePath);
39102
+ continue;
39103
+ }
39104
+ if (status === "D") {
39105
+ baseFiles.add(filePath);
39106
+ continue;
39107
+ }
39108
+ if (status === "M" || status === "T") {
39109
+ baseFiles.add(filePath);
39110
+ headFiles.add(filePath);
39111
+ continue;
39112
+ }
39113
+ return null;
39114
+ }
39115
+ return {
39116
+ baseFiles: [...baseFiles],
39117
+ headFiles: [...headFiles],
39118
+ untrackedFiles: []
39119
+ };
39120
+ };
39093
39121
  const UNTRACKED_FILE_LAST_LINE = Number.MAX_SAFE_INTEGER;
39094
39122
  /**
39095
39123
  * `Git` wraps every `git`-via-subprocess call react-doctor makes
@@ -39292,6 +39320,45 @@ var Git = class Git extends Service()("react-doctor/Git") {
39292
39320
  githubViewerPermission,
39293
39321
  branchExists,
39294
39322
  mergeBase,
39323
+ baselineDiffPlan: (input) => {
39324
+ if (!isSafeGitRevision(input.ref)) return succeed$2(null);
39325
+ return gen(function* () {
39326
+ const unmerged = yield* runGit(input.directory, [
39327
+ "diff",
39328
+ "--no-ext-diff",
39329
+ "-z",
39330
+ "--name-only",
39331
+ "--diff-filter=U",
39332
+ "--relative"
39333
+ ]);
39334
+ if (unmerged.status !== 0 || unmerged.stdout.length > 0) return null;
39335
+ const result = yield* runGit(input.directory, [
39336
+ "diff",
39337
+ "--no-ext-diff",
39338
+ "--no-textconv",
39339
+ "--no-renames",
39340
+ "-z",
39341
+ "--name-status",
39342
+ "--relative",
39343
+ input.ref
39344
+ ]);
39345
+ if (result.status !== 0) return null;
39346
+ const plan = parseBaselineDiffPlan(result.stdout);
39347
+ if (plan === null) return null;
39348
+ const untracked = yield* runGit(input.directory, [
39349
+ "ls-files",
39350
+ "--others",
39351
+ "--exclude-standard",
39352
+ "-z"
39353
+ ]);
39354
+ if (untracked.status !== 0) return null;
39355
+ return {
39356
+ baseFiles: plan.baseFiles,
39357
+ headFiles: plan.headFiles,
39358
+ untrackedFiles: splitNullSeparated(untracked.stdout)
39359
+ };
39360
+ }).pipe(catch_$1(() => succeed$2(null)), withSpan("Git.baselineDiffPlan"));
39361
+ },
39295
39362
  diffSelection: ({ directory, explicitBaseBranch, includeUntracked = false }) => gen(function* () {
39296
39363
  if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) return yield* fail$4(new ReactDoctorError({ reason: new GitBaseBranchInvalid({ detail: "Diff base branch cannot be empty." }) }));
39297
39364
  if (explicitBaseBranch !== void 0) {
@@ -39441,6 +39508,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
39441
39508
  githubViewerPermission: () => succeed$2(snapshot.githubViewerPermission ?? null),
39442
39509
  branchExists: (_directory, branch) => succeed$2(snapshot.branchExists?.get(branch) ?? false),
39443
39510
  mergeBase: ({ ref }) => succeed$2(snapshot.mergeBase?.get(ref) ?? null),
39511
+ baselineDiffPlan: () => succeed$2(snapshot.baselineDiffPlan ?? null),
39444
39512
  diffSelection: () => succeed$2(snapshot.diffSelection ?? null),
39445
39513
  stagedFilePaths: () => succeed$2(snapshot.stagedFiles ?? []),
39446
39514
  showStagedContent: (_directory, relativePath) => succeed$2(snapshot.stagedContent?.get(relativePath) ?? null),
@@ -40778,12 +40846,17 @@ const REACT_ERROR_BOUNDARY_MESSAGE = "This try/catch cannot catch errors thrown
40778
40846
  const REACT_COMPILER_IMPACT = "This component misses React Compiler's automatic memoization & re-renders more than it should";
40779
40847
  const REACT_COMPILER_ACTION = "Rewrite the flagged code so the compiler can optimize it.";
40780
40848
  const REACT_COMPILER_INCOMPATIBLE_LIBRARY_ACTION = "It's how the library works, not a bug in your code. Memoize values you pass from it into other memoized components, or suppress it with `// react-doctor-disable-next-line react-hooks-js/incompatible-library`.";
40849
+ const SET_STATE_IN_EFFECT_ACTION = "Prefer deriving or initializing the value before render. If the effect must read a browser API after mount, treat this as advisory or suppress it with `// react-doctor-disable-next-line react-hooks-js/set-state-in-effect`.";
40781
40850
  const REACT_COMPILER_GENERIC_MESSAGE = `${REACT_COMPILER_IMPACT}. ${REACT_COMPILER_ACTION}`;
40782
40851
  const buildReactCompilerMessage = (reasonSummary, action = REACT_COMPILER_ACTION) => {
40783
40852
  const normalizedSummary = reasonSummary.replace(TRAILING_PERIOD_PATTERN, "");
40784
40853
  if (!normalizedSummary) return `${REACT_COMPILER_IMPACT}. ${action}`;
40785
40854
  return `${REACT_COMPILER_IMPACT}: ${normalizedSummary}. ${action}`;
40786
40855
  };
40856
+ const buildSetStateInEffectMessage = (reasonSummary) => {
40857
+ const normalizedSummary = reasonSummary.replace(TRAILING_PERIOD_PATTERN, "");
40858
+ return `This synchronous effect update causes an extra render${normalizedSummary ? `: ${normalizedSummary}` : ""}. ${SET_STATE_IN_EFFECT_ACTION}`;
40859
+ };
40787
40860
  const PLUGIN_CATEGORY_MAP = {
40788
40861
  react: "Bugs",
40789
40862
  "react-hooks": "Bugs",
@@ -40841,6 +40914,10 @@ const resolveCleanedDiagnostic = (message, help, plugin, rule, project) => {
40841
40914
  message: REACT_ERROR_BOUNDARY_MESSAGE,
40842
40915
  help: reasonDetail || help
40843
40916
  };
40917
+ if (rule === "set-state-in-effect") return {
40918
+ message: buildSetStateInEffectMessage(reasonSummary.trim()),
40919
+ help: reasonDetail || help
40920
+ };
40844
40921
  return {
40845
40922
  message: buildReactCompilerMessage(reasonSummary.trim(), rule === "incompatible-library" ? REACT_COMPILER_INCOMPATIBLE_LIBRARY_ACTION : REACT_COMPILER_ACTION),
40846
40923
  help: appendReanimatedSharedValueHint(reasonDetail || help, rule, project)
@@ -43166,12 +43243,19 @@ const isPathInsideDirectory = (childAbsolutePath, parentAbsolutePath) => {
43166
43243
  */
43167
43244
  const materializeSourceTree = (input) => gen(function* () {
43168
43245
  const materializedFiles = [];
43246
+ const unmaterializedFiles = [];
43169
43247
  const resolvedTempDirectory = path.resolve(input.tempDirectory);
43170
43248
  for (const relativePath of input.files) {
43171
43249
  const content = yield* input.readContent(relativePath).pipe(orElseSucceed(() => null));
43172
- if (content === null) continue;
43250
+ if (content === null) {
43251
+ unmaterializedFiles.push(relativePath);
43252
+ continue;
43253
+ }
43173
43254
  const candidateTargetPath = path.resolve(resolvedTempDirectory, relativePath);
43174
- if (!isPathInsideDirectory(candidateTargetPath, resolvedTempDirectory)) continue;
43255
+ if (!isPathInsideDirectory(candidateTargetPath, resolvedTempDirectory)) {
43256
+ unmaterializedFiles.push(relativePath);
43257
+ continue;
43258
+ }
43175
43259
  yield* sync(() => {
43176
43260
  fs.mkdirSync(path.dirname(candidateTargetPath), { recursive: true });
43177
43261
  fs.writeFileSync(candidateTargetPath, content);
@@ -43188,6 +43272,7 @@ const materializeSourceTree = (input) => gen(function* () {
43188
43272
  return {
43189
43273
  tempDirectory: input.tempDirectory,
43190
43274
  materializedFiles,
43275
+ unmaterializedFiles,
43191
43276
  cleanup: () => {
43192
43277
  try {
43193
43278
  fs.rmSync(input.tempDirectory, {
@@ -43751,4 +43836,4 @@ const toJsonReport = (result, options) => buildJsonReport({
43751
43836
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, hasReactRuntime, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
43752
43837
 
43753
43838
  //# sourceMappingURL=index.js.map
43754
- //# debugId=3121a4e4-8a4a-55ea-ade8-47d0bf576a2f
43839
+ //# debugId=f412a400-e7a3-5cfa-a67f-1192f6a38df2
package/dist/lsp.js CHANGED
@@ -34836,7 +34836,6 @@ const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
34836
34836
  "react/jsx-no-new-function-as-prop": "react-doctor/jsx-no-new-function-as-prop",
34837
34837
  "react/jsx-no-new-object-as-prop": "react-doctor/jsx-no-new-object-as-prop",
34838
34838
  "react/jsx-no-script-url": "react-doctor/jsx-no-script-url",
34839
- "react/jsx-no-target-blank": "react-doctor/jsx-no-target-blank",
34840
34839
  "react/jsx-no-undef": "react-doctor/jsx-no-undef",
34841
34840
  "react/jsx-no-useless-fragment": "react-doctor/jsx-no-useless-fragment",
34842
34841
  "react/jsx-pascal-case": "react-doctor/jsx-pascal-case",
@@ -39068,6 +39067,35 @@ const parseGithubViewerPermission = (stdout) => {
39068
39067
  return /^[A-Z_]+$/.test(value) ? value.toLowerCase() : null;
39069
39068
  };
39070
39069
  const splitNullSeparated = (value) => value.split("\0").filter((entry) => entry.length > 0);
39070
+ const parseBaselineDiffPlan = (value) => {
39071
+ const entries = splitNullSeparated(value);
39072
+ const baseFiles = /* @__PURE__ */ new Set();
39073
+ const headFiles = /* @__PURE__ */ new Set();
39074
+ for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 2) {
39075
+ const status = entries[entryIndex];
39076
+ const filePath = entries[entryIndex + 1];
39077
+ if (status === void 0 || filePath === void 0 || status.length !== 1) return null;
39078
+ if (status === "A") {
39079
+ headFiles.add(filePath);
39080
+ continue;
39081
+ }
39082
+ if (status === "D") {
39083
+ baseFiles.add(filePath);
39084
+ continue;
39085
+ }
39086
+ if (status === "M" || status === "T") {
39087
+ baseFiles.add(filePath);
39088
+ headFiles.add(filePath);
39089
+ continue;
39090
+ }
39091
+ return null;
39092
+ }
39093
+ return {
39094
+ baseFiles: [...baseFiles],
39095
+ headFiles: [...headFiles],
39096
+ untrackedFiles: []
39097
+ };
39098
+ };
39071
39099
  const UNTRACKED_FILE_LAST_LINE = Number.MAX_SAFE_INTEGER;
39072
39100
  /**
39073
39101
  * `Git` wraps every `git`-via-subprocess call react-doctor makes
@@ -39270,6 +39298,45 @@ var Git = class Git extends Service()("react-doctor/Git") {
39270
39298
  githubViewerPermission,
39271
39299
  branchExists,
39272
39300
  mergeBase,
39301
+ baselineDiffPlan: (input) => {
39302
+ if (!isSafeGitRevision(input.ref)) return succeed$2(null);
39303
+ return gen(function* () {
39304
+ const unmerged = yield* runGit(input.directory, [
39305
+ "diff",
39306
+ "--no-ext-diff",
39307
+ "-z",
39308
+ "--name-only",
39309
+ "--diff-filter=U",
39310
+ "--relative"
39311
+ ]);
39312
+ if (unmerged.status !== 0 || unmerged.stdout.length > 0) return null;
39313
+ const result = yield* runGit(input.directory, [
39314
+ "diff",
39315
+ "--no-ext-diff",
39316
+ "--no-textconv",
39317
+ "--no-renames",
39318
+ "-z",
39319
+ "--name-status",
39320
+ "--relative",
39321
+ input.ref
39322
+ ]);
39323
+ if (result.status !== 0) return null;
39324
+ const plan = parseBaselineDiffPlan(result.stdout);
39325
+ if (plan === null) return null;
39326
+ const untracked = yield* runGit(input.directory, [
39327
+ "ls-files",
39328
+ "--others",
39329
+ "--exclude-standard",
39330
+ "-z"
39331
+ ]);
39332
+ if (untracked.status !== 0) return null;
39333
+ return {
39334
+ baseFiles: plan.baseFiles,
39335
+ headFiles: plan.headFiles,
39336
+ untrackedFiles: splitNullSeparated(untracked.stdout)
39337
+ };
39338
+ }).pipe(catch_$1(() => succeed$2(null)), withSpan("Git.baselineDiffPlan"));
39339
+ },
39273
39340
  diffSelection: ({ directory, explicitBaseBranch, includeUntracked = false }) => gen(function* () {
39274
39341
  if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) return yield* fail$4(new ReactDoctorError({ reason: new GitBaseBranchInvalid({ detail: "Diff base branch cannot be empty." }) }));
39275
39342
  if (explicitBaseBranch !== void 0) {
@@ -39419,6 +39486,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
39419
39486
  githubViewerPermission: () => succeed$2(snapshot.githubViewerPermission ?? null),
39420
39487
  branchExists: (_directory, branch) => succeed$2(snapshot.branchExists?.get(branch) ?? false),
39421
39488
  mergeBase: ({ ref }) => succeed$2(snapshot.mergeBase?.get(ref) ?? null),
39489
+ baselineDiffPlan: () => succeed$2(snapshot.baselineDiffPlan ?? null),
39422
39490
  diffSelection: () => succeed$2(snapshot.diffSelection ?? null),
39423
39491
  stagedFilePaths: () => succeed$2(snapshot.stagedFiles ?? []),
39424
39492
  showStagedContent: (_directory, relativePath) => succeed$2(snapshot.stagedContent?.get(relativePath) ?? null),
@@ -40756,12 +40824,17 @@ const REACT_ERROR_BOUNDARY_MESSAGE = "This try/catch cannot catch errors thrown
40756
40824
  const REACT_COMPILER_IMPACT = "This component misses React Compiler's automatic memoization & re-renders more than it should";
40757
40825
  const REACT_COMPILER_ACTION = "Rewrite the flagged code so the compiler can optimize it.";
40758
40826
  const REACT_COMPILER_INCOMPATIBLE_LIBRARY_ACTION = "It's how the library works, not a bug in your code. Memoize values you pass from it into other memoized components, or suppress it with `// react-doctor-disable-next-line react-hooks-js/incompatible-library`.";
40827
+ const SET_STATE_IN_EFFECT_ACTION = "Prefer deriving or initializing the value before render. If the effect must read a browser API after mount, treat this as advisory or suppress it with `// react-doctor-disable-next-line react-hooks-js/set-state-in-effect`.";
40759
40828
  const REACT_COMPILER_GENERIC_MESSAGE = `${REACT_COMPILER_IMPACT}. ${REACT_COMPILER_ACTION}`;
40760
40829
  const buildReactCompilerMessage = (reasonSummary, action = REACT_COMPILER_ACTION) => {
40761
40830
  const normalizedSummary = reasonSummary.replace(TRAILING_PERIOD_PATTERN, "");
40762
40831
  if (!normalizedSummary) return `${REACT_COMPILER_IMPACT}. ${action}`;
40763
40832
  return `${REACT_COMPILER_IMPACT}: ${normalizedSummary}. ${action}`;
40764
40833
  };
40834
+ const buildSetStateInEffectMessage = (reasonSummary) => {
40835
+ const normalizedSummary = reasonSummary.replace(TRAILING_PERIOD_PATTERN, "");
40836
+ return `This synchronous effect update causes an extra render${normalizedSummary ? `: ${normalizedSummary}` : ""}. ${SET_STATE_IN_EFFECT_ACTION}`;
40837
+ };
40765
40838
  const PLUGIN_CATEGORY_MAP = {
40766
40839
  react: "Bugs",
40767
40840
  "react-hooks": "Bugs",
@@ -40819,6 +40892,10 @@ const resolveCleanedDiagnostic = (message, help, plugin, rule, project) => {
40819
40892
  message: REACT_ERROR_BOUNDARY_MESSAGE,
40820
40893
  help: reasonDetail || help
40821
40894
  };
40895
+ if (rule === "set-state-in-effect") return {
40896
+ message: buildSetStateInEffectMessage(reasonSummary.trim()),
40897
+ help: reasonDetail || help
40898
+ };
40822
40899
  return {
40823
40900
  message: buildReactCompilerMessage(reasonSummary.trim(), rule === "incompatible-library" ? REACT_COMPILER_INCOMPATIBLE_LIBRARY_ACTION : REACT_COMPILER_ACTION),
40824
40901
  help: appendReanimatedSharedValueHint(reasonDetail || help, rule, project)
@@ -43144,12 +43221,19 @@ const isPathInsideDirectory = (childAbsolutePath, parentAbsolutePath) => {
43144
43221
  */
43145
43222
  const materializeSourceTree = (input) => gen(function* () {
43146
43223
  const materializedFiles = [];
43224
+ const unmaterializedFiles = [];
43147
43225
  const resolvedTempDirectory = path.resolve(input.tempDirectory);
43148
43226
  for (const relativePath of input.files) {
43149
43227
  const content = yield* input.readContent(relativePath).pipe(orElseSucceed(() => null));
43150
- if (content === null) continue;
43228
+ if (content === null) {
43229
+ unmaterializedFiles.push(relativePath);
43230
+ continue;
43231
+ }
43151
43232
  const candidateTargetPath = path.resolve(resolvedTempDirectory, relativePath);
43152
- if (!isPathInsideDirectory(candidateTargetPath, resolvedTempDirectory)) continue;
43233
+ if (!isPathInsideDirectory(candidateTargetPath, resolvedTempDirectory)) {
43234
+ unmaterializedFiles.push(relativePath);
43235
+ continue;
43236
+ }
43153
43237
  yield* sync(() => {
43154
43238
  fs.mkdirSync(path.dirname(candidateTargetPath), { recursive: true });
43155
43239
  fs.writeFileSync(candidateTargetPath, content);
@@ -43166,6 +43250,7 @@ const materializeSourceTree = (input) => gen(function* () {
43166
43250
  return {
43167
43251
  tempDirectory: input.tempDirectory,
43168
43252
  materializedFiles,
43253
+ unmaterializedFiles,
43169
43254
  cleanup: () => {
43170
43255
  try {
43171
43256
  fs.rmSync(input.tempDirectory, {
@@ -45371,5 +45456,5 @@ const startLanguageServer = () => {
45371
45456
  };
45372
45457
  //#endregion
45373
45458
  export { startLanguageServer };
45374
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="0f56accc-b639-5b90-a1a3-5faa727907dc")}catch(e){}}();
45375
- //# debugId=0f56accc-b639-5b90-a1a3-5faa727907dc
45459
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="2e6fca06-4d93-5b9e-86fd-92bfb63393e0")}catch(e){}}();
45460
+ //# debugId=2e6fca06-4d93-5b9e-86fd-92bfb63393e0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.8.1-dev.7818922",
3
+ "version": "0.8.1-dev.7c9dbed",
4
4
  "description": "Your agent writes bad React. This catches it",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -65,8 +65,8 @@
65
65
  "vscode-languageserver-textdocument": "^1.0.12",
66
66
  "vscode-uri": "^3.1.0",
67
67
  "yaml": "^2.9.0",
68
- "deslop-js": "0.8.1",
69
- "oxlint-plugin-react-doctor": "0.8.1-dev.7818922"
68
+ "oxlint-plugin-react-doctor": "0.8.1-dev.7c9dbed",
69
+ "deslop-js": "0.8.1"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/babel__code-frame": "^7.27.0",
@@ -75,8 +75,8 @@
75
75
  "commander": "^14.0.3",
76
76
  "ora": "^9.4.0",
77
77
  "@react-doctor/api": "0.8.1",
78
- "@react-doctor/language-server": "0.8.1",
79
- "@react-doctor/core": "0.8.1"
78
+ "@react-doctor/core": "0.8.1",
79
+ "@react-doctor/language-server": "0.8.1"
80
80
  },
81
81
  "engines": {
82
82
  "node": "^20.19.0 || >=22.13.0"