react-doctor 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,19 +7,20 @@ import path from "node:path";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES } from "oxlint-plugin-react-doctor";
9
9
  import * as Cause from "effect/Cause";
10
- import * as Config$1 from "effect/Config";
11
10
  import * as Effect from "effect/Effect";
11
+ import * as Config$1 from "effect/Config";
12
12
  import * as Layer from "effect/Layer";
13
13
  import * as Redacted from "effect/Redacted";
14
14
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
15
15
  import * as Otlp from "effect/unstable/observability/Otlp";
16
16
  import * as Context from "effect/Context";
17
+ import * as Console from "effect/Console";
18
+ import * as Fiber from "effect/Fiber";
17
19
  import * as Filter from "effect/Filter";
18
20
  import * as Option from "effect/Option";
19
21
  import * as Ref from "effect/Ref";
20
22
  import * as Stream from "effect/Stream";
21
23
  import * as Cache from "effect/Cache";
22
- import * as Console from "effect/Console";
23
24
  import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner";
24
25
  import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
25
26
  import * as NodePath from "@effect/platform-node-shared/NodePath";
@@ -2191,6 +2192,17 @@ const findMonorepoRoot = (startDirectory) => {
2191
2192
  }
2192
2193
  return null;
2193
2194
  };
2195
+ /**
2196
+ * True when `directory` looks like a project root we shouldn't walk
2197
+ * past — either the working tree's git root (a `.git` entry sits
2198
+ * here) or an npm/pnpm/yarn/bun monorepo root.
2199
+ *
2200
+ * Used as the stop-condition for the ancestor walks performed by
2201
+ * `detectUserLintConfigPaths`, `loadConfigWithSource`, and
2202
+ * `detectReactCompiler`. All three previously inlined their own
2203
+ * byte-equivalent copy.
2204
+ */
2205
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
2194
2206
  const REACT_COMPILER_PACKAGES = new Set([
2195
2207
  "babel-plugin-react-compiler",
2196
2208
  "react-compiler-runtime",
@@ -2241,24 +2253,20 @@ const hasCompilerInConfigFile = (filePath) => {
2241
2253
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
2242
2254
  };
2243
2255
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
2244
- const isProjectBoundary$2 = (directory) => {
2245
- if (fs.existsSync(path.join(directory, ".git"))) return true;
2246
- return isMonorepoRoot(directory);
2247
- };
2248
2256
  const detectReactCompiler = (directory, packageJson) => {
2249
2257
  if (hasCompilerPackage(packageJson)) return true;
2250
2258
  if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
2251
2259
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
2252
2260
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
2253
2261
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
2254
- if (isProjectBoundary$2(directory)) return false;
2262
+ if (isProjectBoundary(directory)) return false;
2255
2263
  let ancestorDirectory = path.dirname(directory);
2256
2264
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
2257
2265
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
2258
2266
  if (isFile(ancestorPackagePath)) {
2259
2267
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
2260
2268
  }
2261
- if (isProjectBoundary$2(ancestorDirectory)) return false;
2269
+ if (isProjectBoundary(ancestorDirectory)) return false;
2262
2270
  ancestorDirectory = path.dirname(ancestorDirectory);
2263
2271
  }
2264
2272
  return false;
@@ -3010,6 +3018,7 @@ const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
3010
3018
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
3011
3019
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
3012
3020
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
3021
+ const CONFIG_CACHE_TTL_MS = 300 * 1e3;
3013
3022
  var InvalidGlobPatternError = class extends Error {
3014
3023
  pattern;
3015
3024
  reason;
@@ -3104,6 +3113,18 @@ const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) =>
3104
3113
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
3105
3114
  return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
3106
3115
  };
3116
+ const SEVERITY_FOR_OVERRIDE = {
3117
+ error: "error",
3118
+ warn: "warning"
3119
+ };
3120
+ const restampSeverity = (diagnostic, override) => {
3121
+ const targetSeverity = SEVERITY_FOR_OVERRIDE[override];
3122
+ if (diagnostic.severity === targetSeverity) return diagnostic;
3123
+ return {
3124
+ ...diagnostic,
3125
+ severity: targetSeverity
3126
+ };
3127
+ };
3107
3128
  /**
3108
3129
  * Assembles the internal `RuleSeverityControls` shape from a user
3109
3130
  * config's top-level `rules` and `categories` fields — the
@@ -3121,25 +3142,115 @@ const buildRuleSeverityControls = (config) => {
3121
3142
  ...config.categories !== void 0 ? { categories: config.categories } : {}
3122
3143
  };
3123
3144
  };
3124
- /**
3125
- * Projects a diagnostic onto the three axes rule-targeted controls
3126
- * reason about:
3127
- *
3128
- * - `ruleKey` the fully-qualified `"<plugin>/<rule>"` form users
3129
- * put in config files (consumed by top-level `rules` severity and
3130
- * `surfaces.*.{include,exclude}Rules`).
3131
- * - `category` the diagnostic's category label (consumed by
3132
- * top-level `categories` severity and
3133
- * `surfaces.*.{include,exclude}Categories`).
3134
- * - `tags` — behavioral tags from the rule registry (consumed by
3135
- * `ignore.tags` and `surfaces.*.{include,exclude}Tags`). Empty
3136
- * for non-`react-doctor` plugins.
3137
- */
3138
- const getDiagnosticRuleIdentity = (diagnostic) => ({
3139
- ruleKey: `${diagnostic.plugin}/${diagnostic.rule}`,
3140
- category: diagnostic.category,
3141
- tags: diagnostic.plugin === "react-doctor" ? reactDoctorPlugin.rules[diagnostic.rule]?.tags ?? [] : []
3142
- });
3145
+ const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
3146
+ const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
3147
+ const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
3148
+ let stringDelimiter = null;
3149
+ for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
3150
+ const character = line[charIndex];
3151
+ if (stringDelimiter !== null) {
3152
+ if (character === "\\") {
3153
+ charIndex++;
3154
+ continue;
3155
+ }
3156
+ if (character === stringDelimiter) stringDelimiter = null;
3157
+ continue;
3158
+ }
3159
+ if (character === "\"" || character === "'" || character === "`") {
3160
+ stringDelimiter = character;
3161
+ continue;
3162
+ }
3163
+ if (character === "/" && line[charIndex + 1] === "/") return true;
3164
+ }
3165
+ return false;
3166
+ };
3167
+ const findOpenerTagOnLine = (line) => {
3168
+ for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
3169
+ if (match.index === void 0) continue;
3170
+ if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
3171
+ }
3172
+ return null;
3173
+ };
3174
+ const findJsxOpenerSpan = (lines, openerLineIndex) => {
3175
+ const openerLine = lines[openerLineIndex];
3176
+ if (openerLine === void 0) return null;
3177
+ const opener = findOpenerTagOnLine(openerLine);
3178
+ if (!opener) return null;
3179
+ const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
3180
+ let braceDepth = 0;
3181
+ let innerAngleDepth = 0;
3182
+ let stringDelimiter = null;
3183
+ for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
3184
+ const currentLine = lines[lineIndex];
3185
+ const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
3186
+ for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
3187
+ const character = currentLine[charIndex];
3188
+ if (stringDelimiter !== null) {
3189
+ if (character === "\\") {
3190
+ charIndex++;
3191
+ continue;
3192
+ }
3193
+ if (character === stringDelimiter) stringDelimiter = null;
3194
+ continue;
3195
+ }
3196
+ if (character === "\"" || character === "'" || character === "`") {
3197
+ stringDelimiter = character;
3198
+ continue;
3199
+ }
3200
+ if (character === "{") {
3201
+ braceDepth++;
3202
+ continue;
3203
+ }
3204
+ if (character === "}") {
3205
+ braceDepth--;
3206
+ continue;
3207
+ }
3208
+ if (braceDepth !== 0) continue;
3209
+ if (character === "<") {
3210
+ const followCharacter = currentLine[charIndex + 1];
3211
+ if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
3212
+ continue;
3213
+ }
3214
+ if (character !== ">") continue;
3215
+ const previousCharacter = currentLine[charIndex - 1];
3216
+ const nextCharacter = currentLine[charIndex + 1];
3217
+ if (previousCharacter === "=" || nextCharacter === "=") continue;
3218
+ if (innerAngleDepth > 0) {
3219
+ innerAngleDepth--;
3220
+ continue;
3221
+ }
3222
+ return lineIndex;
3223
+ }
3224
+ }
3225
+ return null;
3226
+ };
3227
+ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
3228
+ for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
3229
+ const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
3230
+ if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
3231
+ }
3232
+ return null;
3233
+ };
3234
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3235
+ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
3236
+ const collected = [];
3237
+ let isStillInChain = true;
3238
+ for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
3239
+ const candidateLine = lines[candidateIndex];
3240
+ if (candidateLine === void 0) break;
3241
+ const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
3242
+ if (match) {
3243
+ collected.push({
3244
+ commentLineIndex: candidateIndex,
3245
+ ruleList: match[1],
3246
+ isInChain: isStillInChain
3247
+ });
3248
+ continue;
3249
+ }
3250
+ isStillInChain = false;
3251
+ }
3252
+ return collected;
3253
+ };
3143
3254
  const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
3144
3255
  "effect/no-adjust-state-on-prop-change": "react-doctor/no-adjust-state-on-prop-change",
3145
3256
  "effect/no-chain-state-updates": "react-doctor/no-chain-state-updates",
@@ -3270,6 +3381,111 @@ const getEquivalentRuleKeys = (ruleKey) => {
3270
3381
  const nativeRuleKey = canonicalizeRuleKey(ruleKey);
3271
3382
  return [nativeRuleKey, ...getLegacyRuleKeysForNative(nativeRuleKey)];
3272
3383
  };
