diff --git a/.changeset/jsx-no-jsx-as-prop-slot-names.md b/.changeset/jsx-no-jsx-as-prop-slot-names.md
deleted file mode 100644
index dae1db2ee5..0000000000
--- a/.changeset/jsx-no-jsx-as-prop-slot-names.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-"oxlint-plugin-react-doctor": patch
----
-
-fix(react-builtins): `jsx-no-jsx-as-prop` recognises more conventional JSX
-slot props mined from the real-world corpus — the `*Avatar`, `*Text`,
-`*State`, and `*Zone` suffixes (material-ui `ListItem
-leftAvatar`/`primaryText`, supabase `ChartContent loadingState`, leemons
-`leftZone`/`rightZone`), the `config` slot, and capitalised exact forms of
-known slot names (`Footer={}`). Inline JSX in these slots is the
-component's designed API, so flagging it was unactionable noise. Found by the
-fuzz FP oracle.
diff --git a/.changeset/monorepo-subdir-react-detection.md b/.changeset/monorepo-subdir-react-detection.md
new file mode 100644
index 0000000000..93089cd6aa
--- /dev/null
+++ b/.changeset/monorepo-subdir-react-detection.md
@@ -0,0 +1,8 @@
+---
+"@react-doctor/core": patch
+---
+
+Detect React when scanning a package subdirectory of a monorepo, so React rules no longer gate off silently. Two additions at the `discoverProject` seam:
+
+- **Nearest-ancestor discovery.** A scan target with no `package.json` of its own now adopts the nearest enclosing package (a leaf workspace, a plain app root, or a monorepo root — whichever is closest, bounded by the git root) instead of only workspace-configured monorepo roots. Scanning `app/src/components` in a plain React app now inherits the app's React detection rather than synthesizing an empty, React-blind project.
+- **Node-resolution React version fallback.** When declarations yield no usable React version (a version-less spec like `workspace:*` / `*` / a dist-tag, or React living only in a hoisted `node_modules` the declaration walks never reach), the version is resolved the way Node itself would — `require.resolve("react/package.json")` — making "React is installed and importable" ⇒ "React is detected" an invariant. Guarded to installations physically inside the enclosing repo so a globally installed React can't leak in, and it never overrides a parseable peer range (`^18 || ^19` still floors to the lowest supported major).
diff --git a/.changeset/no-secrets-author-name-fp.md b/.changeset/no-secrets-author-name-fp.md
deleted file mode 100644
index 46936cdf2c..0000000000
--- a/.changeset/no-secrets-author-name-fp.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-"oxlint-plugin-react-doctor": patch
----
-
-fix(security): `no-secrets-in-client-code`'s variable-name heuristic no longer
-matches `auth` inside `author`/`authors`/`authority` — a component identifier
-like `TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = ""` is not a
-credential. The credential words that contain "author"
-(`authorization`, `authorised`) still match. Found by the fuzz FP oracle over
-the real-world corpus.
diff --git a/.changeset/no-supply-chain-flag.md b/.changeset/no-supply-chain-flag.md
new file mode 100644
index 0000000000..22e8d9e385
--- /dev/null
+++ b/.changeset/no-supply-chain-flag.md
@@ -0,0 +1,5 @@
+---
+"react-doctor": minor
+---
+
+Add `--supply-chain` / `--no-supply-chain` CLI flags to toggle the dependency supply-chain scan, mirroring `--lint`/`--no-lint` and `--dead-code`/`--no-dead-code`. Supply-chain enablement now resolves as a scan option (`InspectOptions.supplyChain`) against `supplyChain.enabled` — the flag wins — so it takes precedence over per-project config on every scan (a workspace module's config can't undo `--no-supply-chain`), and config isn't mutated so `scan.hasCustomConfig` telemetry stays accurate. The enabled state also rides the per-scan wide event as `scan.supplyChain`.
diff --git a/.changeset/non-react-use-effect-event.md b/.changeset/non-react-use-effect-event.md
new file mode 100644
index 0000000000..17620ce896
--- /dev/null
+++ b/.changeset/non-react-use-effect-event.md
@@ -0,0 +1,5 @@
+---
+"oxlint-plugin-react-doctor": patch
+---
+
+Stop `rules-of-hooks` and `no-effect-event-in-deps` from firing on a `useEffectEvent` imported from a non-React package. Both rules match the hook by NAME to stay in parity with eslint-plugin-react-hooks (whose fixtures call a bare global), so a same-named custom hook — e.g. `@rocket.chat/fuselage-hooks`'s `useEffectEvent`, a stable-callback helper designed to be stored and passed as props — was flagged as if it were React's experimental effect event ("only works when called from Effects", "re-runs your effect every render"). Detection is now disambiguated by import source: a `useEffectEvent` explicitly imported from a module outside `REACT_RUNTIME_MODULE_SOURCES` (`react`, `react-dom`, `preact/compat`, `preact/hooks`) is left alone, while React's own and bare/unimported names keep their existing behavior.
diff --git a/.changeset/only-export-components-nested-scope-fp.md b/.changeset/only-export-components-nested-scope-fp.md
deleted file mode 100644
index f94ca05aa8..0000000000
--- a/.changeset/only-export-components-nested-scope-fp.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-"oxlint-plugin-react-doctor": patch
----
-
-fix(react-builtins): `only-export-components` no longer flags components
-declared inside another function — a test callback (`test("x", () => { const
-Harness = () => ... })`), a factory (`function setup() { const Row = () =>
-... }`), or an object-literal `render` method. Those are never Fast Refresh
-boundaries, so the "not exported" / "file exports nothing" messages told
-users to export values that can't be exported. The local-component walk now
-stays at module scope, matching the origin rule in
-eslint-plugin-react-refresh. Found by the fuzz FP oracle.
diff --git a/.changeset/scan-cache-cross-project-replay.md b/.changeset/scan-cache-cross-project-replay.md
new file mode 100644
index 0000000000..082a8cf035
--- /dev/null
+++ b/.changeset/scan-cache-cross-project-replay.md
@@ -0,0 +1,5 @@
+---
+"react-doctor": patch
+---
+
+Fix whole-repo scan cache replaying another project's diagnostics when a .git-less checkout sits inside an unrelated repository (e.g. a gitignored benchmark/mining clone directory reused across projects). The cache key's git identity (HEAD sha, worktree fingerprint) resolved from the enclosing repository, which cannot see the checkout's files, so two different projects materialized at the same path keyed identically. The key now requires the fingerprinted repository to actually track files under the project directory (cache off otherwise), and every cache hit re-verifies the stored payload's directory and `package.json` content hash so any future keying bug of this class degrades to a miss instead of a cross-project replay.
diff --git a/.changeset/staged-monorepo-subdir-pathspec.md b/.changeset/staged-monorepo-subdir-pathspec.md
new file mode 100644
index 0000000000..c098f5d489
--- /dev/null
+++ b/.changeset/staged-monorepo-subdir-pathspec.md
@@ -0,0 +1,5 @@
+---
+"@react-doctor/core": patch
+---
+
+Fix `--staged` silently scanning nothing when the project is a subdirectory of the git repo (the standard monorepo layout, e.g. `apps/webui`). Staged paths are collected project-relative (`git diff --cached --relative`), but the staged-content read used a bare `git show :` index pathspec, which git resolves against the repo root — so in a subproject every read missed, the file was silently skipped, and the scan "passed" with `scannedFileCount: 0` (particularly dangerous in a pre-commit hook). The index read now uses the cwd-relative `git show :./` form, matching how baseline `:` reads were already resolved.
diff --git a/.npmrc b/.npmrc
index bf2e7648b0..a2db84ed3d 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,3 @@
shamefully-hoist=true
+# vite-plus preview build registry bridge (auto-added by vp)
+registry=https://registry-bridge.viteplus.dev/
diff --git a/action.yml b/action.yml
index a2e4562244..055a0939c9 100644
--- a/action.yml
+++ b/action.yml
@@ -85,25 +85,30 @@ runs:
path: ${{ runner.temp }}/react-doctor-toolchain
key: react-doctor-toolchain-${{ steps.resolve-version.outputs.resolved }}-node${{ inputs.node-version }}-${{ runner.os }}-${{ runner.arch }}
- # Restore react-doctor's scan caches (the per-file content-addressed lint
- # cache + the supply-chain cache) from the most recent previous run. In CI
- # every commit is a fresh, SHA-scoped checkout, so the project-local
- # `node_modules/.cache` never survives between commits — pointing
- # REACT_DOCTOR_CACHE_DIR (set on the scan step) at a stable
- # `${runner.temp}` path lets actions/cache carry it across runs. Restore +
- # explicit save (after the scan) instead of the combined action: the
- # combined post-job save is skipped when the job fails, and a blocking PR
- # scan with findings fails the job BY DESIGN — exactly the runs whose
- # fix-and-push retry needs a warm cache. The key is unique per run (an
- # exact hit would suppress the save), so every run persists its refreshed
- # state instead of freezing the first snapshot under an immutable key;
- # restore falls back by prefix to the newest same-version save, then to
- # ANY version. Cross-version restore is sound because every cached
- # artifact re-validates internally (content hash + ruleset hash for lint
- # entries — the bucket is LRU-pruned — schema version for scan results,
- # TTL for supply-chain scores): a stale entry just misses and re-computes,
- # while the version-independent supply-chain cache keeps a react-doctor
- # release from cold-starting every repo's network checks at once.
+ # Restore react-doctor's scan caches (the whole-repo scan-result cache,
+ # the per-file content-addressed lint cache, the cross-file sidecar cache,
+ # the dead-code result + incremental summary caches, and the supply-chain
+ # cache) from the most recent previous run. In CI every commit is a fresh,
+ # SHA-scoped checkout, so the project-local `node_modules/.cache` never
+ # survives between commits — pointing REACT_DOCTOR_CACHE_DIR (set on the
+ # scan step) at a stable `${runner.temp}` path lets actions/cache carry it
+ # across runs. Restore + explicit save (after the scan) instead of the
+ # combined action: the combined post-job save is skipped when the job
+ # fails, and a blocking PR scan with findings fails the job BY DESIGN —
+ # exactly the runs whose fix-and-push retry needs a warm cache. The key is
+ # unique per run (an exact hit would suppress the save), so every run
+ # persists its refreshed state instead of freezing the first snapshot
+ # under an immutable key; restore falls back by prefix to the newest
+ # same-version save, then to ANY version. Cross-version restore is sound
+ # because every cached artifact re-validates internally (content hash +
+ # ruleset hash for lint entries — the bucket is LRU-pruned — schema
+ # version + per-file content hashes for scan results and the dead-code
+ # caches, TTL for supply-chain scores): a stale entry just misses and
+ # re-computes, while the version-independent supply-chain cache keeps a
+ # react-doctor release from cold-starting every repo's network checks at
+ # once. The stat-fingerprinted caches carry content-hash repair witnesses,
+ # so the fresh checkout's bumped mtimes are repaired in place instead of
+ # missing every run.
- id: scan-cache
if: ${{ steps.resolve-version.outputs.cacheable == 'true' }}
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
diff --git a/package.json b/package.json
index 45134b79b4..81082f02dc 100644
--- a/package.json
+++ b/package.json
@@ -44,23 +44,12 @@
"ts-json-schema-generator": "^2.9.0",
"turbo": "^2.9.7",
"typescript": "^6.0.3",
- "vite-plus": "^0.1.15"
+ "vite": "catalog:",
+ "vite-plus": "catalog:"
},
"engines": {
"node": "^20.19.0 || >=22.13.0",
"pnpm": ">=8"
},
- "packageManager": "pnpm@10.29.1",
- "pnpm": {
- "onlyBuiltDependencies": [
- "@parcel/watcher",
- "@sentry/cli",
- "esbuild",
- "unrs-resolver"
- ],
- "overrides": {
- "oxlint": ">=1.66.0 <1.67.0",
- "oxlint-tsgolint": "^0.23.0"
- }
- }
+ "packageManager": "pnpm@10.29.1"
}
diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md
index c6a5c82352..353bd93913 100644
--- a/packages/api/CHANGELOG.md
+++ b/packages/api/CHANGELOG.md
@@ -1,5 +1,26 @@
# @react-doctor/api
+## 0.7.1
+
+### Patch Changes
+
+- Updated dependencies [[`c0c3fc1`](https://github.com/millionco/react-doctor/commit/c0c3fc170972876c8bbc2419b32e66b9c864df85)]:
+ - @react-doctor/core@0.7.1
+
+## 0.7.0
+
+### Patch Changes
+
+- Updated dependencies []:
+ - @react-doctor/core@0.7.0
+
+## 0.6.3
+
+### Patch Changes
+
+- Updated dependencies []:
+ - @react-doctor/core@0.6.3
+
## 0.6.2
### Patch Changes
diff --git a/packages/api/package.json b/packages/api/package.json
index b3117d1b4a..9a7c785940 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-doctor/api",
- "version": "0.6.2",
+ "version": "0.7.1",
"private": true,
"description": "Programmatic API for React Doctor.",
"license": "SEE LICENSE IN LICENSE",
diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts
index 757d121249..55ae8cf6cf 100644
--- a/packages/api/src/diagnose.ts
+++ b/packages/api/src/diagnose.ts
@@ -9,6 +9,7 @@ import {
detectAiTrainingEnvironment,
Files,
Git,
+ hasReactRuntime,
layerOtlp,
Linter,
LintPartialFailures,
@@ -125,6 +126,7 @@ const outputToDiagnoseResult = (
skippedChecks,
...(Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {}),
project: output.project,
+ reactDetected: hasReactRuntime(output.project),
elapsedMilliseconds,
};
};
@@ -236,12 +238,15 @@ const diagnoseProjectBatch = async (
(projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig),
);
+ const succeededProjects = projectResults.filter((projectResult) => projectResult.ok);
+
return {
projects: projectResults,
- diagnostics: projectResults.flatMap((projectResult) =>
- projectResult.ok ? projectResult.diagnostics : [],
- ),
+ diagnostics: succeededProjects.flatMap((projectResult) => projectResult.diagnostics),
score: findWorstScore(projectResults),
+ ...(succeededProjects.length > 0
+ ? { reactDetected: succeededProjects.some((projectResult) => projectResult.reactDetected) }
+ : {}),
elapsedMilliseconds: globalThis.performance.now() - startTime,
};
};
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 3a5e2eed9e..06950f41c7 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -1,5 +1,5 @@
export { diagnose } from "./diagnose.js";
-export { defineConfig } from "@react-doctor/core";
+export { defineConfig, hasReactRuntime } from "@react-doctor/core";
export type {
DiagnoseOptions,
diff --git a/packages/api/tests/diagnose.test.ts b/packages/api/tests/diagnose.test.ts
index 61d2d91938..2845772664 100644
--- a/packages/api/tests/diagnose.test.ts
+++ b/packages/api/tests/diagnose.test.ts
@@ -69,6 +69,32 @@ describe("diagnose", () => {
}
});
+ it("sets reactDetected true on a React project and false on a non-React one", async () => {
+ const reactResult = await diagnose(path.join(FIXTURES_DIRECTORY, "basic-react"), {
+ deadCode: false,
+ lint: false,
+ });
+ expect(reactResult.reactDetected).toBe(true);
+
+ const nonReactDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-nonreact-"));
+ fs.writeFileSync(
+ path.join(nonReactDirectory, "package.json"),
+ JSON.stringify({ name: "non-react-tool", dependencies: { lodash: "^4.0.0" } }),
+ );
+ fs.mkdirSync(path.join(nonReactDirectory, "src"));
+ fs.writeFileSync(
+ path.join(nonReactDirectory, "src", "index.ts"),
+ "export const add = (firstNumber: number, secondNumber: number): number => firstNumber + secondNumber;\n",
+ );
+ try {
+ const nonReactResult = await diagnose(nonReactDirectory, { deadCode: false, lint: false });
+ expect(nonReactResult.reactDetected).toBe(false);
+ expect(nonReactResult.project.reactVersion).toBeNull();
+ } finally {
+ fs.rmSync(nonReactDirectory, { recursive: true, force: true });
+ }
+ });
+
it("elapsedMilliseconds is non-negative", async () => {
const result = await diagnose(path.join(FIXTURES_DIRECTORY, "basic-react"), {
deadCode: false,
@@ -201,9 +227,25 @@ describe("diagnose({ projects })", () => {
expect(result.projects).toHaveLength(0);
expect(result.diagnostics).toHaveLength(0);
expect(result.score).toBeNull();
+ expect(result.reactDetected).toBeUndefined();
expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0);
});
+ it("aggregates reactDetected across succeeded projects", async () => {
+ const result = await diagnose({
+ projects: [
+ { directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
+ { directory: noReactTempDirectory },
+ ],
+ deadCode: false,
+ lint: false,
+ });
+
+ expect(result.reactDetected).toBe(true);
+ const succeeded = result.projects.find((projectResult) => projectResult.ok);
+ expect(succeeded?.ok && succeeded.reactDetected).toBe(true);
+ });
+
it("clamps concurrency: 0 to 1 without hanging", async () => {
const result = await diagnose({
projects: [{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") }],
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 28b8aadafe..7cddc6d721 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,31 @@
# @react-doctor/core
+## 0.7.1
+
+### Patch Changes
+
+- [#1061](https://github.com/millionco/react-doctor/pull/1061) [`c0c3fc1`](https://github.com/millionco/react-doctor/commit/c0c3fc170972876c8bbc2419b32e66b9c864df85) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Fix a CI-gate false positive in the baseline delta: pre-existing element-level findings (Accessibility-category rules, plus rules flagged `matchByOccurrence` like `iframe-missing-sandbox`) are now matched by `(file, rule)` occurrence count instead of the flagged line's text, so reformatting the flagged line (reindentation, prettier reflow, collapsing a multi-line JSX element) no longer reports the finding as newly introduced. The flag is resolved at diagnostic creation and carried on the diagnostic as an optional `matchByOccurrence` field (also present in the JSON report). Expression-level rules keep line-text-sensitive matching, and a genuinely new extra occurrence still surfaces.
+
+- Updated dependencies [[`c0c3fc1`](https://github.com/millionco/react-doctor/commit/c0c3fc170972876c8bbc2419b32e66b9c864df85)]:
+ - oxlint-plugin-react-doctor@0.7.1
+ - deslop-js@0.7.1
+
+## 0.7.0
+
+### Patch Changes
+
+- Updated dependencies [[`ced746f`](https://github.com/millionco/react-doctor/commit/ced746f518f11e8283d488c4ff31c44e478bb0e5), [`20d81f6`](https://github.com/millionco/react-doctor/commit/20d81f6f26dc8f0562118076f835da2468591d5f), [`ce49250`](https://github.com/millionco/react-doctor/commit/ce4925008d37d7c86a234e6b9c7c2c3afe873405)]:
+ - deslop-js@0.7.0
+ - oxlint-plugin-react-doctor@0.7.0
+
+## 0.6.3
+
+### Patch Changes
+
+- Updated dependencies [[`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`b4faf74`](https://github.com/millionco/react-doctor/commit/b4faf74744c730d0836235854b0233ce59a42566), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`b4faf74`](https://github.com/millionco/react-doctor/commit/b4faf74744c730d0836235854b0233ce59a42566), [`b4faf74`](https://github.com/millionco/react-doctor/commit/b4faf74744c730d0836235854b0233ce59a42566), [`072d37e`](https://github.com/millionco/react-doctor/commit/072d37e8e4f82454d2e187114d0194f26efc1bf0), [`2980d0f`](https://github.com/millionco/react-doctor/commit/2980d0f4ed6abfee061ac02f3a0820806f942b95), [`5fec491`](https://github.com/millionco/react-doctor/commit/5fec491e6844d73f658f355ae2cbe86285068f0e), [`05f6399`](https://github.com/millionco/react-doctor/commit/05f639910abf2b3bfc0802e9ad568ecd2b7ce13d), [`a1c8ee1`](https://github.com/millionco/react-doctor/commit/a1c8ee110e137bbc8771c8a471c20287cccd2b38), [`fa61c20`](https://github.com/millionco/react-doctor/commit/fa61c2056951df2429e79d888e5f7334aaf61cfd), [`ac71a3b`](https://github.com/millionco/react-doctor/commit/ac71a3b8cfc8bdd157f0f1bcd242b61ec69f9c17), [`d8628d7`](https://github.com/millionco/react-doctor/commit/d8628d7f21e60b0e6dfd98d76c9f24e03f7afe24), [`ebeee56`](https://github.com/millionco/react-doctor/commit/ebeee568abf9a7ed37ed9fe0bba695e4f2a11c9f), [`da3b19c`](https://github.com/millionco/react-doctor/commit/da3b19c79c27945d873eb24e34431cbefa8f9938), [`6a9a73b`](https://github.com/millionco/react-doctor/commit/6a9a73b14908272535aabab6742258b61bc2ee5c), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b), [`173cc0a`](https://github.com/millionco/react-doctor/commit/173cc0a8ba5578229e3832b2167d3f7a5386c91b)]:
+ - oxlint-plugin-react-doctor@0.6.3
+ - deslop-js@0.6.3
+
## 0.6.2
### Patch Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index 5374f9c866..a11e7e7ad6 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-doctor/core",
- "version": "0.6.2",
+ "version": "0.7.1",
"private": true,
"description": "Diagnostic engine for React Doctor.",
"license": "SEE LICENSE IN LICENSE",
@@ -28,7 +28,6 @@
"effect": "4.0.0-beta.70",
"eslint-plugin-react-hooks": "^7.1.1",
"jiti": "^2.7.0",
- "oxlint": ">=1.66.0 <1.67.0",
"oxlint-plugin-react-doctor": "workspace:*",
"picomatch": "^4.0.4",
"semver": "^7.7.4",
@@ -38,7 +37,8 @@
"@effect/vitest": "4.0.0-beta.70",
"@types/node": "^25.6.0",
"@types/picomatch": "^4.0.3",
- "@types/semver": "^7.7.1"
+ "@types/semver": "^7.7.1",
+ "vitest": "catalog:"
},
"engines": {
"node": "^20.19.0 || >=22.13.0"
diff --git a/packages/core/src/batch-include-paths.ts b/packages/core/src/batch-include-paths.ts
index bc0f905d50..a583a33454 100644
--- a/packages/core/src/batch-include-paths.ts
+++ b/packages/core/src/batch-include-paths.ts
@@ -1,7 +1,5 @@
import { OXLINT_MAX_FILES_PER_BATCH, SPAWN_ARGS_MAX_LENGTH_CHARS } from "./constants.js";
-
-const estimateArgsLength = (args: string[]): number =>
- args.reduce((total, argument) => total + argument.length + 1, 0);
+import { estimateArgsLength } from "./utils/estimate-args-length.js";
// Splits a (possibly huge) include-path list into batches that each
// fit under BOTH the spawn-args byte budget (Windows CreateProcessW caps
diff --git a/packages/core/src/build-json-report.ts b/packages/core/src/build-json-report.ts
index fdf60ff117..786f314ef7 100644
--- a/packages/core/src/build-json-report.ts
+++ b/packages/core/src/build-json-report.ts
@@ -7,6 +7,7 @@ import type {
InspectResult,
} from "./types/index.js";
import { summarizeDiagnostics } from "./summarize-diagnostics.js";
+import { hasReactRuntime } from "./utils/has-react-runtime.js";
interface BuildJsonReportInput {
version: string;
@@ -81,6 +82,9 @@ export const buildJsonReport = (input: BuildJsonReportInput): JsonReport => {
);
const shared = {
+ ...(input.scans.length > 0
+ ? { reactDetected: input.scans.some((scan) => hasReactRuntime(scan.result.project)) }
+ : {}),
version: input.version,
ok: true as const,
directory: input.directory,
diff --git a/packages/core/src/check-dead-code.ts b/packages/core/src/check-dead-code.ts
index 8c3bd76513..332373ee72 100644
--- a/packages/core/src/check-dead-code.ts
+++ b/packages/core/src/check-dead-code.ts
@@ -6,14 +6,23 @@ import {
collectDeadCodeEntryPatterns,
collectDeadCodeIgnorePatterns,
} from "./dead-code/collect-dead-code-patterns.js";
+import {
+ collectAnalyzedFileStats,
+ computeDeadCodeCacheKey,
+ lookupDeadCodeResultCache,
+ storeDeadCodeResultCache,
+} from "./dead-code/dead-code-result-cache.js";
import { withDeadCodeWorkerSlot } from "./dead-code/dead-code-worker-slots.js";
import {
+ CORE_PACKAGE_VERSION,
+ DEAD_CODE_SUMMARY_CACHE_FILENAME,
DEAD_CODE_WORKER_MAX_OLD_SPACE_MB,
DEAD_CODE_WORKER_TIMEOUT_MS,
MILLISECONDS_PER_SECOND,
TSCONFIG_FILENAMES,
} from "./constants.js";
import { isRecord } from "./utils/is-record.js";
+import { resolveReactDoctorCacheDir } from "./utils/resolve-react-doctor-cache-dir.js";
import { toCanonicalPath } from "./utils/to-canonical-path.js";
import { toRelativePath } from "./utils/to-relative-path.js";
@@ -57,6 +66,37 @@ interface CheckDeadCodeOptions {
* `DEAD_CODE_WORKER_TIMEOUT_MS`.
*/
readonly abortSignal?: AbortSignal;
+ /**
+ * Whether to consult the dead-code caches. Defaults OFF so direct callers
+ * (and existing tests) keep their fresh-analysis semantics; the `DeadCode`
+ * service passes the `DeadCodeResultCacheEnabled` Reference here. Gates
+ * both layers: the whole-project result cache (a hit replays the stored
+ * diagnostics without spawning the analysis worker; a fresh COMPLETE pass
+ * is stored on success — a crashed, timed-out, or aborted worker rejects
+ * before the store) and, on a miss, deslop's incremental summary cache
+ * inside the worker (per-file parse summaries so a changed-files re-analysis
+ * only re-parses what changed).
+ */
+ readonly cacheEnabled?: boolean;
+ /**
+ * Reports the cache outcome (`true` = hit, `false` = miss) once per call.
+ * Not invoked when `cacheEnabled` is off, so the orchestrator's telemetry
+ * distinguishes "no cache" from a miss.
+ */
+ readonly onCacheOutcome?: (didHitCache: boolean) => void;
+ /**
+ * Reports deslop's incremental summary-cache outcome (files served from
+ * cached parse summaries vs freshly parsed) once per ANALYSIS run. Not
+ * invoked on a whole-result cache hit (no analysis ran) or when caching is
+ * off (the worker analyzes without the incremental store), so the
+ * orchestrator's telemetry distinguishes "no cache" from a 0% hit rate.
+ */
+ readonly onSummaryCacheStats?: (stats: DeadCodeSummaryCacheStats) => void;
+}
+
+interface DeadCodeSummaryCacheStats {
+ readonly hits: number;
+ readonly misses: number;
}
interface DeadCodeWorkerInput {
@@ -67,6 +107,11 @@ interface DeadCodeWorkerInput {
readonly deslopJsModuleSpecifier: string;
/** Caps deslop's parse pool via `DESLOP_PARSE_CONCURRENCY` on the child env. */
readonly parseConcurrency?: number;
+ /**
+ * `DeslopConfig.incrementalCachePath` for the worker's `analyze()` call.
+ * Omitted when caching is off — deslop then analyzes from scratch.
+ */
+ readonly incrementalCachePath?: string;
}
interface DeadCodeWorkerHandle {
@@ -104,6 +149,7 @@ interface DeadCodeWorkerResult {
readonly unusedExports: ReadonlyArray;
readonly unusedDependencies: ReadonlyArray;
readonly circularDependencies: ReadonlyArray;
+ readonly summaryCacheStats?: DeadCodeSummaryCacheStats;
}
interface DeadCodeWorkerError {
@@ -150,6 +196,14 @@ process.stdin.on("end", () => {
circularDependencies: result.circularDependencies.map((cycle) => ({
files: cycle.files,
})),
+ ...(result.incrementalCacheStats
+ ? {
+ summaryCacheStats: {
+ hits: result.incrementalCacheStats.summaryHits,
+ misses: result.incrementalCacheStats.summaryMisses,
+ },
+ }
+ : {}),
});
const serializeError = (error) =>
@@ -173,6 +227,9 @@ process.stdin.on("end", () => {
...(workerInput.ignorePatterns.length > 0
? { ignorePatterns: workerInput.ignorePatterns }
: {}),
+ ...(workerInput.incrementalCachePath
+ ? { incrementalCachePath: workerInput.incrementalCachePath }
+ : {}),
// We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
// unusedDependencies, circularDependencies). Everything else deslop can compute
// is pure wasted work for us, and it's the bulk of the runtime:
@@ -183,12 +240,18 @@ process.stdin.on("end", () => {
// are the single most expensive pass — duplicate-block detection alone was
// ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
// speedup on a large repo.
- // Both are provably safe: the consumed graph findings are computed by their own
+ // - reportRedundancy: the DRY-pattern detectors (duplicate types/constants,
+ // simplifiable functions, identity wrappers, …) — ~120 ms of discarded
+ // output per scan, and skipping them lets the incremental cache drop the
+ // DRY-pattern summary fields (the largest slice of the cache file).
+ // All are provably safe: the consumed graph findings are computed by their own
// detectors, independent of these passes (confirmed byte-identical on
- // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
- // needs it for path-alias resolution in the import graph.
+ // excalidraw + mui-material + sentry; re-verified on sentry after the
+ // reportRedundancy flip). tsConfigPath stays — the module resolver needs it
+ // for path-alias resolution in the import graph.
semantic: { enabled: false },
reportCodeQuality: false,
+ reportRedundancy: false,
};
const result = await analyze(defineConfig(config));
emit({ ok: true, result: normalizeResult(result) });
@@ -313,15 +376,25 @@ const parseCircularDependencies = (value: unknown): DeadCodeWorkerCircularDepend
return circularDependencies;
};
+// Telemetry-only, so malformed stats degrade to `undefined` instead of
+// rejecting the scan like the diagnostic fields above do.
+const parseSummaryCacheStats = (value: unknown): DeadCodeSummaryCacheStats | undefined => {
+ if (!isRecord(value)) return undefined;
+ if (typeof value.hits !== "number" || typeof value.misses !== "number") return undefined;
+ return { hits: value.hits, misses: value.misses };
+};
+
const parseDeadCodeWorkerResult = (value: unknown): DeadCodeWorkerResult => {
if (!isRecord(value)) {
throw new Error("Dead-code worker returned an invalid result.");
}
+ const summaryCacheStats = parseSummaryCacheStats(value.summaryCacheStats);
return {
unusedFiles: parseUnusedFiles(value.unusedFiles),
unusedExports: parseUnusedExports(value.unusedExports),
unusedDependencies: parseUnusedDependencies(value.unusedDependencies),
circularDependencies: parseCircularDependencies(value.circularDependencies),
+ ...(summaryCacheStats ? { summaryCacheStats } : {}),
};
};
@@ -498,19 +571,64 @@ export const checkDeadCode = async (options: CheckDeadCodeOptions): Promise => {
const workerHandle = (options.createWorker ?? createDeadCodeWorker)({
rootDirectory,
entryPatterns,
- tsConfigPath: resolveTsConfigPath(rootDirectory),
+ tsConfigPath,
ignorePatterns,
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
+ deslopJsModuleSpecifier,
parseConcurrency: options.parseConcurrency,
+ incrementalCachePath,
});
return runDeadCodeWorkerWithTimeout(
workerHandle,
@@ -528,6 +646,9 @@ export const checkDeadCode = async (options: CheckDeadCodeOptions): Promise toRelativeFilePath(rootDirectory, filePath);
const diagnostics: Diagnostic[] = [];
@@ -600,5 +721,18 @@ export const checkDeadCode = async (options: CheckDeadCodeOptions): Promise= COOPERATIVE_YIELD_BUDGET_MS) {
+ await yieldToEventLoop();
+ sliceStartedAt = performance.now();
+ }
+ continue;
+ }
for (const _ruleStep of session.scanFileByRule(file)) {
- if (performance.now() - sliceStartedAt >= SECURITY_SCAN_YIELD_BUDGET_MS) {
+ if (performance.now() - sliceStartedAt >= COOPERATIVE_YIELD_BUDGET_MS) {
await yieldToEventLoop();
sliceStartedAt = performance.now();
}
diff --git a/packages/core/src/check-supply-chain.ts b/packages/core/src/check-supply-chain.ts
index bdea00bcad..e36efcd258 100644
--- a/packages/core/src/check-supply-chain.ts
+++ b/packages/core/src/check-supply-chain.ts
@@ -405,6 +405,30 @@ const writeCachedSocketBody = (cacheFile: string, body: string): void => {
}
};
+// Drops cache files past the TTL. A live dependency's expired entry would
+// re-fetch anyway; without this, entries for purls that stop being looked up
+// (version bumps, removed dependencies) accumulate forever — a slow monotonic
+// leak in CI, where the whole cache directory is persisted and restored across
+// runs. File mtime stands in for `fetchedAtMs` (same clock: the file is
+// written when the entry is fetched, and both local disks and the CI cache's
+// tar round-trip preserve it), so pruning stats instead of parsing every file.
+const pruneExpiredSocketCache = (cacheDirectory: string): void => {
+ try {
+ const supplyChainCacheDirectory = path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR);
+ const expiryThresholdMs = Date.now() - SUPPLY_CHAIN_CACHE_TTL_MS;
+ for (const entryName of fs.readdirSync(supplyChainCacheDirectory)) {
+ const entryPath = path.join(supplyChainCacheDirectory, entryName);
+ try {
+ if (fs.statSync(entryPath).mtimeMs < expiryThresholdMs) fs.rmSync(entryPath);
+ } catch {
+ continue;
+ }
+ }
+ } catch {
+ // A prune failure must never sink the scan.
+ }
+};
+
// Fetches the free, keyless Socket artifact (score + alerts) for one
// dependency — the same `firewall-api.socket.dev/purl/` endpoint
// Socket Firewall's free tier hits. `Effect.tryPromise` hands `fetch` an
@@ -676,6 +700,7 @@ export const checkSupplyChain = (input: SupplyChainCheckInput): Effect.Effect {
+): Generator {
const priorityCandidates: SecurityScanCandidate[] = [];
const artifactCandidates: SecurityScanCandidate[] = [];
const otherCandidates: SecurityScanCandidate[] = [];
@@ -99,6 +105,7 @@ export function* collectSecurityScanFiles(
isGeneratedBundleByName: classification.isGeneratedBundleByName,
});
}
+ yield null;
}
for (const candidates of [priorityCandidates, artifactCandidates, otherCandidates]) {
diff --git a/packages/core/src/checks/security-scan/constants.ts b/packages/core/src/checks/security-scan/constants.ts
index d5d309b996..687b292aa5 100644
--- a/packages/core/src/checks/security-scan/constants.ts
+++ b/packages/core/src/checks/security-scan/constants.ts
@@ -3,12 +3,6 @@ export const SECURITY_SCAN_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
export const SECURITY_SCAN_MAX_BUNDLE_FILE_SIZE_BYTES = 8 * 1024 * 1024;
export const SECURITY_SCAN_MAX_DIRECTORY_DEPTH = 8;
-// Longest synchronous burst the cooperative scan may hold the event loop
-// before yielding, checked between every (file, rule) step. The overlapping
-// lint pass spawns and drains its child processes from main-thread
-// continuations, so bursts beyond ~a frame idle the whole worker pool.
-export const SECURITY_SCAN_YIELD_BUDGET_MS = 12;
-
export const SKIPPED_DIRECTORY_NAMES = new Set([
".git",
".turbo",
diff --git a/packages/core/src/compute-diagnostic-delta.ts b/packages/core/src/compute-diagnostic-delta.ts
index a341875c74..a471002a7d 100644
--- a/packages/core/src/compute-diagnostic-delta.ts
+++ b/packages/core/src/compute-diagnostic-delta.ts
@@ -25,7 +25,10 @@ export interface ComputeDiagnosticDeltaInput {
const fingerprintDiagnostic = (diagnostic: Diagnostic, lineText: string | null): string => {
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
- const snippet = lineText === null ? "" : createHash("sha1").update(lineText.trim()).digest("hex");
+ const snippet =
+ lineText === null || diagnostic.matchByOccurrence
+ ? ""
+ : createHash("sha1").update(lineText.trim()).digest("hex");
return `${diagnostic.filePath}\u0000${ruleKey}\u0000${snippet}`;
};
@@ -37,6 +40,17 @@ const fingerprintDiagnostic = (diagnostic: Diagnostic, lineText: string | null):
* look new, while a genuinely new occurrence (new line text, or one more of
* the same) surfaces. Identical repeated findings are matched by count.
*
+ * Diagnostics carrying `matchByOccurrence` (resolved at diagnostic creation:
+ * every Accessibility-category finding, plus rules opting in via their
+ * `matchByOccurrence` metadata flag) drop the line-text snippet and match by
+ * `(filePath, plugin/rule)` occurrence count alone. Their identity is the
+ * flagged element, not the line's text, so editing the line (reindentation,
+ * prettier reflow, collapsing a multi-line JSX element) doesn't reclassify a
+ * pre-existing finding as new — while one MORE occurrence of the same rule in
+ * the file still surfaces. Expression-level rules keep the line-text snippet:
+ * there the flagged expression IS the finding, so changed text means new +
+ * fixed.
+ *
* v1 limitation: the fingerprint keys on the head-relative `filePath`, and base
* content is read at that same path. A file renamed by the change therefore has
* no base match, so its pre-existing findings are reported as new. This
diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts
index a832d8ed3d..81389263d1 100644
--- a/packages/core/src/constants.ts
+++ b/packages/core/src/constants.ts
@@ -306,6 +306,13 @@ export const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
// binding is markedly slower than on a developer laptop.
export const OXLINT_SPAWN_TIMEOUT_MS = 60_000;
+// Longest synchronous burst a cooperative main-thread pass (the security
+// scan's walk / file / rule steps, lint's pre-spawn cache hashing) may hold
+// the event loop before handing it back. Lint child processes are spawned and
+// drained from main-thread continuations, so bursts beyond ~a frame idle the
+// whole worker pool — and starve concurrently-scanning sibling projects.
+export const COOPERATIVE_YIELD_BUDGET_MS = 12;
+
// Directory name appended to os.tmpdir() to form the shared base for the V8
// compile cache. Matches the base Node's own module.enableCompileCache() uses,
// so the bin (parent) and the spawned oxlint batches (children) share one tree.
@@ -451,6 +458,15 @@ export const DIAGNOSTIC_CATEGORY_BUCKETS = [
"Maintainability",
] as const;
+// Categories whose findings are matched by occurrence in the CI baseline
+// delta — the finding's identity is the flagged element (a missing
+// attribute, a wrong element), not the flagged line's text — so the delta
+// matches them by `(file, rule)` occurrence count instead of a line-text
+// hash. Every Accessibility rule is element-level; rules in other
+// categories opt in individually via their per-rule `matchByOccurrence`
+// flag (see `resolveMatchByOccurrence` in `runners/oxlint/parse-output`).
+export const OCCURRENCE_MATCHED_CATEGORIES: ReadonlySet = new Set(["Accessibility"]);
+
// Rules whose heuristic only makes sense in application code. A published
// library deliberately exposes flexible primitives (components built in
// render to capture closures, many `render*` slots for composition), so these
@@ -620,10 +636,42 @@ export const FILE_LINT_CACHE_MAX_RULESET_COUNT = 8;
// repos; the most-recently-stored entries are kept when over the cap.
export const FILE_LINT_CACHE_MAX_FILE_COUNT = 50_000;
+// Sidecar lint cache (`runners/oxlint/sidecar-lint-cache.ts`). Caches the
+// cross-file rules' per-file diagnostics keyed by content hash + sidecar
+// ruleset hash, each entry guarded by the file's cross-file dependency probe
+// set, so a warm rescan replays the sidecar instead of re-linting every
+// unchanged file. Shares the file cache's bucket/file caps.
+export const SIDECAR_LINT_CACHE_SCHEMA_VERSION = 1;
+
+export const SIDECAR_LINT_CACHE_FILENAME = "sidecar-lint-cache.json";
+
// Length (chars) of the project-directory hash used to name the tmp-dir cache
// fallback when a project has no `node_modules` to host `.cache/react-doctor`.
export const CACHE_FILENAME_HASH_LENGTH_CHARS = 16;
+// This package's own version, inlined at build time (`vite.config.ts` `env`)
+// the same way the CLI inlines `VERSION`; running from source (tests, dev)
+// falls back to "0.0.0". Cache keys include it because cached diagnostics
+// carry core's POST-PROCESSING (message text, toolchain-dependency filtering),
+// so an upgrade must never replay entries shaped by an older core.
+export const CORE_PACKAGE_VERSION = process.env.REACT_DOCTOR_CORE_VERSION ?? "0.0.0";
+
+// Whole-project dead-code result cache (`dead-code/dead-code-result-cache.ts`).
+// Replays deslop's diagnostics — skipping the analysis worker entirely — when
+// nothing the analysis reads has changed since the stored run.
+// Bumped to 2: entries carry a per-file `files` map (mtime, size, content
+// hash) instead of folding the file stats into the key, so a fresh checkout's
+// bumped mtimes can be repaired against unchanged content.
+export const DEAD_CODE_CACHE_SCHEMA_VERSION = 2;
+
+export const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
+
+// deslop's incremental analysis store (`DeslopConfig.incrementalCachePath`) —
+// per-file parse summaries + collect/resolution/package-fact layers, written
+// by the analysis WORKER for the changed-files case the whole-result cache
+// above can't serve. Lives in the same per-project cache directory.
+export const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json";
+
// Plugin / rule / category identity for the diagnostics the supply-chain
// check emits. `plugin: "socket"` keeps Socket findings visually distinct
// from the `react-doctor` lint surface in the printed list and JSON report.
diff --git a/packages/core/src/dead-code/dead-code-result-cache.ts b/packages/core/src/dead-code/dead-code-result-cache.ts
new file mode 100644
index 0000000000..dd12c11203
--- /dev/null
+++ b/packages/core/src/dead-code/dead-code-result-cache.ts
@@ -0,0 +1,282 @@
+import crypto from "node:crypto";
+import * as fs from "node:fs";
+import { createRequire } from "node:module";
+import * as path from "node:path";
+import * as Schema from "effect/Schema";
+import { ANALYZED_MANIFEST_FILENAMES, DEFAULT_EXTENSIONS } from "deslop-js/analyzed-inputs";
+import type { Diagnostic } from "../types/index.js";
+import { DEAD_CODE_CACHE_FILENAME, DEAD_CODE_CACHE_SCHEMA_VERSION } from "../constants.js";
+import { Diagnostic as DiagnosticSchema } from "../schemas.js";
+import { atomicWriteJson } from "../utils/atomic-write-json.js";
+import { failOpenReadJson } from "../utils/fail-open-read-json.js";
+import { hashFileContents } from "../utils/hash-file-contents.js";
+import { isRecord } from "../utils/is-record.js";
+import { walkSourceTreeFiles } from "../utils/walk-source-tree-files.js";
+
+/**
+ * Whole-project dead-code result cache. Dead-code reachability is a
+ * whole-project property, so the cache holds ONE entry: the diagnostics of the
+ * last complete, successful pass, keyed by everything the analysis reads. Any
+ * input change makes the stored entry unreachable — so there is nothing to
+ * gain from keeping history.
+ *
+ * The entry records every analyzed file as (mtime, size, content hash). A
+ * lookup verifies files by stat first — ~100-200 ms to stat ~9k files versus
+ * seconds to hash them — and REPAIRS a stat mismatch by hashing the file's
+ * current content: identical content accepts the entry and refreshes the
+ * stored stat (the ninja/restat pattern), so a fresh CI checkout — where every
+ * mtime is checkout time but content is unchanged — pays the hash once per
+ * checkout, not a full re-analysis (and not once per run). Additions and
+ * deletions always invalidate — path-set equality is checked both ways. The
+ * accepted blind spot, shared with deslop's summary cache: an edit DURING the
+ * analysis that lands between store-time hash and stat re-verification.
+ *
+ * Every operation fails open: a missing or corrupt cache degrades to a fresh
+ * analysis, never to a wrong result.
+ */
+
+interface AnalyzedFileStat {
+ readonly mtimeMs: number;
+ readonly size: number;
+}
+
+interface DeadCodeCacheKeyInput {
+ /** Canonicalized project root (`checkDeadCode` realpaths it first). */
+ readonly rootDirectory: string;
+ readonly entryPatterns: ReadonlyArray;
+ readonly ignorePatterns: ReadonlyArray;
+ readonly tsConfigPath: string | undefined;
+ readonly deslopJsModuleSpecifier: string;
+ /**
+ * `@react-doctor/core`'s own version (`CORE_PACKAGE_VERSION`). Cached
+ * entries store diagnostics AFTER `checkDeadCode`'s post-processing
+ * (message text, toolchain-dependency filtering), so a core upgrade must
+ * invalidate them even when the analyzed tree is unchanged.
+ */
+ readonly coreVersion: string;
+}
+
+/** Persisted per-file identity: `[mtimeMs, size, contentHash]`. */
+type PersistedFileIdentity = readonly [number, number, string];
+
+interface PersistedDeadCodeResultCache {
+ readonly version: number;
+ readonly key: string;
+ readonly files: Record;
+ readonly diagnostics: ReadonlyArray;
+}
+
+// The fingerprinted file sets come straight from the analyzer package
+// (`deslop-js/analyzed-inputs`): the extensions its import-graph walk parses
+// and every manifest/lockfile/.gitignore name its analysis reads. The worker
+// resolves deslop-js from the same install, so these constants are exactly
+// what the analysis will use — and a deslop version bump also rotates the key
+// via the `deslopVersion` field (belt and suspenders).
+const ANALYZED_FILE_EXTENSIONS = new Set(DEFAULT_EXTENSIONS);
+
+// Beyond what deslop itself reads, the dead-code PASS also depends on:
+// `knip.json` (read core-side by `collect-dead-code-patterns.ts` to derive
+// the entry/ignore patterns) and `deno.lock` (an extra proxy for installed
+// `node_modules` metadata — deslop reads installed packages' bin/peer fields,
+// which only change through an install that rewrites a lockfile).
+const CORE_SIDE_MANIFEST_NAMES = ["knip.json", "deno.lock"];
+
+const ANALYZED_MANIFEST_NAMES = new Set([
+ ...ANALYZED_MANIFEST_FILENAMES,
+ ...CORE_SIDE_MANIFEST_NAMES,
+]);
+
+// tsconfig/jsconfig files anywhere in the tree — path-alias resolution reads
+// the root config, and `extends` chains reach the rest.
+const isTsConfigLikeFile = (fileName: string): boolean =>
+ (fileName.startsWith("tsconfig") || fileName.startsWith("jsconfig")) &&
+ fileName.endsWith(".json");
+
+const isFingerprintedFile = (fileName: string): boolean =>
+ ANALYZED_FILE_EXTENSIONS.has(path.extname(fileName).toLowerCase()) ||
+ ANALYZED_MANIFEST_NAMES.has(fileName) ||
+ isTsConfigLikeFile(fileName);
+
+/**
+ * Stat snapshot of every file the analysis reads, keyed by root-relative
+ * `/`-separated path. Taken BEFORE the (long) analysis so a stored result is
+ * verified against the tree it started from.
+ */
+export const collectAnalyzedFileStats = (
+ rootDirectory: string,
+): ReadonlyMap => {
+ const statByRelativePath = new Map();
+ for (const { absolutePath, name } of walkSourceTreeFiles(rootDirectory)) {
+ if (!isFingerprintedFile(name)) continue;
+ try {
+ const fileStat = fs.statSync(absolutePath);
+ const relativePath = path.relative(rootDirectory, absolutePath).replace(/\\/g, "/");
+ statByRelativePath.set(relativePath, { mtimeMs: fileStat.mtimeMs, size: fileStat.size });
+ } catch {
+ // Vanished between walk and stat — same contribution as deleted.
+ }
+ }
+ return statByRelativePath;
+};
+
+const bundledRequire = createRequire(import.meta.url);
+
+const resolveDeslopVersion = (): string => {
+ try {
+ const packageJson = JSON.parse(
+ fs.readFileSync(bundledRequire.resolve("deslop-js/package.json"), "utf8"),
+ );
+ return isRecord(packageJson) && typeof packageJson.version === "string"
+ ? packageJson.version
+ : "unknown";
+ } catch {
+ return "unknown";
+ }
+};
+
+// Everything that changes what a stored entry MEANS besides the analyzed
+// files themselves, which are carried per-entry (see `files`) so they can be
+// verified — and mtime-repaired — file by file.
+export const computeDeadCodeCacheKey = (input: DeadCodeCacheKeyInput): string =>
+ crypto
+ .createHash("sha1")
+ .update(
+ JSON.stringify({
+ schemaVersion: DEAD_CODE_CACHE_SCHEMA_VERSION,
+ coreVersion: input.coreVersion,
+ deslopVersion: resolveDeslopVersion(),
+ deslopJsModuleSpecifier: input.deslopJsModuleSpecifier,
+ entryPatterns: input.entryPatterns,
+ ignorePatterns: input.ignorePatterns,
+ // Which tsconfig filename resolved (its CONTENT rides in the per-file
+ // identities; existence/choice is what this captures).
+ tsConfigFile:
+ input.tsConfigPath === undefined
+ ? null
+ : path.relative(input.rootDirectory, input.tsConfigPath).replace(/\\/g, "/"),
+ }),
+ )
+ .digest("hex");
+
+const validateDiagnostic = Schema.decodeUnknownSync(DiagnosticSchema);
+
+// Returns `null` if ANY stored entry is malformed, so a corrupt file degrades
+// to a whole-pass miss rather than a partial diagnostic set. The records were
+// serialized straight from `checkDeadCode`'s `Diagnostic[]`, so the validated
+// array replays as-is in its original (deterministic) order.
+const decodeCachedDiagnostics = (raw: ReadonlyArray): ReadonlyArray | null => {
+ try {
+ for (const entry of raw) validateDiagnostic(entry);
+ return raw as ReadonlyArray;
+ } catch {
+ return null;
+ }
+};
+
+const isPersistedFileIdentity = (value: unknown): value is PersistedFileIdentity =>
+ Array.isArray(value) &&
+ value.length === 3 &&
+ typeof value[0] === "number" &&
+ typeof value[1] === "number" &&
+ typeof value[2] === "string";
+
+export interface DeadCodeResultCacheLookupInput {
+ readonly cacheDirectory: string;
+ readonly cacheKey: string;
+ readonly rootDirectory: string;
+ /** The pre-analysis stat snapshot (`collectAnalyzedFileStats`). */
+ readonly currentFileStats: ReadonlyMap;
+}
+
+export const lookupDeadCodeResultCache = (
+ input: DeadCodeResultCacheLookupInput,
+): ReadonlyArray | null => {
+ const cacheFilePath = path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME);
+ const persisted = failOpenReadJson(cacheFilePath, null);
+ if (
+ persisted === null ||
+ !isRecord(persisted) ||
+ persisted.version !== DEAD_CODE_CACHE_SCHEMA_VERSION ||
+ persisted.key !== input.cacheKey ||
+ !isRecord(persisted.files) ||
+ !Array.isArray(persisted.diagnostics)
+ ) {
+ return null;
+ }
+ const storedFileEntries = Object.entries(persisted.files);
+ // Path-set equality both ways: equal counts plus every stored path present
+ // means neither additions nor deletions can slip through.
+ if (storedFileEntries.length !== input.currentFileStats.size) return null;
+ const repairedFiles: Record = {};
+ let repairedCount = 0;
+ for (const [relativePath, storedIdentity] of storedFileEntries) {
+ if (!isPersistedFileIdentity(storedIdentity)) return null;
+ const currentStat = input.currentFileStats.get(relativePath);
+ if (currentStat === undefined) return null;
+ const [storedMtimeMs, storedSize, storedContentHash] = storedIdentity;
+ if (currentStat.mtimeMs === storedMtimeMs && currentStat.size === storedSize) {
+ repairedFiles[relativePath] = storedIdentity;
+ continue;
+ }
+ // A size change is a content change; only a same-size stat mismatch (the
+ // fresh-checkout case) is worth the hash-and-repair read.
+ if (currentStat.size !== storedSize) return null;
+ const currentContentHash = hashFileContents(path.join(input.rootDirectory, relativePath));
+ if (currentContentHash === null || currentContentHash !== storedContentHash) return null;
+ repairedFiles[relativePath] = [currentStat.mtimeMs, currentStat.size, storedContentHash];
+ repairedCount += 1;
+ }
+ const diagnostics = decodeCachedDiagnostics(persisted.diagnostics);
+ if (diagnostics === null) return null;
+ if (repairedCount > 0) {
+ // Persist the refreshed stats so the repair cost is paid once per
+ // checkout: the next lookup takes the stat fast path.
+ atomicWriteJson(cacheFilePath, {
+ version: DEAD_CODE_CACHE_SCHEMA_VERSION,
+ key: input.cacheKey,
+ files: repairedFiles,
+ diagnostics: persisted.diagnostics,
+ });
+ }
+ return diagnostics;
+};
+
+export interface DeadCodeResultCacheStoreInput {
+ readonly cacheDirectory: string;
+ readonly cacheKey: string;
+ readonly rootDirectory: string;
+ /** The pre-analysis stat snapshot (`collectAnalyzedFileStats`). */
+ readonly snapshotFileStats: ReadonlyMap;
+ readonly diagnostics: ReadonlyArray;
+}
+
+export const storeDeadCodeResultCache = (input: DeadCodeResultCacheStoreInput): void => {
+ const persistedFiles: Record = {};
+ for (const [relativePath, snapshotStat] of input.snapshotFileStats) {
+ const absolutePath = path.join(input.rootDirectory, relativePath);
+ // Hash first, stat second: a file edited during the analysis either fails
+ // the stat re-verification below (edit before the hash) or changed after
+ // the hash captured it — in which case the recorded hash matches the
+ // pre-edit content and the next lookup misses on it. Either way a racing
+ // edit can't produce a repairable stale entry. (Files added during the
+ // analysis need no handling: they miss the lookup's path-set equality.)
+ const contentHash = hashFileContents(absolutePath);
+ if (contentHash === null) return;
+ let currentStat: fs.Stats;
+ try {
+ currentStat = fs.statSync(absolutePath);
+ } catch {
+ return;
+ }
+ if (currentStat.mtimeMs !== snapshotStat.mtimeMs || currentStat.size !== snapshotStat.size) {
+ return;
+ }
+ persistedFiles[relativePath] = [snapshotStat.mtimeMs, snapshotStat.size, contentHash];
+ }
+ atomicWriteJson(path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME), {
+ version: DEAD_CODE_CACHE_SCHEMA_VERSION,
+ key: input.cacheKey,
+ files: persistedFiles,
+ diagnostics: input.diagnostics,
+ });
+};
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index b36eced076..e2d35b586d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -87,7 +87,9 @@ export * from "./utils/dedupe-diagnostics.js";
export * from "./utils/define-config.js";
export * from "./utils/detect-ai-training-environment.js";
export * from "./utils/group-by.js";
+export * from "./utils/hash-file-contents.js";
export * from "./utils/has-published-fix-recipe.js";
+export * from "./utils/has-react-runtime.js";
export * from "./utils/is-errno-exception.js";
export * from "./utils/is-large-minified-file.js";
export * from "./utils/list-source-files.js";
@@ -100,6 +102,7 @@ export * from "./utils/remaining-deadline-budget-ms.js";
export * from "./utils/resolve-auto-scan-concurrency.js";
export * from "./utils/resolve-github-actions-score-metadata.js";
export * from "./utils/resolve-lint-batch-ordering.js";
+export * from "./utils/resolve-react-doctor-cache-dir.js";
export * from "./utils/resolve-scan-concurrency.js";
export * from "./utils/sort-diagnostics-stable.js";
export * from "./utils/to-relative-path.js";
diff --git a/packages/core/src/project-info/discover-project.ts b/packages/core/src/project-info/discover-project.ts
index 4d5aa2b02f..c7947d30eb 100644
--- a/packages/core/src/project-info/discover-project.ts
+++ b/packages/core/src/project-info/discover-project.ts
@@ -8,6 +8,8 @@ import { detectReactCompiler } from "./detect-react-compiler.js";
import { extractDependencyInfo } from "./extract-dependency-info.js";
import { findDependencyInfoFromMonorepoRoot } from "./find-dependency-info-from-monorepo-root.js";
import { findMonorepoRoot, isMonorepoRoot } from "./find-monorepo-root.js";
+import { findNearestAncestorPackageJson } from "./find-nearest-ancestor-package-json.js";
+import { resolveInstalledReactVersion } from "./resolve-installed-react-version.js";
import { findReactInWorkspaces } from "./find-react-in-workspaces.js";
import { getDependencyDeclaration } from "./utils/get-dependency-declaration.js";
import { hasReactNativeWorkspaceAnywhere } from "./has-react-native-workspace-anywhere.js";
@@ -45,27 +47,26 @@ export const clearProjectCache = (): void => {
/**
* Build a `ProjectInfo` for a directory that has no `package.json` of
- * its own — a monorepo subfolder like `repo/packages`, or any loose tree
- * of TypeScript/JavaScript files. Dependency + framework detection is
- * inherited from the enclosing workspace root when there is one, so
- * scanning a subdirectory of a React monorepo still gets the React
- * capabilities; a standalone non-React directory simply scans with the
- * framework-agnostic rules. Throws only when the directory has nothing
- * to scan (no enclosing project and no source files of its own).
+ * its own — a package subfolder like `repo/packages` or `app/src/features`,
+ * or any loose tree of TypeScript/JavaScript files. Dependency + framework
+ * detection is inherited from the nearest enclosing package (a leaf
+ * workspace, a plain app root, or a monorepo root — whichever is closest,
+ * bounded by the git root), so scanning a subdirectory of a React project
+ * still gets the React capabilities; a standalone non-React directory simply
+ * scans with the framework-agnostic rules. Throws only when the directory has
+ * nothing to scan (no enclosing project and no source files of its own).
*/
const discoverProjectWithoutPackageJson = (directory: string): ProjectInfo => {
const sourceFileCount = countSourceFiles(directory);
const hasOwnTsConfig = fs.existsSync(path.join(directory, "tsconfig.json"));
- const monorepoRoot = findMonorepoRoot(directory);
+ const enclosingProjectRoot = findNearestAncestorPackageJson(directory);
const enclosingProject =
- monorepoRoot !== null && isFile(path.join(monorepoRoot, "package.json"))
- ? discoverProject(monorepoRoot)
- : null;
+ enclosingProjectRoot !== null ? discoverProject(enclosingProjectRoot) : null;
- // A workspace subfolder (e.g. `repo/packages`): keep the enclosing root's
+ // A package subfolder (e.g. `repo/packages`): keep the enclosing package's
// dependency + framework detection, but scope the directory-specific fields
- // to this folder so React capabilities survive when a React monorepo
+ // to this folder so React capabilities survive when a React project
// subdirectory is scanned.
if (enclosingProject !== null) {
return {
@@ -249,6 +250,16 @@ export const discoverProject = (directory: string): ProjectInfo => {
zodVersion = zodDeclaration.version;
}
+ // Last resort: React is physically installed and importable from here even
+ // though no declaration named a usable version — resolve it the way Node
+ // would. Fires when React is undeclared (hoisted into the repo's
+ // node_modules) or declared only as a version-less spec (`workspace:*`, `*`,
+ // a dist-tag) whose major can't be parsed; a concrete peer range like
+ // `^18 || ^19` already parsed above and is left untouched.
+ if (!reactVersion || parseReactMajor(reactVersion) === null) {
+ reactVersion = resolveInstalledReactVersion(directory) ?? reactVersion;
+ }
+
const projectName = packageJson.name ?? path.basename(directory);
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
const sourceFileCount = countSourceFiles(directory);
diff --git a/packages/core/src/project-info/find-monorepo-root.ts b/packages/core/src/project-info/find-monorepo-root.ts
index dcedd717dc..3203f57a80 100644
--- a/packages/core/src/project-info/find-monorepo-root.ts
+++ b/packages/core/src/project-info/find-monorepo-root.ts
@@ -1,4 +1,5 @@
import * as path from "node:path";
+import { ancestorDirectories } from "../utils/ancestor-directories.js";
import { isFile } from "./utils/is-file.js";
import { readPackageJson } from "./read-package-json.js";
@@ -12,11 +13,8 @@ export const isMonorepoRoot = (directory: string): boolean => {
};
export const findMonorepoRoot = (startDirectory: string): string | null => {
- let currentDirectory = path.dirname(startDirectory);
-
- while (currentDirectory !== path.dirname(currentDirectory)) {
- if (isMonorepoRoot(currentDirectory)) return currentDirectory;
- currentDirectory = path.dirname(currentDirectory);
+ for (const directory of ancestorDirectories(startDirectory, { includeStart: false })) {
+ if (isMonorepoRoot(directory)) return directory;
}
return null;
diff --git a/packages/core/src/project-info/find-nearest-ancestor-package-json.ts b/packages/core/src/project-info/find-nearest-ancestor-package-json.ts
new file mode 100644
index 0000000000..abd6032d3d
--- /dev/null
+++ b/packages/core/src/project-info/find-nearest-ancestor-package-json.ts
@@ -0,0 +1,33 @@
+import * as path from "node:path";
+import { ancestorDirectories } from "../utils/ancestor-directories.js";
+import { isProjectBoundary } from "../utils/is-project-boundary.js";
+import { isFile } from "./utils/is-file.js";
+
+/**
+ * Walk up from `startDirectory` to the nearest ancestor that owns a
+ * `package.json`, stopping at (and including) the enclosing project boundary
+ * — the working tree's git root or a monorepo root. Lets a scan of a package
+ * subfolder that has no `package.json` of its own adopt the nearest enclosing
+ * package as its project root, so it inherits that package's dependency +
+ * framework detection instead of synthesizing an empty (React-blind) project.
+ *
+ * Generalizes the older monorepo-root-only lookup: the nearest ancestor is the
+ * most specific owning package (a leaf workspace, not just the repo root), so
+ * a subfolder of one workspace no longer borrows a sibling's capabilities.
+ *
+ * Returns `null` when no ancestor `package.json` exists inside the project
+ * boundary (a loose tree of files, or a subfolder of a package-less repo).
+ */
+export const findNearestAncestorPackageJson = (startDirectory: string): string | null => {
+ // The scan directory itself has no `package.json` (the caller checked). If it
+ // is already the project boundary — the git root or a monorepo root — don't
+ // walk above it and adopt an unrelated `package.json` from outside the repo.
+ if (isProjectBoundary(startDirectory)) return null;
+
+ for (const directory of ancestorDirectories(startDirectory, { includeStart: false })) {
+ if (isFile(path.join(directory, "package.json"))) return directory;
+ if (isProjectBoundary(directory)) return null;
+ }
+
+ return null;
+};
diff --git a/packages/core/src/project-info/resolve-installed-react-version.ts b/packages/core/src/project-info/resolve-installed-react-version.ts
new file mode 100644
index 0000000000..c43da4a646
--- /dev/null
+++ b/packages/core/src/project-info/resolve-installed-react-version.ts
@@ -0,0 +1,63 @@
+import * as path from "node:path";
+import { ancestorDirectories } from "../utils/ancestor-directories.js";
+import { isProjectBoundary } from "../utils/is-project-boundary.js";
+import { isFile } from "./utils/is-file.js";
+import { readPackageJson } from "./read-package-json.js";
+
+/**
+ * Last-resort React detection: locate React the way Node's `node_modules`
+ * resolution would from `directory` — check each `node_modules/react` up the
+ * tree — but bounded to the enclosing repo so a globally installed React can't
+ * masquerade as the project's version. Stops at (and including) the nearest
+ * project boundary — the git root or a monorepo root — and returns the first
+ * installed `version`.
+ *
+ * Makes "React is installed and importable" ⇒ "React is detected" an invariant
+ * for packages whose only React declaration is a version-less spec
+ * (`workspace:*`, `*`, a dist-tag) or where React lives solely in a hoisted
+ * `node_modules` the declaration walks never reach — the profile of a component
+ * package inside a monorepo. Matching the `node_modules/react` entry rather than
+ * its realpath means a project whose `node_modules` is symlinked elsewhere (a
+ * Docker volume, a shared store) still counts, while a global install outside
+ * the repo does not.
+ *
+ * Returns `null` when React isn't installed within the repo or its package.json
+ * carries no version string. A tree with no boundary marker at all bounds the
+ * search to `directory`, so a dependency hoisted above the scanned package there
+ * is conservatively not adopted.
+ */
+/**
+ * The nearest enclosing project boundary (git root or monorepo root), or
+ * `directory` itself when the scan target sits outside any repo. Bounds the
+ * node_modules walk so a React hoisted anywhere inside the repo counts, but a
+ * React in an ancestor above the repo — a home-directory or global
+ * `node_modules` — can't leak in.
+ */
+const findContainmentRoot = (directory: string): string => {
+ for (const ancestorDirectory of ancestorDirectories(directory, { includeStart: true })) {
+ if (isProjectBoundary(ancestorDirectory)) return ancestorDirectory;
+ }
+ return directory;
+};
+
+export const resolveInstalledReactVersion = (directory: string): string | null => {
+ const containmentRoot = findContainmentRoot(directory);
+
+ for (const ancestorDirectory of ancestorDirectories(directory, { includeStart: true })) {
+ const reactPackageJsonPath = path.join(
+ ancestorDirectory,
+ "node_modules",
+ "react",
+ "package.json",
+ );
+ if (isFile(reactPackageJsonPath)) {
+ const installedVersion = readPackageJson(reactPackageJsonPath).version;
+ return typeof installedVersion === "string" ? installedVersion : null;
+ }
+ // Stop after the containment root — its own node_modules (the hoist target)
+ // was just checked, so anything higher is outside the repo.
+ if (ancestorDirectory === containmentRoot) return null;
+ }
+
+ return null;
+};
diff --git a/packages/core/src/refs.ts b/packages/core/src/refs.ts
index c6b7ded61a..ab42473eb9 100644
--- a/packages/core/src/refs.ts
+++ b/packages/core/src/refs.ts
@@ -158,17 +158,19 @@ export class DeadCodeOverlap extends Context.Reference<"auto" | "on" | "off">(
) {}
/**
- * How the full-scan lint pass orders its file batches. `"arrival"` (the
- * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
- * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
- * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
- * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
- * batch is a straggler (and can trip the per-batch timeout + split), measurably
- * regressing the common full-scan case. LPT needs the heavy files SPREAD across
- * batches before `cost` earns the default. Tests override via
- * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
- * — they pass user-scoped `includePaths` that skip discovery and stay in
- * arrival order; only the full-scan branch reads it.
+ * How the full-scan lint pass plans its file batches. `"cost"` (the default)
+ * builds size-balanced LPT batches (`planLintBatches`): the same mandatory
+ * batch count as greedy chunking (`ceil(files / 100)`), but every batch gets
+ * an even share of files AND bytes, so no 100-file chunk is a straggler while
+ * the remainder-batch worker idles — and the heavy files are SPREAD across
+ * batches, the precondition the old sort-desc-then-chunk-100 `cost` mode
+ * lacked (it packed the heaviest files into one wave-1 straggler batch,
+ * measurably regressing size-skewed repos, which is why it never earned the
+ * default). `"arrival"` (`REACT_DOCTOR_LINT_BATCH_ORDERING=arrival`) is the
+ * rollback hatch to plain greedy 100-file chunking in discovery order. Tests
+ * override via `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans
+ * never reach this — they pass user-scoped `includePaths` that skip discovery
+ * and stay in arrival order; only the full-scan branch reads it.
*/
export class LintBatchOrdering extends Context.Reference<"cost" | "arrival">(
"react-doctor/LintBatchOrdering",
@@ -205,3 +207,57 @@ export class PerFileLintCacheEnabled extends Context.Reference(
},
},
) {}
+
+/**
+ * Whether the sidecar lint cache (`runners/oxlint/sidecar-lint-cache.ts`) is
+ * active. Defaults ON — warm rescans replay the cross-file rules' cached
+ * diagnostics for every file whose dependency fingerprint (the probe set the
+ * plugin's collectors recorded) still matches the tree, and re-lint only the
+ * rest. Only reachable when the per-file lint cache itself is on (the
+ * sidecar covers its cache hits), so `REACT_DOCTOR_NO_CACHE` /
+ * `REACT_DOCTOR_NO_FILE_CACHE` implicitly disable it too. Opt-OUT knobs:
+ *
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
+ * - `REACT_DOCTOR_NO_SIDECAR_CACHE` — granular rollback hatch: keep the
+ * per-file cache but run the always-fresh sidecar over every hit
+ * (the pre-cache behavior).
+ *
+ * Tests override via `Layer.succeed(SidecarLintCacheEnabled, false)`.
+ */
+export class SidecarLintCacheEnabled extends Context.Reference(
+ "react-doctor/SidecarLintCacheEnabled",
+ {
+ defaultValue: () => {
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
+ const noSidecarCache = process.env["REACT_DOCTOR_NO_SIDECAR_CACHE"]?.toLowerCase() ?? "";
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
+ if (CACHE_DISABLED_VALUES.has(noSidecarCache)) return false;
+ return true;
+ },
+ },
+) {}
+
+/**
+ * Whether the whole-project dead-code result cache
+ * (`dead-code/dead-code-result-cache.ts`) is active. Defaults ON — a rescan
+ * whose inputs (source tree, manifests, configs, analyzer version) are
+ * unchanged replays the stored diagnostics instead of re-running the
+ * analysis worker. Opt-OUT, two knobs (matching the per-file lint cache):
+ *
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
+ * - `REACT_DOCTOR_NO_DEAD_CODE_CACHE` — granular: bust only this cache.
+ *
+ * Tests override via `Layer.succeed(DeadCodeResultCacheEnabled, false)`.
+ */
+export class DeadCodeResultCacheEnabled extends Context.Reference(
+ "react-doctor/DeadCodeResultCacheEnabled",
+ {
+ defaultValue: () => {
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
+ const noDeadCodeCache = process.env["REACT_DOCTOR_NO_DEAD_CODE_CACHE"]?.toLowerCase() ?? "";
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
+ if (CACHE_DISABLED_VALUES.has(noDeadCodeCache)) return false;
+ return true;
+ },
+ },
+) {}
diff --git a/packages/core/src/run-inspect.ts b/packages/core/src/run-inspect.ts
index 3bb66f0659..ce41aa4dbc 100644
--- a/packages/core/src/run-inspect.ts
+++ b/packages/core/src/run-inspect.ts
@@ -238,6 +238,37 @@ export interface InspectOutput {
*/
readonly lintCacheHitFileCount: number | null;
readonly lintCacheTotalFileCount: number | null;
+ /**
+ * Sidecar lint cache outcome for the lint pass: cache-hit files whose
+ * cross-file diagnostics replayed from the sidecar store, and the hits
+ * considered. Both `null` when the sidecar cache was disabled or bypassed
+ * (per-file cache off, `REACT_DOCTOR_NO_SIDECAR_CACHE`, no bounded
+ * cross-file rule enabled). Fed to the Sentry wide event as
+ * `lint.sidecarReplayRatio`.
+ */
+ readonly lintSidecarReplayedFileCount: number | null;
+ readonly lintSidecarTotalFileCount: number | null;
+ /**
+ * Dead-code result cache outcome for this scan's dead-code pass: `true`
+ * when the cached result was replayed (the analysis worker never spawned),
+ * `false` on a miss (fresh analysis). `null` when the pass never consulted
+ * the cache — dead-code skipped/disabled, the cache off
+ * (`REACT_DOCTOR_NO_CACHE` / `REACT_DOCTOR_NO_DEAD_CODE_CACHE`), or the
+ * pass discarded by a lint failure. Fed to the Sentry wide event as
+ * `deadCode.cacheHit`.
+ */
+ readonly deadCodeCacheHit: boolean | null;
+ /**
+ * deslop's incremental summary-cache outcome for this scan's dead-code
+ * ANALYSIS: collected files served from cached parse summaries vs freshly
+ * parsed. Both `null` whenever no analysis consulted the incremental store —
+ * a whole-result cache hit (no analysis ran), the cache off, dead-code
+ * skipped/disabled, or the pass discarded by a lint failure. Fed to the
+ * Sentry wide event as `deadCode.summaryCacheHits` /
+ * `deadCode.summaryCacheMisses`.
+ */
+ readonly deadCodeSummaryCacheHits: number | null;
+ readonly deadCodeSummaryCacheMisses: number | null;
/**
* Per-rule tallies of diagnostics the pipeline dropped because the user
* explicitly silenced the rule (config off switches, per-path overrides,
@@ -673,6 +704,13 @@ export const runInspect = (
rootDirectory: scanDirectory,
parseConcurrency: deadCodeParseConcurrency,
workerTimeoutMs: deadCodeTimeout.workerTimeoutMs,
+ onCacheOutcome: (didHitCache) => {
+ deadCodeCacheHit = didHitCache;
+ },
+ onSummaryCacheStats: (stats) => {
+ deadCodeSummaryCacheHits = stats.hits;
+ deadCodeSummaryCacheMisses = stats.misses;
+ },
})
.pipe(
Stream.catchTag("ReactDoctorError", (error: ReactDoctorError) =>
@@ -736,6 +774,11 @@ export const runInspect = (
// or bypassed so the wide event can tell "no cache" from "0% hit".
let lintCacheHitFileCount: number | null = null;
let lintCacheTotalFileCount: number | null = null;
+ let lintSidecarReplayedFileCount: number | null = null;
+ let lintSidecarTotalFileCount: number | null = null;
+ let deadCodeCacheHit: boolean | null = null;
+ let deadCodeSummaryCacheHits: number | null = null;
+ let deadCodeSummaryCacheMisses: number | null = null;
const baseLintStream = linterService
.run({
@@ -761,6 +804,10 @@ export const runInspect = (
lintCacheHitFileCount = cacheHitFileCount;
lintCacheTotalFileCount = totalConsideredFileCount;
},
+ onSidecarStats: (sidecarReplayedFileCount, sidecarConsideredFileCount) => {
+ lintSidecarReplayedFileCount = sidecarReplayedFileCount;
+ lintSidecarTotalFileCount = sidecarConsideredFileCount;
+ },
deadlineEpochMs: input.deadlineEpochMs,
})
.pipe(
@@ -985,6 +1032,13 @@ export const runInspect = (
securityScanFailed,
lintCacheHitFileCount,
lintCacheTotalFileCount,
+ lintSidecarReplayedFileCount,
+ lintSidecarTotalFileCount,
+ // Lint failure discards the dead-code pass entirely (see
+ // `deadCodeFailureState` above), so its cache outcomes must not leak.
+ deadCodeCacheHit: lintFailureState.didFail ? null : deadCodeCacheHit,
+ deadCodeSummaryCacheHits: lintFailureState.didFail ? null : deadCodeSummaryCacheHits,
+ deadCodeSummaryCacheMisses: lintFailureState.didFail ? null : deadCodeSummaryCacheMisses,
suppressedRuleCounts: transform.summarizeSuppressions(),
};
}).pipe(
diff --git a/packages/core/src/run-oxlint.ts b/packages/core/src/run-oxlint.ts
index 35805e04c8..0ed9b86b81 100644
--- a/packages/core/src/run-oxlint.ts
+++ b/packages/core/src/run-oxlint.ts
@@ -1,9 +1,14 @@
import * as fs from "node:fs";
import os from "node:os";
import * as path from "node:path";
-import { CROSS_FILE_RULE_IDS } from "oxlint-plugin-react-doctor";
+import {
+ CROSS_FILE_DEPENDENCY_COLLECTORS,
+ CROSS_FILE_RULE_IDS,
+ collectCrossFileDependencyProbes,
+} from "oxlint-plugin-react-doctor";
import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "./types/index.js";
import { batchIncludePaths } from "./batch-include-paths.js";
+import { COOPERATIVE_YIELD_BUDGET_MS } from "./constants.js";
import { buildRuleSeverityControls } from "./build-rule-severity-controls.js";
import { canOxlintExtendConfig } from "./can-oxlint-extend-config.js";
import { collectIgnorePatterns } from "./collect-ignore-patterns.js";
@@ -13,6 +18,13 @@ import { neutralizeDisableDirectives } from "./neutralize-disable-directives.js"
import { computeRulesetHash } from "./runners/oxlint/compute-ruleset-hash.js";
import { createOxlintConfig } from "./runners/oxlint/config.js";
import { createFileLintCache } from "./runners/oxlint/file-lint-cache.js";
+import { createSidecarProbeAnswerResolver } from "./runners/oxlint/resolve-sidecar-probe-answer.js";
+import type { SidecarProbeAnswerResolver } from "./runners/oxlint/resolve-sidecar-probe-answer.js";
+import { createSidecarLintCache } from "./runners/oxlint/sidecar-lint-cache.js";
+import type {
+ SidecarDependencyProbe,
+ SidecarLintCache,
+} from "./runners/oxlint/sidecar-lint-cache.js";
import { resolveUserPlugins } from "./runners/oxlint/plugin-resolution.js";
import { resolveOxlintToolchainVersions } from "./runners/oxlint/resolve-toolchain-versions.js";
import {
@@ -24,9 +36,10 @@ import { spawnLintBatches } from "./runners/oxlint/spawn-batches.js";
import { validateRuleRegistration } from "./runners/oxlint/validate-rule-registration.js";
import { dedupeDiagnostics } from "./utils/dedupe-diagnostics.js";
import { hashFileContents } from "./utils/hash-file-contents.js";
-import { listSourceFiles, listSourceFilesWithSize } from "./utils/list-source-files.js";
+import { listSourceFilesWithSize } from "./utils/list-source-files.js";
+import { planLintBatches } from "./utils/plan-lint-batches.js";
import { resolveReactDoctorCacheDir } from "./utils/resolve-react-doctor-cache-dir.js";
-import { sortSourceFilesByCost } from "./utils/sort-source-files-by-cost.js";
+import { yieldToEventLoop } from "./utils/yield-to-event-loop.js";
interface RunOxlintOptions {
rootDirectory: string;
@@ -77,12 +90,29 @@ interface RunOxlintOptions {
* misses' full pass, and in a sidecar pass over the cache hits).
*/
perFileLintCacheEnabled?: boolean;
+ /**
+ * Enables the sidecar lint cache, resolved from the
+ * `SidecarLintCacheEnabled` Reference. When on (and the per-file cache is
+ * active), each cache-hit file's cross-file diagnostics replay from the
+ * sidecar store as long as the file's recorded dependency probes still
+ * match the tree; only mismatching files re-lint. Off → every cache hit
+ * runs the always-fresh sidecar pass (the pre-cache behavior).
+ */
+ sidecarLintCacheEnabled?: boolean;
/**
* Called once after the cache split with `(cacheHitFileCount,
* totalConsideredFileCount)`. Surfaced to the Sentry wide event as
* `lintCacheHitRatio`. Not invoked when the cache is disabled or bypassed.
*/
onCacheStats?: (cacheHitFileCount: number, totalConsideredFileCount: number) => void;
+ /**
+ * Called once with `(sidecarReplayedFileCount, sidecarConsideredFileCount)`
+ * — how many cache-hit files replayed their cross-file diagnostics from
+ * the sidecar store vs. the hits considered. Surfaced to the Sentry wide
+ * event as `lint.sidecarReplayRatio`. Not invoked when the sidecar cache
+ * is disabled or bypassed.
+ */
+ onSidecarStats?: (sidecarReplayedFileCount: number, sidecarConsideredFileCount: number) => void;
/** Per-batch wall-clock budget, resolved from the `OxlintSpawnTimeoutMs` Reference. */
spawnTimeoutMs?: number;
/** Per-batch stdout+stderr byte cap, resolved from the `OxlintOutputMaxBytes` Reference. */
@@ -105,9 +135,10 @@ interface RunOxlintOptions {
/** See `SpawnLintBatchesInput.deadlineEpochMs`. */
deadlineEpochMs?: number;
/**
- * Full-scan batch ordering, resolved from the `LintBatchOrdering`
- * Reference. `"arrival"` (the default) keeps discovery order; `"cost"`
- * opts into LPT (largest files first). Only affects the full-scan branch
+ * Full-scan batch planning, resolved from the `LintBatchOrdering`
+ * Reference. `"cost"` (the default) plans size-balanced LPT batches via
+ * `planLintBatches`; `"arrival"` is the rollback hatch to the plain greedy
+ * 100-file chunking in discovery order. Only affects the full-scan branch
* (`includePaths` undefined) — diff / staged scans pass explicit paths and
* are untouched.
*/
@@ -135,6 +166,139 @@ const writeOxlintConfig = (
}
};
+/**
+ * Attributes diagnostics back to the file that produced them by the
+ * normalized path oxlint echoes. Returns `null` when ANY diagnostic can't be
+ * attributed — the path forms don't align, so the caller must skip its cache
+ * store rather than risk caching a wrong empty result for a file that
+ * actually had diagnostics.
+ */
+const attributeDiagnosticsToFiles = (
+ diagnostics: ReadonlyArray,
+ files: ReadonlyArray,
+): Map | null => {
+ const fileByNormalizedPath = new Map();
+ for (const file of files) {
+ fileByNormalizedPath.set(file.replaceAll("\\", "/"), file);
+ }
+ const diagnosticsByFile = new Map();
+ for (const diagnostic of diagnostics) {
+ const file = fileByNormalizedPath.get(diagnostic.filePath);
+ if (file === undefined) return null;
+ const fileDiagnostics = diagnosticsByFile.get(file) ?? [];
+ fileDiagnostics.push(diagnostic);
+ diagnosticsByFile.set(file, fileDiagnostics);
+ }
+ return diagnosticsByFile;
+};
+
+interface SidecarStorablePass {
+ readonly files: ReadonlyArray;
+ /** Cross-file BOUNDED-rule diagnostics this pass produced for `files`. */
+ readonly diagnostics: ReadonlyArray;
+ /** False when the pass had a partial failure or a config fallback — its
+ * output may be incomplete, so nothing from it is stored. */
+ readonly isTrusted: boolean;
+ /** Each file's answered probe set (from `collectSidecarProbesForFiles`). */
+ readonly probesByFile: ReadonlyMap | null>;
+}
+
+/**
+ * Collects each file's cross-file dependency probes (via the plugin's
+ * collectors) with their current answers. Fail-open per file: an unreadable /
+ * unparseable / collector-failing file maps to `null` (no entry stored — it
+ * re-lints next scan). The filesystem is frozen for the scan, so this can run
+ * CONCURRENTLY with an oxlint child-process await — the caller starts it
+ * unawaited before a lint pass and awaits it at store time, hiding the
+ * in-process parse + resolution cost under the subprocess wait. The loop
+ * yields on the cooperative time budget so the child's stdout handling (and
+ * sibling fibers) keep flowing; each per-file collection is synchronous, so
+ * the module-level probe recorder never sees interleaved use. Never rejects.
+ */
+const collectSidecarProbesForFiles = async (input: {
+ files: ReadonlyArray;
+ rootDirectory: string;
+ boundedSidecarRuleIds: ReadonlyArray;
+ probeAnswers: SidecarProbeAnswerResolver;
+}): Promise