react-doctor 0.2.6 → 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.
- package/dist/{cli-logger-Iz5pfDnL.js → cli-logger-C35LXalM.js} +887 -616
- package/dist/cli.js +1145 -636
- package/dist/index.d.ts +32 -16
- package/dist/index.js +938 -625
- package/dist/skills/react-doctor/SKILL.md +16 -2
- package/package.json +6 -14
|
@@ -8,19 +8,20 @@ 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
|
|
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
|
|
2243
|
+
if (isProjectBoundary(ancestorDirectory)) return false;
|
|
2236
2244
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
2237
2245
|
}
|
|
2238
2246
|
return false;
|
|
@@ -2956,7 +2964,6 @@ 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;
|
|
@@ -2986,6 +2993,7 @@ const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
|
2986
2993
|
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
2987
2994
|
const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
|
|
2988
2995
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
2996
|
+
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
2989
2997
|
var InvalidGlobPatternError = class extends Error {
|
|
2990
2998
|
pattern;
|
|
2991
2999
|
reason;
|
|
@@ -3080,6 +3088,18 @@ const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) =>
|
|
|
3080
3088
|
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
3081
3089
|
return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
|
|
3082
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
|
+
};
|
|
3083
3103
|
/**
|
|
3084
3104
|
* Assembles the internal `RuleSeverityControls` shape from a user
|
|
3085
3105
|
* config's top-level `rules` and `categories` fields — the
|
|
@@ -3097,25 +3117,115 @@ const buildRuleSeverityControls = (config) => {
|
|
|
3097
3117
|
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
3098
3118
|
};
|
|
3099
3119
|
};
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
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
|
+
};
|
|
3119
3229
|
const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
|
|
3120
3230
|
"effect/no-adjust-state-on-prop-change": "react-doctor/no-adjust-state-on-prop-change",
|
|
3121
3231
|
"effect/no-chain-state-updates": "react-doctor/no-chain-state-updates",
|
|
@@ -3246,6 +3356,111 @@ const getEquivalentRuleKeys = (ruleKey) => {
|
|
|
3246
3356
|
const nativeRuleKey = canonicalizeRuleKey(ruleKey);
|
|
3247
3357
|
return [nativeRuleKey, ...getLegacyRuleKeysForNative(nativeRuleKey)];
|
|
3248
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
|
+
};
|
|
3249
3464
|
/**
|
|
3250
3465
|
* Resolves the user-configured severity override for a rule.
|
|
3251
3466
|
* Per-rule overrides win over per-category overrides. Returns
|
|
@@ -3263,233 +3478,152 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
3263
3478
|
}
|
|
3264
3479
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
3265
3480
|
};
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
const
|
|
3279
|
-
const
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
|
|
3283
|
-
const character = line[charIndex];
|
|
3284
|
-
if (stringDelimiter !== null) {
|
|
3285
|
-
if (character === "\\") {
|
|
3286
|
-
charIndex++;
|
|
3287
|
-
continue;
|
|
3288
|
-
}
|
|
3289
|
-
if (character === stringDelimiter) stringDelimiter = null;
|
|
3290
|
-
continue;
|
|
3291
|
-
}
|
|
3292
|
-
if (character === "\"" || character === "'" || character === "`") {
|
|
3293
|
-
stringDelimiter = character;
|
|
3294
|
-
continue;
|
|
3295
|
-
}
|
|
3296
|
-
if (character === "/" && line[charIndex + 1] === "/") return true;
|
|
3297
|
-
}
|
|
3298
|
-
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(/^\.\//, "")}`;
|
|
3299
3497
|
};
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
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
|
+
};
|
|
3304
3521
|
}
|
|
3305
3522
|
return null;
|
|
3306
3523
|
};
|
|
3307
|
-
const
|
|
3308
|
-
const
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
if (character === "{") {
|
|
3334
|
-
braceDepth++;
|
|
3335
|
-
continue;
|
|
3336
|
-
}
|
|
3337
|
-
if (character === "}") {
|
|
3338
|
-
braceDepth--;
|
|
3339
|
-
continue;
|
|
3340
|
-
}
|
|
3341
|
-
if (braceDepth !== 0) continue;
|
|
3342
|
-
if (character === "<") {
|
|
3343
|
-
const followCharacter = currentLine[charIndex + 1];
|
|
3344
|
-
if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
|
|
3345
|
-
continue;
|
|
3346
|
-
}
|
|
3347
|
-
if (character !== ">") continue;
|
|
3348
|
-
const previousCharacter = currentLine[charIndex - 1];
|
|
3349
|
-
const nextCharacter = currentLine[charIndex + 1];
|
|
3350
|
-
if (previousCharacter === "=" || nextCharacter === "=") continue;
|
|
3351
|
-
if (innerAngleDepth > 0) {
|
|
3352
|
-
innerAngleDepth--;
|
|
3353
|
-
continue;
|
|
3354
|
-
}
|
|
3355
|
-
return lineIndex;
|
|
3356
|
-
}
|
|
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");
|
|
3357
3550
|
}
|
|
3358
|
-
return
|
|
3551
|
+
return {
|
|
3552
|
+
closerLineIndex,
|
|
3553
|
+
closerColumn,
|
|
3554
|
+
bodyText
|
|
3555
|
+
};
|
|
3359
3556
|
};
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
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);
|
|
3364
3569
|
}
|
|
3365
|
-
return
|
|
3570
|
+
return false;
|
|
3366
3571
|
};
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
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;
|
|
3381
3593
|
continue;
|
|
3382
3594
|
}
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
}
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
|
|
3390
|
-
return ruleList.slice(0, descriptionMatch.index);
|
|
3391
|
-
};
|
|
3392
|
-
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
3393
|
-
const trimmed = ruleList?.trim();
|
|
3394
|
-
if (!trimmed) return true;
|
|
3395
|
-
const ruleSection = stripDescriptionTail(trimmed).trim();
|
|
3396
|
-
if (!ruleSection) return true;
|
|
3397
|
-
return ruleSection.split(/[,\s]+/).some((token) => isSameRuleKey(token.trim(), ruleId));
|
|
3398
|
-
};
|
|
3399
|
-
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
3400
|
-
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
3401
|
-
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
3402
|
-
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
3403
|
-
const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
3404
|
-
const buildAdjacentMismatchHint = (comment, ruleId) => {
|
|
3405
|
-
const ruleListText = comment.ruleList?.trim() ?? "";
|
|
3406
|
-
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}`;
|
|
3407
|
-
};
|
|
3408
|
-
const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
|
|
3409
|
-
const commentLineNumber = comment.commentLineIndex + 1;
|
|
3410
|
-
const diagnosticLineNumber = diagnosticLineIndex + 1;
|
|
3411
|
-
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.`;
|
|
3412
|
-
};
|
|
3413
|
-
const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
|
|
3414
|
-
for (const comments of commentsByAnchor) {
|
|
3415
|
-
const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
|
|
3416
|
-
if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
|
|
3417
|
-
const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
|
|
3418
|
-
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);
|
|
3419
3601
|
}
|
|
3420
|
-
return
|
|
3421
|
-
};
|
|
3422
|
-
const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
|
|
3423
|
-
const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
|
|
3424
|
-
if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
|
|
3425
|
-
isSuppressed: true,
|
|
3426
|
-
nearMissHint: null
|
|
3427
|
-
};
|
|
3428
|
-
const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
|
|
3429
|
-
if (hasChainSuppressor(directComments, ruleId)) return {
|
|
3430
|
-
isSuppressed: true,
|
|
3431
|
-
nearMissHint: null
|
|
3432
|
-
};
|
|
3433
|
-
const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
|
|
3434
|
-
const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
|
|
3435
|
-
if (hasChainSuppressor(openerComments, ruleId)) return {
|
|
3436
|
-
isSuppressed: true,
|
|
3437
|
-
nearMissHint: null
|
|
3438
|
-
};
|
|
3439
|
-
return {
|
|
3440
|
-
isSuppressed: false,
|
|
3441
|
-
nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
|
|
3442
|
-
};
|
|
3443
|
-
};
|
|
3444
|
-
const compileIgnoredFilePatterns = (userConfig) => {
|
|
3445
|
-
const files = userConfig?.ignore?.files;
|
|
3446
|
-
if (!Array.isArray(files)) return [];
|
|
3447
|
-
return compileGlobPatternsLenient(files.filter((entry) => typeof entry === "string"), (error) => warnConfigIssue(`ignore.files: ${error.message}`));
|
|
3448
|
-
};
|
|
3449
|
-
const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
|
|
3450
|
-
if (patterns.length === 0) return false;
|
|
3451
|
-
const relativePath = toRelativePath(filePath, rootDirectory);
|
|
3452
|
-
return patterns.some((pattern) => pattern.test(relativePath));
|
|
3453
|
-
};
|
|
3454
|
-
const resolveCandidateReadPath = (rootDirectory, filePath) => {
|
|
3455
|
-
const normalizedFile = filePath.replace(/\\/g, "/");
|
|
3456
|
-
if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
|
|
3457
|
-
return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
|
|
3458
|
-
};
|
|
3459
|
-
const TEST_FILE_DIRECTORY_PATTERN = /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\//;
|
|
3460
|
-
const TEST_FILE_SUFFIX_PATTERN = /\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
|
|
3461
|
-
const FIXTURE_PROJECT_PATTERN = /\/(?:fixtures|__fixtures__)\//;
|
|
3462
|
-
const SOURCE_ROOT_PATTERN = /\/(?:src|app|lib|components|pages|features|modules|packages|apps|frontend|client)\//g;
|
|
3463
|
-
const stripAboveSourceRoot = (relativePath) => {
|
|
3464
|
-
const fixtureMatch = FIXTURE_PROJECT_PATTERN.exec(relativePath);
|
|
3465
|
-
if (fixtureMatch === null) return relativePath;
|
|
3466
|
-
let lastIdx = -1;
|
|
3467
|
-
for (const match of relativePath.matchAll(SOURCE_ROOT_PATTERN)) if (match.index !== void 0 && match.index > lastIdx) lastIdx = match.index;
|
|
3468
|
-
if (lastIdx >= 0) return relativePath.slice(lastIdx);
|
|
3469
|
-
return relativePath.slice(fixtureMatch.index + fixtureMatch[0].length - 1);
|
|
3602
|
+
return false;
|
|
3470
3603
|
};
|
|
3471
|
-
const
|
|
3472
|
-
if (
|
|
3473
|
-
|
|
3474
|
-
if (TEST_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return true;
|
|
3475
|
-
const scoped = stripAboveSourceRoot(forwardSlashed);
|
|
3476
|
-
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"));
|
|
3477
3607
|
};
|
|
3478
3608
|
/**
|
|
3479
3609
|
* Pre-compiles every stateful filter and returns a single
|
|
3480
|
-
* `apply(diagnostic)` closure that runs:
|
|
3610
|
+
* `apply(diagnostic)` closure that runs (in order):
|
|
3481
3611
|
*
|
|
3482
3612
|
* 1. auto-suppress (test-noise rules in test files; `migration-hint`
|
|
3483
3613
|
* wins over `test-noise`)
|
|
3484
3614
|
* 2. severity overrides (top-level `rules` / `categories`, with
|
|
3485
3615
|
* `"off"` dropping)
|
|
3486
3616
|
* 3. ignore filters (rules / file patterns / per-file overrides)
|
|
3487
|
-
* 4.
|
|
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 ...`)
|
|
3488
3620
|
*
|
|
3489
3621
|
* Returns `null` when the diagnostic is dropped, the (possibly
|
|
3490
|
-
* severity-restamped) diagnostic otherwise.
|
|
3491
|
-
*
|
|
3492
|
-
*
|
|
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.
|
|
3493
3627
|
*/
|
|
3494
3628
|
const buildDiagnosticPipeline = (input) => {
|
|
3495
3629
|
const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables } = input;
|
|
@@ -3497,6 +3631,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3497
3631
|
const ignoredRules = new Set(Array.isArray(userConfig?.ignore?.rules) ? userConfig.ignore.rules.filter((rule) => typeof rule === "string") : []);
|
|
3498
3632
|
const ignoredFilePatterns = compileIgnoredFilePatterns(userConfig);
|
|
3499
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;
|
|
3500
3638
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
3501
3639
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
3502
3640
|
const getFileLines = (filePath) => {
|
|
@@ -3525,6 +3663,16 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3525
3663
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
3526
3664
|
return false;
|
|
3527
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
|
+
};
|
|
3528
3676
|
return { apply: (diagnostic) => {
|
|
3529
3677
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
3530
3678
|
let current = diagnostic;
|
|
@@ -3541,6 +3689,7 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3541
3689
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
3542
3690
|
if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) return null;
|
|
3543
3691
|
if (isDiagnosticIgnoredByOverrides(current, rootDirectory, compiledOverrides)) return null;
|
|
3692
|
+
if (isRnRawTextSuppressedByConfig(current)) return null;
|
|
3544
3693
|
}
|
|
3545
3694
|
if (respectInlineDisables && current.line > 0) {
|
|
3546
3695
|
const lines = getFileLines(current.filePath);
|
|
@@ -3667,6 +3816,27 @@ var ReactDoctorError = class extends Schema.TaggedErrorClass()("ReactDoctorError
|
|
|
3667
3816
|
const formatReactDoctorError = (error) => error.reason.message;
|
|
3668
3817
|
const isSplittableReactDoctorError = (error) => error instanceof ReactDoctorError && error.reason._tag === "OxlintBatchExceeded";
|
|
3669
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)));
|
|
3670
3840
|
const TRACER_PROJECT_NAME = "react-doctor";
|
|
3671
3841
|
const OTEL_ENDPOINT = Config$1.string("REACT_DOCTOR_OTLP_ENDPOINT").pipe(Config$1.option);
|
|
3672
3842
|
const OTEL_AUTH_HEADER = Config$1.redacted("REACT_DOCTOR_OTLP_AUTH_HEADER").pipe(Config$1.option);
|
|
@@ -3709,216 +3879,6 @@ Context.Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => {
|
|
|
3709
3879
|
} });
|
|
3710
3880
|
Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES });
|
|
3711
3881
|
Context.Reference("react-doctor/StagedFilesTempDirPrefix", { defaultValue: () => "react-doctor-staged-" });
|
|
3712
|
-
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
3713
|
-
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
3714
|
-
const PACKAGE_JSON_FILE = "package.json";
|
|
3715
|
-
const PNPM_HARDENING_RULE_KEY = "require-pnpm-hardening";
|
|
3716
|
-
const UTF8_BOM_CHAR = "";
|
|
3717
|
-
const HARDENING_SETTING_KEYS = new Set([
|
|
3718
|
-
"minimumReleaseAge",
|
|
3719
|
-
"blockExoticSubdeps",
|
|
3720
|
-
"trustPolicy"
|
|
3721
|
-
]);
|
|
3722
|
-
const stripInlineComment = (rawValue) => {
|
|
3723
|
-
let activeQuote = null;
|
|
3724
|
-
for (let charIndex = 0; charIndex < rawValue.length; charIndex += 1) {
|
|
3725
|
-
const currentChar = rawValue[charIndex];
|
|
3726
|
-
if (activeQuote !== null) {
|
|
3727
|
-
if (currentChar === activeQuote) activeQuote = null;
|
|
3728
|
-
continue;
|
|
3729
|
-
}
|
|
3730
|
-
if (currentChar === "\"" || currentChar === "'") {
|
|
3731
|
-
activeQuote = currentChar;
|
|
3732
|
-
continue;
|
|
3733
|
-
}
|
|
3734
|
-
if (currentChar !== "#") continue;
|
|
3735
|
-
const previousChar = rawValue[charIndex - 1];
|
|
3736
|
-
if (charIndex === 0 || previousChar !== void 0 && /\s/.test(previousChar)) return rawValue.slice(0, charIndex);
|
|
3737
|
-
}
|
|
3738
|
-
return rawValue;
|
|
3739
|
-
};
|
|
3740
|
-
const unquote = (rawValue) => rawValue.replace(/^["']|["']$/g, "");
|
|
3741
|
-
const stripBom = (rawContent) => rawContent.startsWith(UTF8_BOM_CHAR) ? rawContent.slice(1) : rawContent;
|
|
3742
|
-
const parseHardeningSettings = (content) => {
|
|
3743
|
-
let minimumReleaseAge = null;
|
|
3744
|
-
let blockExoticSubdeps = null;
|
|
3745
|
-
let trustPolicy = null;
|
|
3746
|
-
const lines = stripBom(content).split(/\r?\n/);
|
|
3747
|
-
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
3748
|
-
const lineText = lines[lineIndex];
|
|
3749
|
-
if (lineText === void 0) continue;
|
|
3750
|
-
if (lineText.search(/\S/) !== 0) continue;
|
|
3751
|
-
const trimmedLine = lineText.trim();
|
|
3752
|
-
if (trimmedLine.startsWith("#")) continue;
|
|
3753
|
-
const colonIndex = trimmedLine.indexOf(":");
|
|
3754
|
-
if (colonIndex <= 0) continue;
|
|
3755
|
-
const settingKey = unquote(trimmedLine.slice(0, colonIndex).trim());
|
|
3756
|
-
if (!HARDENING_SETTING_KEYS.has(settingKey)) continue;
|
|
3757
|
-
const inlineValue = stripInlineComment(trimmedLine.slice(colonIndex + 1)).trim();
|
|
3758
|
-
if (inlineValue.length === 0) continue;
|
|
3759
|
-
const scalar = {
|
|
3760
|
-
value: unquote(inlineValue),
|
|
3761
|
-
line: lineIndex + 1,
|
|
3762
|
-
column: lineText.search(/\S/) + 1
|
|
3763
|
-
};
|
|
3764
|
-
if (settingKey === "minimumReleaseAge") minimumReleaseAge = scalar;
|
|
3765
|
-
else if (settingKey === "blockExoticSubdeps") blockExoticSubdeps = scalar;
|
|
3766
|
-
else if (settingKey === "trustPolicy") trustPolicy = scalar;
|
|
3767
|
-
}
|
|
3768
|
-
return {
|
|
3769
|
-
minimumReleaseAge,
|
|
3770
|
-
blockExoticSubdeps,
|
|
3771
|
-
trustPolicy
|
|
3772
|
-
};
|
|
3773
|
-
};
|
|
3774
|
-
const isPnpmManagedProject = (rootDirectory) => {
|
|
3775
|
-
if (isFile(path.join(rootDirectory, PNPM_LOCKFILE))) return true;
|
|
3776
|
-
if (isFile(path.join(rootDirectory, PNPM_WORKSPACE_FILE))) return true;
|
|
3777
|
-
const packageJsonPath = path.join(rootDirectory, PACKAGE_JSON_FILE);
|
|
3778
|
-
if (!isFile(packageJsonPath)) return false;
|
|
3779
|
-
try {
|
|
3780
|
-
const packageJsonRaw = fs.readFileSync(packageJsonPath, "utf-8");
|
|
3781
|
-
const packageJson = JSON.parse(packageJsonRaw);
|
|
3782
|
-
if (packageJson !== null && typeof packageJson === "object" && "packageManager" in packageJson && typeof packageJson.packageManager === "string" && packageJson.packageManager.startsWith("pnpm@")) return true;
|
|
3783
|
-
} catch {
|
|
3784
|
-
return false;
|
|
3785
|
-
}
|
|
3786
|
-
return false;
|
|
3787
|
-
};
|
|
3788
|
-
const buildHardeningDiagnostic = (input) => ({
|
|
3789
|
-
filePath: PNPM_WORKSPACE_FILE,
|
|
3790
|
-
plugin: "react-doctor",
|
|
3791
|
-
rule: PNPM_HARDENING_RULE_KEY,
|
|
3792
|
-
severity: "warning",
|
|
3793
|
-
message: input.message,
|
|
3794
|
-
help: input.help,
|
|
3795
|
-
line: input.line ?? 0,
|
|
3796
|
-
column: input.column ?? 0,
|
|
3797
|
-
category: "Security"
|
|
3798
|
-
});
|
|
3799
|
-
const checkPnpmHardening = (rootDirectory) => {
|
|
3800
|
-
if (!isPnpmManagedProject(rootDirectory)) return [];
|
|
3801
|
-
const workspacePath = path.join(rootDirectory, PNPM_WORKSPACE_FILE);
|
|
3802
|
-
const settings = parseHardeningSettings(isFile(workspacePath) ? fs.readFileSync(workspacePath, "utf-8") : "");
|
|
3803
|
-
const diagnostics = [];
|
|
3804
|
-
if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
|
|
3805
|
-
message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
|
|
3806
|
-
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`
|
|
3807
|
-
}));
|
|
3808
|
-
if (settings.blockExoticSubdeps !== null && settings.blockExoticSubdeps.value.toLowerCase() === "false") diagnostics.push(buildHardeningDiagnostic({
|
|
3809
|
-
line: settings.blockExoticSubdeps.line,
|
|
3810
|
-
column: settings.blockExoticSubdeps.column,
|
|
3811
|
-
message: "`blockExoticSubdeps: false` allows transitive deps from `git:`, `file:`, or tarball URLs — a known supply-chain bypass of the npm registry",
|
|
3812
|
-
help: "Set `blockExoticSubdeps: true` (the default in recent pnpm v11) so transitive deps must come from the registry"
|
|
3813
|
-
}));
|
|
3814
|
-
if (settings.trustPolicy === null) diagnostics.push(buildHardeningDiagnostic({
|
|
3815
|
-
message: "pnpm-workspace.yaml is missing `trustPolicy` — without `no-downgrade`, pnpm silently accepts packages whose trust signals (provenance, signatures) weaken between updates",
|
|
3816
|
-
help: "Add `trustPolicy: no-downgrade` to pnpm-workspace.yaml"
|
|
3817
|
-
}));
|
|
3818
|
-
else if (settings.trustPolicy.value !== "no-downgrade") diagnostics.push(buildHardeningDiagnostic({
|
|
3819
|
-
line: settings.trustPolicy.line,
|
|
3820
|
-
column: settings.trustPolicy.column,
|
|
3821
|
-
message: `\`trustPolicy: ${settings.trustPolicy.value}\` is weaker than \`no-downgrade\` — packages may lose trust signals between updates without you noticing`,
|
|
3822
|
-
help: "Set `trustPolicy: no-downgrade` so pnpm refuses to downgrade trust between resolutions"
|
|
3823
|
-
}));
|
|
3824
|
-
return diagnostics;
|
|
3825
|
-
};
|
|
3826
|
-
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
|
|
3827
|
-
const REDUCED_MOTION_FILE_GLOBS = [
|
|
3828
|
-
"*.ts",
|
|
3829
|
-
"*.tsx",
|
|
3830
|
-
"*.js",
|
|
3831
|
-
"*.jsx",
|
|
3832
|
-
"*.css",
|
|
3833
|
-
"*.scss"
|
|
3834
|
-
];
|
|
3835
|
-
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
3836
|
-
filePath: "package.json",
|
|
3837
|
-
plugin: "react-doctor",
|
|
3838
|
-
rule: "require-reduced-motion",
|
|
3839
|
-
severity: "error",
|
|
3840
|
-
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
3841
|
-
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
3842
|
-
line: 0,
|
|
3843
|
-
column: 0,
|
|
3844
|
-
category: "Accessibility"
|
|
3845
|
-
};
|
|
3846
|
-
const checkReducedMotion = (rootDirectory) => {
|
|
3847
|
-
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
3848
|
-
if (!isFile(packageJsonPath)) return [];
|
|
3849
|
-
let hasMotionLibrary = false;
|
|
3850
|
-
try {
|
|
3851
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
3852
|
-
const allDependencies = {
|
|
3853
|
-
...packageJson.dependencies,
|
|
3854
|
-
...packageJson.devDependencies
|
|
3855
|
-
};
|
|
3856
|
-
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
3857
|
-
} catch {
|
|
3858
|
-
return [];
|
|
3859
|
-
}
|
|
3860
|
-
if (!hasMotionLibrary) return [];
|
|
3861
|
-
const result = spawnSync("git", [
|
|
3862
|
-
"grep",
|
|
3863
|
-
"-ql",
|
|
3864
|
-
"-E",
|
|
3865
|
-
REDUCED_MOTION_GREP_PATTERN,
|
|
3866
|
-
"--",
|
|
3867
|
-
...REDUCED_MOTION_FILE_GLOBS
|
|
3868
|
-
], {
|
|
3869
|
-
cwd: rootDirectory,
|
|
3870
|
-
stdio: [
|
|
3871
|
-
"ignore",
|
|
3872
|
-
"pipe",
|
|
3873
|
-
"pipe"
|
|
3874
|
-
]
|
|
3875
|
-
});
|
|
3876
|
-
if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
3877
|
-
if (result.status === 0) return [];
|
|
3878
|
-
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
3879
|
-
};
|
|
3880
|
-
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
3881
|
-
const listSourceFilesViaGit = (rootDirectory) => {
|
|
3882
|
-
const result = spawnSync("git", [
|
|
3883
|
-
"ls-files",
|
|
3884
|
-
"-z",
|
|
3885
|
-
"--cached",
|
|
3886
|
-
"--others",
|
|
3887
|
-
"--exclude-standard"
|
|
3888
|
-
], {
|
|
3889
|
-
cwd: rootDirectory,
|
|
3890
|
-
encoding: "utf-8",
|
|
3891
|
-
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
3892
|
-
});
|
|
3893
|
-
if (result.error || result.status !== 0) return null;
|
|
3894
|
-
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
|
|
3895
|
-
};
|
|
3896
|
-
const listSourceFilesViaFilesystem = (rootDirectory) => {
|
|
3897
|
-
const filePaths = [];
|
|
3898
|
-
const stack = [rootDirectory];
|
|
3899
|
-
while (stack.length > 0) {
|
|
3900
|
-
const currentDirectory = stack.pop();
|
|
3901
|
-
const entries = readDirectoryEntries(currentDirectory);
|
|
3902
|
-
for (const entry of entries) {
|
|
3903
|
-
const absolutePath = path.join(currentDirectory, entry.name);
|
|
3904
|
-
if (entry.isDirectory()) {
|
|
3905
|
-
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
|
|
3906
|
-
continue;
|
|
3907
|
-
}
|
|
3908
|
-
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
|
|
3909
|
-
}
|
|
3910
|
-
}
|
|
3911
|
-
return filePaths;
|
|
3912
|
-
};
|
|
3913
|
-
const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
|
|
3914
|
-
const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
3915
|
-
if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
|
|
3916
|
-
const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
|
|
3917
|
-
return listSourceFiles(rootDirectory).filter((filePath) => {
|
|
3918
|
-
if (!JSX_FILE_PATTERN.test(filePath)) return false;
|
|
3919
|
-
return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
|
|
3920
|
-
});
|
|
3921
|
-
};
|
|
3922
3882
|
const DIAGNOSTIC_SURFACES = [
|
|
3923
3883
|
"cli",
|
|
3924
3884
|
"prComment",
|
|
@@ -4092,56 +4052,339 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
4092
4052
|
}
|
|
4093
4053
|
return null;
|
|
4094
4054
|
};
|
|
4095
|
-
const
|
|
4096
|
-
const
|
|
4097
|
-
const
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
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, "/"));
|
|
4119
4371
|
}
|
|
4120
|
-
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
4121
4372
|
}
|
|
4122
|
-
|
|
4123
|
-
return null;
|
|
4373
|
+
return filePaths;
|
|
4124
4374
|
};
|
|
4125
|
-
const
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
if (!isDirectory(resolvedRootDir)) {
|
|
4134
|
-
Effect.runSync(Console.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`));
|
|
4135
|
-
return null;
|
|
4136
|
-
}
|
|
4137
|
-
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
|
+
});
|
|
4138
4383
|
};
|
|
4139
|
-
const CONFIG_CACHE_CAPACITY = 16;
|
|
4140
|
-
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
4141
4384
|
var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
4142
4385
|
static layerNode = Layer.effect(Config, Effect.gen(function* () {
|
|
4143
4386
|
const cache = yield* Cache.make({
|
|
4144
|
-
capacity:
|
|
4387
|
+
capacity: 16,
|
|
4145
4388
|
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
4146
4389
|
lookup: (directory) => Effect.sync(() => {
|
|
4147
4390
|
const loaded = loadConfigWithSource(directory);
|
|
@@ -4781,7 +5024,6 @@ const findFirstLintConfigInDirectory = (directory) => {
|
|
|
4781
5024
|
}
|
|
4782
5025
|
return null;
|
|
4783
5026
|
};
|
|
4784
|
-
const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
4785
5027
|
const detectUserLintConfigPaths = (rootDirectory) => {
|
|
4786
5028
|
const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
|
|
4787
5029
|
if (directLintConfig) return [directLintConfig];
|
|
@@ -4917,7 +5159,6 @@ const shouldEnableRule = (requires, tags, capabilities, ignoredTags, disabledBy)
|
|
|
4917
5159
|
}
|
|
4918
5160
|
return true;
|
|
4919
5161
|
};
|
|
4920
|
-
const esmRequire$1 = createRequire(import.meta.url);
|
|
4921
5162
|
/**
|
|
4922
5163
|
* Loads a plugin module via the local require resolver and extracts
|
|
4923
5164
|
* `(name, ruleNames)` from either `module.exports.meta + rules` or
|
|
@@ -4944,15 +5185,16 @@ const readPluginShape = (pluginSpecifier, loadModule) => {
|
|
|
4944
5185
|
ruleNames: new Set(Object.keys(rules))
|
|
4945
5186
|
};
|
|
4946
5187
|
};
|
|
5188
|
+
const bundledRequire = createRequire(import.meta.url);
|
|
4947
5189
|
const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
|
|
4948
5190
|
if (!hasReactCompiler || customRulesOnly) return null;
|
|
4949
5191
|
let pluginSpecifier;
|
|
4950
5192
|
try {
|
|
4951
|
-
pluginSpecifier =
|
|
5193
|
+
pluginSpecifier = bundledRequire.resolve("eslint-plugin-react-hooks");
|
|
4952
5194
|
} catch {
|
|
4953
5195
|
return null;
|
|
4954
5196
|
}
|
|
4955
|
-
const { ruleNames } = readPluginShape(pluginSpecifier, (spec) =>
|
|
5197
|
+
const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => bundledRequire(spec));
|
|
4956
5198
|
return {
|
|
4957
5199
|
entry: {
|
|
4958
5200
|
name: "react-hooks-js",
|
|
@@ -5646,7 +5888,6 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
|
|
|
5646
5888
|
resolve(output);
|
|
5647
5889
|
});
|
|
5648
5890
|
});
|
|
5649
|
-
const PREVIEW_COUNT = 3;
|
|
5650
5891
|
/**
|
|
5651
5892
|
* Runs every prebuilt file batch through oxlint, with binary-split
|
|
5652
5893
|
* retry on the splittable error classes (timeout / output-too-large /
|
|
@@ -5661,7 +5902,8 @@ const PREVIEW_COUNT = 3;
|
|
|
5661
5902
|
* with a slimmer config in that case.
|
|
5662
5903
|
*/
|
|
5663
5904
|
const spawnLintBatches = async (input) => {
|
|
5664
|
-
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);
|
|
5665
5907
|
const allDiagnostics = [];
|
|
5666
5908
|
const droppedFiles = [];
|
|
5667
5909
|
let firstDropReason = null;
|
|
@@ -5680,10 +5922,24 @@ const spawnLintBatches = async (input) => {
|
|
|
5680
5922
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
5681
5923
|
}
|
|
5682
5924
|
};
|
|
5683
|
-
|
|
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
|
+
}
|
|
5684
5940
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
5685
|
-
const previewFiles = droppedFiles.slice(0,
|
|
5686
|
-
const remainderHint = droppedFiles.length >
|
|
5941
|
+
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
5942
|
+
const remainderHint = droppedFiles.length > 3 ? `, +${droppedFiles.length - 3} more` : "";
|
|
5687
5943
|
const reasonHint = firstDropReason ? ` — first failure: ${firstDropReason}` : "";
|
|
5688
5944
|
onPartialFailure(`${droppedFiles.length} file(s) failed to lint and were skipped (${previewFiles}${remainderHint})${reasonHint}`);
|
|
5689
5945
|
}
|
|
@@ -5803,7 +6059,8 @@ const runOxlint = async (options) => {
|
|
|
5803
6059
|
rootDirectory,
|
|
5804
6060
|
nodeBinaryPath,
|
|
5805
6061
|
project,
|
|
5806
|
-
onPartialFailure
|
|
6062
|
+
onPartialFailure,
|
|
6063
|
+
onFileProgress: options.onFileProgress
|
|
5807
6064
|
});
|
|
5808
6065
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
5809
6066
|
try {
|
|
@@ -5884,7 +6141,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
5884
6141
|
configSourceDirectory: input.configSourceDirectory,
|
|
5885
6142
|
onPartialFailure: (reason) => {
|
|
5886
6143
|
collectedFailures.push(reason);
|
|
5887
|
-
}
|
|
6144
|
+
},
|
|
6145
|
+
onFileProgress: input.onFileProgress
|
|
5888
6146
|
}),
|
|
5889
6147
|
catch: ensureReactDoctorError
|
|
5890
6148
|
});
|
|
@@ -5911,6 +6169,48 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
5911
6169
|
return stream;
|
|
5912
6170
|
} }));
|
|
5913
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
|
+
};
|
|
5914
6214
|
const translateProjectInfoError = (cause, directory) => {
|
|
5915
6215
|
if (cause instanceof NoReactDependencyError) return new ReactDoctorError({ reason: new NoReactDependency({ directory: cause.directory }) });
|
|
5916
6216
|
if (cause instanceof ProjectNotFoundError) return new ReactDoctorError({ reason: new ProjectNotFound({ directory: cause.directory }) });
|
|
@@ -6098,26 +6398,33 @@ const fileReader = (filesService, rootDirectory) => (filePath) => {
|
|
|
6098
6398
|
}));
|
|
6099
6399
|
return lines === null ? null : [...lines];
|
|
6100
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
|
+
};
|
|
6101
6408
|
/**
|
|
6102
6409
|
* The full inspect orchestration as a single composable Effect.
|
|
6103
6410
|
*
|
|
6104
|
-
*
|
|
6411
|
+
* Phases:
|
|
6105
6412
|
*
|
|
6106
|
-
* Config.resolve(directory)
|
|
6107
|
-
*
|
|
6108
|
-
*
|
|
6109
|
-
*
|
|
6110
|
-
*
|
|
6111
|
-
*
|
|
6112
|
-
*
|
|
6113
|
-
*
|
|
6114
|
-
*
|
|
6115
|
-
*
|
|
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
|
|
6116
6424
|
*
|
|
6117
|
-
*
|
|
6118
|
-
*
|
|
6119
|
-
*
|
|
6120
|
-
* 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.
|
|
6121
6428
|
*/
|
|
6122
6429
|
const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
6123
6430
|
const projectService = yield* Project;
|
|
@@ -6128,6 +6435,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
6128
6435
|
const scoreService = yield* Score;
|
|
6129
6436
|
const deadCodeService = yield* DeadCode;
|
|
6130
6437
|
const gitService = yield* Git;
|
|
6438
|
+
const progressService = yield* Progress;
|
|
6131
6439
|
const partialFailuresRef = yield* LintPartialFailures;
|
|
6132
6440
|
const resolvedConfig = yield* configService.resolve(input.directory);
|
|
6133
6441
|
const scanDirectory = resolvedConfig.resolvedDirectory;
|
|
@@ -6139,25 +6447,24 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
6139
6447
|
gitService.defaultBranch(scanDirectory).pipe(Effect.orElseSucceed(() => null))
|
|
6140
6448
|
], { concurrency: 3 });
|
|
6141
6449
|
const githubActionsScoreMetadata = input.isCi ? resolveGithubActionsScoreMetadata() : {};
|
|
6142
|
-
const
|
|
6450
|
+
const githubViewerPermissionFiber = yield* Effect.forkChild(input.resolveLocalGithubViewerPermission === true && !input.isCi && repo !== null ? gitService.githubViewerPermission({
|
|
6143
6451
|
directory: scanDirectory,
|
|
6144
6452
|
repo
|
|
6145
|
-
}).pipe(Effect.orElseSucceed(() => null)) : null;
|
|
6146
|
-
const scoreMetadata = {
|
|
6147
|
-
...repo !== null ? { repo } : {},
|
|
6148
|
-
...sha !== null ? { sha } : {},
|
|
6149
|
-
framework: project.framework,
|
|
6150
|
-
...project.reactVersion !== null ? { reactVersion: project.reactVersion } : {},
|
|
6151
|
-
sourceFileCount: project.sourceFileCount,
|
|
6152
|
-
...defaultBranch !== null ? { defaultBranch } : {},
|
|
6153
|
-
...input.doctorVersion !== void 0 ? { doctorVersion: input.doctorVersion } : {},
|
|
6154
|
-
...githubActionsScoreMetadata,
|
|
6155
|
-
...githubViewerPermission !== null ? { githubViewerPermission } : {}
|
|
6156
|
-
};
|
|
6453
|
+
}).pipe(Effect.orElseSucceed(() => null)) : Effect.succeed(null));
|
|
6157
6454
|
const lintIncludePaths = computeJsxIncludePaths([...input.includePaths]) ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config);
|
|
6158
6455
|
const beforeLint = hooks.beforeLint ?? NO_HOOKS.beforeLint;
|
|
6159
6456
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
6160
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)));
|
|
6161
6468
|
const lintFailure = yield* Ref.make({
|
|
6162
6469
|
didFail: false,
|
|
6163
6470
|
reason: null,
|
|
@@ -6167,15 +6474,20 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
6167
6474
|
didFail: false,
|
|
6168
6475
|
reason: null
|
|
6169
6476
|
});
|
|
6170
|
-
const
|
|
6171
|
-
const
|
|
6477
|
+
const shouldRunDeadCode = input.runDeadCode && !isDiffMode;
|
|
6478
|
+
const deadCodeFiber = yield* Effect.forkChild(shouldRunDeadCode ? Stream.runCollect(applyPerElementPipeline(deadCodeService.run({
|
|
6172
6479
|
rootDirectory: scanDirectory,
|
|
6173
|
-
userConfig: resolvedConfig.config
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
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;
|
|
6179
6491
|
const rawLintStream = linterService.run({
|
|
6180
6492
|
rootDirectory: scanDirectory,
|
|
6181
6493
|
project,
|
|
@@ -6186,34 +6498,54 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
6186
6498
|
adoptExistingLintConfig: input.adoptExistingLintConfig,
|
|
6187
6499
|
ignoredTags: input.ignoredTags,
|
|
6188
6500
|
userConfig: resolvedConfig.config ?? void 0,
|
|
6189
|
-
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
|
+
}
|
|
6190
6506
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
6191
6507
|
yield* Ref.set(lintFailure, {
|
|
6192
6508
|
didFail: true,
|
|
6193
6509
|
reason: error.message,
|
|
6194
6510
|
reasonTag: error.reason._tag
|
|
6195
6511
|
});
|
|
6196
|
-
return
|
|
6512
|
+
return Stream.empty;
|
|
6197
6513
|
}))));
|
|
6198
|
-
const
|
|
6199
|
-
rootDirectory: scanDirectory,
|
|
6200
|
-
userConfig: resolvedConfig.config
|
|
6201
|
-
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
6202
|
-
yield* Ref.set(deadCodeFailure, {
|
|
6203
|
-
didFail: true,
|
|
6204
|
-
reason: error.message
|
|
6205
|
-
});
|
|
6206
|
-
return emptyDiagnosticStream;
|
|
6207
|
-
})))) : emptyDiagnosticStream;
|
|
6208
|
-
const transformedStream = Stream.fromIterable(environmentDiagnostics).pipe(Stream.concat(rawLintStream), Stream.concat(deadCodeStream), Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
6209
|
-
const survivingDiagnostics = yield* Stream.runCollect(transformedStream);
|
|
6210
|
-
yield* reporterService.finalize;
|
|
6514
|
+
const lintCollected = yield* Stream.runCollect(applyPerElementPipeline(rawLintStream));
|
|
6211
6515
|
const lintFailureState = yield* Ref.get(lintFailure);
|
|
6212
|
-
const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
|
|
6213
6516
|
yield* afterLint(lintFailureState.didFail);
|
|
6214
|
-
|
|
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);
|
|
6215
6547
|
const score = lintFailureState.didFail ? null : yield* scoreService.compute({
|
|
6216
|
-
diagnostics:
|
|
6548
|
+
diagnostics: scoreDiagnostics,
|
|
6217
6549
|
isCi: input.isCi,
|
|
6218
6550
|
metadata: scoreMetadata
|
|
6219
6551
|
});
|
|
@@ -6236,9 +6568,10 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
6236
6568
|
"inspect.directory": input.directory,
|
|
6237
6569
|
"inspect.includePathCount": input.includePaths.length,
|
|
6238
6570
|
"inspect.runDeadCode": input.runDeadCode,
|
|
6239
|
-
"inspect.isCi": input.isCi
|
|
6571
|
+
"inspect.isCi": input.isCi,
|
|
6572
|
+
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
6240
6573
|
} }));
|
|
6241
|
-
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);
|
|
6242
6575
|
const parseNodeVersion = (versionString) => {
|
|
6243
6576
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
6244
6577
|
return {
|
|
@@ -6349,37 +6682,6 @@ var NodeResolver = class NodeResolver extends Context.Service()("react-doctor/No
|
|
|
6349
6682
|
});
|
|
6350
6683
|
});
|
|
6351
6684
|
};
|
|
6352
|
-
var ProgressCapture = class ProgressCapture extends Context.Service()("react-doctor/ProgressCapture") {
|
|
6353
|
-
static layer = Layer.effect(ProgressCapture, Ref.make([]));
|
|
6354
|
-
};
|
|
6355
|
-
(class Progress extends Context.Service()("react-doctor/Progress") {
|
|
6356
|
-
/**
|
|
6357
|
-
* Layer that uses an injected factory. The cli package provides
|
|
6358
|
-
* its own factory backed by the existing ora-based `spinner.ts`
|
|
6359
|
-
* helper; this layer keeps the core package free of an ora dep.
|
|
6360
|
-
*/
|
|
6361
|
-
static layerOra = (factory) => Layer.succeed(Progress, Progress.of({ start: (text) => Effect.sync(() => factory(text)) }));
|
|
6362
|
-
static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
|
|
6363
|
-
succeed: () => Effect.void,
|
|
6364
|
-
fail: () => Effect.void
|
|
6365
|
-
}) }));
|
|
6366
|
-
static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
|
|
6367
|
-
yield* Ref.update(events, (existing) => [...existing, {
|
|
6368
|
-
_tag: "Started",
|
|
6369
|
-
text
|
|
6370
|
-
}]);
|
|
6371
|
-
return {
|
|
6372
|
-
succeed: (displayText) => Ref.update(events, (existing) => [...existing, {
|
|
6373
|
-
_tag: "Succeeded",
|
|
6374
|
-
text: displayText
|
|
6375
|
-
}]),
|
|
6376
|
-
fail: (displayText) => Ref.update(events, (existing) => [...existing, {
|
|
6377
|
-
_tag: "Failed",
|
|
6378
|
-
text: displayText
|
|
6379
|
-
}])
|
|
6380
|
-
};
|
|
6381
|
-
}) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
|
|
6382
|
-
});
|
|
6383
6685
|
/**
|
|
6384
6686
|
* Zip-Slip defense: `git diff --cached --name-only` is the source
|
|
6385
6687
|
* of `relativePath`, and git normalizes paths during ordinary
|
|
@@ -6591,37 +6893,6 @@ const buildJsonReport = (input) => {
|
|
|
6591
6893
|
error: null
|
|
6592
6894
|
};
|
|
6593
6895
|
};
|
|
6594
|
-
const toStringSet = (values) => {
|
|
6595
|
-
if (!values || values.length === 0) return /* @__PURE__ */ new Set();
|
|
6596
|
-
return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
|
|
6597
|
-
};
|
|
6598
|
-
const buildResolvedControls = (surface, userControls) => {
|
|
6599
|
-
const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
|
|
6600
|
-
const includeTags = toStringSet(userControls?.includeTags);
|
|
6601
|
-
for (const tag of includeTags) excludeTags.delete(tag);
|
|
6602
|
-
for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
|
|
6603
|
-
return {
|
|
6604
|
-
includeTags,
|
|
6605
|
-
excludeTags,
|
|
6606
|
-
includeCategories: toStringSet(userControls?.includeCategories),
|
|
6607
|
-
excludeCategories: toStringSet(userControls?.excludeCategories),
|
|
6608
|
-
includeRuleKeys: toStringSet(userControls?.includeRules),
|
|
6609
|
-
excludeRuleKeys: toStringSet(userControls?.excludeRules)
|
|
6610
|
-
};
|
|
6611
|
-
};
|
|
6612
|
-
const intersects = (values, candidates) => values.some((value) => candidates.has(value));
|
|
6613
|
-
const isDiagnosticOnSurface = (diagnostic, surface, config) => {
|
|
6614
|
-
const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
|
|
6615
|
-
const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
|
|
6616
|
-
if (resolved.includeRuleKeys.has(ruleKey)) return true;
|
|
6617
|
-
if (resolved.includeCategories.has(category)) return true;
|
|
6618
|
-
if (intersects(tags, resolved.includeTags)) return true;
|
|
6619
|
-
if (resolved.excludeRuleKeys.has(ruleKey)) return false;
|
|
6620
|
-
if (resolved.excludeCategories.has(category)) return false;
|
|
6621
|
-
if (intersects(tags, resolved.excludeTags)) return false;
|
|
6622
|
-
return true;
|
|
6623
|
-
};
|
|
6624
|
-
const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
|
|
6625
6896
|
/**
|
|
6626
6897
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
6627
6898
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
@@ -6773,6 +7044,6 @@ const cliLogger = {
|
|
|
6773
7044
|
}
|
|
6774
7045
|
};
|
|
6775
7046
|
//#endregion
|
|
6776
|
-
export {
|
|
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 };
|
|
6777
7048
|
|
|
6778
|
-
//# sourceMappingURL=cli-logger-
|
|
7049
|
+
//# sourceMappingURL=cli-logger-C35LXalM.js.map
|