3384
+ const stripDescriptionTail = (ruleList) => {
3385
+ const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
3386
+ if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
3387
+ return ruleList.slice(0, descriptionMatch.index);
3388
+ };
3389
+ const isRuleListedInComment = (ruleList, ruleId) => {
3390
+ const trimmed = ruleList?.trim();
3391
+ if (!trimmed) return true;
3392
+ const ruleSection = stripDescriptionTail(trimmed).trim();
3393
+ if (!ruleSection) return true;
3394
+ return ruleSection.split(/[,\s]+/).some((token) => isSameRuleKey(token.trim(), ruleId));
3395
+ };
3396
+ const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3397
+ const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
3398
+ const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
3399
+ const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
3400
+ const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
3401
+ const buildAdjacentMismatchHint = (comment, ruleId) => {
3402
+ const ruleListText = comment.ruleList?.trim() ?? "";
3403
+ return `An adjacent react-doctor-disable-next-line at line ${comment.commentLineIndex + 1} lists "${ruleListText}" — ${ruleId} is not in that list. Use the comma form: react-doctor-disable-next-line ${ruleListText}, ${ruleId}`;
3404
+ };
3405
+ const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
3406
+ const commentLineNumber = comment.commentLineIndex + 1;
3407
+ const diagnosticLineNumber = diagnosticLineIndex + 1;
3408
+ return `A react-doctor-disable-next-line for ${ruleId} sits at line ${commentLineNumber}, but ${formatLineGap(diagnosticLineNumber - commentLineNumber - 1)} of code separate it from the diagnostic on line ${diagnosticLineNumber}. Move the comment immediately above line ${diagnosticLineNumber}, or extract the surrounding code into a helper so the suppression is adjacent.`;
3409
+ };
3410
+ const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
3411
+ for (const comments of commentsByAnchor) {
3412
+ const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
3413
+ if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
3414
+ const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
3415
+ if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
3416
+ }
3417
+ return null;
3418
+ };
3419
+ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
3420
+ const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
3421
+ if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
3422
+ isSuppressed: true,
3423
+ nearMissHint: null
3424
+ };
3425
+ const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
3426
+ if (hasChainSuppressor(directComments, ruleId)) return {
3427
+ isSuppressed: true,
3428
+ nearMissHint: null
3429
+ };
3430
+ const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
3431
+ const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
3432
+ if (hasChainSuppressor(openerComments, ruleId)) return {
3433
+ isSuppressed: true,
3434
+ nearMissHint: null
3435
+ };
3436
+ return {
3437
+ isSuppressed: false,
3438
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
3439
+ };
3440
+ };
3441
+ /**
3442
+ * Projects a diagnostic onto the three axes rule-targeted controls
3443
+ * reason about:
3444
+ *
3445
+ * - `ruleKey` — the fully-qualified `"<plugin>/<rule>"` form users
3446
+ * put in config files (consumed by top-level `rules` severity and
3447
+ * `surfaces.*.{include,exclude}Rules`).
3448
+ * - `category` — the diagnostic's category label (consumed by
3449
+ * top-level `categories` severity and
3450
+ * `surfaces.*.{include,exclude}Categories`).
3451
+ * - `tags` — behavioral tags from the rule registry (consumed by
3452
+ * `ignore.tags` and `surfaces.*.{include,exclude}Tags`). Empty
3453
+ * for non-`react-doctor` plugins.
3454
+ */
3455
+ const getDiagnosticRuleIdentity = (diagnostic) => ({
3456
+ ruleKey: `${diagnostic.plugin}/${diagnostic.rule}`,
3457
+ category: diagnostic.category,
3458
+ tags: diagnostic.plugin === "react-doctor" ? reactDoctorPlugin.rules[diagnostic.rule]?.tags ?? [] : []
3459
+ });
3460
+ const compileIgnoredFilePatterns = (userConfig) => {
3461
+ const files = userConfig?.ignore?.files;
3462
+ if (!Array.isArray(files)) return [];
3463
+ return compileGlobPatternsLenient(files.filter((entry) => typeof entry === "string"), (error) => warnConfigIssue(`ignore.files: ${error.message}`));
3464
+ };
3465
+ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
3466
+ if (patterns.length === 0) return false;
3467
+ const relativePath = toRelativePath(filePath, rootDirectory);
3468
+ return patterns.some((pattern) => pattern.test(relativePath));
3469
+ };
3470
+ const TEST_FILE_DIRECTORY_PATTERN = /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\//;
3471
+ const TEST_FILE_SUFFIX_PATTERN = /\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
3472
+ const FIXTURE_PROJECT_PATTERN = /\/(?:fixtures|__fixtures__)\//;
3473
+ const SOURCE_ROOT_PATTERN = /\/(?:src|app|lib|components|pages|features|modules|packages|apps|frontend|client)\//g;
3474
+ const stripAboveSourceRoot = (relativePath) => {
3475
+ const fixtureMatch = FIXTURE_PROJECT_PATTERN.exec(relativePath);
3476
+ if (fixtureMatch === null) return relativePath;
3477
+ let lastIdx = -1;
3478
+ for (const match of relativePath.matchAll(SOURCE_ROOT_PATTERN)) if (match.index !== void 0 && match.index > lastIdx) lastIdx = match.index;
3479
+ if (lastIdx >= 0) return relativePath.slice(lastIdx);
3480
+ return relativePath.slice(fixtureMatch.index + fixtureMatch[0].length - 1);
3481
+ };
3482
+ const isTestFilePath = (relativePath) => {
3483
+ if (relativePath.length === 0) return false;
3484
+ const forwardSlashed = relativePath.replaceAll("\\", "/");
3485
+ if (TEST_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return true;
3486
+ const scoped = stripAboveSourceRoot(forwardSlashed);
3487
+ return TEST_FILE_DIRECTORY_PATTERN.test(scoped);
3488
+ };
3273
3489
  /**
3274
3490
  * Resolves the user-configured severity override for a rule.
3275
3491
  * Per-rule overrides win over per-category overrides. Returns
@@ -3287,233 +3503,152 @@ const resolveRuleSeverityOverride = (input, controls) => {
3287
3503
  }
3288
3504
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3289
3505
  };
3290
- const SEVERITY_FOR_OVERRIDE = {
3291
- error: "error",
3292
- warn: "warning"
3293
- };
3294
- const restampSeverity = (diagnostic, override) => {
3295
- const targetSeverity = SEVERITY_FOR_OVERRIDE[override];
3296
- if (diagnostic.severity === targetSeverity) return diagnostic;
3297
- return {
3298
- ...diagnostic,
3299
- severity: targetSeverity
3300
- };
3301
- };
3302
- const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
3303
- const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
3304
- const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
3305
- let stringDelimiter = null;
3306
- for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
3307
- const character = line[charIndex];
3308
- if (stringDelimiter !== null) {
3309
- if (character === "\\") {
3310
- charIndex++;
3311
- continue;
3312
- }
3313
- if (character === stringDelimiter) stringDelimiter = null;
3314
- continue;
3315
- }
3316
- if (character === "\"" || character === "'" || character === "`") {
3317
- stringDelimiter = character;
3318
- continue;
3319
- }
3320
- if (character === "/" && line[charIndex + 1] === "/") return true;
3321
- }
3322
- return false;
3506
+ /**
3507
+ * Resolves the absolute path to read for a diagnostic's `filePath`,
3508
+ * accounting for the various shapes oxlint emits:
3509
+ *
3510
+ * - Absolute POSIX (`/abs/path/file.tsx`) pass through.
3511
+ * - Absolute Windows (`C:/...` or `C:\...`) — pass through.
3512
+ * - `./relative` or bare relative — join against `rootDirectory`.
3513
+ *
3514
+ * Shared between the streaming diagnostic pipeline and the legacy
3515
+ * array-shaped `mergeAndFilterDiagnostics` wrapper so file-line lookups
3516
+ * use one canonical resolution path.
3517
+ */
3518
+ const resolveCandidateReadPath = (rootDirectory, filePath) => {
3519
+ const normalizedFile = filePath.replace(/\\/g, "/");
3520
+ if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
3521
+ return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
3323
3522
  };
3324
- const findOpenerTagOnLine = (line) => {
3325
- for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
3326
- if (match.index === void 0) continue;
3327
- if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
3523
+ /**
3524
+ * Shared raw-line scanners that detect whether a diagnostic site is
3525
+ * enclosed by a configured `textComponents` entry or a
3526
+ * `rawTextWrapperComponents` entry. Both checks are used by the
3527
+ * diagnostic-pipeline's `rn-no-raw-text` suppression step.
3528
+ *
3529
+ * Heuristic — operates on raw lines without an AST — but good enough
3530
+ * to (a) detect a string-only wrapper child and (b) verify the opener
3531
+ * actually encloses a given diagnostic position.
3532
+ */
3533
+ const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
3534
+ const JSX_CHILD_OPEN_PATTERN = /<[A-Za-z]/;
3535
+ const escapeRegExpSpecials = (rawText) => rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3536
+ const findOpenerAtOrAbove = (lines, upperBoundLineIndex) => {
3537
+ for (let lineIndex = upperBoundLineIndex; lineIndex >= 0; lineIndex--) {
3538
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
3539
+ if (!match) continue;
3540
+ const fullName = match[1];
3541
+ return {
3542
+ fullName,
3543
+ leafName: fullName.includes(".") ? fullName.split(".").at(-1) ?? fullName : fullName,
3544
+ lineIndex
3545
+ };
3328
3546
  }
3329
3547
  return null;
3330
3548
  };
3331
- const findJsxOpenerSpan = (lines, openerLineIndex) => {
3332
- const openerLine = lines[openerLineIndex];
3333
- if (openerLine === void 0) return null;
3334
- const opener = findOpenerTagOnLine(openerLine);
3335
- if (!opener) return null;
3336
- const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
3337
- let braceDepth = 0;
3338
- let innerAngleDepth = 0;
3339
- let stringDelimiter = null;
3340
- for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
3341
- const currentLine = lines[lineIndex];
3342
- const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
3343
- for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
3344
- const character = currentLine[charIndex];
3345
- if (stringDelimiter !== null) {
3346
- if (character === "\\") {
3347
- charIndex++;
3348
- continue;
3349
- }
3350
- if (character === stringDelimiter) stringDelimiter = null;
3351
- continue;
3352
- }
3353
- if (character === "\"" || character === "'" || character === "`") {
3354
- stringDelimiter = character;
3355
- continue;
3356
- }
3357
- if (character === "{") {
3358
- braceDepth++;
3359
- continue;
3360
- }
3361
- if (character === "}") {
3362
- braceDepth--;
3363
- continue;
3364
- }
3365
- if (braceDepth !== 0) continue;
3366
- if (character === "<") {
3367
- const followCharacter = currentLine[charIndex + 1];
3368
- if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
3369
- continue;
3370
- }
3371
- if (character !== ">") continue;
3372
- const previousCharacter = currentLine[charIndex - 1];
3373
- const nextCharacter = currentLine[charIndex + 1];
3374
- if (previousCharacter === "=" || nextCharacter === "=") continue;
3375
- if (innerAngleDepth > 0) {
3376
- innerAngleDepth--;
3377
- continue;
3378
- }
3379
- return lineIndex;
3380
- }
3549
+ const resolveJsxRange = (lines, opener) => {
3550
+ const closingPattern = new RegExp(`</(?:${escapeRegExpSpecials(opener.fullName)}|${escapeRegExpSpecials(opener.leafName)})\\s*>`);
3551
+ let closerLineIndex = -1;
3552
+ let closerColumn = -1;
3553
+ for (let lineIndex = opener.lineIndex; lineIndex < lines.length; lineIndex++) {
3554
+ const match = closingPattern.exec(lines[lineIndex]);
3555
+ if (!match) continue;
3556
+ closerLineIndex = lineIndex;
3557
+ closerColumn = match.index;
3558
+ break;
3559
+ }
3560
+ if (closerLineIndex < 0) return null;
3561
+ const openerLine = lines[opener.lineIndex];
3562
+ const tagStartIndex = openerLine.indexOf(`<${opener.fullName}`);
3563
+ if (tagStartIndex < 0) return null;
3564
+ const openerEndIndex = openerLine.indexOf(">", tagStartIndex);
3565
+ let bodyText;
3566
+ if (opener.lineIndex === closerLineIndex) {
3567
+ if (openerEndIndex < 0 || openerEndIndex >= closerColumn) return null;
3568
+ bodyText = openerLine.slice(openerEndIndex + 1, closerColumn);
3569
+ } else {
3570
+ const segments = [];
3571
+ if (openerEndIndex >= 0) segments.push(openerLine.slice(openerEndIndex + 1));
3572
+ for (let lineIndex = opener.lineIndex + 1; lineIndex < closerLineIndex; lineIndex++) segments.push(lines[lineIndex]);
3573
+ segments.push(lines[closerLineIndex].slice(0, closerColumn));
3574
+ bodyText = segments.join("\n");
3381
3575
  }
3382
- return null;
3576
+ return {
3577
+ closerLineIndex,
3578
+ closerColumn,
3579
+ bodyText
3580
+ };
3383
3581
  };
3384
- const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
3385
- for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
3386
- const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
3387
- if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
3582
+ /**
3583
+ * Returns true when the JSX element opened at or above `diagnosticLine`
3584
+ * is named in `textComponentNames`, matching either by full dotted name
3585
+ * (`NativeTabs.Trigger.Label`) or by the leaf name (`Label`).
3586
+ */
3587
+ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
3588
+ for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
3589
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
3590
+ if (!match) continue;
3591
+ const fullTagName = match[1];
3592
+ const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
3593
+ return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
3388
3594
  }
3389
- return null;
3595
+ return false;
3390
3596
  };
