react-doctor 0.5.8-dev.986557d → 0.5.8-dev.99f2417

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.js CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f65daf5b-9cf1-5118-9736-6ffc410231a0")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="cf52ec59-cee0-5503-b8f2-a4f98bf77ea4")}catch(e){}}();
3
3
  import { createRequire } from "node:module";
4
4
  import * as NodeChildProcess from "node:child_process";
5
5
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
@@ -9041,7 +9041,7 @@ const composePassthrough = /* @__PURE__ */ dual(2, (left, right) => (input) => {
9041
9041
  * @since 2.0.0
9042
9042
  */
9043
9043
  const Scheduler = /* @__PURE__ */ Reference("effect/Scheduler", { defaultValue: () => new MixedScheduler() });
9044
- const setImmediate = "setImmediate" in globalThis ? (f) => {
9044
+ const setImmediate$1 = "setImmediate" in globalThis ? (f) => {
9045
9045
  const timer = globalThis.setImmediate(f);
9046
9046
  return () => globalThis.clearImmediate(timer);
9047
9047
  } : (f) => {
@@ -9085,7 +9085,7 @@ var PriorityBuckets = class {
9085
9085
  var MixedScheduler = class {
9086
9086
  executionMode;
9087
9087
  setImmediate;
9088
- constructor(executionMode = "async", setImmediateFn = setImmediate) {
9088
+ constructor(executionMode = "async", setImmediateFn = setImmediate$1) {
9089
9089
  this.executionMode = executionMode;
9090
9090
  this.setImmediate = setImmediateFn;
9091
9091
  }
@@ -9110,7 +9110,7 @@ var MixedSchedulerDispatcher = class {
9110
9110
  tasks = /* @__PURE__ */ new PriorityBuckets();
9111
9111
  running = void 0;
9112
9112
  setImmediate;
9113
- constructor(setImmediateFn = setImmediate) {
9113
+ constructor(setImmediateFn = setImmediate$1) {
9114
9114
  this.setImmediate = setImmediateFn;
9115
9115
  }
9116
9116
  /**
@@ -36862,6 +36862,7 @@ const DOCS_URL = "https://react.doctor/docs";
36862
36862
  const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
36863
36863
  const FETCH_TIMEOUT_MS = 1e4;
36864
36864
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
36865
+ const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
36865
36866
  const PER_WORKER_MEM_BUDGET_BYTES = 1024 * 1024 * 1024;
36866
36867
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
36867
36868
  const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
@@ -36946,6 +36947,7 @@ const DEAD_CODE_PHASE_TIMEOUT_MS = 15e4;
36946
36947
  const LINT_PHASE_TIMEOUT_MS = 3e5;
36947
36948
  const SCAN_TOTAL_DEADLINE_MS = 9e5;
36948
36949
  const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
36950
+ const DEAD_CODE_WORKER_MEM_BUDGET_BYTES = 2 * 1024 * 1024 * 1024;
36949
36951
  const DEAD_CODE_TIMEOUT_CEILING_MS = 6e5;
36950
36952
  const DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS = 3e4;
36951
36953
  const DEAD_CODE_OVERLAP_PARSE_SHARE = .4;
@@ -37679,7 +37681,10 @@ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_N
37679
37681
  NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.set(nativeRuleKey, aliases);
37680
37682
  }
37681
37683
  const getLegacyRuleKeysForNative = (ruleKey) => NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(ruleKey) ?? [];
37682
- const canonicalizeRuleKey = (ruleKey) => LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey] ?? ruleKey;
37684
+ const canonicalizeRuleKey = (ruleKey) => {
37685
+ const nativeRuleKey = LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey];
37686
+ return typeof nativeRuleKey === "string" ? nativeRuleKey : ruleKey;
37687
+ };
37683
37688
  const isReactDoctorShortIdOf = (bareRuleKey, qualifiedRuleKey) => !bareRuleKey.includes("/") && qualifiedRuleKey === `react-doctor/${bareRuleKey}`;
37684
37689
  const isSameRuleKey = (candidateRuleKey, targetRuleKey) => {
37685
37690
  const canonicalCandidate = canonicalizeRuleKey(candidateRuleKey);
@@ -38182,6 +38187,7 @@ const collectStringSet = (values) => {
38182
38187
  if (!Array.isArray(values)) return /* @__PURE__ */ new Set();
38183
38188
  return new Set(values.filter((value) => typeof value === "string"));
38184
38189
  };
38190
+ const JSX_NO_UNDEF_IDENTIFIER_PATTERN = /^`([^`]+)`/;
38185
38191
  /**
38186
38192
  * Pre-compiles every stateful filter and returns a single
38187
38193
  * `apply(diagnostic)` closure that runs (in order):
@@ -38215,8 +38221,10 @@ const buildDiagnosticPipeline = (input) => {
38215
38221
  const compiledOverrides = compileIgnoreOverrides(userConfig);
38216
38222
  const textComponentNames = collectStringSet(userConfig?.textComponents);
38217
38223
  const rawTextWrapperComponentNames = collectStringSet(userConfig?.rawTextWrapperComponents);
38224
+ const runtimeGlobalNames = collectStringSet(userConfig?.runtimeGlobals);
38218
38225
  const hasTextComponents = textComponentNames.size > 0;
38219
38226
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
38227
+ const hasRuntimeGlobals = runtimeGlobalNames.size > 0;
38220
38228
  const fileLinesCache = /* @__PURE__ */ new Map();
38221
38229
  const fileContextCache = /* @__PURE__ */ new Map();
38222
38230
  const libraryFileCache = /* @__PURE__ */ new Map();
@@ -38268,6 +38276,12 @@ const buildDiagnosticPipeline = (input) => {
38268
38276
  if (hasRawTextWrappers && isInsideStringOnlyWrapper(lines, diagnostic.line, diagnostic.column, rawTextWrapperComponentNames)) return true;
38269
38277
  return false;
38270
38278
  };
38279
+ const isJsxNoUndefSuppressedByConfig = (diagnostic) => {
38280
+ if (!hasRuntimeGlobals) return false;
38281
+ if (diagnostic.rule !== "jsx-no-undef") return false;
38282
+ const identifierMatch = JSX_NO_UNDEF_IDENTIFIER_PATTERN.exec(diagnostic.message);
38283
+ return identifierMatch !== null && runtimeGlobalNames.has(identifierMatch[1]);
38284
+ };
38271
38285
  return { apply: (diagnostic) => {
38272
38286
  if (shouldAutoSuppress(diagnostic)) return null;
38273
38287
  let current = diagnostic;
@@ -38292,6 +38306,7 @@ const buildDiagnosticPipeline = (input) => {
38292
38306
  if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) return null;
38293
38307
  if (isDiagnosticIgnoredByOverrides(current, rootDirectory, compiledOverrides)) return null;
38294
38308
  if (isRnRawTextSuppressedByConfig(current)) return null;
38309
+ if (isJsxNoUndefSuppressedByConfig(current)) return null;
38295
38310
  }
38296
38311
  if (respectInlineDisables && current.line > 0) {
38297
38312
  const lines = getFileLines(current.filePath);
@@ -38553,7 +38568,7 @@ const resolveScanConcurrency = (requested) => {
38553
38568
  if (!Number.isFinite(requested) || requested < 1) return 1;
38554
38569
  return Math.min(Math.floor(requested), 32);
38555
38570
  };
38556
- const readSystemFacts = () => ({
38571
+ const readSystemFacts$1 = () => ({
38557
38572
  availableCores: os.availableParallelism(),
38558
38573
  totalMemoryBytes: os.totalmem(),
38559
38574
  cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
@@ -38574,7 +38589,7 @@ const readSystemFacts = () => ({
38574
38589
  * `facts` is injectable so tests exercise core-bound, memory-bound, cgroup-
38575
38590
  * limited, and ceiling cases without mocking `os` or the filesystem.
38576
38591
  */
38577
- const resolveAutoScanConcurrency = (facts = readSystemFacts()) => {
38592
+ const resolveAutoScanConcurrency = (facts = readSystemFacts$1()) => {
38578
38593
  const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
38579
38594
  const memoryBoundedWorkers = Math.floor(availableMemoryBytes / PER_WORKER_MEM_BUDGET_BYTES);
38580
38595
  return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
@@ -38765,6 +38780,13 @@ const BOOLEAN_FIELD_NAMES = [
38765
38780
  "adoptExistingLintConfig"
38766
38781
  ];
38767
38782
  const STRING_FIELD_NAMES = ["rootDir"];
38783
+ const STRING_ARRAY_FIELD_NAMES = [
38784
+ "projects",
38785
+ "textComponents",
38786
+ "rawTextWrapperComponents",
38787
+ "runtimeGlobals",
38788
+ "serverAuthFunctionNames"
38789
+ ];
38768
38790
  const SURFACE_CONTROL_FIELD_NAMES = [
38769
38791
  "includeTags",
38770
38792
  "excludeTags",
@@ -38866,6 +38888,7 @@ const validateConfigTypes = (config) => {
38866
38888
  const validated = { ...config };
38867
38889
  for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
38868
38890
  for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
38891
+ for (const fieldName of STRING_ARRAY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateStringArrayField(fieldName, value));
38869
38892
  applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
38870
38893
  for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
38871
38894
  applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
@@ -40128,7 +40151,10 @@ const shouldEnableRule = (requires, tags, capabilities, ignoredTags, disabledBy)
40128
40151
  }
40129
40152
  return true;
40130
40153
  };
40131
- const checkSecurityScan = (rootDirectory, options = {}) => {
40154
+ const yieldToEventLoop = () => new Promise((resolve) => {
40155
+ setImmediate(resolve);
40156
+ });
40157
+ const createSecurityScanSession = (rootDirectory, options) => {
40132
40158
  const capabilities = options.project ? buildCapabilities(options.project) : /* @__PURE__ */ new Set();
40133
40159
  const ignoredTags = options.ignoredTags ?? /* @__PURE__ */ new Set();
40134
40160
  const enabledScanRules = REACT_DOCTOR_RULES.flatMap((entry) => {
@@ -40143,7 +40169,7 @@ const checkSecurityScan = (rootDirectory, options = {}) => {
40143
40169
  committedFilesOnly: rule.committedFilesOnly === true
40144
40170
  }];
40145
40171
  });
40146
- if (enabledScanRules.length === 0) return [];
40172
+ if (enabledScanRules.length === 0) return null;
40147
40173
  const diagnostics = [];
40148
40174
  const seen = /* @__PURE__ */ new Set();
40149
40175
  const gitIgnoredCache = /* @__PURE__ */ new Map();
@@ -40155,15 +40181,34 @@ const checkSecurityScan = (rootDirectory, options = {}) => {
40155
40181
  }
40156
40182
  return status === true;
40157
40183
  };
40158
- for (const file of collectSecurityScanFiles(rootDirectory)) for (const { entry, scan, committedFilesOnly } of enabledScanRules) for (const finding of scan(file)) {
40159
- if (committedFilesOnly && isFileGitIgnored(file)) continue;
40160
- const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
40161
- const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
40162
- if (seen.has(key)) continue;
40163
- seen.add(key);
40164
- diagnostics.push(diagnostic);
40184
+ const scanFile = (file) => {
40185
+ for (const { entry, scan, committedFilesOnly } of enabledScanRules) for (const finding of scan(file)) {
40186
+ if (committedFilesOnly && isFileGitIgnored(file)) continue;
40187
+ const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
40188
+ const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
40189
+ if (seen.has(key)) continue;
40190
+ seen.add(key);
40191
+ diagnostics.push(diagnostic);
40192
+ }
40193
+ };
40194
+ return {
40195
+ scanFile,
40196
+ diagnostics
40197
+ };
40198
+ };
40199
+ const checkSecurityScanCooperative = async (rootDirectory, options = {}) => {
40200
+ const session = createSecurityScanSession(rootDirectory, options);
40201
+ if (session === null) return [];
40202
+ let filesSinceYield = 0;
40203
+ for (const file of collectSecurityScanFiles(rootDirectory)) {
40204
+ session.scanFile(file);
40205
+ filesSinceYield += 1;
40206
+ if (filesSinceYield >= 16) {
40207
+ filesSinceYield = 0;
40208
+ await yieldToEventLoop();
40209
+ }
40165
40210
  }
40166
- return diagnostics;
40211
+ return session.diagnostics;
40167
40212
  };
40168
40213
  var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
40169
40214
  let p = process || {}, argv = p.argv || [], env = p.env || {};
@@ -40396,6 +40441,74 @@ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
40396
40441
  return [...seen].filter((pattern) => pattern.length > 0);
40397
40442
  };
40398
40443
  const collectDeadCodeEntryPatterns = (rootDirectory) => [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0);
40444
+ const readSystemFacts = () => ({
40445
+ availableCores: os.availableParallelism(),
40446
+ totalMemoryBytes: os.totalmem(),
40447
+ cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
40448
+ });
40449
+ /**
40450
+ * How many real deslop dead-code child processes may run at once, across the
40451
+ * concurrent per-project `runInspect` fibers of one CLI run. The cap is the
40452
+ * smaller of the core count and the number of `DEAD_CODE_WORKER_MEM_BUDGET_BYTES`
40453
+ * workers that fit in available memory, floored at 1.
40454
+ *
40455
+ * On a roomy dev box / CI runner this resolves high enough that every
40456
+ * concurrently-scanned project still spawns its own worker (no serialization vs
40457
+ * the prior uncapped behavior); on a memory-constrained runner it collapses
40458
+ * toward 1, so the `withDeadCodeWorkerSlot` semaphore serializes the spawns
40459
+ * instead of oversubscribing memory with N simultaneous children — the global
40460
+ * cap the per-project spawn path lacked.
40461
+ *
40462
+ * Mirrors `resolveAutoScanConcurrency` (lint), but budgets memory per the
40463
+ * heavier dead-code worker. `facts` is injectable for tests.
40464
+ */
40465
+ const resolveDeadCodeConcurrency = (facts = readSystemFacts()) => {
40466
+ const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
40467
+ const memoryBoundedWorkers = Math.floor(availableMemoryBytes / DEAD_CODE_WORKER_MEM_BUDGET_BYTES);
40468
+ return Math.max(1, Math.min(facts.availableCores, memoryBoundedWorkers));
40469
+ };
40470
+ let availableSlots = -1;
40471
+ const waiters = [];
40472
+ const releaseSlot = () => {
40473
+ const nextWaiter = waiters.shift();
40474
+ if (nextWaiter !== void 0) nextWaiter();
40475
+ else availableSlots += 1;
40476
+ };
40477
+ /**
40478
+ * Runs `task` once a dead-code worker slot is free, releasing the slot when the
40479
+ * task settles (success or failure). With a high cap (roomy machine) every
40480
+ * caller proceeds immediately; with a low cap (constrained runner) callers
40481
+ * queue and run as slots free.
40482
+ *
40483
+ * `abortSignal` short-circuits the WAIT: if it's already aborted, or fires while
40484
+ * this caller is queued, the call rejects without acquiring a slot or running
40485
+ * `task` — so a cancelled scan (e.g. lint failed) doesn't sit in the queue and
40486
+ * then spawn a child only to tear it down. A queued caller that aborts removes
40487
+ * its own waiter so a later release never hands a slot to a dead request.
40488
+ */
40489
+ const withDeadCodeWorkerSlot = async (task, abortSignal) => {
40490
+ if (abortSignal?.aborted) throw new Error("Dead-code worker aborted.");
40491
+ if (availableSlots < 0) availableSlots = resolveDeadCodeConcurrency();
40492
+ if (availableSlots > 0) availableSlots -= 1;
40493
+ else await new Promise((resolve, reject) => {
40494
+ const waiter = () => {
40495
+ abortSignal?.removeEventListener("abort", onAbort);
40496
+ resolve();
40497
+ };
40498
+ const onAbort = () => {
40499
+ const queuedIndex = waiters.indexOf(waiter);
40500
+ if (queuedIndex !== -1) waiters.splice(queuedIndex, 1);
40501
+ reject(/* @__PURE__ */ new Error("Dead-code worker aborted."));
40502
+ };
40503
+ waiters.push(waiter);
40504
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
40505
+ });
40506
+ try {
40507
+ return await task();
40508
+ } finally {
40509
+ releaseSlot();
40510
+ }
40511
+ };
40399
40512
  /**
40400
40513
  * Resolves a path to its canonical, symlink-free form, falling back to
40401
40514
  * the input when it cannot be realpath'd (broken symlink, permission
@@ -40420,6 +40533,11 @@ const toCanonicalPath = (filePath) => {
40420
40533
  };
40421
40534
  const DEAD_CODE_PLUGIN = "deslop";
40422
40535
  const DEAD_CODE_CATEGORY = "Maintainability";
40536
+ const REACT_DOCTOR_TOOLCHAIN_PACKAGES = new Set([
40537
+ "react-doctor",
40538
+ "eslint-plugin-react-doctor",
40539
+ "oxlint-plugin-react-doctor"
40540
+ ]);
40423
40541
  const DEAD_CODE_WORKER_SCRIPT = `
40424
40542
  const inputChunks = [];
40425
40543
  process.stdin.on("data", (chunk) => inputChunks.push(chunk));
@@ -40686,14 +40804,17 @@ const checkDeadCode = async (options) => {
40686
40804
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
40687
40805
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
40688
40806
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
40689
- const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
40690
- rootDirectory,
40691
- entryPatterns,
40692
- tsConfigPath: resolveTsConfigPath(rootDirectory),
40693
- ignorePatterns,
40694
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
40695
- parseConcurrency: options.parseConcurrency
40696
- }), options.workerTimeoutMs ?? 12e4, options.abortSignal));
40807
+ const spawnAndRun = () => {
40808
+ return runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
40809
+ rootDirectory,
40810
+ entryPatterns,
40811
+ tsConfigPath: resolveTsConfigPath(rootDirectory),
40812
+ ignorePatterns,
40813
+ deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
40814
+ parseConcurrency: options.parseConcurrency
40815
+ }), options.workerTimeoutMs ?? 12e4, options.abortSignal);
40816
+ };
40817
+ const result = parseDeadCodeWorkerResult(options.createWorker === void 0 ? await withDeadCodeWorkerSlot(spawnAndRun, options.abortSignal) : await spawnAndRun());
40697
40818
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
40698
40819
  const diagnostics = [];
