react-doctor 0.2.5 → 0.2.7

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.
@@ -4,23 +4,24 @@ import { spawn, spawnSync } from "node:child_process";
4
4
  import * as Path from "node:path";
5
5
  import path from "node:path";
6
6
  import * as fs$1 from "node:fs";
7
- import fs, { existsSync, readdirSync } from "node:fs";
7
+ import fs, { existsSync, readFileSync, readdirSync } from "node:fs";
8
8
  import * as Schema from "effect/Schema";
9
9
  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";
10
10
  import * as Cause from "effect/Cause";
11
- import * as Config$1 from "effect/Config";
12
11
  import * as Effect from "effect/Effect";
12
+ import * as Config$1 from "effect/Config";
13
13
  import * as Layer from "effect/Layer";
14
14
  import * as Redacted from "effect/Redacted";
15
15
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
16
16
  import * as Otlp from "effect/unstable/observability/Otlp";
17
17
  import * as Context from "effect/Context";
18
+ import * as Console from "effect/Console";
19
+ import * as Fiber from "effect/Fiber";
18
20
  import * as Filter from "effect/Filter";
19
21
  import * as Option from "effect/Option";
20
22
  import * as Ref from "effect/Ref";
21
23
  import * as Stream from "effect/Stream";
22
24
  import * as Cache from "effect/Cache";
23
- import * as Console from "effect/Console";
24
25
  import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner";
25
26
  import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
26
27
  import * as NodePath from "@effect/platform-node-shared/NodePath";
@@ -2165,6 +2166,17 @@ const findMonorepoRoot = (startDirectory) => {
2165
2166
  }
2166
2167
  return null;
2167
2168
  };
2169
+ /**
2170
+ * True when `directory` looks like a project root we shouldn't walk
2171
+ * past — either the working tree's git root (a `.git` entry sits
2172
+ * here) or an npm/pnpm/yarn/bun monorepo root.
2173
+ *
2174
+ * Used as the stop-condition for the ancestor walks performed by
2175
+ * `detectUserLintConfigPaths`, `loadConfigWithSource`, and
2176
+ * `detectReactCompiler`. All three previously inlined their own
2177
+ * byte-equivalent copy.
2178
+ */
2179
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
2168
2180
  const REACT_COMPILER_PACKAGES = new Set([
2169
2181
  "babel-plugin-react-compiler",
2170
2182
  "react-compiler-runtime",
@@ -2215,24 +2227,20 @@ const hasCompilerInConfigFile = (filePath) => {
2215
2227
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
2216
2228
  };
2217
2229
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
2218
- const isProjectBoundary$2 = (directory) => {
2219
- if (fs.existsSync(path.join(directory, ".git"))) return true;
2220
- return isMonorepoRoot(directory);
2221
- };
2222
2230
  const detectReactCompiler = (directory, packageJson) => {
2223
2231
  if (hasCompilerPackage(packageJson)) return true;
2224
2232
  if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
2225
2233
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
2226
2234
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
2227
2235
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
2228
- if (isProjectBoundary$2(directory)) return false;
2236
+ if (isProjectBoundary(directory)) return false;
2229
2237
  let ancestorDirectory = path.dirname(directory);
2230
2238
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
2231
2239
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
2232
2240
  if (isFile(ancestorPackagePath)) {
2233
2241
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
2234
2242
  }
2235
- if (isProjectBoundary$2(ancestorDirectory)) return false;
2243
+ if (isProjectBoundary(ancestorDirectory)) return false;
2236
2244
  ancestorDirectory = path.dirname(ancestorDirectory);
2237
2245
  }
2238
2246
  return false;
@@ -2956,10 +2964,10 @@ const isTailwindAtLeast = (detected, required) => {
2956
2964
  return detected.minor >= required.minor;
2957
2965
  };
2958
2966
  const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
2959
- const MILLISECONDS_PER_SECOND = 1e3;
2960
2967
  const SCORE_API_URL = "https://www.react.doctor/api/score";
2961
2968
  const SHARE_BASE_URL = "https://www.react.doctor/share";
2962
2969
  const FETCH_TIMEOUT_MS = 1e4;
2970
+ const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
2963
2971
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
2964
2972
  const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
2965
2973
  const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
@@ -2985,6 +2993,7 @@ const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
2985
2993
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
2986
2994
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
2987
2995
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
2996
+ const CONFIG_CACHE_TTL_MS = 300 * 1e3;
2988
2997
  var InvalidGlobPatternError = class extends Error {
2989
2998
  pattern;
2990
2999
  reason;
@@ -3079,6 +3088,18 @@ const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) =>
3079
3088
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
3080
3089
  return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
3081
3090
  };
3091
+ const SEVERITY_FOR_OVERRIDE = {
3092
+ error: "error",
3093
+ warn: "warning"
3094
+ };
3095
+ const restampSeverity = (diagnostic, override) => {
3096
+ const targetSeverity = SEVERITY_FOR_OVERRIDE[override];
3097
+ if (diagnostic.severity === targetSeverity) return diagnostic;
3098
+ return {
3099
+ ...diagnostic,
3100
+ severity: targetSeverity
3101
+ };
3102
+ };
3082
3103
  /**
3083
3104
  * Assembles the internal `RuleSeverityControls` shape from a user
3084
3105
  * config's top-level `rules` and `categories` fields — the
@@ -3096,25 +3117,115 @@ const buildRuleSeverityControls = (config) => {
3096
3117
  ...config.categories !== void 0 ? { categories: config.categories } : {}
3097
3118
  };
3098
3119
  };
3099
- /**
3100
- * Projects a diagnostic onto the three axes rule-targeted controls
3101
- * reason about:
3102
- *
3103
- * - `ruleKey` the fully-qualified `"<plugin>/<rule>"` form users
3104
- * put in config files (consumed by top-level `rules` severity and
3105
- * `surfaces.*.{include,exclude}Rules`).
3106
- * - `category` the diagnostic's category label (consumed by
3107
- * top-level `categories` severity and
3108
- * `surfaces.*.{include,exclude}Categories`).
3109
- * - `tags` — behavioral tags from the rule registry (consumed by
3110
- * `ignore.tags` and `surfaces.*.{include,exclude}Tags`). Empty
3111
- * for non-`react-doctor` plugins.
3112
- */
3113
- const getDiagnosticRuleIdentity = (diagnostic) => ({
3114
- ruleKey: `${diagnostic.plugin}/${diagnostic.rule}`,
3115
- category: diagnostic.category,
3116
- tags: diagnostic.plugin === "react-doctor" ? reactDoctorPlugin.rules[diagnostic.rule]?.tags ?? [] : []
3117
- });
3120
+ const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
3121
+ const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
3122
+ const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
3123
+ let stringDelimiter = null;
3124
+ for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
3125
+ const character = line[charIndex];
3126
+ if (stringDelimiter !== null) {
3127
+ if (character === "\\") {
3128
+ charIndex++;
3129
+ continue;
3130
+ }
3131
+ if (character === stringDelimiter) stringDelimiter = null;
3132
+ continue;
3133
+ }
3134
+ if (character === "\"" || character === "'" || character === "`") {
3135
+ stringDelimiter = character;
3136
+ continue;
3137
+ }
3138
+ if (character === "/" && line[charIndex + 1] === "/") return true;
3139
+ }
3140
+ return false;
3141
+ };
3142
+ const findOpenerTagOnLine = (line) => {
3143
+ for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
3144
+ if (match.index === void 0) continue;
3145
+ if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
3146
+ }
3147
+ return null;
3148
+ };
3149
+ const findJsxOpenerSpan = (lines, openerLineIndex) => {
3150
+ const openerLine = lines[openerLineIndex];
3151
+ if (openerLine === void 0) return null;
3152
+ const opener = findOpenerTagOnLine(openerLine);
3153
+ if (!opener) return null;
3154
+ const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
3155
+ let braceDepth = 0;
3156
+ let innerAngleDepth = 0;
3157
+ let stringDelimiter = null;
3158
+ for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
3159
+ const currentLine = lines[lineIndex];
3160
+ const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
3161
+ for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
3162
+ const character = currentLine[charIndex];
3163
+ if (stringDelimiter !== null) {
3164
+ if (character === "\\") {
3165
+ charIndex++;
3166
+ continue;
3167
+ }
3168
+ if (character === stringDelimiter) stringDelimiter = null;
3169
+ continue;
3170
+ }
3171
+ if (character === "\"" || character === "'" || character === "`") {
3172
+ stringDelimiter = character;
3173
+ continue;
3174
+ }
3175
+ if (character === "{") {
3176
+ braceDepth++;
3177
+ continue;
3178
+ }
3179
+ if (character === "}") {
3180
+ braceDepth--;
3181
+ continue;
3182
+ }
3183
+ if (braceDepth !== 0) continue;
3184
+ if (character === "<") {
3185
+ const followCharacter = currentLine[charIndex + 1];
3186
+ if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
3187
+ continue;
3188
+ }
3189
+ if (character !== ">") continue;
3190
+ const previousCharacter = currentLine[charIndex - 1];
3191
+ const nextCharacter = currentLine[charIndex + 1];
3192
+ if (previousCharacter === "=" || nextCharacter === "=") continue;
3193
+ if (innerAngleDepth > 0) {
3194
+ innerAngleDepth--;
3195
+ continue;
3196
+ }
3197
+ return lineIndex;
3198
+ }
3199
+ }
3200
+ return null;
3201
+ };
3202
+ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
3203
+ for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
3204
+ const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
3205
+ if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
3206
+ }
3207
+ return null;
3208
+ };
3209
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3210
+ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
3211
+ const collected = [];
3212
+ let isStillInChain = true;
3213
+ for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
3214
+ const candidateLine = lines[candidateIndex];
3215
+ if (candidateLine === void 0) break;
3216
+ const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
3217
+ if (match) {
3218
+ collected.push({
3219
+ commentLineIndex: candidateIndex,
3220
+ ruleList: match[1],
3221
+ isInChain: isStillInChain
3222
+ });
3223
+ continue;
3224
+ }
3225
+ isStillInChain = false;
3226
+ }
3227
+ return collected;
3228
+ };
3118
3229
  const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
3119
3230
  "effect/no-adjust-state-on-prop-change": "react-doctor/no-adjust-state-on-prop-change",
3120
3231
  "effect/no-chain-state-updates": "react-doctor/no-chain-state-updates",
@@ -3245,6 +3356,111 @@ const getEquivalentRuleKeys = (ruleKey) => {
3245
3356
  const nativeRuleKey = canonicalizeRuleKey(ruleKey);
3246
3357
  return [nativeRuleKey, ...getLegacyRuleKeysForNative(nativeRuleKey)];
3247
3358
  };
