react-doctor 0.0.29 → 0.0.30

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/README.md CHANGED
@@ -85,7 +85,7 @@ Options:
85
85
  -y, --yes skip prompts, scan all workspace projects
86
86
  --project <name> select workspace project (comma-separated for multiple)
87
87
  --diff [base] scan only files changed vs base branch
88
- --no-ami skip Ami-related prompts
88
+ --ami enable Ami-related prompts
89
89
  --fix open Ami to auto-fix all issues
90
90
  -h, --help display help for command
91
91
  ```
package/dist/cli.js CHANGED
@@ -33,7 +33,6 @@ const FETCH_TIMEOUT_MS = 1e4;
33
33
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
34
34
  const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
35
35
  const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
36
- const OFFLINE_FLAG_MESSAGE = "Score not calculated. Remove --offline to calculate score.";
37
36
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
38
37
  const ERROR_RULE_PENALTY = 1.5;
39
38
  const WARNING_RULE_PENALTY = .75;
@@ -129,6 +128,13 @@ const estimateScoreLocally = (diagnostics) => {
129
128
  estimatedLabel: getScoreLabel(estimatedScore)
130
129
  };
131
130
  };
131
+ const calculateScoreLocally = (diagnostics) => {
132
+ const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
133
+ return {
134
+ score: currentScore,
135
+ label: currentLabel
136
+ };
137
+ };
132
138
  const calculateScore = async (diagnostics) => {
133
139
  try {
134
140
  const response = await proxyFetch(SCORE_API_URL, {
@@ -136,10 +142,10 @@ const calculateScore = async (diagnostics) => {
136
142
  headers: { "Content-Type": "application/json" },
137
143
  body: JSON.stringify({ diagnostics })
138
144
  });
139
- if (!response.ok) return null;
145
+ if (!response.ok) return calculateScoreLocally(diagnostics);
140
146
  return await response.json();
141
147
  } catch {
142
- return null;
148
+ return calculateScoreLocally(diagnostics);
143
149
  }
144
150
  };
145
151
  const fetchEstimatedScore = async (diagnostics) => {
@@ -178,13 +184,33 @@ const colorizeByScore = (text, score) => {
178
184
  //#region src/plugin/constants.ts
179
185
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
180
186
 
187
+ //#endregion
188
+ //#region src/utils/is-file.ts
189
+ const isFile = (filePath) => {
190
+ try {
191
+ return fs.statSync(filePath).isFile();
192
+ } catch {
193
+ return false;
194
+ }
195
+ };
196
+
181
197
  //#endregion
182
198
  //#region src/utils/read-package-json.ts
183
- const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
199
+ const readPackageJson = (packageJsonPath) => {
200
+ try {
201
+ return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
202
+ } catch (error) {
203
+ if (error instanceof Error && "code" in error) {
204
+ const { code } = error;
205
+ if (code === "EISDIR" || code === "EACCES") return {};
206
+ }
207
+ throw error;
208
+ }
209
+ };
184
210
 
185
211
  //#endregion
186
212
  //#region src/utils/check-reduced-motion.ts
187
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
213
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
188
214
  const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
189
215
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
190
216
  filePath: "package.json",
@@ -200,7 +226,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
200
226
  };
201
227
  const checkReducedMotion = (rootDirectory) => {
202
228
  const packageJsonPath = path.join(rootDirectory, "package.json");
203
- if (!fs.existsSync(packageJsonPath)) return [];
229
+ if (!isFile(packageJsonPath)) return [];
204
230
  let hasMotionLibrary = false;
205
231
  try {
206
232
  const packageJson = readPackageJson(packageJsonPath);
@@ -228,7 +254,7 @@ const checkReducedMotion = (rootDirectory) => {
228
254
  //#region src/utils/match-glob-pattern.ts
229
255
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
230
256
  const compileGlobPattern = (pattern) => {
231
- const normalizedPattern = pattern.replace(/\\/g, "/");
257
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
232
258
  let regexSource = "^";
233
259
  let characterIndex = 0;
234
260
  while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
@@ -266,26 +292,67 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
266
292
  return true;
267
293
  });
268
294
  };
295
+ const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
296
+ const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
297
+ const isRuleSuppressed = (commentRules, ruleId) => {
298
+ if (!commentRules?.trim()) return true;
299
+ return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
300
+ };
301
+ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
302
+ const fileLineCache = /* @__PURE__ */ new Map();
303
+ const getFileLines = (filePath) => {
304
+ const cached = fileLineCache.get(filePath);
305
+ if (cached !== void 0) return cached;
306
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
307
+ try {
308
+ const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
309
+ fileLineCache.set(filePath, lines);
310
+ return lines;
311
+ } catch {
312
+ fileLineCache.set(filePath, null);
313
+ return null;
314
+ }
315
+ };
316
+ return diagnostics.filter((diagnostic) => {
317
+ if (diagnostic.line <= 0) return true;
318
+ const lines = getFileLines(diagnostic.filePath);
319
+ if (!lines) return true;
320
+ const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
321
+ const currentLine = lines[diagnostic.line - 1];
322
+ if (currentLine) {
323
+ const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
324
+ if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
325
+ }
326
+ if (diagnostic.line >= 2) {
327
+ const prevLine = lines[diagnostic.line - 2];
328
+ if (prevLine) {
329
+ const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
330
+ if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
331
+ }
332
+ }
333
+ return true;
334
+ });
335
+ };
269
336
 
270
337
  //#endregion
271
338
  //#region src/utils/combine-diagnostics.ts
272
339
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
273
340
  const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
274
- const allDiagnostics = [
341
+ const merged = [
275
342
  ...lintDiagnostics,
276
343
  ...deadCodeDiagnostics,
277
344
  ...isDiffMode ? [] : checkReducedMotion(directory)
278
345
  ];
279
- return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
346
+ return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig) : merged, directory);
280
347
  };
281
348
 
282
349
  //#endregion
283
350
  //#region src/utils/find-monorepo-root.ts
284
351
  const isMonorepoRoot = (directory) => {
285
- if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
286
- if (fs.existsSync(path.join(directory, "nx.json"))) return true;
352
+ if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
353
+ if (isFile(path.join(directory, "nx.json"))) return true;
287
354
  const packageJsonPath = path.join(directory, "package.json");
288
- if (!fs.existsSync(packageJsonPath)) return false;
355
+ if (!isFile(packageJsonPath)) return false;
289
356
  const packageJson = readPackageJson(packageJsonPath);
290
357
  return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
291
358
  };
@@ -298,6 +365,10 @@ const findMonorepoRoot = (startDirectory) => {
298
365
  return null;
299
366
  };
300
367
 
368
+ //#endregion
369
+ //#region src/utils/is-plain-object.ts
370
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
371
+
301
372
  //#endregion
302
373
  //#region src/utils/discover-project.ts
303
374
  const REACT_COMPILER_PACKAGES = new Set([
@@ -397,16 +468,37 @@ const detectFramework = (dependencies) => {
397
468
  for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
398
469
  return "unknown";
399
470
  };
471
+ const isCatalogReference = (version) => version.startsWith("catalog:");
472
+ const resolveVersionFromCatalog = (catalog, packageName) => {
473
+ const version = catalog[packageName];
474
+ if (typeof version === "string" && !isCatalogReference(version)) return version;
475
+ return null;
476
+ };
477
+ const resolveCatalogVersion = (packageJson, packageName) => {
478
+ const raw = packageJson;
479
+ if (isPlainObject(raw.catalog)) {
480
+ const version = resolveVersionFromCatalog(raw.catalog, packageName);
481
+ if (version) return version;
482
+ }
483
+ if (isPlainObject(raw.catalogs)) {
484
+ for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
485
+ const version = resolveVersionFromCatalog(catalogEntries, packageName);
486
+ if (version) return version;
487
+ }
488
+ }
489
+ return null;
490
+ };
400
491
  const extractDependencyInfo = (packageJson) => {
401
492
  const allDependencies = collectAllDependencies(packageJson);
493
+ const rawVersion = allDependencies.react ?? null;
402
494
  return {
403
- reactVersion: allDependencies.react ?? null,
495
+ reactVersion: rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null,
404
496
  framework: detectFramework(allDependencies)
405
497
  };
406
498
  };
407
499
  const parsePnpmWorkspacePatterns = (rootDirectory) => {
408
500
  const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
409
- if (!fs.existsSync(workspacePath)) return [];
501
+ if (!isFile(workspacePath)) return [];
410
502
  const content = fs.readFileSync(workspacePath, "utf-8");
411
503
  const patterns = [];
412
504
  let isInsidePackagesBlock = false;
@@ -432,14 +524,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
432
524
  const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
433
525
  if (!cleanPattern.includes("*")) {
434
526
  const directoryPath = path.join(rootDirectory, cleanPattern);
435
- if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
527
+ if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
436
528
  return [];
437
529
  }
438
530
  const wildcardIndex = cleanPattern.indexOf("*");
439
531
  const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
440
532
  const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
441
533
  if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
442
- return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
534
+ return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() && isFile(path.join(entryPath, "package.json")));
443
535
  };
444
536
  const findDependencyInfoFromMonorepoRoot = (directory) => {
445
537
  const monorepoRoot = findMonorepoRoot(directory);
@@ -447,11 +539,17 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
447
539
  reactVersion: null,
448
540
  framework: "unknown"
449
541
  };
450
- const rootPackageJson = readPackageJson(path.join(monorepoRoot, "package.json"));
542
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
543
+ if (!isFile(monorepoPackageJsonPath)) return {
544
+ reactVersion: null,
545
+ framework: "unknown"
546
+ };
547
+ const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
451
548
  const rootInfo = extractDependencyInfo(rootPackageJson);
549
+ const catalogVersion = resolveCatalogVersion(rootPackageJson, "react");
452
550
  const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
453
551
  return {
454
- reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
552
+ reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
455
553
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
456
554
  };
457
555
  };
@@ -485,7 +583,7 @@ const discoverReactSubprojects = (rootDirectory) => {
485
583
  if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
486
584
  const packages = [];
487
585
  const rootPackageJsonPath = path.join(rootDirectory, "package.json");
488
- if (fs.existsSync(rootPackageJsonPath)) {
586
+ if (isFile(rootPackageJsonPath)) {
489
587
  const rootPackageJson = readPackageJson(rootPackageJsonPath);
490
588
  if (hasReactDependency(rootPackageJson)) {
491
589
  const name = rootPackageJson.name ?? path.basename(rootDirectory);
@@ -500,7 +598,7 @@ const discoverReactSubprojects = (rootDirectory) => {
500
598
  if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
501
599
  const subdirectory = path.join(rootDirectory, entry.name);
502
600
  const packageJsonPath = path.join(subdirectory, "package.json");
503
- if (!fs.existsSync(packageJsonPath)) continue;
601
+ if (!isFile(packageJsonPath)) continue;
504
602
  const packageJson = readPackageJson(packageJsonPath);
505
603
  if (!hasReactDependency(packageJson)) continue;
506
604
  const name = packageJson.name ?? entry.name;
@@ -513,7 +611,7 @@ const discoverReactSubprojects = (rootDirectory) => {
513
611
  };
514
612
  const listWorkspacePackages = (rootDirectory) => {
515
613
  const packageJsonPath = path.join(rootDirectory, "package.json");
516
- if (!fs.existsSync(packageJsonPath)) return [];
614
+ if (!isFile(packageJsonPath)) return [];
517
615
  const patterns = getWorkspacePatterns(rootDirectory, readPackageJson(packageJsonPath));
518
616
  if (patterns.length === 0) return [];
519
617
  const packages = [];
@@ -536,7 +634,7 @@ const hasCompilerPackage = (packageJson) => {
536
634
  return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
537
635
  };
538
636
  const fileContainsPattern = (filePath, pattern) => {
539
- if (!fs.existsSync(filePath)) return false;
637
+ if (!isFile(filePath)) return false;
540
638
  const content = fs.readFileSync(filePath, "utf-8");
541
639
  return pattern.test(content);
542
640
  };
@@ -550,7 +648,7 @@ const detectReactCompiler = (directory, packageJson) => {
550
648
  let ancestorDirectory = path.dirname(directory);
551
649
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
552
650
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
553
- if (fs.existsSync(ancestorPackagePath)) {
651
+ if (isFile(ancestorPackagePath)) {
554
652
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
555
653
  }
556
654
  ancestorDirectory = path.dirname(ancestorDirectory);
@@ -559,9 +657,10 @@ const detectReactCompiler = (directory, packageJson) => {
559
657
  };
560
658
  const discoverProject = (directory) => {
561
659
  const packageJsonPath = path.join(directory, "package.json");
562
- if (!fs.existsSync(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
660
+ if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
563
661
  const packageJson = readPackageJson(packageJsonPath);
564
662
  let { reactVersion, framework } = extractDependencyInfo(packageJson);
663
+ if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react");
565
664
  if (!reactVersion || framework === "unknown") {
566
665
  const workspaceInfo = findReactInWorkspaces(directory, packageJson);
567
666
  if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
@@ -661,10 +760,9 @@ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText
661
760
  //#region src/utils/load-config.ts
662
761
  const CONFIG_FILENAME = "react-doctor.config.json";
663
762
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
664
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
665
763
  const loadConfig = (rootDirectory) => {
666
764
  const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
667
- if (fs.existsSync(configFilePath)) try {
765
+ if (isFile(configFilePath)) try {
668
766
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
669
767
  const parsed = JSON.parse(fileContent);
670
768
  if (isPlainObject(parsed)) return parsed;
@@ -673,7 +771,7 @@ const loadConfig = (rootDirectory) => {
673
771
  console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
674
772
  }
675
773
  const packageJsonPath = path.join(rootDirectory, "package.json");
676
- if (fs.existsSync(packageJsonPath)) try {
774
+ if (isFile(packageJsonPath)) try {
677
775
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
678
776
  const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
679
777
  if (isPlainObject(embeddedConfig)) return embeddedConfig;
@@ -932,7 +1030,7 @@ const runKnip = async (rootDirectory) => {
932
1030
  let knipResult;
933
1031
  if (monorepoRoot) {
934
1032
  const packageJsonPath = path.join(rootDirectory, "package.json");
935
- const workspaceName = (fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
1033
+ const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
936
1034
  try {
937
1035
  knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
938
1036
  } catch {
@@ -1215,7 +1313,7 @@ const RULE_CATEGORY_MAP = {
1215
1313
  "react-doctor/rn-no-single-element-style-array": "React Native"
1216
1314
  };
1217
1315
  const RULE_HELP_MAP = {
1218
- "no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`",
1316
+ "no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`. See https://react.dev/learn/you-might-not-need-an-effect",
1219
1317
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
1220
1318
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
1221
1319
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
@@ -1255,7 +1353,7 @@ const RULE_HELP_MAP = {
1255
1353
  "nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
1256
1354
  "nextjs-no-client-fetch-for-server-data": "Remove 'use client' and fetch directly in the Server Component — no API round-trip, secrets stay on server",
1257
1355
  "nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
1258
- "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' in a Server Component, or handle in middleware",
1356
+ "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' directly (works in both server and client components), or handle in middleware",
1259
1357
  "nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
1260
1358
  "nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
1261
1359
  "nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
@@ -1343,7 +1441,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
1343
1441
  child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1344
1442
  child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1345
1443
  child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1346
- child.on("close", () => {
1444
+ child.on("close", (code, signal) => {
1445
+ if (signal) {
1446
+ const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1447
+ const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
1448
+ const detail = stderrOutput ? `: ${stderrOutput}` : "";
1449
+ reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
1450
+ return;
1451
+ }
1347
1452
  const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
1348
1453
  if (!output) {
1349
1454
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
@@ -1614,7 +1719,7 @@ const buildCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMillis
1614
1719
  renderedParts.push(highlighter.dim(fileCountText), highlighter.dim(elapsedTimeText));
1615
1720
  return createFramedLine(plainParts.join(" "), renderedParts.join(" "));
1616
1721
  };
1617
- const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
1722
+ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage, isOffline) => {
1618
1723
  printFramedBox([...buildBrandingLines(scoreResult, noScoreMessage), buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds)]);
1619
1724
  try {
1620
1725
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
@@ -1623,9 +1728,11 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1623
1728
  } catch {
1624
1729
  logger.break();
1625
1730
  }
1626
- const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
1627
- logger.break();
1628
- logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1731
+ if (!isOffline) {
1732
+ const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
1733
+ logger.break();
1734
+ logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1735
+ }
1629
1736
  };
1630
1737
  const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
1631
1738
  if (!isLintEnabled) return null;
@@ -1745,8 +1852,8 @@ const scan = async (directory, inputOptions = {}) => {
1745
1852
  if (didLintFail) skippedChecks.push("lint");
1746
1853
  if (didDeadCodeFail) skippedChecks.push("dead code");
1747
1854
  const hasSkippedChecks = skippedChecks.length > 0;
1748
- const scoreResult = options.offline ? null : await calculateScore(diagnostics);
1749
- const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
1855
+ const scoreResult = options.offline ? calculateScoreLocally(diagnostics) : await calculateScore(diagnostics);
1856
+ const noScoreMessage = OFFLINE_MESSAGE;
1750
1857
  if (options.scoreOnly) {
1751
1858
  if (scoreResult) logger.log(`${scoreResult.score}`);
1752
1859
  else logger.dim(noScoreMessage);
@@ -1777,7 +1884,7 @@ const scan = async (directory, inputOptions = {}) => {
1777
1884
  }
1778
1885
  printDiagnostics(diagnostics, options.verbose);
1779
1886
  const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1780
- printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage);
1887
+ printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, options.offline);
1781
1888
  if (hasSkippedChecks) {
1782
1889
  const skippedLabel = skippedChecks.join(" and ");
1783
1890
  logger.break();
@@ -2104,7 +2211,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
2104
2211
 
2105
2212
  //#endregion
2106
2213
  //#region src/cli.ts
2107
- const VERSION = "0.0.29";
2214
+ const VERSION = "0.0.30";
2108
2215
  const VALID_FAIL_ON_LEVELS = new Set([
2109
2216
  "error",
2110
2217
  "warning",
@@ -2167,7 +2274,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2167
2274
  });
2168
2275
  return Boolean(shouldScanChangedOnly);
2169
2276
  };
2170
- 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 detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--no-ami", "skip Ami-related prompts").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
2277
+ 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 detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--ami", "enable Ami-related prompts").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
2171
2278
  const isScoreOnly = flags.score;
2172
2279
  try {
2173
2280
  const resolvedDirectory = path.resolve(directory);