3391
- const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3392
- const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
3393
- const collected = [];
3394
- let isStillInChain = true;
3395
- for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
3396
- const candidateLine = lines[candidateIndex];
3397
- if (candidateLine === void 0) break;
3398
- const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
3399
- if (match) {
3400
- collected.push({
3401
- commentLineIndex: candidateIndex,
3402
- ruleList: match[1],
3403
- isInChain: isStillInChain
3404
- });
3597
+ /**
3598
+ * Returns true when the diagnostic position is enclosed by the nearest
3599
+ * actually-enclosing opener AND that opener is in `wrapperNames` AND
3600
+ * its body has no JSX child elements (i.e. the wrapper holds only
3601
+ * stringifiable children). Closed siblings above the diagnostic are
3602
+ * skipped `findOpenerAtOrAbove` keeps walking outward.
3603
+ *
3604
+ * Diagnostic line and column are 1-indexed; column may be 0 when oxlint
3605
+ * omits the span (we treat that as "earliest position on the line",
3606
+ * which is conservative for enclosure checks).
3607
+ */
3608
+ const isInsideStringOnlyWrapper = (lines, diagnosticLine, diagnosticColumn, wrapperNames) => {
3609
+ const diagnosticLineIndex = diagnosticLine - 1;
3610
+ const diagnosticColumnIndex = Math.max(0, diagnosticColumn - 1);
3611
+ let upperBoundLineIndex = diagnosticLineIndex;
3612
+ while (upperBoundLineIndex >= 0) {
3613
+ const opener = findOpenerAtOrAbove(lines, upperBoundLineIndex);
3614
+ if (!opener) return false;
3615
+ const range = resolveJsxRange(lines, opener);
3616
+ if (range === null) {
3617
+ upperBoundLineIndex = opener.lineIndex - 1;
3405
3618
  continue;
3406
3619
  }
3407
- isStillInChain = false;
3408
- }
3409
- return collected;
3410
- };
3411
- const stripDescriptionTail = (ruleList) => {
3412
- const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
3413
- if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
3414
- return ruleList.slice(0, descriptionMatch.index);
3415
- };
3416
- const isRuleListedInComment = (ruleList, ruleId) => {
3417
- const trimmed = ruleList?.trim();
3418
- if (!trimmed) return true;
3419
- const ruleSection = stripDescriptionTail(trimmed).trim();
3420
- if (!ruleSection) return true;
3421
- return ruleSection.split(/[,\s]+/).some((token) => isSameRuleKey(token.trim(), ruleId));
3422
- };
3423
- const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3424
- const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
3425
- const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
3426
- const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
3427
- const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
3428
- const buildAdjacentMismatchHint = (comment, ruleId) => {
3429
- const ruleListText = comment.ruleList?.trim() ?? "";
3430
- return `An adjacent react-doctor-disable-next-line at line ${comment.commentLineIndex + 1} lists "${ruleListText}" — ${ruleId} is not in that list. Use the comma form: react-doctor-disable-next-line ${ruleListText}, ${ruleId}`;
3431
- };
3432
- const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
3433
- const commentLineNumber = comment.commentLineIndex + 1;
3434
- const diagnosticLineNumber = diagnosticLineIndex + 1;
3435
- return `A react-doctor-disable-next-line for ${ruleId} sits at line ${commentLineNumber}, but ${formatLineGap(diagnosticLineNumber - commentLineNumber - 1)} of code separate it from the diagnostic on line ${diagnosticLineNumber}. Move the comment immediately above line ${diagnosticLineNumber}, or extract the surrounding code into a helper so the suppression is adjacent.`;
3436
- };
3437
- const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
3438
- for (const comments of commentsByAnchor) {
3439
- const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
3440
- if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
3441
- const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
3442
- if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
3620
+ if (range.closerLineIndex < diagnosticLineIndex || range.closerLineIndex === diagnosticLineIndex && range.closerColumn <= diagnosticColumnIndex) {
3621
+ upperBoundLineIndex = opener.lineIndex - 1;
3622
+ continue;
3623
+ }
3624
+ if (!wrapperNames.has(opener.fullName) && !wrapperNames.has(opener.leafName)) return false;
3625
+ return !JSX_CHILD_OPEN_PATTERN.test(range.bodyText);
3443
3626
  }
3444
- return null;
3445
- };
3446
- const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
3447
- const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
3448
- if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
3449
- isSuppressed: true,
3450
- nearMissHint: null
3451
- };
3452
- const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
3453
- if (hasChainSuppressor(directComments, ruleId)) return {
3454
- isSuppressed: true,
3455
- nearMissHint: null
3456
- };
3457
- const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
3458
- const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
3459
- if (hasChainSuppressor(openerComments, ruleId)) return {
3460
- isSuppressed: true,
3461
- nearMissHint: null
3462
- };
3463
- return {
3464
- isSuppressed: false,
3465
- nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
3466
- };
3467
- };
3468
- const compileIgnoredFilePatterns = (userConfig) => {
3469
- const files = userConfig?.ignore?.files;
3470
- if (!Array.isArray(files)) return [];
3471
- return compileGlobPatternsLenient(files.filter((entry) => typeof entry === "string"), (error) => warnConfigIssue(`ignore.files: ${error.message}`));
3472
- };
3473
- const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
3474
- if (patterns.length === 0) return false;
3475
- const relativePath = toRelativePath(filePath, rootDirectory);
3476
- return patterns.some((pattern) => pattern.test(relativePath));
3477
- };
3478
- const resolveCandidateReadPath = (rootDirectory, filePath) => {
3479
- const normalizedFile = filePath.replace(/\\/g, "/");
3480
- if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
3481
- return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
3482
- };
3483
- const TEST_FILE_DIRECTORY_PATTERN = /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\//;
3484
- const TEST_FILE_SUFFIX_PATTERN = /\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
3485
- const FIXTURE_PROJECT_PATTERN = /\/(?:fixtures|__fixtures__)\//;
3486
- const SOURCE_ROOT_PATTERN = /\/(?:src|app|lib|components|pages|features|modules|packages|apps|frontend|client)\//g;
3487
- const stripAboveSourceRoot = (relativePath) => {
3488
- const fixtureMatch = FIXTURE_PROJECT_PATTERN.exec(relativePath);
3489
- if (fixtureMatch === null) return relativePath;
3490
- let lastIdx = -1;
3491
- for (const match of relativePath.matchAll(SOURCE_ROOT_PATTERN)) if (match.index !== void 0 && match.index > lastIdx) lastIdx = match.index;
3492
- if (lastIdx >= 0) return relativePath.slice(lastIdx);
3493
- return relativePath.slice(fixtureMatch.index + fixtureMatch[0].length - 1);
3627
+ return false;
3494
3628
  };
