react-doctor 0.5.8-dev.f4e8e4b → 0.5.8-dev.f69f216

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]="6a2c86b6-c3a8-5e50-8e7b-daef840df200")}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]="fabe3e34-b40f-514e-a5af-fa68a36365ae")}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";
@@ -26,7 +26,7 @@ import tty from "node:tty";
26
26
  import { codeFrameColumns } from "@babel/code-frame";
27
27
  import Conf from "conf";
28
28
  import basePrompts from "prompts";
29
- import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
29
+ import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource, isSkillAgentType } from "agent-install";
30
30
  import { generateCode, loadFile, writeFile } from "magicast";
31
31
  import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers";
32
32
  //#region \0rolldown/runtime.js
@@ -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);
@@ -38553,7 +38558,7 @@ const resolveScanConcurrency = (requested) => {
38553
38558
  if (!Number.isFinite(requested) || requested < 1) return 1;
38554
38559
  return Math.min(Math.floor(requested), 32);
38555
38560
  };
38556
- const readSystemFacts = () => ({
38561
+ const readSystemFacts$1 = () => ({
38557
38562
  availableCores: os.availableParallelism(),
38558
38563
  totalMemoryBytes: os.totalmem(),
38559
38564
  cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
@@ -38574,7 +38579,7 @@ const readSystemFacts = () => ({
38574
38579
  * `facts` is injectable so tests exercise core-bound, memory-bound, cgroup-
38575
38580
  * limited, and ceiling cases without mocking `os` or the filesystem.
38576
38581
  */
38577
- const resolveAutoScanConcurrency = (facts = readSystemFacts()) => {
38582
+ const resolveAutoScanConcurrency = (facts = readSystemFacts$1()) => {
38578
38583
  const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
38579
38584
  const memoryBoundedWorkers = Math.floor(availableMemoryBytes / PER_WORKER_MEM_BUDGET_BYTES);
38580
38585
  return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
@@ -38765,6 +38770,12 @@ const BOOLEAN_FIELD_NAMES = [
38765
38770
  "adoptExistingLintConfig"
38766
38771
  ];
38767
38772
  const STRING_FIELD_NAMES = ["rootDir"];
38773
+ const STRING_ARRAY_FIELD_NAMES = [
38774
+ "projects",
38775
+ "textComponents",
38776
+ "rawTextWrapperComponents",
38777
+ "serverAuthFunctionNames"
38778
+ ];
38768
38779
  const SURFACE_CONTROL_FIELD_NAMES = [
38769
38780
  "includeTags",
38770
38781
  "excludeTags",
@@ -38866,6 +38877,7 @@ const validateConfigTypes = (config) => {
38866
38877
  const validated = { ...config };
38867
38878
  for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
38868
38879
  for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
38880
+ for (const fieldName of STRING_ARRAY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateStringArrayField(fieldName, value));
38869
38881
  applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
38870
38882
  for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
38871
38883
  applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
@@ -40128,7 +40140,10 @@ const shouldEnableRule = (requires, tags, capabilities, ignoredTags, disabledBy)
40128
40140
  }
40129
40141
  return true;
40130
40142
  };
40131
- const checkSecurityScan = (rootDirectory, options = {}) => {
40143
+ const yieldToEventLoop = () => new Promise((resolve) => {
40144
+ setImmediate(resolve);
40145
+ });
40146
+ const createSecurityScanSession = (rootDirectory, options) => {
40132
40147
  const capabilities = options.project ? buildCapabilities(options.project) : /* @__PURE__ */ new Set();
40133
40148
  const ignoredTags = options.ignoredTags ?? /* @__PURE__ */ new Set();
40134
40149
  const enabledScanRules = REACT_DOCTOR_RULES.flatMap((entry) => {
@@ -40143,7 +40158,7 @@ const checkSecurityScan = (rootDirectory, options = {}) => {
40143
40158
  committedFilesOnly: rule.committedFilesOnly === true
40144
40159
  }];
40145
40160
  });
40146
- if (enabledScanRules.length === 0) return [];
40161
+ if (enabledScanRules.length === 0) return null;
40147
40162
  const diagnostics = [];
40148
40163
  const seen = /* @__PURE__ */ new Set();
40149
40164
  const gitIgnoredCache = /* @__PURE__ */ new Map();
@@ -40155,15 +40170,34 @@ const checkSecurityScan = (rootDirectory, options = {}) => {
40155
40170
  }
40156
40171
  return status === true;
40157
40172
  };
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);
40173
+ const scanFile = (file) => {
40174
+ for (const { entry, scan, committedFilesOnly } of enabledScanRules) for (const finding of scan(file)) {
40175
+ if (committedFilesOnly && isFileGitIgnored(file)) continue;
40176
+ const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
40177
+ const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
40178
+ if (seen.has(key)) continue;
40179
+ seen.add(key);
40180
+ diagnostics.push(diagnostic);
40181
+ }
40182
+ };
40183
+ return {
40184
+ scanFile,
40185
+ diagnostics
40186
+ };
40187
+ };
40188
+ const checkSecurityScanCooperative = async (rootDirectory, options = {}) => {
40189
+ const session = createSecurityScanSession(rootDirectory, options);
40190
+ if (session === null) return [];
40191
+ let filesSinceYield = 0;
40192
+ for (const file of collectSecurityScanFiles(rootDirectory)) {
40193
+ session.scanFile(file);
40194
+ filesSinceYield += 1;
40195
+ if (filesSinceYield >= 16) {
40196
+ filesSinceYield = 0;
40197
+ await yieldToEventLoop();
40198
+ }
40165
40199
  }
40166
- return diagnostics;
40200
+ return session.diagnostics;
40167
40201
  };
40168
40202
  var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
40169
40203
  let p = process || {}, argv = p.argv || [], env = p.env || {};
@@ -40396,6 +40430,74 @@ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
40396
40430
  return [...seen].filter((pattern) => pattern.length > 0);
40397
40431
  };
40398
40432
  const collectDeadCodeEntryPatterns = (rootDirectory) => [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0);
40433
+ const readSystemFacts = () => ({
40434
+ availableCores: os.availableParallelism(),
40435
+ totalMemoryBytes: os.totalmem(),
40436
+ cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
40437
+ });
40438
+ /**
40439
+ * How many real deslop dead-code child processes may run at once, across the
40440
+ * concurrent per-project `runInspect` fibers of one CLI run. The cap is the
40441
+ * smaller of the core count and the number of `DEAD_CODE_WORKER_MEM_BUDGET_BYTES`
40442
+ * workers that fit in available memory, floored at 1.
40443
+ *
40444
+ * On a roomy dev box / CI runner this resolves high enough that every
40445
+ * concurrently-scanned project still spawns its own worker (no serialization vs
40446
+ * the prior uncapped behavior); on a memory-constrained runner it collapses
40447
+ * toward 1, so the `withDeadCodeWorkerSlot` semaphore serializes the spawns
40448
+ * instead of oversubscribing memory with N simultaneous children — the global
40449
+ * cap the per-project spawn path lacked.
40450
+ *
40451
+ * Mirrors `resolveAutoScanConcurrency` (lint), but budgets memory per the
40452
+ * heavier dead-code worker. `facts` is injectable for tests.
40453
+ */
40454
+ const resolveDeadCodeConcurrency = (facts = readSystemFacts()) => {
40455
+ const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
40456
+ const memoryBoundedWorkers = Math.floor(availableMemoryBytes / DEAD_CODE_WORKER_MEM_BUDGET_BYTES);
40457
+ return Math.max(1, Math.min(facts.availableCores, memoryBoundedWorkers));
40458
+ };
40459
+ let availableSlots = -1;
40460
+ const waiters = [];
40461
+ const releaseSlot = () => {
40462
+ const nextWaiter = waiters.shift();
40463
+ if (nextWaiter !== void 0) nextWaiter();
40464
+ else availableSlots += 1;
40465
+ };
40466
+ /**
40467
+ * Runs `task` once a dead-code worker slot is free, releasing the slot when the
40468
+ * task settles (success or failure). With a high cap (roomy machine) every
40469
+ * caller proceeds immediately; with a low cap (constrained runner) callers
40470
+ * queue and run as slots free.
40471
+ *
40472
+ * `abortSignal` short-circuits the WAIT: if it's already aborted, or fires while
40473
+ * this caller is queued, the call rejects without acquiring a slot or running
40474
+ * `task` — so a cancelled scan (e.g. lint failed) doesn't sit in the queue and
40475
+ * then spawn a child only to tear it down. A queued caller that aborts removes
40476
+ * its own waiter so a later release never hands a slot to a dead request.
40477
+ */
40478
+ const withDeadCodeWorkerSlot = async (task, abortSignal) => {
40479
+ if (abortSignal?.aborted) throw new Error("Dead-code worker aborted.");
40480
+ if (availableSlots < 0) availableSlots = resolveDeadCodeConcurrency();
40481
+ if (availableSlots > 0) availableSlots -= 1;
40482
+ else await new Promise((resolve, reject) => {
40483
+ const waiter = () => {
40484
+ abortSignal?.removeEventListener("abort", onAbort);
40485
+ resolve();
40486
+ };
40487
+ const onAbort = () => {
40488
+ const queuedIndex = waiters.indexOf(waiter);
40489
+ if (queuedIndex !== -1) waiters.splice(queuedIndex, 1);
40490
+ reject(/* @__PURE__ */ new Error("Dead-code worker aborted."));
40491
+ };
40492
+ waiters.push(waiter);
40493
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
40494
+ });
40495
+ try {
40496
+ return await task();
40497
+ } finally {
40498
+ releaseSlot();
40499
+ }
40500
+ };
40399
40501
  /**
40400
40502
  * Resolves a path to its canonical, symlink-free form, falling back to
40401
40503
  * the input when it cannot be realpath'd (broken symlink, permission
@@ -40686,14 +40788,17 @@ const checkDeadCode = async (options) => {
40686
40788
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
40687
40789
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
40688
40790
  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));
40791
+ const spawnAndRun = () => {
40792
+ return runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
40793
+ rootDirectory,
40794
+ entryPatterns,
40795
+ tsConfigPath: resolveTsConfigPath(rootDirectory),
40796
+ ignorePatterns,
40797
+ deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
40798
+ parseConcurrency: options.parseConcurrency
40799
+ }), options.workerTimeoutMs ?? 12e4, options.abortSignal);
40800
+ };
40801
+ const result = parseDeadCodeWorkerResult(options.createWorker === void 0 ? await withDeadCodeWorkerSlot(spawnAndRun, options.abortSignal) : await spawnAndRun());
40697
40802
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
40698
40803
  const diagnostics = [];
40699
40804
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -41094,43 +41199,46 @@ var Git = class Git extends Service()("react-doctor/Git") {
41094
41199
  * reason: GitInvocationFailed })` so the rest of the codebase
41095
41200
  * sees a single failure channel.
41096
41201
  */
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({
41202
+ const runCommand = (input) => {
41203
+ const foldSpawnFailure = (cause) => input.command !== "git" ? succeed$2({
41121
41204
  status: 127,
41122
41205
  stdout: "",
41123
41206
  stderr: String(cause)
41124
- });
41125
- return new ReactDoctorError({ reason: new GitInvocationFailed({
41207
+ }) : fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
41126
41208
  args: [...input.args],
41127
41209
  directory: input.directory,
41128
41210
  cause
41129
- }) });
41130
- }), withSpan("git.exec", { attributes: {
41131
- "git.command": input.command,
41132
- "git.subcommand": input.args[0] ?? ""
41133
- } }));
41211
+ }) }));
41212
+ return scoped(gen(function* () {
41213
+ if (!isDirectory(input.directory)) return yield* foldSpawnFailure(`spawn ENOTDIR (cwd is not a directory: ${input.directory})`);
41214
+ const argvLengthChars = input.command.length + 1 + input.args.reduce((total, arg) => total + arg.length + 1, 0);
41215
+ if (argvLengthChars > 24e3) return yield* foldSpawnFailure(`spawn ENAMETOOLONG (${argvLengthChars} argv chars exceed ${SPAWN_ARGS_MAX_LENGTH_CHARS})`);
41216
+ const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
41217
+ cwd: input.directory,
41218
+ env: input.env,
41219
+ extendEnv: true
41220
+ }));
41221
+ const maxStdoutBytes = input.maxStdoutBytes;
41222
+ const stdoutByteCount = yield* make$13(0);
41223
+ const [stdout, stderr, status] = yield* all([
41224
+ 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({
41225
+ args: [...input.args],
41226
+ directory: input.directory,
41227
+ cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
41228
+ }) })) : void_)))))),
41229
+ mkString(decodeText(handle.stderr)),
41230
+ handle.exitCode
41231
+ ], { concurrency: 3 });
41232
+ return {
41233
+ status,
41234
+ stdout,
41235
+ stderr
41236
+ };
41237
+ })).pipe(catchTag$1("PlatformError", foldSpawnFailure), withSpan("git.exec", { attributes: {
41238
+ "git.command": input.command,
41239
+ "git.subcommand": input.args[0] ?? ""
41240
+ } }));
41241
+ };
41134
41242
  const runGit = (directory, args) => runCommand({
41135
41243
  command: "git",
41136
41244
  args,
@@ -41163,7 +41271,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
41163
41271
  "rev-parse",
41164
41272
  "--verify",
41165
41273
  branch
41166
- ]).pipe(map$3((result) => result.status === 0));
41274
+ ]).pipe(map$3((result) => result.status === 0), catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(false) : fail$4(error)));
41167
41275
  const headSha = (directory) => runGit(directory, ["rev-parse", "HEAD"]).pipe(map$3((result) => result.status === 0 ? trimOrNull(result.stdout) : null));