3359
+ const stripDescriptionTail = (ruleList) => {
3360
+ const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
3361
+ if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
3362
+ return ruleList.slice(0, descriptionMatch.index);
3363
+ };
3364
+ const isRuleListedInComment = (ruleList, ruleId) => {
3365
+ const trimmed = ruleList?.trim();
3366
+ if (!trimmed) return true;
3367
+ const ruleSection = stripDescriptionTail(trimmed).trim();
3368
+ if (!ruleSection) return true;
3369
+ return ruleSection.split(/[,\s]+/).some((token) => isSameRuleKey(token.trim(), ruleId));
3370
+ };
3371
+ const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3372
+ const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
3373
+ const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
3374
+ const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
3375
+ const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
3376
+ const buildAdjacentMismatchHint = (comment, ruleId) => {
3377
+ const ruleListText = comment.ruleList?.trim() ?? "";
3378
+ 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}`;
3379
+ };
3380
+ const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
3381
+ const commentLineNumber = comment.commentLineIndex + 1;
3382
+ const diagnosticLineNumber = diagnosticLineIndex + 1;
3383
+ 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.`;
3384
+ };
3385
+ const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
3386
+ for (const comments of commentsByAnchor) {
3387
+ const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
3388
+ if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
3389
+ const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
3390
+ if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
3391
+ }
3392
+ return null;
3393
+ };
3394
+ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
3395
+ const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
3396
+ if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
3397
+ isSuppressed: true,
3398
+ nearMissHint: null
3399
+ };
3400
+ const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
3401
+ if (hasChainSuppressor(directComments, ruleId)) return {
3402
+ isSuppressed: true,
3403
+ nearMissHint: null
3404
+ };
3405
+ const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
3406
+ const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
3407
+ if (hasChainSuppressor(openerComments, ruleId)) return {
3408
+ isSuppressed: true,
3409
+ nearMissHint: null
3410
+ };
3411
+ return {
3412
+ isSuppressed: false,
3413
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
3414
+ };
3415
+ };
3416
+ /**
3417
+ * Projects a diagnostic onto the three axes rule-targeted controls
3418
+ * reason about:
3419
+ *
3420
+ * - `ruleKey` — the fully-qualified `"<plugin>/<rule>"` form users
3421
+ * put in config files (consumed by top-level `rules` severity and
3422
+ * `surfaces.*.{include,exclude}Rules`).
3423
+ * - `category` — the diagnostic's category label (consumed by
3424
+ * top-level `categories` severity and
3425
+ * `surfaces.*.{include,exclude}Categories`).
3426
+ * - `tags` — behavioral tags from the rule registry (consumed by
3427
+ * `ignore.tags` and `surfaces.*.{include,exclude}Tags`). Empty
3428
+ * for non-`react-doctor` plugins.
3429
+ */
3430
+ const getDiagnosticRuleIdentity = (diagnostic) => ({
3431
+ ruleKey: `${diagnostic.plugin}/${diagnostic.rule}`,
3432
+ category: diagnostic.category,
3433
+ tags: diagnostic.plugin === "react-doctor" ? reactDoctorPlugin.rules[diagnostic.rule]?.tags ?? [] : []
3434
+ });
3435
+ const compileIgnoredFilePatterns = (userConfig) => {
3436
+ const files = userConfig?.ignore?.files;
3437
+ if (!Array.isArray(files)) return [];
3438
+ return compileGlobPatternsLenient(files.filter((entry) => typeof entry === "string"), (error) => warnConfigIssue(`ignore.files: ${error.message}`));
3439
+ };
3440
+ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
3441
+ if (patterns.length === 0) return false;
3442
+ const relativePath = toRelativePath(filePath, rootDirectory);
3443
+ return patterns.some((pattern) => pattern.test(relativePath));
3444
+ };
3445
+ const TEST_FILE_DIRECTORY_PATTERN = /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\//;
3446
+ const TEST_FILE_SUFFIX_PATTERN = /\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
3447
+ const FIXTURE_PROJECT_PATTERN = /\/(?:fixtures|__fixtures__)\//;
3448
+ const SOURCE_ROOT_PATTERN = /\/(?:src|app|lib|components|pages|features|modules|packages|apps|frontend|client)\//g;
3449
+ const stripAboveSourceRoot = (relativePath) => {
3450
+ const fixtureMatch = FIXTURE_PROJECT_PATTERN.exec(relativePath);
3451
+ if (fixtureMatch === null) return relativePath;
3452
+ let lastIdx = -1;
3453
+ for (const match of relativePath.matchAll(SOURCE_ROOT_PATTERN)) if (match.index !== void 0 && match.index > lastIdx) lastIdx = match.index;
3454
+ if (lastIdx >= 0) return relativePath.slice(lastIdx);
3455
+ return relativePath.slice(fixtureMatch.index + fixtureMatch[0].length - 1);
3456
+ };
3457
+ const isTestFilePath = (relativePath) => {
3458
+ if (relativePath.length === 0) return false;
3459
+ const forwardSlashed = relativePath.replaceAll("\\", "/");
3460
+ if (TEST_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return true;
3461
+ const scoped = stripAboveSourceRoot(forwardSlashed);
3462
+ return TEST_FILE_DIRECTORY_PATTERN.test(scoped);
3463
+ };
3248
3464
  /**
3249
3465
  * Resolves the user-configured severity override for a rule.
3250
3466
  * Per-rule overrides win over per-category overrides. Returns
@@ -3262,233 +3478,152 @@ const resolveRuleSeverityOverride = (input, controls) => {
3262
3478
  }
3263
3479
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3264
3480
  };
3265
- const SEVERITY_FOR_OVERRIDE = {
3266
- error: "error",
3267
- warn: "warning"
3268
- };
3269
- const restampSeverity = (diagnostic, override) => {
3270
- const targetSeverity = SEVERITY_FOR_OVERRIDE[override];
3271
- if (diagnostic.severity === targetSeverity) return diagnostic;
3272
- return {
3273
- ...diagnostic,
3274
- severity: targetSeverity
3275
- };
3276
- };
3277
- const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
3278
- const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
3279
- const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
3280
- let stringDelimiter = null;
3281
- for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
3282
- const character = line[charIndex];
3283
- if (stringDelimiter !== null) {
3284
- if (character === "\\") {
3285
- charIndex++;
3286
- continue;
3287
- }
3288
- if (character === stringDelimiter) stringDelimiter = null;
3289
- continue;
3290
- }
3291
- if (character === "\"" || character === "'" || character === "`") {
3292
- stringDelimiter = character;
3293
- continue;
3294
- }
3295
- if (character === "/" && line[charIndex + 1] === "/") return true;
3296
- }
3297
- return false;
3481
+ /**
3482
+ * Resolves the absolute path to read for a diagnostic's `filePath`,
3483
+ * accounting for the various shapes oxlint emits:
3484
+ *
3485
+ * - Absolute POSIX (`/abs/path/file.tsx`) pass through.
3486
+ * - Absolute Windows (`C:/...` or `C:\...`) — pass through.
3487
+ * - `./relative` or bare relative — join against `rootDirectory`.
3488
+ *
3489
+ * Shared between the streaming diagnostic pipeline and the legacy
3490
+ * array-shaped `mergeAndFilterDiagnostics` wrapper so file-line lookups
3491
+ * use one canonical resolution path.
3492
+ */
3493
+ const resolveCandidateReadPath = (rootDirectory, filePath) => {
3494
+ const normalizedFile = filePath.replace(/\\/g, "/");
3495
+ if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
3496
+ return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
3298
3497
  };
3299
- const findOpenerTagOnLine = (line) => {
3300
- for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
3301
- if (match.index === void 0) continue;
3302
- if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
3498
+ /**
3499
+ * Shared raw-line scanners that detect whether a diagnostic site is
3500
+ * enclosed by a configured `textComponents` entry or a
3501
+ * `rawTextWrapperComponents` entry. Both checks are used by the
3502
+ * diagnostic-pipeline's `rn-no-raw-text` suppression step.
3503
+ *
3504
+ * Heuristic — operates on raw lines without an AST — but good enough
3505
+ * to (a) detect a string-only wrapper child and (b) verify the opener
3506
+ * actually encloses a given diagnostic position.
3507
+ */
3508
+ const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
3509
+ const JSX_CHILD_OPEN_PATTERN = /<[A-Za-z]/;
3510
+ const escapeRegExpSpecials = (rawText) => rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3511
+ const findOpenerAtOrAbove = (lines, upperBoundLineIndex) => {
3512
+ for (let lineIndex = upperBoundLineIndex; lineIndex >= 0; lineIndex--) {
3513
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
3514
+ if (!match) continue;
3515
+ const fullName = match[1];
3516
+ return {
3517
+ fullName,
3518
+ leafName: fullName.includes(".") ? fullName.split(".").at(-1) ?? fullName : fullName,
3519
+ lineIndex
3520
+ };
3303
3521
  }
3304
3522
  return null;
3305
3523
  };
3306
- const findJsxOpenerSpan = (lines, openerLineIndex) => {
3307
- const openerLine = lines[openerLineIndex];
3308
- if (openerLine === void 0) return null;
3309
- const opener = findOpenerTagOnLine(openerLine);
3310
- if (!opener) return null;
3311
- const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
3312
- let braceDepth = 0;
3313
- let innerAngleDepth = 0;
3314
- let stringDelimiter = null;
3315
- for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
3316
- const currentLine = lines[lineIndex];
3317
- const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
3318
- for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
3319
- const character = currentLine[charIndex];
3320
- if (stringDelimiter !== null) {
3321
- if (character === "\\") {
3322
- charIndex++;
3323
- continue;
3324
- }
3325
- if (character === stringDelimiter) stringDelimiter = null;
3326
- continue;
3327
- }
3328
- if (character === "\"" || character === "'" || character === "`") {
3329
- stringDelimiter = character;
3330
- continue;
3331
- }
3332
- if (character === "{") {
3333
- braceDepth++;
3334
- continue;
3335
- }
3336
- if (character === "}") {
3337
- braceDepth--;
3338
- continue;
3339
- }
3340
- if (braceDepth !== 0) continue;
3341
- if (character === "<") {
3342
- const followCharacter = currentLine[charIndex + 1];
3343
- if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
3344
- continue;
3345
- }
3346
- if (character !== ">") continue;
3347
- const previousCharacter = currentLine[charIndex - 1];
3348
- const nextCharacter = currentLine[charIndex + 1];
3349
- if (previousCharacter === "=" || nextCharacter === "=") continue;
3350
- if (innerAngleDepth > 0) {
3351
- innerAngleDepth--;
3352
- continue;
3353
- }
3354
- return lineIndex;
3355
- }
3524
+ const resolveJsxRange = (lines, opener) => {
3525
+ const closingPattern = new RegExp(`</(?:${escapeRegExpSpecials(opener.fullName)}|${escapeRegExpSpecials(opener.leafName)})\\s*>`);
3526
+ let closerLineIndex = -1;
3527
+ let closerColumn = -1;
3528
+ for (let lineIndex = opener.lineIndex; lineIndex < lines.length; lineIndex++) {
3529
+ const match = closingPattern.exec(lines[lineIndex]);
3530
+ if (!match) continue;
3531
+ closerLineIndex = lineIndex;
3532
+ closerColumn = match.index;
3533
+ break;
3534
+ }
3535
+ if (closerLineIndex < 0) return null;
3536
+ const openerLine = lines[opener.lineIndex];
3537
+ const tagStartIndex = openerLine.indexOf(`<${opener.fullName}`);
3538
+ if (tagStartIndex < 0) return null;
3539
+ const openerEndIndex = openerLine.indexOf(">", tagStartIndex);
3540
+ let bodyText;
3541
+ if (opener.lineIndex === closerLineIndex) {
3542
+ if (openerEndIndex < 0 || openerEndIndex >= closerColumn) return null;
3543
+ bodyText = openerLine.slice(openerEndIndex + 1, closerColumn);
3544
+ } else {
3545
+ const segments = [];
3546
+ if (openerEndIndex >= 0) segments.push(openerLine.slice(openerEndIndex + 1));
3547
+ for (let lineIndex = opener.lineIndex + 1; lineIndex < closerLineIndex; lineIndex++) segments.push(lines[lineIndex]);
3548
+ segments.push(lines[closerLineIndex].slice(0, closerColumn));
3549
+ bodyText = segments.join("\n");
3356
3550
  }
3357
- return null;
3551
+ return {
3552
+ closerLineIndex,
3553
+ closerColumn,
3554
+ bodyText
3555
+ };
3358
3556
  };
3359
- const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
3360
- for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
3361
- const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
3362
- if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
3557
+ /**
3558
+ * Returns true when the JSX element opened at or above `diagnosticLine`
3559
+ * is named in `textComponentNames`, matching either by full dotted name
3560
+ * (`NativeTabs.Trigger.Label`) or by the leaf name (`Label`).
3561
+ */
3562
+ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
3563
+ for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
3564
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
3565
+ if (!match) continue;
3566
+ const fullTagName = match[1];
3567
+ const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
3568
+ return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
3363
3569
  }
3364
- return null;
3570
+ return false;
3365
3571
  };
