react-doctor 0.0.36 → 0.0.39

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
@@ -69,6 +69,8 @@ Supports Claude Code, Codex, GitHub Copilot, Gemini CLI, Cursor, OpenCode, Facto
69
69
  | `project` | | Workspace project(s) to scan (comma-separated) |
70
70
  | `diff` | | Base branch for diff mode. Only changed files are scanned |
71
71
  | `github-token` | | When set on `pull_request` events, posts findings as a PR comment |
72
+ | `fail-on` | `error` | Exit with error code on diagnostics: `error`, `warning`, `none` |
73
+ | `offline` | `false` | Skip sending diagnostics to the react.doctor API |
72
74
  | `node-version` | `20` | Node.js version to use |
73
75
 
74
76
  The action outputs a `score` (0–100) you can use in subsequent steps.
@@ -90,6 +92,14 @@ Options:
90
92
  -h, --help display help for command
91
93
  ```
92
94
 
95
+ ## Browser API
96
+
97
+ Import `react-doctor/browser` to run the same **diagnostics merge, config-based filtering, timing, and scoring pipeline** as `react-doctor/api`’s `diagnose`, but with **caller-supplied** inputs: `project` metadata, a virtual `projectFiles` map (contents keyed by paths relative to `rootDirectory`) for ignore/suppression resolution, and a `runOxlint` callback that performs linting in your environment (for example a Web Worker with oxlint).
98
+
99
+ Git history, real filesystem discovery, knip, the CLI, staged-file detection, and interactive prompts are **not** available in the browser bundle; treat those as Node-only or supply equivalents yourself. `react-doctor/worker` re-exports the same browser-facing modules for worker targets.
100
+
101
+ If you call **`diagnoseCore`** yourself in the browser, pass **`calculateDiagnosticsScore`** from this package (re-exported as **`calculateScore`** on `react-doctor/browser`) so the bundle never pulls in Node-only proxy code.
102
+
93
103
  ## Configuration
94
104
 
95
105
  Create a `react-doctor.config.json` in your project root to customize behavior:
@@ -119,14 +129,18 @@ If both exist, `react-doctor.config.json` takes precedence.
119
129
 
120
130
  ### Config options
121
131
 
122
- | Key | Type | Default | Description |
123
- | -------------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
124
- | `ignore.rules` | `string[]` | `[]` | Rules to suppress, using the `plugin/rule` format shown in diagnostic output (e.g. `react/no-danger`, `knip/exports`, `knip/types`) |
125
- | `ignore.files` | `string[]` | `[]` | File paths to exclude, supports glob patterns (`src/generated/**`, `**/*.test.tsx`) |
126
- | `lint` | `boolean` | `true` | Enable/disable lint checks (same as `--no-lint`) |
127
- | `deadCode` | `boolean` | `true` | Enable/disable dead code detection (same as `--no-dead-code`) |
128
- | `verbose` | `boolean` | `false` | Show file details per rule (same as `--verbose`) |
129
- | `diff` | `boolean \| string` | — | Force diff mode (`true`) or pin a base branch (`"main"`). Set to `false` to disable auto-detection. |
132
+ | Key | Type | Default | Description |
133
+ | ----------------- | -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
134
+ | `ignore.rules` | `string[]` | `[]` | Rules to suppress, using the `plugin/rule` format shown in diagnostic output (e.g. `react/no-danger`, `knip/exports`, `knip/types`) |
135
+ | `ignore.files` | `string[]` | `[]` | File paths to exclude, supports glob patterns (`src/generated/**`, `**/*.test.tsx`) |
136
+ | `lint` | `boolean` | `true` | Enable/disable lint checks (same as `--no-lint`) |
137
+ | `deadCode` | `boolean` | `true` | Enable/disable dead code detection (same as `--no-dead-code`) |
138
+ | `verbose` | `boolean` | `false` | Show file details per rule (same as `--verbose`) |
139
+ | `diff` | `boolean \| string` | — | Force diff mode (`true`) or pin a base branch (`"main"`). Set to `false` to disable auto-detection. |
140
+ | `failOn` | `"error" \| "warning" \| "none"` | `"none"` | Exit with error code on diagnostics of the given severity or above |
141
+ | `customRulesOnly` | `boolean` | `false` | Disable built-in react/jsx-a11y/compiler rules, keeping only `react-doctor/*` plugin rules |
142
+ | `share` | `boolean` | `true` | Show the share-your-results URL after scanning |
143
+ | `textComponents` | `string[]` | `[]` | React Native only. Component names whose children should not trigger `rn-no-raw-text` (e.g. `["MyText", "Label.Bold"]`) |
130
144
 
131
145
  CLI flags always override config values.
132
146
 
@@ -0,0 +1,2 @@
1
+ import { _ as ScoreResult, a as diagnoseBrowser, c as diagnoseCore, d as diagnose, f as calculateScore, g as ReactDoctorConfig, h as ProjectInfo, i as DiagnoseBrowserInput, l as BrowserDiagnoseInput, m as Diagnostic, n as ProcessBrowserDiagnosticsResult, o as DiagnoseCoreOptions, p as calculateScoreLocally, r as processBrowserDiagnostics, s as DiagnoseCoreResult, t as ProcessBrowserDiagnosticsInput, u as BrowserDiagnoseResult } from "./process-browser-diagnostics-Cahx3_oy.js";
2
+ export { type BrowserDiagnoseInput, type BrowserDiagnoseResult, type DiagnoseBrowserInput, type DiagnoseCoreOptions, type DiagnoseCoreResult, type Diagnostic, type ProcessBrowserDiagnosticsInput, type ProcessBrowserDiagnosticsResult, type ProjectInfo, type ReactDoctorConfig, type ScoreResult, calculateScore, calculateScoreLocally, diagnose, diagnoseBrowser, diagnoseCore, processBrowserDiagnostics };
@@ -0,0 +1,3 @@
1
+ import { a as calculateScore, i as diagnose, n as diagnoseBrowser, o as calculateScoreLocally, r as diagnoseCore, t as processBrowserDiagnostics } from "./process-browser-diagnostics-DpaZeYLI.js";
2
+
3
+ export { calculateScore, calculateScoreLocally, diagnose, diagnoseBrowser, diagnoseCore, processBrowserDiagnostics };
package/dist/cli.js CHANGED
@@ -309,55 +309,7 @@ const IGNORED_DIRECTORIES = new Set([
309
309
  ]);
310
310
 
311
311
  //#endregion
312
- //#region src/utils/proxy-fetch.ts
313
- const readNpmConfigValue = (key) => {
314
- try {
315
- const value = execSync(`npm config get ${key}`, {
316
- encoding: "utf-8",
317
- stdio: [
318
- "pipe",
319
- "pipe",
320
- "ignore"
321
- ]
322
- }).trim();
323
- if (value && value !== "null" && value !== "undefined") return value;
324
- } catch {}
325
- };
326
- const resolveProxyUrl = () => process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? readNpmConfigValue("https-proxy") ?? readNpmConfigValue("proxy");
327
- let isProxyUrlResolved = false;
328
- let resolvedProxyUrl;
329
- const getProxyUrl = () => {
330
- if (isProxyUrlResolved) return resolvedProxyUrl;
331
- isProxyUrlResolved = true;
332
- resolvedProxyUrl = resolveProxyUrl();
333
- return resolvedProxyUrl;
334
- };
335
- const createProxyDispatcher = async (proxyUrl) => {
336
- try {
337
- const { ProxyAgent } = await import("undici");
338
- return new ProxyAgent(proxyUrl);
339
- } catch {
340
- return null;
341
- }
342
- };
343
- const proxyFetch = async (url, init) => {
344
- const controller = new AbortController();
345
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
346
- try {
347
- const proxyUrl = getProxyUrl();
348
- const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
349
- return await fetch(url, {
350
- ...init,
351
- signal: controller.signal,
352
- ...dispatcher ? { dispatcher } : {}
353
- });
354
- } finally {
355
- clearTimeout(timeoutId);
356
- }
357
- };
358
-
359
- //#endregion
360
- //#region src/utils/calculate-score.ts
312
+ //#region src/core/calculate-score-locally.ts
361
313
  const getScoreLabel = (score) => {
362
314
  if (score >= SCORE_GOOD_THRESHOLD) return "Great";
363
315
  if (score >= SCORE_OK_THRESHOLD) return "Needs work";
@@ -388,20 +340,90 @@ const calculateScoreLocally = (diagnostics) => {
388
340
  label: getScoreLabel(score)
389
341
  };
390
342
  };
391
- const calculateScore = async (diagnostics) => {
343
+
344
+ //#endregion
345
+ //#region src/core/try-score-from-api.ts
346
+ const parseScoreResult = (value) => {
347
+ if (typeof value !== "object" || value === null) return null;
348
+ if (!("score" in value) || !("label" in value)) return null;
349
+ const scoreValue = Reflect.get(value, "score");
350
+ const labelValue = Reflect.get(value, "label");
351
+ if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
352
+ return {
353
+ score: scoreValue,
354
+ label: labelValue
355
+ };
356
+ };
357
+ const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
358
+ const controller = new AbortController();
359
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
392
360
  try {
393
- const response = await proxyFetch(SCORE_API_URL, {
361
+ const response = await fetchImplementation(SCORE_API_URL, {
394
362
  method: "POST",
395
363
  headers: { "Content-Type": "application/json" },
396
- body: JSON.stringify({ diagnostics })
364
+ body: JSON.stringify({ diagnostics }),
365
+ signal: controller.signal
397
366
  });
398
- if (!response.ok) return calculateScoreLocally(diagnostics);
399
- return await response.json();
367
+ if (!response.ok) return null;
368
+ return parseScoreResult(await response.json());
400
369
  } catch {
401
- return calculateScoreLocally(diagnostics);
370
+ return null;
371
+ } finally {
372
+ clearTimeout(timeoutId);
402
373
  }
403
374
  };
404
375
 
376
+ //#endregion
377
+ //#region src/utils/proxy-fetch.ts
378
+ const getGlobalProcess = () => {
379
+ const candidate = globalThis.process;
380
+ return candidate?.versions?.node ? candidate : void 0;
381
+ };
382
+ const readEnvProxy = () => {
383
+ const proc = getGlobalProcess();
384
+ if (!proc?.env) return void 0;
385
+ return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
386
+ };
387
+ let isProxyUrlResolved = false;
388
+ let resolvedProxyUrl;
389
+ const getProxyUrl = () => {
390
+ if (isProxyUrlResolved) return resolvedProxyUrl;
391
+ isProxyUrlResolved = true;
392
+ resolvedProxyUrl = readEnvProxy();
393
+ return resolvedProxyUrl;
394
+ };
395
+ const createProxyDispatcher = async (proxyUrl) => {
396
+ try {
397
+ const { ProxyAgent } = await import("undici");
398
+ return new ProxyAgent(proxyUrl);
399
+ } catch {
400
+ return null;
401
+ }
402
+ };
403
+ const proxyFetch = async (url, init) => {
404
+ const controller = new AbortController();
405
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
406
+ try {
407
+ const proxyUrl = getProxyUrl();
408
+ const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
409
+ return await fetch(url, {
410
+ ...init,
411
+ signal: controller.signal,
412
+ ...dispatcher ? { dispatcher } : {}
413
+ });
414
+ } finally {
415
+ clearTimeout(timeoutId);
416
+ }
417
+ };
418
+
419
+ //#endregion
420
+ //#region src/utils/calculate-score-node.ts
421
+ const calculateScore = async (diagnostics) => {
422
+ const apiScore = await tryScoreFromApi(diagnostics, proxyFetch);
423
+ if (apiScore) return apiScore;
424
+ return calculateScoreLocally(diagnostics);
425
+ };
426
+
405
427
  //#endregion
406
428
  //#region src/utils/colorize-by-score.ts
407
429
  const colorizeByScore = (text, score) => {
@@ -481,6 +503,19 @@ const checkReducedMotion = (rootDirectory) => {
481
503
  }
482
504
  };
483
505
 
506
+ //#endregion
507
+ //#region src/utils/read-file-lines-node.ts
508
+ const createNodeReadFileLinesSync = (rootDirectory) => {
509
+ return (filePath) => {
510
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
511
+ try {
512
+ return fs.readFileSync(absolutePath, "utf-8").split("\n");
513
+ } catch {
514
+ return null;
515
+ }
516
+ };
517
+ };
518
+
484
519
  //#endregion
485
520
  //#region src/utils/match-glob-pattern.ts
486
521
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
@@ -526,23 +561,22 @@ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
526
561
 
527
562
  //#endregion
528
563
  //#region src/utils/filter-diagnostics.ts
564
+ const resolveCandidateReadPath = (rootDirectory, filePath) => {
565
+ const normalizedFile = filePath.replace(/\\/g, "/");
566
+ if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
567
+ return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
568
+ };
529
569
  const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
530
570
  const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
531
571
  const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
532
- const createFileLinesCache = (rootDirectory) => {
572
+ const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
533
573
  const cache = /* @__PURE__ */ new Map();
