react-doctor 0.0.29 → 0.0.31

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
@@ -32,8 +32,7 @@ const OPEN_BASE_URL = "https://www.react.doctor/open";
32
32
  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
- 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.";
35
+ const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
37
36
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
38
37
  const ERROR_RULE_PENALTY = 1.5;
39
38
  const WARNING_RULE_PENALTY = .75;
@@ -42,6 +41,12 @@ const WARNING_ESTIMATED_FIX_RATE = .8;
42
41
  const MAX_KNIP_RETRIES = 5;
43
42
  const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
44
43
  const OXLINT_RECOMMENDED_NODE_MAJOR = 24;
44
+ const IGNORED_DIRECTORIES = new Set([
45
+ "node_modules",
46
+ "dist",
47
+ "build",
48
+ "coverage"
49
+ ]);
45
50
  const AMI_WEBSITE_URL = "https://ami.dev";
46
51
  const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
47
52
  const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
@@ -129,6 +134,13 @@ const estimateScoreLocally = (diagnostics) => {
129
134
  estimatedLabel: getScoreLabel(estimatedScore)
130
135
  };
131
136
  };
137
+ const calculateScoreLocally = (diagnostics) => {
138
+ const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
139
+ return {
140
+ score: currentScore,
141
+ label: currentLabel
142
+ };
143
+ };
132
144
  const calculateScore = async (diagnostics) => {
133
145
  try {
134
146
  const response = await proxyFetch(SCORE_API_URL, {
@@ -136,10 +148,10 @@ const calculateScore = async (diagnostics) => {
136
148
  headers: { "Content-Type": "application/json" },
137
149
  body: JSON.stringify({ diagnostics })
138
150
  });
139
- if (!response.ok) return null;
151
+ if (!response.ok) return calculateScoreLocally(diagnostics);
140
152
  return await response.json();
141
153
  } catch {
142
- return null;
154
+ return calculateScoreLocally(diagnostics);
143
155
  }
144
156
  };
145
157
  const fetchEstimatedScore = async (diagnostics) => {
@@ -178,13 +190,33 @@ const colorizeByScore = (text, score) => {
178
190
  //#region src/plugin/constants.ts
179
191
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
180
192
 
193
+ //#endregion
194
+ //#region src/utils/is-file.ts
195
+ const isFile = (filePath) => {
196
+ try {
197
+ return fs.statSync(filePath).isFile();
198
+ } catch {
199
+ return false;
200
+ }
201
+ };
202
+
181
203
  //#endregion
182
204
  //#region src/utils/read-package-json.ts
183
- const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
205
+ const readPackageJson = (packageJsonPath) => {
206
+ try {
207
+ return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
208
+ } catch (error) {
209
+ if (error instanceof Error && "code" in error) {
210
+ const { code } = error;
211
+ if (code === "EISDIR" || code === "EACCES") return {};
212
+ }
213
+ throw error;
214
+ }
215
+ };
184
216
 
185
217
  //#endregion
186
218
  //#region src/utils/check-reduced-motion.ts
187
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
219
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
188
220
  const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
189
221
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
190
222
  filePath: "package.json",
@@ -200,7 +232,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
200
232
  };
201
233
  const checkReducedMotion = (rootDirectory) => {
202
234
  const packageJsonPath = path.join(rootDirectory, "package.json");
203
- if (!fs.existsSync(packageJsonPath)) return [];
235
+ if (!isFile(packageJsonPath)) return [];
204
236
  let hasMotionLibrary = false;
205
237
  try {
206
238
  const packageJson = readPackageJson(packageJsonPath);
@@ -228,7 +260,7 @@ const checkReducedMotion = (rootDirectory) => {
228
260
  //#region src/utils/match-glob-pattern.ts
229
261
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
230
262
  const compileGlobPattern = (pattern) => {
231
- const normalizedPattern = pattern.replace(/\\/g, "/");
263
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
232
264
  let regexSource = "^";
233
265
  let characterIndex = 0;
234
266
  while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
@@ -252,17 +284,72 @@ const compileGlobPattern = (pattern) => {
252
284
  return new RegExp(regexSource);
253
285
  };
254
286
 
287
+ //#endregion
288
+ //#region src/utils/is-ignored-file.ts
289
+ const toRelativePath = (filePath, rootDirectory) => {
290
+ const normalizedFilePath = filePath.replace(/\\/g, "/");
291
+ const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
292
+ if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
293
+ return normalizedFilePath.replace(/^\.\//, "");
294
+ };
295
+ const compileIgnoredFilePatterns = (userConfig) => Array.isArray(userConfig?.ignore?.files) ? userConfig.ignore.files.map(compileGlobPattern) : [];
296
+ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
297
+ if (patterns.length === 0) return false;
298
+ const relativePath = toRelativePath(filePath, rootDirectory);
299
+ return patterns.some((pattern) => pattern.test(relativePath));
300
+ };
301
+
255
302
  //#endregion
256
303
  //#region src/utils/filter-diagnostics.ts
257
- const filterIgnoredDiagnostics = (diagnostics, config) => {
304
+ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory) => {
258
305
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
259
- const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
306
+ const ignoredFilePatterns = compileIgnoredFilePatterns(config);
260
307
  if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
261
308
  return diagnostics.filter((diagnostic) => {
262
309
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
263
310
  if (ignoredRules.has(ruleIdentifier)) return false;
264
- const normalizedPath = diagnostic.filePath.replace(/\\/g, "/").replace(/^\.\//, "");
265
- if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
311
+ if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
312
+ return true;
313
+ });
314
+ };
315
+ const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
316
+ const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
317
+ const isRuleSuppressed = (commentRules, ruleId) => {
318
+ if (!commentRules?.trim()) return true;
319
+ return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
320
+ };
321
+ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
322
+ const fileLineCache = /* @__PURE__ */ new Map();
323
+ const getFileLines = (filePath) => {
324
+ const cached = fileLineCache.get(filePath);
325
+ if (cached !== void 0) return cached;
326
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
327
+ try {
328
+ const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
329
+ fileLineCache.set(filePath, lines);
330
+ return lines;
331
+ } catch {
332
+ fileLineCache.set(filePath, null);
333
+ return null;
334
+ }
335
+ };
336
+ return diagnostics.filter((diagnostic) => {
337
+ if (diagnostic.line <= 0) return true;
338
+ const lines = getFileLines(diagnostic.filePath);
339
+ if (!lines) return true;
340
+ const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
341
+ const currentLine = lines[diagnostic.line - 1];
342
+ if (currentLine) {
343
+ const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
344
+ if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
345
+ }
346
+ if (diagnostic.line >= 2) {
347
+ const prevLine = lines[diagnostic.line - 2];
348
+ if (prevLine) {
349
+ const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
350
+ if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
351
+ }
352
+ }
266
353
  return true;
267
354
  });
268
355
  };
@@ -271,21 +358,21 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
271
358
  //#region src/utils/combine-diagnostics.ts
272
359
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
273
360
  const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
274
- const allDiagnostics = [
361
+ const merged = [
275
362
  ...lintDiagnostics,
276
363
  ...deadCodeDiagnostics,
277
364
  ...isDiffMode ? [] : checkReducedMotion(directory)
278
365
  ];
279
- return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
366
+ return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig, directory) : merged, directory);
280
367
  };
281
368
 
282
369
  //#endregion
283
370
  //#region src/utils/find-monorepo-root.ts
284
371
  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;
372
+ if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
373
+ if (isFile(path.join(directory, "nx.json"))) return true;
287
374
  const packageJsonPath = path.join(directory, "package.json");
288
- if (!fs.existsSync(packageJsonPath)) return false;
375
+ if (!isFile(packageJsonPath)) return false;
289
376
  const packageJson = readPackageJson(packageJsonPath);
290
377
  return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
291
378
  };
@@ -298,6 +385,10 @@ const findMonorepoRoot = (startDirectory) => {
298
385
  return null;
299
386
  };
300
387
 
388
+ //#endregion
389
+ //#region src/utils/is-plain-object.ts
390
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
391
+
301
392
  //#endregion
302
393
  //#region src/utils/discover-project.ts
303
394
  const REACT_COMPILER_PACKAGES = new Set([
@@ -351,12 +442,6 @@ const FRAMEWORK_DISPLAY_NAMES = {
351
442
  unknown: "React"
352
443
  };
353
444
  const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
354
- const IGNORED_DIRECTORIES = new Set([
355
- "node_modules",
356
- "dist",
357
- "build",
358
- "coverage"
359
- ]);
360
445
  const countSourceFilesViaFilesystem = (rootDirectory) => {
361
446
  let count = 0;
362
447
  const stack = [rootDirectory];
@@ -397,16 +482,129 @@ const detectFramework = (dependencies) => {
397
482
  for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
398
483
  return "unknown";
399
484
  };
485
+ const isCatalogReference = (version) => version.startsWith("catalog:");
486
+ const extractCatalogName = (version) => {
487
+ if (!isCatalogReference(version)) return null;
488
+ const name = version.slice(8).trim();
489
+ return name.length > 0 ? name : null;
490
+ };
491
+ const resolveVersionFromCatalog = (catalog, packageName) => {
492
+ const version = catalog[packageName];
493
+ if (typeof version === "string" && !isCatalogReference(version)) return version;
494
+ return null;
495
+ };
496
+ const parsePnpmWorkspaceCatalogs = (rootDirectory) => {
497
+ const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
498
+ if (!isFile(workspacePath)) return {
499
+ defaultCatalog: {},
500
+ namedCatalogs: {}
501
+ };
502
+ const content = fs.readFileSync(workspacePath, "utf-8");
503
+ const defaultCatalog = {};
504
+ const namedCatalogs = {};
505
+ let currentSection = "none";
506
+ let currentCatalogName = "";
507
+ for (const line of content.split("\n")) {
508
+ const trimmed = line.trim();
509
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
510
+ const indentLevel = line.search(/\S/);
511
+ if (indentLevel === 0 && trimmed === "catalog:") {
512
+ currentSection = "catalog";
513
+ continue;
514
+ }
515
+ if (indentLevel === 0 && trimmed === "catalogs:") {
516
+ currentSection = "catalogs";
517
+ continue;
518
+ }
519
+ if (indentLevel === 0) {
520
+ currentSection = "none";
521
+ continue;
522
+ }
523
+ if (currentSection === "catalog" && indentLevel > 0) {
524
+ const colonIndex = trimmed.indexOf(":");
525
+ if (colonIndex > 0) {
526
+ const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
527
+ const value = trimmed.slice(colonIndex + 1).trim().replace(/["']/g, "");
528
+ if (key && value) defaultCatalog[key] = value;
529
+ }
530
+ continue;
531
+ }
532
+ if (currentSection === "catalogs" && indentLevel > 0) {
533
+ if (trimmed.endsWith(":") && !trimmed.includes(" ")) {
534
+ currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
535
+ currentSection = "named-catalog";
536
+ namedCatalogs[currentCatalogName] = {};
537
+ continue;
538
+ }
539
+ }
540
+ if (currentSection === "named-catalog" && indentLevel > 0) {
541
+ if (indentLevel <= 2 && trimmed.endsWith(":") && !trimmed.includes(" ")) {
542
+ currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
543
+ namedCatalogs[currentCatalogName] = {};
544
+ continue;
545
+ }
546
+ const colonIndex = trimmed.indexOf(":");
547
+ if (colonIndex > 0 && currentCatalogName) {
548
+ const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
549
+ const value = trimmed.slice(colonIndex + 1).trim().replace(/["']/g, "");
550
+ if (key && value) namedCatalogs[currentCatalogName][key] = value;
551
+ }
552
+ }
553
+ }
554
+ return {
555
+ defaultCatalog,
556
+ namedCatalogs
557
+ };
558
+ };
559
+ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogReference) => {
560
+ if (catalogReference) {
561
+ const namedCatalog = catalogs.namedCatalogs[catalogReference];
562
+ if (namedCatalog?.[packageName]) return namedCatalog[packageName];
563
+ }
564
+ if (catalogs.defaultCatalog[packageName]) return catalogs.defaultCatalog[packageName];
565
+ for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
566
+ return null;
567
+ };
568
+ const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
569
+ const rawVersion = collectAllDependencies(packageJson)[packageName];
570
+ const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
571
+ const raw = packageJson;
572
+ if (isPlainObject(raw.catalog)) {
573
+ const version = resolveVersionFromCatalog(raw.catalog, packageName);
574
+ if (version) return version;
575
+ }
576
+ if (isPlainObject(raw.catalogs)) {
577
+ if (catalogName && isPlainObject(raw.catalogs[catalogName])) {
578
+ const version = resolveVersionFromCatalog(raw.catalogs[catalogName], packageName);
579
+ if (version) return version;
580
+ }
581
+ for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
582
+ const version = resolveVersionFromCatalog(catalogEntries, packageName);
583
+ if (version) return version;
584
+ }
585
+ }
586
+ const workspaces = packageJson.workspaces;
587
+ if (workspaces && !Array.isArray(workspaces) && isPlainObject(workspaces.catalog)) {
588
+ const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
589
+ if (version) return version;
590
+ }
591
+ if (rootDirectory) {
592
+ const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, catalogName);
593
+ if (pnpmVersion) return pnpmVersion;
594
+ }
595
+ return null;
596
+ };
400
597
  const extractDependencyInfo = (packageJson) => {
401
598
  const allDependencies = collectAllDependencies(packageJson);
599
+ const rawVersion = allDependencies.react ?? null;
402
600
  return {
403
- reactVersion: allDependencies.react ?? null,
601
+ reactVersion: rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null,
404
602
  framework: detectFramework(allDependencies)
405
603
  };
406
604
  };
407
605
  const parsePnpmWorkspacePatterns = (rootDirectory) => {
408
606
  const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
409
- if (!fs.existsSync(workspacePath)) return [];
607
+ if (!isFile(workspacePath)) return [];
410
608
  const content = fs.readFileSync(workspacePath, "utf-8");
411
609
  const patterns = [];
412
610
  let isInsidePackagesBlock = false;
@@ -432,14 +630,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
432
630
  const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
433
631
  if (!cleanPattern.includes("*")) {
434
632
  const directoryPath = path.join(rootDirectory, cleanPattern);
435
- if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
633
+ if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
436
634
  return [];
437
635
  }
438
636
  const wildcardIndex = cleanPattern.indexOf("*");
439
637
  const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
440
638
  const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
441
639
  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")));
640
+ 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
641
  };
444
642
  const findDependencyInfoFromMonorepoRoot = (directory) => {
445
643
  const monorepoRoot = findMonorepoRoot(directory);
@@ -447,11 +645,17 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
447
645
  reactVersion: null,
448
646
  framework: "unknown"
449
647
  };
450
- const rootPackageJson = readPackageJson(path.join(monorepoRoot, "package.json"));
648
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
649
+ if (!isFile(monorepoPackageJsonPath)) return {
650
+ reactVersion: null,
651
+ framework: "unknown"
652
+ };
653
+ const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
451
654
  const rootInfo = extractDependencyInfo(rootPackageJson);
655
+ const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot);
452
656
  const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
453
657
  return {
454
- reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
658
+ reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
455
659
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
456
660
  };
457
661
  };
@@ -485,7 +689,7 @@ const discoverReactSubprojects = (rootDirectory) => {
485
689
  if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
486
690
  const packages = [];
487
691
  const rootPackageJsonPath = path.join(rootDirectory, "package.json");
488
- if (fs.existsSync(rootPackageJsonPath)) {
692
+ if (isFile(rootPackageJsonPath)) {
489
693
  const rootPackageJson = readPackageJson(rootPackageJsonPath);
490
694
  if (hasReactDependency(rootPackageJson)) {
491
695
  const name = rootPackageJson.name ?? path.basename(rootDirectory);
@@ -500,7 +704,7 @@ const discoverReactSubprojects = (rootDirectory) => {
500
704
  if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
501
705
  const subdirectory = path.join(rootDirectory, entry.name);
502
706
  const packageJsonPath = path.join(subdirectory, "package.json");
503
- if (!fs.existsSync(packageJsonPath)) continue;
707
+ if (!isFile(packageJsonPath)) continue;
504
708
  const packageJson = readPackageJson(packageJsonPath);
505
709
  if (!hasReactDependency(packageJson)) continue;
506
710
  const name = packageJson.name ?? entry.name;
@@ -513,10 +717,18 @@ const discoverReactSubprojects = (rootDirectory) => {
513
717
  };
514
718
  const listWorkspacePackages = (rootDirectory) => {
515
719
  const packageJsonPath = path.join(rootDirectory, "package.json");
516
- if (!fs.existsSync(packageJsonPath)) return [];
517
- const patterns = getWorkspacePatterns(rootDirectory, readPackageJson(packageJsonPath));
720
+ if (!isFile(packageJsonPath)) return [];
721
+ const packageJson = readPackageJson(packageJsonPath);
722
+ const patterns = getWorkspacePatterns(rootDirectory, packageJson);
518
723
  if (patterns.length === 0) return [];
519
724
  const packages = [];
725
+ if (hasReactDependency(packageJson)) {
726
+ const rootName = packageJson.name ?? path.basename(rootDirectory);
727
+ packages.push({
728
+ name: rootName,
729
+ directory: rootDirectory
730
+ });
731
+ }
520
732
  for (const pattern of patterns) {
521
733
  const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
522
734
  for (const workspaceDirectory of directories) {
@@ -536,7 +748,7 @@ const hasCompilerPackage = (packageJson) => {
536
748
  return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
537
749
  };
538
750
  const fileContainsPattern = (filePath, pattern) => {
539
- if (!fs.existsSync(filePath)) return false;
751
+ if (!isFile(filePath)) return false;
540
752
  const content = fs.readFileSync(filePath, "utf-8");
541
753
  return pattern.test(content);
542
754
  };
@@ -550,7 +762,7 @@ const detectReactCompiler = (directory, packageJson) => {
550
762
  let ancestorDirectory = path.dirname(directory);
551
763
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
552
764
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
553
- if (fs.existsSync(ancestorPackagePath)) {
765
+ if (isFile(ancestorPackagePath)) {
554
766
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
555
767
  }
556
768
  ancestorDirectory = path.dirname(ancestorDirectory);
@@ -559,9 +771,17 @@ const detectReactCompiler = (directory, packageJson) => {
559
771
  };
560
772
  const discoverProject = (directory) => {
561
773
  const packageJsonPath = path.join(directory, "package.json");
562
- if (!fs.existsSync(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
774
+ if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
563
775
  const packageJson = readPackageJson(packageJsonPath);
564
776
  let { reactVersion, framework } = extractDependencyInfo(packageJson);
777
+ if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory);
778
+ if (!reactVersion) {
779
+ const monorepoRoot = findMonorepoRoot(directory);
780
+ if (monorepoRoot) {
781
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
782
+ if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot);
783
+ }
784
+ }
565
785
  if (!reactVersion || framework === "unknown") {
566
786
  const workspaceInfo = findReactInWorkspaces(directory, packageJson);
567
787
  if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
@@ -661,10 +881,9 @@ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText
661
881
  //#region src/utils/load-config.ts
662
882
  const CONFIG_FILENAME = "react-doctor.config.json";
663
883
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
664
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
665
884
  const loadConfig = (rootDirectory) => {
666
885
  const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
667
- if (fs.existsSync(configFilePath)) try {
886
+ if (isFile(configFilePath)) try {
668
887
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
669
888
  const parsed = JSON.parse(fileContent);
670
889
  if (isPlainObject(parsed)) return parsed;
@@ -673,7 +892,7 @@ const loadConfig = (rootDirectory) => {
673
892
  console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
674
893
  }
675
894
  const packageJsonPath = path.join(rootDirectory, "package.json");
676
- if (fs.existsSync(packageJsonPath)) try {
895
+ if (isFile(packageJsonPath)) try {
677
896
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
678
897
  const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
679
898
  if (isPlainObject(embeddedConfig)) return embeddedConfig;
@@ -844,6 +1063,49 @@ const resolveNodeForOxlint = () => {
844
1063
  };
845
1064
  };
846
1065
 
1066
+ //#endregion
1067
+ //#region src/utils/resolve-lint-include-paths.ts
1068
+ const listSourceFilesViaGit = (rootDirectory) => {
1069
+ const result = spawnSync("git", [
1070
+ "ls-files",
1071
+ "--cached",
1072
+ "--others",
1073
+ "--exclude-standard"
1074
+ ], {
1075
+ cwd: rootDirectory,
1076
+ encoding: "utf-8",
1077
+ maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1078
+ });
1079
+ if (result.error || result.status !== 0) return null;
1080
+ return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1081
+ };
1082
+ const listSourceFilesViaFilesystem = (rootDirectory) => {
1083
+ const filePaths = [];
1084
+ const stack = [rootDirectory];
1085
+ while (stack.length > 0) {
1086
+ const currentDirectory = stack.pop();
1087
+ const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
1088
+ for (const entry of entries) {
1089
+ const absolutePath = path.join(currentDirectory, entry.name);
1090
+ if (entry.isDirectory()) {
1091
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
1092
+ continue;
1093
+ }
1094
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
1095
+ }
1096
+ }
1097
+ return filePaths;
1098
+ };
1099
+ const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
1100
+ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
1101
+ if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
1102
+ const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
1103
+ return listSourceFiles(rootDirectory).filter((filePath) => {
1104
+ if (!JSX_FILE_PATTERN.test(filePath)) return false;
1105
+ return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
1106
+ });
1107
+ };
1108
+
847
1109
  //#endregion
848
1110
  //#region src/utils/run-knip.ts
849
1111
  const KNIP_CATEGORY_MAP = {
@@ -932,7 +1194,7 @@ const runKnip = async (rootDirectory) => {
932
1194
  let knipResult;
933
1195
  if (monorepoRoot) {
934
1196
  const packageJsonPath = path.join(rootDirectory, "package.json");
935
- const workspaceName = (fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
1197
+ const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
936
1198
  try {
937
1199
  knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
938
1200
  } catch {
@@ -1102,24 +1364,27 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
1102
1364
 
1103
1365
  //#endregion
1104
1366
  //#region src/utils/neutralize-disable-directives.ts
1105
- const findFilesWithDisableDirectives = (rootDirectory) => {
1106
- const result = spawnSync("git", [
1367
+ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1368
+ const grepArgs = [
1107
1369
  "grep",
1108
1370
  "-l",
1109
1371
  "--untracked",
1110
1372
  "-E",
1111
1373
  "(eslint|oxlint)-disable"
1112
- ], {
1374
+ ];
1375
+ if (includePaths && includePaths.length > 0) grepArgs.push("--", ...includePaths);
1376
+ const result = spawnSync("git", grepArgs, {
1113
1377
  cwd: rootDirectory,
1114
1378
  encoding: "utf-8",
1115
1379
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1116
1380
  });
1117
1381
  if (result.error || result.status === null) return [];
1382
+ if (result.status !== 0 && result.stdout.trim().length === 0) return [];
1118
1383
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1119
1384
  };
1120
1385
  const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
1121
- const neutralizeDisableDirectives = (rootDirectory) => {
1122
- const filePaths = findFilesWithDisableDirectives(rootDirectory);
1386
+ const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1387
+ const filePaths = findFilesWithDisableDirectives(rootDirectory, includePaths);
1123
1388
  const originalContents = /* @__PURE__ */ new Map();
1124
1389
  for (const relativePath of filePaths) {
1125
1390
  const absolutePath = path.join(rootDirectory, relativePath);
@@ -1215,7 +1480,7 @@ const RULE_CATEGORY_MAP = {
1215
1480
  "react-doctor/rn-no-single-element-style-array": "React Native"
1216
1481
  };
1217
1482
  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} />`",
1483
+ "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
1484
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
1220
1485
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
1221
1486
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
@@ -1255,7 +1520,7 @@ const RULE_HELP_MAP = {
1255
1520
  "nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
1256
1521
  "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
1522
  "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",
1523
+ "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' directly (works in both server and client components), or handle in middleware",
1259
1524
  "nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
1260
1525
  "nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
1261
1526
  "nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
@@ -1343,7 +1608,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
1343
1608
  child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1344
1609
  child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1345
1610
  child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1346
- child.on("close", () => {
1611
+ child.on("close", (code, signal) => {
1612
+ if (signal) {
1613
+ const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1614
+ const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
1615
+ const detail = stderrOutput ? `: ${stderrOutput}` : "";
1616
+ reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
1617
+ return;
1618
+ }
1347
1619
  const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
1348
1620
  if (!output) {
1349
1621
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
@@ -1388,7 +1660,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1388
1660
  framework,
1389
1661
  hasReactCompiler
1390
1662
  });
1391
- const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
1663
+ const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths);
1392
1664
  try {
1393
1665
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1394
1666
  const baseArgs = [
@@ -1614,7 +1886,7 @@ const buildCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMillis
1614
1886
  renderedParts.push(highlighter.dim(fileCountText), highlighter.dim(elapsedTimeText));
1615
1887
  return createFramedLine(plainParts.join(" "), renderedParts.join(" "));
1616
1888
  };
1617
- const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
1889
+ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage, isOffline) => {
1618
1890
  printFramedBox([...buildBrandingLines(scoreResult, noScoreMessage), buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds)]);
1619
1891
  try {
1620
1892
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
@@ -1623,9 +1895,11 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1623
1895
  } catch {
1624
1896
  logger.break();
1625
1897
  }
1626
- const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
1627
- logger.break();
1628
- logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1898
+ if (!isOffline) {
1899
+ const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
1900
+ logger.break();
1901
+ logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1902
+ }
1629
1903
  };
1630
1904
  const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
1631
1905
  if (!isLintEnabled) return null;
@@ -1673,7 +1947,7 @@ const mergeScanOptions = (inputOptions, userConfig) => ({
1673
1947
  offline: inputOptions.offline ?? false,
1674
1948
  includePaths: inputOptions.includePaths ?? []
1675
1949
  });
1676
- const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths) => {
1950
+ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount) => {
1677
1951
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
1678
1952
  const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
1679
1953
  const completeStep = (message) => {
@@ -1684,7 +1958,7 @@ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths
1684
1958
  completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
1685
1959
  completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
1686
1960
  if (isDiffMode) completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`);
1687
- else completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
1961
+ else completeStep(`Found ${highlighter.info(`${lintSourceFileCount ?? projectInfo.sourceFileCount}`)} source files.`);
1688
1962
  if (userConfig) completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
1689
1963
  logger.break();
1690
1964
  };
@@ -1696,8 +1970,9 @@ const scan = async (directory, inputOptions = {}) => {
1696
1970
  const { includePaths } = options;
1697
1971
  const isDiffMode = includePaths.length > 0;
1698
1972
  if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
1699
- if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths);
1700
- const jsxIncludePaths = computeJsxIncludePaths(includePaths);
1973
+ const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(directory, userConfig);
1974
+ const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
1975
+ if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount);
1701
1976
  let didLintFail = false;
1702
1977
  let didDeadCodeFail = false;
1703
1978
  const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly);
@@ -1705,7 +1980,7 @@ const scan = async (directory, inputOptions = {}) => {
1705
1980
  const lintPromise = resolvedNodeBinaryPath ? (async () => {
1706
1981
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
1707
1982
  try {
1708
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths, resolvedNodeBinaryPath);
1983
+ const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, resolvedNodeBinaryPath);
1709
1984
  lintSpinner?.succeed("Running lint checks.");
1710
1985
  return lintDiagnostics;
1711
1986
  } catch (error) {
@@ -1745,8 +2020,8 @@ const scan = async (directory, inputOptions = {}) => {
1745
2020
  if (didLintFail) skippedChecks.push("lint");
1746
2021
  if (didDeadCodeFail) skippedChecks.push("dead code");
1747
2022
  const hasSkippedChecks = skippedChecks.length > 0;
1748
- const scoreResult = options.offline ? null : await calculateScore(diagnostics);
1749
- const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
2023
+ const scoreResult = options.offline ? calculateScoreLocally(diagnostics) : await calculateScore(diagnostics);
2024
+ const noScoreMessage = OFFLINE_MESSAGE;
1750
2025
  if (options.scoreOnly) {
1751
2026
  if (scoreResult) logger.log(`${scoreResult.score}`);
1752
2027
  else logger.dim(noScoreMessage);
@@ -1776,8 +2051,8 @@ const scan = async (directory, inputOptions = {}) => {
1776
2051
  };
1777
2052
  }
1778
2053
  printDiagnostics(diagnostics, options.verbose);
1779
- const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1780
- printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage);
2054
+ const displayedSourceFileCount = isDiffMode ? includePaths.length : lintSourceFileCount;
2055
+ printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, options.offline);
1781
2056
  if (hasSkippedChecks) {
1782
2057
  const skippedLabel = skippedChecks.join(" and ");
1783
2058
  logger.break();
@@ -2104,7 +2379,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
2104
2379
 
2105
2380
  //#endregion
2106
2381
  //#region src/cli.ts
2107
- const VERSION = "0.0.29";
2382
+ const VERSION = "0.0.31";
2108
2383
  const VALID_FAIL_ON_LEVELS = new Set([
2109
2384
  "error",
2110
2385
  "warning",
@@ -2157,7 +2432,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2157
2432
  if (effectiveDiff === false || !diffInfo) return false;
2158
2433
  const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
2159
2434
  if (changedSourceFiles.length === 0) return false;
2160
- if (shouldSkipPrompts) return true;
2435
+ if (shouldSkipPrompts) return false;
2161
2436
  if (isScoreOnly) return false;
2162
2437
  const { shouldScanChangedOnly } = await prompts({
2163
2438
  type: "confirm",
@@ -2167,7 +2442,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2167
2442
  });
2168
2443
  return Boolean(shouldScanChangedOnly);
2169
2444
  };
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) => {
2445
+ 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
2446
  const isScoreOnly = flags.score;
2172
2447
  try {
2173
2448
  const resolvedDirectory = path.resolve(directory);