41168
41276
  const mergeBase = (input) => isSafeGitRevision(input.ref) ? runGit(input.directory, [
41169
41277
  "merge-base",
@@ -41377,7 +41485,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
41377
41485
  ]);
41378
41486
  if (result.status !== 0) return null;
41379
41487
  return parseChangedLineRanges(result.stdout);
41380
- }).pipe(withSpan("Git.changedLineRanges"))
41488
+ }).pipe(catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(null) : fail$4(error)), withSpan("Git.changedLineRanges"))
41381
41489
  });
41382
41490
  })).pipe(provide$2(layer$3.pipe(provide$2(mergeAll$1(layer$2, layer$1)))));
41383
41491
  /**
@@ -43854,7 +43962,10 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
43854
43962
  * diagnostics).
43855
43963
  * 2. beforeLint hook (e.g. CLI renders the project-detection block)
43856
43964
  * 3. environment checks (reduced-motion + pnpm hardening +
43857
- * expo/react-native + security scan), collected synchronously
43965
+ * expo/react-native), collected synchronously. The heavier
43966
+ * content-regex security scan is forked instead (like supply-chain
43967
+ * below) and joined before the concat, so its CPU overlaps lint
43968
+ * rather than blocking the event loop before it.
43858
43969
  * 4. The supply-chain check (Socket.dev) is forked onto a background
43859
43970
  * fiber so its ~100% network-bound time overlaps the ~100%
43860
43971
  * CPU/subprocess-bound lint pass below, collapsing two serial
@@ -43874,7 +43985,7 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
43874
43985
  * order, so terminal output is identical either way; supply-chain
43875
43986
  * rides alongside without a spinner.
43876
43987
  * 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
43988
+ * FIXED order (env, security-scan, supply-chain, lint, dead-code) so the output is
43878
43989
  * byte-identical regardless of which fiber settled first. The
43879
43990
  * viewer-permission fiber is joined later, during score-metadata
43880
43991
  * assembly (it feeds score metadata, not diagnostics). The per-element
@@ -43935,12 +44046,12 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43935
44046
  ...checkPnpmHardening(scanDirectory),
43936
44047
  ...checkReactServerComponentsAdvisory(scanDirectory, project),
43937
44048
  ...checkExpoProject(scanDirectory, project),
43938
- ...checkReactNativeProject(scanDirectory, project),
43939
- ...checkSecurityScan(scanDirectory, {
43940
- project,
43941
- ignoredTags: input.ignoredTags
43942
- })
44049
+ ...checkReactNativeProject(scanDirectory, project)
43943
44050
  ])));
44051
+ const securityScanFiber = yield* forkChild(runCollect(applyPerElementPipeline(isDiffMode ? empty$4 : unwrap(promise(() => checkSecurityScanCooperative(scanDirectory, {
44052
+ project,
44053
+ ignoredTags: input.ignoredTags
44054
+ })).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)))))).pipe(withSpan("SecurityScan.run")));
43944
44055
  const shouldRunSupplyChain = !isDiffMode || (input.supplyChainManifestChanged ?? false);
43945
44056
  const supplyChainOverlapTimeout = yield* SupplyChainOverlapTimeoutMs;
43946
44057
  const supplyChainFiber = yield* forkChild(shouldRunSupplyChain ? runCollect(applyPerElementPipeline(supplyChainService.run({
@@ -44076,9 +44187,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
44076
44187
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
44077
44188
  const supplyChainResult = yield* join(supplyChainFiber);
44078
44189
  const supplyChainCollected = supplyChainResult.diagnostics;
44190
+ const securityScanCollected = yield* join(securityScanFiber);
44079
44191
  yield* reporterService.finalize;
44080
44192
  const finalDiagnostics = sortDiagnosticsStable(assignFixGroups([
44081
44193
  ...envCollected,
44194
+ ...securityScanCollected,
44082
44195
  ...supplyChainCollected,
44083
44196
  ...lintCollected,
44084
44197
  ...deadCodeCollected
@@ -44617,6 +44730,61 @@ const computeConfigFingerprint = (projectDirectory, version) => {
44617
44730
  }
44618
44731
  return crypto.createHash("sha1").update(parts.join("|")).digest("hex");
44619
44732
  };
44733
+ const AI_TRAINING_ENV_VARS = [
44734
+ ["HF_DATASETS_CACHE", "huggingface"],
44735
+ ["HF_HOME", "huggingface"],
44736
+ ["HUGGINGFACE_HUB_CACHE", "huggingface"],
44737
+ ["SPACE_ID", "huggingface-spaces"],
44738
+ ["CUDA_VISIBLE_DEVICES", "cuda"],
44739
+ ["NVIDIA_VISIBLE_DEVICES", "nvidia"],
44740
+ ["ROCR_VISIBLE_DEVICES", "rocm"],
44741
+ ["TPU_NAME", "google-tpu"],
44742
+ ["WANDB_RUN_ID", "wandb"],
44743
+ ["WANDB_SWEEP_ID", "wandb"],
44744
+ ["MLFLOW_RUN_ID", "mlflow"],
44745
+ ["MLFLOW_TRACKING_URI", "mlflow"],
44746
+ ["COMET_EXPERIMENT_KEY", "comet"],
44747
+ ["NEPTUNE_RUN_ID", "neptune"],
44748
+ ["CLEARML_TASK_ID", "clearml"],
44749
+ ["DVC_STAGE", "dvc"],
44750
+ ["RAY_WORKER_PROCESS", "ray"],
44751
+ ["RAY_ADDRESS", "ray"],
44752
+ ["MUJOCO_GL", "mujoco"],
44753
+ ["MUJOCO_PATH", "mujoco"],
44754
+ ["GYM_DISABLE_ENV_CHECKER", "gymnasium"],
44755
+ ["SM_TRAINING_ENV", "sagemaker"],
44756
+ ["TRAINING_JOB_ARN", "sagemaker"],
44757
+ ["SAGEMAKER_BASE_DIR", "sagemaker"],
44758
+ ["AZUREML_RUN_ID", "azure-ml"],
44759
+ ["AZURE_ML_MODEL_DIR", "azure-ml"],
44760
+ ["CLOUD_ML_PROJECT_ID", "vertex-ai"],
44761
+ ["VERTEX_AI_LOG_LEVEL", "vertex-ai"],
44762
+ ["DET_MASTER", "determined-ai"],
44763
+ ["LIGHTNING_USER_ID", "lightning-ai"],
44764
+ ["FLYTE_INTERNAL_EXECUTION_ID", "flyte"],
44765
+ ["ARGO_WORKFLOW_NAME", "argo-workflows"],
44766
+ ["KFP_POD_NAME", "kubeflow-pipelines"],
44767
+ ["KAGGLE_KERNEL_RUN_TYPE", "kaggle"],
44768
+ ["COLAB_BACKEND_VERSION", "google-colab"],
44769
+ ["DATABRICKS_RUNTIME_VERSION", "databricks"],
44770
+ ["DAYTONA_WS_ID", "daytona"],
44771
+ ["DAYTONA_WS_NAME", "daytona"],
44772
+ ["E2B_SANDBOX_ID", "e2b"],
44773
+ ["MODAL_FUNCTION_ID", "modal"],
44774
+ ["MODAL_TASK_ID", "modal"],
44775
+ ["RUNPOD_POD_ID", "runpod"],
44776
+ ["REPLICATE_USERNAME", "replicate"],
44777
+ ["VAST_CONTAINERLABEL", "vast-ai"],
44778
+ ["HARBOR_URL", "harbor"],
44779
+ ["HARBOR_HOSTNAME", "harbor"],
44780
+ ["SWE_BENCH_TASK", "swe-bench"],
44781
+ ["SWEBENCH_TASK", "swe-bench"],
44782
+ ["SWE_AGENT_MODEL", "swe-agent"]
44783
+ ];
44784
+ const detectAiTrainingEnvironment = () => {
44785
+ for (const [envVar, label] of AI_TRAINING_ENV_VARS) if (process.env[envVar]) return label;
44786
+ return null;
44787
+ };
44620
44788
  const groupBy = (items, keyFn) => {
44621
44789
  const groups = /* @__PURE__ */ new Map();