534
574
  return (filePath) => {
535
575
  const cached = cache.get(filePath);
536
576
  if (cached !== void 0) return cached;
537
- const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
538
- try {
539
- const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
540
- cache.set(filePath, lines);
541
- return lines;
542
- } catch {
543
- cache.set(filePath, null);
544
- return null;
545
- }
577
+ const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
578
+ cache.set(filePath, lines);
579
+ return lines;
546
580
  };
547
581
  };
548
582
  const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
@@ -559,12 +593,12 @@ const isRuleSuppressed = (commentRules, ruleId) => {
559
593
  if (!commentRules?.trim()) return true;
560
594
  return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
561
595
  };
562
- const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory) => {
596
+ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
563
597
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
564
598
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
565
599
  const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents : []);
566
600
  const hasTextComponents = textComponentNames.size > 0;
567
- const getFileLines = createFileLinesCache(rootDirectory);
601
+ const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
568
602
  return diagnostics.filter((diagnostic) => {
569
603
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
570
604
  if (ignoredRules.has(ruleIdentifier)) return false;
@@ -576,8 +610,8 @@ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory) => {
576
610
  return true;
577
611
  });
578
612
  };
579
- const filterInlineSuppressions = (diagnostics, rootDirectory) => {
580
- const getFileLines = createFileLinesCache(rootDirectory);
613
+ const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
614
+ const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
581
615
  return diagnostics.filter((diagnostic) => {
582
616
  if (diagnostic.line <= 0) return true;
583
617
  const lines = getFileLines(diagnostic.filePath);
@@ -600,15 +634,24 @@ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
600
634
  };
601
635
 
602
636
  //#endregion
603
- //#region src/utils/combine-diagnostics.ts
637
+ //#region src/utils/merge-and-filter-diagnostics.ts
638
+ const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync) => {
639
+ return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
640
+ };
641
+
642
+ //#endregion
643
+ //#region src/utils/jsx-include-paths.ts
604
644
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
605
- const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
606
- const merged = [
645
+
646
+ //#endregion
647
+ //#region src/utils/combine-diagnostics.ts
648
+ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true) => {
649
+ const extraDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
650
+ return mergeAndFilterDiagnostics([
607
651
  ...lintDiagnostics,
608
652
  ...deadCodeDiagnostics,
609
- ...isDiffMode ? [] : checkReducedMotion(directory)
610
- ];
611
- return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig, directory) : merged, directory);
653
+ ...extraDiagnostics
654
+ ], directory, userConfig, readFileLinesSync);
612
655
  };
