react-doctor 0.2.13-dev.adbec28 → 0.2.14-dev.3ceb748

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <picture>
2
2
  <source media="(prefers-color-scheme: dark)" srcset="./assets/react-doctor-readme-logo-dark.svg">
3
3
  <source media="(prefers-color-scheme: light)" srcset="./assets/react-doctor-readme-logo-light.svg">
4
- <img alt="React Doctor" src="./assets/react-doctor-readme-logo-light.svg" width="239" height="40">
4
+ <img alt="React Doctor" src="./assets/react-doctor-readme-logo-light.svg" width="134" height="36">
5
5
  </picture>
6
6
 
7
7
  [![version](https://img.shields.io/npm/v/react-doctor?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-doctor)
@@ -71,6 +71,17 @@ React Doctor scans the files changed in the pull request, emits inline annotatio
71
71
 
72
72
  [Add GitHub Action →](https://github.com/marketplace/actions/react-doctor)
73
73
 
74
+ ### 4. Configure rules in `react-doctor.config.json`
75
+
76
+ Point the `$schema` key at `https://react.doctor/schema/config.json` to get autocomplete, hover docs, and typo warnings for every option in any editor that understands JSON Schema.
77
+
78
+ ```jsonc
79
+ {
80
+ "$schema": "https://react.doctor/schema/config.json",
81
+ "lint": true,
82
+ }
83
+ ```
84
+
74
85
  ## Contributing
75
86
 
76
87
  [Issues welcome!](https://github.com/millionco/react-doctor/issues)
@@ -3189,6 +3189,7 @@ const isTailwindAtLeast = (detected, required) => {
3189
3189
  const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
3190
3190
  const MILLISECONDS_PER_SECOND = 1e3;
3191
3191
  const SCORE_API_URL = "https://www.react.doctor/api/score";
3192
+ const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
3192
3193
  const SHARE_BASE_URL = "https://www.react.doctor/share";
3193
3194
  const PROMPTS_RULES_BASE_URL = "https://www.react.doctor/prompts/rules";
3194
3195
  const FETCH_TIMEOUT_MS = 1e4;
@@ -5909,6 +5910,149 @@ const appendReanimatedSharedValueHint = (help, rule, project) => {
5909
5910
  if (!help) return REANIMATED_SHARED_VALUE_HINT;
5910
5911
  return `${help}\n\n${REANIMATED_SHARED_VALUE_HINT}`;
5911
5912
  };
5913
+ const REDACTED_PLACEHOLDER = "<redacted>";
5914
+ const KEEP_PREFIX = `$1${REDACTED_PLACEHOLDER}`;
5915
+ const KNOWN_SECRET_RULES = [
5916
+ {
5917
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
5918
+ replacement: REDACTED_PLACEHOLDER
5919
+ },
5920
+ {
5921
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
5922
+ replacement: REDACTED_PLACEHOLDER
5923
+ },
5924
+ {
5925
+ pattern: /(?<=:\/\/)[^\s/:@]+:[^\s/@]+(?=@)/g,
5926
+ replacement: REDACTED_PLACEHOLDER
5927
+ },
5928
+ {
5929
+ pattern: /\b(AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|A3T[A-Z0-9])[0-9A-Z]{16,}/g,
5930
+ replacement: KEEP_PREFIX
5931
+ },
5932
+ {
5933
+ pattern: /\b(gh[pousr]_)[A-Za-z0-9]{36,}/g,
5934
+ replacement: KEEP_PREFIX
5935
+ },
5936
+ {
5937
+ pattern: /\b(github_pat_)[A-Za-z0-9_]{22,}/g,
5938
+ replacement: KEEP_PREFIX
5939
+ },
5940
+ {
5941
+ pattern: /\b(glpat-)[A-Za-z0-9_-]{20,}/g,
5942
+ replacement: KEEP_PREFIX
5943
+ },
5944
+ {
5945
+ pattern: /\b(xox[baprs]-)[A-Za-z0-9-]{10,}/g,
5946
+ replacement: KEEP_PREFIX
5947
+ },
5948
+ {
5949
+ pattern: /(?<=hooks\.slack\.com\/services\/)[A-Za-z0-9/+_-]{20,}/g,
5950
+ replacement: REDACTED_PLACEHOLDER
5951
+ },
5952
+ {
5953
+ pattern: /\b((?:sk|rk)_(?:live|test)_)[0-9A-Za-z]{10,}/g,
5954
+ replacement: KEEP_PREFIX
5955
+ },
5956
+ {
5957
+ pattern: /\b(sk-(?:proj-|ant-)?)[A-Za-z0-9_-]{20,}/g,
5958
+ replacement: KEEP_PREFIX
5959
+ },
5960
+ {
5961
+ pattern: /\b(AIza)[0-9A-Za-z_-]{35,}/g,
5962
+ replacement: KEEP_PREFIX
5963
+ },
5964
+ {
5965
+ pattern: /\b(ya29\.)[0-9A-Za-z_-]{20,}/g,
5966
+ replacement: KEEP_PREFIX
5967
+ },
5968
+ {
5969
+ pattern: /\b(npm_)[A-Za-z0-9]{36,}/g,
5970
+ replacement: KEEP_PREFIX
5971
+ },
5972
+ {
5973
+ pattern: /\b(SG\.)[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{43,}/g,
5974
+ replacement: KEEP_PREFIX
5975
+ },
5976
+ {
5977
+ pattern: /\b(SK)[0-9a-fA-F]{32,}/g,
5978
+ replacement: KEEP_PREFIX
5979
+ },
5980
+ {
5981
+ pattern: /\b(dop_v1_)[a-f0-9]{64,}/g,
5982
+ replacement: KEEP_PREFIX
5983
+ },
5984
+ {
5985
+ pattern: /\b(shp(?:at|ca|pa|ss)_)[a-fA-F0-9]{32,}/g,
5986
+ replacement: KEEP_PREFIX
5987
+ },
5988
+ {
5989
+ pattern: /\b(sq0[a-z]{3}-)[0-9A-Za-z_-]{22,}/g,
5990
+ replacement: KEEP_PREFIX
5991
+ },
5992
+ {
5993
+ pattern: /\b([0-9]{8,10}:AA)[0-9A-Za-z_-]{32,}/g,
5994
+ replacement: KEEP_PREFIX
5995
+ },
5996
+ {
5997
+ pattern: /(?<=\bBearer\s)[A-Za-z0-9._~+/=-]{16,}/g,
5998
+ replacement: REDACTED_PLACEHOLDER
5999
+ },
6000
+ {
6001
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
6002
+ replacement: REDACTED_PLACEHOLDER
6003
+ }
6004
+ ];
6005
+ const CANDIDATE_TOKEN_PATTERN = /[A-Za-z0-9_][A-Za-z0-9_-]*/g;
6006
+ const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6007
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6008
+ const HAS_LETTER_PATTERN = /[A-Za-z]/;
6009
+ const HAS_DIGIT_PATTERN = /[0-9]/;
6010
+ const shannonEntropyBits = (value) => {
6011
+ const counts = /* @__PURE__ */ new Map();
6012
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
6013
+ let bits = 0;
6014
+ for (const count of counts.values()) {
6015
+ const probability = count / value.length;
6016
+ bits -= probability * Math.log2(probability);
6017
+ }
6018
+ return bits;
6019
+ };
6020
+ const looksLikeHighEntropySecret = (token) => {
6021
+ if (token.length < 32) return false;
6022
+ if (!HAS_LETTER_PATTERN.test(token) || !HAS_DIGIT_PATTERN.test(token)) return false;
6023
+ if (GIT_OBJECT_ID_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
6024
+ return shannonEntropyBits(token) >= 3;
6025
+ };
6026
+ const redactHighEntropyTokens = (text) => text.replace(CANDIDATE_TOKEN_PATTERN, (token) => looksLikeHighEntropySecret(token) ? REDACTED_PLACEHOLDER : token);
6027
+ /**
6028
+ * Masks API keys, tokens, private keys, credentialed URLs, and emails
6029
+ * found anywhere inside a free-text string, returning the scrubbed text.
6030
+ * Applied to every diagnostic's `message` / `help` at construction time
6031
+ * so secrets never reach the terminal, the JSON report, or the score
6032
+ * API — react-doctor must never echo or transmit a user's secrets.
6033
+ *
6034
+ * Provider tokens keep their non-secret, type-identifying prefix (e.g.
6035
+ * `sk_live_<redacted>`, `ghp_<redacted>`, `AKIA<redacted>`) so the leaked
6036
+ * credential's type stays visible; structural or unknown-format secrets
6037
+ * with no meaningful prefix are masked whole.
6038
+ *
6039
+ * Runs the high-precision known-shape detectors first, then a generic
6040
+ * entropy-gated sweep for unknown-format secrets. Idempotent: the inert
6041
+ * `<redacted>` placeholder matches none of the detectors and is too
6042
+ * short for the generic sweep, so re-running leaves the text unchanged.
6043
+ *
6044
+ * Accepts `unknown` on purpose: callers feed it diagnostic `message` /
6045
+ * `help` that originate from oxlint JSON, which is only shape-checked at
6046
+ * the top level (the per-field `string` types are assumed, not validated).
6047
+ * A malformed non-string value returns `""` instead of throwing on
6048
+ * `.replace`, so one bad diagnostic can't abort parsing the whole batch.
6049
+ */
6050
+ const redactSensitiveText = (text) => {
6051
+ if (typeof text !== "string" || text === "") return "";
6052
+ let redacted = text;
6053
+ for (const rule of KNOWN_SECRET_RULES) redacted = redacted.replace(rule.pattern, rule.replacement);
6054
+ return redactHighEntropyTokens(redacted);
6055
+ };
5912
6056
  const REACT_MODULE_SOURCE = "react";
5913
6057
  const REQUIRE_IDENTIFIER = "require";
5914
6058
  const USE_IDENTIFIER = "use";
@@ -6230,6 +6374,13 @@ const getRuleRecommendation = (ruleName, project) => {
6230
6374
  };
6231
6375
  const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
6232
6376
  const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
6377
+ const cleaned = resolveCleanedDiagnostic(typeof message === "string" ? message : "", typeof help === "string" ? help : "", plugin, rule, project);
6378
+ return {
6379
+ message: redactSensitiveText(cleaned.message),
6380
+ help: redactSensitiveText(cleaned.help)
6381
+ };
6382
+ };
6383
+ const resolveCleanedDiagnostic = (message, help, plugin, rule, project) => {
6233
6384
  if (plugin === "react-hooks-js") return {
6234
6385
  message: REACT_COMPILER_MESSAGE,
6235
6386
  help: appendReanimatedSharedValueHint(message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help, rule, project)
@@ -6434,11 +6585,14 @@ const spawnLintBatches = async (input) => {
6434
6585
  onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
6435
6586
  }
6436
6587
  }, 50) : null;
6437
- const batchDiagnostics = await spawnLintBatch(batch);
6438
- if (progressInterval !== null) clearInterval(progressInterval);
6439
- allDiagnostics.push(...batchDiagnostics);
6440
- scannedFileCount += batch.length;
6441
- onFileProgress?.(scannedFileCount, totalFileCount);
6588
+ try {
6589
+ const batchDiagnostics = await spawnLintBatch(batch);
6590
+ allDiagnostics.push(...batchDiagnostics);
6591
+ scannedFileCount += batch.length;
6592
+ onFileProgress?.(scannedFileCount, totalFileCount);
6593
+ } finally {
6594
+ if (progressInterval !== null) clearInterval(progressInterval);
6595
+ }
6442
6596
  }
6443
6597
  if (droppedFiles.length > 0 && onPartialFailure) {
6444
6598
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6787,17 +6941,21 @@ var Reporter = class Reporter extends Context.Service()("react-doctor/Reporter")
6787
6941
  });
6788
6942
  }));
6789
6943
  };
