mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
1f9ec2b1f8
Deep study of ~/Developer/react-doctor-evals against our current
architecture. The eval pioneered the Effect v4 patterns this repo
is modeled on; this PR brings over the lasting structural and
observability wins.
### Lasting docs (the main deliverable)
- **`.specs/inspect-pipeline.md`** — full spec for `runInspect`, the
streaming orchestrator. Documents the 9-service wiring, the Stream
shape, the failure-folding pattern (Ref + Stream.catchTag), the
layer-stack override knobs (`--offline` / `--no-lint` / etc.), the
per-element pipeline, surface filtering, hooks vs services, the
three-layer call story (`inspect()` → `diagnose()` → `runInspect`),
and the playbook for adding a new diagnostic source or lint backend.
Mirrors the eval's `.specs/github-app.md` format — design constraints
→ module map → numbered sections → locked decisions → open follow-ups.
- **`CONTEXT.md`** — domain glossary. Diagnostic, InspectOutput, Service,
Layer stack, per-element pipeline, surface filter, mode, score,
composite action, ReactDoctorError vs legacy thrown errors, Context.Reference,
branded paths. Pairs with AGENTS.md (which is conventions) and
`.specs/inspect-pipeline.md` (which is the deepest dive).
### `Effect.fn("Service.method")` for free observability
Every non-trivial service-method body is now wrapped in
`Effect.fn("Service.method")` — the canonical eval pattern from
`react-doctor-evals/src/Runner.ts` and `WorkerPool.ts`. Production
cost is zero when no tracer layer is provided. With
`Otlp.layerJson(...)` from `effect/unstable/observability/Otlp`
(eval ref: `Observability.ts → layerAxiom`), users see one named
span per service call.
Wrapped today:
- `Project.discover` (was `Effect.try`)
- `Config.resolve` (was direct `Cache.get`)
- `DeadCode.run` (was `Stream.unwrap(Effect.tryPromise)`)
- `Linter.run` (was `Stream.unwrap(Effect.gen)`)
- `Score.compute` (was `Effect.promise`)
- `runInspect` itself, via `Effect.withSpan("runInspect", { attributes })`
so the orchestrator is the parent of the per-service-method spans.
### AGENTS.md updates
- New `Effect.fn("Service.method")` rule in the Services section.
- New `Config.redacted` guidance for secret-shaped env vars (will land
with the first secret-shaped config; the pattern is documented now
so it lands correctly).
- New `Observability` subsection documenting `Effect.withSpan` +
`Effect.fn` composition, attribute-key naming convention, and the
`Otlp.layerJson` opt-in path.
### Not in this PR (tracked for follow-up)
- **Migrate services to canonical `make:` pattern**: the eval uses
`Context.Service<Self>()("name", { make: Effect.gen(...) }) {
static readonly layer = Layer.effect(this, this.make)
}` while we use the shape-in-type-param form
`Context.Service<Self, Shape>()("name")` + Layer.effect outside.
Both are valid v4; the eval's form is more idiomatic for services
that have init work. Defer the wholesale conversion — the current
shape works and the refactor would churn every service file.
- **Adopt `Layer.mock(Service, { method })` in tests**: cleaner test
layers than hand-rolled `Layer.succeed(Service, Service.of({ ... }))`.
Eval pattern from `react-doctor-evals/src/Runner.ts → layerTest`.
- **`Effect.Command` from `effect/unstable/cli`** to replace commander.
Bigger refactor; tracked for the v0.3 CLI overhaul.
- **`Otlp.layerJson` end-to-end**: the spans are now in place, but
no actual exporter layer is wired in production. End users that
want traces would add `Otlp.layerJson({ baseUrl, resource, headers })`
+ `NodeHttpClient.layerUndici` themselves. Document the snippet
in the README when there's demand.
Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
12 KiB
General Rules
- 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.
Package Layout
packages/
core/ PRIVATE the diagnostic engine
src/
errors.ts tagged Schema.TaggedErrorClass leaves + ReactDoctorError union
schemas.ts Diagnostic / Severity / JsonReport / buildDiagnosticIdentity
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)
project-info/ PRIVATE legacy React project discovery (still separate
from core to avoid a circular dep with oxlint-plugin
over the shared isReactNativeDependencyName helper —
see TODOS.md for the planned consolidation)
types/ PRIVATE shared cross-package type interfaces +
React Native dependency name constants
react-doctor/ PUBLISHED CLI + public inspect() + bin
oxlint-plugin-react-doctor/ PUBLISHED the 100+ rules
eslint-plugin-react-doctor/ PUBLISHED ESLint mirror of the oxlint plugin
website/ PRIVATE docs site
Effect v4 Conventions
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.
Imports
- 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.
Errors
- 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.
Error dispatch / recovery — v4 idioms
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.
Generator hygiene
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.
Services
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 })).
Layer naming
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).
Schemas
- 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.
Ambient config
- 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.)
Observability
- 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 (when wired):
Otlp.layerJson({ baseUrl, resource, headers })fromeffect/unstable/observability/Otlp+NodeHttpClient.layerUndici. Eval reference:react-doctor-evals/src/Observability.ts → layerAxiom. Not currently wired in react-doctor; the spans are in place so it's a one-liner when an end user opts in.
Console / logging
- 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.
Testing
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 schema
Reference reading
tmp/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)