613
656
 
614
657
  //#endregion
@@ -1253,6 +1296,20 @@ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
1253
1296
  });
1254
1297
  };
1255
1298
 
1299
+ //#endregion
1300
+ //#region src/utils/collect-unused-file-paths.ts
1301
+ const collectUnusedFilePaths = (filesIssues) => {
1302
+ if (filesIssues instanceof Set) return [...filesIssues];
1303
+ if (Array.isArray(filesIssues)) return filesIssues.filter((entry) => typeof entry === "string");
1304
+ if (!isPlainObject(filesIssues)) return [];
1305
+ const unusedFilePaths = [];
1306
+ for (const innerValue of Object.values(filesIssues)) {
1307
+ if (!isPlainObject(innerValue)) continue;
1308
+ for (const issue of Object.values(innerValue)) if (isPlainObject(issue) && typeof issue.filePath === "string") unusedFilePaths.push(issue.filePath);
1309
+ }
1310
+ return unusedFilePaths;
1311
+ };
1312
+
1256
1313
  //#endregion
1257
1314
  //#region src/utils/run-knip.ts
1258
1315
  const KNIP_CATEGORY_MAP = {
@@ -1350,8 +1407,8 @@ const runKnip = async (rootDirectory) => {
1350
1407
  } else knipResult = await runKnipWithOptions(rootDirectory);
1351
1408
  const { issues } = knipResult;
1352
1409
  const diagnostics = [];
1353
- for (const unusedFile of issues.files) diagnostics.push({
1354
- filePath: path.relative(rootDirectory, unusedFile),
1410
+ for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
1411
+ filePath: path.relative(rootDirectory, unusedFilePath),
1355
1412
  plugin: "knip",
1356
1413
  rule: "files",
1357
1414
  severity: KNIP_SEVERITY_MAP["files"],
@@ -1984,10 +2041,8 @@ const printDiagnostics = (diagnostics, isVerbose) => {
1984
2041
  if (firstDiagnostic.help) logger.dim(indentMultilineText(firstDiagnostic.help, " "));
1985
2042
  if (isVerbose) {
1986
2043
  const fileLines = buildFileLineMap(ruleDiagnostics);
1987
- for (const [filePath, lines] of fileLines) {
1988
- const lineLabel = lines.length > 0 ? `: ${lines.join(", ")}` : "";
1989
- logger.dim(` ${filePath}${lineLabel}`);
1990
- }
2044
+ for (const [filePath, lines] of fileLines) if (lines.length > 0) for (const line of lines) logger.dim(` ${filePath}:${line}`);
2045
+ else logger.dim(` ${filePath}`);
1991
2046
  }
1992
2047
  logger.break();
1993
2048
  }
@@ -2009,10 +2064,8 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
2009
2064
  ];
2010
2065
  if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
2011
2066
  sections.push("", "Files:");
2012
- for (const [filePath, lines] of fileLines) {
2013
- const lineLabel = lines.length > 0 ? `: ${lines.join(", ")}` : "";
2014
- sections.push(` ${filePath}${lineLabel}`);
2015
- }
2067
+ for (const [filePath, lines] of fileLines) if (lines.length > 0) for (const line of lines) sections.push(` ${filePath}:${line}`);
2068
+ else sections.push(` ${filePath}`);
2016
2069
  return sections.join("\n") + "\n";
2017
2070
  };
2018
2071
  const writeDiagnosticsDirectory = (diagnostics) => {
@@ -2512,7 +2565,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
2512
2565
 
2513
2566
  //#endregion
2514
2567
  //#region src/cli.ts
2515
- const VERSION = "0.0.36";
2568
+ const VERSION = "0.0.39";
2516
2569
  const VALID_FAIL_ON_LEVELS = new Set([
2517
2570
  "error",
2518
2571
  "warning",