6790
- const parseScoreResult = (value) => {
6791
- if (typeof value !== "object" || value === null) return null;
6792
- if (!("score" in value) || !("label" in value)) return null;
6793
- const scoreValue = Reflect.get(value, "score");
6794
- const labelValue = Reflect.get(value, "label");
6795
- if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
6796
- return {
6797
- score: scoreValue,
6798
- label: labelValue
6799
- };
6800
- };
6944
+ const RulePrioritySchema = Schema.Struct({
6945
+ priority: Schema.NullOr(Schema.Number),
6946
+ tier: Schema.Literals([
6947
+ "P0",
6948
+ "P1",
6949
+ "P2",
6950
+ "P3"
6951
+ ])
6952
+ });
6953
+ const ScoreApiResponseSchema = Schema.Struct({
6954
+ score: Schema.Number,
6955
+ label: Schema.String,
6956
+ rules: Schema.optional(Schema.Record(Schema.String, RulePrioritySchema))
6957
+ });
6958
+ const parseScoreResult = (value) => Option.getOrNull(Schema.decodeUnknownOption(ScoreApiResponseSchema)(value));
6801
6959
  const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
6802
6960
  const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
6803
6961
  const describeFailure = (error) => {
@@ -7559,6 +7717,6 @@ const cliLogger = {
7559
7717
  }
7560
7718
  };
7561
7719
  //#endregion
7562
- export { isMonorepoRoot as A, filterDiagnosticsForSurface as C, getDiffInfo as D, formatReactDoctorError as E, restoreLegacyThrow as F, runInspect as I, toRelativePath as L, layerOtlp as M, listWorkspacePackages as N, groupBy as O, resolveScanTarget as P, discoverReactSubprojects as S, formatErrorChain as T, Score as _, DeadCode as a, buildJsonReportError as b, LintPartialFailures as c, OXLINT_NODE_REQUIREMENT as d, Progress as f, SKILL_NAME as g, SHARE_BASE_URL as h, Config as i, isReactDoctorError as j, highlighter as k, Linter as l, Reporter as m, cli_logger_exports as n, Files as o, Project as p, CANONICAL_GITHUB_URL as r, Git as s, cliLogger as t, NodeResolver as u, StagedFiles as v, filterSourceFiles as w, buildRulePromptUrl as x, buildJsonReport as y };
7720
+ export { highlighter as A, discoverReactSubprojects as C, formatReactDoctorError as D, formatErrorChain as E, resolveScanTarget as F, restoreLegacyThrow as I, runInspect as L, isReactDoctorError as M, layerOtlp as N, getDiffInfo as O, listWorkspacePackages as P, toRelativePath as R, buildRulePromptUrl as S, filterSourceFiles as T, SKILL_NAME as _, DeadCode as a, buildJsonReport as b, Git as c, NodeResolver as d, OXLINT_NODE_REQUIREMENT as f, SHARE_BASE_URL as g, Reporter as h, Config as i, isMonorepoRoot as j, groupBy as k, LintPartialFailures as l, Project as m, cli_logger_exports as n, ENTERPRISE_CONTACT_URL as o, Progress as p, CANONICAL_GITHUB_URL as r, Files as s, cliLogger as t, Linter as u, Score as v, filterDiagnosticsForSurface as w, buildJsonReportError as x, StagedFiles as y };
7563
7721
 
7564
- //# sourceMappingURL=cli-logger-CSZagq1E.js.map
7722
+ //# sourceMappingURL=cli-logger-BgVL1vBI.js.map
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./rolldown-runtime-uZX_iqCz.js";
2
- import { A as isMonorepoRoot, C as filterDiagnosticsForSurface, D as getDiffInfo, E as formatReactDoctorError, F as restoreLegacyThrow, I as runInspect, L as toRelativePath, M as layerOtlp, N as listWorkspacePackages, O as groupBy, P as resolveScanTarget, S as discoverReactSubprojects, T as formatErrorChain, _ as Score, a as DeadCode, b as buildJsonReportError, c as LintPartialFailures, d as OXLINT_NODE_REQUIREMENT, f as Progress, g as SKILL_NAME, h as SHARE_BASE_URL, i as Config, j as isReactDoctorError, k as highlighter, l as Linter, m as Reporter, o as Files, p as Project, r as CANONICAL_GITHUB_URL, s as Git, t as cliLogger, u as NodeResolver, v as StagedFiles, w as filterSourceFiles, x as buildRulePromptUrl, y as buildJsonReport } from "./cli-logger-CSZagq1E.js";
2
+ import { A as highlighter, C as discoverReactSubprojects, D as formatReactDoctorError, E as formatErrorChain, F as resolveScanTarget, I as restoreLegacyThrow, L as runInspect, M as isReactDoctorError, N as layerOtlp, O as getDiffInfo, P as listWorkspacePackages, R as toRelativePath, S as buildRulePromptUrl, T as filterSourceFiles, _ as SKILL_NAME, a as DeadCode, b as buildJsonReport, c as Git, d as NodeResolver, f as OXLINT_NODE_REQUIREMENT, g as SHARE_BASE_URL, h as Reporter, i as Config, j as isMonorepoRoot, k as groupBy, l as LintPartialFailures, m as Project, o as ENTERPRISE_CONTACT_URL, p as Progress, r as CANONICAL_GITHUB_URL, s as Files, t as cliLogger, u as Linter, v as Score, w as filterDiagnosticsForSurface, x as buildJsonReportError, y as StagedFiles } from "./cli-logger-BgVL1vBI.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { execFileSync, execSync } from "node:child_process";
5
5
  import path, { join } from "node:path";
@@ -6161,6 +6161,12 @@ const makeNoopConsole = () => ({
6161
6161
  warn: () => {}
6162
6162
  });
6163
6163
  //#endregion
6164
+ //#region src/cli/utils/build-no-score-message.ts
6165
+ const ENTERPRISE_CONTACT_HINT = `Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`;
6166
+ const buildNoScoreMessage = (isScoreDisabled) => {
6167
+ return `${isScoreDisabled ? "Score disabled by --no-score." : "Score unavailable (could not reach the score API)."} ${ENTERPRISE_CONTACT_HINT}`;
6168
+ };
6169
+ //#endregion
6164
6170
  //#region src/cli/utils/render-agent-guidance.ts
6165
6171
  const AGENT_GUIDANCE_LINES = [
6166
6172
  "Treat React Doctor diagnostics as starting hypotheses. Read the relevant code before confirming or suppressing each finding.",
@@ -6192,7 +6198,22 @@ const SEVERITY_ORDER = {
6192
6198
  warning: 1
6193
6199
  };
6194
6200
  const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
6195
- const sortByImportance = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
6201
+ const buildRulePriorityMap = (scores) => {
6202
+ const rulePriority = /* @__PURE__ */ new Map();
6203
+ for (const score of scores) {
6204
+ if (!score?.rules) continue;
6205
+ for (const [ruleKey, info] of Object.entries(score.rules)) if (typeof info.priority === "number") rulePriority.set(ruleKey, info.priority);
6206
+ }
6207
+ return rulePriority;
6208
+ };
6209
+ const effectivePriority = (ruleKey, diagnostics, rulePriority) => {
6210
+ const known = rulePriority?.get(ruleKey);
6211
+ if (known !== void 0) return known;
6212
+ return diagnostics[0].severity === "error" ? 55 : 35;
6213
+ };
6214
+ const sortByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA, diagnosticsA], [ruleKeyB, diagnosticsB]) => {
6215
+ const priorityDelta = effectivePriority(ruleKeyB, diagnosticsB, rulePriority) - effectivePriority(ruleKeyA, diagnosticsA, rulePriority);
6216
+ if (priorityDelta !== 0) return priorityDelta;
6196
6217
  const severityDelta = SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
6197
6218
  if (severityDelta !== 0) return severityDelta;
6198
6219
  return diagnosticsB.length - diagnosticsA.length;
@@ -6229,14 +6250,20 @@ const buildCompactRuleGroupLine = (ruleKey, ruleDiagnostics, ruleNameColumnWidth
6229
6250
  return ` ${icon} ${siteCountBadge.length > 0 ? colorizeBySeverity(padRuleNameToColumn(ruleKey, ruleNameColumnWidth), firstDiagnostic.severity) : colorizeBySeverity(ruleKey, firstDiagnostic.severity)}${siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : ""}`;
6230
6251
  };
6231
6252
  const getWorstSeverity = (diagnostics) => diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "error" : "warning";
6232
- const buildCategoryDiagnosticGroups = (diagnostics) => {
6253
+ const categoryTopPriority = (categoryGroup, rulePriority) => {
6254
+ const [topRuleKey, topDiagnostics] = categoryGroup.ruleGroups[0];
6255
+ return effectivePriority(topRuleKey, topDiagnostics, rulePriority);
6256
+ };
6257
+ const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
6233
6258
  return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
6234
6259
  return {
6235
6260
  category,
6236
6261
  diagnostics: categoryDiagnostics,
6237
- ruleGroups: sortByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()])
6262
+ ruleGroups: sortByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority)
6238
6263
  };