3366
- const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3367
- const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
3368
- const collected = [];
3369
- let isStillInChain = true;
3370
- for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
3371
- const candidateLine = lines[candidateIndex];
3372
- if (candidateLine === void 0) break;
3373
- const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
3374
- if (match) {
3375
- collected.push({
3376
- commentLineIndex: candidateIndex,
3377
- ruleList: match[1],
3378
- isInChain: isStillInChain
3379
- });
3572
+ /**
3573
+ * Returns true when the diagnostic position is enclosed by the nearest
3574
+ * actually-enclosing opener AND that opener is in `wrapperNames` AND
3575
+ * its body has no JSX child elements (i.e. the wrapper holds only
3576
+ * stringifiable children). Closed siblings above the diagnostic are
3577
+ * skipped `findOpenerAtOrAbove` keeps walking outward.
3578
+ *
3579
+ * Diagnostic line and column are 1-indexed; column may be 0 when oxlint
3580
+ * omits the span (we treat that as "earliest position on the line",
3581
+ * which is conservative for enclosure checks).
3582
+ */
3583
+ const isInsideStringOnlyWrapper = (lines, diagnosticLine, diagnosticColumn, wrapperNames) => {
3584
+ const diagnosticLineIndex = diagnosticLine - 1;
3585
+ const diagnosticColumnIndex = Math.max(0, diagnosticColumn - 1);
3586
+ let upperBoundLineIndex = diagnosticLineIndex;
3587
+ while (upperBoundLineIndex >= 0) {
3588
+ const opener = findOpenerAtOrAbove(lines, upperBoundLineIndex);
3589
+ if (!opener) return false;
3590
+ const range = resolveJsxRange(lines, opener);
3591
+ if (range === null) {
3592
+ upperBoundLineIndex = opener.lineIndex - 1;
3380
3593
  continue;
3381
3594
  }
3382
- isStillInChain = false;
3383
- }
3384
- return collected;
3385
- };
3386
- const stripDescriptionTail = (ruleList) => {
3387
- const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
3388
- if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
3389
- return ruleList.slice(0, descriptionMatch.index);
3390
- };
3391
- const isRuleListedInComment = (ruleList, ruleId) => {
3392
- const trimmed = ruleList?.trim();
3393
- if (!trimmed) return true;
3394
- const ruleSection = stripDescriptionTail(trimmed).trim();
3395
- if (!ruleSection) return true;
3396
- return ruleSection.split(/[,\s]+/).some((token) => isSameRuleKey(token.trim(), ruleId));
3397
- };
3398
- const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3399
- const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
3400
- const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
3401
- const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
3402
- const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
3403
- const buildAdjacentMismatchHint = (comment, ruleId) => {
3404
- const ruleListText = comment.ruleList?.trim() ?? "";
3405
- 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}`;
3406
- };
3407
- const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
3408
- const commentLineNumber = comment.commentLineIndex + 1;
3409
- const diagnosticLineNumber = diagnosticLineIndex + 1;
3410
- 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.`;
3411
- };
3412
- const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
3413
- for (const comments of commentsByAnchor) {
3414
- const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
3415
- if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
3416
- const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
3417
- if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
3595
+ if (range.closerLineIndex < diagnosticLineIndex || range.closerLineIndex === diagnosticLineIndex && range.closerColumn <= diagnosticColumnIndex) {
3596
+ upperBoundLineIndex = opener.lineIndex - 1;
3597
+ continue;
3598
+ }
3599
+ if (!wrapperNames.has(opener.fullName) && !wrapperNames.has(opener.leafName)) return false;
3600
+ return !JSX_CHILD_OPEN_PATTERN.test(range.bodyText);
3418
3601
  }
3419
- return null;
3420
- };
3421
- const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
3422
- const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
3423
- if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
3424
- isSuppressed: true,
3425
- nearMissHint: null
3426
- };
3427
- const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
3428
- if (hasChainSuppressor(directComments, ruleId)) return {
3429
- isSuppressed: true,
3430
- nearMissHint: null
3431
- };
3432
- const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
3433
- const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
3434
- if (hasChainSuppressor(openerComments, ruleId)) return {
3435
- isSuppressed: true,
3436
- nearMissHint: null
3437
- };
3438
- return {
3439
- isSuppressed: false,
3440
- nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
3441
- };
3442
- };
3443
- const compileIgnoredFilePatterns = (userConfig) => {
3444
- const files = userConfig?.ignore?.files;
3445
- if (!Array.isArray(files)) return [];
3446
- return compileGlobPatternsLenient(files.filter((entry) => typeof entry === "string"), (error) => warnConfigIssue(`ignore.files: ${error.message}`));
3447
- };
3448
- const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
3449
- if (patterns.length === 0) return false;
3450
- const relativePath = toRelativePath(filePath, rootDirectory);
3451
- return patterns.some((pattern) => pattern.test(relativePath));
3452
- };
3453
- const resolveCandidateReadPath = (rootDirectory, filePath) => {
3454
- const normalizedFile = filePath.replace(/\\/g, "/");
3455
- if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
3456
- return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
3457
- };
3458
- const TEST_FILE_DIRECTORY_PATTERN = /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\//;
3459
- const TEST_FILE_SUFFIX_PATTERN = /\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
3460
- const FIXTURE_PROJECT_PATTERN = /\/(?:fixtures|__fixtures__)\//;
3461
- const SOURCE_ROOT_PATTERN = /\/(?:src|app|lib|components|pages|features|modules|packages|apps|frontend|client)\//g;
3462
- const stripAboveSourceRoot = (relativePath) => {
3463
- const fixtureMatch = FIXTURE_PROJECT_PATTERN.exec(relativePath);
3464
- if (fixtureMatch === null) return relativePath;
3465
- let lastIdx = -1;
3466
- for (const match of relativePath.matchAll(SOURCE_ROOT_PATTERN)) if (match.index !== void 0 && match.index > lastIdx) lastIdx = match.index;
3467
- if (lastIdx >= 0) return relativePath.slice(lastIdx);
3468
- return relativePath.slice(fixtureMatch.index + fixtureMatch[0].length - 1);
3602
+ return false;
3469
3603
  };
