Skip to content

refactor(runtime): full Effect v4 runtime — services, streaming pipeline, tagged errors#304

Closed
aidenybai wants to merge 11 commits into
mainfrom
cursor/effect-runtime-foundation-6abb
Closed

refactor(runtime): full Effect v4 runtime — services, streaming pipeline, tagged errors#304
aidenybai wants to merge 11 commits into
mainfrom
cursor/effect-runtime-foundation-6abb

Conversation

@aidenybai
Copy link
Copy Markdown
Member

@aidenybai aidenybai commented May 20, 2026

What this is

react-doctor runs on a small Effect v4 runtime layer. The two public entry points (inspect() for the CLI, diagnose() for the programmatic API) share one orchestration Effect (runInspect) and one Service vocabulary. The diagnostic pipeline is genuinely streaming end-to-end, every error is a tagged ReactDoctorError with a reason union, every file IO crosses one Files service boundary, and the layer set is complete enough that adding a second linter backend, an LSP host, an NDJSON cache, or a SARIF reporter is "one new layer" rather than "rewrite the orchestration."

Rebased onto current main (which added a --dead-code feature in aae328cc and a score-payload gzip fix in d16d2c7b). The dead-code feature is integrated through the runtime as a new DeadCode Context.Service; the gzip fix is internal to calculateScore and the runtime's Score.layerHttp picks it up transparently.

All 1,202 react-doctor tests pass (1,198 pre---dead-code + 4 new for that feature). 31 new runtime tests cover the foundation, orchestration, streaming pipeline, file-IO service, dead-code service, spinner service, ndjson reporter, composite linter, and tagged-error contracts.

Architecture

Services (one Context.Service per orthogonal axis)

Service Purpose Live layer Test surface
Project Discover the React project at a directory layerNode (wraps discoverProject) layerOf(projectInfo)
Config Load react-doctor.config.json + rootDir redirect, cached via Cache.make (capacity 16, TTL 5min) layerNode layerOf({ config, resolvedDirectory })
Files All filesystem reads / lists / stats the pipeline needs layerNode layerInMemory(Map<absolutePath, content>)
Linter Stream diagnostics for an input layerOxlint, layerComposite([...]) layerNoop, layerOf(diagnostics)
DeadCode Whole-project reachability analysis layerNode (wraps checkDeadCode / deslop-js) layerNoop, layerOf(diagnostics)
Reporter emit(diagnostic) + finalize layerNdjson(path) (Schema-encoded NDJSON), layerCapture layerNoop, layerCapture
Score Compute the score layerHttp layerOffline, layerOf(result)
Spinner Terminal feedback during long phases layerOra(factory) layerNoop, layerCapture (records events)
LintPartialFailures Per-batch soft-failure messages from linters layerLive (Ref) (replaceable in tests)