3495
- const isTestFilePath = (relativePath) => {
3496
- if (relativePath.length === 0) return false;
3497
- const forwardSlashed = relativePath.replaceAll("\\", "/");
3498
- if (TEST_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return true;
3499
- const scoped = stripAboveSourceRoot(forwardSlashed);
3500
- return TEST_FILE_DIRECTORY_PATTERN.test(scoped);
3629
+ const collectStringSet = (values) => {
3630
+ if (!Array.isArray(values)) return /* @__PURE__ */ new Set();
3631
+ return new Set(values.filter((value) => typeof value === "string"));
3501
3632
  };
3502
3633
  /**
3503
3634
  * Pre-compiles every stateful filter and returns a single
3504
- * `apply(diagnostic)` closure that runs:
3635
+ * `apply(diagnostic)` closure that runs (in order):
3505
3636
  *
3506
3637
  * 1. auto-suppress (test-noise rules in test files; `migration-hint`
3507
3638
  * wins over `test-noise`)
3508
3639
  * 2. severity overrides (top-level `rules` / `categories`, with
3509
3640
  * `"off"` dropping)
3510
3641
  * 3. ignore filters (rules / file patterns / per-file overrides)
3511
- * 4. inline suppressions (`// react-doctor-disable-next-line ...`)
3642
+ * 4. `rn-no-raw-text` suppression via configured `textComponents` and
3643
+ * `rawTextWrapperComponents` (config-driven JSX enclosure checks)
3644
+ * 5. inline suppressions (`// react-doctor-disable-next-line ...`)
3512
3645
  *
3513
3646
  * Returns `null` when the diagnostic is dropped, the (possibly
3514
- * severity-restamped) diagnostic otherwise. Shared by `runInspect`'s
3515
- * streaming pipeline and the array-shaped `mergeAndFilterDiagnostics`
3516
- * single source of truth.
3647
+ * severity-restamped) diagnostic otherwise.
3648
+ *
3649
+ * This is the single source of truth for diagnostic filtering — both
3650
+ * `runInspect`'s streaming pipeline and the array-shaped
3651
+ * `mergeAndFilterDiagnostics` wrapper apply this closure per element.
3517
3652
  */
3518
3653
  const buildDiagnosticPipeline = (input) => {
3519
3654
  const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables } = input;
@@ -3521,6 +3656,10 @@ const buildDiagnosticPipeline = (input) => {
3521
3656
  const ignoredRules = new Set(Array.isArray(userConfig?.ignore?.rules) ? userConfig.ignore.rules.filter((rule) => typeof rule === "string") : []);
3522
3657
  const ignoredFilePatterns = compileIgnoredFilePatterns(userConfig);
3523
3658
  const compiledOverrides = compileIgnoreOverrides(userConfig);
3659
+ const textComponentNames = collectStringSet(userConfig?.textComponents);
3660
+ const rawTextWrapperComponentNames = collectStringSet(userConfig?.rawTextWrapperComponents);
3661
+ const hasTextComponents = textComponentNames.size > 0;
3662
+ const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3524
3663
  const fileLinesCache = /* @__PURE__ */ new Map();
3525
3664
  const testFileCache = /* @__PURE__ */ new Map();
3526
3665
  const getFileLines = (filePath) => {
@@ -3549,6 +3688,16 @@ const buildDiagnosticPipeline = (input) => {
3549
3688
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
3550
3689
  return false;
3551
3690
  };
3691
+ const isRnRawTextSuppressedByConfig = (diagnostic) => {
3692
+ if (diagnostic.rule !== "rn-no-raw-text") return false;
3693
+ if (diagnostic.line <= 0) return false;
3694
+ if (!hasTextComponents && !hasRawTextWrappers) return false;
3695
+ const lines = getFileLines(diagnostic.filePath);
3696
+ if (!lines) return false;
3697
+ if (hasTextComponents && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return true;
3698
+ if (hasRawTextWrappers && isInsideStringOnlyWrapper(lines, diagnostic.line, diagnostic.column, rawTextWrapperComponentNames)) return true;
3699
+ return false;
3700
+ };
3552
3701
  return { apply: (diagnostic) => {
3553
3702
  if (shouldAutoSuppress(diagnostic)) return null;
3554
3703
  let current = diagnostic;
@@ -3565,6 +3714,7 @@ const buildDiagnosticPipeline = (input) => {
3565
3714
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
3566
3715
  if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) return null;
3567
3716
  if (isDiagnosticIgnoredByOverrides(current, rootDirectory, compiledOverrides)) return null;
3717
+ if (isRnRawTextSuppressedByConfig(current)) return null;
3568
3718
  }
3569
3719
  if (respectInlineDisables && current.line > 0) {
3570
3720
  const lines = getFileLines(current.filePath);
@@ -3691,6 +3841,27 @@ var ReactDoctorError = class extends Schema.TaggedErrorClass()("ReactDoctorError
3691
3841
  const formatReactDoctorError = (error) => error.reason.message;
3692
3842
  const isSplittableReactDoctorError = (error) => error instanceof ReactDoctorError && error.reason._tag === "OxlintBatchExceeded";
3693
3843
  const isReactDoctorError = (error) => error instanceof ReactDoctorError;
3844
+ /**
3845
+ * Tagged-reason → legacy thrown-class boundary shared by every public
3846
+ * shell (`inspect()` in `react-doctor`, `diagnose()` in `@react-doctor/api`).
3847
+ *
3848
+ * `Effect.catchReasons` dispatches on the tagged-error sub-channel
3849
+ * without manual `instanceof` checks. Each handler converts a tagged
3850
+ * reason into the historical thrown class advertised by the legacy
3851
+ * public-API contract (via `Effect.die`, which `Effect.runPromise`
3852
+ * re-throws unchanged). The `orElse` branch re-`die`s the original
3853
+ * `ReactDoctorError` instance so advanced callers can still narrow on
3854
+ * `error.reason._tag` while grep-stderr users keep the same
3855
+ * `error.message` they always saw.
3856
+ *
3857
+ * Adding a new legacy thrown class is a one-line change on the
3858
+ * `Effect.catchReasons` map — both shells pick it up automatically.
3859
+ */
3860
+ const restoreLegacyThrow = (effect) => effect.pipe(Effect.catchReasons("ReactDoctorError", {
3861
+ NoReactDependency: (reason) => Effect.die(new NoReactDependencyError(reason.directory)),
3862
+ ProjectNotFound: (reason) => Effect.die(new ProjectNotFoundError(reason.directory)),
3863
+ AmbiguousProject: (reason) => Effect.die(new AmbiguousProjectError(reason.directory, [...reason.candidates]))
3864
+ }, (_reason, error) => Effect.die(error)));
3694
3865
  const TRACER_PROJECT_NAME = "react-doctor";
3695
3866
  const OTEL_ENDPOINT = Config$1.string("REACT_DOCTOR_OTLP_ENDPOINT").pipe(Config$1.option);
3696
3867
  const OTEL_AUTH_HEADER = Config$1.redacted("REACT_DOCTOR_OTLP_AUTH_HEADER").pipe(Config$1.option);
@@ -3733,216 +3904,6 @@ Context.Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => {
3733
3904
  } });
3734
3905
  Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES });
3735
3906
  Context.Reference("react-doctor/StagedFilesTempDirPrefix", { defaultValue: () => "react-doctor-staged-" });
3736
- const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
3737
- const PNPM_LOCKFILE = "pnpm-lock.yaml";
3738
- const PACKAGE_JSON_FILE = "package.json";
3739
- const PNPM_HARDENING_RULE_KEY = "require-pnpm-hardening";
3740
- const UTF8_BOM_CHAR = "";
3741
- const HARDENING_SETTING_KEYS = new Set([
3742
- "minimumReleaseAge",
3743
- "blockExoticSubdeps",
3744
- "trustPolicy"
3745
- ]);
3746
- const stripInlineComment = (rawValue) => {
3747
- let activeQuote = null;
3748
- for (let charIndex = 0; charIndex < rawValue.length; charIndex += 1) {
3749
- const currentChar = rawValue[charIndex];
3750
- if (activeQuote !== null) {
3751
- if (currentChar === activeQuote) activeQuote = null;
3752
- continue;
3753
- }
3754
- if (currentChar === "\"" || currentChar === "'") {
3755
- activeQuote = currentChar;
3756
- continue;
3757
- }
3758
- if (currentChar !== "#") continue;
3759
- const previousChar = rawValue[charIndex - 1];
3760
- if (charIndex === 0 || previousChar !== void 0 && /\s/.test(previousChar)) return rawValue.slice(0, charIndex);
3761
- }
3762
- return rawValue;
3763
- };
3764
- const unquote = (rawValue) => rawValue.replace(/^["']|["']$/g, "");
3765
- const stripBom = (rawContent) => rawContent.startsWith(UTF8_BOM_CHAR) ? rawContent.slice(1) : rawContent;
3766
- const parseHardeningSettings = (content) => {
3767
- let minimumReleaseAge = null;
3768
- let blockExoticSubdeps = null;
3769
- let trustPolicy = null;
3770
- const lines = stripBom(content).split(/\r?\n/);
3771
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
3772
- const lineText = lines[lineIndex];
3773
- if (lineText === void 0) continue;
3774
- if (lineText.search(/\S/) !== 0) continue;
3775
- const trimmedLine = lineText.trim();
3776
- if (trimmedLine.startsWith("#")) continue;
3777
- const colonIndex = trimmedLine.indexOf(":");
3778
- if (colonIndex <= 0) continue;
3779
- const settingKey = unquote(trimmedLine.slice(0, colonIndex).trim());
3780
- if (!HARDENING_SETTING_KEYS.has(settingKey)) continue;
3781
- const inlineValue = stripInlineComment(trimmedLine.slice(colonIndex + 1)).trim();
3782
- if (inlineValue.length === 0) continue;
3783
- const scalar = {
3784
- value: unquote(inlineValue),
3785
- line: lineIndex + 1,
3786
- column: lineText.search(/\S/) + 1
3787
- };
3788
- if (settingKey === "minimumReleaseAge") minimumReleaseAge = scalar;
3789
- else if (settingKey === "blockExoticSubdeps") blockExoticSubdeps = scalar;
3790
- else if (settingKey === "trustPolicy") trustPolicy = scalar;
3791
- }
3792
- return {
3793
- minimumReleaseAge,
3794
- blockExoticSubdeps,
3795
- trustPolicy
3796
- };
3797
- };
3798
- const isPnpmManagedProject = (rootDirectory) => {
3799
- if (isFile(path.join(rootDirectory, PNPM_LOCKFILE))) return true;
3800
- if (isFile(path.join(rootDirectory, PNPM_WORKSPACE_FILE))) return true;
3801
- const packageJsonPath = path.join(rootDirectory, PACKAGE_JSON_FILE);
3802
- if (!isFile(packageJsonPath)) return false;
3803
- try {
3804
- const packageJsonRaw = fs.readFileSync(packageJsonPath, "utf-8");
3805
- const packageJson = JSON.parse(packageJsonRaw);
3806
- if (packageJson !== null && typeof packageJson === "object" && "packageManager" in packageJson && typeof packageJson.packageManager === "string" && packageJson.packageManager.startsWith("pnpm@")) return true;
3807
- } catch {
3808
- return false;
3809
- }
3810
- return false;
3811
- };
3812
- const buildHardeningDiagnostic = (input) => ({
3813
- filePath: PNPM_WORKSPACE_FILE,
3814
- plugin: "react-doctor",
3815
- rule: PNPM_HARDENING_RULE_KEY,
3816
- severity: "warning",
3817
- message: input.message,
3818
- help: input.help,
3819
- line: input.line ?? 0,
3820
- column: input.column ?? 0,
3821
- category: "Security"
3822
- });
3823
- const checkPnpmHardening = (rootDirectory) => {
3824
- if (!isPnpmManagedProject(rootDirectory)) return [];
3825
- const workspacePath = path.join(rootDirectory, PNPM_WORKSPACE_FILE);
3826
- const settings = parseHardeningSettings(isFile(workspacePath) ? fs.readFileSync(workspacePath, "utf-8") : "");
3827
- const diagnostics = [];
3828
- if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
3829
- message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
3830
- help: `Add \`minimumReleaseAge: ${RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES}\` (7 days) to pnpm-workspace.yaml to delay installs until releases have had time to be vetted`
3831
- }));
3832
- if (settings.blockExoticSubdeps !== null && settings.blockExoticSubdeps.value.toLowerCase() === "false") diagnostics.push(buildHardeningDiagnostic({
3833
- line: settings.blockExoticSubdeps.line,
3834
- column: settings.blockExoticSubdeps.column,
3835
- message: "`blockExoticSubdeps: false` allows transitive deps from `git:`, `file:`, or tarball URLs — a known supply-chain bypass of the npm registry",
3836
- help: "Set `blockExoticSubdeps: true` (the default in recent pnpm v11) so transitive deps must come from the registry"
3837
- }));
3838
- if (settings.trustPolicy === null) diagnostics.push(buildHardeningDiagnostic({
3839
- message: "pnpm-workspace.yaml is missing `trustPolicy` — without `no-downgrade`, pnpm silently accepts packages whose trust signals (provenance, signatures) weaken between updates",
3840
- help: "Add `trustPolicy: no-downgrade` to pnpm-workspace.yaml"
3841
- }));
3842
- else if (settings.trustPolicy.value !== "no-downgrade") diagnostics.push(buildHardeningDiagnostic({
3843
- line: settings.trustPolicy.line,
3844
- column: settings.trustPolicy.column,
3845
- message: `\`trustPolicy: ${settings.trustPolicy.value}\` is weaker than \`no-downgrade\` — packages may lose trust signals between updates without you noticing`,
3846
- help: "Set `trustPolicy: no-downgrade` so pnpm refuses to downgrade trust between resolutions"
3847
- }));
3848
- return diagnostics;
3849
- };
3850
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
3851
- const REDUCED_MOTION_FILE_GLOBS = [
3852
- "*.ts",
3853
- "*.tsx",
3854
- "*.js",
3855
- "*.jsx",
3856
- "*.css",
3857
- "*.scss"
3858
- ];
3859
- const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
3860
- filePath: "package.json",
3861
- plugin: "react-doctor",
3862
- rule: "require-reduced-motion",
3863
- severity: "error",
3864
- message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
3865
- help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
3866
- line: 0,
3867
- column: 0,
3868
- category: "Accessibility"
3869
- };
3870
- const checkReducedMotion = (rootDirectory) => {
3871
- const packageJsonPath = path.join(rootDirectory, "package.json");
3872
- if (!isFile(packageJsonPath)) return [];
3873
- let hasMotionLibrary = false;
3874
- try {
3875
- const packageJson = readPackageJson(packageJsonPath);
3876
- const allDependencies = {
3877
- ...packageJson.dependencies,
3878
- ...packageJson.devDependencies
3879
- };
3880
- hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
3881
- } catch {
3882
- return [];
3883
- }
3884
- if (!hasMotionLibrary) return [];
3885
- const result = spawnSync("git", [
3886
- "grep",
3887
- "-ql",
3888
- "-E",
3889
- REDUCED_MOTION_GREP_PATTERN,
3890
- "--",
3891
- ...REDUCED_MOTION_FILE_GLOBS
3892
- ], {
3893
- cwd: rootDirectory,
3894
- stdio: [
3895
- "ignore",
3896
- "pipe",
3897
- "pipe"
3898
- ]
3899
- });
3900
- if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
3901
- if (result.status === 0) return [];
3902
- return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
3903
- };
3904
- const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
3905
- const listSourceFilesViaGit = (rootDirectory) => {
3906
- const result = spawnSync("git", [
3907
- "ls-files",
3908
- "-z",
3909
- "--cached",
3910
- "--others",
3911
- "--exclude-standard"
3912
- ], {
3913
- cwd: rootDirectory,
3914
- encoding: "utf-8",
3915
- maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
3916
- });
3917
- if (result.error || result.status !== 0) return null;
3918
- return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
3919
- };
3920
- const listSourceFilesViaFilesystem = (rootDirectory) => {
3921
- const filePaths = [];
3922
- const stack = [rootDirectory];
3923
- while (stack.length > 0) {
3924
- const currentDirectory = stack.pop();
3925
- const entries = readDirectoryEntries(currentDirectory);
3926
- for (const entry of entries) {
3927
- const absolutePath = path.join(currentDirectory, entry.name);
3928
- if (entry.isDirectory()) {
3929
- if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
3930
- continue;
3931
- }
3932
- if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
3933
- }
3934
- }
3935
- return filePaths;
3936
- };
3937
- const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
3938
- const resolveLintIncludePaths = (rootDirectory, userConfig) => {
3939
- if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
3940
- const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
3941
- return listSourceFiles(rootDirectory).filter((filePath) => {
3942
- if (!JSX_FILE_PATTERN.test(filePath)) return false;
3943
- return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
3944
- });
3945
- };
3946
3907
  const DIAGNOSTIC_SURFACES = [
3947
3908
  "cli",
3948
3909
  "prComment",
@@ -3950,6 +3911,22 @@ const DIAGNOSTIC_SURFACES = [
3950
3911
  "ciFailure"
3951
3912
  ];
3952
3913
  const isDiagnosticSurface = (value) => typeof value === "string" && DIAGNOSTIC_SURFACES.includes(value);
3914
+ /**
3915
+ * Built-in surface exclusions applied before any user config.
3916
+ *
3917
+ * `design`-tagged rules are weak-signal style cleanup — they still ship
3918
+ * to the local CLI so developers see them while editing, but they're
3919
+ * removed from the PR comment surface, the score, and the CI gate so
3920
+ * they can't bury real React findings or fail a build over a Tailwind
3921
+ * shorthand. Override per-surface via `config.surfaces.<surface>` to
3922
+ * promote individual rules back in by tag, category, or rule id.
3923
+ */
3924
+ const DEFAULT_SURFACE_EXCLUDED_TAGS = {
3925
+ cli: [],
3926
+ prComment: ["design"],
3927
+ score: ["design"],
3928
+ ciFailure: ["design"]
3929
+ };
3953
3930
  const VALID_RULE_SEVERITIES = [
3954
3931
  "error",
3955
3932
  "warn",
@@ -4096,63 +4073,346 @@ const loadConfigFromDirectory = (directory) => {
4096
4073
  };
4097
4074
  }
4098
4075
  } catch {
4099
- return null;
4076
+ return null;
4077
+ }
4078
+ return null;
4079
+ };
4080
+ const cachedConfigs = /* @__PURE__ */ new Map();
4081
+ const clearConfigCache = () => {
4082
+ cachedConfigs.clear();
4083
+ };
4084
+ const loadConfigWithSource = (rootDirectory) => {
4085
+ const cached = cachedConfigs.get(rootDirectory);
4086
+ if (cached !== void 0) return cached;
4087
+ const localConfig = loadConfigFromDirectory(rootDirectory);
4088
+ if (localConfig) {
4089
+ cachedConfigs.set(rootDirectory, localConfig);
4090
+ return localConfig;
4091
+ }
4092
+ if (isProjectBoundary(rootDirectory)) {
4093
+ cachedConfigs.set(rootDirectory, null);
4094
+ return null;
4095
+ }
4096
+ let ancestorDirectory = path.dirname(rootDirectory);
4097
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4098
+ const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4099
+ if (ancestorConfig) {
4100
+ cachedConfigs.set(rootDirectory, ancestorConfig);
4101
+ return ancestorConfig;
4102
+ }
4103
+ if (isProjectBoundary(ancestorDirectory)) {
4104
+ cachedConfigs.set(rootDirectory, null);
4105
+ return null;
4106
+ }
4107
+ ancestorDirectory = path.dirname(ancestorDirectory);
4108
+ }
4109
+ cachedConfigs.set(rootDirectory, null);
4110
+ return null;
4111
+ };
4112
+ const resolveConfigRootDir = (config, configSourceDirectory) => {
4113
+ if (!config || !configSourceDirectory) return null;
4114
+ const rawRootDir = config.rootDir;
4115
+ if (typeof rawRootDir !== "string") return null;
4116
+ const trimmedRootDir = rawRootDir.trim();
4117
+ if (trimmedRootDir.length === 0) return null;
4118
+ const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
4119
+ if (resolvedRootDir === configSourceDirectory) return null;
4120
+ if (!isDirectory(resolvedRootDir)) {
4121
+ Effect.runSync(Console.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`));
4122
+ return null;
4123
+ }
4124
+ return resolvedRootDir;
4125
+ };
4126
+ const resolveDiagnoseTarget = (directory) => {
4127
+ if (isFile(path.join(directory, "package.json"))) return directory;
4128
+ const reactSubprojects = discoverReactSubprojects(directory);
4129
+ if (reactSubprojects.length === 0) return null;
4130
+ if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
4131
+ throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
4132
+ };
4133
+ /**
4134
+ * The canonical entry-point translation shared by every public shell
4135
+ * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4136
+ *
4137
+ * 1. Resolve the requested directory to absolute.
4138
+ * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4139
+ * if present.
4140
+ * 3. Honor `config.rootDir` to redirect the scan to a nested
4141
+ * project root, if configured.
4142
+ * 4. Walk into a nested React subproject when the requested
4143
+ * directory has no `package.json` of its own (raises
4144
+ * `AmbiguousProjectError` when multiple candidates exist).
4145
+ *
4146
+ * Throws `ProjectNotFoundError` when neither the requested directory
4147
+ * nor any discoverable nested project has a `package.json`.
4148
+ *
4149
+ * Before this helper existed, the same three-step dance was reproduced
4150
+ * in `api/diagnose.ts`, `react-doctor/inspect.ts`, and the CLI's
4151
+ * `cli/commands/inspect.ts` — each loading the config independently
4152
+ * (the orchestrator's `Config.layerNode` then loads it a fourth time
4153
+ * via its own cache). Routing through `resolveScanTarget` keeps every
4154
+ * shell in agreement on what "the scan directory" means.
4155
+ */
4156
+ const resolveScanTarget = (requestedDirectory) => {
4157
+ const absoluteRequested = path.resolve(requestedDirectory);
4158
+ const loadedConfig = loadConfigWithSource(absoluteRequested);
4159
+ const userConfig = loadedConfig?.config ?? null;
4160
+ const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4161
+ const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
4162
+ const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
4163
+ return {
4164
+ resolvedDirectory: resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect,
4165
+ requestedDirectory: absoluteRequested,
4166
+ userConfig,
4167
+ configSourceDirectory,
4168
+ didRedirectViaRootDir: redirectedDirectory !== null
4169
+ };
4170
+ };
4171
+ const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4172
+ const PNPM_LOCKFILE = "pnpm-lock.yaml";
4173
+ const PACKAGE_JSON_FILE = "package.json";
4174
+ const PNPM_HARDENING_RULE_KEY = "require-pnpm-hardening";
4175
+ const UTF8_BOM_CHAR = "";
4176
+ const HARDENING_SETTING_KEYS = new Set([
4177
+ "minimumReleaseAge",
4178
+ "blockExoticSubdeps",
4179
+ "trustPolicy"
4180
+ ]);
4181
+ const stripInlineComment = (rawValue) => {
4182
+ let activeQuote = null;
4183
+ for (let charIndex = 0; charIndex < rawValue.length; charIndex += 1) {
4184
+ const currentChar = rawValue[charIndex];
4185
+ if (activeQuote !== null) {
4186
+ if (currentChar === activeQuote) activeQuote = null;
4187
+ continue;
4188
+ }
4189
+ if (currentChar === "\"" || currentChar === "'") {
4190
+ activeQuote = currentChar;
4191
+ continue;
4192
+ }
4193
+ if (currentChar !== "#") continue;
4194
+ const previousChar = rawValue[charIndex - 1];
4195
+ if (charIndex === 0 || previousChar !== void 0 && /\s/.test(previousChar)) return rawValue.slice(0, charIndex);
4196
+ }
4197
+ return rawValue;
4198
+ };
4199
+ const unquote = (rawValue) => rawValue.replace(/^["']|["']$/g, "");
4200
+ const stripBom = (rawContent) => rawContent.startsWith(UTF8_BOM_CHAR) ? rawContent.slice(1) : rawContent;
4201
+ const parseHardeningSettings = (content) => {
4202
+ let minimumReleaseAge = null;
4203
+ let blockExoticSubdeps = null;
4204
+ let trustPolicy = null;
4205
+ const lines = stripBom(content).split(/\r?\n/);
4206
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4207
+ const lineText = lines[lineIndex];
4208
+ if (lineText === void 0) continue;
4209
+ if (lineText.search(/\S/) !== 0) continue;
4210
+ const trimmedLine = lineText.trim();
4211
+ if (trimmedLine.startsWith("#")) continue;
4212
+ const colonIndex = trimmedLine.indexOf(":");
4213
+ if (colonIndex <= 0) continue;
4214
+ const settingKey = unquote(trimmedLine.slice(0, colonIndex).trim());
4215
+ if (!HARDENING_SETTING_KEYS.has(settingKey)) continue;
4216
+ const inlineValue = stripInlineComment(trimmedLine.slice(colonIndex + 1)).trim();
4217
+ if (inlineValue.length === 0) continue;
4218
+ const scalar = {
4219
+ value: unquote(inlineValue),
4220
+ line: lineIndex + 1,
4221
+ column: lineText.search(/\S/) + 1
4222
+ };
4223
+ if (settingKey === "minimumReleaseAge") minimumReleaseAge = scalar;
4224
+ else if (settingKey === "blockExoticSubdeps") blockExoticSubdeps = scalar;
4225
+ else if (settingKey === "trustPolicy") trustPolicy = scalar;
4226
+ }
4227
+ return {
4228
+ minimumReleaseAge,
4229
+ blockExoticSubdeps,
4230
+ trustPolicy
4231
+ };
4232
+ };
4233
+ const isPnpmManagedProject = (rootDirectory) => {
4234
+ if (isFile(path.join(rootDirectory, PNPM_LOCKFILE))) return true;
4235
+ if (isFile(path.join(rootDirectory, PNPM_WORKSPACE_FILE))) return true;
4236
+ const packageJsonPath = path.join(rootDirectory, PACKAGE_JSON_FILE);
4237
+ if (!isFile(packageJsonPath)) return false;
4238
+ try {
4239
+ const packageJsonRaw = fs.readFileSync(packageJsonPath, "utf-8");
4240
+ const packageJson = JSON.parse(packageJsonRaw);
4241
+ if (packageJson !== null && typeof packageJson === "object" && "packageManager" in packageJson && typeof packageJson.packageManager === "string" && packageJson.packageManager.startsWith("pnpm@")) return true;
4242
+ } catch {
4243
+ return false;
4244
+ }
4245
+ return false;
4246
+ };
4247
+ const buildHardeningDiagnostic = (input) => ({
4248
+ filePath: PNPM_WORKSPACE_FILE,
4249
+ plugin: "react-doctor",
4250
+ rule: PNPM_HARDENING_RULE_KEY,
4251
+ severity: "warning",
4252
+ message: input.message,
4253
+ help: input.help,
4254
+ line: input.line ?? 0,
4255
+ column: input.column ?? 0,
4256
+ category: "Security"
4257
+ });
4258
+ const checkPnpmHardening = (rootDirectory) => {
4259
+ if (!isPnpmManagedProject(rootDirectory)) return [];
4260
+ const workspacePath = path.join(rootDirectory, PNPM_WORKSPACE_FILE);
4261
+ const settings = parseHardeningSettings(isFile(workspacePath) ? fs.readFileSync(workspacePath, "utf-8") : "");
4262
+ const diagnostics = [];
4263
+ if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
4264
+ message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
4265
+ help: `Add \`minimumReleaseAge: ${RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES}\` (7 days) to pnpm-workspace.yaml to delay installs until releases have had time to be vetted`
4266
+ }));
4267
+ if (settings.blockExoticSubdeps !== null && settings.blockExoticSubdeps.value.toLowerCase() === "false") diagnostics.push(buildHardeningDiagnostic({
4268
+ line: settings.blockExoticSubdeps.line,
4269
+ column: settings.blockExoticSubdeps.column,
4270
+ message: "`blockExoticSubdeps: false` allows transitive deps from `git:`, `file:`, or tarball URLs — a known supply-chain bypass of the npm registry",
4271
+ help: "Set `blockExoticSubdeps: true` (the default in recent pnpm v11) so transitive deps must come from the registry"
4272
+ }));
4273
+ if (settings.trustPolicy === null) diagnostics.push(buildHardeningDiagnostic({
4274
+ message: "pnpm-workspace.yaml is missing `trustPolicy` — without `no-downgrade`, pnpm silently accepts packages whose trust signals (provenance, signatures) weaken between updates",
4275
+ help: "Add `trustPolicy: no-downgrade` to pnpm-workspace.yaml"
4276
+ }));
4277
+ else if (settings.trustPolicy.value !== "no-downgrade") diagnostics.push(buildHardeningDiagnostic({
4278
+ line: settings.trustPolicy.line,
4279
+ column: settings.trustPolicy.column,
4280
+ message: `\`trustPolicy: ${settings.trustPolicy.value}\` is weaker than \`no-downgrade\` — packages may lose trust signals between updates without you noticing`,
4281
+ help: "Set `trustPolicy: no-downgrade` so pnpm refuses to downgrade trust between resolutions"
4282
+ }));
4283
+ return diagnostics;
4284
+ };
4285
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
4286
+ const REDUCED_MOTION_FILE_GLOBS = [
4287
+ "*.ts",
4288
+ "*.tsx",
4289
+ "*.js",
4290
+ "*.jsx",
4291
+ "*.css",
4292
+ "*.scss"
4293
+ ];
4294
+ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
4295
+ filePath: "package.json",
4296
+ plugin: "react-doctor",
4297
+ rule: "require-reduced-motion",
4298
+ severity: "error",
4299
+ message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
4300
+ help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
4301
+ line: 0,
4302
+ column: 0,
4303
+ category: "Accessibility"
4304
+ };
4305
+ const checkReducedMotion = (rootDirectory) => {
4306
+ const packageJsonPath = path.join(rootDirectory, "package.json");
4307
+ if (!isFile(packageJsonPath)) return [];
4308
+ let hasMotionLibrary = false;
4309
+ try {
4310
+ const packageJson = readPackageJson(packageJsonPath);
4311
+ const allDependencies = {
4312
+ ...packageJson.dependencies,
4313
+ ...packageJson.devDependencies
4314
+ };
4315
+ hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
4316
+ } catch {
4317
+ return [];
4100
4318
  }
4101
- return null;
4319
+ if (!hasMotionLibrary) return [];
4320
+ const result = spawnSync("git", [
4321
+ "grep",
4322
+ "-ql",
4323
+ "-E",
4324
+ REDUCED_MOTION_GREP_PATTERN,
4325
+ "--",
4326
+ ...REDUCED_MOTION_FILE_GLOBS
4327
+ ], {
4328
+ cwd: rootDirectory,
4329
+ stdio: [
4330
+ "ignore",
4331
+ "pipe",
4332
+ "pipe"
4333
+ ]
4334
+ });
4335
+ if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
4336
+ if (result.status === 0) return [];
4337
+ return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
4102
4338
  };