44622
44790
  for (const item of items) {
@@ -44702,7 +44870,25 @@ const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
44702
44870
  ["TEAMCITY_VERSION", "teamcity"],
44703
44871
  ["BITBUCKET_BUILD_NUMBER", "bitbucket"],
44704
44872
  ["TRAVIS", "travis"],
44705
- ["DRONE", "drone"]
44873
+ ["DRONE", "drone"],
44874
+ ["SEMAPHORE", "semaphore"],
44875
+ ["APPVEYOR", "appveyor"],
44876
+ ["HARNESS_BUILD_ID", "harness"],
44877
+ ["BUDDY", "buddy"],
44878
+ ["CF_BUILD_ID", "codefresh"],
44879
+ ["NETLIFY", "netlify"],
44880
+ ["RAILWAY_SERVICE_ID", "railway"],
44881
+ ["VERCEL", "vercel"],
44882
+ ["CM_BUILD_ID", "codemagic"],
44883
+ ["PROW_JOB_ID", "prow"],
44884
+ ["AGOLA_GIT_REF", "agola"],
44885
+ ["CIRRUS_CI", "cirrus-ci"],
44886
+ ["BLACKSMITH_STICKYDISK_TOKEN", "blacksmith"],
44887
+ ["WARPBUILD_RUNNER_VERIFICATION_TOKEN", "warpbuild"],
44888
+ ["NSC_CACHE_PATH", "namespace"],
44889
+ ["UBICLOUD_CACHE_URL", "ubicloud"],
44890
+ ["RENDER", "render"],
44891
+ ["FLY_APP_NAME", "fly-io"]
44706
44892
  ];
44707
44893
  const GITHUB_ACTION_MARKER_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_GITHUB_ACTION";
44708
44894
  const ACTION_INPUT_ENVIRONMENT_VARIABLES = {
@@ -44720,7 +44906,10 @@ const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
44720
44906
  ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
44721
44907
  ["OPENCODE", "opencode"],
44722
44908
  ["GOOSE_TERMINAL", "goose"],
44723
- ["AMP_THREAD_ID", "amp"]
44909
+ ["AMP_THREAD_ID", "amp"],
44910
+ ["CLINE_ACTIVE", "cline"],
44911
+ ["AUGMENT_AGENT", "augment"],
44912
+ ["TRAE_AI_SHELL_ID", "trae-ai"]
44724
44913
  ];
44725
44914
  const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
44726
44915
  const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
@@ -44839,6 +45028,7 @@ const NANOSECONDS_PER_SECOND = 1000000000n;
44839
45028
  const METRIC = {
44840
45029
  cliInvoked: "cli.invoked",
44841
45030
  cliError: "cli.error",
45031
+ cliEnvironmentError: "cli.env_error",
44842
45032
  projectDetected: "project.detected",
44843
45033
  projectPathSelected: "project.path_selected",
44844
45034
  projectConfigSelected: "project.config_selected",
@@ -44868,7 +45058,9 @@ const METRIC = {
44868
45058
  lspSessionStarted: "lsp.session.started",
44869
45059
  lspScanCompleted: "lsp.scan.completed",
44870
45060
  lspScanDuration: "lsp.scan.duration",
44871
- lspScanDiagnostics: "lsp.scan.diagnostics"
45061
+ lspScanDiagnostics: "lsp.scan.diagnostics",
45062
+ aiTrainingWarningShown: "ai.training.warning_shown",
45063
+ jsonOutUsed: "json.out_used"
44872
45064
  };
44873
45065
  //#endregion
44874
45066
  //#region src/cli/utils/noop-console.ts
@@ -44911,7 +45103,7 @@ const makeNoopConsole = () => ({
44911
45103
  });
44912
45104
  //#endregion
44913
45105
  //#region src/cli/utils/version.ts
44914
- const VERSION = "0.5.8-dev.f4e8e4b";
45106
+ const VERSION = "0.5.8-dev.f69f216";
44915
45107
  //#endregion
44916
45108
  //#region src/cli/utils/json-mode.ts
44917
45109
  let context = null;
@@ -44949,12 +45141,13 @@ const installSilentConsole = () => {
44949
45141
  "trace"
44950
45142
  ]) target[key] = source[key];