6239
6264
  }).toSorted((categoryGroupA, categoryGroupB) => {
6265
+ const priorityDelta = categoryTopPriority(categoryGroupB, rulePriority) - categoryTopPriority(categoryGroupA, rulePriority);
6266
+ if (priorityDelta !== 0) return priorityDelta;
6240
6267
  const severityDelta = SEVERITY_ORDER[getWorstSeverity(categoryGroupA.diagnostics)] - SEVERITY_ORDER[getWorstSeverity(categoryGroupB.diagnostics)];
6241
6268
  if (severityDelta !== 0) return severityDelta;
6242
6269
  if (categoryGroupA.diagnostics.length !== categoryGroupB.diagnostics.length) return categoryGroupB.diagnostics.length - categoryGroupA.diagnostics.length;
@@ -6267,8 +6294,8 @@ const buildVerboseRuleGroupLines = (ruleKey, ruleDiagnostics, ruleNameColumnWidt
6267
6294
  lines.push("");
6268
6295
  return lines;
6269
6296
  };
6270
- const buildDefaultDiagnosticsLines = (diagnostics) => {
6271
- const categoryGroups = buildCategoryDiagnosticGroups(diagnostics);
6297
+ const buildDefaultDiagnosticsLines = (diagnostics, rulePriority) => {
6298
+ const categoryGroups = buildCategoryDiagnosticGroups(diagnostics, rulePriority);
6272
6299
  const lines = [];
6273
6300
  for (const categoryGroup of categoryGroups) lines.push(buildCompactCategoryLine(categoryGroup));
6274
6301
  lines.push("");
@@ -6280,11 +6307,11 @@ const buildDefaultDiagnosticsLines = (diagnostics) => {
6280
6307
  * single Effect.forEach over Console.log so failures or fiber
6281
6308
  * interruption produce predictable partial output.
6282
6309
  */
6283
- const printDiagnostics = (diagnostics, isVerbose, rootDirectory) => Effect.gen(function* () {
6310
+ const printDiagnostics = (diagnostics, isVerbose, rootDirectory, rulePriority) => Effect.gen(function* () {
6284
6311
  let lines;
6285
- if (!isVerbose) lines = buildDefaultDiagnosticsLines(diagnostics);
6312
+ if (!isVerbose) lines = buildDefaultDiagnosticsLines(diagnostics, rulePriority);
6286
6313
  else {
6287
- const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
6314
+ const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority);
6288
6315
  const ruleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.map(([ruleKey]) => ruleKey));
6289
6316
  lines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => buildVerboseRuleGroupLines(ruleKey, ruleDiagnostics, ruleNameColumnWidth));
6290
6317
  }
@@ -6580,6 +6607,7 @@ const shouldSelectAllChoices = (choiceStates) => {
6580
6607
  //#endregion
6581
6608
  //#region src/cli/utils/unref-stdin.ts
6582
6609
  const unrefStdin = () => {
6610
+ if (process.stdin.isTTY) return;
6583
6611
  process.stdin.unref?.();
6584
6612
  };
6585
6613
  //#endregion
@@ -6679,7 +6707,7 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
6679
6707
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
6680
6708
  //#endregion
6681
6709
  //#region src/cli/utils/version.ts
6682
- const VERSION = "0.2.13-dev.adbec28";
6710
+ const VERSION = "0.2.14-dev.3ceb748";
6683
6711
  //#endregion
6684
6712
  //#region src/inspect.ts
6685
6713
  const silentConsole = makeNoopConsole();
@@ -6803,7 +6831,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6803
6831
  if (didLintFail) skippedChecks.push("lint");
6804
6832
  if (didDeadCodeFail) skippedChecks.push("dead-code");
6805
6833
  const hasSkippedChecks = skippedChecks.length > 0;
6806
- const noScoreMessage = options.noScore ? "Score disabled by --no-score." : "Score unavailable (could not reach the score API).";
6834
+ const noScoreMessage = buildNoScoreMessage(options.noScore);
6807
6835
  const skippedCheckReasons = {};
6808
6836
  if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
6809
6837
  else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
@@ -6840,7 +6868,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6840
6868
  return buildResult();
6841
6869
  }
6842
6870
  yield* Console.log("");
6843
- yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory);
6871
+ yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
6844
6872
  if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
6845
6873
  if (demotedDiagnosticCount > 0) {
6846
6874
  yield* Console.log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
@@ -7229,7 +7257,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
7229
7257
  const surfaceDiagnostics = filterDiagnosticsForSurface(completedScans.flatMap((scan) => scan.result.diagnostics), "cli", userConfig);
7230
7258
  if (surfaceDiagnostics.length > 0) {
7231
7259
  yield* Console.log("");
7232
- yield* printDiagnostics(surfaceDiagnostics, verbose, "");
7260
+ yield* printDiagnostics(surfaceDiagnostics, verbose, "", buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
7233
7261
  }
7234
7262
  const aggregateScore = computeAggregateScore(completedScans);
7235
7263
  const totalSourceFileCount = completedScans.reduce((sum, scan) => sum + scan.result.project.sourceFileCount, 0);
@@ -7500,7 +7528,7 @@ const warnSetupPromptFailure = async (options, error) => {
7500
7528
  return;
7501
7529
  }
7502
7530
  try {
7503
- const { cliLogger } = await import("./cli-logger-CSZagq1E.js").then((n) => n.n);
7531
+ const { cliLogger } = await import("./cli-logger-BgVL1vBI.js").then((n) => n.n);
7504
7532
  cliLogger.warn(message);
7505
7533
  } catch {}
7506
7534
  };
package/dist/index.d.ts CHANGED
@@ -20,9 +20,9 @@ interface ReactDoctorIgnoreConfig {
20
20
  * locally but excluded from PR comments, the score, or the CI gate:
21
21
  *
22
22
  * - `cli` — local terminal output from `react-doctor` (`printDiagnostics`).
23
- * - `prComment` — output captured by the GitHub Action for the sticky
24
- * PR comment. Enabled when the CLI is run with `--pr-comment` (the
25
- * action sets this automatically when `github-token` is provided).
23
+ * - `prComment` — diagnostics destined for a sticky pull-request
24
+ * summary comment. Selected by running the CLI with `--pr-comment`
25
+ * (sets `outputSurface: "prComment"`).
26
26
  * - `score` — diagnostics shipped to the React Doctor score API
27
27
  * (or counted toward local score calculations).
28
28
  * - `ciFailure` — diagnostics that count toward the `--fail-on` exit
@@ -349,9 +349,15 @@ interface ProjectInfo {
349
349
  }
350
350
  //#endregion
351
351
  //#region src/types/score.d.ts
352
+ type RuleTier = "P0" | "P1" | "P2" | "P3";
353
+ interface RulePriority {
354
+ readonly priority: number | null;
355
+ readonly tier: RuleTier;
356
+ }
352
357
  interface ScoreResult {
353
358
  score: number;
354
359
  label: string;
360
+ readonly rules?: Readonly<Record<string, RulePriority>>;
355
361
  } //#endregion
356
362
  //#region src/types/diagnose.d.ts
357
363
  interface DiagnoseOptions {
package/dist/index.js CHANGED
@@ -5939,6 +5939,149 @@ const appendReanimatedSharedValueHint = (help, rule, project) => {
5939
5939
  if (!help) return REANIMATED_SHARED_VALUE_HINT;
5940
5940
  return `${help}\n\n${REANIMATED_SHARED_VALUE_HINT}`;
5941
5941
  };
5942
+ const REDACTED_PLACEHOLDER = "<redacted>";
5943
+ const KEEP_PREFIX = `$1${REDACTED_PLACEHOLDER}`;
5944
+ const KNOWN_SECRET_RULES = [
5945
+ {
5946
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
5947
+ replacement: REDACTED_PLACEHOLDER
5948
+ },
5949
+ {
5950
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
5951
+ replacement: REDACTED_PLACEHOLDER
5952
+ },
5953
+ {
5954
+ pattern: /(?<=:\/\/)[^\s/:@]+:[^\s/@]+(?=@)/g,
5955
+ replacement: REDACTED_PLACEHOLDER
5956
+ },
5957
+ {
5958
+ pattern: /\b(AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|A3T[A-Z0-9])[0-9A-Z]{16,}/g,
5959
+ replacement: KEEP_PREFIX
5960
+ },
5961
+ {
5962
+ pattern: /\b(gh[pousr]_)[A-Za-z0-9]{36,}/g,
5963
+ replacement: KEEP_PREFIX
5964
+ },
5965
+ {
5966
+ pattern: /\b(github_pat_)[A-Za-z0-9_]{22,}/g,
5967
+ replacement: KEEP_PREFIX
5968
+ },
5969
+ {
5970
+ pattern: /\b(glpat-)[A-Za-z0-9_-]{20,}/g,
5971
+ replacement: KEEP_PREFIX
5972
+ },
5973
+ {
5974
+ pattern: /\b(xox[baprs]-)[A-Za-z0-9-]{10,}/g,
5975
+ replacement: KEEP_PREFIX
5976
+ },
5977
+ {
5978
+ pattern: /(?<=hooks\.slack\.com\/services\/)[A-Za-z0-9/+_-]{20,}/g,
5979
+ replacement: REDACTED_PLACEHOLDER
5980
+ },
5981
+ {
5982
+ pattern: /\b((?:sk|rk)_(?:live|test)_)[0-9A-Za-z]{10,}/g,
5983
+ replacement: KEEP_PREFIX
5984
+ },
5985
+ {
5986
+ pattern: /\b(sk-(?:proj-|ant-)?)[A-Za-z0-9_-]{20,}/g,
5987
+ replacement: KEEP_PREFIX
5988
+ },
5989
+ {
5990
+ pattern: /\b(AIza)[0-9A-Za-z_-]{35,}/g,
5991
+ replacement: KEEP_PREFIX
5992
+ },
5993
+ {
5994
+ pattern: /\b(ya29\.)[0-9A-Za-z_-]{20,}/g,
5995
+ replacement: KEEP_PREFIX
5996
+ },
5997
+ {
5998
+ pattern: /\b(npm_)[A-Za-z0-9]{36,}/g,
5999
+ replacement: KEEP_PREFIX
6000
+ },
6001
+ {
6002
+ pattern: /\b(SG\.)[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{43,}/g,
6003
+ replacement: KEEP_PREFIX
6004
+ },
6005
+ {
6006
+ pattern: /\b(SK)[0-9a-fA-F]{32,}/g,
6007
+ replacement: KEEP_PREFIX
6008
+ },
6009
+ {
6010
+ pattern: /\b(dop_v1_)[a-f0-9]{64,}/g,
6011
+ replacement: KEEP_PREFIX
6012
+ },
6013
+ {
6014
+ pattern: /\b(shp(?:at|ca|pa|ss)_)[a-fA-F0-9]{32,}/g,
6015
+ replacement: KEEP_PREFIX
6016
+ },
6017
+ {
6018
+ pattern: /\b(sq0[a-z]{3}-)[0-9A-Za-z_-]{22,}/g,
6019
+ replacement: KEEP_PREFIX
6020
+ },
6021
+ {
6022
+ pattern: /\b([0-9]{8,10}:AA)[0-9A-Za-z_-]{32,}/g,
6023
+ replacement: KEEP_PREFIX
6024
+ },
6025
+ {
6026
+ pattern: /(?<=\bBearer\s)[A-Za-z0-9._~+/=-]{16,}/g,
6027
+ replacement: REDACTED_PLACEHOLDER
6028
+ },
6029
+ {
6030
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
6031
+ replacement: REDACTED_PLACEHOLDER
6032
+ }
6033
+ ];
6034
+ const CANDIDATE_TOKEN_PATTERN = /[A-Za-z0-9_][A-Za-z0-9_-]*/g;
6035
+ const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6036
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6037
+ const HAS_LETTER_PATTERN = /[A-Za-z]/;
6038
+ const HAS_DIGIT_PATTERN = /[0-9]/;
6039
+ const shannonEntropyBits = (value) => {
6040
+ const counts = /* @__PURE__ */ new Map();
6041
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
6042
+ let bits = 0;
6043
+ for (const count of counts.values()) {
6044
+ const probability = count / value.length;
6045
+ bits -= probability * Math.log2(probability);
6046
+ }
6047
+ return bits;
6048
+ };
6049
+ const looksLikeHighEntropySecret = (token) => {
6050
+ if (token.length < 32) return false;
6051
+ if (!HAS_LETTER_PATTERN.test(token) || !HAS_DIGIT_PATTERN.test(token)) return false;
6052
+ if (GIT_OBJECT_ID_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
6053
+ return shannonEntropyBits(token) >= 3;
6054
+ };
6055
+ const redactHighEntropyTokens = (text) => text.replace(CANDIDATE_TOKEN_PATTERN, (token) => looksLikeHighEntropySecret(token) ? REDACTED_PLACEHOLDER : token);
6056
+ /**
6057
+ * Masks API keys, tokens, private keys, credentialed URLs, and emails
6058
+ * found anywhere inside a free-text string, returning the scrubbed text.
6059
+ * Applied to every diagnostic's `message` / `help` at construction time
6060
+ * so secrets never reach the terminal, the JSON report, or the score
6061
+ * API — react-doctor must never echo or transmit a user's secrets.
6062
+ *
6063
+ * Provider tokens keep their non-secret, type-identifying prefix (e.g.
6064
+ * `sk_live_<redacted>`, `ghp_<redacted>`, `AKIA<redacted>`) so the leaked
6065
+ * credential's type stays visible; structural or unknown-format secrets
6066
+ * with no meaningful prefix are masked whole.
6067
+ *
6068
+ * Runs the high-precision known-shape detectors first, then a generic
6069
+ * entropy-gated sweep for unknown-format secrets. Idempotent: the inert
6070
+ * `<redacted>` placeholder matches none of the detectors and is too
6071
+ * short for the generic sweep, so re-running leaves the text unchanged.
6072
+ *
6073
+ * Accepts `unknown` on purpose: callers feed it diagnostic `message` /
6074
+ * `help` that originate from oxlint JSON, which is only shape-checked at
6075
+ * the top level (the per-field `string` types are assumed, not validated).
6076
+ * A malformed non-string value returns `""` instead of throwing on
6077
+ * `.replace`, so one bad diagnostic can't abort parsing the whole batch.
6078
+ */
6079
+ const redactSensitiveText = (text) => {
6080
+ if (typeof text !== "string" || text === "") return "";
6081
+ let redacted = text;
6082
+ for (const rule of KNOWN_SECRET_RULES) redacted = redacted.replace(rule.pattern, rule.replacement);
6083
+ return redactHighEntropyTokens(redacted);
6084
+ };
5942
6085
  const REACT_MODULE_SOURCE = "react";
5943
6086
  const REQUIRE_IDENTIFIER = "require";
5944
6087
  const USE_IDENTIFIER = "use";
@@ -6260,6 +6403,13 @@ const getRuleRecommendation = (ruleName, project) => {
6260
6403
  };
6261
6404
  const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
6262
6405
  const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
6406
+ const cleaned = resolveCleanedDiagnostic(typeof message === "string" ? message : "", typeof help === "string" ? help : "", plugin, rule, project);
6407
+ return {
6408
+ message: redactSensitiveText(cleaned.message),
6409
+ help: redactSensitiveText(cleaned.help)
6410
+ };
6411
+ };
6412
+ const resolveCleanedDiagnostic = (message, help, plugin, rule, project) => {
6263
6413
  if (plugin === "react-hooks-js") return {
6264
6414
  message: REACT_COMPILER_MESSAGE,
6265
6415
  help: appendReanimatedSharedValueHint(message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help, rule, project)
@@ -6464,11 +6614,14 @@ const spawnLintBatches = async (input) => {
6464
6614
  onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
6465
6615
  }
6466
6616
  }, 50) : null;
6467
- const batchDiagnostics = await spawnLintBatch(batch);
6468
- if (progressInterval !== null) clearInterval(progressInterval);
6469
- allDiagnostics.push(...batchDiagnostics);
6470
- scannedFileCount += batch.length;
6471
- onFileProgress?.(scannedFileCount, totalFileCount);
6617
+ try {
6618
+ const batchDiagnostics = await spawnLintBatch(batch);
6619
+ allDiagnostics.push(...batchDiagnostics);
6620
+ scannedFileCount += batch.length;
6621
+ onFileProgress?.(scannedFileCount, totalFileCount);
6622
+ } finally {
6623
+ if (progressInterval !== null) clearInterval(progressInterval);
6624
+ }
6472
6625
  }
6473
6626
  if (droppedFiles.length > 0 && onPartialFailure) {
6474
6627
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6817,17 +6970,21 @@ var Reporter = class Reporter extends Context.Service()("react-doctor/Reporter")
6817
6970
  });
6818
6971
  }));
6819
6972
  };
6820
- const parseScoreResult = (value) => {
6821
- if (typeof value !== "object" || value === null) return null;
6822
- if (!("score" in value) || !("label" in value)) return null;
6823
- const scoreValue = Reflect.get(value, "score");
6824
- const labelValue = Reflect.get(value, "label");
6825
- if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
6826
- return {
6827
- score: scoreValue,
6828
- label: labelValue
6829
- };
6830
- };
6973
+ const RulePrioritySchema = Schema.Struct({
6974
+ priority: Schema.NullOr(Schema.Number),
6975
+ tier: Schema.Literals([
6976
+ "P0",
6977
+ "P1",
6978
+ "P2",
6979
+ "P3"
6980
+ ])
6981
+ });
6982
+ const ScoreApiResponseSchema = Schema.Struct({
6983
+ score: Schema.Number,
6984
+ label: Schema.String,
6985
+ rules: Schema.optional(Schema.Record(Schema.String, RulePrioritySchema))
6986
+ });
6987
+ const parseScoreResult = (value) => Option.getOrNull(Schema.decodeUnknownOption(ScoreApiResponseSchema)(value));
6831
6988
  const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
6832
6989
  const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
6833
6990
  const describeFailure = (error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.2.13-dev.adbec28",
3
+ "version": "0.2.14-dev.3ceb748",
4
4
  "description": "Diagnose and fix React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -52,20 +52,20 @@
52
52
  "@effect/platform-node-shared": "4.0.0-beta.70",
53
53
  "agent-install": "0.0.5",
54
54
  "conf": "^15.1.0",
55
- "deslop-js": "^0.0.13",
55
+ "deslop-js": "^0.0.14",
56
56
  "effect": "4.0.0-beta.70",
57
57
  "eslint-plugin-react-hooks": "^7.1.1",
58
58
  "oxlint": "^1.66.0",
59
59
  "prompts": "^2.4.2",
60
60
  "typescript": ">=5.0.4 <7",
61
- "oxlint-plugin-react-doctor": "0.2.13-dev.adbec28"
61
+ "oxlint-plugin-react-doctor": "0.2.14-dev.3ceb748"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@types/prompts": "^2.4.9",
65
65
  "commander": "^14.0.3",
66
66
  "ora": "^9.4.0",
67
- "@react-doctor/api": "0.2.13",
68
- "@react-doctor/core": "0.2.13"
67
+ "@react-doctor/api": "0.2.14",
68
+ "@react-doctor/core": "0.2.14"
69
69
  },
70
70
  "engines": {
71
71
  "node": "^20.19.0 || >=22.12.0"