- MUST: Use @antfu/ni. Use
nito install,nr SCRIPT_NAMEto run.nunto uninstall. - MUST: Use TypeScript interfaces over types.
- MUST: Keep all types in the global scope.
- MUST: Use arrow functions over function declarations
- MUST: Never comment unless absolutely necessary.
- If the code is a hack (like a setTimeout or potentially confusing code), it must be prefixed with // HACK: reason for hack
- MUST: Use kebab-case for files
- MUST: Use descriptive names for variables (avoid shorthands, or 1-2 character names).
- Example: for .map(), you can use
innerXinstead ofx - Example: instead of
movedusedidPositionChange
- Example: for .map(), you can use
- MUST: Frequently re-evaluate and refactor variable names to be more accurate and descriptive.
- MUST: Do not type cast ("as") unless absolutely necessary
- MUST: Remove unused code and don't repeat yourself.
- MUST: Always search the codebase, think of many solutions, then implement the most elegant solution.
- MUST: Put all magic numbers in
constants.tsusingSCREAMING_SNAKE_CASEwith unit suffixes (_MS,_PX). - MUST: Put small, focused utility functions in
utils/with one utility per file. - MUST: Use Boolean over !!.
- MUST: Never write changesets (
.changeset/*.md) or editCHANGELOG.md. Changesets and changelog entries are authored by humans at release time, not by agents.
packages/
core/ PRIVATE the diagnostic engine
src/
types/ PRIVATE shared cross-package TS types (DiagnoseOptions,
ProjectInfo, JsonReport, …) — no runtime code
project-info/ project discovery (discoverProject, findMonorepoRoot,
framework detection, narrow Error subclasses thrown
BEFORE the Effect runtime takes over)
errors.ts tagged Schema.TaggedErrorClass leaves + ReactDoctorError union
schemas.ts Diagnostic / Severity / JsonReport / buildDiagnosticIdentity
(also exposed as `@react-doctor/core/schemas` subpath
since the names overlap the TS types above)
refs.ts Context.Reference for ambient env config
paths.ts Schema.brand for OxlintBinaryPath / NodeBinaryPath
run-inspect.ts streaming orchestrator (the heart)
build-diagnostic-pipeline per-element filter pipeline (single source of truth)
services/ 10 Context.Service classes (Files, Git, Project,
Config, Linter, DeadCode, Score, Reporter, Progress,
NodeResolver, StagedFiles) + LintPartialFailures
... rest of the lint / score / suppression engine
api/ PRIVATE programmatic diagnose() (Effect.runPromise shell)
react-doctor/ PUBLISHED CLI + public inspect() + bin
oxlint-plugin-react-doctor/ PUBLISHED the 100+ rules, owns the canonical
`react-native-dependency-names.ts` (re-exported from
core to break the rule-package ↔ core cycle)
eslint-plugin-react-doctor/ PUBLISHED ESLint mirror of the oxlint plugin
website/ PRIVATE docs site
Built on effect@4.0.0-beta.70. See tmp/effect/.patterns/effect.md (cloned reference)
and ~/Developer/react-doctor-evals/src/ (the application that pioneered these patterns
for this codebase) for canonical examples.
- ALWAYS:
import * as Schema from "effect/Schema",import * as Effect from "effect/Effect",import * as Cause from "effect/Cause", etc. — one module per import line. - NEVER:
import { Schema, Effect } from "effect"— the umbrella import inflates the type-resolution graph and contradicts what every other Effect codebase does.
- Every fallible service fails with
ReactDoctorError(reason: Schema.Union([...])) - Each leaf is a
Schema.TaggedErrorClass<Self>()("Tag", { fields })with aget message()getter (NOTmessage =) returning a human string. - Opaque causes use
Cause.pretty(Cause.fail(this.cause))in the message body. - Renderers dispatch on
error.reason._tag, NEVER onerror.message.includes(...). formatReactDoctorError(error)/isReactDoctorError(error)/isSplittableReactDoctorError(error)live incore/src/errors.ts. Use them; don't add new error-shape helpers.
Effect.catchReasons(errorTag, cases, orElse?)— the v4-canonical way to dispatch on aSchema.TaggedErrorClassreason union. Each entry catches one reason_tag; the optionalorElsehandles unmatched reasons. NEVER write manualif (cause.reason instanceof X)ladders inside acatchblock — the Effect pipeline gives you exhaustive, type-safe narrowing for free. Seeinspect.ts → restoreLegacyThrowandapi/diagnose.tsfor the canonical shape.Effect.catchTag(tag, handler)— for a single tagged error (e.g.Effect.catchTag("PlatformError", ...)inservices/git.tsto fold theChildProcessplatform error into aReactDoctorError).Effect.catch(renamed from v3Effect.catchAll) — for catch-all.Effect.die(error)— promote a recovered value into a defect thatrunPromisere-throws unchanged. Used incatchReasonshandlers when the programmatic contract still wants the legacyErrorclass on the throw.- NEVER
try/catchinsideEffect.gen(v4 hard rule). Wrap the sync throw inEffect.try({ try, catch })and recover viaEffect.orElseSucceed/Effect.catchinstead. Seerender-summary.ts → printSummaryfor the canonical shape.
return yield* Effect.fail(...)— terminal effects (Effect.fail, Effect.interrupt, Effect.die) must bereturn yield*so TypeScript sees the unreachable-code property. Bareyield*of a terminal lets unreachable code accumulate after it. Seeservices/git.tsdiffSelectionfor examples.Effect.gen({ self: this }, function* () { ... })— v4 changed theself-bound form. The plainEffect.gen(function* () { ... })form is unchanged; only class-method generators bound tothisneed the options object.Effect.fnUntraced(function* () { ... })— prefer over a function whose body isEffect.genwhen the function is called many times per operation (hot path). Cuts tracing overhead. Not currently used in this codebase — Git invocations and inspect-pipeline calls run once per scan, not in a hot loop.
Context.Service<Self, Interface>()("react-doctor/Name", { make: ... })— short prefix in the identifier (matches react-doctor-evals'rde/Xshape).- Service method bodies use
Effect.fnUntracedfor hot paths,Effect.syncfor one-liners. Test layers + orchestration useEffect.gen. Effect.fn("Service.method")for non-trivial methods so they surface as named spans in OTel traces. Production cost is zero when no tracer layer is provided; withOtlp.layerJson(...)users see one span per service call. Canonical eval pattern (react-doctor-evals/src/Runner.ts→ every method).Service.of({ ... })everywhere insideLayer.succeed/make:— never{ ... } as const.Layer.effectwhen the service has init work (e.g.Cache.make);Layer.succeedwhen stateless.- Method takes a single object arg when there are >1 parameters
(e.g.
Files.readLines({ filePath, rootDirectory })).
layerNodefor the production Node.js implementation.layerOf(value)for the test layer that returns a pre-supplied value.layerInMemory(Map)for filesystem-shaped services backed by an in-memory tree.layerCapturefor the test layer that records calls into aRefexposed via a sibling*Captureservice (e.g.ReporterCapture,ProgressCapture).layerNoopfor the production layer that has void-return / discard semantics (Reporter, Progress). Analyzers (Linter, DeadCode) uselayerOf([])instead.layerComposite(backends)for the slot a future second backend plugs into.- Implementation-specific names:
layerOxlint,layerHttp,layerNdjson(path),layerOra(factory).
- Use
Schema.Class<Self>("Name")({ fields })for wire records. - Use
Schema.Literals(["a", "b"])for unions of literals (plural),Schema.Literal(1)for single literals. Schema.NullOr(X)forX | null;Schema.optional(X)forX?.Schema.brand("X")via.pipe()for branded primitives.- Schema for wire types (Diagnostic, JsonReport); interfaces for arg types (InspectInput, LintInput) — avoid runtime encode/decode cost on hot paths.
- Env-var reads + cache paths go through
Context.Reference<T>("react-doctor/X", { defaultValue }). Seecore/src/refs.ts. Tests override viaLayer.succeed(MyRef, ...). - Secrets (API tokens, signing keys) should prefer
Config.redacted("ENV_NAME")overContext.Referenceso they auto-redact in logs / traces. Group withConfig.all({ ... })at the service constructor when you need several. (Pattern fromreact-doctor-evals/src/GitHub.ts— not yet used in this codebase; document the convention so the first secret-shaped config does it right.)
- Wrap the top-level entry of a multi-step operation in
Effect.withSpan("name", { attributes }). Seecore/src/run-inspect.ts → runInspectfor the canonical shape. Attribute keys use dotted namespacing (inspect.directory,inspect.isCi). - Per-service-method spans come from
Effect.fn("Service.method")— see Services section above. The two compose:runInspectis the parent span, everyService.methodis a child. - Production observability layer is
layerOtlpincore/src/observability.ts(wired into bothinspect()anddiagnose()). It's a no-op unless the user sets BOTHREACT_DOCTOR_OTLP_ENDPOINT(e.g.https://api.axiom.co) andREACT_DOCTOR_OTLP_AUTH_HEADER(e.g.Bearer <token>) in the environment. When both are set, it providesOtlp.layerJson({...})fromeffect/unstable/observability/OtlpwithNodeHttpClient.layerUndicias the transport, so everyEffect.fn("Service.method")span and every top-levelEffect.withSpan("...")ships to the configured backend. Eval reference:react-doctor-evals/src/Observability.ts → layerAxiom.
- ALWAYS:
import * as Console from "effect/Console"andyield* Console.log(...)/Console.warn(...)/Console.error(...)from inside renderers, services, and any Effect-typed code. Effect'sConsoleis aContext.Referencewhose default sink isglobalThis.console, so the production path is identical to a rawconsole.logwhile remaining swappable for tests / silent mode. - NEVER: invent a parallel
Logger/LoggerWriterabstraction. The historical custom Logger service was removed when the renderer pipeline went Effect-typed; the only remaining bridge iscli/utils/cli-logger.ts, a thin sync wrapper aroundEffect.runSync(Console.X)for imperative CLI helpers that aren't yetEffect.gen. - Silent mode is
Effect.provideService(Console.Console, silentConsole)(renderer pipeline) orinstallSilentConsole()(JSON mode, which monkey-patches the global console because the surrounding CLI command body is imperative). Both routes leave the underlyingConsole.*Effect intact — there is noif (silent) returncheck at any call site.
Tests live alongside source in each package's tests/ directory:
packages/core/tests/— service tests + run-inspect orchestration testspackages/api/tests/— api shell testspackages/react-doctor/tests/— CLI + end-to-end fixture tests
Test framework is vite-plus/test (the existing vitest wrapper).
Run checks always before committing with:
pnpm test # all packages
pnpm lint
pnpm typecheck
pnpm format # use `format:check` to verify only
pnpm smoke:json-report # validates the built CLI's JSON output against the schematmp/effect/.patterns/effect.md— canonical Effect v4 idioms (cloned for reference, gitignored)~/Developer/react-doctor-evals/src/— sister application this codebase's runtime patterns are modeled on (Schemas.ts, Runner.ts, Worker.ts, errors.ts shapes)