3470
- const isTestFilePath = (relativePath) => {
3471
- if (relativePath.length === 0) return false;
3472
- const forwardSlashed = relativePath.replaceAll("\\", "/");
3473
- if (TEST_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return true;
3474
- const scoped = stripAboveSourceRoot(forwardSlashed);
3475
- return TEST_FILE_DIRECTORY_PATTERN.test(scoped);
3604
+ const collectStringSet = (values) => {
3605
+ if (!Array.isArray(values)) return /* @__PURE__ */ new Set();
3606
+ return new Set(values.filter((value) => typeof value === "string"));
3476
3607
  };
3477
3608
  /**
3478
3609
  * Pre-compiles every stateful filter and returns a single
3479
- * `apply(diagnostic)` closure that runs:
3610
+ * `apply(diagnostic)` closure that runs (in order):
3480
3611
  *
3481
3612
  * 1. auto-suppress (test-noise rules in test files; `migration-hint`
3482
3613
  * wins over `test-noise`)
3483
3614
  * 2. severity overrides (top-level `rules` / `categories`, with
3484
3615
  * `"off"` dropping)
3485
3616
  * 3. ignore filters (rules / file patterns / per-file overrides)
3486
- * 4. inline suppressions (`// react-doctor-disable-next-line ...`)
3617
+ * 4. `rn-no-raw-text` suppression via configured `textComponents` and
3618
+ * `rawTextWrapperComponents` (config-driven JSX enclosure checks)
3619
+ * 5. inline suppressions (`// react-doctor-disable-next-line ...`)
3487
3620
  *
3488
3621
  * Returns `null` when the diagnostic is dropped, the (possibly
3489
- * severity-restamped) diagnostic otherwise. Shared by `runInspect`'s
3490
- * streaming pipeline and the array-shaped `mergeAndFilterDiagnostics`
3491
- * single source of truth.
3622
+ * severity-restamped) diagnostic otherwise.
3623
+ *
3624
+ * This is the single source of truth for diagnostic filtering — both
3625
+ * `runInspect`'s streaming pipeline and the array-shaped
3626
+ * `mergeAndFilterDiagnostics` wrapper apply this closure per element.
3492
3627
  */
3493
3628
  const buildDiagnosticPipeline = (input) => {
3494
3629
  const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables } = input;
@@ -3496,6 +3631,10 @@ const buildDiagnosticPipeline = (input) => {
3496
3631
  const ignoredRules = new Set(Array.isArray(userConfig?.ignore?.rules) ? userConfig.ignore.rules.filter((rule) => typeof rule === "string") : []);
3497
3632
  const ignoredFilePatterns = compileIgnoredFilePatterns(userConfig);
3498
3633
  const compiledOverrides = compileIgnoreOverrides(userConfig);
3634
+ const textComponentNames = collectStringSet(userConfig?.textComponents);
3635
+ const rawTextWrapperComponentNames = collectStringSet(userConfig?.rawTextWrapperComponents);
3636
+ const hasTextComponents = textComponentNames.size > 0;
3637
+ const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3499
3638
  const fileLinesCache = /* @__PURE__ */ new Map();
3500
3639
  const testFileCache = /* @__PURE__ */ new Map();
3501
3640
  const getFileLines = (filePath) => {
@@ -3524,6 +3663,16 @@ const buildDiagnosticPipeline = (input) => {
3524
3663
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
3525
3664
  return false;
3526
3665
  };
3666
+ const isRnRawTextSuppressedByConfig = (diagnostic) => {
3667
+ if (diagnostic.rule !== "rn-no-raw-text") return false;
3668
+ if (diagnostic.line <= 0) return false;
3669
+ if (!hasTextComponents && !hasRawTextWrappers) return false;
3670
+ const lines = getFileLines(diagnostic.filePath);
3671
+ if (!lines) return false;
3672
+ if (hasTextComponents && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return true;
3673
+ if (hasRawTextWrappers && isInsideStringOnlyWrapper(lines, diagnostic.line, diagnostic.column, rawTextWrapperComponentNames)) return true;
3674
+ return false;
3675
+ };
3527
3676
  return { apply: (diagnostic) => {
3528
3677
  if (shouldAutoSuppress(diagnostic)) return null;
3529
3678
  let current = diagnostic;
@@ -3540,6 +3689,7 @@ const buildDiagnosticPipeline = (input) => {
3540
3689
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
3541
3690
  if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) return null;
3542
3691
  if (isDiagnosticIgnoredByOverrides(current, rootDirectory, compiledOverrides)) return null;
3692
+ if (isRnRawTextSuppressedByConfig(current)) return null;
3543
3693
  }
3544
3694
  if (respectInlineDisables && current.line > 0) {
3545
3695
  const lines = getFileLines(current.filePath);
@@ -3666,6 +3816,27 @@ var ReactDoctorError = class extends Schema.TaggedErrorClass()("ReactDoctorError
3666
3816
  const formatReactDoctorError = (error) => error.reason.message;
3667
3817
  const isSplittableReactDoctorError = (error) => error instanceof ReactDoctorError && error.reason._tag === "OxlintBatchExceeded";
3668
3818
  const isReactDoctorError = (error) => error instanceof ReactDoctorError;
3819
+ /**
3820
+ * Tagged-reason → legacy thrown-class boundary shared by every public
3821
+ * shell (`inspect()` in `react-doctor`, `diagnose()` in `@react-doctor/api`).
3822
+ *
3823
+ * `Effect.catchReasons` dispatches on the tagged-error sub-channel
3824
+ * without manual `instanceof` checks. Each handler converts a tagged
3825
+ * reason into the historical thrown class advertised by the legacy
3826
+ * public-API contract (via `Effect.die`, which `Effect.runPromise`
3827
+ * re-throws unchanged). The `orElse` branch re-`die`s the original
3828
+ * `ReactDoctorError` instance so advanced callers can still narrow on
3829
+ * `error.reason._tag` while grep-stderr users keep the same
3830
+ * `error.message` they always saw.
3831
+ *
3832
+ * Adding a new legacy thrown class is a one-line change on the
3833
+ * `Effect.catchReasons` map — both shells pick it up automatically.
3834
+ */
3835
+ const restoreLegacyThrow = (effect) => effect.pipe(Effect.catchReasons("ReactDoctorError", {
3836
+ NoReactDependency: (reason) => Effect.die(new NoReactDependencyError(reason.directory)),
3837
+ ProjectNotFound: (reason) => Effect.die(new ProjectNotFoundError(reason.directory)),
3838
+ AmbiguousProject: (reason) => Effect.die(new AmbiguousProjectError(reason.directory, [...reason.candidates]))
3839
+ }, (_reason, error) => Effect.die(error)));
3669
3840
  const TRACER_PROJECT_NAME = "react-doctor";
3670
3841
  const OTEL_ENDPOINT = Config$1.string("REACT_DOCTOR_OTLP_ENDPOINT").pipe(Config$1.option);
3671
3842
  const OTEL_AUTH_HEADER = Config$1.redacted("REACT_DOCTOR_OTLP_AUTH_HEADER").pipe(Config$1.option);
@@ -3708,216 +3879,6 @@ Context.Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => {
3708
3879
  } });
3709
3880
  Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES });
3710
3881
  Context.Reference("react-doctor/StagedFilesTempDirPrefix", { defaultValue: () => "react-doctor-staged-" });
3711
- const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
3712
- const PNPM_LOCKFILE = "pnpm-lock.yaml";
3713
- const PACKAGE_JSON_FILE = "package.json";
3714
- const PNPM_HARDENING_RULE_KEY = "require-pnpm-hardening";
3715
- const UTF8_BOM_CHAR = "";
3716
- const HARDENING_SETTING_KEYS = new Set([
3717
- "minimumReleaseAge",
3718
- "blockExoticSubdeps",
3719
- "trustPolicy"
3720
- ]);
3721
- const stripInlineComment = (rawValue) => {
3722
- let activeQuote = null;
3723
- for (let charIndex = 0; charIndex < rawValue.length; charIndex += 1) {
3724
- const currentChar = rawValue[charIndex];
3725
- if (activeQuote !== null) {
3726
- if (currentChar === activeQuote) activeQuote = null;
3727
- continue;
3728
- }
3729
- if (currentChar === "\"" || currentChar === "'") {
3730
- activeQuote = currentChar;
3731
- continue;
3732
- }
3733
- if (currentChar !== "#") continue;
3734
- const previousChar = rawValue[charIndex - 1];
3735
- if (charIndex === 0 || previousChar !== void 0 && /\s/.test(previousChar)) return rawValue.slice(0, charIndex);
3736
- }
3737
- return rawValue;
3738
- };
3739
- const unquote = (rawValue) => rawValue.replace(/^["']|["']$/g, "");
3740
- const stripBom = (rawContent) => rawContent.startsWith(UTF8_BOM_CHAR) ? rawContent.slice(1) : rawContent;
3741
- const parseHardeningSettings = (content) => {
3742
- let minimumReleaseAge = null;
3743
- let blockExoticSubdeps = null;
3744
- let trustPolicy = null;
3745
- const lines = stripBom(content).split(/\r?\n/);
3746
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
3747
- const lineText = lines[lineIndex];
3748
- if (lineText === void 0) continue;
3749
- if (lineText.search(/\S/) !== 0) continue;
3750
- const trimmedLine = lineText.trim();
3751
- if (trimmedLine.startsWith("#")) continue;
3752
- const colonIndex = trimmedLine.indexOf(":");
3753
- if (colonIndex <= 0) continue;
3754
- const settingKey = unquote(trimmedLine.slice(0, colonIndex).trim());
3755
- if (!HARDENING_SETTING_KEYS.has(settingKey)) continue;
3756
- const inlineValue = stripInlineComment(trimmedLine.slice(colonIndex + 1)).trim();
3757
- if (inlineValue.length === 0) continue;
3758
- const scalar = {
3759
- value: unquote(inlineValue),
3760
- line: lineIndex + 1,
3761
- column: lineText.search(/\S/) + 1
3762
- };
3763
- if (settingKey === "minimumReleaseAge") minimumReleaseAge = scalar;
3764
- else if (settingKey === "blockExoticSubdeps") blockExoticSubdeps = scalar;
3765
- else if (settingKey === "trustPolicy") trustPolicy = scalar;
3766
- }
3767
- return {
3768
- minimumReleaseAge,
3769
- blockExoticSubdeps,
3770
- trustPolicy
3771
- };
3772
- };
3773
- const isPnpmManagedProject = (rootDirectory) => {
3774
- if (isFile(path.join(rootDirectory, PNPM_LOCKFILE))) return true;
3775
- if (isFile(path.join(rootDirectory, PNPM_WORKSPACE_FILE))) return true;
3776
- const packageJsonPath = path.join(rootDirectory, PACKAGE_JSON_FILE);
3777
- if (!isFile(packageJsonPath)) return false;
3778
- try {
3779
- const packageJsonRaw = fs.readFileSync(packageJsonPath, "utf-8");
3780
- const packageJson = JSON.parse(packageJsonRaw);
3781
- if (packageJson !== null && typeof packageJson === "object" && "packageManager" in packageJson && typeof packageJson.packageManager === "string" && packageJson.packageManager.startsWith("pnpm@")) return true;
3782
- } catch {
3783
- return false;
3784
- }
3785
- return false;
3786
- };
3787
- const buildHardeningDiagnostic = (input) => ({
3788
- filePath: PNPM_WORKSPACE_FILE,
3789
- plugin: "react-doctor",
3790
- rule: PNPM_HARDENING_RULE_KEY,
3791
- severity: "warning",
3792
- message: input.message,
3793
- help: input.help,
3794
- line: input.line ?? 0,
3795
- column: input.column ?? 0,
3796
- category: "Security"
3797
- });
3798
- const checkPnpmHardening = (rootDirectory) => {
3799
- if (!isPnpmManagedProject(rootDirectory)) return [];
3800
- const workspacePath = path.join(rootDirectory, PNPM_WORKSPACE_FILE);
3801
- const settings = parseHardeningSettings(isFile(workspacePath) ? fs.readFileSync(workspacePath, "utf-8") : "");
3802
- const diagnostics = [];
3803
- if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
3804
- message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
3805
- 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`
3806
- }));
3807
- if (settings.blockExoticSubdeps !== null && settings.blockExoticSubdeps.value.toLowerCase() === "false") diagnostics.push(buildHardeningDiagnostic({
3808
- line: settings.blockExoticSubdeps.line,
3809
- column: settings.blockExoticSubdeps.column,
3810
- message: "`blockExoticSubdeps: false` allows transitive deps from `git:`, `file:`, or tarball URLs — a known supply-chain bypass of the npm registry",
3811
- help: "Set `blockExoticSubdeps: true` (the default in recent pnpm v11) so transitive deps must come from the registry"
3812
- }));
3813
- if (settings.trustPolicy === null) diagnostics.push(buildHardeningDiagnostic({
3814
- message: "pnpm-workspace.yaml is missing `trustPolicy` — without `no-downgrade`, pnpm silently accepts packages whose trust signals (provenance, signatures) weaken between updates",
3815
- help: "Add `trustPolicy: no-downgrade` to pnpm-workspace.yaml"
3816
- }));
3817
- else if (settings.trustPolicy.value !== "no-downgrade") diagnostics.push(buildHardeningDiagnostic({
3818
- line: settings.trustPolicy.line,
3819
- column: settings.trustPolicy.column,
3820
- message: `\`trustPolicy: ${settings.trustPolicy.value}\` is weaker than \`no-downgrade\` — packages may lose trust signals between updates without you noticing`,
3821
- help: "Set `trustPolicy: no-downgrade` so pnpm refuses to downgrade trust between resolutions"
3822
- }));
3823
- return diagnostics;
3824
- };
3825
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
3826
- const REDUCED_MOTION_FILE_GLOBS = [
3827
- "*.ts",
3828
- "*.tsx",
3829
- "*.js",
3830
- "*.jsx",
3831
- "*.css",
3832
- "*.scss"
3833
- ];
3834
- const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
3835
- filePath: "package.json",
3836
- plugin: "react-doctor",
3837
- rule: "require-reduced-motion",
3838
- severity: "error",
3839
- message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
3840
- help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
3841
- line: 0,
3842
- column: 0,
3843
- category: "Accessibility"
3844
- };
3845
- const checkReducedMotion = (rootDirectory) => {
3846
- const packageJsonPath = path.join(rootDirectory, "package.json");
3847
- if (!isFile(packageJsonPath)) return [];
3848
- let hasMotionLibrary = false;
3849
- try {
3850
- const packageJson = readPackageJson(packageJsonPath);
3851
- const allDependencies = {
3852
- ...packageJson.dependencies,
3853
- ...packageJson.devDependencies
3854
- };
3855
- hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
3856
- } catch {
3857
- return [];
3858
- }
3859
- if (!hasMotionLibrary) return [];
3860
- const result = spawnSync("git", [
3861
- "grep",
3862
- "-ql",
3863
- "-E",
3864
- REDUCED_MOTION_GREP_PATTERN,
3865
- "--",
3866
- ...REDUCED_MOTION_FILE_GLOBS
3867
- ], {
3868
- cwd: rootDirectory,
3869
- stdio: [
3870
- "ignore",
3871
- "pipe",
3872
- "pipe"
3873
- ]
3874
- });
3875
- if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
3876
- if (result.status === 0) return [];
3877
- return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
3878
- };
3879
- const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
3880
- const listSourceFilesViaGit = (rootDirectory) => {
3881
- const result = spawnSync("git", [
3882
- "ls-files",
3883
- "-z",
3884
- "--cached",
3885
- "--others",
3886
- "--exclude-standard"
3887
- ], {
3888
- cwd: rootDirectory,
3889
- encoding: "utf-8",
3890
- maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
3891
- });
3892
- if (result.error || result.status !== 0) return null;
3893
- return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
3894
- };
3895
- const listSourceFilesViaFilesystem = (rootDirectory) => {
3896
- const filePaths = [];
3897
- const stack = [rootDirectory];
3898
- while (stack.length > 0) {
3899
- const currentDirectory = stack.pop();
3900
- const entries = readDirectoryEntries(currentDirectory);
3901
- for (const entry of entries) {
3902
- const absolutePath = path.join(currentDirectory, entry.name);
3903
- if (entry.isDirectory()) {
3904
- if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
3905
- continue;
3906
- }
3907
- if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
3908
- }
3909
- }
3910
- return filePaths;
3911
- };
3912
- const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
3913
- const resolveLintIncludePaths = (rootDirectory, userConfig) => {
3914
- if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
3915
- const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
3916
- return listSourceFiles(rootDirectory).filter((filePath) => {
3917
- if (!JSX_FILE_PATTERN.test(filePath)) return false;
3918
- return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
3919
- });
3920
- };
3921
3882
  const DIAGNOSTIC_SURFACES = [
3922
3883
  "cli",
3923
3884
  "prComment",
@@ -4091,56 +4052,339 @@ const loadConfigFromDirectory = (directory) => {
4091
4052
  }
4092
4053
  return null;
4093
4054
  };
4094
- const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
4095
- const cachedConfigs = /* @__PURE__ */ new Map();
4096
- const loadConfigWithSource = (rootDirectory) => {
4097
- const cached = cachedConfigs.get(rootDirectory);
4098
- if (cached !== void 0) return cached;
4099
- const localConfig = loadConfigFromDirectory(rootDirectory);
4100
- if (localConfig) {
4101
- cachedConfigs.set(rootDirectory, localConfig);
4102
- return localConfig;
4103
- }
4104
- if (isProjectBoundary$1(rootDirectory)) {
4105
- cachedConfigs.set(rootDirectory, null);
4106
- return null;
4107
- }
4108
- let ancestorDirectory = path.dirname(rootDirectory);
4109
- while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4110
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4111
- if (ancestorConfig) {
4112
- cachedConfigs.set(rootDirectory, ancestorConfig);
4113
- return ancestorConfig;
4114
- }
4115
- if (isProjectBoundary$1(ancestorDirectory)) {
4116
- cachedConfigs.set(rootDirectory, null);
4117
- return null;
4055
+ const cachedConfigs = /* @__PURE__ */ new Map();
4056
+ const loadConfigWithSource = (rootDirectory) => {
4057
+ const cached = cachedConfigs.get(rootDirectory);
4058
+ if (cached !== void 0) return cached;
4059
+ const localConfig = loadConfigFromDirectory(rootDirectory);
4060
+ if (localConfig) {
4061
+ cachedConfigs.set(rootDirectory, localConfig);
4062
+ return localConfig;
4063
+ }
4064
+ if (isProjectBoundary(rootDirectory)) {
4065
+ cachedConfigs.set(rootDirectory, null);
4066
+ return null;
4067
+ }
4068
+ let ancestorDirectory = path.dirname(rootDirectory);
4069
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4070
+ const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4071
+ if (ancestorConfig) {
4072
+ cachedConfigs.set(rootDirectory, ancestorConfig);
4073
+ return ancestorConfig;
4074
+ }
4075
+ if (isProjectBoundary(ancestorDirectory)) {
4076
+ cachedConfigs.set(rootDirectory, null);
4077
+ return null;
4078
+ }
4079
+ ancestorDirectory = path.dirname(ancestorDirectory);
4080
+ }
4081
+ cachedConfigs.set(rootDirectory, null);
4082
+ return null;
4083
+ };
4084
+ const resolveConfigRootDir = (config, configSourceDirectory) => {
4085
+ if (!config || !configSourceDirectory) return null;
4086
+ const rawRootDir = config.rootDir;
4087
+ if (typeof rawRootDir !== "string") return null;
4088
+ const trimmedRootDir = rawRootDir.trim();
4089
+ if (trimmedRootDir.length === 0) return null;
4090
+ const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
4091
+ if (resolvedRootDir === configSourceDirectory) return null;
4092
+ if (!isDirectory(resolvedRootDir)) {
4093
+ Effect.runSync(Console.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`));
4094
+ return null;
4095
+ }
4096
+ return resolvedRootDir;
4097
+ };
4098
+ const resolveDiagnoseTarget = (directory) => {
4099
+ if (isFile(path.join(directory, "package.json"))) return directory;
4100
+ const reactSubprojects = discoverReactSubprojects(directory);
4101
+ if (reactSubprojects.length === 0) return null;
4102
+ if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
4103
+ throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
4104
+ };
4105
+ /**
4106
+ * The canonical entry-point translation shared by every public shell
4107
+ * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4108
+ *
4109
+ * 1. Resolve the requested directory to absolute.
4110
+ * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4111
+ * if present.
4112
+ * 3. Honor `config.rootDir` to redirect the scan to a nested
4113
+ * project root, if configured.
4114
+ * 4. Walk into a nested React subproject when the requested
4115
+ * directory has no `package.json` of its own (raises
4116
+ * `AmbiguousProjectError` when multiple candidates exist).
4117
+ *
4118
+ * Throws `ProjectNotFoundError` when neither the requested directory
4119
+ * nor any discoverable nested project has a `package.json`.
4120
+ *
4121
+ * Before this helper existed, the same three-step dance was reproduced
4122
+ * in `api/diagnose.ts`, `react-doctor/inspect.ts`, and the CLI's
4123
+ * `cli/commands/inspect.ts` — each loading the config independently
4124
+ * (the orchestrator's `Config.layerNode` then loads it a fourth time
4125
+ * via its own cache). Routing through `resolveScanTarget` keeps every
4126
+ * shell in agreement on what "the scan directory" means.
4127
+ */
4128
+ const resolveScanTarget = (requestedDirectory) => {
4129
+ const absoluteRequested = path.resolve(requestedDirectory);
4130
+ const loadedConfig = loadConfigWithSource(absoluteRequested);
4131
+ const userConfig = loadedConfig?.config ?? null;
4132
+ const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4133
+ const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
4134
+ const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
4135
+ return {
4136
+ resolvedDirectory: resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect,
4137
+ requestedDirectory: absoluteRequested,
4138
+ userConfig,
4139
+ configSourceDirectory,
4140
+ didRedirectViaRootDir: redirectedDirectory !== null
4141
+ };
4142
+ };
4143
+ const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4144
+ const PNPM_LOCKFILE = "pnpm-lock.yaml";
4145
+ const PACKAGE_JSON_FILE = "package.json";
4146
+ const PNPM_HARDENING_RULE_KEY = "require-pnpm-hardening";
4147
+ const UTF8_BOM_CHAR = "";
4148
+ const HARDENING_SETTING_KEYS = new Set([
4149
+ "minimumReleaseAge",
4150
+ "blockExoticSubdeps",
4151
+ "trustPolicy"
4152
+ ]);
4153
+ const stripInlineComment = (rawValue) => {
4154
+ let activeQuote = null;
4155
+ for (let charIndex = 0; charIndex < rawValue.length; charIndex += 1) {
4156
+ const currentChar = rawValue[charIndex];
4157
+ if (activeQuote !== null) {
4158
+ if (currentChar === activeQuote) activeQuote = null;
4159
+ continue;
4160
+ }
4161
+ if (currentChar === "\"" || currentChar === "'") {
4162
+ activeQuote = currentChar;
4163
+ continue;
4164
+ }
4165
+ if (currentChar !== "#") continue;
4166
+ const previousChar = rawValue[charIndex - 1];
4167
+ if (charIndex === 0 || previousChar !== void 0 && /\s/.test(previousChar)) return rawValue.slice(0, charIndex);
4168
+ }
4169
+ return rawValue;
4170
+ };
4171
+ const unquote = (rawValue) => rawValue.replace(/^["']|["']$/g, "");
4172
+ const stripBom = (rawContent) => rawContent.startsWith(UTF8_BOM_CHAR) ? rawContent.slice(1) : rawContent;
4173
+ const parseHardeningSettings = (content) => {
4174
+ let minimumReleaseAge = null;
4175
+ let blockExoticSubdeps = null;
4176
+ let trustPolicy = null;
4177
+ const lines = stripBom(content).split(/\r?\n/);
4178
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4179
+ const lineText = lines[lineIndex];
4180
+ if (lineText === void 0) continue;
4181
+ if (lineText.search(/\S/) !== 0) continue;
4182
+ const trimmedLine = lineText.trim();
4183
+ if (trimmedLine.startsWith("#")) continue;
4184
+ const colonIndex = trimmedLine.indexOf(":");
4185
+ if (colonIndex <= 0) continue;
4186
+ const settingKey = unquote(trimmedLine.slice(0, colonIndex).trim());
4187
+ if (!HARDENING_SETTING_KEYS.has(settingKey)) continue;
4188
+ const inlineValue = stripInlineComment(trimmedLine.slice(colonIndex + 1)).trim();
4189
+ if (inlineValue.length === 0) continue;
4190
+ const scalar = {
4191
+ value: unquote(inlineValue),
4192
+ line: lineIndex + 1,
4193
+ column: lineText.search(/\S/) + 1
4194
+ };
4195
+ if (settingKey === "minimumReleaseAge") minimumReleaseAge = scalar;
4196
+ else if (settingKey === "blockExoticSubdeps") blockExoticSubdeps = scalar;
4197
+ else if (settingKey === "trustPolicy") trustPolicy = scalar;
4198
+ }
4199
+ return {
4200
+ minimumReleaseAge,
4201
+ blockExoticSubdeps,
4202
+ trustPolicy
4203
+ };
4204
+ };
4205
+ const isPnpmManagedProject = (rootDirectory) => {
4206
+ if (isFile(path.join(rootDirectory, PNPM_LOCKFILE))) return true;
4207
+ if (isFile(path.join(rootDirectory, PNPM_WORKSPACE_FILE))) return true;
4208
+ const packageJsonPath = path.join(rootDirectory, PACKAGE_JSON_FILE);
4209
+ if (!isFile(packageJsonPath)) return false;
4210
+ try {
4211
+ const packageJsonRaw = fs.readFileSync(packageJsonPath, "utf-8");
4212
+ const packageJson = JSON.parse(packageJsonRaw);
4213
+ if (packageJson !== null && typeof packageJson === "object" && "packageManager" in packageJson && typeof packageJson.packageManager === "string" && packageJson.packageManager.startsWith("pnpm@")) return true;
4214
+ } catch {
4215
+ return false;
4216
+ }
4217
+ return false;
4218
+ };
4219
+ const buildHardeningDiagnostic = (input) => ({
4220
+ filePath: PNPM_WORKSPACE_FILE,
4221
+ plugin: "react-doctor",
4222
+ rule: PNPM_HARDENING_RULE_KEY,
4223
+ severity: "warning",
4224
+ message: input.message,
4225
+ help: input.help,
4226
+ line: input.line ?? 0,
4227
+ column: input.column ?? 0,
4228
+ category: "Security"
4229
+ });
4230
+ const checkPnpmHardening = (rootDirectory) => {
4231
+ if (!isPnpmManagedProject(rootDirectory)) return [];
4232
+ const workspacePath = path.join(rootDirectory, PNPM_WORKSPACE_FILE);
4233
+ const settings = parseHardeningSettings(isFile(workspacePath) ? fs.readFileSync(workspacePath, "utf-8") : "");
4234
+ const diagnostics = [];
4235
+ if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
4236
+ message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
4237
+ 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`
4238
+ }));
4239
+ if (settings.blockExoticSubdeps !== null && settings.blockExoticSubdeps.value.toLowerCase() === "false") diagnostics.push(buildHardeningDiagnostic({
4240
+ line: settings.blockExoticSubdeps.line,
4241
+ column: settings.blockExoticSubdeps.column,
4242
+ message: "`blockExoticSubdeps: false` allows transitive deps from `git:`, `file:`, or tarball URLs — a known supply-chain bypass of the npm registry",
4243
+ help: "Set `blockExoticSubdeps: true` (the default in recent pnpm v11) so transitive deps must come from the registry"
4244
+ }));
4245
+ if (settings.trustPolicy === null) diagnostics.push(buildHardeningDiagnostic({
4246
+ message: "pnpm-workspace.yaml is missing `trustPolicy` — without `no-downgrade`, pnpm silently accepts packages whose trust signals (provenance, signatures) weaken between updates",
4247
+ help: "Add `trustPolicy: no-downgrade` to pnpm-workspace.yaml"
4248
+ }));
4249
+ else if (settings.trustPolicy.value !== "no-downgrade") diagnostics.push(buildHardeningDiagnostic({
4250
+ line: settings.trustPolicy.line,
4251
+ column: settings.trustPolicy.column,
4252
+ message: `\`trustPolicy: ${settings.trustPolicy.value}\` is weaker than \`no-downgrade\` — packages may lose trust signals between updates without you noticing`,
4253
+ help: "Set `trustPolicy: no-downgrade` so pnpm refuses to downgrade trust between resolutions"
4254
+ }));
4255
+ return diagnostics;
4256
+ };
4257
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
4258
+ const REDUCED_MOTION_FILE_GLOBS = [
4259
+ "*.ts",
4260
+ "*.tsx",
4261
+ "*.js",
4262
+ "*.jsx",
4263
+ "*.css",
4264
+ "*.scss"
4265
+ ];
4266
+ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
4267
+ filePath: "package.json",
4268
+ plugin: "react-doctor",
4269
+ rule: "require-reduced-motion",
4270
+ severity: "error",
4271
+ message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
4272
+ help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
4273
+ line: 0,
4274
+ column: 0,
4275
+ category: "Accessibility"
4276
+ };
4277
+ const checkReducedMotion = (rootDirectory) => {
4278
+ const packageJsonPath = path.join(rootDirectory, "package.json");
4279
+ if (!isFile(packageJsonPath)) return [];
4280
+ let hasMotionLibrary = false;
4281
+ try {
4282
+ const packageJson = readPackageJson(packageJsonPath);
4283
+ const allDependencies = {
4284
+ ...packageJson.dependencies,
4285
+ ...packageJson.devDependencies
4286
+ };
4287
+ hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
4288
+ } catch {
4289
+ return [];
4290
+ }
4291
+ if (!hasMotionLibrary) return [];
4292
+ const result = spawnSync("git", [
4293
+ "grep",
4294
+ "-ql",
4295
+ "-E",
4296
+ REDUCED_MOTION_GREP_PATTERN,
4297
+ "--",
4298
+ ...REDUCED_MOTION_FILE_GLOBS
4299
+ ], {
4300
+ cwd: rootDirectory,
4301
+ stdio: [
4302
+ "ignore",
4303
+ "pipe",
4304
+ "pipe"
4305
+ ]
4306
+ });
4307
+ if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
4308
+ if (result.status === 0) return [];
4309
+ return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
4310
+ };
4311
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
4312
+ const toStringSet = (values) => {
4313
+ if (!values || values.length === 0) return /* @__PURE__ */ new Set();
4314
+ return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
4315
+ };
4316
+ const buildResolvedControls = (surface, userControls) => {
4317
+ const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
4318
+ const includeTags = toStringSet(userControls?.includeTags);
4319
+ for (const tag of includeTags) excludeTags.delete(tag);
4320
+ for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
4321
+ return {
4322
+ includeTags,
4323
+ excludeTags,
4324
+ includeCategories: toStringSet(userControls?.includeCategories),
4325
+ excludeCategories: toStringSet(userControls?.excludeCategories),
4326
+ includeRuleKeys: toStringSet(userControls?.includeRules),
4327
+ excludeRuleKeys: toStringSet(userControls?.excludeRules)
4328
+ };
4329
+ };
4330
+ const intersects = (values, candidates) => values.some((value) => candidates.has(value));
4331
+ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
4332
+ const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
4333
+ const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
4334
+ if (resolved.includeRuleKeys.has(ruleKey)) return true;
4335
+ if (resolved.includeCategories.has(category)) return true;
4336
+ if (intersects(tags, resolved.includeTags)) return true;
4337
+ if (resolved.excludeRuleKeys.has(ruleKey)) return false;
4338
+ if (resolved.excludeCategories.has(category)) return false;
4339
+ if (intersects(tags, resolved.excludeTags)) return false;
4340
+ return true;
4341
+ };
4342
+ const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
4343
+ const listSourceFilesViaGit = (rootDirectory) => {
4344
+ const result = spawnSync("git", [
4345
+ "ls-files",
4346
+ "-z",
4347
+ "--cached",
4348
+ "--others",
4349
+ "--exclude-standard"
4350
+ ], {
4351
+ cwd: rootDirectory,
4352
+ encoding: "utf-8",
4353
+ maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
4354
+ });
4355
+ if (result.error || result.status !== 0) return null;
4356
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
4357
+ };
4358
+ const listSourceFilesViaFilesystem = (rootDirectory) => {
4359
+ const filePaths = [];
4360
+ const stack = [rootDirectory];
4361
+ while (stack.length > 0) {
4362
+ const currentDirectory = stack.pop();
4363
+ const entries = readDirectoryEntries(currentDirectory);
4364
+ for (const entry of entries) {
4365
+ const absolutePath = path.join(currentDirectory, entry.name);
4366
+ if (entry.isDirectory()) {
4367
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
4368
+ continue;
4369
+ }
4370
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
4118
4371
  }
4119
- ancestorDirectory = path.dirname(ancestorDirectory);
4120
4372
  }
4121
- cachedConfigs.set(rootDirectory, null);
4122
- return null;
4373
+ return filePaths;
4123
4374
  };
