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