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