4124
- const resolveConfigRootDir = (config, configSourceDirectory) => {
4125
- if (!config || !configSourceDirectory) return null;
4126
- const rawRootDir = config.rootDir;
4127
- if (typeof rawRootDir !== "string") return null;
4128
- const trimmedRootDir = rawRootDir.trim();
4129
- if (trimmedRootDir.length === 0) return null;
4130
- const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
4131
- if (resolvedRootDir === configSourceDirectory) return null;
4132
- if (!isDirectory(resolvedRootDir)) {
4133
- Effect.runSync(Console.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`));
4134
- return null;
4135
- }
4136
- return resolvedRootDir;
4375
+ const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
4376
+ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
4377
+ if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
4378
+ const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
4379
+ return listSourceFiles(rootDirectory).filter((filePath) => {
4380
+ if (!JSX_FILE_PATTERN.test(filePath)) return false;
4381
+ return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
4382
+ });
4137
4383
  };
4138
- const CONFIG_CACHE_CAPACITY = 16;
4139
- const CONFIG_CACHE_TTL_MS = 300 * 1e3;
4140
4384
  var Config = class Config extends Context.Service()("react-doctor/Config") {
4141
4385
  static layerNode = Layer.effect(Config, Effect.gen(function* () {
4142
4386
  const cache = yield* Cache.make({
4143
- capacity: CONFIG_CACHE_CAPACITY,
4387
+ capacity: 16,
4144
4388
  timeToLive: CONFIG_CACHE_TTL_MS,
4145
4389
  lookup: (directory) => Effect.sync(() => {
4146
4390
  const loaded = loadConfigWithSource(directory);
@@ -4427,6 +4671,20 @@ const parseGithubRepoFromRemoteUrl = (remoteUrl) => {
4427
4671
  const urlMatch = /^(?:https?:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+)$/.exec(withoutGitSuffix);
4428
4672
  return urlMatch ? `${urlMatch[1]}/${urlMatch[2]}` : null;
4429
4673
  };
4674
+ const parseGithubRepo = (repo) => {
4675
+ const [owner, name, ...extraParts] = repo.split("/");
4676
+ if (owner === void 0 || name === void 0 || extraParts.length > 0) return null;
4677
+ if (owner.length === 0 || name.length === 0) return null;
4678
+ return {
4679
+ owner,
4680
+ name
4681
+ };
4682
+ };
4683
+ const parseGithubViewerPermission = (stdout) => {
4684
+ const value = trimOrNull(stdout);
4685
+ if (value === null || value === "null") return null;
4686
+ return /^[A-Z_]+$/.test(value) ? value.toLowerCase() : null;
4687
+ };
4430
4688
  const splitNullSeparated = (value) => value.split("\0").filter((entry) => entry.length > 0);
4431
4689
  /**
4432
4690
  * `Git` wraps every `git`-via-subprocess call react-doctor makes
@@ -4452,9 +4710,10 @@ var Git = class Git extends Context.Service()("react-doctor/Git") {
4452
4710
  * reason: GitInvocationFailed })` so the rest of the codebase
4453
4711
  * sees a single failure channel.
4454
4712
  */
4455
- const runGit = (directory, args) => Effect.scoped(Effect.gen(function* () {
4456
- const handle = yield* spawner.spawn(ChildProcess.make("git", [...args], {
4457
- cwd: directory,
4713
+ const runCommand = (input) => Effect.scoped(Effect.gen(function* () {
4714
+ const handle = yield* spawner.spawn(ChildProcess.make(input.command, [...input.args], {
4715
+ cwd: input.directory,
4716
+ env: input.env,
4458
4717
  extendEnv: true
4459
4718
  }));
4460
4719
  const [stdout, stderr, status] = yield* Effect.all([
@@ -4467,11 +4726,23 @@ var Git = class Git extends Context.Service()("react-doctor/Git") {
4467
4726
  stdout,
4468
4727
  stderr
4469
4728
  };
4470
- })).pipe(Effect.catchTag("PlatformError", (cause) => new ReactDoctorError({ reason: new GitInvocationFailed({
4471
- args: [...args],
4472
- directory,
4473
- cause
4474
- }) })));
4729
+ })).pipe(Effect.catchTag("PlatformError", (cause) => {
4730
+ if (input.command !== "git") return Effect.succeed({
4731
+ status: 127,
4732
+ stdout: "",
4733
+ stderr: String(cause)
4734
+ });
4735
+ return new ReactDoctorError({ reason: new GitInvocationFailed({
4736
+ args: [...input.args],
4737
+ directory: input.directory,
4738
+ cause
4739
+ }) });
4740
+ }));
4741
+ const runGit = (directory, args) => runCommand({
4742
+ command: "git",
4743
+ args,
4744
+ directory
4745
+ });
4475
4746
  const currentBranch = (directory) => runGit(directory, [
4476
4747
  "rev-parse",
4477
4748
  "--abbrev-ref",
@@ -4506,11 +4777,43 @@ var Git = class Git extends Context.Service()("react-doctor/Git") {
4506
4777
  "--get",
4507
4778
  "remote.origin.url"
4508
4779
  ]).pipe(Effect.map((result) => result.status === 0 ? parseGithubRepoFromRemoteUrl(result.stdout) : null));
4780
+ const githubViewerPermission = (input) => Effect.gen(function* () {
4781
+ const parsedRepo = parseGithubRepo(input.repo);
4782
+ if (parsedRepo === null) return null;
4783
+ const resultOption = yield* runCommand({
4784
+ command: "gh",
4785
+ args: [
4786
+ "api",
4787
+ "graphql",
4788
+ "-F",
4789
+ `owner=${parsedRepo.owner}`,
4790
+ "-F",
4791
+ `name=${parsedRepo.name}`,
4792
+ "-f",
4793
+ `query=
4794
+ query(\$owner: String!, \$name: String!) {
4795
+ repository(owner: \$owner, name: \$name) {
4796
+ viewerPermission
4797
+ }
4798
+ }
4799
+ `,
4800
+ "--jq",
4801
+ ".data.repository.viewerPermission"
4802
+ ],
4803
+ directory: input.directory,
4804
+ env: { GH_PROMPT_DISABLED: "1" }
4805
+ }).pipe(Effect.timeoutOption(GITHUB_VIEWER_PERMISSION_TIMEOUT_MS));
4806
+ if (Option.isNone(resultOption)) return null;
4807
+ const result = resultOption.value;
4808
+ if (result.status !== 0) return null;
4809
+ return parseGithubViewerPermission(result.stdout);
4810
+ }).pipe(Effect.catch(() => Effect.succeed(null)));
4509
4811
  return Git.of({
4510
4812
  currentBranch,
4511
4813
  defaultBranch,
4512
4814
  headSha,
4513
4815
  githubRepo,
4816
+ githubViewerPermission,
4514
4817
  branchExists,
4515
4818
  diffSelection: ({ directory, explicitBaseBranch }) => Effect.gen(function* () {
4516
4819
  if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) return yield* Effect.fail(new ReactDoctorError({ reason: new GitBaseBranchInvalid({ detail: "Diff base branch cannot be empty." }) }));
@@ -4605,6 +4908,7 @@ var Git = class Git extends Context.Service()("react-doctor/Git") {
4605
4908
  defaultBranch: () => Effect.succeed(snapshot.defaultBranch ?? null),
4606
4909
  headSha: () => Effect.succeed(snapshot.headSha ?? null),
4607
4910
  githubRepo: () => Effect.succeed(snapshot.githubRepo ?? null),
4911
+ githubViewerPermission: () => Effect.succeed(snapshot.githubViewerPermission ?? null),
4608
4912
  branchExists: (_directory, branch) => Effect.succeed(snapshot.branchExists?.get(branch) ?? false),
4609
4913
  diffSelection: () => Effect.succeed(snapshot.diffSelection ?? null),
4610
4914
  stagedFilePaths: () => Effect.succeed(snapshot.stagedFiles ?? []),
@@ -4720,7 +5024,6 @@ const findFirstLintConfigInDirectory = (directory) => {
4720
5024
  }
4721
5025
  return null;
4722
5026
  };
4723
- const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
4724
5027
  const detectUserLintConfigPaths = (rootDirectory) => {
4725
5028
  const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
4726
5029
  if (directLintConfig) return [directLintConfig];
@@ -4856,7 +5159,6 @@ const shouldEnableRule = (requires, tags, capabilities, ignoredTags, disabledBy)
4856
5159
  }
4857
5160
  return true;
4858
5161
  };
4859
- const esmRequire$1 = createRequire(import.meta.url);
4860
5162
  /**
4861
5163
  * Loads a plugin module via the local require resolver and extracts
4862
5164
  * `(name, ruleNames)` from either `module.exports.meta + rules` or
@@ -4883,15 +5185,16 @@ const readPluginShape = (pluginSpecifier, loadModule) => {
4883
5185
  ruleNames: new Set(Object.keys(rules))
4884
5186
  };
4885
5187
  };
5188
+ const bundledRequire = createRequire(import.meta.url);
4886
5189
  const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
4887
5190
  if (!hasReactCompiler || customRulesOnly) return null;
4888
5191
  let pluginSpecifier;
4889
5192
  try {
4890
- pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-hooks");
5193
+ pluginSpecifier = bundledRequire.resolve("eslint-plugin-react-hooks");
4891
5194
  } catch {
4892
5195
  return null;
4893
5196
  }
4894
- const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => esmRequire$1(spec));
5197
+ const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => bundledRequire(spec));
4895
5198
  return {
4896
5199
  entry: {
4897
5200
  name: "react-hooks-js",
@@ -5585,7 +5888,6 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
5585
5888
  resolve(output);
5586
5889
  });
5587
5890
  });
5588
- const PREVIEW_COUNT = 3;
5589
5891
  /**
5590
5892
  * Runs every prebuilt file batch through oxlint, with binary-split
5591
5893
  * retry on the splittable error classes (timeout / output-too-large /
@@ -5600,7 +5902,8 @@ const PREVIEW_COUNT = 3;
5600
5902
  * with a slimmer config in that case.
5601
5903
  */
5602
5904
  const spawnLintBatches = async (input) => {
5603
- const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure } = input;
5905
+ const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress } = input;
5906
+ const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
5604
5907
  const allDiagnostics = [];
5605
5908
  const droppedFiles = [];
5606
5909
  let firstDropReason = null;
@@ -5619,10 +5922,24 @@ const spawnLintBatches = async (input) => {
5619
5922
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
5620
5923
  }
5621
5924
  };
5622
- for (const batch of fileBatches) allDiagnostics.push(...await spawnLintBatch(batch));
5925
+ let scannedFileCount = 0;
5926
+ for (const batch of fileBatches) {
5927
+ let batchFileIndex = 0;
5928
+ const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
5929
+ if (batchFileIndex < batch.length) {
5930
+ batchFileIndex += 1;
5931
+ onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
5932
+ }
5933
+ }, 50) : null;
5934
+ const batchDiagnostics = await spawnLintBatch(batch);
5935
+ if (progressInterval !== null) clearInterval(progressInterval);
5936
+ allDiagnostics.push(...batchDiagnostics);
5937
+ scannedFileCount += batch.length;
5938
+ onFileProgress?.(scannedFileCount, totalFileCount);
5939
+ }
5623
5940
  if (droppedFiles.length > 0 && onPartialFailure) {
5624
- const previewFiles = droppedFiles.slice(0, PREVIEW_COUNT).join(", ");
5625
- const remainderHint = droppedFiles.length > PREVIEW_COUNT ? `, +${droppedFiles.length - PREVIEW_COUNT} more` : "";
5941
+ const previewFiles = droppedFiles.slice(0, 3).join(", ");
5942
+ const remainderHint = droppedFiles.length > 3 ? `, +${droppedFiles.length - 3} more` : "";
5626
5943
  const reasonHint = firstDropReason ? ` — first failure: ${firstDropReason}` : "";
5627
5944
  onPartialFailure(`${droppedFiles.length} file(s) failed to lint and were skipped (${previewFiles}${remainderHint})${reasonHint}`);
5628
5945
  }
@@ -5742,7 +6059,8 @@ const runOxlint = async (options) => {
5742
6059
  rootDirectory,
5743
6060
  nodeBinaryPath,
5744
6061
  project,
5745
- onPartialFailure
6062
+ onPartialFailure,
6063
+ onFileProgress: options.onFileProgress
5746
6064
  });
5747
6065
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
5748
6066
  try {
@@ -5823,7 +6141,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
5823
6141
  configSourceDirectory: input.configSourceDirectory,
5824
6142
  onPartialFailure: (reason) => {
5825
6143
  collectedFailures.push(reason);
5826
- }
6144
+ },
6145
+ onFileProgress: input.onFileProgress
5827
6146
  }),
5828
6147
  catch: ensureReactDoctorError
5829
6148
  });
@@ -5850,6 +6169,48 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
5850
6169
  return stream;
5851
6170
  } }));
5852
6171
  };
6172
+ var ProgressCapture = class ProgressCapture extends Context.Service()("react-doctor/ProgressCapture") {
6173
+ static layer = Layer.effect(ProgressCapture, Ref.make([]));
6174
+ };
6175
+ /**
6176
+ * `Progress` is the terminal-feedback service. Layer slot for ora
6177
+ * (CLI), log lines, GitHub Action `::group::`, or a no-op for silent
6178
+ * modes. Tests use `layerCapture` to record start/succeed/fail
6179
+ * events into a Ref instead of mocking the underlying spinner module.
6180
+ */
6181
+ var Progress = class Progress extends Context.Service()("react-doctor/Progress") {
6182
+ /**
6183
+ * Layer that uses an injected factory. The cli package provides
6184
+ * its own factory backed by the existing ora-based `spinner.ts`
6185
+ * helper; this layer keeps the core package free of an ora dep.
6186
+ */
6187
+ static layerOra = (factory) => Layer.succeed(Progress, Progress.of({ start: (text) => Effect.sync(() => factory(text)) }));
6188
+ static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
6189
+ update: () => Effect.void,
6190
+ succeed: () => Effect.void,
6191
+ fail: () => Effect.void
6192
+ }) }));
6193
+ static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
6194
+ yield* Ref.update(events, (existing) => [...existing, {
6195
+ _tag: "Started",
6196
+ text
6197
+ }]);
6198
+ return {
6199
+ update: (displayText) => Ref.update(events, (existing) => [...existing, {
6200
+ _tag: "Updated",
6201
+ text: displayText
6202
+ }]),
6203
+ succeed: (displayText) => Ref.update(events, (existing) => [...existing, {
6204
+ _tag: "Succeeded",
6205
+ text: displayText
6206
+ }]),
6207
+ fail: (displayText) => Ref.update(events, (existing) => [...existing, {
6208
+ _tag: "Failed",
6209
+ text: displayText
6210
+ }])
6211
+ };
6212
+ }) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
6213
+ };
5853
6214
  const translateProjectInfoError = (cause, directory) => {
5854
6215
  if (cause instanceof NoReactDependencyError) return new ReactDoctorError({ reason: new NoReactDependency({ directory: cause.directory }) });
5855
6216
  if (cause instanceof ProjectNotFoundError) return new ReactDoctorError({ reason: new ProjectNotFound({ directory: cause.directory }) });
@@ -5947,7 +6308,11 @@ const calculateScore = async (diagnostics, options = {}) => {
5947
6308
  ...options.metadata?.framework ? { framework: options.metadata.framework } : {},
5948
6309
  ...options.metadata?.reactVersion ? { reactVersion: options.metadata.reactVersion } : {},
5949
6310
  ...typeof options.metadata?.sourceFileCount === "number" ? { sourceFileCount: options.metadata.sourceFileCount } : {},
5950
- ...options.metadata?.defaultBranch ? { defaultBranch: options.metadata.defaultBranch } : {}
6311
+ ...options.metadata?.defaultBranch ? { defaultBranch: options.metadata.defaultBranch } : {},
6312
+ ...options.metadata?.doctorVersion ? { doctorVersion: options.metadata.doctorVersion } : {},
6313
+ ...options.metadata?.githubEventName ? { githubEventName: options.metadata.githubEventName } : {},
6314
+ ...options.metadata?.githubActorAssociation ? { githubActorAssociation: options.metadata.githubActorAssociation } : {},
6315
+ ...options.metadata?.githubViewerPermission ? { githubViewerPermission: options.metadata.githubViewerPermission } : {}
5951
6316
  }));
5952
6317
  const response = await fetch(requestUrl, {
5953
6318
  method: "POST",
@@ -5992,6 +6357,32 @@ var Score = class Score extends Context.Service()("react-doctor/Score") {
5992
6357
  }) }));
5993
6358
  static layerOf = (result) => Layer.succeed(Score, Score.of({ compute: () => Effect.succeed(result) }));
5994
6359
  };
6360
+ const getObjectProperty = (value, propertyName) => {
6361
+ if (typeof value !== "object" || value === null) return void 0;
6362
+ return Reflect.get(value, propertyName);
6363
+ };
6364
+ const getStringProperty = (value, propertyName) => {
6365
+ const propertyValue = getObjectProperty(value, propertyName);
6366
+ return typeof propertyValue === "string" && propertyValue.length > 0 ? propertyValue : void 0;
6367
+ };
6368
+ const readGithubEventPayload = (eventPath) => {
6369
+ if (eventPath === void 0 || eventPath.length === 0) return null;
6370
+ try {
6371
+ return JSON.parse(readFileSync(eventPath, "utf8"));
6372
+ } catch {
6373
+ return null;
6374
+ }
6375
+ };
6376
+ const resolveGithubActionsScoreMetadata = (environment = process.env) => {
6377
+ if (environment.GITHUB_ACTIONS !== "true") return {};
6378
+ const pullRequest = getObjectProperty(readGithubEventPayload(environment.GITHUB_EVENT_PATH), "pull_request");
6379
+ const eventName = environment.GITHUB_EVENT_NAME;
6380
+ const actorAssociation = getStringProperty(pullRequest, "author_association");
6381
+ return {
6382
+ ...eventName !== void 0 && eventName.length > 0 ? { githubEventName: eventName } : {},
6383
+ ...actorAssociation !== void 0 ? { githubActorAssociation: actorAssociation } : {}
6384
+ };
6385
+ };
5995
6386
  const NO_HOOKS = {
5996
6387
  beforeLint: () => Effect.void,
5997
6388
  afterLint: () => Effect.void
@@ -6007,26 +6398,33 @@ const fileReader = (filesService, rootDirectory) => (filePath) => {
6007
6398
  }));
6008
6399
  return lines === null ? null : [...lines];
6009
6400
  };
6401
+ const LINT_FAIL_TEXT = "Scanning failed (lint, non-fatal).";
6402
+ const LINT_NATIVE_BINDING_FAIL_TEXT = (nodeVersion) => `Scanning failed — oxlint native binding not found (Node ${nodeVersion}).`;
6403
+ const DEAD_CODE_FAIL_TEXT = "Scanning failed (dead-code analysis, non-fatal).";
6404
+ const formatLintFailText = (reasonTag, nodeVersion) => {
6405
+ if (reasonTag === "OxlintUnavailable" || reasonTag === "OxlintSpawnFailed") return LINT_NATIVE_BINDING_FAIL_TEXT(nodeVersion);
6406
+ return LINT_FAIL_TEXT;
6407
+ };
6010
6408
  /**
6011
6409
  * The full inspect orchestration as a single composable Effect.
6012
6410
  *
6013
- * Wires the 8 services into a streaming pipeline:
6411
+ * Phases:
6014
6412
  *
6015
- * Config.resolve(directory)
6016
- * -> Project.discover(resolvedDirectory)
6017
- * -> Git metadata for score attribution
6018
- * -> Stream.fromIterable(env diagnostics: reduced-motion + pnpm hardening)
6019
- * -> Stream.concat(Linter.run(...)) [folds ReactDoctorError into Ref]
6020
- * -> Stream.concat(DeadCode.run(...)) [folds Error into Ref]
6021
- * -> Stream.filterMap(perElementPipeline.apply) [auto-suppress / severity / ignore / inline]
6022
- * -> Stream.tap(Reporter.emit) [side-channel: future LSP / NDJSON]
6023
- * -> Stream.runCollect
6024
- * -> Score.compute(filtered)
6413
+ * 1. Config.resolve(directory) → Project.discover → Git metadata
6414
+ * 2. beforeLint hook (e.g. CLI renders the project-detection block)
6415
+ * 3. environment checks (reduced-motion + pnpm hardening)
6416
+ * 4. Linter.run + DeadCode.run forked as concurrent fibers so
6417
+ * their wall-clock times overlap. Progress spinners stay
6418
+ * sequential (lint first, then dead-code) for clean terminal
6419
+ * output. GitHub viewer permission also runs as a background
6420
+ * fiber during this phase.
6421
+ * 5. afterLint hook
6422
+ * 6. Reporter.finalize
6423
+ * 7. Score.compute against the surface-filtered diagnostic set
6025
6424
  *
6026
- * Lint and dead-code failures are folded via `Stream.catchTag` into
6027
- * Ref state on the orchestrator side — they don't sink the whole
6028
- * scan, and the renderer (cli or programmatic api) surfaces them
6029
- * via `skippedCheckReasons`.
6425
+ * The orchestrator owns spinner lifecycle via `Progress`; callers
6426
+ * choose `Progress.layerOra(...)` for CLI feedback or
6427
+ * `Progress.layerNoop` for silent / programmatic runs.
6030
6428
  */
6031
6429
  const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6032
6430
  const projectService = yield* Project;
@@ -6037,6 +6435,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6037
6435
  const scoreService = yield* Score;
6038
6436
  const deadCodeService = yield* DeadCode;
6039
6437
  const gitService = yield* Git;
6438
+ const progressService = yield* Progress;
6040
6439
  const partialFailuresRef = yield* LintPartialFailures;
6041
6440
  const resolvedConfig = yield* configService.resolve(input.directory);
6042
6441
  const scanDirectory = resolvedConfig.resolvedDirectory;
@@ -6047,18 +6446,25 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6047
6446
  gitService.headSha(scanDirectory).pipe(Effect.orElseSucceed(() => null)),
6048
6447
  gitService.defaultBranch(scanDirectory).pipe(Effect.orElseSucceed(() => null))
6049
6448
  ], { concurrency: 3 });
6050
- const scoreMetadata = {
6051
- ...repo !== null ? { repo } : {},
6052
- ...sha !== null ? { sha } : {},
6053
- framework: project.framework,
6054
- ...project.reactVersion !== null ? { reactVersion: project.reactVersion } : {},
6055
- sourceFileCount: project.sourceFileCount,
6056
- ...defaultBranch !== null ? { defaultBranch } : {}
6057
- };
6449
+ const githubActionsScoreMetadata = input.isCi ? resolveGithubActionsScoreMetadata() : {};
6450
+ const githubViewerPermissionFiber = yield* Effect.forkChild(input.resolveLocalGithubViewerPermission === true && !input.isCi && repo !== null ? gitService.githubViewerPermission({
6451
+ directory: scanDirectory,
6452
+ repo
6453
+ }).pipe(Effect.orElseSucceed(() => null)) : Effect.succeed(null));
6058
6454
  const lintIncludePaths = computeJsxIncludePaths([...input.includePaths]) ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config);
