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 | null>> => { + const buildProbes = (file: string): SidecarDependencyProbe[] | null => { + const absoluteFilePath = path.resolve(input.rootDirectory, file); + let trace: ReturnType; + try { + trace = collectCrossFileDependencyProbes({ + absoluteFilePath, + sourceText: fs.readFileSync(absoluteFilePath, "utf8"), + ruleIds: input.boundedSidecarRuleIds, + }); + } catch { + return null; + } + if (trace === null) return null; + const probes: SidecarDependencyProbe[] = []; + for (const contentPath of trace.contentPaths) { + const relativePath = input.probeAnswers.toRelativePath(contentPath); + probes.push({ + kind: "content", + path: relativePath, + answer: input.probeAnswers.answerFor("content", relativePath), + }); + } + // A path can carry BOTH probe kinds (e.g. a package.json probed for + // existence during the ancestor walk and then read): keep both — the + // content answer can't distinguish a directory from a missing file, but + // the resolvers can (`"dir"` vs `"none"`). + for (const existencePath of trace.existencePaths) { + const relativePath = input.probeAnswers.toRelativePath(existencePath); + probes.push({ + kind: "exists", + path: relativePath, + answer: input.probeAnswers.answerFor("exists", relativePath), + }); + } + return probes; + }; + + const probesByFile = new Map | null>(); + let collectSliceStartedAt = performance.now(); + for (const file of input.files) { + probesByFile.set(file, buildProbes(file)); + if (performance.now() - collectSliceStartedAt >= COOPERATIVE_YIELD_BUDGET_MS) { + await yieldToEventLoop(); + collectSliceStartedAt = performance.now(); + } + } + return probesByFile; +}; + +/** + * Stores each freshly-linted file's probe set + cross-file diagnostics so + * the next scan can replay instead of re-linting. + */ +const storeSidecarEntries = (input: { + sidecarCache: SidecarLintCache; + cacheKeyByFile: ReadonlyMap; + storablePasses: ReadonlyArray; +}): void => { + let didStoreAnyEntry = false; + for (const pass of input.storablePasses) { + if (!pass.isTrusted) continue; + const diagnosticsByFile = attributeDiagnosticsToFiles(pass.diagnostics, pass.files); + if (diagnosticsByFile === null) continue; + for (const file of pass.files) { + const cacheKey = input.cacheKeyByFile.get(file); + if (cacheKey === undefined) continue; + const probes = pass.probesByFile.get(file); + if (probes === undefined || probes === null) continue; + input.sidecarCache.store(cacheKey, { + probes, + diagnostics: diagnosticsByFile.get(file) ?? [], + }); + didStoreAnyEntry = true; + } + } + if (didStoreAnyEntry) input.sidecarCache.persist(); +}; + const REACT_HOOKS_JS_DROP_PREFIX = "React Compiler rules (react-hooks-js/*) skipped — eslint-plugin-react-hooks failed to load in this environment"; @@ -203,10 +367,12 @@ export const runOxlint = async (options: RunOxlintOptions): Promise; }) => createOxlintConfig({ pluginPath, @@ -259,6 +426,7 @@ export const runOxlint = async (options: RunOxlintOptions): Promise - lintBatchOrdering === "cost" - ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) - : listSourceFiles(rootDirectory); - - const candidateFiles = includePaths !== undefined ? includePaths : discoverScanFiles(); + // Hand the event loop back once before the synchronous discovery walk + + // cache-hash burst below. The orchestrator forked the security-scan and + // supply-chain fibers just before lint, but a forked fiber only runs when + // the current one suspends — this is their first chance to start (fire the + // Socket.dev requests, begin the scan walk) instead of stalling until the + // first oxlint spawn. + await yieldToEventLoop(); + // Only a full scan pays the walk — diff / staged scans pass + // explicit paths. + const sizedScanFiles = + includePaths === undefined ? listSourceFilesWithSize(rootDirectory) : null; + const candidateFiles = + includePaths !== undefined ? includePaths : (sizedScanFiles ?? []).map((entry) => entry.path); + + // `"cost"` (the default) plans size-balanced LPT batches — the size is + // the discovery walk's existing stat, captured rather than re-paid. + // `"arrival"` (the `REACT_DOCTOR_LINT_BATCH_ORDERING` rollback hatch) and + // every explicit-`includePaths` scan keep the plain greedy chunking. + const sizeByFile = + sizedScanFiles !== null && lintBatchOrdering === "cost" + ? new Map(sizedScanFiles.map((entry) => [entry.path, entry.sizeBytes])) + : null; + const buildFileBatches = (passBaseArgs: string[], passFiles: string[]): string[][] => + sizeByFile !== null + ? planLintBatches({ baseArgs: passBaseArgs, files: passFiles, sizeByFile }) + : batchIncludePaths(passBaseArgs, passFiles); // Runs one oxlintrc over a file list, retrying once with the optional // react-hooks-js plugin stripped if it fails to import (issue #833). @@ -375,7 +556,7 @@ export const runOxlint = async (options: RunOxlintOptions): Promise spawnLintBatches({ baseArgs: passBaseArgs, @@ -434,33 +615,113 @@ export const runOxlint = async (options: RunOxlintOptions): Promise(); + // Canonical relative path → content hash, reused by the sidecar cache's + // probe-answer resolver so verifying a source-file dependency is a map + // lookup instead of a re-read. + const contentHashByRelativePath = new Map(); const missFiles: string[] = []; const replayedDiagnostics: Diagnostic[] = []; + let hashSliceStartedAt = performance.now(); for (const candidateFile of candidateFiles) { - const contentHash = hashFileContents(path.resolve(rootDirectory, candidateFile)); + const absoluteCandidatePath = path.resolve(rootDirectory, candidateFile); + const contentHash = hashFileContents(absoluteCandidatePath); if (contentHash === null) { missFiles.push(candidateFile); continue; } + contentHashByRelativePath.set( + path.relative(rootDirectory, absoluteCandidatePath).replaceAll("\\", "/"), + contentHash, + ); const cacheKey = `${candidateFile.replaceAll("\\", "/")}\u0000${contentHash}`; cacheKeyByFile.set(candidateFile, cacheKey); const cachedDiagnostics = cache.lookup(cacheKey); if (cachedDiagnostics === null) missFiles.push(candidateFile); else replayedDiagnostics.push(...cachedDiagnostics); + if (performance.now() - hashSliceStartedAt >= COOPERATIVE_YIELD_BUDGET_MS) { + await yieldToEventLoop(); + hashSliceStartedAt = performance.now(); + } } const cacheHitFileCount = candidateFiles.length - missFiles.length; + // The cross-file sidecar over the cache hits. The enabled sidecar rules + // split by whether the plugin ships a dependency collector for them: + // + // - BOUNDED rules replay from the sidecar cache: a hit file whose + // stored dependency probes all still match the tree replays its + // stored cross-file diagnostics; only mismatching (or unknown) + // files re-lint, in a sidecar pass narrowed to the bounded rules. + // - UNBOUNDED rules (no collector — see the plugin's + // `UNBOUNDED_CROSS_FILE_RULE_IDS`; empty today) can't be + // fingerprinted, so they run always-fresh over EVERY hit file in a + // second narrowed pass. The degradation is per-rule, not global. + // + // With the sidecar cache off (or no bounded rule enabled) the split + // collapses to the pre-cache behavior: one always-fresh sidecar pass, + // all cross-file rules, every hit file. + const enabledSidecarRuleIds = Object.keys( + buildConfig({ extendsPaths: [], ruleSelection: "sidecar" }).rules, + ) + .map((ruleKey) => ruleKey.slice(ruleKey.lastIndexOf("/") + 1)) + .filter((ruleId) => CROSS_FILE_RULE_IDS.has(ruleId)); + const boundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => + CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId), + ); + const boundedSidecarRuleIdSet: ReadonlySet = new Set(boundedSidecarRuleIds); + const unboundedSidecarRuleIds = enabledSidecarRuleIds.filter( + (ruleId) => !CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId), + ); + const useSidecarCache = sidecarLintCacheEnabled && boundedSidecarRuleIds.length > 0; + + const sidecarCache = useSidecarCache + ? createSidecarLintCache( + resolveReactDoctorCacheDir(rootDirectory), + computeRulesetHash({ + config: buildConfig({ + extendsPaths: [], + ruleSelection: "sidecar", + sidecarRuleIdFilter: boundedSidecarRuleIdSet, + }), + toolchainVersions: resolveOxlintToolchainVersions(nodeBinaryPath), + ignorePatterns: combinedPatterns, + tsconfigContent, + }), + ) + : null; + const probeAnswers = createSidecarProbeAnswerResolver({ + rootDirectory, + contentHashByRelativePath, + }); + // Started UNAWAITED so the in-process probe collection interleaves with + // the full pass's child-process await below (the filesystem is frozen + // for the scan, so collecting before/during/after the pass is + // equivalent). Never rejects — a failing file maps to `null`. + const missProbesTask = + sidecarCache === null + ? null + : collectSidecarProbesForFiles({ + files: missFiles, + rootDirectory, + boundedSidecarRuleIds, + probeAnswers, + }); + // Miss files run the FULL config in one pass — cacheable and cross-file - // rules together — and the always-fresh cross-file sidecar covers only - // the cache HITS. The previous shape (cacheable pass over misses, then - // sidecar over every file) parsed and spawned over each miss twice, - // doubling the lint cost of a cold-cache scan — every CI run. The fresh - // output is partitioned by rule id below so only cacheable-rule - // diagnostics are stored; cache contents and reported diagnostics are - // unchanged, and a stale cross-file verdict still can't be served (hits - // get the sidecar, misses ran the cross-file rules in the full pass). + // rules together — and the cross-file sidecar covers only the cache + // HITS. The previous shape (cacheable pass over misses, then sidecar + // over every file) parsed and spawned over each miss twice, doubling + // the lint cost of a cold-cache scan — every CI run. The fresh output + // is partitioned by rule id below so only cacheable-rule diagnostics + // are stored; cache contents and reported diagnostics are unchanged, + // and a stale cross-file verdict still can't be served (hits replay + // only behind matching dependency probes, misses ran the cross-file + // rules in the full pass). const fullResult = await runConfigOverFiles( (overrides) => buildConfig({ @@ -474,44 +735,103 @@ export const runOxlint = async (options: RunOxlintOptions): Promise !missFileSet.has(candidateFile)); - const sidecarResult = await runConfigOverFiles( - () => buildConfig({ extendsPaths: [], ruleSelection: "sidecar" }), + + // Split the hit files into replays and sidecar re-lints by re-answering + // each stored probe against the current tree. Source-file answers are + // map lookups over the hashes computed above; the loop still stats + // package.json / tsconfig probes, so it shares the cooperative budget. + const sidecarReplayedDiagnostics: Diagnostic[] = []; + const sidecarLintFiles: string[] = []; + if (sidecarCache === null) { + sidecarLintFiles.push(...hitFiles); + } else { + let verifySliceStartedAt = performance.now(); + for (const hitFile of hitFiles) { + const cacheKey = cacheKeyByFile.get(hitFile); + const entry = cacheKey === undefined ? null : sidecarCache.lookup(cacheKey); + const isReplayable = + entry !== null && + entry.probes.every( + (probe) => probeAnswers.answerFor(probe.kind, probe.path) === probe.answer, + ); + if (isReplayable) sidecarReplayedDiagnostics.push(...entry.diagnostics); + else sidecarLintFiles.push(hitFile); + if (performance.now() - verifySliceStartedAt >= COOPERATIVE_YIELD_BUDGET_MS) { + await yieldToEventLoop(); + verifySliceStartedAt = performance.now(); + } + } + } + + // Same overlap as `missProbesTask`: collect the re-lint set's probes + // while the bounded sidecar pass awaits its child processes. + const sidecarProbesTask = + sidecarCache === null + ? null + : collectSidecarProbesForFiles({ + files: sidecarLintFiles, + rootDirectory, + boundedSidecarRuleIds, + probeAnswers, + }); + // Replayed files are completed work: without them in the numerator the + // spinner stalls at missFiles + sidecarLintFiles of candidateFiles, and + // an all-replayed pass (empty sidecarLintFiles) would never tick at all. + const sidecarReplayedFileCount = hitFiles.length - sidecarLintFiles.length; + if (sidecarReplayedFileCount > 0) { + options.onFileProgress?.( + missFiles.length + sidecarReplayedFileCount, + candidateFiles.length, + ); + } + const boundedSidecarResult = await runConfigOverFiles( + () => + buildConfig({ + extendsPaths: [], + ruleSelection: "sidecar", + ...(useSidecarCache ? { sidecarRuleIdFilter: boundedSidecarRuleIdSet } : {}), + }), "oxlintrc.sidecar.json", - hitFiles, + sidecarLintFiles, options.onFileProgress && ((scannedFileCount) => - options.onFileProgress?.(missFiles.length + scannedFileCount, candidateFiles.length)), + options.onFileProgress?.( + missFiles.length + sidecarReplayedFileCount + scannedFileCount, + candidateFiles.length, + )), ); + const unboundedSidecarResult = + useSidecarCache && unboundedSidecarRuleIds.length > 0 + ? await runConfigOverFiles( + () => + buildConfig({ + extendsPaths: [], + ruleSelection: "sidecar", + sidecarRuleIdFilter: new Set(unboundedSidecarRuleIds), + }), + "oxlintrc.sidecar-unbounded.json", + hitFiles, + undefined, + ) + : { diagnostics: [], didDropReactHooksJsPlugin: false, hadPartialFailure: false }; + const freshCacheableDiagnostics: Diagnostic[] = []; for (const diagnostic of fullResult.diagnostics) { if (!CROSS_FILE_RULE_IDS.has(diagnostic.rule)) freshCacheableDiagnostics.push(diagnostic); } - // Reported only after both passes succeed — if lint throws, the run fails - // and no cache-hit ratio is attached to a failed scan's telemetry. + // Reported only after every pass succeeds — if lint throws, the run + // fails and no cache ratio is attached to a failed scan's telemetry. onCacheStats?.(cacheHitFileCount, candidateFiles.length); - - // Attribute fresh cacheable diagnostics back to their miss file by the - // normalized path oxlint echoes. If ANY diagnostic can't be attributed, - // the path forms don't align — skip the store rather than risk caching a - // wrong empty result for a file that actually had diagnostics. - const missFileByNormalizedPath = new Map(); - for (const missFile of missFiles) { - missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile); - } - const freshDiagnosticsByFile = new Map(); - let isAttributionSound = true; - for (const diagnostic of freshCacheableDiagnostics) { - const missFile = missFileByNormalizedPath.get(diagnostic.filePath); - if (missFile === undefined) { - isAttributionSound = false; - break; - } - const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? []; - fileDiagnostics.push(diagnostic); - freshDiagnosticsByFile.set(missFile, fileDiagnostics); + if (sidecarCache !== null) { + onSidecarStats?.(hitFiles.length - sidecarLintFiles.length, hitFiles.length); } + const freshDiagnosticsByFile = attributeDiagnosticsToFiles( + freshCacheableDiagnostics, + missFiles, + ); + // Skip the store when this run can't be trusted to represent a clean, // complete lint of the miss files: // - a react-hooks-js fallback rewrote the cacheable config, so its @@ -522,7 +842,7 @@ export const runOxlint = async (options: RunOxlintOptions): Promise + boundedSidecarRuleIdSet.has(diagnostic.rule), + ), + isTrusted: !fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure, + probesByFile: await missProbesTask, + }, + ], + }); + } + // Dedupe the merged result to match the non-cached path (which dedupes // at the end of `spawnLintBatches`): a duplicate path in `includePaths` // replays the same cached set twice, which dedupe collapses — so warm @@ -540,12 +883,14 @@ export const runOxlint = async (options: RunOxlintOptions): Promise spawnLintBatches({ diff --git a/packages/core/src/runners/oxlint/capabilities.ts b/packages/core/src/runners/oxlint/capabilities.ts index 99c4d64929..e1a8837b76 100644 --- a/packages/core/src/runners/oxlint/capabilities.ts +++ b/packages/core/src/runners/oxlint/capabilities.ts @@ -5,6 +5,7 @@ import { LATEST_KNOWN_PREACT_MAJOR, LATEST_KNOWN_REACT_MAJOR, } from "../../constants.js"; +import { hasReactRuntime } from "../../utils/has-react-runtime.js"; import { isReactAtLeast, isTailwindAtLeast, @@ -25,7 +26,7 @@ export const buildCapabilities = (project: ProjectInfo): ReadonlySet => // React-family rules are equally applicable there. Framework-agnostic // rules (security, architecture, bundle-size, js-performance, zod, …) // never require this and keep running on non-React codebases. - if (project.reactVersion !== null || project.preactVersion !== null) { + if (hasReactRuntime(project)) { capabilities.add("react"); } if ( diff --git a/packages/core/src/runners/oxlint/config.ts b/packages/core/src/runners/oxlint/config.ts index e4402b0bd5..bc1e7cd880 100644 --- a/packages/core/src/runners/oxlint/config.ts +++ b/packages/core/src/runners/oxlint/config.ts @@ -46,9 +46,16 @@ export interface OxlintConfigOptions { * linted file's own content, so their output is content-addressable. * - `"sidecar"` — ONLY the cross-file react-doctor rules; the React * Compiler frontend and user plugins are dropped (none are cross-file). - * This config always runs fresh on every file and is never cached. */ ruleSelection?: "cacheable" | "sidecar"; + /** + * Narrows a `"sidecar"` selection to a subset of the cross-file rules. + * The sidecar cache path uses it to split fingerprint-BOUNDED rules + * (replayable via the dependency-probe store) from UNBOUNDED ones (no + * dependency collector — they re-lint every file, every scan). Ignored + * for other selections. + */ + sidecarRuleIdFilter?: ReadonlySet; } const resolveSettingsRootDirectory = (rootDirectory: string): string => { @@ -117,6 +124,7 @@ export const createOxlintConfig = ({ userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection, + sidecarRuleIdFilter, }: OxlintConfigOptions) => { // The sidecar carries only cross-file react-doctor rules — the React // Compiler frontend isn't cross-file, so it never belongs there. @@ -149,6 +157,13 @@ export const createOxlintConfig = ({ // only them. The default (undefined) keeps every rule. if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue; if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue; + if ( + ruleSelection === "sidecar" && + sidecarRuleIdFilter !== undefined && + !sidecarRuleIdFilter.has(registryEntry.id) + ) { + continue; + } // Scan rules run via core's check-security-scan environment // check, not oxlint — registering them would only add dead visitors. if (rule.scan !== undefined) continue; diff --git a/packages/core/src/runners/oxlint/parse-output.ts b/packages/core/src/runners/oxlint/parse-output.ts index c6b21a3aa2..a3db7b8373 100644 --- a/packages/core/src/runners/oxlint/parse-output.ts +++ b/packages/core/src/runners/oxlint/parse-output.ts @@ -7,7 +7,7 @@ import type { OxlintOutput, ProjectInfo, } from "../../types/index.js"; -import { ERROR_PREVIEW_LENGTH_CHARS } from "../../constants.js"; +import { ERROR_PREVIEW_LENGTH_CHARS, OCCURRENCE_MATCHED_CATEGORIES } from "../../constants.js"; import { isLintableSourceFile } from "../../utils/is-lintable-source-file.js"; import { isMinifiedSource } from "../../utils/is-minified-source.js"; import { OxlintOutputUnparseable, ReactDoctorError } from "../../errors.js"; @@ -201,6 +201,17 @@ const parseRuleCode = (code: string): { plugin: string; rule: string } => { const resolveDiagnosticCategory = (plugin: string, rule: string): string => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs"; +// Whether the finding's identity is the flagged element rather than the +// flagged line's text, so `computeDiagnosticDelta` matches it by +// `(file, rule)` occurrence count. Resolved here — the one place that +// already consults rule metadata — so the delta stays a pure function of +// its `Diagnostic` inputs. Every Accessibility-category finding qualifies +// (element-level by nature, including adopted third-party a11y rules); +// rules in other categories opt in via their `matchByOccurrence` flag. +const resolveMatchByOccurrence = (rule: string, category: string): boolean => + OCCURRENCE_MATCHED_CATEGORIES.has(category) || + Boolean(reactDoctorPlugin.rules[rule]?.matchByOccurrence); + /** * Maps oxlint's non-primary labels (`labels[1..]`) into related source * locations. Editors surface these as a diagnostic's @@ -328,6 +339,7 @@ export const parseOxlintOutput = ( // for everything else; offset / length are additive. const primarySpan = primaryLabel?.span; const relatedLocations = buildRelatedLocations(diagnostic.labels, normalizedFilePath); + const category = resolveDiagnosticCategory(plugin, rule); return { filePath: normalizedFilePath, plugin, @@ -340,7 +352,8 @@ export const parseOxlintOutput = ( line: primarySpan?.line ?? 0, column: primarySpan?.column ?? 0, ...(primarySpan ? { offset: primarySpan.offset, length: primarySpan.length } : {}), - category: resolveDiagnosticCategory(plugin, rule), + category, + ...(resolveMatchByOccurrence(rule, category) ? { matchByOccurrence: true } : {}), ...(relatedLocations.length > 0 ? { relatedLocations } : {}), }; }); diff --git a/packages/core/src/runners/oxlint/resolve-sidecar-probe-answer.ts b/packages/core/src/runners/oxlint/resolve-sidecar-probe-answer.ts new file mode 100644 index 0000000000..19cdd6b53f --- /dev/null +++ b/packages/core/src/runners/oxlint/resolve-sidecar-probe-answer.ts @@ -0,0 +1,74 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { hashFileContents } from "../../utils/hash-file-contents.js"; +import type { SidecarDependencyProbe } from "./sidecar-lint-cache.js"; + +const ABSENT_CONTENT_ANSWER = "absent"; + +export interface SidecarProbeAnswerResolver { + /** Current answer for a probe path: content hash / existence class. */ + readonly answerFor: (kind: SidecarDependencyProbe["kind"], relativePath: string) => string; + /** Root-relative, `/`-separated form every stored probe path uses. */ + readonly toRelativePath: (absolutePath: string) => string; +} + +/** + * Answers dependency probes for the sidecar lint cache — used both when + * STORING an entry (turn the collector's probe paths into answers) and when + * VERIFYING one (re-answer each stored probe against the current tree). + * Store and verify must agree exactly, so both go through this one resolver. + * + * - `content` — SHA-1 of the file's bytes, `"absent"` when unreadable + * (missing, a directory, permission failure). Source files reuse the + * hashes the cache-partition loop already computed + * (`contentHashByRelativePath`); everything else (package.json, + * tsconfig, ignored files) is hashed on demand. + * - `exists` — the existence class a module-resolution candidate probe + * sees: `"file"`, `"dir"`, or `"none"` (missing or an exotic entry — + * `statSync` follows symlinks, matching the resolvers). + * + * Answers are memoized per scan: the filesystem is frozen for the run, and + * hot dependency paths (a barrel imported by hundreds of files, the shared + * tsconfig chain) repeat across many files' probe sets. + */ +export const createSidecarProbeAnswerResolver = (input: { + rootDirectory: string; + contentHashByRelativePath: ReadonlyMap; +}): SidecarProbeAnswerResolver => { + const contentAnswerByPath = new Map(); + const existsAnswerByPath = new Map(); + + const toRelativePath = (absolutePath: string): string => + path.relative(input.rootDirectory, absolutePath).replaceAll("\\", "/"); + + const contentAnswer = (relativePath: string): string => { + const memoized = contentAnswerByPath.get(relativePath); + if (memoized !== undefined) return memoized; + const answer = + input.contentHashByRelativePath.get(relativePath) ?? + hashFileContents(path.resolve(input.rootDirectory, relativePath)) ?? + ABSENT_CONTENT_ANSWER; + contentAnswerByPath.set(relativePath, answer); + return answer; + }; + + const existsAnswer = (relativePath: string): string => { + const memoized = existsAnswerByPath.get(relativePath); + if (memoized !== undefined) return memoized; + let answer: string; + try { + const stat = fs.statSync(path.resolve(input.rootDirectory, relativePath)); + answer = stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "none"; + } catch { + answer = "none"; + } + existsAnswerByPath.set(relativePath, answer); + return answer; + }; + + return { + answerFor: (kind, relativePath) => + kind === "content" ? contentAnswer(relativePath) : existsAnswer(relativePath), + toRelativePath, + }; +}; diff --git a/packages/core/src/runners/oxlint/sidecar-lint-cache.ts b/packages/core/src/runners/oxlint/sidecar-lint-cache.ts new file mode 100644 index 0000000000..773be06415 Binary files /dev/null and b/packages/core/src/runners/oxlint/sidecar-lint-cache.ts differ diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index f4d867d537..1c49e81b0a 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -32,6 +32,7 @@ export class Diagnostic extends Schema.Class("Diagnostic")({ endLine: Schema.optional(Schema.Number), endColumn: Schema.optional(Schema.Number), category: Schema.String, + matchByOccurrence: Schema.optional(Schema.Boolean), fileContext: Schema.optional(Schema.Literals(["test", "story"])), suppressionHint: Schema.optional(Schema.String), relatedLocations: Schema.optional(Schema.Array(DiagnosticRelatedLocation)), @@ -120,6 +121,16 @@ export class JsonReportV1 extends Schema.Class("JsonReportV1")({ * successful `baseline` run (`schemaVersion: 2`) and on non-compare scopes. */ baselineDegraded: Schema.optional(Schema.Boolean), + /** + * Whether any scanned project resolved a React-compatible runtime (React + * or Preact). `false` means every React-runtime rule family was gated off, + * so an empty `diagnostics` array is vacuous — NOT the same as a clean + * React scan. Consumers gating on the report (CI, verifiers, hooks) should + * treat `reactDetected === false` as "wrong scan target", not "all clear". + * Absent when nothing was scanned (`projects` is empty), on error reports, + * and on reports from older CLI versions. + */ + reactDetected: Schema.optional(Schema.Boolean), diff: Schema.NullOr(JsonReportDiffInfo), projects: Schema.Array(JsonReportProjectEntry), diagnostics: Schema.Array(Diagnostic), @@ -147,6 +158,8 @@ export class JsonReportV2 extends Schema.Class("JsonReportV2")({ ok: Schema.Boolean, directory: Schema.String, mode: JsonReportMode, + /** See `JsonReportV1.reactDetected`. */ + reactDetected: Schema.optional(Schema.Boolean), diff: Schema.NullOr(JsonReportDiffInfo), baseline: JsonReportBaseline, projects: Schema.Array(JsonReportProjectEntry), diff --git a/packages/core/src/services/dead-code.ts b/packages/core/src/services/dead-code.ts index 1b7d71dc69..0ab7fe31b7 100644 --- a/packages/core/src/services/dead-code.ts +++ b/packages/core/src/services/dead-code.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream"; import type { Diagnostic } from "../types/index.js"; import { checkDeadCode } from "../check-dead-code.js"; import { DeadCodeAnalysisFailed, ReactDoctorError } from "../errors.js"; +import { DeadCodeResultCacheEnabled } from "../refs.js"; interface DeadCodeInput { readonly rootDirectory: string; @@ -20,6 +21,24 @@ interface DeadCodeInput { * `DEAD_CODE_WORKER_TIMEOUT_MS` floor. */ readonly workerTimeoutMs?: number; + /** + * Reports the dead-code result cache outcome (`true` = hit, `false` = + * miss). Not invoked when the cache is disabled + * (`DeadCodeResultCacheEnabled` off), so the orchestrator's telemetry + * distinguishes "no cache" from a miss. + */ + readonly onCacheOutcome?: (didHitCache: boolean) => void; + /** + * Reports deslop's incremental summary-cache outcome (cached vs freshly + * parsed file counts) when the analysis ran with the incremental store. + * Not invoked on a whole-result cache hit or when caching is off. + */ + readonly onSummaryCacheStats?: (stats: DeadCodeSummaryCacheStatsInput) => void; +} + +interface DeadCodeSummaryCacheStatsInput { + readonly hits: number; + readonly misses: number; } /** @@ -47,6 +66,7 @@ export class DeadCode extends Context.Service< // surfaces as a single named span in OTel traces (parent // of the per-call `Effect.tryPromise`). Effect.fn("DeadCode.run")(function* () { + const cacheEnabled = yield* DeadCodeResultCacheEnabled; return yield* Effect.tryPromise({ // The signal is wired to fiber interruption: when the // orchestrator interrupts this fiber (lint failed / scan @@ -58,6 +78,9 @@ export class DeadCode extends Context.Service< parseConcurrency: input.parseConcurrency, workerTimeoutMs: input.workerTimeoutMs, abortSignal: signal, + cacheEnabled, + onCacheOutcome: input.onCacheOutcome, + onSummaryCacheStats: input.onSummaryCacheStats, }), catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) }), diff --git a/packages/core/src/services/git.ts b/packages/core/src/services/git.ts index 2bda2fda18..68cb933cd0 100644 --- a/packages/core/src/services/git.ts +++ b/packages/core/src/services/git.ts @@ -769,7 +769,14 @@ export class Git extends Context.Service< showStagedContent: (directory, relativePath, options) => runCommand({ command: "git", - args: ["show", `:${relativePath}`], + // The `./` prefix is required for the same reason as `showRefContent` + // below: git reads a bare `:` index pathspec relative to the + // REPO ROOT, but `relativePath` is relative to `directory` (the + // scanned project, which may be a monorepo subproject). `:./` makes + // git resolve it against the cwd, so a subproject's staged content is + // read correctly instead of silently missing (the whole file set + // would otherwise be skipped and `--staged` scans nothing). + args: ["show", `:./${relativePath}`], directory, maxStdoutBytes: options?.maxBufferBytes, }).pipe(Effect.map((result) => (result.status === 0 ? result.stdout : null))), diff --git a/packages/core/src/services/linter.ts b/packages/core/src/services/linter.ts index 25247b5d9c..cc7c0b0247 100644 --- a/packages/core/src/services/linter.ts +++ b/packages/core/src/services/linter.ts @@ -11,6 +11,7 @@ import { OxlintOutputMaxBytes, OxlintSpawnTimeoutMs, PerFileLintCacheEnabled, + SidecarLintCacheEnabled, } from "../refs.js"; import { runOxlint } from "../run-oxlint.js"; @@ -46,6 +47,11 @@ export interface LintInput { readonly nodeBinaryPath?: string; readonly onFileProgress?: (scannedFileCount: number, totalFileCount: number) => void; readonly onCacheStats?: (cacheHitFileCount: number, totalConsideredFileCount: number) => void; + /** See `RunOxlintOptions.onSidecarStats`. */ + readonly onSidecarStats?: ( + sidecarReplayedFileCount: number, + sidecarConsideredFileCount: number, + ) => void; /** See `RunOxlintOptions.deadlineEpochMs`. */ readonly deadlineEpochMs?: number; } @@ -117,6 +123,7 @@ export class Linter extends Context.Service< const concurrency = yield* OxlintConcurrency; const lintBatchOrdering = yield* LintBatchOrdering; const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled; + const sidecarLintCacheEnabled = yield* SidecarLintCacheEnabled; const collectedFailures: string[] = []; const diagnostics = yield* Effect.tryPromise({ // `Effect.tryPromise` aborts this signal when the fiber is @@ -139,7 +146,9 @@ export class Linter extends Context.Service< }, onFileProgress: input.onFileProgress, perFileLintCacheEnabled, + sidecarLintCacheEnabled, onCacheStats: input.onCacheStats, + onSidecarStats: input.onSidecarStats, spawnTimeoutMs, outputMaxBytes, concurrency, diff --git a/packages/core/src/types/diagnose.ts b/packages/core/src/types/diagnose.ts index a2e4b3c685..98b9f9fd43 100644 --- a/packages/core/src/types/diagnose.ts +++ b/packages/core/src/types/diagnose.ts @@ -34,6 +34,17 @@ export interface DiagnoseResult { /** See `InspectResult.skippedCheckReasons`. */ skippedCheckReasons?: Record; project: ProjectInfo; + /** + * Whether the scanned project resolved a React-compatible runtime (React + * or Preact). `false` means every React-runtime rule family was gated + * off, so an empty `diagnostics` array is vacuous — NOT the same as a + * clean React scan. Consumers gating on the result should treat + * `reactDetected === false` as "wrong scan target", not "all clear". + * Mirrors `JsonReport.reactDetected`; same predicate as + * `hasReactRuntime(result.project)`. Always set by `diagnose()`; + * optional so hand-constructed results keep compiling. + */ + reactDetected?: boolean; elapsedMilliseconds: number; } @@ -89,5 +100,12 @@ export interface DiagnoseProjectsResult { projects: ProjectResult[]; diagnostics: Diagnostic[]; score: ScoreResult | null; + /** + * Whether any successfully scanned project resolved a React-compatible + * runtime (React or Preact). Absent when no project scanned successfully. + * See `DiagnoseResult.reactDetected` for gating guidance; per-project + * detail is on each `ProjectResultOk.reactDetected`. + */ + reactDetected?: boolean; elapsedMilliseconds: number; } diff --git a/packages/core/src/types/diagnostic.ts b/packages/core/src/types/diagnostic.ts index 3108ca0aa8..2e8b6d26bf 100644 --- a/packages/core/src/types/diagnostic.ts +++ b/packages/core/src/types/diagnostic.ts @@ -79,6 +79,17 @@ export interface Diagnostic { /** 1-indexed end column of the primary span, when derivable. */ endColumn?: number; category: string; + /** + * Set when the finding's identity is the flagged element itself (a missing + * attribute, a wrong element) rather than the flagged line's text — every + * Accessibility-category finding, plus rules that opt in via their + * `matchByOccurrence` metadata flag. `computeDiagnosticDelta` matches these + * by `(filePath, plugin/rule)` occurrence count, so reformatting the flagged + * line doesn't reclassify a pre-existing finding as new. Resolved at + * diagnostic creation, where rule metadata is available. Absent means + * line-text-sensitive matching (the default). + */ + matchByOccurrence?: boolean; /** * Set when the file never ships to users (`"test"` / `"story"`), so * renderers can label the site instead of implying production impact. diff --git a/packages/core/src/types/inspect.ts b/packages/core/src/types/inspect.ts index 1ce3f085e0..842e47afa0 100644 --- a/packages/core/src/types/inspect.ts +++ b/packages/core/src/types/inspect.ts @@ -48,6 +48,32 @@ export interface InspectResult { */ lintCacheHitFileCount?: number | null; lintCacheTotalFileCount?: number | null; + /** + * Sidecar lint cache outcome: cache-hit files whose cross-file diagnostics + * replayed from the sidecar store, and the hits considered. Both absent + * when the sidecar cache was off or bypassed. The CLI projects these onto + * the Sentry wide event as `lint.sidecarReplayRatio`. + */ + lintSidecarReplayedFileCount?: number | null; + lintSidecarTotalFileCount?: number | null; + /** + * Dead-code result cache outcome: `true` when the pass replayed a cached + * result (the analysis never ran), `false` on a fresh analysis. Absent when + * the pass never consulted the cache — dead-code skipped, the cache + * disabled, or a whole-repo cache replay where no analysis ran. The CLI + * projects it onto the Sentry wide event as `deadCode.cacheHit`. + */ + deadCodeCacheHit?: boolean | null; + /** + * deslop's incremental summary-cache outcome for the dead-code analysis: + * collected files served from cached parse summaries vs freshly parsed. + * Both absent whenever no analysis consulted the incremental store — a + * whole-result cache hit, the cache disabled, or dead-code skipped. The CLI + * projects them onto the Sentry wide event as `deadCode.summaryCacheHits` / + * `deadCode.summaryCacheMisses`. + */ + deadCodeSummaryCacheHits?: number | null; + deadCodeSummaryCacheMisses?: number | null; /** * Present only for a baseline run (`InspectOptions.baseline` set). The * `diagnostics` above are then the *introduced* findings only; this @@ -86,6 +112,13 @@ export interface InspectOptions { lint?: boolean; /** See `ReactDoctorConfig.deadCode`. Ignored in diff / staged mode. */ deadCode?: boolean; + /** + * Whether to run the Socket.dev supply-chain scan. Resolves against + * `ReactDoctorConfig.supplyChain.enabled` (this wins when set), defaulting + * to `true`. Kept as an option — not folded into the config — so it takes + * precedence over per-project config on every scan, like `lint`/`deadCode`. + */ + supplyChain?: boolean; includePaths?: string[]; configOverride?: ReactDoctorConfig | null; /** @@ -312,6 +345,17 @@ export interface JsonReportV1 { ok: boolean; directory: string; mode: JsonReportMode; + /** + * Whether any scanned project resolved a React-compatible runtime (React + * or Preact). `false` means every React-runtime rule family was gated off, + * so an empty `diagnostics` array is vacuous — NOT the same as a clean + * React scan. Consumers gating on the report (CI, verifiers, hooks) should + * treat `reactDetected === false` as "wrong scan target", not "all clear" + * (per project, `projects[].project.reactVersion` / `preactVersion` say + * which roots were non-React). Absent when nothing was scanned (`projects` + * is empty), on error reports, and on reports from older CLI versions. + */ + reactDetected?: boolean; diff: JsonReportDiffInfo | null; projects: JsonReportProjectEntry[]; /** diff --git a/packages/core/src/utils/ancestor-directories.ts b/packages/core/src/utils/ancestor-directories.ts new file mode 100644 index 0000000000..124f113121 --- /dev/null +++ b/packages/core/src/utils/ancestor-directories.ts @@ -0,0 +1,23 @@ +import * as path from "node:path"; + +/** + * The chain of ancestor directories of `startDirectory`, nearest first, up to + * and including the filesystem root. With `includeStart`, the chain begins at + * `startDirectory` itself; otherwise at its parent. The one place the "walk up + * the tree" traversal is expressed, so each caller adds only its own match / + * stop policy instead of re-deriving the `path.dirname` loop. + */ +export const ancestorDirectories = ( + startDirectory: string, + { includeStart }: { includeStart: boolean }, +): string[] => { + const directories: string[] = []; + let currentDirectory = includeStart ? startDirectory : path.dirname(startDirectory); + + while (true) { + directories.push(currentDirectory); + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) return directories; + currentDirectory = parentDirectory; + } +}; diff --git a/packages/core/src/utils/compute-config-fingerprint.ts b/packages/core/src/utils/compute-config-fingerprint.ts index 465fa62783..0116ff5f14 100644 --- a/packages/core/src/utils/compute-config-fingerprint.ts +++ b/packages/core/src/utils/compute-config-fingerprint.ts @@ -1,19 +1,21 @@ import crypto from "node:crypto"; -import fs from "node:fs"; import path from "node:path"; import { CONFIG_FINGERPRINT_FILENAMES } from "../constants.js"; +import { hashFileContents } from "./hash-file-contents.js"; +// Content-hashed (not stat-fingerprinted) so the fingerprint survives a fresh +// CI checkout, where every file's mtime is checkout time: identical config +// content keys identically across re-clones. The files are configs and +// manifests — small enough that hashing the chain costs single-digit +// milliseconds per scan (lockfiles are the largest, and hashing is far +// cheaper than the scan they key). export const computeConfigFingerprint = (projectDirectory: string, version: string): string => { const parts: string[] = [`v=${version}`]; let directory = projectDirectory; for (;;) { for (const filename of CONFIG_FINGERPRINT_FILENAMES) { - try { - const stat = fs.statSync(path.join(directory, filename)); - parts.push(`${directory}/${filename}=${stat.mtimeMs}:${stat.size}`); - } catch { - continue; - } + const contentHash = hashFileContents(path.join(directory, filename)); + if (contentHash !== null) parts.push(`${directory}/${filename}=${contentHash}`); } const parent = path.dirname(directory); if (parent === directory) break; diff --git a/packages/core/src/utils/estimate-args-length.ts b/packages/core/src/utils/estimate-args-length.ts new file mode 100644 index 0000000000..06dabab412 --- /dev/null +++ b/packages/core/src/utils/estimate-args-length.ts @@ -0,0 +1,5 @@ +// Estimated total command-line length of an argv (each argument plus one +// separator char), compared against `SPAWN_ARGS_MAX_LENGTH_CHARS` by both +// lint batchers so a batch never exceeds the spawn-args budget. +export const estimateArgsLength = (args: ReadonlyArray): number => + args.reduce((total, argument) => total + argument.length + 1, 0); diff --git a/packages/core/src/utils/has-react-runtime.ts b/packages/core/src/utils/has-react-runtime.ts new file mode 100644 index 0000000000..e31bccd028 --- /dev/null +++ b/packages/core/src/utils/has-react-runtime.ts @@ -0,0 +1,10 @@ +import type { ProjectInfo } from "../types/index.js"; + +/** + * Whether project discovery resolved a React-compatible runtime (React or + * Preact, which ships the same hooks + JSX model). This is the same + * predicate that grants the `react` capability, so `false` means every + * React-runtime rule family was gated off for the project. + */ +export const hasReactRuntime = (project: ProjectInfo): boolean => + project.reactVersion !== null || project.preactVersion !== null; diff --git a/packages/core/src/utils/plan-lint-batches.ts b/packages/core/src/utils/plan-lint-batches.ts new file mode 100644 index 0000000000..274387519e --- /dev/null +++ b/packages/core/src/utils/plan-lint-batches.ts @@ -0,0 +1,107 @@ +import { OXLINT_MAX_FILES_PER_BATCH, SPAWN_ARGS_MAX_LENGTH_CHARS } from "../constants.js"; +import { estimateArgsLength } from "./estimate-args-length.js"; + +export interface PlanLintBatchesInput { + readonly baseArgs: ReadonlyArray; + readonly files: ReadonlyArray; + /** + * Byte size per file (the discovery walk's existing stat). Drives the LPT + * assignment; a file missing from the map weighs zero, matching + * `listSourceFilesWithSize`'s stat-failure fallback. + */ + readonly sizeByFile: ReadonlyMap; +} + +interface MutableLintBatch { + fileIndexes: number[]; + totalSizeBytes: number; + argsLengthChars: number; +} + +/** + * Balanced LPT (longest-processing-time-first) lint batch planner. Where + * `batchIncludePaths` greedily fills 100-file batches and leaves a small + * remainder batch (617 files → 6×100 + 17, so the parallel pool's wall clock + * is set by whichever 100-file chunk happened to collect the most work while + * the remainder's worker idles), this planner keeps the SAME mandatory batch + * count — `ceil(files / OXLINT_MAX_FILES_PER_BATCH)`, the JS-plugin + * perf-cliff / native-binding SIGABRT guard — and assigns files largest-first, + * each to the least-loaded batch by cumulative byte size. Every batch ends up + * with an even share of files AND bytes, so no batch is a straggler; heavy + * files are SPREAD across batches (the documented precondition for `cost` + * ordering to beat `arrival`) instead of concentrated in one. + * + * Deliberately NOT worker-count-aware: splitting into extra batches to feed + * idle workers was measured (619-file corpus, 10 cores) to REGRESS the lint + * wall — each additional concurrent subprocess pays a contended cold start + * (node boot + plugin import + native dlopen, ~180 ms uncontended and 2-3× + * that in a full wave) that outweighs the smaller per-batch file share. + * + * Every batch also respects the spawn-args char budget; a file that fits no + * existing batch opens a new one, mirroring `batchIncludePaths`. Within a + * batch, files return in arrival order, and the assignment is fully + * deterministic (ties broken by lowest batch index), so a plan is + * reproducible for a fixed (files, sizes) input. Batch composition cannot + * change diagnostics — rules that look beyond one file (`CROSS_FILE_RULE_IDS`) + * read the OTHER files via the filesystem, never via batch-mates — so the + * plan only moves wall-clock time. + * + * O(files × batches); the linear least-loaded scan is fine at real-world + * scale (a 3,500-file monorepo is 36 batches ≈ 126k comparisons). + */ +export const planLintBatches = ({ + baseArgs, + files, + sizeByFile, +}: PlanLintBatchesInput): string[][] => { + if (files.length === 0) return []; + const batchCount = Math.ceil(files.length / OXLINT_MAX_FILES_PER_BATCH); + const maxFilesPerBatch = Math.ceil(files.length / batchCount); + const baseArgsLengthChars = estimateArgsLength(baseArgs); + + const descendingSizeIndexes = [...files.keys()].sort( + (leftIndex, rightIndex) => + (sizeByFile.get(files[rightIndex]) ?? 0) - (sizeByFile.get(files[leftIndex]) ?? 0), + ); + + const batches: MutableLintBatch[] = Array.from({ length: batchCount }, () => ({ + fileIndexes: [], + totalSizeBytes: 0, + argsLengthChars: baseArgsLengthChars, + })); + + for (const fileIndex of descendingSizeIndexes) { + const entryLengthChars = files[fileIndex].length + 1; + let targetBatch: MutableLintBatch | null = null; + for (const batch of batches) { + if (batch.fileIndexes.length >= maxFilesPerBatch) continue; + if ( + batch.fileIndexes.length > 0 && + batch.argsLengthChars + entryLengthChars > SPAWN_ARGS_MAX_LENGTH_CHARS + ) { + continue; + } + const isLighterBatch = + targetBatch === null || + batch.totalSizeBytes < targetBatch.totalSizeBytes || + (batch.totalSizeBytes === targetBatch.totalSizeBytes && + batch.fileIndexes.length < targetBatch.fileIndexes.length); + if (isLighterBatch) targetBatch = batch; + } + if (targetBatch === null) { + targetBatch = { fileIndexes: [], totalSizeBytes: 0, argsLengthChars: baseArgsLengthChars }; + batches.push(targetBatch); + } + targetBatch.fileIndexes.push(fileIndex); + targetBatch.totalSizeBytes += sizeByFile.get(files[fileIndex]) ?? 0; + targetBatch.argsLengthChars += entryLengthChars; + } + + return batches + .filter((batch) => batch.fileIndexes.length > 0) + .map((batch) => + batch.fileIndexes + .sort((leftIndex, rightIndex) => leftIndex - rightIndex) + .map((fileIndex) => files[fileIndex]), + ); +}; diff --git a/packages/core/src/utils/resolve-lint-batch-ordering.ts b/packages/core/src/utils/resolve-lint-batch-ordering.ts index 4c718077ce..b0bce6d69e 100644 --- a/packages/core/src/utils/resolve-lint-batch-ordering.ts +++ b/packages/core/src/utils/resolve-lint-batch-ordering.ts @@ -1,17 +1,13 @@ -// How the full-scan lint pass orders its file batches. `"arrival"` (the -// default) keeps `git ls-files` discovery order; `"cost"` opts into LPT -// (largest files first). NOTE: `cost` defaults OFF because the current -// sort-desc-then-chunk-100 builds a single wave-1 batch out of the heaviest -// files — on size-skewed repos (e.g. a few large `*.test` files among many -// tiny modules) that mega-batch becomes a straggler (and can trip the -// per-batch timeout + binary-split), measurably REGRESSING the common -// full-scan case rather than helping it. LPT needs the heavy files SPREAD -// across batches (interleave/deal), not concentrated, before `cost` should be -// the default. Until then `cost` is an env opt-in for repos it demonstrably -// helps. The env var is read in one place so the `LintBatchOrdering` Reference -// (runtime behavior) and the CLI's `lintBatchOrdering` telemetry tag (cohort -// label) can never disagree. +// How the full-scan lint pass plans its file batches. `"cost"` (the default) +// builds worker-count-aware, size-balanced LPT batches via `planLintBatches` +// — the heavy files are SPREAD across batches (the precondition that kept the +// old sort-desc-then-chunk-100 `cost` mode from earning the default; that +// mode packed the heaviest files into one wave-1 straggler batch). Set +// `REACT_DOCTOR_LINT_BATCH_ORDERING=arrival` to roll back to plain greedy +// 100-file chunking in discovery order. The env var is read in one place so +// the `LintBatchOrdering` Reference (runtime behavior) and the CLI's +// `lintBatchOrdering` telemetry tag (cohort label) can never disagree. export const resolveLintBatchOrdering = (): "cost" | "arrival" => { const raw = process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase(); - return raw === "cost" ? "cost" : "arrival"; + return raw === "arrival" ? "arrival" : "cost"; }; diff --git a/packages/core/src/utils/sort-source-files-by-cost.ts b/packages/core/src/utils/sort-source-files-by-cost.ts deleted file mode 100644 index 70c252392e..0000000000 --- a/packages/core/src/utils/sort-source-files-by-cost.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { SourceFileEntry } from "../types/index.js"; - -// LPT (longest-processing-time-first) batch ordering: feed the largest files -// first so the greedy work-stealing pool in `mapWithConcurrency` starts the -// heaviest batch in wave 1 instead of stranding it after the cheap batches -// drain. File size is a WEAK AST-cost proxy (persisted per-batch durations are -// the v2 signal); it is free here because the size was already stat'd by the -// minified gate. Stable on ties (V8 `Array.prototype.sort` is stable) so -// equal-size files keep their `git ls-files` order — and the deterministic -// diagnostic sort downstream means batch order no longer affects any output -// regardless. -export const sortSourceFilesByCost = (entries: ReadonlyArray): string[] => - [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path); diff --git a/packages/core/tests/build-json-report.test.ts b/packages/core/tests/build-json-report.test.ts index dbcca34303..85b88ffd32 100644 --- a/packages/core/tests/build-json-report.test.ts +++ b/packages/core/tests/build-json-report.test.ts @@ -78,6 +78,99 @@ describe("buildJsonReport", () => { expect(report.baselineDegraded).toBe(true); }); + it("marks reactDetected true on a scan that resolved a React runtime", () => { + const report = buildJsonReport({ + version: "1.2.3", + directory: "/repo", + mode: "full", + diff: null, + scans: [{ directory: "/repo", result: result() }], + totalElapsedMilliseconds: 1200, + }); + expect(report.reactDetected).toBe(true); + }); + + it("marks reactDetected false when no scanned project resolved React or Preact", () => { + const nonReactProject: ProjectInfo = { + ...projectInfo, + reactVersion: null, + reactMajorVersion: null, + preactVersion: null, + preactMajorVersion: null, + framework: "unknown", + }; + const report = buildJsonReport({ + version: "1.2.3", + directory: "/repo", + mode: "full", + diff: null, + scans: [ + { directory: "/repo", result: result({ diagnostics: [], project: nonReactProject }) }, + ], + totalElapsedMilliseconds: 1200, + }); + expect(report.reactDetected).toBe(false); + expect(report.ok).toBe(true); + expect(report.diagnostics).toHaveLength(0); + }); + + it("marks reactDetected true in a workspace where only some roots are React", () => { + const nonReactProject: ProjectInfo = { + ...projectInfo, + rootDirectory: "/repo/packages/tooling", + reactVersion: null, + reactMajorVersion: null, + framework: "unknown", + }; + const report = buildJsonReport({ + version: "1.2.3", + directory: "/repo", + mode: "full", + diff: null, + scans: [ + { + directory: "/repo/packages/tooling", + result: result({ diagnostics: [], project: nonReactProject }), + }, + { directory: "/repo/packages/app", result: result() }, + ], + totalElapsedMilliseconds: 1200, + }); + expect(report.reactDetected).toBe(true); + }); + + it("marks reactDetected true when a Preact runtime satisfies the react capability", () => { + const preactProject: ProjectInfo = { + ...projectInfo, + reactVersion: null, + reactMajorVersion: null, + preactVersion: "10.19.2", + preactMajorVersion: 10, + framework: "vite", + }; + const report = buildJsonReport({ + version: "1.2.3", + directory: "/repo", + mode: "full", + diff: null, + scans: [{ directory: "/repo", result: result({ project: preactProject }) }], + totalElapsedMilliseconds: 1200, + }); + expect(report.reactDetected).toBe(true); + }); + + it("omits reactDetected when nothing was scanned", () => { + const report = buildJsonReport({ + version: "1.2.3", + directory: "/repo", + mode: "staged", + diff: null, + scans: [], + totalElapsedMilliseconds: 1200, + }); + expect("reactDetected" in report).toBe(false); + }); + it("emits a v2 baseline report carrying the new/fixed delta and head score", () => { const report = buildJsonReport({ version: "1.2.3", diff --git a/packages/core/tests/check-dead-code.test.ts b/packages/core/tests/check-dead-code.test.ts index 35101b5845..943ee6852e 100644 --- a/packages/core/tests/check-dead-code.test.ts +++ b/packages/core/tests/check-dead-code.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import * as path from "node:path"; import { afterAll, describe, expect, it } from "vite-plus/test"; import { checkDeadCode } from "../src/check-dead-code.js"; +import { resolveReactDoctorCacheDir } from "../src/utils/resolve-react-doctor-cache-dir.js"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-check-dead-code-")); @@ -289,6 +290,58 @@ describe("checkDeadCode", () => { expect(flaggedDevDeps).toEqual(["Unused devDependency: `genuinely-unused`"]); }); + it( + "reuses deslop's incremental summary cache across real worker runs and reflects edits", + { timeout: 120_000 }, + async () => { + const directory = setupProject("incremental-cache-e2e", { + "src/index.ts": 'import { keep } from "./lib.js";\nexport const entry = keep;\n', + "src/lib.ts": "export const keep = 1;\n", + "src/orphan.ts": "export const orphan = 1;\n", + }); + // Pins `resolveReactDoctorCacheDir` to the fixture so both cache files + // live (and are cleaned up) with it. + fs.mkdirSync(path.join(directory, "node_modules"), { recursive: true }); + const summariesPath = path.join( + resolveReactDoctorCacheDir(directory), + "dead-code-summaries.json", + ); + + const firstRun = await checkDeadCode({ rootDirectory: directory, cacheEnabled: true }); + expect(fs.existsSync(summariesPath)).toBe(true); + expect(firstRun.some((diagnostic) => diagnostic.rule === "unused-file")).toBe(true); + expect(firstRun.some((diagnostic) => diagnostic.message.includes("newlyUnused"))).toBe(false); + + // Invalidate the whole-result layer so the worker actually re-runs and + // serves the changed-files case from the incremental summary store. + fs.appendFileSync(path.join(directory, "src", "lib.ts"), "export const newlyUnused = 2;\n"); + + let reportedStats: { hits: number; misses: number } | null = null; + const secondRun = await checkDeadCode({ + rootDirectory: directory, + cacheEnabled: true, + onSummaryCacheStats: (stats) => { + reportedStats = stats; + }, + }); + expect(secondRun.some((diagnostic) => diagnostic.message.includes("newlyUnused"))).toBe(true); + // Only the edited file re-parses; the other two summaries replay. + expect(reportedStats).toEqual({ hits: 2, misses: 1 }); + + // Byte-identical to an uncached full analysis at the same tree state — + // and the uncached control reports no summary-cache stats. + let didControlReportStats = false; + const controlRun = await checkDeadCode({ + rootDirectory: directory, + onSummaryCacheStats: () => { + didControlReportStats = true; + }, + }); + expect(secondRun).toEqual(controlRun); + expect(didControlReportStats).toBe(false); + }, + ); + it("rejects malformed worker results instead of silently dropping diagnostics", async () => { const directory = setupProject("malformed-worker-result", { "src/index.ts": "export const used = 1;\n", diff --git a/packages/core/tests/check-security-scan.test.ts b/packages/core/tests/check-security-scan.test.ts index d9fd7e0097..798063fc91 100644 --- a/packages/core/tests/check-security-scan.test.ts +++ b/packages/core/tests/check-security-scan.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { checkSecurityScan } from "@react-doctor/core"; +import { checkSecurityScan, checkSecurityScanCooperative } from "@react-doctor/core"; import type { Diagnostic } from "@react-doctor/core"; import { REACT_DOCTOR_RULES } from "oxlint-plugin-react-doctor"; @@ -236,6 +236,17 @@ describe("checkSecurityScan", () => { }); }); + describe("cooperative driver", () => { + it("produces the same diagnostics, in the same order, as the sync driver", async () => { + const fixtureDirectory = path.join(FIXTURES_DIRECTORY, "eva-mintlify-docs-platform"); + const syncDiagnostics = checkSecurityScan(fixtureDirectory); + expect(syncDiagnostics.length).toBeGreaterThan(0); + await expect(checkSecurityScanCooperative(fixtureDirectory)).resolves.toEqual( + syncDiagnostics, + ); + }); + }); + describe("supabase-rls-policy-risk regressions", () => { it("conservatively flags IF EXISTS disable-RLS (cross-migration drop state not tracked)", () => { // The per-file scan can't see that an earlier migration dropped the table, diff --git a/packages/core/tests/check-supply-chain-cache.test.ts b/packages/core/tests/check-supply-chain-cache.test.ts index 98ffc92e6b..334c1e98d6 100644 --- a/packages/core/tests/check-supply-chain-cache.test.ts +++ b/packages/core/tests/check-supply-chain-cache.test.ts @@ -105,6 +105,24 @@ describe("supply-chain on-disk cache", () => { expect(second).toEqual(first); // advisory still produced from the fresh fetch }); + it("prunes cache files past the TTL so stale purls don't accumulate across CI restores", async () => { + stubSocketFetch(); + await runCheck(); + const cacheFiles = walkCacheFiles(cacheDirectory); + expect(cacheFiles.length).toBeGreaterThan(0); + + // Simulate an entry for a purl no run looks up anymore (a bumped or + // removed dependency) whose mtime is past the 24h TTL. + const staleFile = path.join(path.dirname(cacheFiles[0]), "stale-purl.json"); + fs.writeFileSync(staleFile, JSON.stringify({ fetchedAtMs: 0, body: "{}" })); + const expiredDate = new Date(Date.now() - 48 * 60 * 60 * 1_000); + fs.utimesSync(staleFile, expiredDate, expiredDate); + + await runCheck(); + expect(fs.existsSync(staleFile)).toBe(false); + expect(walkCacheFiles(cacheDirectory).length).toBeGreaterThan(0); // live entries survive + }); + it("re-fetches every run when REACT_DOCTOR_NO_CACHE is set (cache bypassed)", async () => { process.env["REACT_DOCTOR_NO_CACHE"] = "1"; const fetchMock = stubSocketFetch(); diff --git a/packages/core/tests/compute-diagnostic-delta.test.ts b/packages/core/tests/compute-diagnostic-delta.test.ts index ac7a1fbee8..9bf1ba460a 100644 --- a/packages/core/tests/compute-diagnostic-delta.test.ts +++ b/packages/core/tests/compute-diagnostic-delta.test.ts @@ -91,6 +91,88 @@ describe("computeDiagnosticDelta", () => { expect(delta.fixedCount).toBe(1); }); + it("matches a pre-existing occurrence-matched finding whose flagged line was reformatted", () => { + const base = [ + makeDiagnostic({ + plugin: "react-doctor", + rule: "iframe-has-title", + category: "Accessibility", + matchByOccurrence: true, + line: 10, + }), + ]; + const head = [ + makeDiagnostic({ + plugin: "react-doctor", + rule: "iframe-has-title", + category: "Accessibility", + matchByOccurrence: true, + line: 12, + }), + ]; + const delta = computeDiagnosticDelta({ + headDiagnostics: head, + baseDiagnostics: base, + // Prettier reflowed the multi-line