4103
- const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
4104
- const cachedConfigs = /* @__PURE__ */ new Map();
4105
- const clearConfigCache = () => {
4106
- cachedConfigs.clear();
4339
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
4340
+ const toStringSet = (values) => {
4341
+ if (!values || values.length === 0) return /* @__PURE__ */ new Set();
4342
+ return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
4343
+ };
4344
+ const buildResolvedControls = (surface, userControls) => {
4345
+ const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
4346
+ const includeTags = toStringSet(userControls?.includeTags);
4347
+ for (const tag of includeTags) excludeTags.delete(tag);
4348
+ for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
4349
+ return {
4350
+ includeTags,
4351
+ excludeTags,
4352
+ includeCategories: toStringSet(userControls?.includeCategories),
4353
+ excludeCategories: toStringSet(userControls?.excludeCategories),
4354
+ includeRuleKeys: toStringSet(userControls?.includeRules),
4355
+ excludeRuleKeys: toStringSet(userControls?.excludeRules)
4356
+ };
4107
4357
  };
4108
- const loadConfigWithSource = (rootDirectory) => {
4109
- const cached = cachedConfigs.get(rootDirectory);
4110
- if (cached !== void 0) return cached;
4111
- const localConfig = loadConfigFromDirectory(rootDirectory);
4112
- if (localConfig) {
4113
- cachedConfigs.set(rootDirectory, localConfig);
4114
- return localConfig;
4115
- }
4116
- if (isProjectBoundary$1(rootDirectory)) {
4117
- cachedConfigs.set(rootDirectory, null);
4118
- return null;
4119
- }
4120
- let ancestorDirectory = path.dirname(rootDirectory);
4121
- while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4122
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4123
- if (ancestorConfig) {
4124
- cachedConfigs.set(rootDirectory, ancestorConfig);
4125
- return ancestorConfig;
4126
- }
4127
- if (isProjectBoundary$1(ancestorDirectory)) {
4128
- cachedConfigs.set(rootDirectory, null);
4129
- return null;
4358
+ const intersects = (values, candidates) => values.some((value) => candidates.has(value));
4359
+ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
4360
+ const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
4361
+ const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
4362
+ if (resolved.includeRuleKeys.has(ruleKey)) return true;
4363
+ if (resolved.includeCategories.has(category)) return true;
4364
+ if (intersects(tags, resolved.includeTags)) return true;
4365
+ if (resolved.excludeRuleKeys.has(ruleKey)) return false;
4366
+ if (resolved.excludeCategories.has(category)) return false;
4367
+ if (intersects(tags, resolved.excludeTags)) return false;
4368
+ return true;
4369
+ };
4370
+ const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
4371
+ const listSourceFilesViaGit = (rootDirectory) => {
4372
+ const result = spawnSync("git", [
4373
+ "ls-files",
4374
+ "-z",
4375
+ "--cached",
4376
+ "--others",
4377
+ "--exclude-standard"
4378
+ ], {
4379
+ cwd: rootDirectory,
4380
+ encoding: "utf-8",
4381
+ maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
4382
+ });
4383
+ if (result.error || result.status !== 0) return null;
4384
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
4385
+ };
4386
+ const listSourceFilesViaFilesystem = (rootDirectory) => {
4387
+ const filePaths = [];
4388
+ const stack = [rootDirectory];
4389
+ while (stack.length > 0) {
4390
+ const currentDirectory = stack.pop();
4391
+ const entries = readDirectoryEntries(currentDirectory);
4392
+ for (const entry of entries) {
4393
+ const absolutePath = path.join(currentDirectory, entry.name);
4394
+ if (entry.isDirectory()) {
4395
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
4396
+ continue;
4397
+ }
4398
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
4130
4399
  }
4131
- ancestorDirectory = path.dirname(ancestorDirectory);
4132
4400
  }
4133
- cachedConfigs.set(rootDirectory, null);
4134
- return null;
4401
+ return filePaths;
4135
4402
  };
4136
- const resolveConfigRootDir = (config, configSourceDirectory) => {
4137
- if (!config || !configSourceDirectory) return null;
4138
- const rawRootDir = config.rootDir;
4139
- if (typeof rawRootDir !== "string") return null;
4140
- const trimmedRootDir = rawRootDir.trim();
4141
- if (trimmedRootDir.length === 0) return null;
4142
- const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
4143
- if (resolvedRootDir === configSourceDirectory) return null;
4144
- if (!isDirectory(resolvedRootDir)) {
4145
- Effect.runSync(Console.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`));
4146
- return null;
4147
- }
4148
- return resolvedRootDir;
4403
+ const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
4404
+ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
4405
+ if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
4406
+ const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
4407
+ return listSourceFiles(rootDirectory).filter((filePath) => {
4408
+ if (!JSX_FILE_PATTERN.test(filePath)) return false;
4409
+ return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
4410
+ });
4149
4411
  };
4150
- const CONFIG_CACHE_CAPACITY = 16;
4151
- const CONFIG_CACHE_TTL_MS = 300 * 1e3;
4152
4412
  var Config = class Config extends Context.Service()("react-doctor/Config") {
4153
4413
  static layerNode = Layer.effect(Config, Effect.gen(function* () {
4154
4414
  const cache = yield* Cache.make({
4155
- capacity: CONFIG_CACHE_CAPACITY,
4415
+ capacity: 16,
4156
4416
  timeToLive: CONFIG_CACHE_TTL_MS,
4157
4417
  lookup: (directory) => Effect.sync(() => {
4158
4418
  const loaded = loadConfigWithSource(directory);
@@ -4795,7 +5055,6 @@ const findFirstLintConfigInDirectory = (directory) => {
4795
5055
  }
4796
5056
  return null;
4797
5057
  };
4798
- const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
4799
5058
  const detectUserLintConfigPaths = (rootDirectory) => {
4800
5059
  const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
4801
5060
  if (directLintConfig) return [directLintConfig];
@@ -4931,7 +5190,6 @@ const shouldEnableRule = (requires, tags, capabilities, ignoredTags, disabledBy)
4931
5190
  }
4932
5191
  return true;
4933
5192
  };
4934
- const esmRequire$1 = createRequire(import.meta.url);
4935
5193
  /**
4936
5194
  * Loads a plugin module via the local require resolver and extracts
4937
5195
  * `(name, ruleNames)` from either `module.exports.meta + rules` or
@@ -4958,15 +5216,16 @@ const readPluginShape = (pluginSpecifier, loadModule) => {
4958
5216
  ruleNames: new Set(Object.keys(rules))
4959
5217
  };
4960
5218
  };
5219
+ const bundledRequire = createRequire(import.meta.url);
4961
5220
  const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
4962
5221
  if (!hasReactCompiler || customRulesOnly) return null;
4963
5222
  let pluginSpecifier;
4964
5223
  try {
4965
- pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-hooks");
5224
+ pluginSpecifier = bundledRequire.resolve("eslint-plugin-react-hooks");
4966
5225
  } catch {
4967
5226
  return null;
4968
5227
  }
4969
- const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => esmRequire$1(spec));
5228
+ const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => bundledRequire(spec));
4970
5229
  return {
4971
5230
  entry: {
4972
5231
  name: "react-hooks-js",
@@ -5660,7 +5919,6 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
5660
5919
  resolve(output);
5661
5920
  });
5662
5921
  });
5663
- const PREVIEW_COUNT = 3;
5664
5922
  /**
5665
5923
  * Runs every prebuilt file batch through oxlint, with binary-split
5666
5924
  * retry on the splittable error classes (timeout / output-too-large /
@@ -5675,7 +5933,8 @@ const PREVIEW_COUNT = 3;
5675
5933
  * with a slimmer config in that case.
5676
5934
  */
5677
5935
  const spawnLintBatches = async (input) => {
5678
- const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure } = input;
5936
+ const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress } = input;
5937
+ const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
5679
5938
  const allDiagnostics = [];
5680
5939
  const droppedFiles = [];
5681
5940
  let firstDropReason = null;
@@ -5694,10 +5953,24 @@ const spawnLintBatches = async (input) => {
5694
5953
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
5695
5954
  }
5696
5955
  };
5697
- for (const batch of fileBatches) allDiagnostics.push(...await spawnLintBatch(batch));
5956
+ let scannedFileCount = 0;
5957
+ for (const batch of fileBatches) {
5958
+ let batchFileIndex = 0;
5959
+ const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
5960
+ if (batchFileIndex < batch.length) {
5961
+ batchFileIndex += 1;
5962
+ onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
5963
+ }
5964
+ }, 50) : null;
5965
+ const batchDiagnostics = await spawnLintBatch(batch);
5966
+ if (progressInterval !== null) clearInterval(progressInterval);
5967
+ allDiagnostics.push(...batchDiagnostics);
5968
+ scannedFileCount += batch.length;
5969
+ onFileProgress?.(scannedFileCount, totalFileCount);
5970
+ }
5698
5971
  if (droppedFiles.length > 0 && onPartialFailure) {
5699
- const previewFiles = droppedFiles.slice(0, PREVIEW_COUNT).join(", ");
5700
- const remainderHint = droppedFiles.length > PREVIEW_COUNT ? `, +${droppedFiles.length - PREVIEW_COUNT} more` : "";
5972
+ const previewFiles = droppedFiles.slice(0, 3).join(", ");
5973
+ const remainderHint = droppedFiles.length > 3 ? `, +${droppedFiles.length - 3} more` : "";
5701
5974
  const reasonHint = firstDropReason ? ` — first failure: ${firstDropReason}` : "";
5702
5975
  onPartialFailure(`${droppedFiles.length} file(s) failed to lint and were skipped (${previewFiles}${remainderHint})${reasonHint}`);
5703
5976
  }
@@ -5817,7 +6090,8 @@ const runOxlint = async (options) => {
5817
6090
  rootDirectory,
5818
6091
  nodeBinaryPath,
5819
6092
  project,
5820
- onPartialFailure
6093
+ onPartialFailure,
6094
+ onFileProgress: options.onFileProgress
5821
6095
  });
5822
6096
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
5823
6097
  try {
@@ -5898,7 +6172,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
5898
6172
  configSourceDirectory: input.configSourceDirectory,
5899
6173
  onPartialFailure: (reason) => {
5900
6174
  collectedFailures.push(reason);
5901
- }
6175
+ },
6176
+ onFileProgress: input.onFileProgress
5902
6177
  }),
5903
6178
  catch: ensureReactDoctorError
5904
6179
  });
@@ -5925,6 +6200,48 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
5925
6200
  return stream;
5926
6201
  } }));
5927
6202
  };
6203
+ var ProgressCapture = class ProgressCapture extends Context.Service()("react-doctor/ProgressCapture") {
6204
+ static layer = Layer.effect(ProgressCapture, Ref.make([]));
6205
+ };
6206
+ /**
6207
+ * `Progress` is the terminal-feedback service. Layer slot for ora
6208
+ * (CLI), log lines, GitHub Action `::group::`, or a no-op for silent
6209
+ * modes. Tests use `layerCapture` to record start/succeed/fail
6210
+ * events into a Ref instead of mocking the underlying spinner module.
6211
+ */
6212
+ var Progress = class Progress extends Context.Service()("react-doctor/Progress") {
6213
+ /**
6214
+ * Layer that uses an injected factory. The cli package provides
6215
+ * its own factory backed by the existing ora-based `spinner.ts`
6216
+ * helper; this layer keeps the core package free of an ora dep.
6217
+ */
6218
+ static layerOra = (factory) => Layer.succeed(Progress, Progress.of({ start: (text) => Effect.sync(() => factory(text)) }));
6219
+ static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
6220
+ update: () => Effect.void,
6221
+ succeed: () => Effect.void,
6222
+ fail: () => Effect.void
6223
+ }) }));
6224
+ static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
6225
+ yield* Ref.update(events, (existing) => [...existing, {
6226
+ _tag: "Started",
6227
+ text
6228
+ }]);
6229
+ return {
6230
+ update: (displayText) => Ref.update(events, (existing) => [...existing, {
6231
+ _tag: "Updated",
6232
+ text: displayText
6233
+ }]),
6234
+ succeed: (displayText) => Ref.update(events, (existing) => [...existing, {
6235
+ _tag: "Succeeded",
6236
+ text: displayText
6237
+ }]),
6238
+ fail: (displayText) => Ref.update(events, (existing) => [...existing, {
6239
+ _tag: "Failed",
6240
+ text: displayText
6241
+ }])
6242
+ };
6243
+ }) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
6244
+ };
5928
6245
  const translateProjectInfoError = (cause, directory) => {
5929
6246
  if (cause instanceof NoReactDependencyError) return new ReactDoctorError({ reason: new NoReactDependency({ directory: cause.directory }) });
5930
6247
  if (cause instanceof ProjectNotFoundError) return new ReactDoctorError({ reason: new ProjectNotFound({ directory: cause.directory }) });
@@ -6112,26 +6429,33 @@ const fileReader = (filesService, rootDirectory) => (filePath) => {
6112
6429
  }));
6113
6430
  return lines === null ? null : [...lines];
6114
6431
  };
6432
+ const LINT_FAIL_TEXT = "Scanning failed (lint, non-fatal).";
6433
+ const LINT_NATIVE_BINDING_FAIL_TEXT = (nodeVersion) => `Scanning failed — oxlint native binding not found (Node ${nodeVersion}).`;
6434
+ const DEAD_CODE_FAIL_TEXT = "Scanning failed (dead-code analysis, non-fatal).";
6435
+ const formatLintFailText = (reasonTag, nodeVersion) => {
6436
+ if (reasonTag === "OxlintUnavailable" || reasonTag === "OxlintSpawnFailed") return LINT_NATIVE_BINDING_FAIL_TEXT(nodeVersion);
6437
+ return LINT_FAIL_TEXT;
6438
+ };
6115
6439
  /**
6116
6440
  * The full inspect orchestration as a single composable Effect.
6117
6441
  *
6118
- * Wires the 8 services into a streaming pipeline:
6442
+ * Phases:
6119
6443
  *
6120
- * Config.resolve(directory)
6121
- * -> Project.discover(resolvedDirectory)
6122
- * -> Git metadata for score attribution
6123
- * -> Stream.fromIterable(env diagnostics: reduced-motion + pnpm hardening)
6124
- * -> Stream.concat(Linter.run(...)) [folds ReactDoctorError into Ref]
6125
- * -> Stream.concat(DeadCode.run(...)) [folds Error into Ref]
6126
- * -> Stream.filterMap(perElementPipeline.apply) [auto-suppress / severity / ignore / inline]
6127
- * -> Stream.tap(Reporter.emit) [side-channel: future LSP / NDJSON]
6128
- * -> Stream.runCollect
6129
- * -> Score.compute(filtered)
6444
+ * 1. Config.resolve(directory) → Project.discover → Git metadata
6445
+ * 2. beforeLint hook (e.g. CLI renders the project-detection block)
6446
+ * 3. environment checks (reduced-motion + pnpm hardening)
6447
+ * 4. Linter.run + DeadCode.run forked as concurrent fibers so
6448
+ * their wall-clock times overlap. Progress spinners stay
6449
+ * sequential (lint first, then dead-code) for clean terminal
6450
+ * output. GitHub viewer permission also runs as a background
6451
+ * fiber during this phase.
6452
+ * 5. afterLint hook
6453
+ * 6. Reporter.finalize
6454
+ * 7. Score.compute against the surface-filtered diagnostic set
6130
6455
  *
6131
- * Lint and dead-code failures are folded via `Stream.catchTag` into
6132
- * Ref state on the orchestrator side — they don't sink the whole
6133
- * scan, and the renderer (cli or programmatic api) surfaces them
6134
- * via `skippedCheckReasons`.
6456
+ * The orchestrator owns spinner lifecycle via `Progress`; callers
6457
+ * choose `Progress.layerOra(...)` for CLI feedback or
6458
+ * `Progress.layerNoop` for silent / programmatic runs.
6135
6459
  */
6136
6460
  const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6137
6461
  const projectService = yield* Project;
@@ -6142,6 +6466,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6142
6466
  const scoreService = yield* Score;
6143
6467
  const deadCodeService = yield* DeadCode;
6144
6468
  const gitService = yield* Git;
6469
+ const progressService = yield* Progress;
6145
6470
  const partialFailuresRef = yield* LintPartialFailures;
6146
6471
  const resolvedConfig = yield* configService.resolve(input.directory);
6147
6472
  const scanDirectory = resolvedConfig.resolvedDirectory;
@@ -6153,25 +6478,24 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6153
6478
  gitService.defaultBranch(scanDirectory).pipe(Effect.orElseSucceed(() => null))
6154
6479
  ], { concurrency: 3 });
6155
6480
  const githubActionsScoreMetadata = input.isCi ? resolveGithubActionsScoreMetadata() : {};
6156
- const githubViewerPermission = input.resolveLocalGithubViewerPermission === true && !input.isCi && repo !== null ? yield* gitService.githubViewerPermission({
6481
+ const githubViewerPermissionFiber = yield* Effect.forkChild(input.resolveLocalGithubViewerPermission === true && !input.isCi && repo !== null ? gitService.githubViewerPermission({
6157
6482
  directory: scanDirectory,
6158
6483
  repo
6159
- }).pipe(Effect.orElseSucceed(() => null)) : null;
6160
- const scoreMetadata = {
6161
- ...repo !== null ? { repo } : {},
6162
- ...sha !== null ? { sha } : {},
6163
- framework: project.framework,
6164
- ...project.reactVersion !== null ? { reactVersion: project.reactVersion } : {},
6165
- sourceFileCount: project.sourceFileCount,
6166
- ...defaultBranch !== null ? { defaultBranch } : {},
6167
- ...input.doctorVersion !== void 0 ? { doctorVersion: input.doctorVersion } : {},
6168
- ...githubActionsScoreMetadata,
6169
- ...githubViewerPermission !== null ? { githubViewerPermission } : {}
6170
- };
6484
+ }).pipe(Effect.orElseSucceed(() => null)) : Effect.succeed(null));
6171
6485
  const lintIncludePaths = computeJsxIncludePaths([...input.includePaths]) ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config);
6172
6486
  const beforeLint = hooks.beforeLint ?? NO_HOOKS.beforeLint;
6173
6487
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
6174
6488
  yield* beforeLint(project, lintIncludePaths ?? void 0);
6489
+ const isDiffMode = input.includePaths.length > 0;
6490
+ const transform = buildDiagnosticPipeline({
6491
+ rootDirectory: scanDirectory,
6492
+ userConfig: resolvedConfig.config,
6493
+ readFileLinesSync: fileReader(filesService, scanDirectory),
6494
+ respectInlineDisables: input.respectInlineDisables
6495
+ });
6496
+ const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
6497
+ const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
6498
+ const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
6175
6499
  const lintFailure = yield* Ref.make({
6176
6500
  didFail: false,
6177
6501
  reason: null,
@@ -6181,15 +6505,20 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6181
6505
  didFail: false,
6182
6506
  reason: null
6183
6507
  });
6184
- const isDiffMode = input.includePaths.length > 0;
6185
- const transform = buildDiagnosticPipeline({
6508
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode;
6509
+ const deadCodeFiber = yield* Effect.forkChild(shouldRunDeadCode ? Stream.runCollect(applyPerElementPipeline(deadCodeService.run({
6186
6510
  rootDirectory: scanDirectory,
6187
- userConfig: resolvedConfig.config,
6188
- readFileLinesSync: fileReader(filesService, scanDirectory),
6189
- respectInlineDisables: input.respectInlineDisables
6190
- });
6191
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
6192
- const emptyDiagnosticStream = Stream.empty;
6511
+ userConfig: resolvedConfig.config
6512
+ }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
6513
+ yield* Ref.set(deadCodeFailure, {
6514
+ didFail: true,
6515
+ reason: error.message
6516
+ });
6517
+ return Stream.empty;
6518
+ })))))) : Effect.succeed([]));
6519
+ const scanProgress = yield* progressService.start("Scanning...");
6520
+ const scanStartTime = Date.now();
6521
+ let lastReportedTotalFileCount = 0;
6193
6522
  const rawLintStream = linterService.run({
6194
6523
  rootDirectory: scanDirectory,
6195
6524
  project,
@@ -6200,34 +6529,54 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6200
6529
  adoptExistingLintConfig: input.adoptExistingLintConfig,
6201
6530
  ignoredTags: input.ignoredTags,
6202
6531
  userConfig: resolvedConfig.config ?? void 0,
6203
- configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0
6532
+ configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
6533
+ onFileProgress: (scannedFileCount, totalFileCount) => {
6534
+ lastReportedTotalFileCount = totalFileCount;
6535
+ Effect.runSync(scanProgress.update(`Scanning (${scannedFileCount}/${totalFileCount})...`));
6536
+ }
6204
6537
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
6205
6538
  yield* Ref.set(lintFailure, {
6206
6539
  didFail: true,
6207
6540
  reason: error.message,
6208
6541
  reasonTag: error.reason._tag
6209
6542
  });
6210
- return emptyDiagnosticStream;
6543
+ return Stream.empty;
6211
6544
  }))));
6212
- const deadCodeStream = input.runDeadCode && !isDiffMode ? deadCodeService.run({
6213
- rootDirectory: scanDirectory,
6214
- userConfig: resolvedConfig.config
6215
- }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
6216
- yield* Ref.set(deadCodeFailure, {
6217
- didFail: true,
6218
- reason: error.message
6219
- });
6220
- return emptyDiagnosticStream;
6221
- })))) : emptyDiagnosticStream;
6222
- const transformedStream = Stream.fromIterable(environmentDiagnostics).pipe(Stream.concat(rawLintStream), Stream.concat(deadCodeStream), Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
6223
- const survivingDiagnostics = yield* Stream.runCollect(transformedStream);
6224
- yield* reporterService.finalize;
6545
+ const lintCollected = yield* Stream.runCollect(applyPerElementPipeline(rawLintStream));
6225
6546
  const lintFailureState = yield* Ref.get(lintFailure);
6226
- const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
6227
6547
  yield* afterLint(lintFailureState.didFail);
6228
- const finalDiagnostics = [...survivingDiagnostics];
6548
+ if (lintFailureState.didFail) {
6549
+ yield* Fiber.interrupt(deadCodeFiber);
6550
+ yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
6551
+ }
6552
+ const deadCodeCollected = lintFailureState.didFail ? [] : yield* Fiber.join(deadCodeFiber);
6553
+ const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
6554
+ const scanElapsedSeconds = ((Date.now() - scanStartTime) / 1e3).toFixed(1);
6555
+ const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
6556
+ if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
6557
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
6558
+ yield* reporterService.finalize;
6559
+ const finalDiagnostics = [
6560
+ ...envCollected,
6561
+ ...lintCollected,
6562
+ ...deadCodeCollected
6563
+ ];
6564
+ const githubViewerPermission = yield* Fiber.join(githubViewerPermissionFiber);
6565
+ const scoreMetadata = {
6566
+ ...repo !== null ? { repo } : {},
6567
+ ...sha !== null ? { sha } : {},
6568
+ framework: project.framework,
6569
+ ...project.reactVersion !== null ? { reactVersion: project.reactVersion } : {},
6570
+ sourceFileCount: project.sourceFileCount,
6571
+ ...defaultBranch !== null ? { defaultBranch } : {},
6572
+ ...input.doctorVersion !== void 0 ? { doctorVersion: input.doctorVersion } : {},
6573
+ ...githubActionsScoreMetadata,
6574
+ ...githubViewerPermission !== null ? { githubViewerPermission } : {}
6575
+ };
6576
+ const scoreSurface = input.scoreSurface ?? "score";
6577
+ const scoreDiagnostics = filterDiagnosticsForSurface([...finalDiagnostics], scoreSurface, resolvedConfig.config);
6229
6578
  const score = lintFailureState.didFail ? null : yield* scoreService.compute({
6230
- diagnostics: finalDiagnostics,
6579
+ diagnostics: scoreDiagnostics,
6231
6580
  isCi: input.isCi,
6232
6581
  metadata: scoreMetadata
6233
6582
  });
@@ -6250,9 +6599,25 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6250
6599
  "inspect.directory": input.directory,
6251
6600
  "inspect.includePathCount": input.includePaths.length,
6252
6601
  "inspect.runDeadCode": input.runDeadCode,
6253
- "inspect.isCi": input.isCi
6602
+ "inspect.isCi": input.isCi,
6603
+ "inspect.scoreSurface": input.scoreSurface ?? "score"
6254
6604
  } }));
6255
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Reporter.layerNoop, Score.layerHttp);
6605
+ /**
6606
+ * Default layer stack for the production CLI / programmatic API:
6607
+ * real Node-side services for Project / Config / Files / Git / Linter /
6608
+ * DeadCode; HTTP for Score; noop Progress (the CLI overrides with
6609
+ * `Progress.layerOra(...)` for terminal feedback); the silent Reporter
6610
+ * (the orchestrator already returns the diagnostic array via
6611
+ * `Stream.runCollect`).
6612
+ *
6613
+ * Callers tweak by replacing individual layers: `--no-score` swaps
6614
+ * `Score.layerHttp` for `Score.layerOf(null)`; `--no-lint` swaps
6615
+ * `Linter.layerOxlint` for `Linter.layerOf([])`; `--no-dead-code`
6616
+ * swaps `DeadCode.layerNode` for `DeadCode.layerOf([])`; a caller
6617
+ * with a pre-loaded config swaps `Config.layerNode` for
6618
+ * `Config.layerOf(resolved)`.
6619
+ */
6620
+ const layerInspectLive = Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
6256
6621
  const parseNodeVersion = (versionString) => {
6257
6622
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
6258
6623
  return {
@@ -6352,37 +6717,6 @@ const resolveNodeForOxlint = () => {
6352
6717
  });
6353
6718
  });
6354
6719
  });
6355
- var ProgressCapture = class ProgressCapture extends Context.Service()("react-doctor/ProgressCapture") {
6356
- static layer = Layer.effect(ProgressCapture, Ref.make([]));
6357
- };
6358
- (class Progress extends Context.Service()("react-doctor/Progress") {
6359
- /**
6360
- * Layer that uses an injected factory. The cli package provides
6361
- * its own factory backed by the existing ora-based `spinner.ts`
6362
- * helper; this layer keeps the core package free of an ora dep.
6363
- */
6364
- static layerOra = (factory) => Layer.succeed(Progress, Progress.of({ start: (text) => Effect.sync(() => factory(text)) }));
6365
- static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
6366
- succeed: () => Effect.void,
6367
- fail: () => Effect.void
6368
- }) }));
6369
- static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
6370
- yield* Ref.update(events, (existing) => [...existing, {
6371
- _tag: "Started",
6372
- text
6373
- }]);
6374
- return {
6375
- succeed: (displayText) => Ref.update(events, (existing) => [...existing, {
6376
- _tag: "Succeeded",
6377
- text: displayText
6378
- }]),
6379
- fail: (displayText) => Ref.update(events, (existing) => [...existing, {
6380
- _tag: "Failed",
6381
- text: displayText
6382
- }])
6383
- };
6384
- }) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
6385
- });
6386
6720
  /**
6387
6721
  * Zip-Slip defense: `git diff --cached --name-only` is the source
6388
6722
  * of `relativePath`, and git normalizes paths during ordinary
@@ -6581,10 +6915,6 @@ const buildJsonReport = (input) => {
6581
6915
  error: null
6582
6916
  };
6583
6917
  };
6584
- const testFileResultCache = /* @__PURE__ */ new Map();
6585
- const clearAutoSuppressionCaches = () => {
6586
- testFileResultCache.clear();
6587
- };
6588
6918
  /**
6589
6919
  * Programmatic façade over `Git.diffSelection`. Async because the
6590
6920
  * Git service runs through Effect's `ChildProcess` (true subprocess
@@ -6681,49 +7011,32 @@ var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin((
6681
7011
  module.exports.createColors = createColors;
6682
7012
  })))(), 1);
6683
7013
  import_picocolors.default.red, import_picocolors.default.yellow, import_picocolors.default.cyan, import_picocolors.default.green, import_picocolors.default.dim, import_picocolors.default.gray, import_picocolors.default.bold;
6684
- const resolveDiagnoseTarget = (directory) => {
6685
- if (isFile(path.join(directory, "package.json"))) return directory;
6686
- const reactSubprojects = discoverReactSubprojects(directory);
6687
- if (reactSubprojects.length === 0) return null;
6688
- if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
6689
- throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
6690
- };
7014
+ /**
7015
+ * Back-compat alias: the streaming pipeline holds its caches in
7016
+ * per-pipeline closures that are garbage-collected when the pipeline
7017
+ * goes out of scope, so there is nothing to clear at module scope. The
7018
+ * public CLI's `clearCaches()` still calls this for symmetry with the
7019
+ * other `clear*` helpers.
7020
+ */
7021
+ const clearAutoSuppressionCaches = () => {};
6691
7022
  //#endregion
6692
7023
  //#region ../api/dist/index.js
6693
- const buildLayerStack = () => Layer.mergeAll(Project.layerNode, Config.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, DeadCode.layerNode, Score.layerHttp, Reporter.layerNoop);
6694
7024
  const diagnose = async (directory, options = {}) => {
6695
7025
  const startTime = globalThis.performance.now();
6696
- const requestedDirectory = path.resolve(directory);
6697
- /**
6698
- * Pre-resolve the rootDir redirect + auto-fallback to nested React
6699
- * subprojects BEFORE handing off to runInspect. These two
6700
- * directory-shape concerns predate the project-discovery boundary:
6701
- * the rootDir redirect happens against the config (which lives at
6702
- * the requested directory), and resolveDiagnoseTarget walks down to
6703
- * find a nested React project when the requested directory itself
6704
- * lacks a package.json. runInspect itself only knows "go discover
6705
- * the project at this directory".
6706
- */
6707
- const initialLoadedConfig = loadConfigWithSource(requestedDirectory);
6708
- const directoryAfterRedirect = resolveConfigRootDir(initialLoadedConfig?.config ?? null, initialLoadedConfig?.sourceDirectory ?? null) ?? requestedDirectory;
6709
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect);
6710
- if (!resolvedDirectory) throw new ProjectNotFoundError(directoryAfterRedirect);
7026
+ const scanTarget = resolveScanTarget(directory);
7027
+ const includePaths = options.includePaths ?? [];
6711
7028
  const program = runInspect({
6712
- directory: resolvedDirectory,
6713
- includePaths: options.includePaths ?? [],
6714
- customRulesOnly: initialLoadedConfig?.config?.customRulesOnly ?? false,
6715
- respectInlineDisables: options.respectInlineDisables ?? initialLoadedConfig?.config?.respectInlineDisables ?? true,
6716
- adoptExistingLintConfig: initialLoadedConfig?.config?.adoptExistingLintConfig ?? true,
6717
- ignoredTags: new Set(initialLoadedConfig?.config?.ignore?.tags ?? []),
6718
- runDeadCode: options.deadCode ?? initialLoadedConfig?.config?.deadCode ?? true,
7029
+ directory: scanTarget.resolvedDirectory,
7030
+ includePaths,
7031
+ customRulesOnly: scanTarget.userConfig?.customRulesOnly ?? false,
7032
+ respectInlineDisables: options.respectInlineDisables ?? scanTarget.userConfig?.respectInlineDisables ?? true,
7033
+ adoptExistingLintConfig: scanTarget.userConfig?.adoptExistingLintConfig ?? true,
7034
+ ignoredTags: new Set(scanTarget.userConfig?.ignore?.tags ?? []),
7035
+ runDeadCode: options.deadCode ?? scanTarget.userConfig?.deadCode ?? true,
6719
7036
  isCi: false,
6720
7037
  resolveLocalGithubViewerPermission: true
6721
7038
  });
6722
- const output = await Effect.runPromise(program.pipe(Effect.provide(buildLayerStack()), Effect.provide(layerOtlp), Effect.catchReasons("ReactDoctorError", {
6723
- NoReactDependency: (reason) => Effect.die(new NoReactDependencyError(reason.directory)),
6724
- ProjectNotFound: (reason) => Effect.die(new ProjectNotFoundError(reason.directory)),
6725
- AmbiguousProject: (reason) => Effect.die(new AmbiguousProjectError(reason.directory, [...reason.candidates]))
6726
- }, (_reason, error) => Effect.die(new Error(error.message)))));
7039
+ const output = await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(layerInspectLive), Effect.provide(layerOtlp))));
6727
7040
  if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
6728
7041
  const skippedChecks = [];
6729
7042
  const skippedCheckReasons = {};