40699
40820
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -40722,6 +40843,7 @@ const checkDeadCode = async (options) => {
40722
40843
  });
40723
40844
  }
40724
40845
  for (const unusedDependency of result.unusedDependencies) {
40846
+ if (REACT_DOCTOR_TOOLCHAIN_PACKAGES.has(unusedDependency.name)) continue;
40725
40847
  const label = unusedDependency.isDevDependency ? "devDependency" : "dependency";
40726
40848
  diagnostics.push({
40727
40849
  filePath: "package.json",
@@ -41094,43 +41216,46 @@ var Git = class Git extends Service()("react-doctor/Git") {
41094
41216
  * reason: GitInvocationFailed })` so the rest of the codebase
41095
41217
  * sees a single failure channel.
41096
41218
  */
41097
- const runCommand = (input) => scoped(gen(function* () {
41098
- const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
41099
- cwd: input.directory,
41100
- env: input.env,
41101
- extendEnv: true
41102
- }));
41103
- const maxStdoutBytes = input.maxStdoutBytes;
41104
- const stdoutByteCount = yield* make$13(0);
41105
- const [stdout, stderr, status] = yield* all([
41106
- mkString(decodeText(maxStdoutBytes === void 0 ? handle.stdout : handle.stdout.pipe(tap((chunk) => updateAndGet(stdoutByteCount, (total) => total + chunk.length).pipe(flatMap$2((total) => total > maxStdoutBytes ? fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
41107
- args: [...input.args],
41108
- directory: input.directory,
41109
- cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
41110
- }) })) : void_)))))),
41111
- mkString(decodeText(handle.stderr)),
41112
- handle.exitCode
41113
- ], { concurrency: 3 });
41114
- return {
41115
- status,
41116
- stdout,
41117
- stderr
41118
- };
41119
- })).pipe(catchTag$1("PlatformError", (cause) => {
41120
- if (input.command !== "git") return succeed$2({
41219
+ const runCommand = (input) => {
41220
+ const foldSpawnFailure = (cause) => input.command !== "git" ? succeed$2({
41121
41221
  status: 127,
41122
41222
  stdout: "",
41123
41223
  stderr: String(cause)
41124
- });
41125
- return new ReactDoctorError({ reason: new GitInvocationFailed({
41224
+ }) : fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
41126
41225
  args: [...input.args],
41127
41226
  directory: input.directory,
41128
41227
  cause
41129
- }) });
41130
- }), withSpan("git.exec", { attributes: {
41131
- "git.command": input.command,
41132
- "git.subcommand": input.args[0] ?? ""
41133
- } }));
41228
+ }) }));
41229
+ return scoped(gen(function* () {
41230
+ if (!isDirectory(input.directory)) return yield* foldSpawnFailure(`spawn ENOTDIR (cwd is not a directory: ${input.directory})`);
41231
+ const argvLengthChars = input.command.length + 1 + input.args.reduce((total, arg) => total + arg.length + 1, 0);
41232
+ if (argvLengthChars > 24e3) return yield* foldSpawnFailure(`spawn ENAMETOOLONG (${argvLengthChars} argv chars exceed ${SPAWN_ARGS_MAX_LENGTH_CHARS})`);
41233
+ const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
41234
+ cwd: input.directory,
41235
+ env: input.env,
41236
+ extendEnv: true
41237
+ }));
41238
+ const maxStdoutBytes = input.maxStdoutBytes;
41239
+ const stdoutByteCount = yield* make$13(0);
41240
+ const [stdout, stderr, status] = yield* all([
41241
+ mkString(decodeText(maxStdoutBytes === void 0 ? handle.stdout : handle.stdout.pipe(tap((chunk) => updateAndGet(stdoutByteCount, (total) => total + chunk.length).pipe(flatMap$2((total) => total > maxStdoutBytes ? fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
41242
+ args: [...input.args],
41243
+ directory: input.directory,
41244
+ cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
41245
+ }) })) : void_)))))),
41246
+ mkString(decodeText(handle.stderr)),
41247
+ handle.exitCode
41248
+ ], { concurrency: 3 });
41249
+ return {
41250
+ status,
41251
+ stdout,
41252
+ stderr
41253
+ };
41254
+ })).pipe(catchTag$1("PlatformError", foldSpawnFailure), withSpan("git.exec", { attributes: {
41255
+ "git.command": input.command,
41256
+ "git.subcommand": input.args[0] ?? ""
41257
+ } }));
41258
+ };
41134
41259
  const runGit = (directory, args) => runCommand({
41135
41260
  command: "git",
41136
41261
  args,
@@ -41163,7 +41288,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
41163
41288
  "rev-parse",
41164
41289
  "--verify",
41165
41290
  branch
41166
- ]).pipe(map$3((result) => result.status === 0));
41291
+ ]).pipe(map$3((result) => result.status === 0), catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(false) : fail$4(error)));
41167
41292
  const headSha = (directory) => runGit(directory, ["rev-parse", "HEAD"]).pipe(map$3((result) => result.status === 0 ? trimOrNull(result.stdout) : null));
41168
41293
  const mergeBase = (input) => isSafeGitRevision(input.ref) ? runGit(input.directory, [
41169
41294
  "merge-base",
@@ -41377,7 +41502,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
41377
41502
  ]);
41378
41503
  if (result.status !== 0) return null;
41379
41504
  return parseChangedLineRanges(result.stdout);
41380
- }).pipe(withSpan("Git.changedLineRanges"))
41505
+ }).pipe(catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(null) : fail$4(error)), withSpan("Git.changedLineRanges"))
41381
41506
  });
41382
41507
  })).pipe(provide$2(layer$3.pipe(provide$2(mergeAll$1(layer$2, layer$1)))));
41383
41508
  /**
@@ -43854,7 +43979,10 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
43854
43979
  * diagnostics).
43855
43980
  * 2. beforeLint hook (e.g. CLI renders the project-detection block)
43856
43981
  * 3. environment checks (reduced-motion + pnpm hardening +
43857
- * expo/react-native + security scan), collected synchronously
43982
+ * expo/react-native), collected synchronously. The heavier
43983
+ * content-regex security scan is forked instead (like supply-chain
43984
+ * below) and joined before the concat, so its CPU overlaps lint
43985
+ * rather than blocking the event loop before it.
43858
43986
  * 4. The supply-chain check (Socket.dev) is forked onto a background
43859
43987
  * fiber so its ~100% network-bound time overlaps the ~100%
43860
43988
  * CPU/subprocess-bound lint pass below, collapsing two serial
@@ -43874,7 +44002,7 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
43874
44002
  * order, so terminal output is identical either way; supply-chain
43875
44003
  * rides alongside without a spinner.
43876
44004
  * 6. Join the supply-chain fiber, then assemble the diagnostics in a
43877
- * FIXED order (env, supply-chain, lint, dead-code) so the output is
44005
+ * FIXED order (env, security-scan, supply-chain, lint, dead-code) so the output is
43878
44006
  * byte-identical regardless of which fiber settled first. The
43879
44007
  * viewer-permission fiber is joined later, during score-metadata
43880
44008
  * assembly (it feeds score metadata, not diagnostics). The per-element
@@ -43935,12 +44063,12 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43935
44063
  ...checkPnpmHardening(scanDirectory),
43936
44064
  ...checkReactServerComponentsAdvisory(scanDirectory, project),
43937
44065
  ...checkExpoProject(scanDirectory, project),
43938
- ...checkReactNativeProject(scanDirectory, project),
43939
- ...checkSecurityScan(scanDirectory, {
43940
- project,
43941
- ignoredTags: input.ignoredTags
43942
- })
44066
+ ...checkReactNativeProject(scanDirectory, project)
43943
44067
  ])));
44068
+ const securityScanFiber = yield* forkChild(runCollect(applyPerElementPipeline(isDiffMode ? empty$4 : unwrap(promise(() => checkSecurityScanCooperative(scanDirectory, {
44069
+ project,
44070
+ ignoredTags: input.ignoredTags
44071
+ })).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)))))).pipe(withSpan("SecurityScan.run")));
43944
44072
  const shouldRunSupplyChain = !isDiffMode || (input.supplyChainManifestChanged ?? false);
43945
44073
  const supplyChainOverlapTimeout = yield* SupplyChainOverlapTimeoutMs;
43946
44074
  const supplyChainFiber = yield* forkChild(shouldRunSupplyChain ? runCollect(applyPerElementPipeline(supplyChainService.run({
@@ -44076,9 +44204,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
44076
44204
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
44077
44205
  const supplyChainResult = yield* join(supplyChainFiber);
44078
44206
  const supplyChainCollected = supplyChainResult.diagnostics;
44207
+ const securityScanCollected = yield* join(securityScanFiber);
44079
44208
  yield* reporterService.finalize;
44080
44209
  const finalDiagnostics = sortDiagnosticsStable(assignFixGroups([
44081
44210
  ...envCollected,
44211
+ ...securityScanCollected,
44082
44212
  ...supplyChainCollected,
44083
44213
  ...lintCollected,
44084
44214
  ...deadCodeCollected
@@ -44617,6 +44747,61 @@ const computeConfigFingerprint = (projectDirectory, version) => {
44617
44747
  }
44618
44748
  return crypto.createHash("sha1").update(parts.join("|")).digest("hex");
44619
44749
  };
44750
+ const AI_TRAINING_ENV_VARS = [
44751
+ ["HF_DATASETS_CACHE", "huggingface"],
44752
+ ["HF_HOME", "huggingface"],
44753
+ ["HUGGINGFACE_HUB_CACHE", "huggingface"],
44754
+ ["SPACE_ID", "huggingface-spaces"],
44755
+ ["CUDA_VISIBLE_DEVICES", "cuda"],
44756
+ ["NVIDIA_VISIBLE_DEVICES", "nvidia"],
44757
+ ["ROCR_VISIBLE_DEVICES", "rocm"],
44758
+ ["TPU_NAME", "google-tpu"],
44759
+ ["WANDB_RUN_ID", "wandb"],
44760
+ ["WANDB_SWEEP_ID", "wandb"],
44761
+ ["MLFLOW_RUN_ID", "mlflow"],
44762
+ ["MLFLOW_TRACKING_URI", "mlflow"],
44763
+ ["COMET_EXPERIMENT_KEY", "comet"],
44764
+ ["NEPTUNE_RUN_ID", "neptune"],
44765
+ ["CLEARML_TASK_ID", "clearml"],
44766
+ ["DVC_STAGE", "dvc"],
44767
+ ["RAY_WORKER_PROCESS", "ray"],
44768
+ ["RAY_ADDRESS", "ray"],
44769
+ ["MUJOCO_GL", "mujoco"],
44770
+ ["MUJOCO_PATH", "mujoco"],
44771
+ ["GYM_DISABLE_ENV_CHECKER", "gymnasium"],
44772
+ ["SM_TRAINING_ENV", "sagemaker"],
44773
+ ["TRAINING_JOB_ARN", "sagemaker"],
44774
+ ["SAGEMAKER_BASE_DIR", "sagemaker"],
44775
+ ["AZUREML_RUN_ID", "azure-ml"],
44776
+ ["AZURE_ML_MODEL_DIR", "azure-ml"],
44777
+ ["CLOUD_ML_PROJECT_ID", "vertex-ai"],
44778
+ ["VERTEX_AI_LOG_LEVEL", "vertex-ai"],
44779
+ ["DET_MASTER", "determined-ai"],
44780
+ ["LIGHTNING_USER_ID", "lightning-ai"],
44781
+ ["FLYTE_INTERNAL_EXECUTION_ID", "flyte"],
44782
+ ["ARGO_WORKFLOW_NAME", "argo-workflows"],
44783
+ ["KFP_POD_NAME", "kubeflow-pipelines"],
44784
+ ["KAGGLE_KERNEL_RUN_TYPE", "kaggle"],
44785
+ ["COLAB_BACKEND_VERSION", "google-colab"],
44786
+ ["DATABRICKS_RUNTIME_VERSION", "databricks"],
44787
+ ["DAYTONA_WS_ID", "daytona"],
44788
+ ["DAYTONA_WS_NAME", "daytona"],
44789
+ ["E2B_SANDBOX_ID", "e2b"],
44790
+ ["MODAL_FUNCTION_ID", "modal"],
44791
+ ["MODAL_TASK_ID", "modal"],
44792
+ ["RUNPOD_POD_ID", "runpod"],
44793
+ ["REPLICATE_USERNAME", "replicate"],
44794
+ ["VAST_CONTAINERLABEL", "vast-ai"],
44795
+ ["HARBOR_URL", "harbor"],
44796
+ ["HARBOR_HOSTNAME", "harbor"],
44797
+ ["SWE_BENCH_TASK", "swe-bench"],
44798
+ ["SWEBENCH_TASK", "swe-bench"],
44799
+ ["SWE_AGENT_MODEL", "swe-agent"]
44800
+ ];
44801
+ const detectAiTrainingEnvironment = () => {
44802
+ for (const [envVar, label] of AI_TRAINING_ENV_VARS) if (process.env[envVar]) return label;
44803
+ return null;
44804
+ };
44620
44805
  const groupBy = (items, keyFn) => {
44621
44806
  const groups = /* @__PURE__ */ new Map();
44622
44807
  for (const item of items) {
@@ -44702,7 +44887,25 @@ const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
44702
44887
  ["TEAMCITY_VERSION", "teamcity"],
44703
44888
  ["BITBUCKET_BUILD_NUMBER", "bitbucket"],
44704
44889
  ["TRAVIS", "travis"],
44705
- ["DRONE", "drone"]
44890
+ ["DRONE", "drone"],
44891
+ ["SEMAPHORE", "semaphore"],
44892
+ ["APPVEYOR", "appveyor"],
44893
+ ["HARNESS_BUILD_ID", "harness"],
44894
+ ["BUDDY", "buddy"],
44895
+ ["CF_BUILD_ID", "codefresh"],
44896
+ ["NETLIFY", "netlify"],
44897
+ ["RAILWAY_SERVICE_ID", "railway"],
44898
+ ["VERCEL", "vercel"],
44899
+ ["CM_BUILD_ID", "codemagic"],
44900
+ ["PROW_JOB_ID", "prow"],
44901
+ ["AGOLA_GIT_REF", "agola"],
44902
+ ["CIRRUS_CI", "cirrus-ci"],
44903
+ ["BLACKSMITH_STICKYDISK_TOKEN", "blacksmith"],
44904
+ ["WARPBUILD_RUNNER_VERIFICATION_TOKEN", "warpbuild"],
44905
+ ["NSC_CACHE_PATH", "namespace"],
44906
+ ["UBICLOUD_CACHE_URL", "ubicloud"],
44907
+ ["RENDER", "render"],
44908
+ ["FLY_APP_NAME", "fly-io"]
44706
44909
  ];
44707
44910
  const GITHUB_ACTION_MARKER_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_GITHUB_ACTION";
44708
44911
  const ACTION_INPUT_ENVIRONMENT_VARIABLES = {
@@ -44720,7 +44923,10 @@ const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
44720
44923
  ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
44721
44924
  ["OPENCODE", "opencode"],
44722
44925
  ["GOOSE_TERMINAL", "goose"],
44723
- ["AMP_THREAD_ID", "amp"]
44926
+ ["AMP_THREAD_ID", "amp"],
44927
+ ["CLINE_ACTIVE", "cline"],
44928
+ ["AUGMENT_AGENT", "augment"],
44929
+ ["TRAE_AI_SHELL_ID", "trae-ai"]
44724
44930
  ];
44725
44931
  const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
44726
44932
  const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
@@ -44839,6 +45045,7 @@ const NANOSECONDS_PER_SECOND = 1000000000n;
44839
45045
  const METRIC = {
44840
45046
  cliInvoked: "cli.invoked",
44841
45047
  cliError: "cli.error",
45048
+ cliEnvironmentError: "cli.env_error",
44842
45049
  projectDetected: "project.detected",
44843
45050
  projectPathSelected: "project.path_selected",
44844
45051
  projectConfigSelected: "project.config_selected",
@@ -44868,7 +45075,9 @@ const METRIC = {
44868
45075
  lspSessionStarted: "lsp.session.started",
44869
45076
  lspScanCompleted: "lsp.scan.completed",
44870
45077
  lspScanDuration: "lsp.scan.duration",
44871
- lspScanDiagnostics: "lsp.scan.diagnostics"
45078
+ lspScanDiagnostics: "lsp.scan.diagnostics",
45079
+ aiTrainingWarningShown: "ai.training.warning_shown",
45080
+ jsonOutUsed: "json.out_used"
44872
45081
  };
44873
45082
  //#endregion
44874
45083
  //#region src/cli/utils/noop-console.ts
@@ -44911,7 +45120,7 @@ const makeNoopConsole = () => ({
44911
45120
  });
44912
45121
  //#endregion
44913
45122
  //#region src/cli/utils/version.ts
44914
- const VERSION = "0.5.8-dev.986557d";
45123
+ const VERSION = "0.5.8-dev.99f2417";
44915
45124
  //#endregion
44916
45125
  //#region src/cli/utils/json-mode.ts
44917
45126
  let context = null;
@@ -44949,12 +45158,13 @@ const installSilentConsole = () => {
44949
45158
  "trace"
44950
45159
  ]) target[key] = source[key];
44951
45160
  };
44952
- const enableJsonMode = ({ compact, directory }) => {
45161
+ const enableJsonMode = ({ compact, directory, outputFile }) => {
44953
45162
  context = {
44954
45163
  compact,
44955
45164
  directory,
44956
45165
  startTime: performance$1.now(),
44957
- mode: "full"
45166
+ mode: "full",
45167
+ outputFile: outputFile ?? null
44958
45168
  };
44959
45169
  installSilentConsole();
44960
45170
  };
@@ -44967,7 +45177,11 @@ const setJsonReportMode = (mode) => {
44967
45177
  };
44968
45178
  const writeJsonReport = (report) => {
44969
45179
  const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
44970
- process.stdout.write(`${serialized}\n`);
45180
+ if (context?.outputFile) {
45181
+ const resolvedPath = Path.resolve(context.outputFile);
45182
+ NFS.mkdirSync(Path.dirname(resolvedPath), { recursive: true });
45183
+ NFS.writeFileSync(resolvedPath, `${serialized}\n`);
45184
+ } else process.stdout.write(`${serialized}\n`);
44971
45185
  };
44972
45186
  const writeJsonErrorReport = (error, sentryEventId) => {
44973
45187
  if (!context) return;
@@ -45275,13 +45489,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
45275
45489
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
45276
45490
  * standard `SENTRY_RELEASE` override.
45277
45491
  */
45278
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.8-dev.986557d`;
45492
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.8-dev.99f2417`;
45279
45493
  /**
45280
45494
  * Deployment environment shown in Sentry's environment filter. Defaults to
45281
45495
  * `production` for tagged releases and `development` for dev/unbuilt versions,
45282
45496
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
45283
45497
  */
45284
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.8-dev.986557d") ? "development" : "production");
45498
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.8-dev.99f2417") ? "development" : "production");
45285
45499
  /**
45286
45500
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
45287
45501
  * (set to `0` to disable tracing) and falls back to
@@ -48877,6 +49091,25 @@ const shouldBlockCi = (diagnostics, blockingLevel) => {
48877
49091
  */
48878
49092
  const toCategoryKey = (category) => category.toLowerCase().replace(/[^a-z0-9]+/g, "_");
48879
49093
  //#endregion
49094
+ //#region src/cli/utils/with-namespace.ts
49095
+ /**
49096
+ * Prefixes every key in a flat attribute group with `namespace.`, so a logical
49097
+ * group (scan config, diagnostics rollup, score, …) carries one dotted
49098
+ * namespace applied in a single place rather than hand-spelled into each key —
49099
+ * which is how the wide event drifted into a flat, half-namespaced soup. The
49100
+ * dotted prefix is what makes the attributes tree up in Sentry's attribute
49101
+ * browser and stay group-/filter-/aggregate-able in the Spans dataset.
49102
+ *
49103
+ * Value types are preserved verbatim (numbers stay numbers so Sentry infers a
49104
+ * numeric attribute and `p75(...)`/`avg(...)` work; `null` is kept so
49105
+ * `toSpanAttributes` can drop absent signals rather than coerce them).
49106
+ */
49107
+ const withNamespace = (namespace, attributes) => {
49108
+ const namespaced = {};
49109
+ for (const [key, value] of Object.entries(attributes)) namespaced[`${namespace}.${key}`] = value;
49110
+ return namespaced;
49111
+ };
49112
+ //#endregion
48880
49113
  //#region src/cli/utils/build-run-event.ts
48881
49114
  const readEnvBoolean = (name) => {
48882
49115
  const value = process.env[name];
@@ -48898,12 +49131,12 @@ const buildOutcomeAttributes = (input) => {
48898
49131
  if (input.result === void 0) {
48899
49132
  const error = input.error;
48900
49133
  const known = isReactDoctorError(error);
48901
- return {
48902
- outcome: "error",
49134
+ return withNamespace("outcome", {
49135
+ status: "error",
48903
49136
  exitCode: 1,
48904
49137
  knownError: known,
48905
49138
  errorTag: known ? error.reason._tag : error instanceof Error ? error.name : null
48906
- };
49139
+ });
48907
49140
  }
48908
49141
  const result = input.result;
48909
49142
  const summary = summarizeDiagnostics(result.diagnostics);
@@ -48937,66 +49170,82 @@ const buildOutcomeAttributes = (input) => {
48937
49170
  }
48938
49171
  let fixGroupedFindings = 0;
48939
49172
  for (const count of findingsPerFixGroup.values()) fixGroupedFindings += count;
49173
+ const categoryRollup = {};
49174
+ for (const [category, count] of countByCategory) categoryRollup[`category.${toCategoryKey(category)}`] = count;
48940
49175
  const attributes = {
48941
- outcome,
48942
- exitCode: wouldBlock ? 1 : 0,
48943
- wouldBlock,
48944
- blocking: blockingLevel,
48945
- scanClean: isClean,
48946
- totalDiagnostics: summary.totalDiagnosticCount,
48947
- errorCount: summary.errorCount,
48948
- warningCount: summary.warningCount,
48949
- affectedFiles: summary.affectedFileCount,
48950
- diagnosticsInTestFiles,
48951
- diagnosticsInStoryFiles,
48952
- distinctRulesFired: countByRule.size,
48953
- "diag.fixGroups": findingsPerFixGroup.size,
48954
- "diag.fixGroupedFindings": fixGroupedFindings,
48955
- topRule,
48956
- "migration.largestRuleBucketFiles": largestRuleBucket ? largestRuleBucket.fileCount : null,
48957
- "migration.largestRuleBucketSites": largestRuleBucket ? largestRuleBucket.siteCount : null,
48958
- "migration.largestRuleBucketRule": largestRuleBucket ? largestRuleBucket.ruleKey : null,
48959
- scannedFileCount: result.scannedFileCount ?? null,
48960
- lintCacheHitFiles: result.lintCacheHitFileCount ?? null,
48961
- lintCacheTotalFiles: result.lintCacheTotalFileCount ?? null,
48962
- lintCacheHitRatio: result.lintCacheTotalFileCount != null && result.lintCacheTotalFileCount > 0 ? (result.lintCacheHitFileCount ?? 0) / result.lintCacheTotalFileCount : null,
48963
- elapsedMs: result.elapsedMilliseconds,
48964
- scanPhaseMs: result.scanElapsedMilliseconds ?? null,
48965
- score: result.score ? result.score.score : null,
48966
- scoreLabel: result.score ? result.score.label : null,
48967
- scoreAvailable: result.score !== null,
48968
- skippedCheckCount: result.skippedChecks.length,
48969
- didLintFail: input.didLintFail ?? null,
48970
- lintFailureReasonKind: input.lintFailureReasonKind ?? null,
48971
- lintPartialFailureCount: input.lintPartialFailureCount ?? null,
48972
- lintDroppedFileCount: input.lintDroppedFileCount ?? null,
48973
- didDeadCodeFail: input.didDeadCodeFail ?? null,
48974
- supplyChainOverlapTimedOut: input.supplyChainOverlapTimedOut ?? null,
48975
- deadCodeOverlapped: input.deadCodeOverlapped ?? null
48976
- };
48977
- for (const [category, count] of countByCategory) attributes[`diag.category.${toCategoryKey(category)}`] = count;
48978
- if (result.baselineDelta) {
48979
- attributes["baseline.new"] = summary.totalDiagnosticCount;
48980
- attributes["baseline.fixed"] = result.baselineDelta.fixedCount;
48981
- attributes["baseline.baseTotal"] = result.baselineDelta.baseTotalCount;
48982
- attributes["baseline.degraded"] = false;
48983
- } else if (input.gateExempt) attributes["baseline.degraded"] = true;
49176
+ ...withNamespace("outcome", {
49177
+ status: outcome,
49178
+ exitCode: wouldBlock ? 1 : 0,
49179
+ wouldBlock,
49180
+ blocking: blockingLevel,
49181
+ clean: isClean,
49182
+ skippedChecks: result.skippedChecks.length
49183
+ }),
49184
+ ...withNamespace("diag", {
49185
+ total: summary.totalDiagnosticCount,
49186
+ errors: summary.errorCount,
49187
+ warnings: summary.warningCount,
49188
+ affectedFiles: summary.affectedFileCount,
49189
+ inTestFiles: diagnosticsInTestFiles,
49190
+ inStoryFiles: diagnosticsInStoryFiles,
49191
+ distinctRules: countByRule.size,
49192
+ topRule,
49193
+ fixGroups: findingsPerFixGroup.size,
49194
+ fixGroupedFindings,
49195
+ ...categoryRollup
49196
+ }),
49197
+ ...withNamespace("score", {
49198
+ value: result.score ? result.score.score : null,
49199
+ label: result.score ? result.score.label : null,
49200
+ available: result.score !== null
49201
+ }),
49202
+ ...withNamespace("lint", {
49203
+ failed: input.didLintFail ?? null,
49204
+ failureReasonKind: input.lintFailureReasonKind ?? null,
49205
+ partialFailureCount: input.lintPartialFailureCount ?? null,
49206
+ droppedFileCount: input.lintDroppedFileCount ?? null,
49207
+ cacheHitFiles: result.lintCacheHitFileCount ?? null,
49208
+ cacheTotalFiles: result.lintCacheTotalFileCount ?? null,
49209
+ cacheHitRatio: result.lintCacheTotalFileCount != null && result.lintCacheTotalFileCount > 0 ? (result.lintCacheHitFileCount ?? 0) / result.lintCacheTotalFileCount : null
49210
+ }),
49211
+ ...withNamespace("deadCode", {
49212
+ failed: input.didDeadCodeFail ?? null,
49213
+ overlapped: input.deadCodeOverlapped ?? null
49214
+ }),
49215
+ ...withNamespace("supplyChain", { overlapTimedOut: input.supplyChainOverlapTimedOut ?? null }),
49216
+ ...withNamespace("timing", {
49217
+ elapsedMs: result.elapsedMilliseconds,
49218
+ scanMs: result.scanElapsedMilliseconds ?? null
49219
+ }),
49220
+ ...withNamespace("migration", {
49221
+ largestRuleBucketFiles: largestRuleBucket ? largestRuleBucket.fileCount : null,
49222
+ largestRuleBucketSites: largestRuleBucket ? largestRuleBucket.siteCount : null,
49223
+ largestRuleBucketRule: largestRuleBucket ? largestRuleBucket.ruleKey : null
49224
+ })
49225
+ };
49226
+ if (result.baselineDelta) Object.assign(attributes, withNamespace("baseline", {
49227
+ new: summary.totalDiagnosticCount,
49228
+ fixed: result.baselineDelta.fixedCount,
49229
+ baseTotal: result.baselineDelta.baseTotalCount,
49230
+ degraded: false
49231
+ }));
49232
+ else if (input.gateExempt) attributes["baseline.degraded"] = true;
48984
49233
  return attributes;
48985
49234
  };
48986
- const buildCiAttributes = () => {
49235
+ const buildActionAttributes = () => {
48987
49236
  const { githubActorAssociation } = resolveGithubActionsScoreMetadata();
48988
- return {
49237
+ return withNamespace("action", {
48989
49238
  actorAssociation: githubActorAssociation ?? null,
48990
49239
  runnerOs: detectRunnerOs(),
48991
49240
  comment: readEnvBoolean(ACTION_INPUT_ENVIRONMENT_VARIABLES.comment),
48992
49241
  reviewComments: readEnvBoolean(ACTION_INPUT_ENVIRONMENT_VARIABLES.reviewComments),
48993
49242
  versionPin: resolveVersionPin(process.env[ACTION_INPUT_ENVIRONMENT_VARIABLES.version])
48994
- };
49243
+ });
48995
49244
  };
48996
- const buildConfigAttributes = (input) => {
49245
+ const buildScanAttributes = (input) => {
48997
49246
  const ruleOverrides = input.userConfig?.rules ?? {};
48998
49247
  const ruleKeys = Object.keys(ruleOverrides);
48999
- return {
49248
+ return withNamespace("scan", {
49000
49249
  mode: input.mode,
49001
49250
  scope: input.scope,
49002
49251
  parallel: input.parallel,
@@ -49011,12 +49260,18 @@ const buildConfigAttributes = (input) => {
49011
49260
  ignoredTagCount: input.ignoredTagCount,
49012
49261
  hasCustomConfig: input.hasCustomConfig,
49013
49262
  rulesConfigured: ruleKeys.length,
49014
- rulesDisabled: ruleKeys.filter((key) => ruleOverrides[key] === "off").length
49015
- };
49263
+ rulesDisabled: ruleKeys.filter((key) => ruleOverrides[key] === "off").length,
49264
+ fileCount: input.result?.scannedFileCount ?? null
49265
+ });
49016
49266
  };
49017
49267
  /**
49018
- * Projects a scan into the flat attribute set for its root span — the canonical
49019
- * per-scan "wide event". Pure and exported so the projection (outcome
49268
+ * Projects a scan into the namespaced attribute set for its root span — the
49269
+ * canonical per-scan "wide event". Every attribute carries a dotted namespace
49270
+ * that groups it by concept (`scan.*` config, `action.*` CI knobs, `outcome.*`
49271
+ * verdict, `diag.*` findings, `score.*`, `lint.*`, `deadCode.*`,
49272
+ * `supplyChain.*`, `timing.*`, `migration.*`, `baseline.*`) so the attributes
49273
+ * tree up in Sentry's attribute browser and stay filter-/group-/aggregate-able
49274
+ * in the Spans dataset. Pure and exported so the projection (outcome
49020
49275
  * precedence, rule/category rollups, CI knobs, config shape) is unit-testable
49021
49276
  * without a live Sentry client. `null` values are dropped so absent signals
49022
49277
  * never become misleading `"null"` attributes. The run + project base context
@@ -49025,8 +49280,8 @@ const buildConfigAttributes = (input) => {
49025
49280
  * those don't carry.
49026
49281
  */
49027
49282
  const buildRunEventAttributes = (input) => toSpanAttributes({
49028
- ...buildConfigAttributes(input),
49029
- ...buildCiAttributes(),
49283
+ ...buildScanAttributes(input),
49284
+ ...buildActionAttributes(),
49030
49285
  ...buildOutcomeAttributes(input)
49031
49286
  });
49032
49287
  /**
@@ -50324,6 +50579,7 @@ const NON_BLOCKING_REACT_DOCTOR_COMMAND = [
50324
50579
  `if ${REACT_DOCTOR_COMMAND} > "$react_doctor_output" 2>&1; then`,
50325
50580
  "rm -f \"$react_doctor_output\";",
50326
50581
  "else",
50582
+ "cat \"$react_doctor_output\" >&2;",
50327
50583
  "rm -f \"$react_doctor_output\";",
50328
50584
  `printf "%s\\n" "React Doctor found staged regressions." "Run ${REACT_DOCTOR_COMMAND} to inspect." "Want them fixed? Ask your agent to run that command and resolve the findings." >&2;`,
50329
50585
  "fi"
@@ -50354,7 +50610,7 @@ const readPackageJson = (projectRoot) => {
50354
50610
  return null;
50355
50611
  }
50356
50612
  };
50357
- const writeJsonFile$1 = (filePath, value) => {
50613
+ const writeJsonFile = (filePath, value) => {
50358
50614
  NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
50359
50615
  };
50360
50616
  const packageHasDependency = (projectRoot, dependencyName) => {
@@ -51074,7 +51330,8 @@ const getStagedSourceFiles = async (directory) => {
51074
51330
  return [...await runPromise(gen(function* () {
51075
51331
  return yield* (yield* StagedFiles).discoverSourceFiles(directory);
51076
51332
  }).pipe(provide(stagedFilesLayer)))];
51077
- } catch {
51333
+ } catch (error) {
51334
+ cliLogger.warn(`Failed to discover staged files: ${error instanceof Error ? error.message : String(error)}`);
51078
51335
  return [];
51079
51336
  }
51080
51337
  };
@@ -51093,6 +51350,35 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
51093
51350
  };
51094
51351
  };
51095
51352
  //#endregion
51353
+ //#region src/cli/utils/is-environment-error.ts
51354
+ const isNodeSystemError = (error) => error instanceof Error && typeof error.code === "string";
51355
+ const ENVIRONMENT_ERROR_CODES = new Set([
51356
+ "ENOSPC",
51357
+ "EIO",
51358
+ "EROFS",
51359
+ "EACCES",
51360
+ "EPERM",
51361
+ "ENOTDIR"
51362
+ ]);
51363
+ const isEnvironmentError = (error) => {
51364
+ if (!isNodeSystemError(error)) return false;
51365
+ if (error.code === "ENOENT") return error.syscall?.startsWith("spawn") ?? false;
51366
+ return error.code !== void 0 && ENVIRONMENT_ERROR_CODES.has(error.code);
51367
+ };
51368
+ const formatEnvironmentError = (error) => {
51369
+ if (!isNodeSystemError(error)) return error instanceof Error ? error.message : String(error);
51370
+ switch (error.code) {
51371
+ case "ENOSPC": return "No space left on device. Free up disk space and try again.";
51372
+ case "EIO": return "I/O error: the filesystem or disk may be failing. Check your system logs.";
51373
+ case "EROFS": return "Read-only filesystem: cannot write to this location.";
51374
+ case "EACCES":
51375
+ case "EPERM": return error.path ? `Permission denied accessing ${error.path}. Check file permissions and try again.` : "Permission denied. Check file permissions and try again.";
51376
+ case "ENOTDIR": return error.path ? `A file exists at ${error.path} or one of its parent paths where a directory was expected.` : "A file exists where a directory was expected.";
51377
+ case "ENOENT": return "Required command not found. Ensure the tool (e.g. git) is installed and on your PATH.";
51378
+ default: return error.message;
51379
+ }
51380
+ };
51381
+ //#endregion
51096
51382
  //#region src/cli/utils/handle-error.ts
51097
51383
  const OTLP_ENDPOINT_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_ENDPOINT";
51098
51384
  const OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_AUTH_HEADER";
@@ -51175,15 +51461,19 @@ const handleError = (error, options = {}) => {
51175
51461
  process.exitCode = 1;
51176
51462
  };
51177
51463
  /**
51178
- * Renderer for expected, user-actionable failures — a bad `--diff` value or
51179
- * a base branch that isn't fetched. Prints just the (already human-readable)
51180
- * message — no "Something went wrong", prefilled issue, Discord link, or
51181
- * Sentry reference — because there is no bug to report.
51464
+ * Renderer for expected, user-actionable failures — a bad `--diff` value,
51465
+ * a base branch that isn't fetched, or environment errors like disk-full or
51466
+ * permission-denied. Prints just the (already human-readable) message — no
51467
+ * "Something went wrong", prefilled issue, Discord link, or Sentry reference
51468
+ * — because there is no bug to report.
51182
51469
  */
51183
51470
  const handleUserError = (error, options = {}) => {
51471
+ const isEnvError = isEnvironmentError(error);
51472
+ if (isEnvError) recordCount(METRIC.cliEnvironmentError, 1, { code: error.code ?? "unknown" });
51473
+ const message = isEnvError ? formatEnvironmentError(error) : formatErrorForReport(error);
51184
51474
  runSync(gen(function* () {
51185
51475
  yield* error$1("");
51186
- yield* error$1(highlighter.error(formatErrorForReport(error)));
51476
+ yield* error$1(highlighter.error(message));
51187
51477
  yield* error$1("");
51188
51478
  }));
51189
51479
  if (options.shouldExit !== false) process.exit(1);
@@ -51198,7 +51488,7 @@ const handleUserError = (error, options = {}) => {
51198
51488
  * `handleUserError` (a plain message — no "Something went wrong", prefilled
51199
51489
  * issue, Discord link, or Sentry reference), since there is no bug to report.
51200
51490
  *
51201
- * Three distinct shapes reach the CLI's catch blocks:
51491
+ * Four distinct shapes reach the CLI's catch blocks:
51202
51492
  *
51203
51493
  * - **Project-discovery failures** (`NoReactDependencyError`,
51204
51494
  * `ProjectNotFoundError`, `PackageJsonNotFoundError`, `NotADirectoryError`,
@@ -51211,12 +51501,19 @@ const handleUserError = (error, options = {}) => {
51211
51501
  * `--project` name.
51212
51502
  * - **Bad `--diff` input** (`GitBaseBranchInvalid` / `GitBaseBranchMissing`)
51213
51503
  * stays the tagged `ReactDoctorError`, so dispatch on the reason `_tag`.
51504
+ * - **Environment failures** (`ENOSPC`, `EIO`, `EROFS`, `EACCES`, `EPERM`,
51505
+ * `ENOTDIR`, plus a `spawn`-scoped `ENOENT` for a missing binary) — disk
51506
+ * full / failing / read-only, permission denied, or a path blocked by a
51507
+ * file. React Doctor cannot fix the user's environment; exit cleanly with an
51508
+ * actionable message instead of crashing. See `is-environment-error.ts` for
51509
+ * why the set stays narrow (codes that usually mean our bug keep reaching
51510
+ * Sentry).
51214
51511
  *
51215
51512
  * This composes the existing core narrowers rather than introducing a new
51216
51513
  * error-shape helper (AGENTS.md): it encodes CLI-layer reporting policy, not
51217
51514
  * knowledge of the `ReactDoctorError` shape.
51218
51515
  */
51219
- const isExpectedUserError = (error) => error instanceof CliInputError || isProjectDiscoveryError(error) || isReactDoctorError(error) && (error.reason._tag === "GitBaseBranchInvalid" || error.reason._tag === "GitBaseBranchMissing");
51516
+ const isExpectedUserError = (error) => error instanceof CliInputError || isProjectDiscoveryError(error) || isEnvironmentError(error) || isReactDoctorError(error) && (error.reason._tag === "GitBaseBranchInvalid" || error.reason._tag === "GitBaseBranchMissing");
51220
51517
  //#endregion
51221
51518
  //#region src/cli/utils/build-handoff-payload.ts
51222
51519
  const buildHandoffPayload = (input) => {
@@ -51392,7 +51689,7 @@ const installDoctorScript = (options) => {
51392
51689
  };
51393
51690
  })();
51394
51691
  const scriptStatus = scriptTarget.status;
51395
- if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
51692
+ if (scriptStatus === "created") writeJsonFile(packageJsonPath, {
51396
51693
  ...packageJson,
51397
51694
  scripts: {
51398
51695
  ...isRecord$1(scripts) ? scripts : {},
@@ -51980,28 +52277,42 @@ const rememberInstallAgents = (agents, options = {}) => writePreference(INSTALL_
51980
52277
  const CLAUDE_AGENT = "claude-code";
51981
52278
  const CURSOR_AGENT = "cursor";
51982
52279
  const CLAUDE_SETTINGS_RELATIVE_PATH = ".claude/settings.json";
51983
- const CLAUDE_HOOK_RELATIVE_PATH = ".claude/hooks/react-doctor.sh";
51984
- const CLAUDE_HOOK_COMMAND = "sh \"$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.sh\"";
52280
+ const CLAUDE_HOOK_RELATIVE_PATH = ".claude/hooks/react-doctor.mjs";
52281
+ const CLAUDE_HOOK_COMMAND = "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.mjs\"";
51985
52282
  const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
51986
- const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
52283
+ const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.mjs";
52284
+ const CURSOR_HOOK_COMMAND = "node .cursor/hooks/react-doctor.mjs";
51987
52285
  const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
51988
52286
  const CURSOR_HOOKS_SCHEMA_VERSION = 1;
51989
- const JSON_INDENT_SPACES$1 = 2;
51990
52287
  const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
51991
52288
  const readJsonFile = (filePath, fallback) => {
51992
52289
  if (!NFS.existsSync(filePath)) return fallback;
51993
52290
  const content = NFS.readFileSync(filePath, "utf8").trim();
51994
52291
  if (content.length === 0) return fallback;
51995
- return JSON.parse(content);
52292
+ try {
52293
+ return JSON.parse(content);
52294
+ } catch (error) {
52295
+ if (error instanceof SyntaxError) throw new CliInputError(`Could not parse ${filePath}: the file contains invalid JSON. Fix the syntax errors in this file and re-run the install command.`);
52296
+ throw error;
52297
+ }
51996
52298
  };
51997
- const writeJsonFile = (filePath, value) => {
51998
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
51999
- NFS.writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
52299
+ const ensureDirectoryExists = (directoryPath) => {
52300
+ try {
52301
+ NFS.mkdirSync(directoryPath, { recursive: true });
52302
+ } catch (error) {
52303
+ const code = error.code;
52304
+ if (code === "EACCES" || code === "EPERM") throw new CliInputError(`Could not create directory ${directoryPath}: permission denied. Ensure you have write permissions for this location and re-run the install command.`);
52305
+ if (code === "ENOTDIR" || code === "EEXIST") throw new CliInputError(`Could not create directory ${directoryPath}: a file exists at this path or one of its parent paths. Remove the conflicting file and re-run the install command.`);
52306
+ throw error;
52307
+ }
52308
+ };
52309
+ const writeJsonFileWithDirectoryCheck = (filePath, value) => {
52310
+ ensureDirectoryExists(Path.dirname(filePath));
52311
+ writeJsonFile(filePath, value);
52000
52312
  };
52001
52313
  const writeHookScript = (filePath) => {
52002
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
52314
+ ensureDirectoryExists(Path.dirname(filePath));
52003
52315
  NFS.writeFileSync(filePath, buildAgentHookScript());
52004
- NFS.chmodSync(filePath, 493);
52005
52316
  };
52006
52317
  const hasClaudeHookCommand = (groups) => groups.some((group) => (group.hooks ?? []).some((hook) => hook.command === CLAUDE_HOOK_COMMAND));
52007
52318
  const installClaudeHook = (projectRoot) => {
@@ -52015,14 +52326,14 @@ const installClaudeHook = (projectRoot) => {
52015
52326
  command: CLAUDE_HOOK_COMMAND
52016
52327
  }] });
52017
52328
  hooks.PostToolBatch = postToolBatchHooks;
52018
- writeJsonFile(settingsPath, {
52329
+ writeJsonFileWithDirectoryCheck(settingsPath, {
52019
52330
  ...settings,
52020
52331
  hooks
52021
52332
  });
52022
52333
  writeHookScript(hookPath);
52023
52334
  return [settingsPath, hookPath];
52024
52335
  };
52025
- const hasCursorHookCommand = (handlers) => handlers.some((handler) => handler.command === CURSOR_HOOK_RELATIVE_PATH);
52336
+ const hasCursorHookCommand = (handlers) => handlers.some((handler) => handler.command === CURSOR_HOOK_COMMAND);
52026
52337
  const installCursorHook = (projectRoot) => {
52027
52338
  const configPath = Path.join(projectRoot, CURSOR_HOOKS_RELATIVE_PATH);
52028
52339
  const hookPath = Path.join(projectRoot, CURSOR_HOOK_RELATIVE_PATH);
@@ -52030,12 +52341,12 @@ const installCursorHook = (projectRoot) => {
52030
52341
  const hooks = { ...config.hooks ?? {} };
52031
52342
  const postToolUseHooks = [...hooks.postToolUse ?? []];
52032
52343
  if (!hasCursorHookCommand(postToolUseHooks)) postToolUseHooks.push({
52033
- command: CURSOR_HOOK_RELATIVE_PATH,
52344
+ command: CURSOR_HOOK_COMMAND,
52034
52345
  matcher: CURSOR_HOOK_MATCHER,
52035
52346
  timeout: 120
52036
52347
  });
52037
52348
  hooks.postToolUse = postToolUseHooks;
52038
- writeJsonFile(configPath, {
52349
+ writeJsonFileWithDirectoryCheck(configPath, {
52039
52350
  ...config,
52040
52351
  version: config.version ?? CURSOR_HOOKS_SCHEMA_VERSION,
52041
52352
  hooks
@@ -52044,102 +52355,111 @@ const installCursorHook = (projectRoot) => {
52044
52355
  return [configPath, hookPath];
52045
52356
  };
52046
52357
  const buildAgentHookScript = () => [
52047
- "#!/bin/sh",
52048
- "set -u",
52358
+ "import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';",
52359
+ "import { tmpdir } from 'node:os';",
52360
+ "import { join, dirname } from 'node:path';",
52361
+ "import { fileURLToPath } from 'node:url';",
52362
+ "import { spawnSync } from 'node:child_process';",
52049
52363
  "",
52050
- "input_file=$(mktemp \"${TMPDIR:-/tmp}/react-doctor-agent-hook.XXXXXX\")",
52051
- "output_file=$(mktemp \"${TMPDIR:-/tmp}/react-doctor-agent-hook-output.XXXXXX\")",
52052
- "trap 'rm -f \"$input_file\" \"$output_file\"' EXIT",
52053
- "cat > \"$input_file\"",
52364
+ "const __filename = fileURLToPath(import.meta.url);",
52365
+ "const __dirname = dirname(__filename);",
52054
52366
  "",
52055
- "script_dir=$(CDPATH= cd \"$(dirname \"$0\")\" && pwd)",
52056
- "project_root=${CLAUDE_PROJECT_DIR:-}",
52057
- "if [ -z \"$project_root\" ]; then",
52058
- " project_root=$(CDPATH= cd \"$script_dir/../..\" && pwd)",
52059
- "fi",
52060
- "if ! cd \"$project_root\"; then",
52061
- " exit 0",
52062
- "fi",
52367
+ "const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'ApplyPatch']);",
52063
52368
  "",
52064
- "should_scan() {",
52065
- " if ! command -v node >/dev/null 2>&1; then",
52066
- " return 0",
52067
- " fi",
52369
+ "const readStdin = () => {",
52370
+ " try {",
52371
+ " return readFileSync(0, 'utf8');",
52372
+ " } catch {",
52373
+ " return '';",
52374
+ " }",
52375
+ "};",
52068
52376
  "",
52069
- " node - \"$input_file\" <<'NODE'",
52070
- "const fs = require('node:fs');",
52071
- "const inputPath = process.argv[2];",
52072
- "const editToolNames = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'ApplyPatch']);",
52073
- "try {",
52074
- " const input = JSON.parse(fs.readFileSync(inputPath, 'utf8') || '{}');",
52377
+ "const shouldScan = (input) => {",
52075
52378
  " const eventName = input.hook_event_name || input.eventName || input.event_name;",
52076
52379
  " if (eventName === 'PostToolBatch') {",
52077
52380
  " const toolCalls = Array.isArray(input.tool_calls) ? input.tool_calls : [];",
52078
- " process.exit(toolCalls.some((toolCall) => editToolNames.has(toolCall.tool_name)) ? 0 : 10);",
52381
+ " return toolCalls.some((toolCall) => EDIT_TOOL_NAMES.has(toolCall.tool_name));",
52079
52382
  " }",
52080
52383
  " const toolName = input.tool_name || input.toolName || input.tool;",
52081
- " process.exit(!toolName || editToolNames.has(toolName) ? 0 : 10);",
52082
- "} catch {",
52083
- " process.exit(0);",
52084
- "}",
52085
- "NODE",
52086
- "}",
52384
+ " return !toolName || EDIT_TOOL_NAMES.has(toolName);",
52385
+ "};",
52087
52386
  "",
52088
- "run_react_doctor() {",
52089
- " if [ -x ./node_modules/.bin/react-doctor ]; then",
52090
- " ./node_modules/.bin/react-doctor --verbose --scope changed --blocking warning --no-score",
52091
- " return",
52092
- " fi",
52387
+ "const runReactDoctor = (outputPath) => {",
52388
+ " // Each candidate is a single shell command string (not an args array):",
52389
+ " // `shell: true` is required to run the Windows `.cmd` shims, and an args",
52390
+ " // array with `shell: true` trips Node's DEP0190. A missing command exits",
52391
+ " // 127 via the shell (no ENOENT error), so fall through on that. With no",
52392
+ " // runner found, exit 0 silently — stdout is parsed as the hook's JSON.",
52393
+ " const commands = [",
52394
+ " './node_modules/.bin/react-doctor --verbose --scope changed --blocking warning --no-score',",
52395
+ " 'react-doctor --verbose --scope changed --blocking warning --no-score',",
52396
+ " 'pnpm dlx react-doctor@latest --verbose --scope changed --blocking warning --no-score',",
52397
+ " 'npx --yes react-doctor@latest --verbose --scope changed --blocking warning --no-score',",
52398
+ " ];",
52093
52399
  "",
52094
- " if command -v react-doctor >/dev/null 2>&1; then",
52095
- " react-doctor --verbose --scope changed --blocking warning --no-score",
52096
- " return",
52097
- " fi",
52400
+ " for (const command of commands) {",
52401
+ " const result = spawnSync(command, { encoding: 'utf8', shell: true });",
52402
+ " if (result.error?.code === 'ENOENT' || result.status === 127) continue;",
52403
+ " try {",
52404
+ " writeFileSync(outputPath, (result.stdout || '') + (result.stderr || ''));",
52405
+ " } catch {}",
52406
+ " return result.status;",
52407
+ " }",
52098
52408
  "",
52099
- " if command -v pnpm >/dev/null 2>&1; then",
52100
- " pnpm dlx react-doctor@latest --verbose --scope changed --blocking warning --no-score",
52101
- " return",
52102
- " fi",
52409
+ " return 0;",
52410
+ "};",
52103
52411
  "",
52104
- " if command -v npx >/dev/null 2>&1; then",
52105
- " npx --yes react-doctor@latest --verbose --scope changed --blocking warning --no-score",
52106
- " return",
52107
- " fi",
52412
+ "const cleanup = (...paths) => {",
52413
+ " for (const path of paths) {",
52414
+ " try { unlinkSync(path); } catch {}",
52415
+ " }",
52416
+ "};",
52108
52417
  "",
52109
- " printf '%s\\n' 'react-doctor: command not found; skipping agent hook scan.'",
52110
- " return 0",
52111
- "}",
52418
+ "const main = () => {",
52419
+ " let input;",
52420
+ " try {",
52421
+ " const stdinContent = readStdin();",
52422
+ " input = JSON.parse(stdinContent || '{}');",
52423
+ " } catch {",
52424
+ " input = {};",
52425
+ " }",
52112
52426
  "",
52113
- "if ! should_scan; then",
52114
- " exit 0",
52115
- "fi",
52427
+ " if (!shouldScan(input)) {",
52428
+ " process.exit(0);",
52429
+ " }",
52116
52430
  "",
52117
- "if run_react_doctor > \"$output_file\" 2>&1; then",
52118
- " exit 0",
52119
- "fi",
52431
+ " const projectRoot = process.env.CLAUDE_PROJECT_DIR || join(__dirname, '../..');",
52432
+ " const outputPath = join(tmpdir(), `react-doctor-agent-hook-output-${process.pid}.txt`);",
52120
52433
  "",
52121
- "node - \"$input_file\" \"$output_file\" <<'NODE'",
52122
- "const fs = require('node:fs');",
52123
- "const inputPath = process.argv[2];",
52124
- "const outputPath = process.argv[3];",
52125
- "const readInput = () => {",
52126
52434
  " try {",
52127
- " return JSON.parse(fs.readFileSync(inputPath, 'utf8') || '{}');",
52435
+ " process.chdir(projectRoot);",
52128
52436
  " } catch {",
52129
- " return {};",
52437
+ " process.exit(0);",
52438
+ " }",
52439
+ "",
52440
+ " const scanResult = runReactDoctor(outputPath);",
52441
+ " if (scanResult === 0) {",
52442
+ " cleanup(outputPath);",
52443
+ " process.exit(0);",
52444
+ " }",
52445
+ "",
52446
+ " const scanOutput = readFileSync(outputPath, 'utf8').trim();",
52447
+ " cleanup(outputPath);",
52448
+ "",
52449
+ " if (!scanOutput) {",
52450
+ " process.exit(0);",
52451
+ " }",
52452
+ "",
52453
+ " const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing. For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.\\n\\n${scanOutput}`;",
52454
+ "",
52455
+ " if (input.hook_event_name === 'PostToolBatch') {",
52456
+ " console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: message } }));",
52457
+ " } else {",
52458
+ " console.log(JSON.stringify({ additional_context: message }));",
52130
52459
  " }",
52131
52460
  "};",
52132
- "const input = readInput();",
52133
- "const scanOutput = fs.readFileSync(outputPath, 'utf8').trim();",
52134
- "if (!scanOutput) process.exit(0);",
52135
- "const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing. For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.\\n\\n${scanOutput}`;",
52136
- "if (input.hook_event_name === 'PostToolBatch') {",
52137
- " console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: message } }));",
52138
- "} else {",
52139
- " console.log(JSON.stringify({ additional_context: message }));",
52140
- "}",
52141
- "NODE",
52142
- ""
52461
+ "",
52462
+ "main();"
52143
52463
  ].join("\n");
52144
52464
  const installReactDoctorAgentHooks = (options) => {
52145
52465
  const installedAgents = [];
@@ -52200,6 +52520,7 @@ const buildReactDoctorHookBlock = () => [
52200
52520
  "if react_doctor_scan_staged_files > \"$react_doctor_output\" 2>&1; then",
52201
52521
  " rm -f \"$react_doctor_output\"",
52202
52522
  "else",
52523
+ " cat \"$react_doctor_output\" >&2",
52203
52524
  " rm -f \"$react_doctor_output\"",
52204
52525
  ` printf '%s\\n' "React Doctor found staged regressions." "Run ${REACT_DOCTOR_COMMAND} to inspect." "Want them fixed? Ask your agent to run that command and resolve the findings." >&2`,
52205
52526
  "fi",
@@ -52271,7 +52592,7 @@ const installPackageJsonHook = (options, strategy) => {
52271
52592
  parent = cloned;
52272
52593
  }
52273
52594
  parent[leafKey] = strategy.leafShape === "array" ? appendArrayCommand(parent[leafKey]) : appendStringCommand(parent[leafKey]);
52274
- writeJsonFile$1(packageJsonPath, nextPackageJson);
52595
+ writeJsonFile(packageJsonPath, nextPackageJson);
52275
52596
  removeLegacyManagedRunner(options.projectRoot);
52276
52597
  return {
52277
52598
  hookPath: packageJsonPath,
@@ -52965,6 +53286,26 @@ const CLI_AGENT_AUTO_FLAGS = {
52965
53286
  codex: ["--yolo"],
52966
53287
  cursor: ["--force"]
52967
53288
  };
53289
+ const resolveCursorBundledNode = () => {
53290
+ if (!isWindows) return null;
53291
+ const localAppData = process.env.LOCALAPPDATA;
53292
+ if (!localAppData) return null;
53293
+ const cursorAgentRoot = Path.join(localAppData, "cursor-agent", "versions");
53294
+ try {
53295
+ const versions = NFS.readdirSync(cursorAgentRoot);
53296
+ if (versions.length === 0) return null;
53297
+ const sortedVersions = versions.sort((a, b) => b.localeCompare(a, void 0, { numeric: true }));
53298
+ for (const version of sortedVersions) try {
53299
+ const bundledNodePath = Path.join(cursorAgentRoot, version, "node.exe");
53300
+ const entryScriptPath = Path.join(cursorAgentRoot, version, "index.js");
53301
+ if (NFS.statSync(bundledNodePath).isFile() && NFS.statSync(entryScriptPath).isFile()) return {
53302
+ command: bundledNodePath,
53303
+ args: [entryScriptPath]
53304
+ };
53305
+ } catch {}
53306
+ } catch {}
53307
+ return null;
53308
+ };
52968
53309
  const resolveWindowsCmdEntryScript = (command) => {
52969
53310
  if (!isWindows) return null;
52970
53311
  const pathDirectories = (process.env.PATH ?? "").split(Path.delimiter).filter(Boolean);
@@ -52984,11 +53325,10 @@ const resolveWindowsCmdEntryScript = (command) => {
52984
53325
  }
52985
53326
  return null;
52986
53327
  };
52987
- const spawnAgent = (command, args, cwd, shell = false) => new Promise((resolve, reject) => {
53328
+ const spawnAgent = (command, args, cwd) => new Promise((resolve, reject) => {
52988
53329
  const child = spawn(command, [...args], {
52989
53330
  cwd,
52990
- stdio: "inherit",
52991
- shell
53331
+ stdio: "inherit"
52992
53332
  });
52993
53333
  child.on("error", reject);
52994
53334
  child.on("close", (code) => resolve(code ?? 0));
@@ -52997,6 +53337,10 @@ const launchCliAgent = async (agentId, prompt, cwd) => {
52997
53337
  const binary = CLI_AGENT_BINARIES[agentId];
52998
53338
  const agentArgs = [...CLI_AGENT_AUTO_FLAGS[agentId], prompt];
52999
53339
  if (isWindows) {
53340
+ if (agentId === "cursor") {
53341
+ const cursorResolution = resolveCursorBundledNode();
53342
+ if (cursorResolution) return spawnAgent(cursorResolution.command, [...cursorResolution.args, ...agentArgs], cwd);
53343
+ }
53000
53344
  const entryScript = resolveWindowsCmdEntryScript(binary);
53001
53345
  if (entryScript) return spawnAgent(process.execPath, [entryScript, ...agentArgs], cwd);
53002
53346
  }
@@ -53710,7 +54054,7 @@ const warnDeprecatedDiff = (flags, userConfig) => {
53710
54054
  };
53711
54055
  const warnDiffUnavailable = (requested, isQuiet) => {
53712
54056
  if (isQuiet) return;
53713
- if (typeof requested.base === "string") cliLogger.warn(`Could not compute diff against "${requested.base}" (merge-base failed or HEAD has no history). Running full scan.`);
54057
+ if (typeof requested.base === "string") cliLogger.warn(`Could not compute diff against "${requested.base}" (git unavailable, ref not found, or merge-base failed). Running full scan.`);
53714
54058
  else cliLogger.warn("No feature branch or uncommitted changes detected. Running full scan.");
53715
54059
  cliLogger.break();
53716
54060
  };
@@ -53907,6 +54251,7 @@ const resolveRequestedProjects = (requestedNames, workspacePackages, rootDirecto
53907
54251
  return requestedNames.map((requestedName) => {
53908
54252
  const matched = workspacePackages.find((workspacePackage) => workspacePackage.name === requestedName || Path.basename(workspacePackage.directory) === requestedName);
53909
54253
  if (matched) return matched.directory;
54254
+ if (Path.basename(rootDirectory) === requestedName) return rootDirectory;
53910
54255
  const candidateDirectory = Path.resolve(rootDirectory, requestedName);
53911
54256
  if (isDirectory(candidateDirectory)) {
53912
54257
  recordCount(METRIC.projectPathSelected);
@@ -54014,6 +54359,17 @@ const warnDeprecatedFailOn = (flags, userConfig) => {
54014
54359
  cliLogger.warn(`${source} is deprecated; rename it to ${replacement}.`);
54015
54360
  };
54016
54361
  //#endregion
54362
+ //#region src/cli/utils/warn-ai-training-environment.ts
54363
+ let didWarnAiTraining = false;
54364
+ const warnIfAiTrainingEnvironment = () => {
54365
+ if (didWarnAiTraining) return;
54366
+ const detected = detectAiTrainingEnvironment();
54367
+ if (detected === null) return;
54368
+ didWarnAiTraining = true;
54369
+ process.stderr.write(`${highlighter.warn("react-doctor detected use in an AI or ML pipeline. This use requires written permission under the react-doctor license — contact founders@million.dev to request access.")}\n`);
54370
+ recordCount(METRIC.aiTrainingWarningShown, 1, { environment: detected });
54371
+ };
54372
+ //#endregion
54017
54373
  //#region src/cli/utils/validate-mode-flags.ts
54018
54374
  const usedDiffAlias = (flags) => flags.diff !== void 0 && flags.diff !== false && flags.diff !== "false" && flags.diff !== "";
54019
54375
  const usedScope = (flags) => typeof flags.scope === "string" && flags.scope.length > 0;
@@ -54097,10 +54453,14 @@ const inspectAction = async (directory, flags) => {
54097
54453
  const isQuiet = isScoreOnly || isJsonMode;
54098
54454
  const requestedDirectory = Path.resolve(directory);
54099
54455
  const startTime = performance$1.now();
54100
- if (isJsonMode) enableJsonMode({
54101
- compact: Boolean(flags.jsonCompact),
54102
- directory: requestedDirectory
54103
- });
54456
+ if (isJsonMode) {
54457
+ enableJsonMode({
54458
+ compact: Boolean(flags.jsonCompact),
54459
+ directory: requestedDirectory,
54460
+ outputFile: flags.jsonOut
54461
+ });
54462
+ if (flags.jsonOut) recordCount(METRIC.jsonOutUsed, 1);
54463
+ }
54104
54464
  recordCount(METRIC.cliInvoked, 1, { command: "inspect" });
54105
54465
  try {
54106
54466
  validateModeFlags(flags);
@@ -54114,6 +54474,7 @@ const inspectAction = async (directory, flags) => {
54114
54474
  setJsonReportDirectory(resolvedDirectory);
54115
54475
  warnDeprecatedFailOn(flags, userConfig);
54116
54476
  warnDeprecatedDiff(flags, userConfig);
54477
+ warnIfAiTrainingEnvironment();
54117
54478
  if (scanTarget.didRedirectViaRootDir && !isQuiet) {
54118
54479
  cliLogger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
54119
54480
  cliLogger.break();
@@ -54401,6 +54762,10 @@ const installAction = async (options, command) => {
54401
54762
  projectRoot: options.cwd ?? process.cwd()
54402
54763
  });
54403
54764
  } catch (error) {
54765
+ if (isExpectedUserError(error)) {
54766
+ handleUserError(error);
54767
+ return;
54768
+ }
54404
54769
  handleError(error, { sentryEventId: await reportErrorToSentry(error) });
54405
54770
  }
54406
54771
  };
@@ -55033,6 +55398,14 @@ const applyColorPreference = (argv, env = process.env) => {
55033
55398
  setColorEnabled(enabled);
55034
55399
  };
55035
55400
  //#endregion
55401
+ //#region src/cli/utils/ensure-windows-utf8-console.ts
55402
+ const ensureWindowsUtf8Console = () => {
55403
+ if (process.platform !== "win32" || !process.stdout.isTTY) return;
55404
+ try {
55405
+ execFileSync("chcp.com", ["65001"], { stdio: "ignore" });
55406
+ } catch {}
55407
+ };
55408
+ //#endregion
55036
55409
  //#region src/cli/utils/exit-gracefully.ts
55037
55410
  const exitGracefully = () => {
55038
55411
  try {
@@ -55175,6 +55548,7 @@ const ROOT_FLAG_SPEC = {
55175
55548
  "--category",
55176
55549
  "--changed-files-from",
55177
55550
  "--blocking",
55551
+ "--json-out",
55178
55552
  "--output-dir",
55179
55553
  "--fail-on",
55180
55554
  "--project",
@@ -55365,7 +55739,7 @@ ${highlighter.dim("Learn more:")}
55365
55739
  ${highlighter.info(CANONICAL_GITHUB_URL)}
55366
55740
  `;
55367
55741
  const collectCategoryOption = (value, previousValues) => [...previousValues ?? [], value];
55368
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--debug", "force a Sentry trace and print its id at the end (paste it into a bug report)").option("--output-dir <dir>", "directory for the full diagnostics dump (default: a temp folder)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--no-parallel", "lint serially with one worker (default: parallel across CPU cores; set the worker count with REACT_DOCTOR_PARALLEL)").option("--project <name>", "select projects: workspace names or directory paths (comma-separated for multiple); overrides the `projects` config field").option("--scope <value>", "how much to scan/report: full (default), files, changed (only new issues vs base), or lines (only changed lines)").option("--base <ref>", "base git ref for files/changed/lines scope (auto-detected when omitted)").addOption(new Option("--diff [base]", "[deprecated] alias for --scope changed (pass `false` to force a full scan)").hideHelp()).addOption(new Option("--changed-files-from <file>", "scan source files listed in a newline-delimited changed-files file").hideHelp()).option("--no-score", "skip the score API, the share URL, and crash reporting").addOption(new Option("--category <category>", "only show diagnostics in a category (repeatable; e.g. Security)").argParser(collectCategoryOption)).option("--no-telemetry", "alias for --no-score (skip the score API, share URL, and crash reporting)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--blocking <level>", "severity that fails CI: error (default), warning, or none (advisory)").addOption(new Option("--fail-on <level>", "[deprecated] alias for --blocking <level>").hideHelp()).option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (default)").option("--no-warnings", "hide warning-severity diagnostics (errors only)").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderRootHelpEpilog);
55742
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--debug", "force a Sentry trace and print its id at the end (paste it into a bug report)").option("--output-dir <dir>", "directory for the full diagnostics dump (default: a temp folder)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("--json-out <path>", "with --json, write the report to a file instead of stdout").option("-y, --yes", "skip prompts, scan all workspace projects").option("--no-parallel", "lint serially with one worker (default: parallel across CPU cores; set the worker count with REACT_DOCTOR_PARALLEL)").option("--project <name>", "select projects: workspace names or directory paths (comma-separated for multiple); overrides the `projects` config field").option("--scope <value>", "how much to scan/report: full (default), files, changed (only new issues vs base), or lines (only changed lines)").option("--base <ref>", "base git ref for files/changed/lines scope (auto-detected when omitted)").addOption(new Option("--diff [base]", "[deprecated] alias for --scope changed (pass `false` to force a full scan)").hideHelp()).addOption(new Option("--changed-files-from <file>", "scan source files listed in a newline-delimited changed-files file").hideHelp()).option("--no-score", "skip the score API, the share URL, and crash reporting").addOption(new Option("--category <category>", "only show diagnostics in a category (repeatable; e.g. Security)").argParser(collectCategoryOption)).option("--no-telemetry", "alias for --no-score (skip the score API, share URL, and crash reporting)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--blocking <level>", "severity that fails CI: error (default), warning, or none (advisory)").addOption(new Option("--fail-on <level>", "[deprecated] alias for --blocking <level>").hideHelp()).option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (default)").option("--no-warnings", "hide warning-severity diagnostics (errors only)").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderRootHelpEpilog);
55369
55743
  program.action(inspectAction);
55370
55744
  program.command("why <location>").description("Explain why a rule fired (or why a suppression didn't apply) at a file:line").option("--project <name>", "select projects: workspace names or directory paths (comma-separated for multiple)").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").action((location, options) => whyAction(location, options));
55371
55745
  program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderInstallHelpEpilog).action(installAction);
@@ -55390,6 +55764,7 @@ if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
55390
55764
  process.exit(0);
55391
55765
  }
55392
55766
  applyColorPreference(strippedArgv);
55767
+ ensureWindowsUtf8Console();
55393
55768
  const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
55394
55769
  Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => program.parseAsync(argv)).then(() => flushSentry()).catch(async (error) => {
55395
55770
  const isUserError = isExpectedUserError(error);
@@ -55408,4 +55783,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
55408
55783
  export {};
55409
55784
 
55410
55785
  //# sourceMappingURL=cli.js.map
55411
- //# debugId=f65daf5b-9cf1-5118-9736-6ffc410231a0
55786
+ //# debugId=cf52ec59-cee0-5503-b8f2-a4f98bf77ea4