44951
45143
  };
44952
- const enableJsonMode = ({ compact, directory }) => {
45144
+ const enableJsonMode = ({ compact, directory, outputFile }) => {
44953
45145
  context = {
44954
45146
  compact,
44955
45147
  directory,
44956
45148
  startTime: performance$1.now(),
44957
- mode: "full"
45149
+ mode: "full",
45150
+ outputFile: outputFile ?? null
44958
45151
  };
44959
45152
  installSilentConsole();
44960
45153
  };
@@ -44967,7 +45160,11 @@ const setJsonReportMode = (mode) => {
44967
45160
  };
44968
45161
  const writeJsonReport = (report) => {
44969
45162
  const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
44970
- process.stdout.write(`${serialized}\n`);
45163
+ if (context?.outputFile) {
45164
+ const resolvedPath = Path.resolve(context.outputFile);
45165
+ NFS.mkdirSync(Path.dirname(resolvedPath), { recursive: true });
45166
+ NFS.writeFileSync(resolvedPath, `${serialized}\n`);
45167
+ } else process.stdout.write(`${serialized}\n`);
44971
45168
  };
44972
45169
  const writeJsonErrorReport = (error, sentryEventId) => {
44973
45170
  if (!context) return;
@@ -45275,13 +45472,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
45275
45472
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
45276
45473
  * standard `SENTRY_RELEASE` override.
45277
45474
  */
45278
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.8-dev.f4e8e4b`;
45475
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.8-dev.f69f216`;
45279
45476
  /**
45280
45477
  * Deployment environment shown in Sentry's environment filter. Defaults to
45281
45478
  * `production` for tagged releases and `development` for dev/unbuilt versions,
45282
45479
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
45283
45480
  */
45284
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.8-dev.f4e8e4b") ? "development" : "production");
45481
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.8-dev.f69f216") ? "development" : "production");
45285
45482
  /**
45286
45483
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
45287
45484
  * (set to `0` to disable tracing) and falls back to
@@ -48877,6 +49074,25 @@ const shouldBlockCi = (diagnostics, blockingLevel) => {
48877
49074
  */
48878
49075
  const toCategoryKey = (category) => category.toLowerCase().replace(/[^a-z0-9]+/g, "_");
48879
49076
  //#endregion
49077
+ //#region src/cli/utils/with-namespace.ts
49078
+ /**
49079
+ * Prefixes every key in a flat attribute group with `namespace.`, so a logical
49080
+ * group (scan config, diagnostics rollup, score, …) carries one dotted
49081
+ * namespace applied in a single place rather than hand-spelled into each key —
49082
+ * which is how the wide event drifted into a flat, half-namespaced soup. The
49083
+ * dotted prefix is what makes the attributes tree up in Sentry's attribute
49084
+ * browser and stay group-/filter-/aggregate-able in the Spans dataset.
49085
+ *
49086
+ * Value types are preserved verbatim (numbers stay numbers so Sentry infers a
49087
+ * numeric attribute and `p75(...)`/`avg(...)` work; `null` is kept so
49088
+ * `toSpanAttributes` can drop absent signals rather than coerce them).
49089
+ */
49090
+ const withNamespace = (namespace, attributes) => {
49091
+ const namespaced = {};
49092
+ for (const [key, value] of Object.entries(attributes)) namespaced[`${namespace}.${key}`] = value;
49093
+ return namespaced;
49094
+ };
49095
+ //#endregion
48880
49096
  //#region src/cli/utils/build-run-event.ts
48881
49097
  const readEnvBoolean = (name) => {
48882
49098
  const value = process.env[name];
@@ -48898,12 +49114,12 @@ const buildOutcomeAttributes = (input) => {
48898
49114
  if (input.result === void 0) {
48899
49115
  const error = input.error;
48900
49116
  const known = isReactDoctorError(error);
48901
- return {
48902
- outcome: "error",
49117
+ return withNamespace("outcome", {
49118
+ status: "error",
48903
49119
  exitCode: 1,
48904
49120
  knownError: known,
48905
49121
  errorTag: known ? error.reason._tag : error instanceof Error ? error.name : null
48906
- };
49122
+ });
48907
49123
  }
48908
49124
  const result = input.result;
48909
49125
  const summary = summarizeDiagnostics(result.diagnostics);
@@ -48937,66 +49153,82 @@ const buildOutcomeAttributes = (input) => {
48937
49153
  }
48938
49154
  let fixGroupedFindings = 0;
48939
49155
  for (const count of findingsPerFixGroup.values()) fixGroupedFindings += count;
49156
+ const categoryRollup = {};
49157
+ for (const [category, count] of countByCategory) categoryRollup[`category.${toCategoryKey(category)}`] = count;
48940
49158
  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;
49159
+ ...withNamespace("outcome", {
49160
+ status: outcome,
49161
+ exitCode: wouldBlock ? 1 : 0,
49162
+ wouldBlock,
49163
+ blocking: blockingLevel,
49164
+ clean: isClean,
49165
+ skippedChecks: result.skippedChecks.length
49166
+ }),
49167
+ ...withNamespace("diag", {
49168
+ total: summary.totalDiagnosticCount,
49169
+ errors: summary.errorCount,
49170
+ warnings: summary.warningCount,
49171
+ affectedFiles: summary.affectedFileCount,
49172
+ inTestFiles: diagnosticsInTestFiles,
49173
+ inStoryFiles: diagnosticsInStoryFiles,
49174
+ distinctRules: countByRule.size,
49175
+ topRule,
49176
+ fixGroups: findingsPerFixGroup.size,
49177
+ fixGroupedFindings,
49178
+ ...categoryRollup
49179
+ }),
49180
+ ...withNamespace("score", {
49181
+ value: result.score ? result.score.score : null,
49182
+ label: result.score ? result.score.label : null,
49183
+ available: result.score !== null
49184
+ }),
49185
+ ...withNamespace("lint", {
49186
+ failed: input.didLintFail ?? null,
49187
+ failureReasonKind: input.lintFailureReasonKind ?? null,
49188
+ partialFailureCount: input.lintPartialFailureCount ?? null,
49189
+ droppedFileCount: input.lintDroppedFileCount ?? null,
49190
+ cacheHitFiles: result.lintCacheHitFileCount ?? null,
49191
+ cacheTotalFiles: result.lintCacheTotalFileCount ?? null,
49192
+ cacheHitRatio: result.lintCacheTotalFileCount != null && result.lintCacheTotalFileCount > 0 ? (result.lintCacheHitFileCount ?? 0) / result.lintCacheTotalFileCount : null
49193
+ }),
49194
+ ...withNamespace("deadCode", {
49195
+ failed: input.didDeadCodeFail ?? null,
49196
+ overlapped: input.deadCodeOverlapped ?? null
49197
+ }),
49198
+ ...withNamespace("supplyChain", { overlapTimedOut: input.supplyChainOverlapTimedOut ?? null }),
49199
+ ...withNamespace("timing", {
49200
+ elapsedMs: result.elapsedMilliseconds,
49201
+ scanMs: result.scanElapsedMilliseconds ?? null
49202
+ }),
49203
+ ...withNamespace("migration", {
49204
+ largestRuleBucketFiles: largestRuleBucket ? largestRuleBucket.fileCount : null,
49205
+ largestRuleBucketSites: largestRuleBucket ? largestRuleBucket.siteCount : null,
49206
+ largestRuleBucketRule: largestRuleBucket ? largestRuleBucket.ruleKey : null
49207
+ })
49208
+ };
49209
+ if (result.baselineDelta) Object.assign(attributes, withNamespace("baseline", {
49210
+ new: summary.totalDiagnosticCount,
49211
+ fixed: result.baselineDelta.fixedCount,
49212
+ baseTotal: result.baselineDelta.baseTotalCount,
49213
+ degraded: false
49214
+ }));
49215
+ else if (input.gateExempt) attributes["baseline.degraded"] = true;
48984
49216
  return attributes;
48985
49217
  };
48986
- const buildCiAttributes = () => {
49218
+ const buildActionAttributes = () => {
48987
49219
  const { githubActorAssociation } = resolveGithubActionsScoreMetadata();
48988
- return {
49220
+ return withNamespace("action", {
48989
49221
  actorAssociation: githubActorAssociation ?? null,
48990
49222
  runnerOs: detectRunnerOs(),
48991
49223
  comment: readEnvBoolean(ACTION_INPUT_ENVIRONMENT_VARIABLES.comment),
48992
49224
  reviewComments: readEnvBoolean(ACTION_INPUT_ENVIRONMENT_VARIABLES.reviewComments),
48993
49225
  versionPin: resolveVersionPin(process.env[ACTION_INPUT_ENVIRONMENT_VARIABLES.version])
48994
- };
49226
+ });
48995
49227
  };
48996
- const buildConfigAttributes = (input) => {
49228
+ const buildScanAttributes = (input) => {
48997
49229
  const ruleOverrides = input.userConfig?.rules ?? {};
48998
49230
  const ruleKeys = Object.keys(ruleOverrides);
48999
- return {
49231
+ return withNamespace("scan", {
49000
49232
  mode: input.mode,
49001
49233
  scope: input.scope,
49002
49234
  parallel: input.parallel,
@@ -49011,12 +49243,18 @@ const buildConfigAttributes = (input) => {
49011
49243
  ignoredTagCount: input.ignoredTagCount,
49012
49244
  hasCustomConfig: input.hasCustomConfig,
49013
49245
  rulesConfigured: ruleKeys.length,
49014
- rulesDisabled: ruleKeys.filter((key) => ruleOverrides[key] === "off").length
49015
- };
49246
+ rulesDisabled: ruleKeys.filter((key) => ruleOverrides[key] === "off").length,
49247
+ fileCount: input.result?.scannedFileCount ?? null
49248
+ });
49016
49249
  };
49017
49250
  /**
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
49251
+ * Projects a scan into the namespaced attribute set for its root span — the
49252
+ * canonical per-scan "wide event". Every attribute carries a dotted namespace
49253
+ * that groups it by concept (`scan.*` config, `action.*` CI knobs, `outcome.*`
49254
+ * verdict, `diag.*` findings, `score.*`, `lint.*`, `deadCode.*`,
49255
+ * `supplyChain.*`, `timing.*`, `migration.*`, `baseline.*`) so the attributes
49256
+ * tree up in Sentry's attribute browser and stay filter-/group-/aggregate-able
49257
+ * in the Spans dataset. Pure and exported so the projection (outcome
49020
49258
  * precedence, rule/category rollups, CI knobs, config shape) is unit-testable
49021
49259
  * without a live Sentry client. `null` values are dropped so absent signals
49022
49260
  * never become misleading `"null"` attributes. The run + project base context
@@ -49025,8 +49263,8 @@ const buildConfigAttributes = (input) => {
49025
49263
  * those don't carry.
49026
49264
  */
49027
49265
  const buildRunEventAttributes = (input) => toSpanAttributes({
49028
- ...buildConfigAttributes(input),
49029
- ...buildCiAttributes(),
49266
+ ...buildScanAttributes(input),
49267
+ ...buildActionAttributes(),
49030
49268
  ...buildOutcomeAttributes(input)
49031
49269
  });
49032
49270
  /**
@@ -49689,6 +49927,8 @@ const ONBOARDING_EVENT = "onboarding";
49689
49927
  const CI_PITCH_EVENT = "ci-pitch";
49690
49928
  const ACTION_UPGRADE_EVENT = "action-upgrade-v2";
49691
49929
  const SETUP_HINT_EVENT = "setup-hint";
49930
+ const HANDOFF_TARGET_PREFERENCE_ID = "handoff-target";
49931
+ const INSTALL_AGENTS_PREFERENCE_ID = "install-agents";
49692
49932
  const foldLegacyDecisions = (projects, legacy, eventId) => {
49693
49933
  for (const [hash, record] of Object.entries(legacy ?? {})) {
49694
49934
  const existing = projects[hash] ?? { rootDirectory: record.rootDirectory ?? "" };
@@ -49807,6 +50047,14 @@ const recordGate = (gate, target = {}, options = {}) => updateCliState((state) =
49807
50047
  }
49808
50048
  }
49809
50049
  })), options);
50050
+ const readPreference = (preference, target = {}, options = {}) => readCliState((state) => selectScope(state, preference, target.projectRoot)?.preferences?.[preference.id] ?? null, null, options);
50051
+ const writePreference = (preference, value, target = {}, options = {}) => updateCliState((state) => updateScope(state, preference, target.projectRoot, (scope) => ({
50052
+ ...scope,
50053
+ preferences: {
50054
+ ...scope.preferences,
50055
+ [preference.id]: value
50056
+ }
50057
+ })), options);
49810
50058
  const isMigrationPending = (migration, target = {}, options = {}) => {
49811
50059
  if (migration.scope === "project" && target.projectRoot === void 0) return false;
49812
50060
  return readCliState((state) => {
@@ -50344,7 +50592,7 @@ const readPackageJson = (projectRoot) => {
50344
50592
  return null;
50345
50593
  }
50346
50594
  };
50347
- const writeJsonFile$1 = (filePath, value) => {
50595
+ const writeJsonFile = (filePath, value) => {
50348
50596
  NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
50349
50597
  };
50350
50598
  const packageHasDependency = (projectRoot, dependencyName) => {
@@ -51064,7 +51312,8 @@ const getStagedSourceFiles = async (directory) => {
51064
51312
  return [...await runPromise(gen(function* () {
51065
51313
  return yield* (yield* StagedFiles).discoverSourceFiles(directory);
51066
51314
  }).pipe(provide(stagedFilesLayer)))];
51067
- } catch {
51315
+ } catch (error) {
51316
+ cliLogger.warn(`Failed to discover staged files: ${error instanceof Error ? error.message : String(error)}`);
51068
51317
  return [];
51069
51318
  }
51070
51319
  };
@@ -51083,6 +51332,35 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
51083
51332
  };
51084
51333
  };
51085
51334
  //#endregion
51335
+ //#region src/cli/utils/is-environment-error.ts
51336
+ const isNodeSystemError = (error) => error instanceof Error && typeof error.code === "string";
51337
+ const ENVIRONMENT_ERROR_CODES = new Set([
51338
+ "ENOSPC",
51339
+ "EIO",
51340
+ "EROFS",
51341
+ "EACCES",
51342
+ "EPERM",
51343
+ "ENOTDIR"
51344
+ ]);
51345
+ const isEnvironmentError = (error) => {
51346
+ if (!isNodeSystemError(error)) return false;
51347
+ if (error.code === "ENOENT") return error.syscall?.startsWith("spawn") ?? false;
51348
+ return error.code !== void 0 && ENVIRONMENT_ERROR_CODES.has(error.code);
51349
+ };
51350
+ const formatEnvironmentError = (error) => {
51351
+ if (!isNodeSystemError(error)) return error instanceof Error ? error.message : String(error);
51352
+ switch (error.code) {
51353
+ case "ENOSPC": return "No space left on device. Free up disk space and try again.";
51354
+ case "EIO": return "I/O error: the filesystem or disk may be failing. Check your system logs.";
51355
+ case "EROFS": return "Read-only filesystem: cannot write to this location.";
51356
+ case "EACCES":
51357
+ case "EPERM": return error.path ? `Permission denied accessing ${error.path}. Check file permissions and try again.` : "Permission denied. Check file permissions and try again.";
51358
+ 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.";
51359
+ case "ENOENT": return "Required command not found. Ensure the tool (e.g. git) is installed and on your PATH.";
51360
+ default: return error.message;
51361
+ }
51362
+ };
51363
+ //#endregion
51086
51364
  //#region src/cli/utils/handle-error.ts
51087
51365
  const OTLP_ENDPOINT_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_ENDPOINT";
51088
51366
  const OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_AUTH_HEADER";
@@ -51165,15 +51443,19 @@ const handleError = (error, options = {}) => {
51165
51443
  process.exitCode = 1;
51166
51444
  };
51167
51445
  /**
51168
- * Renderer for expected, user-actionable failures — a bad `--diff` value or
51169
- * a base branch that isn't fetched. Prints just the (already human-readable)
51170
- * message — no "Something went wrong", prefilled issue, Discord link, or
51171
- * Sentry reference — because there is no bug to report.
51446
+ * Renderer for expected, user-actionable failures — a bad `--diff` value,
51447
+ * a base branch that isn't fetched, or environment errors like disk-full or
51448
+ * permission-denied. Prints just the (already human-readable) message — no
51449
+ * "Something went wrong", prefilled issue, Discord link, or Sentry reference
51450
+ * — because there is no bug to report.
51172
51451
  */
51173
51452
  const handleUserError = (error, options = {}) => {
51453
+ const isEnvError = isEnvironmentError(error);
51454
+ if (isEnvError) recordCount(METRIC.cliEnvironmentError, 1, { code: error.code ?? "unknown" });
51455
+ const message = isEnvError ? formatEnvironmentError(error) : formatErrorForReport(error);
51174
51456
  runSync(gen(function* () {
51175
51457
  yield* error$1("");
51176
- yield* error$1(highlighter.error(formatErrorForReport(error)));
51458
+ yield* error$1(highlighter.error(message));
51177
51459
  yield* error$1("");
51178
51460
  }));
51179
51461
  if (options.shouldExit !== false) process.exit(1);
@@ -51188,7 +51470,7 @@ const handleUserError = (error, options = {}) => {
51188
51470
  * `handleUserError` (a plain message — no "Something went wrong", prefilled
51189
51471
  * issue, Discord link, or Sentry reference), since there is no bug to report.
51190
51472
  *
51191
- * Three distinct shapes reach the CLI's catch blocks:
51473
+ * Four distinct shapes reach the CLI's catch blocks:
51192
51474
  *
51193
51475
  * - **Project-discovery failures** (`NoReactDependencyError`,
51194
51476
  * `ProjectNotFoundError`, `PackageJsonNotFoundError`, `NotADirectoryError`,
@@ -51201,12 +51483,19 @@ const handleUserError = (error, options = {}) => {
51201
51483
  * `--project` name.
51202
51484
  * - **Bad `--diff` input** (`GitBaseBranchInvalid` / `GitBaseBranchMissing`)
51203
51485
  * stays the tagged `ReactDoctorError`, so dispatch on the reason `_tag`.
51486
+ * - **Environment failures** (`ENOSPC`, `EIO`, `EROFS`, `EACCES`, `EPERM`,
51487
+ * `ENOTDIR`, plus a `spawn`-scoped `ENOENT` for a missing binary) — disk
51488
+ * full / failing / read-only, permission denied, or a path blocked by a
51489
+ * file. React Doctor cannot fix the user's environment; exit cleanly with an
51490
+ * actionable message instead of crashing. See `is-environment-error.ts` for
51491
+ * why the set stays narrow (codes that usually mean our bug keep reaching
51492
+ * Sentry).
51204
51493
  *
51205
51494
  * This composes the existing core narrowers rather than introducing a new
51206
51495
  * error-shape helper (AGENTS.md): it encodes CLI-layer reporting policy, not
51207
51496
  * knowledge of the `ReactDoctorError` shape.
51208
51497
  */
51209
- const isExpectedUserError = (error) => error instanceof CliInputError || isProjectDiscoveryError(error) || isReactDoctorError(error) && (error.reason._tag === "GitBaseBranchInvalid" || error.reason._tag === "GitBaseBranchMissing");
51498
+ const isExpectedUserError = (error) => error instanceof CliInputError || isProjectDiscoveryError(error) || isEnvironmentError(error) || isReactDoctorError(error) && (error.reason._tag === "GitBaseBranchInvalid" || error.reason._tag === "GitBaseBranchMissing");
51210
51499
  //#endregion
51211
51500
  //#region src/cli/utils/build-handoff-payload.ts
51212
51501
  const buildHandoffPayload = (input) => {
@@ -51287,6 +51576,20 @@ const detectAvailableAgents = async () => {
51287
51576
  const detected = new Set([...detectPathAvailableAgents(), ...await detectInstalledSkillAgents()]);
51288
51577
  return getSkillAgentTypes().filter((agent) => agent !== "universal" && detected.has(agent));
51289
51578
  };
51579
+ const DEFAULT_INSTALL_AGENTS = [
51580
+ "claude-code",
51581
+ "cursor",
51582
+ "codex",
51583
+ "opencode"
51584
+ ];
51585
+ const computeDefaultSelectedAgents = (detectedAgents, rememberedAgents) => {
51586
+ const detected = new Set(detectedAgents);
51587
+ const remembered = rememberedAgents.filter((agent) => detected.has(agent));
51588
+ if (remembered.length > 0) return remembered;
51589
+ const defaults = DEFAULT_INSTALL_AGENTS.filter((agent) => detected.has(agent));
51590
+ if (defaults.length > 0) return defaults;
51591
+ return detectedAgents.length === 1 ? [...detectedAgents] : [];
51592
+ };
51290
51593
  //#endregion
51291
51594
  //#region src/cli/utils/install-doctor-script.ts
51292
51595
  const DOCTOR_SCRIPT_NAME = "doctor";
@@ -51368,7 +51671,7 @@ const installDoctorScript = (options) => {
51368
51671
  };
51369
51672
  })();
51370
51673
  const scriptStatus = scriptTarget.status;
51371
- if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
51674
+ if (scriptStatus === "created") writeJsonFile(packageJsonPath, {
51372
51675
  ...packageJson,
51373
51676
  scripts: {
51374
51677
  ...isRecord$1(scripts) ? scripts : {},
@@ -51544,6 +51847,14 @@ const recordCiPromptDecision = (projectRoot, outcome, options = {}) => recordGat
51544
51847
  outcome
51545
51848
  }, options);
51546
51849
  //#endregion
51850
+ //#region src/cli/utils/handoff-target-preference.ts
51851
+ const HANDOFF_TARGET_PREFERENCE = {
51852
+ id: HANDOFF_TARGET_PREFERENCE_ID,
51853
+ scope: "global"
51854
+ };
51855
+ const readHandoffTarget = (options = {}) => readPreference(HANDOFF_TARGET_PREFERENCE, {}, options);
51856
+ const rememberHandoffTarget = (target, options = {}) => writePreference(HANDOFF_TARGET_PREFERENCE, target, {}, options);
51857
+ //#endregion
51547
51858
  //#region src/cli/utils/open-url.ts
51548
51859
  const resolveOpenCommand = (url) => {
51549
51860
  if (process$1.platform === "darwin") return {
@@ -51931,6 +52242,19 @@ const setUpGitHubActions = async (options) => {
51931
52242
  return didCreateWorkflow;
51932
52243
  };
51933
52244
  //#endregion
52245
+ //#region src/cli/utils/install-agents-preference.ts
52246
+ const INSTALL_AGENTS_PREFERENCE = {
52247
+ id: INSTALL_AGENTS_PREFERENCE_ID,
52248
+ scope: "global"
52249
+ };
52250
+ const PREFERENCE_SEPARATOR = ",";
52251
+ const readInstallAgents = (options = {}) => {
52252
+ const stored = readPreference(INSTALL_AGENTS_PREFERENCE, {}, options);
52253
+ if (stored === null) return [];
52254
+ return stored.split(PREFERENCE_SEPARATOR).map((entry) => entry.trim()).filter((entry) => isSkillAgentType(entry));
52255
+ };
52256
+ const rememberInstallAgents = (agents, options = {}) => writePreference(INSTALL_AGENTS_PREFERENCE, agents.join(PREFERENCE_SEPARATOR), {}, options);
52257
+ //#endregion
51934
52258
  //#region src/cli/utils/install-agent-hooks.ts
51935
52259
  const CLAUDE_AGENT = "claude-code";
51936
52260
  const CURSOR_AGENT = "cursor";
@@ -51941,20 +52265,34 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
51941
52265
  const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
51942
52266
  const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
51943
52267
  const CURSOR_HOOKS_SCHEMA_VERSION = 1;
51944
- const JSON_INDENT_SPACES$1 = 2;
51945
52268
  const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
51946
52269
  const readJsonFile = (filePath, fallback) => {
51947
52270
  if (!NFS.existsSync(filePath)) return fallback;
51948
52271
  const content = NFS.readFileSync(filePath, "utf8").trim();
51949
52272
  if (content.length === 0) return fallback;
51950
- return JSON.parse(content);
52273
+ try {
52274
+ return JSON.parse(content);
52275
+ } catch (error) {
52276
+ 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.`);
52277
+ throw error;
52278
+ }
51951
52279
  };
51952
- const writeJsonFile = (filePath, value) => {
51953
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
51954
- NFS.writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
52280
+ const ensureDirectoryExists = (directoryPath) => {
52281
+ try {
52282
+ NFS.mkdirSync(directoryPath, { recursive: true });
52283
+ } catch (error) {
52284
+ const code = error.code;
52285
+ 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.`);
52286
+ 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.`);
52287
+ throw error;
52288
+ }
52289
+ };
52290
+ const writeJsonFileWithDirectoryCheck = (filePath, value) => {
52291
+ ensureDirectoryExists(Path.dirname(filePath));
52292
+ writeJsonFile(filePath, value);
51955
52293
  };
51956
52294
  const writeHookScript = (filePath) => {
51957
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
52295
+ ensureDirectoryExists(Path.dirname(filePath));
51958
52296
  NFS.writeFileSync(filePath, buildAgentHookScript());
51959
52297
  NFS.chmodSync(filePath, 493);
51960
52298
  };
@@ -51970,7 +52308,7 @@ const installClaudeHook = (projectRoot) => {
51970
52308
  command: CLAUDE_HOOK_COMMAND
51971
52309
  }] });
51972
52310
  hooks.PostToolBatch = postToolBatchHooks;
51973
- writeJsonFile(settingsPath, {
52311
+ writeJsonFileWithDirectoryCheck(settingsPath, {
51974
52312
  ...settings,
51975
52313
  hooks
51976
52314
  });
@@ -51990,7 +52328,7 @@ const installCursorHook = (projectRoot) => {
51990
52328
  timeout: 120
51991
52329
  });
51992
52330
  hooks.postToolUse = postToolUseHooks;
51993
- writeJsonFile(configPath, {
52331
+ writeJsonFileWithDirectoryCheck(configPath, {
51994
52332
  ...config,
51995
52333
  version: config.version ?? CURSOR_HOOKS_SCHEMA_VERSION,
51996
52334
  hooks
@@ -52226,7 +52564,7 @@ const installPackageJsonHook = (options, strategy) => {
52226
52564
  parent = cloned;
52227
52565
  }
52228
52566
  parent[leafKey] = strategy.leafShape === "array" ? appendArrayCommand(parent[leafKey]) : appendStringCommand(parent[leafKey]);
52229
- writeJsonFile$1(packageJsonPath, nextPackageJson);
52567
+ writeJsonFile(packageJsonPath, nextPackageJson);
52230
52568
  removeLegacyManagedRunner(options.projectRoot);
52231
52569
  return {
52232
52570
  hookPath: packageJsonPath,
@@ -52809,6 +53147,9 @@ const runInstallReactDoctor = async (options = {}) => {
52809
53147
  const shouldUpgradeWorkflow = canUpgradeWorkflow && (Boolean(options.yes) || upgradePromptOutcome === "yes");
52810
53148
  if (upgradePromptOutcome === "no" && !options.dryRun) recordActionUpgradeDecision(projectRoot, "declined");
52811
53149
  if ((ciPromptOutcome === "yes" || ciPromptOutcome === "no") && !options.dryRun) recordCiPromptDecision(projectRoot, ciPromptOutcome === "yes" ? "accepted" : "declined");
53150
+ const rememberedAgents = options.lastSelectedAgents ?? readInstallAgents();
53151
+ const defaultSelectedAgents = computeDefaultSelectedAgents(detectedAgents, rememberedAgents);
53152
+ const usedRememberedAgents = rememberedAgents.some((agent) => detectedAgents.includes(agent));
52812
53153
  const selectedAgents = skipPrompts ? detectedAgents : (await prompt({
52813
53154
  type: "multiselect",
52814
53155
  name: "agents",
@@ -52816,12 +53157,13 @@ const runInstallReactDoctor = async (options = {}) => {
52816
53157
  choices: detectedAgents.map((agent) => ({
52817
53158
  title: getSkillAgentConfig(agent).displayName,
52818
53159
  value: agent,
52819
- selected: true
53160
+ selected: defaultSelectedAgents.includes(agent)
52820
53161
  })),
52821
53162
  instructions: false,
52822
53163
  min: 1
52823
53164
  }, promptOptions)).agents ?? [];
52824
53165
  if (selectedAgents.length === 0) return;
53166
+ if (!skipPrompts && !options.dryRun) rememberInstallAgents(selectedAgents);
52825
53167
  let dependencyResult;
52826
53168
  if (!options.dryRun) {
52827
53169
  await installReactDoctorSkillStep(sourceDir, selectedAgents, projectRoot);
@@ -52880,6 +53222,8 @@ const runInstallReactDoctor = async (options = {}) => {
52880
53222
  }
52881
53223
  recordCount(METRIC.installCompleted, 1, {
52882
53224
  agentsCount: selectedAgents.length,
53225
+ agentsDetected: detectedAgents.length,
53226
+ usedRememberedAgents,
52883
53227
  gitHook: shouldInstallGitHook,
52884
53228
  agentHooks: shouldInstallAgentHooks,
52885
53229
  workflow: didInstallWorkflow,
@@ -52914,6 +53258,26 @@ const CLI_AGENT_AUTO_FLAGS = {
52914
53258
  codex: ["--yolo"],
52915
53259
  cursor: ["--force"]
52916
53260
  };
53261
+ const resolveCursorBundledNode = () => {
53262
+ if (!isWindows) return null;
53263
+ const localAppData = process.env.LOCALAPPDATA;
53264
+ if (!localAppData) return null;
53265
+ const cursorAgentRoot = Path.join(localAppData, "cursor-agent", "versions");
53266
+ try {
53267
+ const versions = NFS.readdirSync(cursorAgentRoot);
53268
+ if (versions.length === 0) return null;
53269
+ const sortedVersions = versions.sort((a, b) => b.localeCompare(a, void 0, { numeric: true }));
53270
+ for (const version of sortedVersions) try {
53271
+ const bundledNodePath = Path.join(cursorAgentRoot, version, "node.exe");
53272
+ const entryScriptPath = Path.join(cursorAgentRoot, version, "index.js");
53273
+ if (NFS.statSync(bundledNodePath).isFile() && NFS.statSync(entryScriptPath).isFile()) return {
53274
+ command: bundledNodePath,
53275
+ args: [entryScriptPath]
53276
+ };
53277
+ } catch {}
53278
+ } catch {}
53279
+ return null;
53280
+ };
52917
53281
  const resolveWindowsCmdEntryScript = (command) => {
52918
53282
  if (!isWindows) return null;
52919
53283
  const pathDirectories = (process.env.PATH ?? "").split(Path.delimiter).filter(Boolean);
@@ -52933,11 +53297,10 @@ const resolveWindowsCmdEntryScript = (command) => {
52933
53297
  }
52934
53298
  return null;
52935
53299
  };
52936
- const spawnAgent = (command, args, cwd, shell = false) => new Promise((resolve, reject) => {
53300
+ const spawnAgent = (command, args, cwd) => new Promise((resolve, reject) => {
52937
53301
  const child = spawn(command, [...args], {
52938
53302
  cwd,
52939
- stdio: "inherit",
52940
- shell
53303
+ stdio: "inherit"
52941
53304
  });
52942
53305
  child.on("error", reject);
52943
53306
  child.on("close", (code) => resolve(code ?? 0));
@@ -52946,6 +53309,10 @@ const launchCliAgent = async (agentId, prompt, cwd) => {
52946
53309
  const binary = CLI_AGENT_BINARIES[agentId];
52947
53310
  const agentArgs = [...CLI_AGENT_AUTO_FLAGS[agentId], prompt];
52948
53311
  if (isWindows) {
53312
+ if (agentId === "cursor") {
53313
+ const cursorResolution = resolveCursorBundledNode();
53314
+ if (cursorResolution) return spawnAgent(cursorResolution.command, [...cursorResolution.args, ...agentArgs], cwd);
53315
+ }
52949
53316
  const entryScript = resolveWindowsCmdEntryScript(binary);
52950
53317
  if (entryScript) return spawnAgent(process.execPath, [entryScript, ...agentArgs], cwd);
52951
53318
  }
@@ -53078,29 +53445,34 @@ const handoffToAgent = async (input) => {
53078
53445
  outcome: "ci-suppressed",
53079
53446
  diagnosticsCount: input.diagnostics.length
53080
53447
  });
53448
+ const choices = [
53449
+ ...(await detectLaunchableAgents()).map((agentId) => ({
53450
+ title: getSkillAgentConfig(agentId).displayName,
53451
+ description: `Open ${CLI_AGENT_BINARIES[agentId]} here with the top issues as a prompt`,
53452
+ value: agentId
53453
+ })),
53454
+ {
53455
+ title: "Copy prompt to clipboard",
53456
+ description: "Paste into any agent or chat",
53457
+ value: CLIPBOARD_CHOICE
53458
+ },
53459
+ {
53460
+ title: "Skip",
53461
+ description: "Don't hand off",
53462
+ value: SKIP_CHOICE
53463
+ }
53464
+ ];
53465
+ const rememberedTarget = readHandoffTarget();
53466
+ const rememberedChoiceIndex = choices.findIndex((choice) => choice.value === rememberedTarget);
53467
+ const initial = rememberedChoiceIndex >= 0 ? rememberedChoiceIndex : 0;
53081
53468
  const { handoffTarget } = await prompts({
53082
53469
  type: "select",
53083
53470
  name: "handoffTarget",
53084
53471
  message: "What would you like to do next?",
53085
- choices: [
53086
- ...(await detectLaunchableAgents()).map((agentId) => ({
53087
- title: getSkillAgentConfig(agentId).displayName,
53088
- description: `Open ${CLI_AGENT_BINARIES[agentId]} here with the top issues as a prompt`,
53089
- value: agentId
53090
- })),
53091
- {
53092
- title: "Copy prompt to clipboard",
53093
- description: "Paste into any agent or chat",
53094
- value: CLIPBOARD_CHOICE
53095
- },
53096
- {
53097
- title: "Skip",
53098
- description: "Don't hand off",
53099
- value: SKIP_CHOICE
53100
- }
53101
- ],
53102
- initial: 0
53472
+ choices,
53473
+ initial
53103
53474
  }, { onCancel: () => true });
53475
+ if (handoffTarget !== void 0) rememberHandoffTarget(handoffTarget);
53104
53476
  let handoffOutcome = "launch";
53105
53477
  if (handoffTarget === void 0) handoffOutcome = "cancel";
53106
53478
  else if (handoffTarget === SKIP_CHOICE) handoffOutcome = "skip";
@@ -53108,7 +53480,9 @@ const handoffToAgent = async (input) => {
53108
53480
  recordCount(METRIC.agentHandoff, 1, {
53109
53481
  outcome: handoffOutcome,
53110
53482
  agent: handoffOutcome === "launch" ? handoffTarget : void 0,
53111
- diagnosticsCount: input.diagnostics.length
53483
+ diagnosticsCount: input.diagnostics.length,
53484
+ defaultRemembered: rememberedChoiceIndex >= 0,
53485
+ keptDefault: handoffTarget === choices[initial].value
53112
53486
  });
53113
53487
  if (handoffTarget === void 0 || handoffTarget === SKIP_CHOICE) return;
53114
53488
  const payload = buildHandoffPayload({
@@ -53652,7 +54026,7 @@ const warnDeprecatedDiff = (flags, userConfig) => {
53652
54026
  };
53653
54027
  const warnDiffUnavailable = (requested, isQuiet) => {
53654
54028
  if (isQuiet) return;
53655
- 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.`);
54029
+ 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.`);
53656
54030
  else cliLogger.warn("No feature branch or uncommitted changes detected. Running full scan.");
53657
54031
  cliLogger.break();
53658
54032
  };
@@ -53849,6 +54223,7 @@ const resolveRequestedProjects = (requestedNames, workspacePackages, rootDirecto
53849
54223
  return requestedNames.map((requestedName) => {
53850
54224
  const matched = workspacePackages.find((workspacePackage) => workspacePackage.name === requestedName || Path.basename(workspacePackage.directory) === requestedName);
53851
54225
  if (matched) return matched.directory;
54226
+ if (Path.basename(rootDirectory) === requestedName) return rootDirectory;
53852
54227
  const candidateDirectory = Path.resolve(rootDirectory, requestedName);
53853
54228
  if (isDirectory(candidateDirectory)) {
53854
54229
  recordCount(METRIC.projectPathSelected);
@@ -53956,6 +54331,17 @@ const warnDeprecatedFailOn = (flags, userConfig) => {
53956
54331
  cliLogger.warn(`${source} is deprecated; rename it to ${replacement}.`);
53957
54332
  };
53958
54333
  //#endregion
54334
+ //#region src/cli/utils/warn-ai-training-environment.ts
54335
+ let didWarnAiTraining = false;
54336
+ const warnIfAiTrainingEnvironment = () => {
54337
+ if (didWarnAiTraining) return;
54338
+ const detected = detectAiTrainingEnvironment();
54339
+ if (detected === null) return;
54340
+ didWarnAiTraining = true;
54341
+ 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`);
54342
+ recordCount(METRIC.aiTrainingWarningShown, 1, { environment: detected });
54343
+ };
54344
+ //#endregion
53959
54345
  //#region src/cli/utils/validate-mode-flags.ts
53960
54346
  const usedDiffAlias = (flags) => flags.diff !== void 0 && flags.diff !== false && flags.diff !== "false" && flags.diff !== "";
53961
54347
  const usedScope = (flags) => typeof flags.scope === "string" && flags.scope.length > 0;
@@ -54039,10 +54425,14 @@ const inspectAction = async (directory, flags) => {
54039
54425
  const isQuiet = isScoreOnly || isJsonMode;
54040
54426
  const requestedDirectory = Path.resolve(directory);
54041
54427
  const startTime = performance$1.now();
54042
- if (isJsonMode) enableJsonMode({
54043
- compact: Boolean(flags.jsonCompact),
54044
- directory: requestedDirectory
54045
- });
54428
+ if (isJsonMode) {
54429
+ enableJsonMode({
54430
+ compact: Boolean(flags.jsonCompact),
54431
+ directory: requestedDirectory,
54432
+ outputFile: flags.jsonOut
54433
+ });
54434
+ if (flags.jsonOut) recordCount(METRIC.jsonOutUsed, 1);
54435
+ }
54046
54436
  recordCount(METRIC.cliInvoked, 1, { command: "inspect" });
54047
54437
  try {
54048
54438
  validateModeFlags(flags);
@@ -54056,6 +54446,7 @@ const inspectAction = async (directory, flags) => {
54056
54446
  setJsonReportDirectory(resolvedDirectory);
54057
54447
  warnDeprecatedFailOn(flags, userConfig);
54058
54448
  warnDeprecatedDiff(flags, userConfig);
54449
+ warnIfAiTrainingEnvironment();
54059
54450
  if (scanTarget.didRedirectViaRootDir && !isQuiet) {
54060
54451
  cliLogger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
54061
54452
  cliLogger.break();
@@ -54343,6 +54734,10 @@ const installAction = async (options, command) => {
54343
54734
  projectRoot: options.cwd ?? process.cwd()
54344
54735
  });
54345
54736
  } catch (error) {
54737
+ if (isExpectedUserError(error)) {
54738
+ handleUserError(error);
54739
+ return;
54740
+ }
54346
54741
  handleError(error, { sentryEventId: await reportErrorToSentry(error) });
54347
54742
  }
54348
54743
  };
@@ -55117,6 +55512,7 @@ const ROOT_FLAG_SPEC = {
55117
55512
  "--category",
55118
55513
  "--changed-files-from",
55119
55514
  "--blocking",
55515
+ "--json-out",
55120
55516
  "--output-dir",
55121
55517
  "--fail-on",
55122
55518
  "--project",
@@ -55307,7 +55703,7 @@ ${highlighter.dim("Learn more:")}
55307
55703
  ${highlighter.info(CANONICAL_GITHUB_URL)}
55308
55704
  `;
55309
55705
  const collectCategoryOption = (value, previousValues) => [...previousValues ?? [], value];
55310
- 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);
55706
+ 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);
55311
55707
  program.action(inspectAction);
55312
55708
  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));
55313
55709
  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);
@@ -55350,4 +55746,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
55350
55746
  export {};
55351
55747
 
55352
55748
  //# sourceMappingURL=cli.js.map
55353
- //# debugId=6a2c86b6-c3a8-5e50-8e7b-daef840df200
55749
+ //# debugId=fabe3e34-b40f-514e-a5af-fa68a36365ae