6059
6455
  const beforeLint = hooks.beforeLint ?? NO_HOOKS.beforeLint;
6060
6456
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
6061
6457
  yield* beforeLint(project, lintIncludePaths ?? void 0);
6458
+ const isDiffMode = input.includePaths.length > 0;
6459
+ const transform = buildDiagnosticPipeline({
6460
+ rootDirectory: scanDirectory,
6461
+ userConfig: resolvedConfig.config,
6462
+ readFileLinesSync: fileReader(filesService, scanDirectory),
6463
+ respectInlineDisables: input.respectInlineDisables
6464
+ });
6465
+ const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
6466
+ const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
6467
+ const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
6062
6468
  const lintFailure = yield* Ref.make({
6063
6469
  didFail: false,
6064
6470
  reason: null,
@@ -6068,15 +6474,20 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6068
6474
  didFail: false,
6069
6475
  reason: null
6070
6476
  });
6071
- const isDiffMode = input.includePaths.length > 0;
6072
- const transform = buildDiagnosticPipeline({
6477
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode;
6478
+ const deadCodeFiber = yield* Effect.forkChild(shouldRunDeadCode ? Stream.runCollect(applyPerElementPipeline(deadCodeService.run({
6073
6479
  rootDirectory: scanDirectory,
6074
- userConfig: resolvedConfig.config,
6075
- readFileLinesSync: fileReader(filesService, scanDirectory),
6076
- respectInlineDisables: input.respectInlineDisables
6077
- });
6078
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
6079
- const emptyDiagnosticStream = Stream.empty;
6480
+ userConfig: resolvedConfig.config
6481
+ }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
6482
+ yield* Ref.set(deadCodeFailure, {
6483
+ didFail: true,
6484
+ reason: error.message
6485
+ });
6486
+ return Stream.empty;
6487
+ })))))) : Effect.succeed([]));
6488
+ const scanProgress = yield* progressService.start("Scanning...");
6489
+ const scanStartTime = Date.now();
6490
+ let lastReportedTotalFileCount = 0;
6080
6491
  const rawLintStream = linterService.run({
6081
6492
  rootDirectory: scanDirectory,
6082
6493
  project,
@@ -6087,34 +6498,54 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6087
6498
  adoptExistingLintConfig: input.adoptExistingLintConfig,
6088
6499
  ignoredTags: input.ignoredTags,
6089
6500
  userConfig: resolvedConfig.config ?? void 0,
6090
- configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0
6501
+ configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
6502
+ onFileProgress: (scannedFileCount, totalFileCount) => {
6503
+ lastReportedTotalFileCount = totalFileCount;
6504
+ Effect.runSync(scanProgress.update(`Scanning (${scannedFileCount}/${totalFileCount})...`));
6505
+ }
6091
6506
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
6092
6507
  yield* Ref.set(lintFailure, {
6093
6508
  didFail: true,
6094
6509
  reason: error.message,
6095
6510
  reasonTag: error.reason._tag
6096
6511
  });
6097
- return emptyDiagnosticStream;
6512
+ return Stream.empty;
6098
6513
  }))));
6099
- const deadCodeStream = input.runDeadCode && !isDiffMode ? deadCodeService.run({
6100
- rootDirectory: scanDirectory,
6101
- userConfig: resolvedConfig.config
6102
- }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
6103
- yield* Ref.set(deadCodeFailure, {
6104
- didFail: true,
6105
- reason: error.message
6106
- });
6107
- return emptyDiagnosticStream;
6108
- })))) : emptyDiagnosticStream;
6109
- const transformedStream = Stream.fromIterable(environmentDiagnostics).pipe(Stream.concat(rawLintStream), Stream.concat(deadCodeStream), Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
6110
- const survivingDiagnostics = yield* Stream.runCollect(transformedStream);
6111
- yield* reporterService.finalize;
6514
+ const lintCollected = yield* Stream.runCollect(applyPerElementPipeline(rawLintStream));
6112
6515
  const lintFailureState = yield* Ref.get(lintFailure);
6113
- const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
6114
6516
  yield* afterLint(lintFailureState.didFail);
6115
- const finalDiagnostics = [...survivingDiagnostics];
6517
+ if (lintFailureState.didFail) {
6518
+ yield* Fiber.interrupt(deadCodeFiber);
6519
+ yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
6520
+ }
6521
+ const deadCodeCollected = lintFailureState.didFail ? [] : yield* Fiber.join(deadCodeFiber);
6522
+ const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
6523
+ const scanElapsedSeconds = ((Date.now() - scanStartTime) / 1e3).toFixed(1);
6524
+ const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
6525
+ if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
6526
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
6527
+ yield* reporterService.finalize;
6528
+ const finalDiagnostics = [
6529
+ ...envCollected,
6530
+ ...lintCollected,
6531
+ ...deadCodeCollected
6532
+ ];
6533
+ const githubViewerPermission = yield* Fiber.join(githubViewerPermissionFiber);
6534
+ const scoreMetadata = {
6535
+ ...repo !== null ? { repo } : {},
6536
+ ...sha !== null ? { sha } : {},
6537
+ framework: project.framework,
6538
+ ...project.reactVersion !== null ? { reactVersion: project.reactVersion } : {},
6539
+ sourceFileCount: project.sourceFileCount,
6540
+ ...defaultBranch !== null ? { defaultBranch } : {},
6541
+ ...input.doctorVersion !== void 0 ? { doctorVersion: input.doctorVersion } : {},
6542
+ ...githubActionsScoreMetadata,
6543
+ ...githubViewerPermission !== null ? { githubViewerPermission } : {}
6544
+ };
6545
+ const scoreSurface = input.scoreSurface ?? "score";
6546
+ const scoreDiagnostics = filterDiagnosticsForSurface([...finalDiagnostics], scoreSurface, resolvedConfig.config);
6116
6547
  const score = lintFailureState.didFail ? null : yield* scoreService.compute({
6117
- diagnostics: finalDiagnostics,
6548
+ diagnostics: scoreDiagnostics,
6118
6549
  isCi: input.isCi,
6119
6550
  metadata: scoreMetadata
6120
6551
  });
@@ -6137,9 +6568,10 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6137
6568
  "inspect.directory": input.directory,
6138
6569
  "inspect.includePathCount": input.includePaths.length,
6139
6570
  "inspect.runDeadCode": input.runDeadCode,
6140
- "inspect.isCi": input.isCi
6571
+ "inspect.isCi": input.isCi,
6572
+ "inspect.scoreSurface": input.scoreSurface ?? "score"
6141
6573
  } }));
6142
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Reporter.layerNoop, Score.layerHttp);
6574
+ Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
6143
6575
  const parseNodeVersion = (versionString) => {
6144
6576
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
6145
6577
  return {
@@ -6250,37 +6682,6 @@ var NodeResolver = class NodeResolver extends Context.Service()("react-doctor/No
6250
6682
  });
6251
6683
  });
6252
6684
  };
6253
- var ProgressCapture = class ProgressCapture extends Context.Service()("react-doctor/ProgressCapture") {
6254
- static layer = Layer.effect(ProgressCapture, Ref.make([]));
6255
- };
6256
- (class Progress extends Context.Service()("react-doctor/Progress") {
6257
- /**
6258
- * Layer that uses an injected factory. The cli package provides
6259
- * its own factory backed by the existing ora-based `spinner.ts`
6260
- * helper; this layer keeps the core package free of an ora dep.
6261
- */
6262
- static layerOra = (factory) => Layer.succeed(Progress, Progress.of({ start: (text) => Effect.sync(() => factory(text)) }));
6263
- static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
6264
- succeed: () => Effect.void,
6265
- fail: () => Effect.void
6266
- }) }));
6267
- static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
6268
- yield* Ref.update(events, (existing) => [...existing, {
6269
- _tag: "Started",
6270
- text
6271
- }]);
6272
- return {
6273
- succeed: (displayText) => Ref.update(events, (existing) => [...existing, {
6274
- _tag: "Succeeded",
6275
- text: displayText
6276
- }]),
6277
- fail: (displayText) => Ref.update(events, (existing) => [...existing, {
6278
- _tag: "Failed",
6279
- text: displayText
6280
- }])
6281
- };
6282
- }) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
6283
- });
6284
6685
  /**
6285
6686
  * Zip-Slip defense: `git diff --cached --name-only` is the source
6286
6687
  * of `relativePath`, and git normalizes paths during ordinary
@@ -6492,37 +6893,6 @@ const buildJsonReport = (input) => {
6492
6893
  error: null
6493
6894
  };
6494
6895
  };
6495
- const toStringSet = (values) => {
6496
- if (!values || values.length === 0) return /* @__PURE__ */ new Set();
6497
- return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
6498
- };
6499
- const buildResolvedControls = (surface, userControls) => {
6500
- const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
6501
- const includeTags = toStringSet(userControls?.includeTags);
6502
- for (const tag of includeTags) excludeTags.delete(tag);
6503
- for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
6504
- return {
6505
- includeTags,
6506
- excludeTags,
6507
- includeCategories: toStringSet(userControls?.includeCategories),
6508
- excludeCategories: toStringSet(userControls?.excludeCategories),
6509
- includeRuleKeys: toStringSet(userControls?.includeRules),
6510
- excludeRuleKeys: toStringSet(userControls?.excludeRules)
6511
- };
6512
- };
6513
- const intersects = (values, candidates) => values.some((value) => candidates.has(value));
6514
- const isDiagnosticOnSurface = (diagnostic, surface, config) => {
6515
- const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
6516
- const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
6517
- if (resolved.includeRuleKeys.has(ruleKey)) return true;
6518
- if (resolved.includeCategories.has(category)) return true;
6519
- if (intersects(tags, resolved.includeTags)) return true;
6520
- if (resolved.excludeRuleKeys.has(ruleKey)) return false;
6521
- if (resolved.excludeCategories.has(category)) return false;
6522
- if (intersects(tags, resolved.excludeTags)) return false;
6523
- return true;
6524
- };
6525
- const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
6526
6896
  /**
6527
6897
  * Programmatic façade over `Git.diffSelection`. Async because the
6528
6898
  * Git service runs through Effect's `ChildProcess` (true subprocess
@@ -6674,6 +7044,6 @@ const cliLogger = {
6674
7044
  }
6675
7045
  };
6676
7046
  //#endregion
6677
- export { formatReactDoctorError as A, toRelativePath as B, buildJsonReportError as C, filterSourceFiles as D, filterDiagnosticsForSurface as E, layerOtlp as F, listWorkspacePackages as I, loadConfigWithSource as L, groupBy as M, highlighter as N, formatErrorChain as O, isReactDoctorError as P, resolveConfigRootDir as R, buildJsonReport as S, discoverReactSubprojects as T, Reporter as _, Config as a, Score as b, Git as c, MILLISECONDS_PER_SECOND as d, NoReactDependencyError as f, ProjectNotFoundError as g, Project as h, CANONICAL_GITHUB_URL as i, getDiffInfo as j, formatFrameworkName as k, LintPartialFailures as l, OXLINT_NODE_REQUIREMENT as m, cli_logger_exports as n, DeadCode as o, NodeResolver as p, AmbiguousProjectError as r, Files as s, cliLogger as t, Linter as u, SHARE_BASE_URL as v, calculateScore as w, StagedFiles as x, SKILL_NAME as y, runInspect as z };
7047
+ export { isReactDoctorError as A, filterSourceFiles as C, groupBy as D, getDiffInfo as E, runInspect as F, toRelativePath as I, listWorkspacePackages as M, resolveScanTarget as N, highlighter as O, restoreLegacyThrow as P, filterDiagnosticsForSurface as S, formatReactDoctorError as T, Score as _, DeadCode as a, buildJsonReportError as b, LintPartialFailures as c, OXLINT_NODE_REQUIREMENT as d, Progress as f, SKILL_NAME as g, SHARE_BASE_URL as h, Config as i, layerOtlp as j, isMonorepoRoot as k, Linter as l, Reporter as m, cli_logger_exports as n, Files as o, Project as p, CANONICAL_GITHUB_URL as r, Git as s, cliLogger as t, NodeResolver as u, StagedFiles as v, formatErrorChain as w, discoverReactSubprojects as x, buildJsonReport as y };
6678
7048
 
6679
- //# sourceMappingURL=cli-logger-CISyjOAb.js.map
7049
+ //# sourceMappingURL=cli-logger-C35LXalM.js.map