react-doctor 0.2.0-beta.4 → 0.2.0-beta.5

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
@@ -113,6 +113,25 @@ React Doctor respects `.gitignore`, `.eslintignore`, `.oxlintignore`, `.prettier
113
113
 
114
114
  If you have a JSON oxlint or eslint config (`.oxlintrc.json` or `.eslintrc.json`), its rules get merged into the scan automatically and count toward the score. Set `adoptExistingLintConfig: false` to opt out.
115
115
 
116
+ #### Surface controls (CLI, PR comments, score, CI failure)
117
+
118
+ Diagnostics flow through four independent surfaces — `cli`, `prComment`, `score`, and `ciFailure` — and each one can be tuned per tag, category, or rule id. By default the `design` tag (Tailwind shorthand cleanup like `w-5 h-5 → size-5`, pure-black backgrounds, gradient text, …) stays visible on the local CLI but is excluded from the PR comment, the score, and the `--fail-on` gate so style cleanup can't dilute meaningful React findings:
119
+
120
+ ```json
121
+ {
122
+ "surfaces": {
123
+ "prComment": {
124
+ "includeTags": ["design"],
125
+ "excludeCategories": ["Performance"]
126
+ },
127
+ "score": { "includeRules": ["react-doctor/design-no-redundant-size-axes"] },
128
+ "ciFailure": { "excludeTags": ["test-noise"] }
129
+ }
130
+ }
131
+ ```
132
+
133
+ Each surface accepts `includeTags`, `excludeTags`, `includeCategories`, `excludeCategories`, `includeRules`, and `excludeRules`. Include wins over exclude when both match. Run the CLI with `--pr-comment` (the GitHub Action passes it automatically when `github-token` is set) to apply the `prComment` surface to the printed output destined for sticky PR comments.
134
+
116
135
  #### Optional companion plugins
117
136
 
118
137
  When the following ESLint plugins are installed in the scanned project (or hoisted in your monorepo), React Doctor folds their rules into the same scan. Both are listed as **optional peer dependencies** — install only what you want.
@@ -210,6 +229,8 @@ Options:
210
229
  --offline skip the score API and share URL (no score shown)
211
230
  --fail-on <level> exit with error on diagnostics: error, warning, none
212
231
  --annotations output diagnostics as GitHub Actions annotations
232
+ --pr-comment tune CLI output for sticky PR comments (drops design
233
+ cleanup from the printed list and fail-on gate)
213
234
  --explain <file:line> diagnose why a rule fired or why a suppression didn't apply
214
235
  --why <file:line> alias for --explain
215
236
  -h, --help display help
@@ -235,6 +256,7 @@ When a suppression isn't working, `--explain <file:line>` (or its alias `--why <
235
256
  | `offline` | `boolean` | `false` |
236
257
  | `textComponents` | `string[]` | `[]` |
237
258
  | `rawTextWrapperComponents` | `string[]` | `[]` |
259
+ | `serverAuthFunctionNames` | `string[]` | `[]` |
238
260
  | `respectInlineDisables` | `boolean` | `true` |
239
261
  | `adoptExistingLintConfig` | `boolean` | `true` |
240
262
  | `ignore.tags` | `string[]` | `[]` |
@@ -243,10 +265,34 @@ When a suppression isn't working, `--explain <file:line>` (or its alias `--why <
243
265
 
244
266
  `rawTextWrapperComponents` is the narrower option for components that are not text elements but safely route string-only children through an internal `<Text>` (e.g. `heroui-native`'s `Button`, which stringifies its children and renders them through a `ButtonLabel`). Listed wrappers suppress `rn-no-raw-text` only when their children are entirely stringifiable. A wrapper with mixed children — e.g. `<Button>Save<Icon /></Button>` — still reports because the wrapper can't safely route raw text alongside a sibling JSX element.
245
267
 
268
+ `serverAuthFunctionNames` teaches `server-auth-actions` about custom auth guards your codebase wraps around its auth library (e.g. `requireWorkspaceMember`, `ensureSignedIn`). Listed names are accepted as a valid top-of-action auth check whether called bare (`requireWorkspaceMember()`) or as a member (`guards.requireWorkspaceMember()`), and — unlike the built-in default list — are treated as distinctive so the receiver is not re-validated.
269
+
246
270
  `ignore.tags` suppresses entire categories of rules by tag. For example, `"tags": ["design"]` disables all opinionated design rules (gradient text, pure black backgrounds, side tab borders, default Tailwind palettes). Available tags: `"design"`.
247
271
 
248
272
  `offline` skips the score API entirely — no score is shown and no share URL is generated. Automatically enabled in CI environments (GitHub Actions, GitLab CI, CircleCI) so CI runs don't depend on the network.
249
273
 
274
+ ### React Native rules in mixed monorepos
275
+
276
+ `rn-*` rules respect per-package boundaries automatically. In a mixed React Native + web monorepo (`apps/mobile` alongside `apps/web` / `apps/vite-app` / `packages/storybook` / `apps/docs`), every `rn-*` rule walks up to the file's nearest `package.json` before running:
277
+
278
+ - Packages that declare `react-native`, `react-native-tvos`, `expo`, `expo-router`, `@expo/*`, `react-native-windows`, `react-native-macos`, anything under the `@react-native/` or `@react-native-` namespaces (`@react-native-firebase/app`, `@react-native-async-storage/async-storage`, …), or Metro's top-level `"react-native"` resolution field → rules ON.
279
+ - Packages that declare a web-only framework (`next`, `vite`, `react-scripts`, `gatsby`, `@remix-run/*`, `@docusaurus/*`, `@storybook/*`, or plain `react-dom` without an RN sibling) → rules OFF.
280
+ - Packages with no clear local signal → fall back to the project-level framework detection.
281
+
282
+ File extensions override the package classification when they're unambiguous: `*.web.tsx` / `*.web.jsx` are always skipped (Metro resolves these only against `react-native-web`); `*.ios.tsx` / `*.android.tsx` / `*.native.tsx` are always scanned (mobile-only).
283
+
284
+ The detection is bidirectional: a web-rooted monorepo (root `package.json` declares `next` or `vite`) still loads the `rn-*` rules when any workspace targets React Native — the file-level boundary then keeps them silent on the web workspaces and active on the mobile ones.
285
+
286
+ `rn-no-raw-text` additionally short-circuits raw text inside platform-fork branches:
287
+
288
+ - `if (Platform.OS === "web") { … }` consequent — and the `else` branch of `if (Platform.OS !== "web")`.
289
+ - `Platform.OS === "web" ? <X /> : …` ternaries, `Platform.OS === "web" && <X />` short-circuits, and the reversed-operand form `"web" === Platform.OS`.
290
+ - `switch (Platform.OS) { case "web": … }` case bodies (other cases still report).
291
+ - `Platform.select({ web: <X />, default: <Y /> })` — only the `web` arm is exempt.
292
+ - `Platform?.OS === "web"` (optional chain) and `Platform.OS! === "web"` (TS non-null assertion) parse the same way as the bare form.
293
+
294
+ The walker stops at function and `Program` boundaries — JSX defined inside a callback hoisted out of a `Platform.OS` branch does not inherit the parent guard. Negative platform checks like `Platform.OS === "ios"` are deliberately NOT treated as web exemptions; only the explicit web branch is.
295
+
250
296
  ## Scoring
251
297
 
252
298
  The health score formula: `100 - (unique_error_rules x 1.5) - (unique_warning_rules x 0.75)`.
package/dist/cli.js CHANGED
@@ -12,6 +12,25 @@ import { randomUUID } from "node:crypto";
12
12
  import basePrompts from "prompts";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
15
+ //#region ../types/dist/index.js
16
+ const REACT_NATIVE_DEPENDENCY_NAMES = new Set([
17
+ "react-native",
18
+ "react-native-tvos",
19
+ "expo",
20
+ "expo-router",
21
+ "@expo/cli",
22
+ "@expo/metro-config",
23
+ "@expo/metro-runtime",
24
+ "react-native-windows",
25
+ "react-native-macos"
26
+ ]);
27
+ const REACT_NATIVE_DEPENDENCY_PREFIXES = ["@react-native/", "@react-native-"];
28
+ const isReactNativeDependencyName = (dependencyName) => {
29
+ if (REACT_NATIVE_DEPENDENCY_NAMES.has(dependencyName)) return true;
30
+ for (const prefix of REACT_NATIVE_DEPENDENCY_PREFIXES) if (dependencyName.startsWith(prefix)) return true;
31
+ return false;
32
+ };
33
+ //#endregion
15
34
  //#region ../project-info/dist/index.js
16
35
  var ReactDoctorError = class extends Error {
17
36
  name = "ReactDoctorError";
@@ -648,6 +667,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
648
667
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
649
668
  };
650
669
  };
670
+ const containsAnyReactNativeDependency = (section) => {
671
+ if (!section) return false;
672
+ for (const dependencyName of Object.keys(section)) if (isReactNativeDependencyName(dependencyName)) return true;
673
+ return false;
674
+ };
675
+ const isPackageJsonReactNativeAware = (packageJson) => {
676
+ if (typeof packageJson["react-native"] === "string") return true;
677
+ if (containsAnyReactNativeDependency(packageJson.dependencies)) return true;
678
+ if (containsAnyReactNativeDependency(packageJson.devDependencies)) return true;
679
+ if (containsAnyReactNativeDependency(packageJson.peerDependencies)) return true;
680
+ if (containsAnyReactNativeDependency(packageJson.optionalDependencies)) return true;
681
+ return false;
682
+ };
683
+ const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => {
684
+ if (isPackageJsonReactNativeAware(rootPackageJson)) return true;
685
+ const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
686
+ if (patterns.length === 0) return false;
687
+ const visitedDirectories = /* @__PURE__ */ new Set();
688
+ for (const pattern of patterns) {
689
+ const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
690
+ for (const workspaceDirectory of directories) {
691
+ if (visitedDirectories.has(workspaceDirectory)) continue;
692
+ visitedDirectories.add(workspaceDirectory);
693
+ if (isPackageJsonReactNativeAware(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
694
+ }
695
+ }
696
+ return false;
697
+ };
651
698
  const TANSTACK_QUERY_PACKAGES = new Set([
652
699
  "@tanstack/react-query",
653
700
  "@tanstack/query-core",
@@ -820,6 +867,7 @@ const discoverProject = (directory) => {
820
867
  const projectName = packageJson.name ?? path.basename(directory);
821
868
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
822
869
  const sourceFileCount = countSourceFiles(directory);
870
+ const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
823
871
  const projectInfo = {
824
872
  rootDirectory: directory,
825
873
  projectName,
@@ -830,6 +878,7 @@ const discoverProject = (directory) => {
830
878
  hasTypeScript,
831
879
  hasReactCompiler: detectReactCompiler(directory, packageJson),
832
880
  hasTanStackQuery: hasTanStackQuery(packageJson),
881
+ hasReactNativeWorkspace,
833
882
  sourceFileCount
834
883
  };
835
884
  cachedProjectInfos.set(directory, projectInfo);
@@ -1850,6 +1899,71 @@ const detectUserLintConfigPaths = (rootDirectory) => {
1850
1899
  }
1851
1900
  return [];
1852
1901
  };
1902
+ const DIAGNOSTIC_SURFACES = [
1903
+ "cli",
1904
+ "prComment",
1905
+ "score",
1906
+ "ciFailure"
1907
+ ];
1908
+ const isDiagnosticSurface = (value) => typeof value === "string" && DIAGNOSTIC_SURFACES.includes(value);
1909
+ /**
1910
+ * Built-in surface exclusions applied before any user config.
1911
+ *
1912
+ * `design`-tagged rules are weak-signal style cleanup — they still ship
1913
+ * to the local CLI so developers see them while editing, but they're
1914
+ * removed from the PR comment surface, the score, and the CI gate so
1915
+ * they can't bury real React findings or fail a build over a Tailwind
1916
+ * shorthand. Override per-surface via `config.surfaces.<surface>` to
1917
+ * promote individual rules back in by tag, category, or rule id.
1918
+ */
1919
+ const DEFAULT_SURFACE_EXCLUDED_TAGS = {
1920
+ cli: [],
1921
+ prComment: ["design"],
1922
+ score: ["design"],
1923
+ ciFailure: ["design"]
1924
+ };
1925
+ const toStringSet = (values) => {
1926
+ if (!values || values.length === 0) return /* @__PURE__ */ new Set();
1927
+ const collected = /* @__PURE__ */ new Set();
1928
+ for (const value of values) if (typeof value === "string" && value.length > 0) collected.add(value);
1929
+ return collected;
1930
+ };
1931
+ const buildResolvedControls = (surface, userControls) => {
1932
+ const baseExcludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
1933
+ const userIncludeTags = toStringSet(userControls?.includeTags);
1934
+ for (const includedTag of userIncludeTags) baseExcludeTags.delete(includedTag);
1935
+ const userExcludeTags = toStringSet(userControls?.excludeTags);
1936
+ for (const excludedTag of userExcludeTags) baseExcludeTags.add(excludedTag);
1937
+ return {
1938
+ includeTags: userIncludeTags,
1939
+ excludeTags: baseExcludeTags,
1940
+ includeCategories: toStringSet(userControls?.includeCategories),
1941
+ excludeCategories: toStringSet(userControls?.excludeCategories),
1942
+ includeRuleKeys: toStringSet(userControls?.includeRules),
1943
+ excludeRuleKeys: toStringSet(userControls?.excludeRules)
1944
+ };
1945
+ };
1946
+ const getRuleTags = (diagnostic) => {
1947
+ if (diagnostic.plugin !== "react-doctor") return [];
1948
+ return reactDoctorPlugin.rules[diagnostic.rule]?.tags ?? [];
1949
+ };
1950
+ const intersectsAny = (values, candidateSet) => {
1951
+ for (const value of values) if (candidateSet.has(value)) return true;
1952
+ return false;
1953
+ };
1954
+ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
1955
+ const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
1956
+ const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
1957
+ const tags = getRuleTags(diagnostic);
1958
+ if (resolved.includeRuleKeys.has(ruleKey)) return true;
1959
+ if (resolved.includeCategories.has(diagnostic.category)) return true;
1960
+ if (intersectsAny(tags, resolved.includeTags)) return true;
1961
+ if (resolved.excludeRuleKeys.has(ruleKey)) return false;
1962
+ if (resolved.excludeCategories.has(diagnostic.category)) return false;
1963
+ if (intersectsAny(tags, resolved.excludeTags)) return false;
1964
+ return true;
1965
+ };
1966
+ const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
1853
1967
  const runGit = (cwd, args) => {
1854
1968
  const result = spawnSync("git", args, {
1855
1969
  cwd,
@@ -1996,6 +2110,61 @@ const validateString = (fieldName, value) => {
1996
2110
  if (typeof value === "string") return value;
1997
2111
  warnConfigField(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
1998
2112
  };
2113
+ const SURFACE_CONTROL_FIELD_NAMES = [
2114
+ "includeTags",
2115
+ "excludeTags",
2116
+ "includeCategories",
2117
+ "excludeCategories",
2118
+ "includeRules",
2119
+ "excludeRules"
2120
+ ];
2121
+ const validateStringArrayField = (fieldName, value) => {
2122
+ if (value === void 0) return void 0;
2123
+ if (!Array.isArray(value)) {
2124
+ warnConfigField(`config field "${fieldName}" must be an array of strings (got ${typeof value}); ignoring this field.`);
2125
+ return;
2126
+ }
2127
+ const collected = [];
2128
+ for (const entry of value) {
2129
+ if (typeof entry !== "string") {
2130
+ warnConfigField(`config field "${fieldName}" contains a non-string entry (${typeof entry}); ignoring the entry.`);
2131
+ continue;
2132
+ }
2133
+ collected.push(entry);
2134
+ }
2135
+ return collected;
2136
+ };
2137
+ const validateSurfaceControls = (surface, rawControls) => {
2138
+ if (rawControls === void 0) return void 0;
2139
+ if (typeof rawControls !== "object" || rawControls === null || Array.isArray(rawControls)) {
2140
+ warnConfigField(`config field "surfaces.${surface}" must be an object (got ${typeof rawControls}); ignoring this surface.`);
2141
+ return;
2142
+ }
2143
+ const validated = {};
2144
+ for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
2145
+ const value = rawControls[fieldName];
2146
+ const validatedValue = validateStringArrayField(`surfaces.${surface}.${fieldName}`, value);
2147
+ if (validatedValue !== void 0) validated[fieldName] = validatedValue;
2148
+ }
2149
+ return validated;
2150
+ };
2151
+ const validateSurfacesField = (rawSurfaces) => {
2152
+ if (rawSurfaces === void 0) return void 0;
2153
+ if (typeof rawSurfaces !== "object" || rawSurfaces === null || Array.isArray(rawSurfaces)) {
2154
+ warnConfigField(`config field "surfaces" must be an object (got ${typeof rawSurfaces}); ignoring this field.`);
2155
+ return;
2156
+ }
2157
+ const validated = {};
2158
+ for (const [key, value] of Object.entries(rawSurfaces)) {
2159
+ if (!isDiagnosticSurface(key)) {
2160
+ warnConfigField(`config field "surfaces.${key}" is not a known surface (expected one of: ${DIAGNOSTIC_SURFACES.join(", ")}); ignoring.`);
2161
+ continue;
2162
+ }
2163
+ const controls = validateSurfaceControls(key, value);
2164
+ if (controls !== void 0) validated[key] = controls;
2165
+ }
2166
+ return validated;
2167
+ };
1999
2168
  const validateConfigTypes = (config) => {
2000
2169
  const validated = { ...config };
2001
2170
  for (const fieldName of BOOLEAN_FIELD_NAMES) {
@@ -2012,6 +2181,11 @@ const validateConfigTypes = (config) => {
2012
2181
  if (validatedString === void 0) delete validated[fieldName];
2013
2182
  else validated[fieldName] = validatedString;
2014
2183
  }
2184
+ if (config.surfaces !== void 0) {
2185
+ const validatedSurfaces = validateSurfacesField(config.surfaces);
2186
+ if (validatedSurfaces === void 0) delete validated.surfaces;
2187
+ else validated.surfaces = validatedSurfaces;
2188
+ }
2015
2189
  return validated;
2016
2190
  };
2017
2191
  const CONFIG_FILENAME = "react-doctor.config.json";
@@ -2313,7 +2487,7 @@ const dedupeDiagnostics = (diagnostics) => {
2313
2487
  const buildCapabilities = (project) => {
2314
2488
  const capabilities = /* @__PURE__ */ new Set();
2315
2489
  capabilities.add(project.framework);
2316
- if (project.framework === "expo" || project.framework === "react-native") capabilities.add("react-native");
2490
+ if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
2317
2491
  const reactMajor = project.reactMajorVersion;
2318
2492
  if (reactMajor !== null) for (let major = 17; major <= reactMajor; major++) capabilities.add(`react:${major}`);
2319
2493
  if (project.tailwindVersion !== null) {
@@ -2453,7 +2627,11 @@ const BUILTIN_A11Y_RULES = {
2453
2627
  "jsx-a11y/no-distracting-elements": "error",
2454
2628
  "jsx-a11y/iframe-has-title": "warn"
2455
2629
  };
2456
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set() }) => {
2630
+ const resolveSettingsRootDirectory = (rootDirectory) => {
2631
+ if (!fs.existsSync(rootDirectory)) return rootDirectory;
2632
+ return fs.realpathSync(rootDirectory);
2633
+ };
2634
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames }) => {
2457
2635
  const reactHooksJsPlugin = resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
2458
2636
  const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
2459
2637
  const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
@@ -2482,6 +2660,11 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
2482
2660
  },
2483
2661
  plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
2484
2662
  jsPlugins: [...jsPlugins, pluginPath],
2663
+ settings: { "react-doctor": {
2664
+ framework: project.framework,
2665
+ rootDirectory: resolveSettingsRootDirectory(project.rootDirectory),
2666
+ ...serverAuthFunctionNames && serverAuthFunctionNames.length > 0 ? { serverAuthFunctionNames: [...serverAuthFunctionNames] } : {}
2667
+ } },
2485
2668
  rules: {
2486
2669
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
2487
2670
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
@@ -2784,7 +2967,25 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
2784
2967
  const bindingResolution = resolveUseCallBinding(sourceText, absolutePath, primaryLabel.span.offset);
2785
2968
  return bindingResolution !== null && !bindingResolution.isReactUseBinding;
2786
2969
  };
2787
- const getRuleRecommendation = (ruleName) => reactDoctorPlugin.rules[ruleName]?.recommendation;
2970
+ const getPublicEnvPrefix = (framework) => {
2971
+ switch (framework) {
2972
+ case "nextjs": return "NEXT_PUBLIC_*";
2973
+ case "vite":
2974
+ case "tanstack-start": return "VITE_*";
2975
+ case "cra": return "REACT_APP_*";
2976
+ case "gatsby": return "GATSBY_*";
2977
+ default: return null;
2978
+ }
2979
+ };
2980
+ const buildNoSecretsRecommendation = (project, fallbackRecommendation) => {
2981
+ const publicEnvPrefix = getPublicEnvPrefix(project.framework);
2982
+ if (!publicEnvPrefix) return fallbackRecommendation;
2983
+ return `Move secrets to server-only code. In ${formatFrameworkName(project.framework)}, only \`${publicEnvPrefix}\` env vars are exposed to the browser, and they must not contain secrets`;
2984
+ };
2985
+ const getRuleRecommendation = (ruleName, project) => {
2986
+ if (ruleName === "no-secrets-in-client-code") return buildNoSecretsRecommendation(project, reactDoctorPlugin.rules["no-secrets-in-client-code"]?.recommendation ?? "Move secrets to server-only code");
2987
+ return reactDoctorPlugin.rules[ruleName]?.recommendation;
2988
+ };
2788
2989
  const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
2789
2990
  const esmRequire = createRequire(import.meta.url);
2790
2991
  const PLUGIN_CATEGORY_MAP = {
@@ -2809,14 +3010,14 @@ const PLUGIN_CATEGORY_MAP = {
2809
3010
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
2810
3011
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
2811
3012
  const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
2812
- const cleanDiagnosticMessage = (message, help, plugin, rule) => {
3013
+ const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
2813
3014
  if (plugin === "react-hooks-js") return {
2814
3015
  message: REACT_COMPILER_MESSAGE,
2815
3016
  help: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help
2816
3017
  };
2817
3018
  return {
2818
3019
  message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
2819
- help: help || getRuleRecommendation(rule) || ""
3020
+ help: help || getRuleRecommendation(rule, project) || ""
2820
3021
  };
2821
3022
  };
2822
3023
  const parseRuleCode = (code) => {
@@ -2919,7 +3120,7 @@ const isOxlintOutput = (value) => {
2919
3120
  const candidate = value;
2920
3121
  return Array.isArray(candidate.diagnostics);
2921
3122
  };
2922
- const parseOxlintOutput = (stdout, rootDirectory) => {
3123
+ const parseOxlintOutput = (stdout, project, rootDirectory) => {
2923
3124
  if (!stdout) return [];
2924
3125
  const jsonStart = stdout.indexOf("{");
2925
3126
  const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
@@ -2933,7 +3134,7 @@ const parseOxlintOutput = (stdout, rootDirectory) => {
2933
3134
  return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
2934
3135
  const { plugin, rule } = parseRuleCode(diagnostic.code);
2935
3136
  const primaryLabel = diagnostic.labels[0];
2936
- const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
3137
+ const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
2937
3138
  return {
2938
3139
  filePath: diagnostic.filename,
2939
3140
  plugin,
@@ -2963,7 +3164,7 @@ const validateRuleRegistration = () => {
2963
3164
  for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2964
3165
  const ruleName = fullKey.replace(/^react-doctor\//, "");
2965
3166
  if (!getRuleCategory(ruleName)) missingCategory.push(fullKey);
2966
- if (!getRuleRecommendation(ruleName)) missingHelp.push(fullKey);
3167
+ if (!reactDoctorPlugin.rules[ruleName]?.recommendation) missingHelp.push(fullKey);
2967
3168
  if (FRAMEWORK_SPECIFIC_RULE_KEYS.has(fullKey) && !reactDoctorPlugin.rules[ruleName]?.requires) missingMetadata.push(fullKey);
2968
3169
  }
2969
3170
  if (missingCategory.length > 0 || missingHelp.length > 0 || missingMetadata.length > 0) {
@@ -2976,7 +3177,8 @@ const validateRuleRegistration = () => {
2976
3177
  }
2977
3178
  };
2978
3179
  const runOxlint = async (options) => {
2979
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), onPartialFailure } = options;
3180
+ const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, onPartialFailure } = options;
3181
+ const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
2980
3182
  validateRuleRegistration();
2981
3183
  if (includePaths !== void 0 && includePaths.length === 0) return [];
2982
3184
  const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
@@ -2988,7 +3190,8 @@ const runOxlint = async (options) => {
2988
3190
  project,
2989
3191
  customRulesOnly,
2990
3192
  extendsPaths,
2991
- ignoredTags
3193
+ ignoredTags,
3194
+ serverAuthFunctionNames
2992
3195
  });
2993
3196
  const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
2994
3197
  try {
@@ -3025,7 +3228,7 @@ const runOxlint = async (options) => {
3025
3228
  const spawnLintBatch = async (batch) => {
3026
3229
  const batchArgs = [...baseArgs, ...batch];
3027
3230
  try {
3028
- return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), rootDirectory);
3231
+ return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), project, rootDirectory);
3029
3232
  } catch (error) {
3030
3233
  if (!isSplittableOxlintBatchError(error)) throw error;
3031
3234
  if (batch.length <= 1) {
@@ -3055,7 +3258,8 @@ const runOxlint = async (options) => {
3055
3258
  project,
3056
3259
  customRulesOnly,
3057
3260
  extendsPaths: [],
3058
- ignoredTags
3261
+ ignoredTags,
3262
+ serverAuthFunctionNames
3059
3263
  }));
3060
3264
  return await spawnLintBatches();
3061
3265
  }
@@ -3585,7 +3789,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
3585
3789
  share: userConfig?.share ?? true,
3586
3790
  respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
3587
3791
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
3588
- ignoredTags: buildIgnoredTags(userConfig)
3792
+ ignoredTags: buildIgnoredTags(userConfig),
3793
+ outputSurface: inputOptions.outputSurface ?? "cli"
3589
3794
  });
3590
3795
  const inspect = async (directory, inputOptions = {}) => {
3591
3796
  const startTime = performance.now();
@@ -3643,6 +3848,7 @@ const runInspect = async (directory, options, userConfig, startTime) => {
3643
3848
  respectInlineDisables: options.respectInlineDisables,
3644
3849
  adoptExistingLintConfig: options.adoptExistingLintConfig,
3645
3850
  ignoredTags: options.ignoredTags,
3851
+ userConfig,
3646
3852
  onPartialFailure: (reason) => lintPartialFailures.push(reason)
3647
3853
  });
3648
3854
  lintSpinner?.succeed("Running lint checks.");
@@ -3670,7 +3876,8 @@ const runInspect = async (directory, options, userConfig, startTime) => {
3670
3876
  const skippedChecks = [];
3671
3877
  if (didLintFail) skippedChecks.push("lint");
3672
3878
  const hasSkippedChecks = skippedChecks.length > 0;
3673
- const scoreResult = options.offline ? null : await calculateScore(diagnostics);
3879
+ const scoreDiagnostics = filterDiagnosticsForSurface(diagnostics, "score", userConfig);
3880
+ const scoreResult = options.offline ? null : await calculateScore(scoreDiagnostics);
3674
3881
  const noScoreMessage = options.offline ? "Score unavailable in offline mode." : "Score unavailable (could not reach the score API).";
3675
3882
  const skippedCheckReasons = {};
3676
3883
  if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
@@ -3688,11 +3895,14 @@ const runInspect = async (directory, options, userConfig, startTime) => {
3688
3895
  else logger.dim(noScoreMessage);
3689
3896
  return buildResult();
3690
3897
  }
3691
- if (diagnostics.length === 0) {
3898
+ const surfaceDiagnostics = filterDiagnosticsForSurface(diagnostics, options.outputSurface, userConfig);
3899
+ const demotedDiagnosticCount = diagnostics.length - surfaceDiagnostics.length;
3900
+ if (surfaceDiagnostics.length === 0) {
3692
3901
  if (hasSkippedChecks) {
3693
3902
  const skippedLabel = skippedChecks.join(" and ");
3694
3903
  logger.warn(`No issues detected, but ${skippedLabel} checks failed — results are incomplete.`);
3695
- } else logger.success("No issues found!");
3904
+ } else if (demotedDiagnosticCount > 0) logger.success(`No issues found! (${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface — see config.surfaces.)`);
3905
+ else logger.success("No issues found!");
3696
3906
  logger.break();
3697
3907
  if (hasSkippedChecks) {
3698
3908
  printBrandingOnlyHeader();
@@ -3702,10 +3912,14 @@ const runInspect = async (directory, options, userConfig, startTime) => {
3702
3912
  return buildResult();
3703
3913
  }
3704
3914
  logger.break();
3705
- printDiagnostics(diagnostics, options.verbose, directory);
3915
+ printDiagnostics(surfaceDiagnostics, options.verbose, directory);
3916
+ if (demotedDiagnosticCount > 0) {
3917
+ logger.log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
3918
+ logger.break();
3919
+ }
3706
3920
  const displayedSourceFileCount = isDiffMode ? includePaths.length : lintSourceFileCount;
3707
3921
  const shouldShowShareLink = !options.offline && options.share;
3708
- printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, !shouldShowShareLink);
3922
+ printSummary(surfaceDiagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, !shouldShowShareLink);
3709
3923
  if (hasSkippedChecks) {
3710
3924
  const skippedLabel = skippedChecks.join(" and ");
3711
3925
  logger.break();
@@ -3805,7 +4019,7 @@ const CI_ENVIRONMENT_VARIABLES = [
3805
4019
  const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
3806
4020
  //#endregion
3807
4021
  //#region src/cli/utils/version.ts
3808
- const VERSION = "0.2.0-beta.4";
4022
+ const VERSION = "0.2.0-beta.5";
3809
4023
  //#endregion
3810
4024
  //#region src/cli/utils/json-mode.ts
3811
4025
  let context = null;
@@ -3871,7 +4085,8 @@ const resolveCliInspectOptions = (flags, userConfig) => ({
3871
4085
  scoreOnly: Boolean(flags.score),
3872
4086
  offline: Boolean(flags.offline) || (userConfig?.offline ?? false) || isCiEnvironment(),
3873
4087
  silent: Boolean(flags.json),
3874
- respectInlineDisables: flags.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true
4088
+ respectInlineDisables: flags.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
4089
+ outputSurface: flags.prComment ? "prComment" : "cli"
3875
4090
  });
3876
4091
  //#endregion
3877
4092
  //#region src/cli/utils/resolve-diff-mode.ts
@@ -4096,6 +4311,7 @@ const validateModeFlags = (flags) => {
4096
4311
  if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
4097
4312
  if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
4098
4313
  if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
4314
+ if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
4099
4315
  if (flags.annotations && (flags.json || flags.score)) throw new Error("--annotations cannot be combined with --json or --score.");
4100
4316
  if (flags.explain !== void 0 && flags.why !== void 0) throw new Error("Use --explain or --why, not both — they're aliases of the same flag.");
4101
4317
  if ((flags.explain ?? flags.why) !== void 0 && (flags.json || flags.score || flags.annotations || flags.staged)) throw new Error("--explain cannot be combined with --json, --score, --annotations, or --staged.");
@@ -4193,7 +4409,8 @@ const inspectAction = async (directory, flags) => {
4193
4409
  totalElapsedMilliseconds: performance.now() - startTime
4194
4410
  }));
4195
4411
  if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
4196
- if (!isScoreOnly && shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
4412
+ const ciFailureDiagnostics = filterDiagnosticsForSurface(remappedDiagnostics, "ciFailure", userConfig);
4413
+ if (!isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
4197
4414
  } finally {
4198
4415
  snapshot.cleanup();
4199
4416
  }
@@ -4256,7 +4473,8 @@ const inspectAction = async (directory, flags) => {
4256
4473
  totalElapsedMilliseconds: performance.now() - startTime
4257
4474
  }));
4258
4475
  if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
4259
- if (!isScoreOnly && shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
4476
+ const ciFailureDiagnostics = filterDiagnosticsForSurface(allDiagnostics, "ciFailure", userConfig);
4477
+ if (!isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
4260
4478
  } catch (error) {
4261
4479
  if (isJsonMode) {
4262
4480
  writeJsonErrorReport(error);
@@ -4387,7 +4605,7 @@ const exitGracefully = () => {
4387
4605
  //#region src/cli/index.ts
4388
4606
  process.on("SIGINT", exitGracefully);
4389
4607
  process.on("SIGTERM", exitGracefully);
4390
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--offline", "skip the score API and the share URL (no score is shown)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: error)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").addHelpText("after", `
4608
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--offline", "skip the score API and the share URL (no score is shown)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: error)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").addHelpText("after", `
4391
4609
  ${highlighter.dim("Configuration:")}
4392
4610
  Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
4393
4611
  CLI flags always override config values. See the README for the full schema.
package/dist/index.d.ts CHANGED
@@ -11,6 +11,52 @@ interface ReactDoctorIgnoreConfig {
11
11
  overrides?: ReactDoctorIgnoreOverride[];
12
12
  tags?: string[];
13
13
  }
14
+ /**
15
+ * Discrete output channels a diagnostic can flow through after a scan.
16
+ * Each surface is filtered independently so a rule can be visible
17
+ * locally but excluded from PR comments, the score, or the CI gate:
18
+ *
19
+ * - `cli` — local terminal output from `react-doctor` (`printDiagnostics`).
20
+ * - `prComment` — output captured by the GitHub Action for the sticky
21
+ * PR comment. Enabled when the CLI is run with `--pr-comment` (the
22
+ * action sets this automatically when `github-token` is provided).
23
+ * - `score` — diagnostics shipped to the React Doctor score API
24
+ * (or counted toward local score calculations).
25
+ * - `ciFailure` — diagnostics that count toward the `--fail-on` exit
26
+ * code gate. A diagnostic excluded from this surface never fails the
27
+ * build, regardless of severity.
28
+ *
29
+ * Defaults: design rules (tag `"design"`) are excluded from `prComment`,
30
+ * `score`, and `ciFailure` so style cleanup doesn't dilute meaningful
31
+ * React findings. They remain in `cli` so locally-running developers
32
+ * still see the suggestion when they touch the file.
33
+ */
34
+ type DiagnosticSurface = "cli" | "prComment" | "score" | "ciFailure";
35
+ interface SurfaceControls {
36
+ /**
37
+ * Tag names whose diagnostics should be force-included on the surface,
38
+ * even if a default or category-level exclusion would otherwise drop
39
+ * them. Include wins over exclude when both apply to the same rule.
40
+ */
41
+ includeTags?: string[];
42
+ /**
43
+ * Tag names whose diagnostics should be excluded from the surface.
44
+ * Use this to silence whole rule families (e.g. `["design"]`,
45
+ * `["test-noise"]`) for a single channel without touching others.
46
+ */
47
+ excludeTags?: string[];
48
+ /** Category names (e.g. `"Architecture"`) to force-include. */
49
+ includeCategories?: string[];
50
+ /** Category names (e.g. `"Architecture"`) to exclude. */
51
+ excludeCategories?: string[];
52
+ /**
53
+ * Fully-qualified rule keys (`"<plugin>/<rule>"`, e.g.
54
+ * `"react-doctor/design-no-redundant-size-axes"`) to force-include.
55
+ */
56
+ includeRules?: string[];
57
+ /** Fully-qualified rule keys to exclude from this surface. */
58
+ excludeRules?: string[];
59
+ }
14
60
  interface ReactDoctorConfig {
15
61
  ignore?: ReactDoctorIgnoreConfig;
16
62
  lint?: boolean;
@@ -56,6 +102,20 @@ interface ReactDoctorConfig {
56
102
  * suppresses regardless of sibling content.
57
103
  */
58
104
  rawTextWrapperComponents?: string[];
105
+ /**
106
+ * Project-level allowlist of function names that the
107
+ * `server-auth-actions` rule treats as an auth check at the top of
108
+ * a server action. Names are accepted whether called as a bare
109
+ * identifier (`myAuthGuard()`) or as the final property of a
110
+ * member call (`ctx.myAuthGuard()`); unlike the built-in default
111
+ * list, user-provided names are treated as distinctive and never
112
+ * subject to receiver-object disambiguation.
113
+ *
114
+ * Use this to teach react-doctor about custom auth guards in
115
+ * codebases that wrap their auth library — e.g. a project-local
116
+ * `requireWorkspaceMember` or `ensureSignedIn`.
117
+ */
118
+ serverAuthFunctionNames?: string[];
59
119
  /**
60
120
  * Whether to respect inline `// eslint-disable*`, `// oxlint-disable*`,
61
121
  * and `// react-doctor-disable*` comments in source files. Default: `true`.
@@ -96,6 +156,26 @@ interface ReactDoctorConfig {
96
156
  * Set to `false` to scan only react-doctor's curated rule set.
97
157
  */
98
158
  adoptExistingLintConfig?: boolean;
159
+ /**
160
+ * Per-surface include/exclude controls. Each `DiagnosticSurface` is
161
+ * resolved independently against rule tags, category, and id so a
162
+ * single rule can be visible locally yet hidden from PR comments,
163
+ * neutralized from the score, and excluded from `--fail-on` — all
164
+ * without touching the rule's severity or activation.
165
+ *
166
+ * Defaults (applied before user overrides):
167
+ *
168
+ * - `prComment` excludes tag `"design"`
169
+ * - `score` excludes tag `"design"`
170
+ * - `ciFailure` excludes tag `"design"`
171
+ *
172
+ * Pass any controls block (even an empty `{}`) to keep the default
173
+ * exclusions; the user's include/exclude entries layer on top.
174
+ * Include entries always win over exclude entries — handy for
175
+ * promoting a single high-signal `design-*` rule back into the
176
+ * score or PR-comment surface.
177
+ */
178
+ surfaces?: Partial<Record<DiagnosticSurface, SurfaceControls>>;
99
179
  } //#endregion
100
180
  //#region src/diagnostic.d.ts
101
181
  interface Diagnostic {
@@ -124,6 +204,20 @@ interface ProjectInfo {
124
204
  hasTypeScript: boolean;
125
205
  hasReactCompiler: boolean;
126
206
  hasTanStackQuery: boolean;
207
+ /**
208
+ * `true` when the project (or any of its workspace packages) declares
209
+ * React Native or Expo as a dependency. Enables the `react-native`
210
+ * capability — and therefore every `rn-*` rule — even on web-rooted
211
+ * monorepos where the entry-point `package.json` is Next / Vite /
212
+ * Remix but a sibling workspace (`apps/mobile`, `packages/native-ui`)
213
+ * targets React Native. The file-level package-boundary check in
214
+ * `oxlint-plugin-react-doctor` still keeps the rules silent on the
215
+ * web workspaces.
216
+ *
217
+ * `false` collapses the gate to the legacy "framework is RN" behavior
218
+ * — no `rn-*` rules load for the project at all.
219
+ */
220
+ hasReactNativeWorkspace: boolean;
127
221
  sourceFileCount: number;
128
222
  }
129
223
  //#endregion
package/dist/index.js CHANGED
@@ -5,6 +5,25 @@ import { spawn, spawnSync } from "node:child_process";
5
5
  import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor";
6
6
  import os from "node:os";
7
7
  import * as ts from "typescript";
8
+ //#region ../types/dist/index.js
9
+ const REACT_NATIVE_DEPENDENCY_NAMES = new Set([
10
+ "react-native",
11
+ "react-native-tvos",
12
+ "expo",
13
+ "expo-router",
14
+ "@expo/cli",
15
+ "@expo/metro-config",
16
+ "@expo/metro-runtime",
17
+ "react-native-windows",
18
+ "react-native-macos"
19
+ ]);
20
+ const REACT_NATIVE_DEPENDENCY_PREFIXES = ["@react-native/", "@react-native-"];
21
+ const isReactNativeDependencyName = (dependencyName) => {
22
+ if (REACT_NATIVE_DEPENDENCY_NAMES.has(dependencyName)) return true;
23
+ for (const prefix of REACT_NATIVE_DEPENDENCY_PREFIXES) if (dependencyName.startsWith(prefix)) return true;
24
+ return false;
25
+ };
26
+ //#endregion
8
27
  //#region ../project-info/dist/index.js
9
28
  var ReactDoctorError = class extends Error {
10
29
  name = "ReactDoctorError";
@@ -225,6 +244,18 @@ const FRAMEWORK_PACKAGES = {
225
244
  expo: "expo",
226
245
  "react-native": "react-native"
227
246
  };
247
+ const FRAMEWORK_DISPLAY_NAMES = {
248
+ nextjs: "Next.js",
249
+ "tanstack-start": "TanStack Start",
250
+ vite: "Vite",
251
+ cra: "Create React App",
252
+ remix: "Remix",
253
+ gatsby: "Gatsby",
254
+ expo: "Expo",
255
+ "react-native": "React Native",
256
+ unknown: "React"
257
+ };
258
+ const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
228
259
  const detectFramework = (dependencies) => {
229
260
  for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
230
261
  return "unknown";
@@ -651,6 +682,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
651
682
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
652
683
  };
653
684
  };
685
+ const containsAnyReactNativeDependency = (section) => {
686
+ if (!section) return false;
687
+ for (const dependencyName of Object.keys(section)) if (isReactNativeDependencyName(dependencyName)) return true;
688
+ return false;
689
+ };
690
+ const isPackageJsonReactNativeAware = (packageJson) => {
691
+ if (typeof packageJson["react-native"] === "string") return true;
692
+ if (containsAnyReactNativeDependency(packageJson.dependencies)) return true;
693
+ if (containsAnyReactNativeDependency(packageJson.devDependencies)) return true;
694
+ if (containsAnyReactNativeDependency(packageJson.peerDependencies)) return true;
695
+ if (containsAnyReactNativeDependency(packageJson.optionalDependencies)) return true;
696
+ return false;
697
+ };
698
+ const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => {
699
+ if (isPackageJsonReactNativeAware(rootPackageJson)) return true;
700
+ const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
701
+ if (patterns.length === 0) return false;
702
+ const visitedDirectories = /* @__PURE__ */ new Set();
703
+ for (const pattern of patterns) {
704
+ const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
705
+ for (const workspaceDirectory of directories) {
706
+ if (visitedDirectories.has(workspaceDirectory)) continue;
707
+ visitedDirectories.add(workspaceDirectory);
708
+ if (isPackageJsonReactNativeAware(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
709
+ }
710
+ }
711
+ return false;
712
+ };
654
713
  const TANSTACK_QUERY_PACKAGES = new Set([
655
714
  "@tanstack/react-query",
656
715
  "@tanstack/query-core",
@@ -826,6 +885,7 @@ const discoverProject = (directory) => {
826
885
  const projectName = packageJson.name ?? path.basename(directory);
827
886
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
828
887
  const sourceFileCount = countSourceFiles(directory);
888
+ const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
829
889
  const projectInfo = {
830
890
  rootDirectory: directory,
831
891
  projectName,
@@ -836,6 +896,7 @@ const discoverProject = (directory) => {
836
896
  hasTypeScript,
837
897
  hasReactCompiler: detectReactCompiler(directory, packageJson),
838
898
  hasTanStackQuery: hasTanStackQuery(packageJson),
899
+ hasReactNativeWorkspace,
839
900
  sourceFileCount
840
901
  };
841
902
  cachedProjectInfos.set(directory, projectInfo);
@@ -1843,6 +1904,13 @@ const detectUserLintConfigPaths = (rootDirectory) => {
1843
1904
  }
1844
1905
  return [];
1845
1906
  };
1907
+ const DIAGNOSTIC_SURFACES = [
1908
+ "cli",
1909
+ "prComment",
1910
+ "score",
1911
+ "ciFailure"
1912
+ ];
1913
+ const isDiagnosticSurface = (value) => typeof value === "string" && DIAGNOSTIC_SURFACES.includes(value);
1846
1914
  const runGit = (cwd, args) => {
1847
1915
  const result = spawnSync("git", args, {
1848
1916
  cwd,
@@ -1989,6 +2057,61 @@ const validateString = (fieldName, value) => {
1989
2057
  if (typeof value === "string") return value;
1990
2058
  warnConfigField(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
1991
2059
  };
2060
+ const SURFACE_CONTROL_FIELD_NAMES = [
2061
+ "includeTags",
2062
+ "excludeTags",
2063
+ "includeCategories",
2064
+ "excludeCategories",
2065
+ "includeRules",
2066
+ "excludeRules"
2067
+ ];
2068
+ const validateStringArrayField = (fieldName, value) => {
2069
+ if (value === void 0) return void 0;
2070
+ if (!Array.isArray(value)) {
2071
+ warnConfigField(`config field "${fieldName}" must be an array of strings (got ${typeof value}); ignoring this field.`);
2072
+ return;
2073
+ }
2074
+ const collected = [];
2075
+ for (const entry of value) {
2076
+ if (typeof entry !== "string") {
2077
+ warnConfigField(`config field "${fieldName}" contains a non-string entry (${typeof entry}); ignoring the entry.`);
2078
+ continue;
2079
+ }
2080
+ collected.push(entry);
2081
+ }
2082
+ return collected;
2083
+ };
2084
+ const validateSurfaceControls = (surface, rawControls) => {
2085
+ if (rawControls === void 0) return void 0;
2086
+ if (typeof rawControls !== "object" || rawControls === null || Array.isArray(rawControls)) {
2087
+ warnConfigField(`config field "surfaces.${surface}" must be an object (got ${typeof rawControls}); ignoring this surface.`);
2088
+ return;
2089
+ }
2090
+ const validated = {};
2091
+ for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
2092
+ const value = rawControls[fieldName];
2093
+ const validatedValue = validateStringArrayField(`surfaces.${surface}.${fieldName}`, value);
2094
+ if (validatedValue !== void 0) validated[fieldName] = validatedValue;
2095
+ }
2096
+ return validated;
2097
+ };
2098
+ const validateSurfacesField = (rawSurfaces) => {
2099
+ if (rawSurfaces === void 0) return void 0;
2100
+ if (typeof rawSurfaces !== "object" || rawSurfaces === null || Array.isArray(rawSurfaces)) {
2101
+ warnConfigField(`config field "surfaces" must be an object (got ${typeof rawSurfaces}); ignoring this field.`);
2102
+ return;
2103
+ }
2104
+ const validated = {};
2105
+ for (const [key, value] of Object.entries(rawSurfaces)) {
2106
+ if (!isDiagnosticSurface(key)) {
2107
+ warnConfigField(`config field "surfaces.${key}" is not a known surface (expected one of: ${DIAGNOSTIC_SURFACES.join(", ")}); ignoring.`);
2108
+ continue;
2109
+ }
2110
+ const controls = validateSurfaceControls(key, value);
2111
+ if (controls !== void 0) validated[key] = controls;
2112
+ }
2113
+ return validated;
2114
+ };
1992
2115
  const validateConfigTypes = (config) => {
1993
2116
  const validated = { ...config };
1994
2117
  for (const fieldName of BOOLEAN_FIELD_NAMES) {
@@ -2005,6 +2128,11 @@ const validateConfigTypes = (config) => {
2005
2128
  if (validatedString === void 0) delete validated[fieldName];
2006
2129
  else validated[fieldName] = validatedString;
2007
2130
  }
2131
+ if (config.surfaces !== void 0) {
2132
+ const validatedSurfaces = validateSurfacesField(config.surfaces);
2133
+ if (validatedSurfaces === void 0) delete validated.surfaces;
2134
+ else validated.surfaces = validatedSurfaces;
2135
+ }
2008
2136
  return validated;
2009
2137
  };
2010
2138
  const CONFIG_FILENAME = "react-doctor.config.json";
@@ -2242,7 +2370,7 @@ const dedupeDiagnostics = (diagnostics) => {
2242
2370
  const buildCapabilities = (project) => {
2243
2371
  const capabilities = /* @__PURE__ */ new Set();
2244
2372
  capabilities.add(project.framework);
2245
- if (project.framework === "expo" || project.framework === "react-native") capabilities.add("react-native");
2373
+ if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
2246
2374
  const reactMajor = project.reactMajorVersion;
2247
2375
  if (reactMajor !== null) for (let major = 17; major <= reactMajor; major++) capabilities.add(`react:${major}`);
2248
2376
  if (project.tailwindVersion !== null) {
@@ -2382,7 +2510,11 @@ const BUILTIN_A11Y_RULES = {
2382
2510
  "jsx-a11y/no-distracting-elements": "error",
2383
2511
  "jsx-a11y/iframe-has-title": "warn"
2384
2512
  };
2385
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set() }) => {
2513
+ const resolveSettingsRootDirectory = (rootDirectory) => {
2514
+ if (!fs.existsSync(rootDirectory)) return rootDirectory;
2515
+ return fs.realpathSync(rootDirectory);
2516
+ };
2517
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames }) => {
2386
2518
  const reactHooksJsPlugin = resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
2387
2519
  const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
2388
2520
  const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
@@ -2411,6 +2543,11 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
2411
2543
  },
2412
2544
  plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
2413
2545
  jsPlugins: [...jsPlugins, pluginPath],
2546
+ settings: { "react-doctor": {
2547
+ framework: project.framework,
2548
+ rootDirectory: resolveSettingsRootDirectory(project.rootDirectory),
2549
+ ...serverAuthFunctionNames && serverAuthFunctionNames.length > 0 ? { serverAuthFunctionNames: [...serverAuthFunctionNames] } : {}
2550
+ } },
2414
2551
  rules: {
2415
2552
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
2416
2553
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
@@ -2713,7 +2850,25 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
2713
2850
  const bindingResolution = resolveUseCallBinding(sourceText, absolutePath, primaryLabel.span.offset);
2714
2851
  return bindingResolution !== null && !bindingResolution.isReactUseBinding;
2715
2852
  };
2716
- const getRuleRecommendation = (ruleName) => reactDoctorPlugin.rules[ruleName]?.recommendation;
2853
+ const getPublicEnvPrefix = (framework) => {
2854
+ switch (framework) {
2855
+ case "nextjs": return "NEXT_PUBLIC_*";
2856
+ case "vite":
2857
+ case "tanstack-start": return "VITE_*";
2858
+ case "cra": return "REACT_APP_*";
2859
+ case "gatsby": return "GATSBY_*";
2860
+ default: return null;
2861
+ }
2862
+ };
2863
+ const buildNoSecretsRecommendation = (project, fallbackRecommendation) => {
2864
+ const publicEnvPrefix = getPublicEnvPrefix(project.framework);
2865
+ if (!publicEnvPrefix) return fallbackRecommendation;
2866
+ return `Move secrets to server-only code. In ${formatFrameworkName(project.framework)}, only \`${publicEnvPrefix}\` env vars are exposed to the browser, and they must not contain secrets`;
2867
+ };
2868
+ const getRuleRecommendation = (ruleName, project) => {
2869
+ if (ruleName === "no-secrets-in-client-code") return buildNoSecretsRecommendation(project, reactDoctorPlugin.rules["no-secrets-in-client-code"]?.recommendation ?? "Move secrets to server-only code");
2870
+ return reactDoctorPlugin.rules[ruleName]?.recommendation;
2871
+ };
2717
2872
  const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
2718
2873
  const esmRequire = createRequire(import.meta.url);
2719
2874
  const PLUGIN_CATEGORY_MAP = {
@@ -2738,14 +2893,14 @@ const PLUGIN_CATEGORY_MAP = {
2738
2893
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
2739
2894
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
2740
2895
  const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
2741
- const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2896
+ const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
2742
2897
  if (plugin === "react-hooks-js") return {
2743
2898
  message: REACT_COMPILER_MESSAGE,
2744
2899
  help: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help
2745
2900
  };
2746
2901
  return {
2747
2902
  message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
2748
- help: help || getRuleRecommendation(rule) || ""
2903
+ help: help || getRuleRecommendation(rule, project) || ""
2749
2904
  };
2750
2905
  };
2751
2906
  const parseRuleCode = (code) => {
@@ -2848,7 +3003,7 @@ const isOxlintOutput = (value) => {
2848
3003
  const candidate = value;
2849
3004
  return Array.isArray(candidate.diagnostics);
2850
3005
  };
2851
- const parseOxlintOutput = (stdout, rootDirectory) => {
3006
+ const parseOxlintOutput = (stdout, project, rootDirectory) => {
2852
3007
  if (!stdout) return [];
2853
3008
  const jsonStart = stdout.indexOf("{");
2854
3009
  const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
@@ -2862,7 +3017,7 @@ const parseOxlintOutput = (stdout, rootDirectory) => {
2862
3017
  return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
2863
3018
  const { plugin, rule } = parseRuleCode(diagnostic.code);
2864
3019
  const primaryLabel = diagnostic.labels[0];
2865
- const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
3020
+ const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
2866
3021
  return {
2867
3022
  filePath: diagnostic.filename,
2868
3023
  plugin,
@@ -2892,7 +3047,7 @@ const validateRuleRegistration = () => {
2892
3047
  for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2893
3048
  const ruleName = fullKey.replace(/^react-doctor\//, "");
2894
3049
  if (!getRuleCategory(ruleName)) missingCategory.push(fullKey);
2895
- if (!getRuleRecommendation(ruleName)) missingHelp.push(fullKey);
3050
+ if (!reactDoctorPlugin.rules[ruleName]?.recommendation) missingHelp.push(fullKey);
2896
3051
  if (FRAMEWORK_SPECIFIC_RULE_KEYS.has(fullKey) && !reactDoctorPlugin.rules[ruleName]?.requires) missingMetadata.push(fullKey);
2897
3052
  }
2898
3053
  if (missingCategory.length > 0 || missingHelp.length > 0 || missingMetadata.length > 0) {
@@ -2905,7 +3060,8 @@ const validateRuleRegistration = () => {
2905
3060
  }
2906
3061
  };
2907
3062
  const runOxlint = async (options) => {
2908
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), onPartialFailure } = options;
3063
+ const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, onPartialFailure } = options;
3064
+ const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
2909
3065
  validateRuleRegistration();
2910
3066
  if (includePaths !== void 0 && includePaths.length === 0) return [];
2911
3067
  const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
@@ -2917,7 +3073,8 @@ const runOxlint = async (options) => {
2917
3073
  project,
2918
3074
  customRulesOnly,
2919
3075
  extendsPaths,
2920
- ignoredTags
3076
+ ignoredTags,
3077
+ serverAuthFunctionNames
2921
3078
  });
2922
3079
  const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
2923
3080
  try {
@@ -2954,7 +3111,7 @@ const runOxlint = async (options) => {
2954
3111
  const spawnLintBatch = async (batch) => {
2955
3112
  const batchArgs = [...baseArgs, ...batch];
2956
3113
  try {
2957
- return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), rootDirectory);
3114
+ return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), project, rootDirectory);
2958
3115
  } catch (error) {
2959
3116
  if (!isSplittableOxlintBatchError(error)) throw error;
2960
3117
  if (batch.length <= 1) {
@@ -2984,7 +3141,8 @@ const runOxlint = async (options) => {
2984
3141
  project,
2985
3142
  customRulesOnly,
2986
3143
  extendsPaths: [],
2987
- ignoredTags
3144
+ ignoredTags,
3145
+ serverAuthFunctionNames
2988
3146
  }));
2989
3147
  return await spawnLintBatches();
2990
3148
  }
@@ -3048,7 +3206,8 @@ const diagnose = async (directory, options = {}) => {
3048
3206
  customRulesOnly: userConfig?.customRulesOnly ?? false,
3049
3207
  respectInlineDisables: effectiveRespectInlineDisables,
3050
3208
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
3051
- ignoredTags
3209
+ ignoredTags,
3210
+ userConfig
3052
3211
  }).catch((error) => {
3053
3212
  console.error("Lint failed:", error);
3054
3213
  return EMPTY_DIAGNOSTICS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.2.0-beta.4",
3
+ "version": "0.2.0-beta.5",
4
4
  "description": "Diagnose and fix React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -56,15 +56,15 @@
56
56
  "picocolors": "^1.1.1",
57
57
  "prompts": "^2.4.2",
58
58
  "typescript": ">=5.0.4 <7",
59
- "oxlint-plugin-react-doctor": "0.2.0-beta.4"
59
+ "oxlint-plugin-react-doctor": "0.2.0-beta.5"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/prompts": "^2.4.9",
63
63
  "eslint-plugin-react-hooks": "^7.1.1",
64
64
  "eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
65
- "@react-doctor/core": "0.2.0-beta.4",
66
- "@react-doctor/project-info": "0.2.0-beta.2",
67
- "@react-doctor/types": "0.2.0-beta.2"
65
+ "@react-doctor/types": "0.2.0-beta.3",
66
+ "@react-doctor/project-info": "0.2.0-beta.3",
67
+ "@react-doctor/core": "0.2.0-beta.5"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "eslint-plugin-react-hooks": "^6 || ^7",