react-doctor 0.7.0 → 0.7.1-dev.1880b15

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]="96cac03b-44a6-5d88-a5c9-697a5e8cf45e")}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]="47338856-c658-5c78-a5e2-e871c2dafaa7")}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";
@@ -22405,6 +22405,7 @@ var Diagnostic = class extends Class("Diagnostic")({
22405
22405
  endLine: optional(Number$1),
22406
22406
  endColumn: optional(Number$1),
22407
22407
  category: String$1,
22408
+ matchByOccurrence: optional(Boolean$1),
22408
22409
  fileContext: optional(Literals(["test", "story"])),
22409
22410
  suppressionHint: optional(String$1),
22410
22411
  relatedLocations: optional(ArraySchema(DiagnosticRelatedLocation)),
@@ -22475,6 +22476,16 @@ var JsonReportV1 = class extends Class("JsonReportV1")({
22475
22476
  * successful `baseline` run (`schemaVersion: 2`) and on non-compare scopes.
22476
22477
  */
22477
22478
  baselineDegraded: optional(Boolean$1),
22479
+ /**
22480
+ * Whether any scanned project resolved a React-compatible runtime (React
22481
+ * or Preact). `false` means every React-runtime rule family was gated off,
22482
+ * so an empty `diagnostics` array is vacuous — NOT the same as a clean
22483
+ * React scan. Consumers gating on the report (CI, verifiers, hooks) should
22484
+ * treat `reactDetected === false` as "wrong scan target", not "all clear".
22485
+ * Absent when nothing was scanned (`projects` is empty), on error reports,
22486
+ * and on reports from older CLI versions.
22487
+ */
22488
+ reactDetected: optional(Boolean$1),
22478
22489
  diff: NullOr(JsonReportDiffInfo),
22479
22490
  projects: ArraySchema(JsonReportProjectEntry),
22480
22491
  diagnostics: ArraySchema(Diagnostic),
@@ -22500,6 +22511,8 @@ var JsonReportV2 = class extends Class("JsonReportV2")({
22500
22511
  ok: Boolean$1,
22501
22512
  directory: String$1,
22502
22513
  mode: JsonReportMode,
22514
+ /** See `JsonReportV1.reactDetected`. */
22515
+ reactDetected: optional(Boolean$1),
22503
22516
  diff: NullOr(JsonReportDiffInfo),
22504
22517
  baseline: JsonReportBaseline,
22505
22518
  projects: ArraySchema(JsonReportProjectEntry),
@@ -36108,6 +36121,7 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
36108
36121
  "Accessibility",
36109
36122
  "Maintainability"
36110
36123
  ];
36124
+ const OCCURRENCE_MATCHED_CATEGORIES = new Set(["Accessibility"]);
36111
36125
  const APP_ONLY_RULE_KEYS = new Set([
36112
36126
  "react-hooks-js/static-components",
36113
36127
  "react-doctor/no-render-prop-children",
@@ -36130,7 +36144,7 @@ const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
36130
36144
  const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
36131
36145
  const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
36132
36146
  const SIDECAR_LINT_CACHE_FILENAME = "sidecar-lint-cache.json";
36133
- const CORE_PACKAGE_VERSION = "0.7.0";
36147
+ const CORE_PACKAGE_VERSION = "0.7.1";
36134
36148
  const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
36135
36149
  const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json";
36136
36150
  const SUPPLY_CHAIN_PLUGIN = "socket";
@@ -36248,6 +36262,23 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
36248
36262
  const listSourceFilesWithSize = (rootDirectory) => collectSizedSourceFiles(rootDirectory, (listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory)).sort());
36249
36263
  const listSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).map((entry) => entry.path);
36250
36264
  const countSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).length;
36265
+ /**
36266
+ * The chain of ancestor directories of `startDirectory`, nearest first, up to
36267
+ * and including the filesystem root. With `includeStart`, the chain begins at
36268
+ * `startDirectory` itself; otherwise at its parent. The one place the "walk up
36269
+ * the tree" traversal is expressed, so each caller adds only its own match /
36270
+ * stop policy instead of re-deriving the `path.dirname` loop.
36271
+ */
36272
+ const ancestorDirectories = (startDirectory, { includeStart }) => {
36273
+ const directories = [];
36274
+ let currentDirectory = includeStart ? startDirectory : Path.dirname(startDirectory);
36275
+ while (true) {
36276
+ directories.push(currentDirectory);
36277
+ const parentDirectory = Path.dirname(currentDirectory);
36278
+ if (parentDirectory === currentDirectory) return directories;
36279
+ currentDirectory = parentDirectory;
36280
+ }
36281
+ };
36251
36282
  const cachedPackageJsons = /* @__PURE__ */ new Map();
36252
36283
  const readPackageJsonUncached = (packageJsonPath) => {
36253
36284
  try {
@@ -36278,11 +36309,7 @@ const isMonorepoRoot = (directory) => {
36278
36309
  return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
36279
36310
  };
36280
36311
  const findMonorepoRoot = (startDirectory) => {
36281
- let currentDirectory = Path.dirname(startDirectory);
36282
- while (currentDirectory !== Path.dirname(currentDirectory)) {
36283
- if (isMonorepoRoot(currentDirectory)) return currentDirectory;
36284
- currentDirectory = Path.dirname(currentDirectory);
36285
- }
36312
+ for (const directory of ancestorDirectories(startDirectory, { includeStart: false })) if (isMonorepoRoot(directory)) return directory;
36286
36313
  return null;
36287
36314
  };
36288
36315
  /**
@@ -36928,6 +36955,74 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
36928
36955
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
36929
36956
  };
36930
36957
  };
36958
+ /**
36959
+ * Walk up from `startDirectory` to the nearest ancestor that owns a
36960
+ * `package.json`, stopping at (and including) the enclosing project boundary
36961
+ * — the working tree's git root or a monorepo root. Lets a scan of a package
36962
+ * subfolder that has no `package.json` of its own adopt the nearest enclosing
36963
+ * package as its project root, so it inherits that package's dependency +
36964
+ * framework detection instead of synthesizing an empty (React-blind) project.
36965
+ *
36966
+ * Generalizes the older monorepo-root-only lookup: the nearest ancestor is the
36967
+ * most specific owning package (a leaf workspace, not just the repo root), so
36968
+ * a subfolder of one workspace no longer borrows a sibling's capabilities.
36969
+ *
36970
+ * Returns `null` when no ancestor `package.json` exists inside the project
36971
+ * boundary (a loose tree of files, or a subfolder of a package-less repo).
36972
+ */
36973
+ const findNearestAncestorPackageJson = (startDirectory) => {
36974
+ if (isProjectBoundary(startDirectory)) return null;
36975
+ for (const directory of ancestorDirectories(startDirectory, { includeStart: false })) {
36976
+ if (isFile(Path.join(directory, "package.json"))) return directory;
36977
+ if (isProjectBoundary(directory)) return null;
36978
+ }
36979
+ return null;
36980
+ };
36981
+ /**
36982
+ * Last-resort React detection: locate React the way Node's `node_modules`
36983
+ * resolution would from `directory` — check each `node_modules/react` up the
36984
+ * tree — but bounded to the enclosing repo so a globally installed React can't
36985
+ * masquerade as the project's version. Stops at (and including) the nearest
36986
+ * project boundary — the git root or a monorepo root — and returns the first
36987
+ * installed `version`.
36988
+ *
36989
+ * Makes "React is installed and importable" ⇒ "React is detected" an invariant
36990
+ * for packages whose only React declaration is a version-less spec
36991
+ * (`workspace:*`, `*`, a dist-tag) or where React lives solely in a hoisted
36992
+ * `node_modules` the declaration walks never reach — the profile of a component
36993
+ * package inside a monorepo. Matching the `node_modules/react` entry rather than
36994
+ * its realpath means a project whose `node_modules` is symlinked elsewhere (a
36995
+ * Docker volume, a shared store) still counts, while a global install outside
36996
+ * the repo does not.
36997
+ *
36998
+ * Returns `null` when React isn't installed within the repo or its package.json
36999
+ * carries no version string. A tree with no boundary marker at all bounds the
37000
+ * search to `directory`, so a dependency hoisted above the scanned package there
37001
+ * is conservatively not adopted.
37002
+ */
37003
+ /**
37004
+ * The nearest enclosing project boundary (git root or monorepo root), or
37005
+ * `directory` itself when the scan target sits outside any repo. Bounds the
37006
+ * node_modules walk so a React hoisted anywhere inside the repo counts, but a
37007
+ * React in an ancestor above the repo — a home-directory or global
37008
+ * `node_modules` — can't leak in.
37009
+ */
37010
+ const findContainmentRoot = (directory) => {
37011
+ for (const ancestorDirectory of ancestorDirectories(directory, { includeStart: true })) if (isProjectBoundary(ancestorDirectory)) return ancestorDirectory;
37012
+ return directory;
37013
+ };
37014
+ const resolveInstalledReactVersion = (directory) => {
37015
+ const containmentRoot = findContainmentRoot(directory);
37016
+ for (const ancestorDirectory of ancestorDirectories(directory, { includeStart: true })) {
37017
+ const reactPackageJsonPath = Path.join(ancestorDirectory, "node_modules", "react", "package.json");
37018
+ if (isFile(reactPackageJsonPath)) {
37019
+ const installedVersion = readPackageJson$1(reactPackageJsonPath).version;
37020
+ return typeof installedVersion === "string" ? installedVersion : null;
37021
+ }
37022
+ if (ancestorDirectory === containmentRoot) return null;
37023
+ }
37024
+ return null;
37025
+ };
36931
37026
  const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
36932
37027
  const rootValue = select(rootPackageJson);
36933
37028
  if (rootValue !== null) return rootValue;
@@ -37264,19 +37359,20 @@ const discoverReactSubprojects = (rootDirectory) => {
37264
37359
  const cachedProjectInfos = /* @__PURE__ */ new Map();
37265
37360
  /**
37266
37361
  * Build a `ProjectInfo` for a directory that has no `package.json` of
37267
- * its own — a monorepo subfolder like `repo/packages`, or any loose tree
37268
- * of TypeScript/JavaScript files. Dependency + framework detection is
37269
- * inherited from the enclosing workspace root when there is one, so
37270
- * scanning a subdirectory of a React monorepo still gets the React
37271
- * capabilities; a standalone non-React directory simply scans with the
37272
- * framework-agnostic rules. Throws only when the directory has nothing
37273
- * to scan (no enclosing project and no source files of its own).
37362
+ * its own — a package subfolder like `repo/packages` or `app/src/features`,
37363
+ * or any loose tree of TypeScript/JavaScript files. Dependency + framework
37364
+ * detection is inherited from the nearest enclosing package (a leaf
37365
+ * workspace, a plain app root, or a monorepo root — whichever is closest,
37366
+ * bounded by the git root), so scanning a subdirectory of a React project
37367
+ * still gets the React capabilities; a standalone non-React directory simply
37368
+ * scans with the framework-agnostic rules. Throws only when the directory has
37369
+ * nothing to scan (no enclosing project and no source files of its own).
37274
37370
  */
37275
37371
  const discoverProjectWithoutPackageJson = (directory) => {
37276
37372
  const sourceFileCount = countSourceFiles(directory);
37277
37373
  const hasOwnTsConfig = NFS.existsSync(Path.join(directory, "tsconfig.json"));
37278
- const monorepoRoot = findMonorepoRoot(directory);
37279
- const enclosingProject = monorepoRoot !== null && isFile(Path.join(monorepoRoot, "package.json")) ? discoverProject(monorepoRoot) : null;
37374
+ const enclosingProjectRoot = findNearestAncestorPackageJson(directory);
37375
+ const enclosingProject = enclosingProjectRoot !== null ? discoverProject(enclosingProjectRoot) : null;
37280
37376
  if (enclosingProject !== null) return {
37281
37377
  ...enclosingProject,
37282
37378
  rootDirectory: directory,
@@ -37380,6 +37476,7 @@ const discoverProject = (directory) => {
37380
37476
  if (!reactVersion && reactDeclaration.version && !isCatalogReference(reactDeclaration.version)) reactVersion = reactDeclaration.version;
37381
37477
  if (!tailwindVersion && tailwindDeclaration.version && !isCatalogReference(tailwindDeclaration.version)) tailwindVersion = tailwindDeclaration.version;
37382
37478
  if (!zodVersion && zodDeclaration.version && !isCatalogReference(zodDeclaration.version)) zodVersion = zodDeclaration.version;
37479
+ if (!reactVersion || parseReactMajor(reactVersion) === null) reactVersion = resolveInstalledReactVersion(directory) ?? reactVersion;
37383
37480
  const projectName = packageJson.name ?? Path.basename(directory);
37384
37481
  const hasTypeScript = NFS.existsSync(Path.join(directory, "tsconfig.json"));
37385
37482
  const sourceFileCount = countSourceFiles(directory);
@@ -40237,10 +40334,17 @@ function* collectSecurityScanFiles(rootDirectory) {
40237
40334
  }
40238
40335
  }
40239
40336
  }
40337
+ /**
40338
+ * Whether project discovery resolved a React-compatible runtime (React or
40339
+ * Preact, which ships the same hooks + JSX model). This is the same
40340
+ * predicate that grants the `react` capability, so `false` means every
40341
+ * React-runtime rule family was gated off for the project.
40342
+ */
40343
+ const hasReactRuntime = (project) => project.reactVersion !== null || project.preactVersion !== null;
40240
40344
  const buildCapabilities = (project) => {
40241
40345
  const capabilities = /* @__PURE__ */ new Set();
40242
40346
  capabilities.add(project.framework);
40243
- if (project.reactVersion !== null || project.preactVersion !== null) capabilities.add("react");
40347
+ if (hasReactRuntime(project)) capabilities.add("react");
40244
40348
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
40245
40349
  if (project.expoVersion !== null) capabilities.add("expo");
40246
40350
  if (project.nextjsMajorVersion !== null && project.nextjsMajorVersion >= 15) capabilities.add("nextjs:15");
@@ -41786,7 +41890,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
41786
41890
  })),
41787
41891
  showStagedContent: (directory, relativePath, options) => runCommand({
41788
41892
  command: "git",
41789
- args: ["show", `:${relativePath}`],
41893
+ args: ["show", `:./${relativePath}`],
41790
41894
  directory,
41791
41895
  maxStdoutBytes: options?.maxBufferBytes
41792
41896
  }).pipe(map$3((result) => result.status === 0 ? result.stdout : null)),
@@ -43092,6 +43196,7 @@ const parseRuleCode = (code) => {
43092
43196
  };
43093
43197
  };
43094
43198
  const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs";
43199
+ const resolveMatchByOccurrence = (rule, category) => OCCURRENCE_MATCHED_CATEGORIES.has(category) || Boolean(reactDoctorPlugin.rules[rule]?.matchByOccurrence);
43095
43200
  /**
43096
43201
  * Maps oxlint's non-primary labels (`labels[1..]`) into related source
43097
43202
  * locations. Editors surface these as a diagnostic's
@@ -43155,6 +43260,7 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
43155
43260
  const normalizedFilePath = diagnostic.filename.replaceAll("\\", "/");
43156
43261
  const primarySpan = primaryLabel?.span;
43157
43262
  const relatedLocations = buildRelatedLocations(diagnostic.labels, normalizedFilePath);
43263
+ const category = resolveDiagnosticCategory(plugin, rule);
43158
43264
  return {
43159
43265
  filePath: normalizedFilePath,
43160
43266
  plugin,
@@ -43170,7 +43276,8 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
43170
43276
  offset: primarySpan.offset,
43171
43277
  length: primarySpan.length
43172
43278
  } : {},
43173
- category: resolveDiagnosticCategory(plugin, rule),
43279
+ category,
43280
+ ...resolveMatchByOccurrence(rule, category) ? { matchByOccurrence: true } : {},
43174
43281
  ...relatedLocations.length > 0 ? { relatedLocations } : {}
43175
43282
  };
43176
43283
  });
@@ -45355,6 +45462,7 @@ const buildJsonReport = (input) => {
45355
45462
  const worstScoredProject = findWorstScoredProject(projects);
45356
45463
  const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
45357
45464
  const shared = {
45465
+ ...input.scans.length > 0 ? { reactDetected: input.scans.some((scan) => hasReactRuntime(scan.result.project)) } : {},
45358
45466
  version: input.version,
45359
45467
  ok: true,
45360
45468
  directory: input.directory,
@@ -45405,7 +45513,7 @@ const buildSkippedChecks = (input) => {
45405
45513
  };
45406
45514
  const fingerprintDiagnostic = (diagnostic, lineText) => {
45407
45515
  const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
45408
- const snippet = lineText === null ? "" : createHash("sha1").update(lineText.trim()).digest("hex");
45516
+ const snippet = lineText === null || diagnostic.matchByOccurrence ? "" : createHash("sha1").update(lineText.trim()).digest("hex");
45409
45517
  return `${diagnostic.filePath}\u0000${ruleKey}\u0000${snippet}`;
45410
45518
  };
45411
45519
  /**
@@ -45416,6 +45524,17 @@ const fingerprintDiagnostic = (diagnostic, lineText) => {
45416
45524
  * look new, while a genuinely new occurrence (new line text, or one more of
45417
45525
  * the same) surfaces. Identical repeated findings are matched by count.
45418
45526
  *
45527
+ * Diagnostics carrying `matchByOccurrence` (resolved at diagnostic creation:
45528
+ * every Accessibility-category finding, plus rules opting in via their
45529
+ * `matchByOccurrence` metadata flag) drop the line-text snippet and match by
45530
+ * `(filePath, plugin/rule)` occurrence count alone. Their identity is the
45531
+ * flagged element, not the line's text, so editing the line (reindentation,
45532
+ * prettier reflow, collapsing a multi-line JSX element) doesn't reclassify a
45533
+ * pre-existing finding as new — while one MORE occurrence of the same rule in
45534
+ * the file still surfaces. Expression-level rules keep the line-text snippet:
45535
+ * there the flagged expression IS the finding, so changed text means new +
45536
+ * fixed.
45537
+ *
45419
45538
  * v1 limitation: the fingerprint keys on the head-relative `filePath`, and base
45420
45539
  * content is read at that same path. A file renamed by the change therefore has
45421
45540
  * no base match, so its pre-existing findings are reported as new. This
@@ -45818,6 +45937,7 @@ const METRIC = {
45818
45937
  scanScore: "scan.score",
45819
45938
  scanClean: "scan.clean",
45820
45939
  scanCheckSkipped: "scan.check_skipped",
45940
+ scanNoReactDetected: "scan.no_react_detected",
45821
45941
  baselineDegraded: "baseline.degraded",
45822
45942
  ruleFired: "rule.fired",
45823
45943
  ruleDisabled: "rule.disabled",
@@ -45887,7 +46007,7 @@ const makeNoopConsole = () => ({
45887
46007
  });
45888
46008
  //#endregion
45889
46009
  //#region src/cli/utils/version.ts
45890
- const VERSION = "0.7.0";
46010
+ const VERSION = "0.7.1-dev.1880b15";
45891
46011
  //#endregion
45892
46012
  //#region src/cli/utils/json-mode.ts
45893
46013
  let context = null;
@@ -46256,13 +46376,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
46256
46376
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
46257
46377
  * standard `SENTRY_RELEASE` override.
46258
46378
  */
46259
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.7.0`;
46379
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.7.1-dev.1880b15`;
46260
46380
  /**
46261
46381
  * Deployment environment shown in Sentry's environment filter. Defaults to
46262
46382
  * `production` for tagged releases and `development` for dev/unbuilt versions,
46263
46383
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
46264
46384
  */
46265
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.7.0") ? "development" : "production");
46385
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.7.1-dev.1880b15") ? "development" : "production");
46266
46386
  /**
46267
46387
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
46268
46388
  * (set to `0` to disable tracing) and falls back to
@@ -58106,7 +58226,7 @@ const buildRuntimeLayers = (input) => {
58106
58226
  const linterLayer = input.shouldSkipLint ? Linter.layerOf([]) : Linter.layerOxlint;
58107
58227
  const deadCodeLayer = input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]);
58108
58228
  const scoreLayer = input.shouldComputeScore ? Score.layerHttp : Score.layerOf(null);
58109
- const supplyChainLayer = input.userConfig?.supplyChain?.enabled === false ? SupplyChain.layerOf([]) : SupplyChain.layerNode;
58229
+ const supplyChainLayer = input.shouldRunSupplyChain ? SupplyChain.layerNode : SupplyChain.layerOf([]);
58110
58230
  const projectLayer = input.projectInfoOverride === void 0 ? Project.layerNode : Project.layerOf(input.projectInfoOverride);
58111
58231
  const progressLayer = input.shouldShowProgressSpinners ? Progress.layerOra(buildSpinnerProgressHandle) : Progress.layerNoop;
58112
58232
  const baseLayers = mergeAll$1(projectLayer, input.hasConfigOverride ? Config.layerOf({
@@ -58462,21 +58582,22 @@ const resolveDotenvFingerprint = (projectDirectory) => {
58462
58582
  return [];
58463
58583
  }
58464
58584
  };
58465
- const hasHiddenTrackedFileState = (projectDirectory) => {
58585
+ const isGitIdentityTrustworthy = (projectDirectory) => {
58466
58586
  const output = runGit(projectDirectory, ["ls-files", "-v"]);
58467
- if (output === null) return true;
58468
- return output.split("\n").some((line) => line.length > 0 && line[0] !== "H");
58587
+ if (output === null) return false;
58588
+ const entryLines = output.split("\n").filter((line) => line.length > 0);
58589
+ return entryLines.length > 0 && entryLines.every((line) => line[0] === "H");
58469
58590
  };
58470
58591
  const resolveCacheFilePath = (projectDirectory) => path.join(resolveReactDoctorCacheDir(projectDirectory), SCAN_RESULT_CACHE_FILENAME);
58471
58592
  const readPersistedCache = (cacheFilePath) => {
58472
58593
  try {
58473
58594
  const parsed = JSON.parse(fs.readFileSync(cacheFilePath, "utf8"));
58474
- if (!isRecord(parsed) || parsed.version !== 3) return {
58475
- version: 3,
58595
+ if (!isRecord(parsed) || parsed.version !== 4) return {
58596
+ version: 4,
58476
58597
  entries: []
58477
58598
  };
58478
58599
  if (!Array.isArray(parsed.entries)) return {
58479
- version: 3,
58600
+ version: 4,
58480
58601
  entries: []
58481
58602
  };
58482
58603
  const entries = [];
@@ -58486,12 +58607,12 @@ const readPersistedCache = (cacheFilePath) => {
58486
58607
  entries.push(entry);
58487
58608
  }
58488
58609
  return {
58489
- version: 3,
58610
+ version: 4,
58490
58611
  entries
58491
58612
  };
58492
58613
  } catch {
58493
58614
  return {
58494
- version: 3,
58615
+ version: 4,
58495
58616
  entries: []
58496
58617
  };
58497
58618
  }
@@ -58523,6 +58644,7 @@ const resolveProjectIdentity = (projectDirectory) => {
58523
58644
  return path.resolve(projectDirectory);
58524
58645
  }
58525
58646
  };
58647
+ const resolveManifestContentHash = (projectDirectory) => hashFileContents(getPackageJsonPath(projectDirectory));
58526
58648
  const fileFingerprint = (filePath) => {
58527
58649
  try {
58528
58650
  const stat = fs.statSync(filePath);
@@ -58545,14 +58667,14 @@ const resolveToolchainFingerprint = (nodeBinaryPath) => {
58545
58667
  };
58546
58668
  const buildScanResultCacheKey = (input) => {
58547
58669
  if (isCacheGloballyDisabled()) return null;
58548
- if (hasHiddenTrackedFileState(input.projectDirectory)) return null;
58670
+ if (!isGitIdentityTrustworthy(input.projectDirectory)) return null;
58549
58671
  const worktreeFingerprint = buildWorktreeFingerprint(input.projectDirectory);
58550
58672
  if (worktreeFingerprint === null) return null;
58551
58673
  const headSha = readHeadSha(input.projectDirectory);
58552
58674
  if (headSha === null) return null;
58553
58675
  if (stringifyStableJson(input.userConfig) === null) return null;
58554
58676
  const cacheKeyJson = stringifyStableJson({
58555
- schemaVersion: 3,
58677
+ schemaVersion: 4,
58556
58678
  projectIdentity: resolveProjectIdentity(input.projectDirectory),
58557
58679
  headSha,
58558
58680
  worktreeFingerprint,
@@ -58567,6 +58689,7 @@ const buildScanResultCacheKey = (input) => {
58567
58689
  engineOptions: {
58568
58690
  lint: input.options.lint,
58569
58691
  deadCode: input.options.deadCode,
58692
+ supplyChain: input.options.supplyChain,
58570
58693
  includePaths: [...input.options.includePaths].sort(),
58571
58694
  customRulesOnly: input.options.customRulesOnly,
58572
58695
  respectInlineDisables: input.options.respectInlineDisables,
@@ -58592,17 +58715,24 @@ const createScanResultCache = (projectDirectory) => {
58592
58715
  for (const entry of persistedCache.entries) entries.set(entry.key, entry);
58593
58716
  const persist = () => {
58594
58717
  writePersistedCache(cacheFilePath, {
58595
- version: 3,
58718
+ version: 4,
58596
58719
  entries: [...entries.values()].sort((firstEntry, secondEntry) => secondEntry.createdAtMs - firstEntry.createdAtMs).slice(0, 20)
58597
58720
  });
58598
58721
  };
58599
58722
  return {
58600
- lookup: (key) => entries.get(key)?.payload ?? null,
58723
+ lookup: (key) => {
58724
+ const payload = entries.get(key)?.payload;
58725
+ if (payload === void 0) return null;
58726
+ return resolveProjectIdentity(payload.directory) === resolveProjectIdentity(projectDirectory) && (payload.manifestContentHash ?? null) === resolveManifestContentHash(projectDirectory) ? payload : null;
58727
+ },
58601
58728
  store: (key, payload) => {
58602
58729
  entries.set(key, {
58603
58730
  key,
58604
58731
  createdAtMs: Date.now(),
58605
- payload
58732
+ payload: {
58733
+ ...payload,
58734
+ manifestContentHash: resolveManifestContentHash(projectDirectory)
58735
+ }
58606
58736
  });
58607
58737
  persist();
58608
58738
  }
@@ -58867,6 +58997,7 @@ const buildScanAttributes = (input) => {
58867
58997
  maxDurationMs: input.maxDurationMs,
58868
58998
  lint: input.lint,
58869
58999
  deadCode: input.deadCode,
59000
+ supplyChain: input.supplyChain,
58870
59001
  scoreOnly: input.scoreOnly,
58871
59002
  noScore: input.noScore,
58872
59003
  respectInlineDisables: input.respectInlineDisables,
@@ -60141,6 +60272,7 @@ const buildIgnoredTags = (userConfig) => {
60141
60272
  const mergeInspectOptions = (inputOptions, userConfig) => ({
60142
60273
  lint: inputOptions.lint ?? userConfig?.lint ?? true,
60143
60274
  deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
60275
+ supplyChain: inputOptions.supplyChain ?? userConfig?.supplyChain?.enabled ?? true,
60144
60276
  verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
60145
60277
  outputDirectory: inputOptions.outputDirectory || null,
60146
60278
  scoreOnly: inputOptions.scoreOnly ?? false,
@@ -60180,6 +60312,7 @@ const buildRunEventConfig = (options, userConfig, hasCustomConfig, resolvedWorke
60180
60312
  maxDurationMs: options.maxDurationMs,
60181
60313
  lint: options.lint,
60182
60314
  deadCode: options.deadCode,
60315
+ supplyChain: options.supplyChain,
60183
60316
  scoreOnly: options.scoreOnly,
60184
60317
  noScore: options.noScore,
60185
60318
  respectInlineDisables: options.respectInlineDisables,
@@ -60263,6 +60396,7 @@ const runBaselineComparison = async (params) => {
60263
60396
  projectInfoOverride: params.headProjectInfo,
60264
60397
  shouldSkipLint: !params.options.lint || !params.resolvedNodeBinaryPath,
60265
60398
  shouldRunDeadCode: false,
60399
+ shouldRunSupplyChain: params.options.supplyChain,
60266
60400
  shouldComputeScore: false,
60267
60401
  shouldShowProgressSpinners: false,
60268
60402
  oxlintConcurrency: params.options.concurrency
@@ -60351,6 +60485,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
60351
60485
  configSourceDirectory,
60352
60486
  shouldSkipLint: !options.lint || lintBindingMissing,
60353
60487
  shouldRunDeadCode: options.deadCode,
60488
+ shouldRunSupplyChain: options.supplyChain,
60354
60489
  shouldComputeScore: !options.noScore,
60355
60490
  shouldShowProgressSpinners,
60356
60491
  oxlintConcurrency: options.concurrency
@@ -62636,8 +62771,8 @@ const resolveParallelFlag = (parallel) => parallel === false ? 1 : void 0;
62636
62771
  * accepts. Flag-derived fields only (`scoreOnly`, `noScore`, `silent`,
62637
62772
  * `isCi`): every `userConfig` fallback — including `noScore` — lives in
62638
62773
  * `inspect()`'s merge layer, which sees each project's module-merged
62639
- * config. The plain boolean knobs (`lint`, `deadCode`, `verbose`) pass
62640
- * through unchanged for the same reason. This resolver reads
62774
+ * config. The plain boolean knobs (`lint`, `deadCode`, `supplyChain`,
62775
+ * `verbose`) pass through unchanged for the same reason. This resolver reads
62641
62776
  * `userConfig` only to decide the `--blocking warning` gate.
62642
62777
  */
62643
62778
  const resolveCliInspectOptions = (flags, userConfig) => {
@@ -62645,6 +62780,7 @@ const resolveCliInspectOptions = (flags, userConfig) => {
62645
62780
  return {
62646
62781
  lint: flags.lint,
62647
62782
  deadCode: flags.deadCode,
62783
+ supplyChain: flags.supplyChain,
62648
62784
  verbose: flags.verbose,
62649
62785
  outputDirectory: flags.outputDir,
62650
62786
  respectInlineDisables: flags.respectInlineDisables === false ? false : void 0,
@@ -62975,6 +63111,11 @@ const finalizeScans = (input) => {
62975
63111
  const baselineComputed = input.completedScans.length > 0 && input.completedScans.every((scan) => scan.result.baselineDelta !== void 0);
62976
63112
  const baselineDegraded = input.baselineIntended && !baselineComputed;
62977
63113
  const mode = baselineDegraded ? "diff" : input.mode;
63114
+ const isReactDetected = input.completedScans.some((scan) => hasReactRuntime(scan.result.project));
63115
+ if (input.completedScans.length > 0 && !isReactDetected) {
63116
+ recordCount(METRIC.scanNoReactDetected, 1);
63117
+ cliLogger.warn(`No React project detected at ${input.resolvedDirectory} — React rules were gated off; this is not the same as a clean scan.`);
63118
+ }
62978
63119
  const jsonCompletedScans = filterCompletedScansByCategories(input.completedScans, input.categoryFilters);
62979
63120
  if (input.isJsonMode) {
62980
63121
  const baseline = baselineComputed && baselineDeltas.length > 0 ? {
@@ -63190,7 +63331,7 @@ const inspectAction = async (directory, flags) => {
63190
63331
  const scanDirectory = projectScanTarget.resolvedDirectory;
63191
63332
  const projectConfig = projectDirectory === resolvedDirectory ? userConfig : mergeReactDoctorConfigs(userConfig, projectScanTarget.userConfig ?? void 0);
63192
63333
  const projectConfigSourceDirectory = projectScanTarget.userConfig?.plugins === void 0 ? scanTarget.configSourceDirectory : projectScanTarget.configSourceDirectory;
63193
- const supplyChainEnabled = projectConfig?.supplyChain?.enabled !== false;
63334
+ const supplyChainEnabled = flags.supplyChain ?? projectConfig?.supplyChain?.enabled !== false;
63194
63335
  let includePaths;
63195
63336
  let supplyChainManifestChanged = false;
63196
63337
  if (isDiffMode) {
@@ -64110,10 +64251,12 @@ const ROOT_FLAG_SPEC = {
64110
64251
  "--no-parallel",
64111
64252
  "--no-respect-inline-disables",
64112
64253
  "--no-score",
64254
+ "--no-supply-chain",
64113
64255
  "--no-telemetry",
64114
64256
  "--no-warnings",
64115
64257
  "--score",
64116
64258
  "--staged",
64259
+ "--supply-chain",
64117
64260
  "--verbose",
64118
64261
  "--version",
64119
64262
  "--warnings",
@@ -64362,7 +64505,7 @@ ${highlighter.dim("Learn more:")}
64362
64505
  ${highlighter.info(CI_URL)}
64363
64506
  `;
64364
64507
  const collectCategoryOption = (value, previousValues) => [...previousValues ?? [], value];
64365
- 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("--max-duration <seconds>", "scan time budget for the whole run, shared across workspace projects: past it, remaining lint batches and dead-code are skipped and partial results are reported (skipped files are listed in the JSON report)").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);
64508
+ 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("--supply-chain", "enable the dependency supply-chain scan (default)").option("--no-supply-chain", "skip the dependency supply-chain scan (Socket.dev dependency health checks)").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("--max-duration <seconds>", "scan time budget for the whole run, shared across workspace projects: past it, remaining lint batches and dead-code are skipped and partial results are reported (skipped files are listed in the JSON report)").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);
64366
64509
  program.action(inspectAction);
64367
64510
  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));
64368
64511
  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);
@@ -64412,4 +64555,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
64412
64555
  export {};
64413
64556
 
64414
64557
  //# sourceMappingURL=cli.js.map
64415
- //# debugId=96cac03b-44a6-5d88-a5c9-697a5e8cf45e
64558
+ //# debugId=47338856-c658-5c78-a5e2-e871c2dafaa7
package/dist/index.d.ts CHANGED
@@ -9493,6 +9493,17 @@ interface Diagnostic {
9493
9493
  /** 1-indexed end column of the primary span, when derivable. */
9494
9494
  endColumn?: number;
9495
9495
  category: string;
9496
+ /**
9497
+ * Set when the finding's identity is the flagged element itself (a missing
9498
+ * attribute, a wrong element) rather than the flagged line's text — every
9499
+ * Accessibility-category finding, plus rules that opt in via their
9500
+ * `matchByOccurrence` metadata flag. `computeDiagnosticDelta` matches these
9501
+ * by `(filePath, plugin/rule)` occurrence count, so reformatting the flagged
9502
+ * line doesn't reclassify a pre-existing finding as new. Resolved at
9503
+ * diagnostic creation, where rule metadata is available. Absent means
9504
+ * line-text-sensitive matching (the default).
9505
+ */
9506
+ matchByOccurrence?: boolean;
9496
9507
  /**
9497
9508
  * Set when the file never ships to users (`"test"` / `"story"`), so
9498
9509
  * renderers can label the site instead of implying production impact.
@@ -9644,6 +9655,17 @@ interface DiagnoseResult {
9644
9655
  /** See `InspectResult.skippedCheckReasons`. */
9645
9656
  skippedCheckReasons?: Record<string, string>;
9646
9657
  project: ProjectInfo;
9658
+ /**
9659
+ * Whether the scanned project resolved a React-compatible runtime (React
9660
+ * or Preact). `false` means every React-runtime rule family was gated
9661
+ * off, so an empty `diagnostics` array is vacuous — NOT the same as a
9662
+ * clean React scan. Consumers gating on the result should treat
9663
+ * `reactDetected === false` as "wrong scan target", not "all clear".
9664
+ * Mirrors `JsonReport.reactDetected`; same predicate as
9665
+ * `hasReactRuntime(result.project)`. Always set by `diagnose()`;
9666
+ * optional so hand-constructed results keep compiling.
9667
+ */
9668
+ reactDetected?: boolean;
9647
9669
  elapsedMilliseconds: number;
9648
9670
  }
9649
9671
  /**
@@ -9693,6 +9715,13 @@ interface DiagnoseProjectsResult {
9693
9715
  projects: ProjectResult[];
9694
9716
  diagnostics: Diagnostic[];
9695
9717
  score: ScoreResult | null;
9718
+ /**
9719
+ * Whether any successfully scanned project resolved a React-compatible
9720
+ * runtime (React or Preact). Absent when no project scanned successfully.
9721
+ * See `DiagnoseResult.reactDetected` for gating guidance; per-project
9722
+ * detail is on each `ProjectResultOk.reactDetected`.
9723
+ */
9724
+ reactDetected?: boolean;
9696
9725
  elapsedMilliseconds: number;
9697
9726
  } //#endregion
9698
9727
  //#region src/types/handle-error.d.ts
@@ -9888,6 +9917,17 @@ interface JsonReportV1$1 {
9888
9917
  ok: boolean;
9889
9918
  directory: string;
9890
9919
  mode: JsonReportMode;
9920
+ /**
9921
+ * Whether any scanned project resolved a React-compatible runtime (React
9922
+ * or Preact). `false` means every React-runtime rule family was gated off,
9923
+ * so an empty `diagnostics` array is vacuous — NOT the same as a clean
9924
+ * React scan. Consumers gating on the report (CI, verifiers, hooks) should
9925
+ * treat `reactDetected === false` as "wrong scan target", not "all clear"
9926
+ * (per project, `projects[].project.reactVersion` / `preactVersion` say
9927
+ * which roots were non-React). Absent when nothing was scanned (`projects`
9928
+ * is empty), on error reports, and on reports from older CLI versions.
9929
+ */
9930
+ reactDetected?: boolean;
9891
9931
  diff: JsonReportDiffInfo | null;
9892
9932
  projects: JsonReportProjectEntry[];
9893
9933
  /**
@@ -10172,6 +10212,16 @@ declare const summarizeDiagnostics: (diagnostics: Diagnostic[], worstScore?: num
10172
10212
  declare const defineConfig: (config: ReactDoctorConfig) => ReactDoctorConfig; //#endregion
10173
10213
  //#region src/utils/detect-ai-training-environment.d.ts
10174
10214
  //#endregion
10215
+ //#region src/utils/has-react-runtime.d.ts
10216
+ /**
10217
+ * Whether project discovery resolved a React-compatible runtime (React or
10218
+ * Preact, which ships the same hooks + JSX model). This is the same
10219
+ * predicate that grants the `react` capability, so `false` means every
10220
+ * React-runtime rule family was gated off for the project.
10221
+ */
10222
+ declare const hasReactRuntime: (project: ProjectInfo) => boolean; //#endregion
10223
+ //#region src/utils/is-errno-exception.d.ts
10224
+ //#endregion
10175
10225
  //#region ../api/dist/index.d.ts
10176
10226
  //#region src/diagnose.d.ts
10177
10227
  interface Diagnose {
@@ -10198,5 +10248,5 @@ interface ToJsonReportOptions {
10198
10248
  }
10199
10249
  declare const toJsonReport: (result: DiagnoseResult, options: ToJsonReportOptions) => JsonReport;
10200
10250
  //#endregion
10201
- export { AmbiguousProjectError, type DiagnoseOptions, type DiagnoseProjectsInput, type DiagnoseProjectsResult, type DiagnoseResult, type Diagnostic, type DiffInfo, type JsonReport, type JsonReportDiffInfo, type JsonReportError, type JsonReportMode, type JsonReportProjectEntry, type JsonReportSummary, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, type ProjectDefinition, type ProjectInfo, ProjectNotFoundError, type ProjectResult, type ProjectResultError, type ProjectResultOk, type ReactDoctorConfig, ReactDoctorError, type ScoreResult, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
10251
+ export { AmbiguousProjectError, type DiagnoseOptions, type DiagnoseProjectsInput, type DiagnoseProjectsResult, type DiagnoseResult, type Diagnostic, type DiffInfo, type JsonReport, type JsonReportDiffInfo, type JsonReportError, type JsonReportMode, type JsonReportProjectEntry, type JsonReportSummary, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, type ProjectDefinition, type ProjectInfo, ProjectNotFoundError, type ProjectResult, type ProjectResultError, type ProjectResultOk, type ReactDoctorConfig, ReactDoctorError, type ScoreResult, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, hasReactRuntime, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
10202
10252
  //# sourceMappingURL=index.d.ts.map
package/dist/index.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]="5c52dece-7b5e-5102-9381-7d05fa45734f")}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]="f00947fe-3794-5d73-a491-232b535dc962")}catch(e){}}();
3
3
  import { r as __toESM$1, t as __commonJSMin$1 } from "./chunk-N93fKeF6.js";
4
4
  import { createRequire } from "node:module";
5
5
  import * as NFS from "node:fs";
@@ -19304,6 +19304,7 @@ var Diagnostic = class extends Class("Diagnostic")({
19304
19304
  endLine: optional(Number$1),
19305
19305
  endColumn: optional(Number$1),
19306
19306
  category: String$1,
19307
+ matchByOccurrence: optional(Boolean$1),
19307
19308
  fileContext: optional(Literals(["test", "story"])),
19308
19309
  suppressionHint: optional(String$1),
19309
19310
  relatedLocations: optional(ArraySchema(DiagnosticRelatedLocation)),
@@ -19374,6 +19375,16 @@ var JsonReportV1 = class extends Class("JsonReportV1")({
19374
19375
  * successful `baseline` run (`schemaVersion: 2`) and on non-compare scopes.
19375
19376
  */
19376
19377
  baselineDegraded: optional(Boolean$1),
19378
+ /**
19379
+ * Whether any scanned project resolved a React-compatible runtime (React
19380
+ * or Preact). `false` means every React-runtime rule family was gated off,
19381
+ * so an empty `diagnostics` array is vacuous — NOT the same as a clean
19382
+ * React scan. Consumers gating on the report (CI, verifiers, hooks) should
19383
+ * treat `reactDetected === false` as "wrong scan target", not "all clear".
19384
+ * Absent when nothing was scanned (`projects` is empty), on error reports,
19385
+ * and on reports from older CLI versions.
19386
+ */
19387
+ reactDetected: optional(Boolean$1),
19377
19388
  diff: NullOr(JsonReportDiffInfo),
19378
19389
  projects: ArraySchema(JsonReportProjectEntry),
19379
19390
  diagnostics: ArraySchema(Diagnostic),
@@ -19399,6 +19410,8 @@ var JsonReportV2 = class extends Class("JsonReportV2")({
19399
19410
  ok: Boolean$1,
19400
19411
  directory: String$1,
19401
19412
  mode: JsonReportMode,
19413
+ /** See `JsonReportV1.reactDetected`. */
19414
+ reactDetected: optional(Boolean$1),
19402
19415
  diff: NullOr(JsonReportDiffInfo),
19403
19416
  baseline: JsonReportBaseline,
19404
19417
  projects: ArraySchema(JsonReportProjectEntry),
@@ -32907,6 +32920,7 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
32907
32920
  "Accessibility",
32908
32921
  "Maintainability"
32909
32922
  ];
32923
+ const OCCURRENCE_MATCHED_CATEGORIES = new Set(["Accessibility"]);
32910
32924
  const APP_ONLY_RULE_KEYS = new Set([
32911
32925
  "react-hooks-js/static-components",
32912
32926
  "react-doctor/no-render-prop-children",
@@ -32929,7 +32943,7 @@ const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
32929
32943
  const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
32930
32944
  const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
32931
32945
  const SIDECAR_LINT_CACHE_FILENAME = "sidecar-lint-cache.json";
32932
- const CORE_PACKAGE_VERSION = "0.7.0";
32946
+ const CORE_PACKAGE_VERSION = "0.7.1";
32933
32947
  const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
32934
32948
  const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json";
32935
32949
  const SUPPLY_CHAIN_PLUGIN = "socket";
@@ -33050,6 +33064,23 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
33050
33064
  const listSourceFilesWithSize = (rootDirectory) => collectSizedSourceFiles(rootDirectory, (listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory)).sort());
33051
33065
  const listSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).map((entry) => entry.path);
33052
33066
  const countSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).length;
33067
+ /**
33068
+ * The chain of ancestor directories of `startDirectory`, nearest first, up to
33069
+ * and including the filesystem root. With `includeStart`, the chain begins at
33070
+ * `startDirectory` itself; otherwise at its parent. The one place the "walk up
33071
+ * the tree" traversal is expressed, so each caller adds only its own match /
33072
+ * stop policy instead of re-deriving the `path.dirname` loop.
33073
+ */
33074
+ const ancestorDirectories = (startDirectory, { includeStart }) => {
33075
+ const directories = [];
33076
+ let currentDirectory = includeStart ? startDirectory : Path.dirname(startDirectory);
33077
+ while (true) {
33078
+ directories.push(currentDirectory);
33079
+ const parentDirectory = Path.dirname(currentDirectory);
33080
+ if (parentDirectory === currentDirectory) return directories;
33081
+ currentDirectory = parentDirectory;
33082
+ }
33083
+ };
33053
33084
  const cachedPackageJsons = /* @__PURE__ */ new Map();
33054
33085
  const clearPackageJsonCache = () => {
33055
33086
  cachedPackageJsons.clear();
@@ -33083,11 +33114,7 @@ const isMonorepoRoot = (directory) => {
33083
33114
  return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
33084
33115
  };
33085
33116
  const findMonorepoRoot = (startDirectory) => {
33086
- let currentDirectory = Path.dirname(startDirectory);
33087
- while (currentDirectory !== Path.dirname(currentDirectory)) {
33088
- if (isMonorepoRoot(currentDirectory)) return currentDirectory;
33089
- currentDirectory = Path.dirname(currentDirectory);
33090
- }
33117
+ for (const directory of ancestorDirectories(startDirectory, { includeStart: false })) if (isMonorepoRoot(directory)) return directory;
33091
33118
  return null;
33092
33119
  };
33093
33120
  /**
@@ -33733,6 +33760,74 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
33733
33760
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
33734
33761
  };
33735
33762
  };
33763
+ /**
33764
+ * Walk up from `startDirectory` to the nearest ancestor that owns a
33765
+ * `package.json`, stopping at (and including) the enclosing project boundary
33766
+ * — the working tree's git root or a monorepo root. Lets a scan of a package
33767
+ * subfolder that has no `package.json` of its own adopt the nearest enclosing
33768
+ * package as its project root, so it inherits that package's dependency +
33769
+ * framework detection instead of synthesizing an empty (React-blind) project.
33770
+ *
33771
+ * Generalizes the older monorepo-root-only lookup: the nearest ancestor is the
33772
+ * most specific owning package (a leaf workspace, not just the repo root), so
33773
+ * a subfolder of one workspace no longer borrows a sibling's capabilities.
33774
+ *
33775
+ * Returns `null` when no ancestor `package.json` exists inside the project
33776
+ * boundary (a loose tree of files, or a subfolder of a package-less repo).
33777
+ */
33778
+ const findNearestAncestorPackageJson = (startDirectory) => {
33779
+ if (isProjectBoundary(startDirectory)) return null;
33780
+ for (const directory of ancestorDirectories(startDirectory, { includeStart: false })) {
33781
+ if (isFile(Path.join(directory, "package.json"))) return directory;
33782
+ if (isProjectBoundary(directory)) return null;
33783
+ }
33784
+ return null;
33785
+ };
33786
+ /**
33787
+ * Last-resort React detection: locate React the way Node's `node_modules`
33788
+ * resolution would from `directory` — check each `node_modules/react` up the
33789
+ * tree — but bounded to the enclosing repo so a globally installed React can't
33790
+ * masquerade as the project's version. Stops at (and including) the nearest
33791
+ * project boundary — the git root or a monorepo root — and returns the first
33792
+ * installed `version`.
33793
+ *
33794
+ * Makes "React is installed and importable" ⇒ "React is detected" an invariant
33795
+ * for packages whose only React declaration is a version-less spec
33796
+ * (`workspace:*`, `*`, a dist-tag) or where React lives solely in a hoisted
33797
+ * `node_modules` the declaration walks never reach — the profile of a component
33798
+ * package inside a monorepo. Matching the `node_modules/react` entry rather than
33799
+ * its realpath means a project whose `node_modules` is symlinked elsewhere (a
33800
+ * Docker volume, a shared store) still counts, while a global install outside
33801
+ * the repo does not.
33802
+ *
33803
+ * Returns `null` when React isn't installed within the repo or its package.json
33804
+ * carries no version string. A tree with no boundary marker at all bounds the
33805
+ * search to `directory`, so a dependency hoisted above the scanned package there
33806
+ * is conservatively not adopted.
33807
+ */
33808
+ /**
33809
+ * The nearest enclosing project boundary (git root or monorepo root), or
33810
+ * `directory` itself when the scan target sits outside any repo. Bounds the
33811
+ * node_modules walk so a React hoisted anywhere inside the repo counts, but a
33812
+ * React in an ancestor above the repo — a home-directory or global
33813
+ * `node_modules` — can't leak in.
33814
+ */
33815
+ const findContainmentRoot = (directory) => {
33816
+ for (const ancestorDirectory of ancestorDirectories(directory, { includeStart: true })) if (isProjectBoundary(ancestorDirectory)) return ancestorDirectory;
33817
+ return directory;
33818
+ };
33819
+ const resolveInstalledReactVersion = (directory) => {
33820
+ const containmentRoot = findContainmentRoot(directory);
33821
+ for (const ancestorDirectory of ancestorDirectories(directory, { includeStart: true })) {
33822
+ const reactPackageJsonPath = Path.join(ancestorDirectory, "node_modules", "react", "package.json");
33823
+ if (isFile(reactPackageJsonPath)) {
33824
+ const installedVersion = readPackageJson(reactPackageJsonPath).version;
33825
+ return typeof installedVersion === "string" ? installedVersion : null;
33826
+ }
33827
+ if (ancestorDirectory === containmentRoot) return null;
33828
+ }
33829
+ return null;
33830
+ };
33736
33831
  const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
33737
33832
  const rootValue = select(rootPackageJson);
33738
33833
  if (rootValue !== null) return rootValue;
@@ -34072,19 +34167,20 @@ const clearProjectCache = () => {
34072
34167
  };
34073
34168
  /**
34074
34169
  * Build a `ProjectInfo` for a directory that has no `package.json` of
34075
- * its own — a monorepo subfolder like `repo/packages`, or any loose tree
34076
- * of TypeScript/JavaScript files. Dependency + framework detection is
34077
- * inherited from the enclosing workspace root when there is one, so
34078
- * scanning a subdirectory of a React monorepo still gets the React
34079
- * capabilities; a standalone non-React directory simply scans with the
34080
- * framework-agnostic rules. Throws only when the directory has nothing
34081
- * to scan (no enclosing project and no source files of its own).
34170
+ * its own — a package subfolder like `repo/packages` or `app/src/features`,
34171
+ * or any loose tree of TypeScript/JavaScript files. Dependency + framework
34172
+ * detection is inherited from the nearest enclosing package (a leaf
34173
+ * workspace, a plain app root, or a monorepo root — whichever is closest,
34174
+ * bounded by the git root), so scanning a subdirectory of a React project
34175
+ * still gets the React capabilities; a standalone non-React directory simply
34176
+ * scans with the framework-agnostic rules. Throws only when the directory has
34177
+ * nothing to scan (no enclosing project and no source files of its own).
34082
34178
  */
34083
34179
  const discoverProjectWithoutPackageJson = (directory) => {
34084
34180
  const sourceFileCount = countSourceFiles(directory);
34085
34181
  const hasOwnTsConfig = NFS.existsSync(Path.join(directory, "tsconfig.json"));
34086
- const monorepoRoot = findMonorepoRoot(directory);
34087
- const enclosingProject = monorepoRoot !== null && isFile(Path.join(monorepoRoot, "package.json")) ? discoverProject(monorepoRoot) : null;
34182
+ const enclosingProjectRoot = findNearestAncestorPackageJson(directory);
34183
+ const enclosingProject = enclosingProjectRoot !== null ? discoverProject(enclosingProjectRoot) : null;
34088
34184
  if (enclosingProject !== null) return {
34089
34185
  ...enclosingProject,
34090
34186
  rootDirectory: directory,
@@ -34188,6 +34284,7 @@ const discoverProject = (directory) => {
34188
34284
  if (!reactVersion && reactDeclaration.version && !isCatalogReference(reactDeclaration.version)) reactVersion = reactDeclaration.version;
34189
34285
  if (!tailwindVersion && tailwindDeclaration.version && !isCatalogReference(tailwindDeclaration.version)) tailwindVersion = tailwindDeclaration.version;
34190
34286
  if (!zodVersion && zodDeclaration.version && !isCatalogReference(zodDeclaration.version)) zodVersion = zodDeclaration.version;
34287
+ if (!reactVersion || parseReactMajor(reactVersion) === null) reactVersion = resolveInstalledReactVersion(directory) ?? reactVersion;
34191
34288
  const projectName = packageJson.name ?? Path.basename(directory);
34192
34289
  const hasTypeScript = NFS.existsSync(Path.join(directory, "tsconfig.json"));
34193
34290
  const sourceFileCount = countSourceFiles(directory);
@@ -37010,10 +37107,17 @@ function* collectSecurityScanFiles(rootDirectory) {
37010
37107
  }
37011
37108
  }
37012
37109
  }
37110
+ /**
37111
+ * Whether project discovery resolved a React-compatible runtime (React or
37112
+ * Preact, which ships the same hooks + JSX model). This is the same
37113
+ * predicate that grants the `react` capability, so `false` means every
37114
+ * React-runtime rule family was gated off for the project.
37115
+ */
37116
+ const hasReactRuntime = (project) => project.reactVersion !== null || project.preactVersion !== null;
37013
37117
  const buildCapabilities = (project) => {
37014
37118
  const capabilities = /* @__PURE__ */ new Set();
37015
37119
  capabilities.add(project.framework);
37016
- if (project.reactVersion !== null || project.preactVersion !== null) capabilities.add("react");
37120
+ if (hasReactRuntime(project)) capabilities.add("react");
37017
37121
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
37018
37122
  if (project.expoVersion !== null) capabilities.add("expo");
37019
37123
  if (project.nextjsMajorVersion !== null && project.nextjsMajorVersion >= 15) capabilities.add("nextjs:15");
@@ -38543,7 +38647,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
38543
38647
  })),
38544
38648
  showStagedContent: (directory, relativePath, options) => runCommand({
38545
38649
  command: "git",
38546
- args: ["show", `:${relativePath}`],
38650
+ args: ["show", `:./${relativePath}`],
38547
38651
  directory,
38548
38652
  maxStdoutBytes: options?.maxBufferBytes
38549
38653
  }).pipe(map$3((result) => result.status === 0 ? result.stdout : null)),
@@ -39849,6 +39953,7 @@ const parseRuleCode = (code) => {
39849
39953
  };
39850
39954
  };
39851
39955
  const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs";
39956
+ const resolveMatchByOccurrence = (rule, category) => OCCURRENCE_MATCHED_CATEGORIES.has(category) || Boolean(reactDoctorPlugin.rules[rule]?.matchByOccurrence);
39852
39957
  /**
39853
39958
  * Maps oxlint's non-primary labels (`labels[1..]`) into related source
39854
39959
  * locations. Editors surface these as a diagnostic's
@@ -39912,6 +40017,7 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
39912
40017
  const normalizedFilePath = diagnostic.filename.replaceAll("\\", "/");
39913
40018
  const primarySpan = primaryLabel?.span;
39914
40019
  const relatedLocations = buildRelatedLocations(diagnostic.labels, normalizedFilePath);
40020
+ const category = resolveDiagnosticCategory(plugin, rule);
39915
40021
  return {
39916
40022
  filePath: normalizedFilePath,
39917
40023
  plugin,
@@ -39927,7 +40033,8 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
39927
40033
  offset: primarySpan.offset,
39928
40034
  length: primarySpan.length
39929
40035
  } : {},
39930
- category: resolveDiagnosticCategory(plugin, rule),
40036
+ category,
40037
+ ...resolveMatchByOccurrence(rule, category) ? { matchByOccurrence: true } : {},
39931
40038
  ...relatedLocations.length > 0 ? { relatedLocations } : {}
39932
40039
  };
39933
40040
  });
@@ -42088,6 +42195,7 @@ const buildJsonReport = (input) => {
42088
42195
  const worstScoredProject = findWorstScoredProject(projects);
42089
42196
  const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
42090
42197
  const shared = {
42198
+ ...input.scans.length > 0 ? { reactDetected: input.scans.some((scan) => hasReactRuntime(scan.result.project)) } : {},
42091
42199
  version: input.version,
42092
42200
  ok: true,
42093
42201
  directory: input.directory,
@@ -42317,6 +42425,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
42317
42425
  skippedChecks,
42318
42426
  ...Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {},
42319
42427
  project: output.project,
42428
+ reactDetected: hasReactRuntime(output.project),
42320
42429
  elapsedMilliseconds
42321
42430
  };
42322
42431
  };
@@ -42372,10 +42481,12 @@ const diagnoseProjectBatch = async (input) => {
42372
42481
  const startTime = globalThis.performance.now();
42373
42482
  const { projects, concurrency, config: batchConfig, ...baseOptions } = input;
42374
42483
  const projectResults = await mapWithConcurrency(projects, concurrency ?? 4, (projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig));
42484
+ const succeededProjects = projectResults.filter((projectResult) => projectResult.ok);
42375
42485
  return {
42376
42486
  projects: projectResults,
42377
- diagnostics: projectResults.flatMap((projectResult) => projectResult.ok ? projectResult.diagnostics : []),
42487
+ diagnostics: succeededProjects.flatMap((projectResult) => projectResult.diagnostics),
42378
42488
  score: findWorstScore(projectResults),
42489
+ ...succeededProjects.length > 0 ? { reactDetected: succeededProjects.some((projectResult) => projectResult.reactDetected) } : {},
42379
42490
  elapsedMilliseconds: globalThis.performance.now() - startTime
42380
42491
  };
42381
42492
  };
@@ -42410,7 +42521,7 @@ const toJsonReport = (result, options) => buildJsonReport({
42410
42521
  totalElapsedMilliseconds: result.elapsedMilliseconds
42411
42522
  });
42412
42523
  //#endregion
42413
- export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
42524
+ export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, hasReactRuntime, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
42414
42525
 
42415
42526
  //# sourceMappingURL=index.js.map
42416
- //# debugId=5c52dece-7b5e-5102-9381-7d05fa45734f
42527
+ //# debugId=f00947fe-3794-5d73-a491-232b535dc962
package/dist/lsp.js CHANGED
@@ -19333,6 +19333,7 @@ var Diagnostic = class extends Class("Diagnostic")({
19333
19333
  endLine: optional(Number$1),
19334
19334
  endColumn: optional(Number$1),
19335
19335
  category: String$1,
19336
+ matchByOccurrence: optional(Boolean$1),
19336
19337
  fileContext: optional(Literals(["test", "story"])),
19337
19338
  suppressionHint: optional(String$1),
19338
19339
  relatedLocations: optional(ArraySchema(DiagnosticRelatedLocation)),
@@ -19410,6 +19411,16 @@ var JsonReportV1 = class extends Class("JsonReportV1")({
19410
19411
  * successful `baseline` run (`schemaVersion: 2`) and on non-compare scopes.
19411
19412
  */
19412
19413
  baselineDegraded: optional(Boolean$1),
19414
+ /**
19415
+ * Whether any scanned project resolved a React-compatible runtime (React
19416
+ * or Preact). `false` means every React-runtime rule family was gated off,
19417
+ * so an empty `diagnostics` array is vacuous — NOT the same as a clean
19418
+ * React scan. Consumers gating on the report (CI, verifiers, hooks) should
19419
+ * treat `reactDetected === false` as "wrong scan target", not "all clear".
19420
+ * Absent when nothing was scanned (`projects` is empty), on error reports,
19421
+ * and on reports from older CLI versions.
19422
+ */
19423
+ reactDetected: optional(Boolean$1),
19413
19424
  diff: NullOr(JsonReportDiffInfo),
19414
19425
  projects: ArraySchema(JsonReportProjectEntry),
19415
19426
  diagnostics: ArraySchema(Diagnostic),
@@ -19435,6 +19446,8 @@ var JsonReportV2 = class extends Class("JsonReportV2")({
19435
19446
  ok: Boolean$1,
19436
19447
  directory: String$1,
19437
19448
  mode: JsonReportMode,
19449
+ /** See `JsonReportV1.reactDetected`. */
19450
+ reactDetected: optional(Boolean$1),
19438
19451
  diff: NullOr(JsonReportDiffInfo),
19439
19452
  baseline: JsonReportBaseline,
19440
19453
  projects: ArraySchema(JsonReportProjectEntry),
@@ -32966,6 +32979,7 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
32966
32979
  "Accessibility",
32967
32980
  "Maintainability"
32968
32981
  ];
32982
+ const OCCURRENCE_MATCHED_CATEGORIES = new Set(["Accessibility"]);
32969
32983
  const APP_ONLY_RULE_KEYS = new Set([
32970
32984
  "react-hooks-js/static-components",
32971
32985
  "react-doctor/no-render-prop-children",
@@ -32988,7 +33002,7 @@ const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
32988
33002
  const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
32989
33003
  const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
32990
33004
  const SIDECAR_LINT_CACHE_FILENAME = "sidecar-lint-cache.json";
32991
- const CORE_PACKAGE_VERSION = "0.7.0";
33005
+ const CORE_PACKAGE_VERSION = "0.7.1";
32992
33006
  const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
32993
33007
  const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json";
32994
33008
  const SUPPLY_CHAIN_PLUGIN = "socket";
@@ -33109,6 +33123,23 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
33109
33123
  const listSourceFilesWithSize = (rootDirectory) => collectSizedSourceFiles(rootDirectory, (listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory)).sort());
33110
33124
  const listSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).map((entry) => entry.path);
33111
33125
  const countSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).length;
33126
+ /**
33127
+ * The chain of ancestor directories of `startDirectory`, nearest first, up to
33128
+ * and including the filesystem root. With `includeStart`, the chain begins at
33129
+ * `startDirectory` itself; otherwise at its parent. The one place the "walk up
33130
+ * the tree" traversal is expressed, so each caller adds only its own match /
33131
+ * stop policy instead of re-deriving the `path.dirname` loop.
33132
+ */
33133
+ const ancestorDirectories = (startDirectory, { includeStart }) => {
33134
+ const directories = [];
33135
+ let currentDirectory = includeStart ? startDirectory : Path.dirname(startDirectory);
33136
+ while (true) {
33137
+ directories.push(currentDirectory);
33138
+ const parentDirectory = Path.dirname(currentDirectory);
33139
+ if (parentDirectory === currentDirectory) return directories;
33140
+ currentDirectory = parentDirectory;
33141
+ }
33142
+ };
33112
33143
  const cachedPackageJsons = /* @__PURE__ */ new Map();
33113
33144
  const clearPackageJsonCache = () => {
33114
33145
  cachedPackageJsons.clear();
@@ -33142,11 +33173,7 @@ const isMonorepoRoot = (directory) => {
33142
33173
  return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
33143
33174
  };
33144
33175
  const findMonorepoRoot = (startDirectory) => {
33145
- let currentDirectory = Path.dirname(startDirectory);
33146
- while (currentDirectory !== Path.dirname(currentDirectory)) {
33147
- if (isMonorepoRoot(currentDirectory)) return currentDirectory;
33148
- currentDirectory = Path.dirname(currentDirectory);
33149
- }
33176
+ for (const directory of ancestorDirectories(startDirectory, { includeStart: false })) if (isMonorepoRoot(directory)) return directory;
33150
33177
  return null;
33151
33178
  };
33152
33179
  /**
@@ -33792,6 +33819,74 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
33792
33819
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
33793
33820
  };
33794
33821
  };
33822
+ /**
33823
+ * Walk up from `startDirectory` to the nearest ancestor that owns a
33824
+ * `package.json`, stopping at (and including) the enclosing project boundary
33825
+ * — the working tree's git root or a monorepo root. Lets a scan of a package
33826
+ * subfolder that has no `package.json` of its own adopt the nearest enclosing
33827
+ * package as its project root, so it inherits that package's dependency +
33828
+ * framework detection instead of synthesizing an empty (React-blind) project.
33829
+ *
33830
+ * Generalizes the older monorepo-root-only lookup: the nearest ancestor is the
33831
+ * most specific owning package (a leaf workspace, not just the repo root), so
33832
+ * a subfolder of one workspace no longer borrows a sibling's capabilities.
33833
+ *
33834
+ * Returns `null` when no ancestor `package.json` exists inside the project
33835
+ * boundary (a loose tree of files, or a subfolder of a package-less repo).
33836
+ */
33837
+ const findNearestAncestorPackageJson = (startDirectory) => {
33838
+ if (isProjectBoundary(startDirectory)) return null;
33839
+ for (const directory of ancestorDirectories(startDirectory, { includeStart: false })) {
33840
+ if (isFile(Path.join(directory, "package.json"))) return directory;
33841
+ if (isProjectBoundary(directory)) return null;
33842
+ }
33843
+ return null;
33844
+ };
33845
+ /**
33846
+ * Last-resort React detection: locate React the way Node's `node_modules`
33847
+ * resolution would from `directory` — check each `node_modules/react` up the
33848
+ * tree — but bounded to the enclosing repo so a globally installed React can't
33849
+ * masquerade as the project's version. Stops at (and including) the nearest
33850
+ * project boundary — the git root or a monorepo root — and returns the first
33851
+ * installed `version`.
33852
+ *
33853
+ * Makes "React is installed and importable" ⇒ "React is detected" an invariant
33854
+ * for packages whose only React declaration is a version-less spec
33855
+ * (`workspace:*`, `*`, a dist-tag) or where React lives solely in a hoisted
33856
+ * `node_modules` the declaration walks never reach — the profile of a component
33857
+ * package inside a monorepo. Matching the `node_modules/react` entry rather than
33858
+ * its realpath means a project whose `node_modules` is symlinked elsewhere (a
33859
+ * Docker volume, a shared store) still counts, while a global install outside
33860
+ * the repo does not.
33861
+ *
33862
+ * Returns `null` when React isn't installed within the repo or its package.json
33863
+ * carries no version string. A tree with no boundary marker at all bounds the
33864
+ * search to `directory`, so a dependency hoisted above the scanned package there
33865
+ * is conservatively not adopted.
33866
+ */
33867
+ /**
33868
+ * The nearest enclosing project boundary (git root or monorepo root), or
33869
+ * `directory` itself when the scan target sits outside any repo. Bounds the
33870
+ * node_modules walk so a React hoisted anywhere inside the repo counts, but a
33871
+ * React in an ancestor above the repo — a home-directory or global
33872
+ * `node_modules` — can't leak in.
33873
+ */
33874
+ const findContainmentRoot = (directory) => {
33875
+ for (const ancestorDirectory of ancestorDirectories(directory, { includeStart: true })) if (isProjectBoundary(ancestorDirectory)) return ancestorDirectory;
33876
+ return directory;
33877
+ };
33878
+ const resolveInstalledReactVersion = (directory) => {
33879
+ const containmentRoot = findContainmentRoot(directory);
33880
+ for (const ancestorDirectory of ancestorDirectories(directory, { includeStart: true })) {
33881
+ const reactPackageJsonPath = Path.join(ancestorDirectory, "node_modules", "react", "package.json");
33882
+ if (isFile(reactPackageJsonPath)) {
33883
+ const installedVersion = readPackageJson(reactPackageJsonPath).version;
33884
+ return typeof installedVersion === "string" ? installedVersion : null;
33885
+ }
33886
+ if (ancestorDirectory === containmentRoot) return null;
33887
+ }
33888
+ return null;
33889
+ };
33795
33890
  const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
33796
33891
  const rootValue = select(rootPackageJson);
33797
33892
  if (rootValue !== null) return rootValue;
@@ -34131,19 +34226,20 @@ const clearProjectCache = () => {
34131
34226
  };
34132
34227
  /**
34133
34228
  * Build a `ProjectInfo` for a directory that has no `package.json` of
34134
- * its own — a monorepo subfolder like `repo/packages`, or any loose tree
34135
- * of TypeScript/JavaScript files. Dependency + framework detection is
34136
- * inherited from the enclosing workspace root when there is one, so
34137
- * scanning a subdirectory of a React monorepo still gets the React
34138
- * capabilities; a standalone non-React directory simply scans with the
34139
- * framework-agnostic rules. Throws only when the directory has nothing
34140
- * to scan (no enclosing project and no source files of its own).
34229
+ * its own — a package subfolder like `repo/packages` or `app/src/features`,
34230
+ * or any loose tree of TypeScript/JavaScript files. Dependency + framework
34231
+ * detection is inherited from the nearest enclosing package (a leaf
34232
+ * workspace, a plain app root, or a monorepo root — whichever is closest,
34233
+ * bounded by the git root), so scanning a subdirectory of a React project
34234
+ * still gets the React capabilities; a standalone non-React directory simply
34235
+ * scans with the framework-agnostic rules. Throws only when the directory has
34236
+ * nothing to scan (no enclosing project and no source files of its own).
34141
34237
  */
34142
34238
  const discoverProjectWithoutPackageJson = (directory) => {
34143
34239
  const sourceFileCount = countSourceFiles(directory);
34144
34240
  const hasOwnTsConfig = NFS.existsSync(Path.join(directory, "tsconfig.json"));
34145
- const monorepoRoot = findMonorepoRoot(directory);
34146
- const enclosingProject = monorepoRoot !== null && isFile(Path.join(monorepoRoot, "package.json")) ? discoverProject(monorepoRoot) : null;
34241
+ const enclosingProjectRoot = findNearestAncestorPackageJson(directory);
34242
+ const enclosingProject = enclosingProjectRoot !== null ? discoverProject(enclosingProjectRoot) : null;
34147
34243
  if (enclosingProject !== null) return {
34148
34244
  ...enclosingProject,
34149
34245
  rootDirectory: directory,
@@ -34247,6 +34343,7 @@ const discoverProject = (directory) => {
34247
34343
  if (!reactVersion && reactDeclaration.version && !isCatalogReference(reactDeclaration.version)) reactVersion = reactDeclaration.version;
34248
34344
  if (!tailwindVersion && tailwindDeclaration.version && !isCatalogReference(tailwindDeclaration.version)) tailwindVersion = tailwindDeclaration.version;
34249
34345
  if (!zodVersion && zodDeclaration.version && !isCatalogReference(zodDeclaration.version)) zodVersion = zodDeclaration.version;
34346
+ if (!reactVersion || parseReactMajor(reactVersion) === null) reactVersion = resolveInstalledReactVersion(directory) ?? reactVersion;
34250
34347
  const projectName = packageJson.name ?? Path.basename(directory);
34251
34348
  const hasTypeScript = NFS.existsSync(Path.join(directory, "tsconfig.json"));
34252
34349
  const sourceFileCount = countSourceFiles(directory);
@@ -36995,10 +37092,17 @@ function* collectSecurityScanFiles(rootDirectory) {
36995
37092
  }
36996
37093
  }
36997
37094
  }
37095
+ /**
37096
+ * Whether project discovery resolved a React-compatible runtime (React or
37097
+ * Preact, which ships the same hooks + JSX model). This is the same
37098
+ * predicate that grants the `react` capability, so `false` means every
37099
+ * React-runtime rule family was gated off for the project.
37100
+ */
37101
+ const hasReactRuntime = (project) => project.reactVersion !== null || project.preactVersion !== null;
36998
37102
  const buildCapabilities = (project) => {
36999
37103
  const capabilities = /* @__PURE__ */ new Set();
37000
37104
  capabilities.add(project.framework);
37001
- if (project.reactVersion !== null || project.preactVersion !== null) capabilities.add("react");
37105
+ if (hasReactRuntime(project)) capabilities.add("react");
37002
37106
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
37003
37107
  if (project.expoVersion !== null) capabilities.add("expo");
37004
37108
  if (project.nextjsMajorVersion !== null && project.nextjsMajorVersion >= 15) capabilities.add("nextjs:15");
@@ -38528,7 +38632,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
38528
38632
  })),
38529
38633
  showStagedContent: (directory, relativePath, options) => runCommand({
38530
38634
  command: "git",
38531
- args: ["show", `:${relativePath}`],
38635
+ args: ["show", `:./${relativePath}`],
38532
38636
  directory,
38533
38637
  maxStdoutBytes: options?.maxBufferBytes
38534
38638
  }).pipe(map$3((result) => result.status === 0 ? result.stdout : null)),
@@ -39834,6 +39938,7 @@ const parseRuleCode = (code) => {
39834
39938
  };
39835
39939
  };
39836
39940
  const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs";
39941
+ const resolveMatchByOccurrence = (rule, category) => OCCURRENCE_MATCHED_CATEGORIES.has(category) || Boolean(reactDoctorPlugin.rules[rule]?.matchByOccurrence);
39837
39942
  /**
39838
39943
  * Maps oxlint's non-primary labels (`labels[1..]`) into related source
39839
39944
  * locations. Editors surface these as a diagnostic's
@@ -39897,6 +40002,7 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
39897
40002
  const normalizedFilePath = diagnostic.filename.replaceAll("\\", "/");
39898
40003
  const primarySpan = primaryLabel?.span;
39899
40004
  const relatedLocations = buildRelatedLocations(diagnostic.labels, normalizedFilePath);
40005
+ const category = resolveDiagnosticCategory(plugin, rule);
39900
40006
  return {
39901
40007
  filePath: normalizedFilePath,
39902
40008
  plugin,
@@ -39912,7 +40018,8 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
39912
40018
  offset: primarySpan.offset,
39913
40019
  length: primarySpan.length
39914
40020
  } : {},
39915
- category: resolveDiagnosticCategory(plugin, rule),
40021
+ category,
40022
+ ...resolveMatchByOccurrence(rule, category) ? { matchByOccurrence: true } : {},
39916
40023
  ...relatedLocations.length > 0 ? { relatedLocations } : {}
39917
40024
  };
39918
40025
  });
@@ -42085,7 +42192,7 @@ const computeConfigFingerprint = (projectDirectory, version) => {
42085
42192
  /** Display name used in client-facing messages and progress titles. */
42086
42193
  const SERVER_DISPLAY_NAME = "React Doctor";
42087
42194
  /** Server version reported in `serverInfo`; injected at build, `dev` from source. */
42088
- const SERVER_VERSION = "0.7.0";
42195
+ const SERVER_VERSION = "0.7.1";
42089
42196
  /** `Diagnostic.source` shown next to every published diagnostic. */
42090
42197
  const DIAGNOSTIC_SOURCE = "react-doctor";
42091
42198
  /**
@@ -43792,6 +43899,7 @@ const METRIC = {
43792
43899
  scanScore: "scan.score",
43793
43900
  scanClean: "scan.clean",
43794
43901
  scanCheckSkipped: "scan.check_skipped",
43902
+ scanNoReactDetected: "scan.no_react_detected",
43795
43903
  baselineDegraded: "baseline.degraded",
43796
43904
  ruleFired: "rule.fired",
43797
43905
  ruleDisabled: "rule.disabled",
@@ -44135,5 +44243,5 @@ const startLanguageServer = () => {
44135
44243
  };
44136
44244
  //#endregion
44137
44245
  export { startLanguageServer };
44138
- !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]="1b40bd68-7ad4-55e0-a6bd-91b6cdcd0ba8")}catch(e){}}();
44139
- //# debugId=1b40bd68-7ad4-55e0-a6bd-91b6cdcd0ba8
44246
+ !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]="8bc40ab2-982a-5d5d-a65c-9733ec980391")}catch(e){}}();
44247
+ //# debugId=8bc40ab2-982a-5d5d-a65c-9733ec980391
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.7.0",
3
+ "version": "0.7.1-dev.1880b15",
4
4
  "description": "Your agent writes bad React. This catches it",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -65,8 +65,8 @@
65
65
  "vscode-languageserver-textdocument": "^1.0.12",
66
66
  "vscode-uri": "^3.1.0",
67
67
  "yaml": "^2.9.0",
68
- "deslop-js": "0.7.0",
69
- "oxlint-plugin-react-doctor": "0.7.0"
68
+ "deslop-js": "0.7.1",
69
+ "oxlint-plugin-react-doctor": "0.7.1-dev.1880b15"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/babel__code-frame": "^7.27.0",
@@ -74,9 +74,9 @@
74
74
  "@xterm/headless": "^6.0.0",
75
75
  "commander": "^14.0.3",
76
76
  "ora": "^9.4.0",
77
- "@react-doctor/api": "0.7.0",
78
- "@react-doctor/core": "0.7.0",
79
- "@react-doctor/language-server": "0.7.0"
77
+ "@react-doctor/language-server": "0.7.1",
78
+ "@react-doctor/core": "0.7.1",
79
+ "@react-doctor/api": "0.7.1"
80
80
  },
81
81
  "engines": {
82
82
  "node": "^20.19.0 || >=22.13.0"