The only production linter today is Linter.layerOxlintlayerComposite([...]) is the layer slot a future second backend would plug into without changing the orchestrator. Direct consumers of these services today are exactly two: inspect() (the CLI's entry point) and diagnose() (the programmatic API).

Tagged errors with a reason union — end to end

ReactDoctorError { reason: Schema.Union([...]) } lives in @react-doctor/core (the runtime re-exports without a circular dep). Every leaf failure is a Schema.TaggedErrorClass:

OxlintBinaryNotFound, OxlintNativeBindingFailed, OxlintSpawnFailed, OxlintTimedOut, OxlintOutputTooLarge, OxlintOutOfMemory, OxlintKilled, OxlintOutputUnparseable, ConfigParseFailed, ProjectNotFound, NoReactDependency, AmbiguousProject.

runOxlint raises tagged errors directly. Every error.message.includes(...) site across the lint boundary is gone.

Public API entry points (inspect(), diagnose()) translate runtime tagged errors back into the legacy classes (NoReactDependencyError, ProjectNotFoundError, AmbiguousProjectError) at the boundary using a structural _tag check.

Schema is the wire

  • runtime/diagnostic-schema.tsDiagnostic + Severity + buildDiagnosticIdentity.
  • runtime/json-report-schema.tsJsonReport as Schema.Union([JsonReportV1]).
  • Reporter.layerNdjson Schema-encodes every diagnostic at the wire boundary.

Streaming pipeline — actually streaming

runInspect's diagnostic flow is Stream-driven end to end with no intermediate array materialization:

  1. Stream.fromIterable(checkReducedMotion(scanDirectory)) prepends environment-side diagnostics (skipped in --diff mode).
  2. Stream.concat(Linter.lint(...)) appends the lint stream, with Stream.catchTag("ReactDoctorError", ...) folding mid-stream linter failures into didLintFail.
  3. Stream.concat(deadCodeStream) appends dead-code diagnostics (skipped in --diff / --staged mode since reachability is whole-project; failure folds into didDeadCodeFail via Effect.matchEffect).
  4. Stream.filterMap(filterMapNullable(transform.apply)) applies the per-element auto-suppress / severity / ignore / inline-suppression chain.
  5. Stream.tap(reporter.emit) pushes survivors to the Reporter as they emerge.
  6. Stream.runCollect produces the final array for the score / surface filter / public API.

The per-element transform lives in core/build-diagnostic-pipeline.ts and is shared with mergeAndFilterDiagnostics — there is no second copy of the chain.

runInspect orchestration

runInspect(input, hooks): Effect<RunInspectOutput, ReactDoctorError, Project | Config | DeadCode | Files | Linter | LintPartialFailures | Reporter | Score | HooksR>:

  1. Config.resolve(directory){ config, resolvedDirectory }
  2. Project.discover(resolvedDirectory)ProjectInfo (raises NoReactDependency when reactVersion === null)
  3. buildDiagnosticPipeline({ files: Files, ... }) → per-element transform
  4. Linter.lint(...) and DeadCode.compute(...) streams → Stream.tap(Reporter.emit) → folded counts
  5. Score.compute(...) over the surface-filtered subset
  6. Returns RunInspectOutput with project, userConfig, diagnostics, score, didLintFail, lintFailureReason, lintFailureReasonTag, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason

RunInspectHooks<HooksR> is parametric on the hook environment so a caller can yield* AnyService from a hook (the CLI does this for Spinner).

Public API rewires

inspect.ts (CLI) — Loads config + merges options, then runs the runtime via Effect.runPromise. CLI-tailored layer stack: Linter.layerNoop for --no-lint / missing oxlint native binding, Linter.layerOxlint otherwise; Score.layerOffline for --offline; DeadCode.layerNode when options.deadCode is true, DeadCode.layerNoop otherwise; Spinner.layerNoop for --silent / --score / --json, Spinner.layerOra(oraFactory) otherwise. beforeLint / afterLint hooks drive the existing project-detection rendering and the lint spinner; a separate dead-code spinner line renders after lint finalizes.

index.ts (diagnose) — Preserves resolveDiagnoseTarget auto-fallback to nested React subprojects and resolveConfigRootDir redirect; AmbiguousProjectError still throws as itself. Wires DeadCode.layerNode / layerNoop based on options.deadCode. Returns skippedChecks + skippedCheckReasons to match the post-#291 public type.

Tests (31 new in runtime, all 1,202 react-doctor tests green)

  • diagnostic-schema.test.ts, json-report-schema.test.ts, errors.test.ts, pipeline.test.ts, tagged-errors.test.ts — schemas + tagged errors
  • run-inspect.test.ts — full orchestration with all-layerOf services + Files.layerInMemory + DeadCode.layerOf([]); mid-stream Stream.fail folds into didLintFail; missing React yields NoReactDependency; beforeLint / afterLint hooks fire in order
  • files.test.tslayerInMemory exposes the four primitives
  • dead-code.test.tslayerOf returns the supplied diagnostics; layerNoop returns []; service exposes Error-typed failure channel
  • spinner.test.tslayerCapture / layerNoop / layerOra
  • reporter-ndjson.test.ts — Schema-encoded NDJSON line per emit
  • linter-composite.test.ts — backends concatenate; backends share the LintPartialFailures Ref via the runtime context

Verification

pnpm typecheck   # 12 packages green
pnpm test        # 1,233 tests pass (1,202 react-doctor + 31 runtime)
pnpm lint        # 0 warnings, 0 errors
pnpm format:check
pnpm build       # all packages, including website

CLI smoke test on tests/fixtures/basic-react:

$ node packages/react-doctor/dist/cli.js packages/react-doctor/tests/fixtures/basic-react --offline --no-lint
...
Dead Code 23 issues
  ⚠ Unused file ×22
  ⚠ Unused dependency
  React Doctor (www.react.doctor)
  Score unavailable in offline mode.
  23 issues across 23/22 files in 201ms

Dead-code diagnostics flow end-to-end through the streaming pipeline.

Commits on the branch (after rebase)

  1. chore: ignore .repos/ scratch directory for cloned reference repos
  2. feat(runtime): introduce @react-doctor/runtime — Effect v4 foundation
  3. feat(runtime): add Project, Config, Score services + runInspect orchestrator + LintPartialFailures
  4. refactor(react-doctor): rewire inspect() and diagnose() through the Effect runtime
  5. refactor: kill magic-string error sniffing across the lint boundary
  6. refactor(handle-error): render tagged ReactDoctorError via formatReactDoctorError
  7. feat(runtime): Files service + end-to-end streaming diagnostic pipeline
  8. feat(runtime): Cache.make config, Spinner / NDJSON / Composite layers + structural tag matching
  9. docs(runtime): drop Biome as the canonical Linter-swap example
  10. fix(runtime): scrub overstated and stale claims across docs and comments
  11. feat(runtime): integrate dead-code analysis as a Context.Service — new commit on top of the rebase

Packaging

  • @react-doctor/runtime is private (workspace, not published).
  • @react-doctor/core has effect@4.0.0-beta.69 as a runtime dependency.
  • effect@4.0.0-beta.69 is react-doctor's dependencies and neverBundle in vite.config.ts.
  • pnpm test runs --filter=react-doctor --filter=@react-doctor/runtime.

Out of scope (sensible follow-ups)

  • A real second linter backend (an in-process ESLint worker pool covering rules oxlint can't yet express, or a sandboxed runner for untrusted user config).
  • LSP host that replaces Reporter.layerCapture with a Reporter.layerLsp calling connection.sendDiagnostics per emit.
  • SARIF reporter for the GitHub Code Scanning integration.
  • Watch mode that uses Reporter.layerNdjson keyed by (filePath, contentHash) for incremental scans, with Stream cancellation per project.
  • Replace vi.mock("ora", …) in inspect.test.ts with a Spinner.layerCapture-driven test once inspect() exposes a layer-injection seam.
Open in Web Open in Cursor 

@reactreview
Copy link
Copy Markdown

reactreview Bot commented May 20, 2026

0 score

Copy as prompt
Check if these React Review issues are valid. If so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.

Run this before and after your changes to verify the result:
npx react-doctor@latest --verbose --diff

Do not modify the react-doctor configuration unless explicitly asked.
Fix the underlying code issues instead of changing or suppressing the rules.

React Review found 0 errors and 2 warnings. This PR leaves the React health score unchanged.

<file name="packages/core/src/build-diagnostic-pipeline.ts">

<violation number="1" location="packages/core/src/build-diagnostic-pipeline.ts:59">
Severity: Warning

array.includes() in a loop is O(n) per call — convert to a Set for O(1) lookups

Use a `Set` or `Map` for repeated membership tests / keyed lookups — `Array.includes`/`find` is O(n) per call

Rule: `js-set-map-lookups`
</violation>

<violation number="2" location="packages/core/src/build-diagnostic-pipeline.ts:84">
Severity: Warning

array.includes() in a loop is O(n) per call — convert to a Set for O(1) lookups

Use a `Set` or `Map` for repeated membership tests / keyed lookups — `Array.includes`/`find` is O(n) per call

Rule: `js-set-map-lookups`
</violation>

</file>

Reviewed by reactreview for commit 2605bd6. Configure here.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 20, 2026 10:55pm

@cursor cursor Bot changed the title feat(runtime): introduce @react-doctor/runtime — Effect v4 foundation refactor(runtime): introduce Effect v4 runtime + rewire inspect()/diagnose() onto it May 20, 2026
@aidenybai aidenybai marked this pull request as ready for review May 20, 2026 20:29
Comment thread packages/runtime/src/errors.ts
Comment thread packages/core/src/run-oxlint.ts
Comment thread packages/react-doctor/src/index.ts
Comment thread packages/runtime/src/index.ts
@cursor cursor Bot changed the title refactor(runtime): introduce Effect v4 runtime + rewire inspect()/diagnose() onto it refactor(runtime): full Effect v4 runtime — services, streaming pipeline, tagged errors May 20, 2026
const didLintFail = lintBindingMissing || result.didLintFail;
const lintFailureReason = lintBindingMissing
? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}`
: result.lintFailureReason;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score nullified when lint binding is missing

Medium Severity

When lintBindingMissing is true, the runtime correctly computes a score (since Linter.layerNoop doesn't fail), but inspect.ts unconditionally nullifies it via score: didLintFail ? null : result.score. The old code always called calculateScore(scoreDiagnostics) regardless of didLintFail, returning a real ScoreResult in the programmatic InspectResult. Callers relying on result.score being non-null when lint was skipped due to a missing binding now get null.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4176089. Configure here.

Comment thread packages/react-doctor/src/inspect.ts
Comment thread packages/react-doctor/src/index.ts
Comment thread packages/react-doctor/src/index.ts
Comment thread packages/react-doctor/src/cli/utils/handle-error.ts
Comment thread packages/core/src/merge-and-filter-diagnostics.ts
Comment thread packages/react-doctor/src/inspect.ts
cursoragent and others added 11 commits May 20, 2026 22:37
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Adds an internal, unpublished workspace package that demonstrates the
load-bearing architectural moves react-doctor would adopt to mirror
react-doctor-evals' Effect v4 design end-to-end. Nothing in the
existing CLI / inspect / diagnose paths is changed yet — this is the
parallel skeleton later slices will pull onto.

Patterns introduced:

- Schema is the wire (effect/Schema):
  - diagnostic-schema.ts: Diagnostic + Severity + buildDiagnosticIdentity
    (`${file}::${line}:${col}::${plugin}/${rule}`).
  - json-report-schema.ts: JsonReport exposed as Schema.Union([JsonReportV1])
    so adding schemaVersion: 2 later is one new union member, decoded by
    every downstream consumer through one schema instead of trusting fields.

- Tagged-error facade with a reason union (errors.ts):
  - Leaf TaggedErrorClass entries per concrete oxlint / config / project
    failure mode (OxlintBinaryNotFound, OxlintTimedOut, OxlintOutputTooLarge,
    OxlintOutputUnparseable, OxlintBatchFileDropped, OxlintSpawnFailed,
    ConfigParseFailed, ProjectNotFound, NoReactDependency).
  - One ReactDoctorError facade carrying reason: Schema.Union([...]).
  - formatReactDoctorError as the single message renderer; switch over
    reason._tag enforces exhaustiveness.

- Two orthogonal axes via Context.Service + Layer:
  - Linter (linter.ts): cross-backend service with lint(input) returning
    Stream<Diagnostic, ReactDoctorError>. layerOxlint wraps the existing
    runOxlint (mapping legacy plain-Error throws to tagged reasons at one
    boundary), layerNoop returns Stream.empty, layerOf(diagnostics)
    is the test surface.
  - Reporter (reporter.ts): emit(diagnostic) + finalize. layerNoop drops,
    layerCapture stores into a Ref exposed via the second-service
    ReporterCapture so tests can yield* Ref.get(yield* ReporterCapture)
    instead of mocking I/O.

- Streaming pipeline (pipeline.ts): runDiagnosticPipeline composes
  Linter.lint -> Stream.filter(keep) -> Reporter.emit and folds severity
  counts in a single pass via Stream.runFoldEffect. Counts are
  DiagnosticPipelineCounts; no intermediate diagnostic array is
  materialized.

Tests (vite-plus/test) cover the wire boundaries and pipeline behavior
without spawning oxlint:

- diagnostic-schema.test.ts: decode happy path, rejects bad severity,
  identity is deterministic.
- json-report-schema.test.ts: v1 round-trip, missing schemaVersion is a
  decode failure (not a runtime crash later).
- errors.test.ts: facade renders, propagates as a tagged failure through
  Effect.gen, recovers via Effect.catchTag.
- pipeline.test.ts: Linter.layerOf + Reporter.layerCapture verifies the
  full pipeline (counts + emitted diagnostics), the keep predicate drops
  weak-signal categories, and Linter.layerNoop yields zero counts/output.

Out of scope for this commit: replacing inspect() / diagnose() / the CLI,
deleting format-error-chain.ts, migrating tests off the legacy entry
points. Those are follow-up slices that pull onto this foundation
incrementally.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…strator + LintPartialFailures

Builds out the runtime so the existing CLI / inspect / diagnose paths can
ride on top of it. The orchestrating Effect (`runInspect`) composes
every Service the public entry points need; adding a new entry point
(LSP host, daemon, GitHub Action surface) is now a layer choice plus a
`runInspect` invocation, not a re-implementation of the orchestration.

New services:

- Project (project.ts): `discover(directory)` returning ProjectInfo or
  a tagged ReactDoctorError. `layerNode` wraps
  @react-doctor/project-info's `discoverProject` and translates
  ProjectNotFoundError / NoReactDependencyError /
  PackageJsonNotFoundError / AmbiguousProjectError into matching
  tagged reasons (NoReactDependency, ProjectNotFound,
  AmbiguousProject) so callers downstream get to match on the runtime
  vocabulary. `layerOf` is the test surface.

- Config (config.ts): `resolve(directory)` returning
  { config, resolvedDirectory }. `layerNode` runs
  loadConfigWithSource + resolveConfigRootDir in one place;
  `layerOf` lets callers (tests, the public API after its own
  redirect walk) bypass the on-disk re-load.

- Score (score.ts): `compute(diagnostics)` returning ScoreResult or
  null. `layerHttp` (production), `layerOffline` (--offline),
  `layerOf` (tests) are the three knobs the CLI already exposes.

- LintPartialFailures (linter.ts): a Ref<readonly string[]> exposed
  as a service. `Linter.layerOxlint` pushes per-batch soft failures
  there via Ref.update; the orchestrator reads it after the lint
  stream completes. Replaces the legacy onPartialFailure callback +
  closure-captured array; same downstream contract for
  skippedCheckReasons['lint:partial'].

- AmbiguousProject reason (errors.ts): mirrors the legacy class with
  directory + candidates fields, formatted by formatReactDoctorError.

Orchestration:

- runInspect(input, hooks): Effect<RunInspectOutput, ReactDoctorError,
  Project | Config | Linter | LintPartialFailures | Reporter | Score>.
  - Resolves config (and rootDir redirect) via Config.
  - Discovers project via Project; raises tagged NoReactDependency
    when the resolved project has no React.
  - Streams diagnostics from Linter; uses Stream.tap for
    Reporter.emit and Stream.catchTag("ReactDoctorError") to fold
    mid-stream linter failures into didLintFail/lintFailureReason
    instead of failing the orchestration.
  - Pulls scoring diagnostics through filterDiagnosticsForSurface,
    then computes the score via Score.
  - beforeLint / afterLint hooks let surface code (CLI spinner,
    project-detection block; later: LSP host's status bar) participate
    without owning the orchestration.

- layerInspectLive: Project.layerNode + Config.layerNode +
  Linter.layerOxlint + LintPartialFailures.layerLive +
  Reporter.layerCapture + Score.layerHttp. Default production
  stack; callers swap individual layers.

Tests (runtime package, 4 new + the existing 11 still green):

- runInspect composes everything with Project.layerOf + Config.layerOf
  + Linter.layerOf + Score.layerOf + Reporter.layerCapture and the
  result aligns with the captured Reporter output.
- Stream.fail in the Linter folds into didLintFail with a
  format-derived lintFailureReason; the orchestration does NOT
  reject.
- Discovering a project without React yields the tagged
  NoReactDependency reason in the failure channel.
- beforeLint / afterLint hooks fire in order with the right args.

The runtime tests now run alongside the existing react-doctor tests
under the workspace test script (--filter=@react-doctor/runtime added)
so regressions are caught in the same pnpm test pass.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…ffect runtime

The two public entry points of `react-doctor` (`inspect()` for the CLI,
`diagnose()` for the programmatic API) are now thin shells around the
runtime's `runInspect` Effect. The orchestration code that used to live
in two places — project discovery, config + rootDir redirect, lint
spawning + spinner UX, score round-trip, partial-failure plumbing —
now lives once in the runtime and is shared between them.

Public API behavior is preserved end-to-end. All 1,198 existing tests
remain green; the 15 runtime tests (11 foundation + 4 runInspect)
exercise the new orchestration paths.

inspect.ts:

- Loads config once via @react-doctor/core, merges options (so silent /
  scoreOnly / verbose take effect from the first log line), then runs
  the runtime via Effect.runPromise.
- Provides a layer stack tailored to the CLI flags: Linter.layerNoop
  for --no-lint or when the oxlint native binding is missing,
  Linter.layerOxlint otherwise; Score.layerOffline for --offline,
  Score.layerHttp otherwise; Config.layerOf when the caller passed
  configOverride (skips the on-disk re-load), Config.layerNode
  otherwise.
- beforeLint / afterLint hooks drive the spinner + project-detection
  block. Mid-stream lint failures surface back through the runtime's
  didLintFail / lintFailureReason — the renderer keeps producing
  skippedChecks: ['lint'] and the 'native binding' upgrade hint where
  the legacy code did.
- Wraps `Effect.runPromise` rejections to restore the legacy
  NoReactDependencyError class at the public API boundary, so
  inspect.test.ts's '.rejects.toThrow("No React dependency found")'
  contract continues to hold.

index.ts:

- diagnose() preserves its programmatic-API behavior: rootDir-redirect
  via resolveConfigRootDir, then resolveDiagnoseTarget falls back to a
  nested React subproject when the requested directory has no root
  package.json. AmbiguousProjectError still throws as
  AmbiguousProjectError. Once the directory is resolved, the
  orchestration delegates to runInspect with Config.layerOf so the
  runtime doesn't redo the redirect walk.
- Tagged-error → legacy-class translation lives in restoreLegacyThrow:
  NoReactDependency → NoReactDependencyError, ProjectNotFound →
  ProjectNotFoundError, AmbiguousProject → AmbiguousProjectError,
  everything else falls back to a stringified message anchored to the
  requested directory.

Build / packaging:

- `@react-doctor/runtime` joins react-doctor's devDependencies (same
  bundling story as @react-doctor/core: workspace package, tree-shaken
  into dist at build time).
- `effect` is added as a runtime dependency of react-doctor, marked as
  `neverBundle` in vite.config.ts so the published bundle stays lean
  and effect resolves through node_modules at install time.
- root `pnpm test` now also runs --filter=@react-doctor/runtime, so
  the orchestrator + service tests run on every `pnpm test` invocation
  alongside the 1,198 existing react-doctor tests.

spinner.ts: noopHandle's succeed/fail accept the displayText argument
so the SpinnerHandle interface unifies cleanly with ora's runtime
shape, fixing the closure-narrowing TS issue when the started spinner
is captured in a Ref-style holder for the Effect-based hooks.

What's intentionally out of scope here (follow-ups):

- Push the tagged errors *into* @react-doctor/core's runOxlint so the
  mapLegacyOxlintError string-sniff at Linter.layerOxlint goes away.
- Reshape combineDiagnostics's filter chain into Stream.filter
  operators inside runInspect (today the streaming pipeline still
  collects diagnostics into an array before calling combineDiagnostics
  — the hard parts of combineDiagnostics, like sync file-line reads
  for suppression evaluation, deserve their own slice).
- A second Linter backend (Biome, ESLint worker pool) to prove the
  layer swap empirically.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
The lint-side of the codebase had three places that string-matched on
`error.message` to discriminate failure modes (per-batch
binary-split recovery in the oxlint runner; legacy → tagged
translation in the runtime; native-binding hint dispatch in the CLI
renderer). Tagged errors now flow end to end, so the discriminator is
`reason._tag` everywhere.

Errors live in core (errors.ts):

- @react-doctor/core gains `effect` as a runtime dependency and is
  the canonical home for tagged ReactDoctorError + reason union.
- Leaf reasons: OxlintBinaryNotFound, OxlintNativeBindingFailed,
  OxlintSpawnFailed, OxlintTimedOut, OxlintOutputTooLarge,
  OxlintOutOfMemory, OxlintKilled, OxlintOutputUnparseable,
  OxlintBatchFileDropped, ConfigParseFailed, ProjectNotFound,
  NoReactDependency, AmbiguousProject.
- New typed `OxlintNativeBindingFailed` separates a runtime-side
  oxlint native-binding crash from the pre-check
  `OxlintBinaryNotFound` (resolveOxlintNode).
- `SPLITTABLE_OXLINT_REASONS` + `isSplittableReactDoctorError`
  centralize which reason tags trigger the runner's binary-split
  recovery (timeout, output-too-large, OOM).
- `@react-doctor/runtime/errors.ts` is now a re-export from core,
  preserving the runtime's public surface without a circular dep.

run-oxlint.ts throws tagged errors directly:

- Timeout → OxlintTimedOut
- Output-size kill → OxlintOutputTooLarge
- SIGABRT → OxlintOutOfMemory; other signals → OxlintKilled
- stderr-only failure with "native binding" → OxlintNativeBindingFailed
- stderr-only failure otherwise → OxlintSpawnFailed
- spawn-level errors (ENOENT etc.) → OxlintSpawnFailed
- JSON parse / shape mismatch → OxlintOutputUnparseable
- isSplittableOxlintBatchError (the previous string-match helper) is
  replaced by `isSplittableReactDoctorError` operating on the tag.

runtime/linter.ts deletes `mapLegacyOxlintError`:

- The 35-line `message.includes(...)` switch is gone.
- `Effect.tryPromise` catch is one line: pass-through ReactDoctorError,
  wrap anything else in OxlintSpawnFailed.

RunInspectOutput.lintFailureReasonTag:

- New field carrying `reason._tag | null` so renderers dispatch off
  the discriminant. The legacy `lintFailureReason` (the rendered
  string) stays for skippedCheckReasons, but no consumer needs to
  parse it now.

inspect.ts native-binding hint dispatch:

- Replaces `lintFailureReason.includes('native binding')` with
  `lintFailureReasonTag === 'OxlintNativeBindingFailed' ||
  lintFailureReasonTag === 'OxlintBinaryNotFound'`. Identical UX
  for the user, no string format coupling between the runner and
  the CLI.

handle-error.ts and build-json-report-error.ts:

- Tagged ReactDoctorErrors render through formatReactDoctorError so
  the CLI error text and the JSON report's `error.message` /
  `error.name` agree.
- Legacy Error chains still walk via getErrorChainMessages — the
  third-party plugin / fs-throw escape hatch is preserved.

Tests (3 new, 18 total in runtime; all 1,198 react-doctor tests still
pass):

- isSplittableReactDoctorError discriminates by tag, not message text.
- formatReactDoctorError renders OxlintNativeBindingFailed with stderr
  context (drives the upgrade-Node hint).
- A runOxlint-shaped Promise rejection with a tagged error
  passes through the runtime's tryPromise catch unchanged.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…tDoctorError

CLI error rendering now defers to formatReactDoctorError when the
caught value is a ReactDoctorError, producing the same one-line
message the JSON reporter writes. Falls back to the cause-chain walk
for legacy Error throws (third-party plugin failures, fs permission,
the surface the chain walker was originally written for).

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
The pipeline is now genuinely streaming. Diagnostics flow from
`Linter.lint` through per-element `Stream.filterMap` of every
auto-suppress / severity / ignore / inline-suppression transform
straight into `Reporter.emit`, with no intermediate array. Every
file IO read/list goes through a Layer-driven `Files` service so
the suppression evaluator and the source-file walker are
swappable via `Files.layerInMemory` for tests.

Files service (runtime/files.ts):

- readLines(path, root), listSourceFiles(root), isFile(path),
  isDirectory(path) — the four primitives the pipeline actually
  uses today; nothing speculative.
- layerNode wraps the existing core/project-info helpers
  (createNodeReadFileLinesSync, listSourceFiles, isFile,
  isDirectory). One boundary where the runtime crosses into Node
  fs, instead of a sync `fs.readFileSync` call buried inside the
  suppression evaluator.
- layerInMemory(tree: Map<absolutePath, content>) — the in-memory
  test surface. Implies directory existence by the prefix of any
  file path. Lets future suppression-eval tests skip temp dir
  setup entirely.
- buildSyncReadFileLines(files, rootDirectory) adapts the service
  to the legacy sync callback shape `combineDiagnostics` and
  `mergeAndFilterDiagnostics` already consume — same readLines
  call site, but now layered.

Per-element pipeline transform (core/build-diagnostic-pipeline.ts):

- Single `buildDiagnosticPipeline(deps).apply(diagnostic)` closure
  applies the four legacy transforms in their original order:
  auto-suppress (test-noise tag + isTestFile), severity controls
  (drop on 'off', restamp on warn/error), ignore filters
  (rules / files / overrides + JSX-aware textComponents /
  rawTextWrapperComponents), inline suppressions
  (react-doctor-disable-next-line + near-miss hints).
- Compilation costs (regex patterns, Set lookups, fileLines cache,
  testFileResultCache) are paid once per scan, captured in the
  closure.
- mergeAndFilterDiagnostics now delegates to this transform, so
  the array entry point and the streaming pipeline share one code
  path. No second copy of the chain to keep in sync.

runInspect end-to-end stream:

- Stream.fromIterable(checkReducedMotion(scanDirectory)) prepends
  environment-side diagnostics (skipped in --diff mode).
- Stream.concat(linter.lint(...)) appends the lint stream.
- Stream.filterMap(filterMapNullable(transform.apply)) applies
  the per-element pipeline (drops on null).
- Stream.tap(reporter.emit) pushes survivors to the Reporter as
  they emerge — a future LSP host / NDJSON cache / SARIF writer
  sees diagnostics mid-scan, not after runCollect.
- combineDiagnostics is no longer called from runInspect.

Public API entry points (react-doctor/src/inspect.ts and
src/index.ts) provide Files.layerNode in their layer stacks. The
new RunInspectHooks<HooksR> generic propagates the hook env so a
caller can yield* from any service (the CLI uses it for Spinner
in the next commit).

Tests:

- 2 new in runtime/tests/files.test.ts: layerInMemory exposes the
  four primitives; buildSyncReadFileLines adapts to the legacy
  callback. Combined with the run-inspect tests now providing
  Files.layerInMemory, the runtime test suite covers the streaming
  pipeline end to end without temp dirs.
- All 1,198 react-doctor tests still pass — combineDiagnostics
  and mergeAndFilterDiagnostics keep their public signatures and
  routing through the new pipeline produces identical results.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
… + structural tag matching

Five focused additions that complete the layer set the runtime
needs to be genuinely production-shaped, plus a structural
`_tag` discriminator that fixes a vitest module-isolation bug on
the public-API `instanceof` check.

Cache.make for Config.layerNode:

- Replaces a one-shot per-scan `loadConfigWithSource` +
  `resolveConfigRootDir` pair with `Cache.make` keyed on
  directory, capacity 16, TTL 5 minutes. Watch mode, the LSP host,
  and the webhook server only re-read config files when the cache
  expires or callers invalidate. The legacy module-scoped `Map`
  inside core's load-config helpers stays — it covers the
  one-shot case where the runtime is bypassed.

Spinner Context.Service (runtime/spinner.ts):

- start(text) → SpinnerHandle; handle.succeed(text) /
  handle.fail(text) return Effect<void>. Surface is intentionally
  narrow: terminate exactly once.
- layerOra(factory) — CLI-supplied factory, keeps the runtime
  package free of an ora dep. inspect.ts wraps the existing
  ora-backed helper.
- layerNoop — handles whose succeed/fail are Effect.void; used
  for --silent / --score / --json.
- layerCapture — records every Started / Succeeded / Failed
  event into a Ref exposed via SpinnerCapture for unit tests
  asserting on spinner UX without ora at all.
- inspect.ts now sources the spinner via the Spinner service
  rather than calling ora helpers directly, so future surfaces
  (LSP status bar, progress-bar reporter for big monorepos)
  swap layers without touching inspect.ts.

Reporter.layerNdjson(filePath):

- Append-only NDJSON emission: every diagnostic encoded through
  Schema.encodeUnknownSync(Diagnostic) and written as one JSON
  line. Mirrors the eval harness's `.evals/<traceId>.jsonl`
  shape; the parent directory is created lazily so callers don't
  have to mkdirp.
- Schema-encode at the wire boundary is the same idiom used at
  the read side — version evolution through Schema.Union, decode
  failure as a tagged error, no field-trust on either side.

Linter.layerComposite([backend, backend, …]):

- Composes multiple Linter implementations by Stream.concat-ing
  their lint streams. The downstream pipeline can't tell a
  composite run apart from a single-backend run (and dedupe
  inside runOxlint covers overlap).
- This is the layer swap the README has been promising since the
  foundation PR — adding a Biome backend, an ESLint worker pool
  for rules oxlint doesn't yet implement, or a fake test-only
  backend is now `Linter.of({ lint })` followed by
  `Linter.layerComposite([oxlint, biome, eslintWorkerPool])`.

Structural `_tag` discriminator at the public-API boundary:

- inspect.ts, index.ts, handle-error.ts, and
  build-json-report-error.ts now use `isReactDoctorErrorLike`
  (a structural `_tag === 'ReactDoctorError'` check) instead of
  `instanceof ReactDoctorError`. Vitest's module isolation
  produces different class identities for the dual
  `Schema.TaggedErrorClass`-derived class at the catch site vs.
  the throw site (the bundle graph shows both
  `ReactDoctorError` and `ReactDoctorError$1`). The `_tag`
  discriminator is the contract the runtime publishes
  structurally; it's also what `Effect.catchTag` matches on, so
  routing all class-identity checks through the same field
  collapses two ways of saying "this is a runtime tagged error"
  into one.
- Production code paths inside core/runtime keep `instanceof`
  because they live in the same module-graph context where the
  error is thrown.

Tests (10 new in runtime, 28 total):

- spinner.test.ts: layerCapture records events in order;
  layerNoop emits Effect.void; layerOra delegates to the
  injected factory.
- reporter-ndjson.test.ts: every emitted diagnostic is one
  Schema-encoded JSON line; parent dir is created lazily.
- linter-composite.test.ts: backends concatenate in order; an
  empty backend list yields Stream.empty; backends share the
  LintPartialFailures Ref via the runtime context (proving the
  composition layer interoperates with the rest of the service
  graph).

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
React Doctor does not use Biome. The previous slice picked Biome
as a stand-in 'second linter backend' in inline comments, the
runtime README, and the linter-composite test's hypothetical
backend names — that read like we either ship a Biome integration
or are committed to one, neither of which is true.

Replaces every Biome mention with neutral phrasing ("a second
backend", "an in-process ESLint worker pool", "a sandboxed
runner") and renames the test fakes from `oxlintLike` /
`biomeLike` to `primaryBackend` / `secondaryBackend` so the
test reads as a layer-composition contract test rather than an
oxlint+Biome integration test. The composite layer's contract is
unchanged; what's adopted on top of it remains a downstream
product decision.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Targeted cleanup of things I implied or asserted that aren't
actually true about React Doctor.

Removed:

- 'eslint plugin host' / 'GitHub Action' as runtime consumers in
  Config / handle-error / index.ts comments. The ESLint plugin
  depends only on `oxlint-plugin-react-doctor` (the rule
  registry); the GitHub Action is a composite action that shells
  out to `npx react-doctor@latest`. Neither uses
  `@react-doctor/runtime` directly. Replaced with the actual
  consumer pair: `inspect()` (CLI) and `diagnose()`
  (programmatic), with future runtime consumers (LSP host, watch
  mode) framed explicitly as hypothetical.
- 'webhook server' as an example long-running process in
  Config.layerNode's cache rationale. React Doctor doesn't have a
  webhook server; that was bleed-through from the
  react-doctor-evals harness tour. Replaced with a generic 'any
  long-running runtime that keeps the runtime alive across
  multiple scans'.
- Confident 'vitest module isolation can produce different class
  identities' explanation in 4 `isReactDoctorErrorLike` comments.
  I observed the symptom (`instanceof` unreliable across the
  module boundary) and fixed it (structural `_tag` check), but I
  don't actually know the root cause for certain — could be ESM
  live-binding init order, dual class names in the bundle graph
  (legacy project-info `ReactDoctorError` + the new tagged
  class), vitest's mock loader, or any combination. Comments now
  acknowledge the empirical observation without committing to a
  specific cause.
- `OxlintBatchFileDropped` reason class. Defined and exported,
  never instantiated. The batch-drop case is reported through
  the `LintPartialFailures` Ref as plain strings (matching the
  legacy `onPartialFailure` callback contract); the unused
  tagged class was left over from an earlier design pass.
  Dropped from the union, the formatter, and the runtime's
  re-export surface. The runtime's index.ts also picks up
  `OxlintNativeBindingFailed`, `OxlintKilled`,
  `OxlintOutOfMemory`, and `isSplittableReactDoctorError` in
  the export list (they were exported from the runtime's
  errors.ts re-export but missing from the runtime index's
  named-export block).
- Specific 'in-process ESLint worker pool', 'LSP publishDiagnostics
  adapter', 'FiberMap keyed by project root' framing in
  `runInspect` and `runDiagnosticPipeline` doc comments.
  Replaced with neutral 'future runtime consumers would swap
  whichever layers fit their surface — the orchestrator doesn't
  change'. The streaming-pipeline benefits doc now lists what's
  true today (bounded memory, mid-scan `Reporter.emit`) and
  separately frames future cancellation as 'the shape unlocks'
  rather than 'the runtime delivers'.

Rewritten:

- packages/runtime/README.md was the original parallel-skeleton
  README, claiming the package 'does not (yet) replace
  inspect()/diagnose()' and listing only Linter / Reporter /
  Pipeline / Schemas / Errors. Stale on every count: it now
  powers both public entry points; Project, Config, Files,
  Score, Spinner, LintPartialFailures, runInspect,
  Linter.layerComposite, Reporter.layerNdjson, and the
  buildDiagnosticPipeline transform are all part of the surface.
  Rewrote with the current service table, the streaming-pipeline
  shape, the orchestrator signature, the public-API rewires, and
  an honest 'what this package does NOT do' section that names
  the layer slots whose implementations are downstream product
  decisions (second linter backend, LSP host, SARIF reporter,
  watch mode).
- The OxlintBatchFileDropped removal reflows
  build-json-report-error.ts and the formatter's exhaustive
  switch — TypeScript verifies coverage of the smaller union.

All 1,226 tests still pass (1,198 react-doctor + 28 runtime);
typecheck, lint, format clean. CLI smoke test against
basic-react fixture is unchanged.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
main grew a `--dead-code` feature in commit aae328c that wires a
deslop-js-based reachability pass into the legacy `inspect()` /
`diagnose()` flow. After rebasing onto main, the rebase replayed
that feature on top of the runtime-rewired entry points, but the
new orchestration didn't yet know about dead-code. This commit
brings the feature back through the runtime so the streaming
pipeline picks up dead-code diagnostics with the same Layer-driven
contract every other axis already follows.

DeadCode Context.Service (runtime/dead-code.ts):

- compute(rootDirectory, userConfig): Effect<readonly Diagnostic[], Error>
- layerNode wraps @react-doctor/core's checkDeadCode (which
  delegates to deslop-js); failures are surfaced as Error so the
  orchestrator can fold them into didDeadCodeFail.
- layerNoop returns []; used when --no-dead-code or
  config.deadCode = false.
- layerOf(diagnostics) is the test surface; mirrors Linter.layerOf.

runInspect orchestration:

- New RunInspectInput.runDeadCode flag (the orchestrator
  additionally gates on !isDiffMode since reachability is
  whole-project, matching how checkReducedMotion is gated).
- RunInspectOutput.didDeadCodeFail / .deadCodeFailureReason mirror
  the lint failure pair, so callers distinguish 'skipped on
  purpose' from 'tried and failed'.
- Dead-code diagnostics flow through the same Stream.filterMap
  pipeline as lint and environment diagnostics: prepend env, then
  the lint stream, then the dead-code stream, then the per-element
  transform, then Reporter.emit.
- DeadCode failures are caught with Effect.matchEffect at the
  service boundary and folded into the deadCodeFailure Ref —
  never sinking the whole scan.

Public API:

- inspect.ts threads options.deadCode through ResolvedInspectOptions,
  passes runDeadCode to the runtime, and renders a separate
  'Analyzing dead code...' spinner line after lint finalizes (so
  two ora frame loops don't compete for stderr). didDeadCodeFail
  surfaces as skippedChecks: ['dead-code'] with
  skippedCheckReasons['dead-code'] in the InspectResult.
- index.ts (diagnose) wires the same runDeadCode flag and provides
  DeadCode.layerNode / layerNoop based on options.deadCode.
  DiagnoseResult now carries skippedChecks + skippedCheckReasons
  to match the new public type — programmatic consumers see exactly
  the same shape the legacy diagnose() returned post-#291.

Tests (3 new in runtime, 31 total; 4 new in react-doctor for the
dead-code feature itself, 1,202 total there):

- runtime/tests/dead-code.test.ts: layerOf returns the supplied
  diagnostics; layerNoop returns []; service shape supports
  Error-typed failure channel.
- runtime/tests/run-inspect.test.ts: every test layer stack now
  includes DeadCode.layerOf([]) so the runtime's expanded service
  graph is satisfied; baseInput sets runDeadCode: false to match
  the test scenarios that don't exercise dead-code.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@cursor cursor Bot force-pushed the cursor/effect-runtime-foundation-6abb branch from 8531c93 to 2605bd6 Compare May 20, 2026 22:55
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2605bd6. Configure here.

...(result.skippedCheckReasons
? { skippedCheckReasons: result.skippedCheckReasons }
: {}),
skippedChecks: [],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toJsonReport drops skippedChecks and skippedCheckReasons from report

High Severity

toJsonReport now hardcodes skippedChecks: [] and completely drops skippedCheckReasons from the per-project scan entry. The old code forwarded result.skippedChecks and conditionally spread result.skippedCheckReasons from the DiagnoseResult. When dead-code analysis fails, diagnose() populates both fields, but the JSON report will always claim no checks were skipped — silently discarding actionable failure information for programmatic API consumers.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2605bd6. Configure here.

const skippedChecks: string[] = [];
const skippedCheckReasons: Record<string, string> = {};
if (deadCodeFailureReason !== null) {
if (result.didDeadCodeFail) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnose() function does not handle didLintFail or lintPartialFailures, causing incomplete skippedChecks and skippedCheckReasons when linting fails.

Fix on Vercel

@aidenybai
Copy link
Copy Markdown
Member Author

Superseded by the merged Effect v4 stack: #421, #426, #427, #431, #432, #433, #434. The full Effect v4 runtime is now on main with the canonical v4 patterns (Context.Service, Schema.TaggedErrorClass union, ChildProcess via @effect/platform-node, Effect.catchReasons, Effect.Console-based renderers, NodeResolver / StagedFiles / Git / Files / Project / Config / Linter / DeadCode / Score / Reporter / Progress services). See AGENTS.md → Effect v4 Conventions for the current shape.

@aidenybai aidenybai closed this May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants