- 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: Use
trufflerto find existing symbols before adding a utility, helper, type, or rule, and again after finishing a task to catch duplicates and dead code (see "Symbol Search & Deduplication"). - MUST: Always search the codebase, think of many solutions, then implement the most elegant solution.
- MUST: Before adding or changing the public surface (CLI flags/commands, the score, config, the JSON report, package APIs, the GitHub Action, website, or terminal output), run the
product-thinkingpass (.agents/skills/product-thinking/): name the user's job, reuse before adding, wire one telemetry metric, add the compatibility artifacts, and set a kill metric. Lint rules use the rule pipeline instead. - 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 !!.
@rayhanadev/truffler (dev dependency) is fuzzy JS/TS symbol search powered by oxc-parser.
Use it to avoid duplicating existing code. The find-similar-functions skill
(.agents/skills/find-similar-functions/) carries the full workflow; the short version:
- WHEN PLANNING / SCOPING — before adding a utility, helper, type, constant, or rule, search
for an existing symbol to reuse or extend. Derive a few queries from the behavior (proposed
name + domain noun + verb), search the narrowest root first, then read the top matches before
writing anything. It is how you reuse an existing helper instead of duplicating it, per "don't
repeat yourself" and the one-utility-per-file
utils/convention. - AFTER FINISHING A TASK — re-run searches for the symbols you added to confirm you did not duplicate an existing helper, and delete any code your change superseded.
bunx @rayhanadev/truffler "<query>" packages --kind function,method,interface,type,constant --limit 20Run it with bunx @rayhanadev/truffler (the published bin is a TypeScript entry Bun runs
directly, and the pinned dev dependency is reused rather than re-downloaded). Narrow <query>
and the root (e.g. packages/core/src) for precision; broaden only when nothing matches.
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
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. - Sentry tracing (CLI only). The published CLI bridges the same Effect spans
into Sentry.
cli/utils/apply-observability.tsis the single chooser of the tracer backend (Effect has oneTracerreference, so they're mutually exclusive): user OTLP wins (and the Effect trace is parented under the Sentry trace viaTracer.externalSpanfor a sharedtrace_id); otherwise, when Sentry performance tracing is live,cli/utils/sentry-tracer.ts(makeSentryTracer) materializes each Effect span as a child Sentry span under the per-run transaction (cli/utils/with-sentry-run-span.ts); otherwise the no-op native tracer. All of this is gated byisSentryTracingEnabled()so it's a true no-op for the@react-doctor/apilibrary,--no-score, tests, andSENTRY_TRACES_SAMPLE_RATE=0. Source maps are uploaded for symbolication byscripts/sentry-sourcemaps.mjs(Debug IDs); the SDKrelease(react-doctor@<version>) must match what that script uploads. - Sentry scope shape.
cli/utils/build-sentry-scope.tsis the one place that projects the run snapshot (and the scanned project, once known) into Sentrytags+contexts; bothinstrument.ts(initialScope) andreport-error.tsconsume it, so add new metadata there, not at call sites. Project info is captured in thebeforeLinthook viarecordSentryProjectContext(with-sentry-run-span.ts), which both remembers it for the lazy error path (a module-level ref read bybuildSentryScope, mirroring howbuildRunContextreads ambient state at capture time) and sets it as root-span attributes for the live transaction. - Sentry anonymization. Telemetry must stay anonymized.
Sentry.initsetssendDefaultPii: false, andbeforeSend+beforeSendTransactionboth runscrubSentryEvent(cli/utils/scrub-sentry-event.ts): it strips hostname/server_name/device name and the IP-bearinguser, drops captured stack-frame local variables, and runs every remaining string (messages, frames, contexts, extra, tags, breadcrumbs, and span attributes likeinspect.directory) throughscrubSensitivePaths(home dir / username →~, incli/utils/scrub-sensitive-text.ts) + core'sredactSensitiveText(secrets/emails).buildRunContextalso scrubscwd/argvat the source. If you add a new field to any Sentry event, confirm it carries no username, hostname, IP, secret, or absolute path — and prefer adding it throughbuildSentryScopeso the central scrub covers it.scrubSentryEventreturnsnullon any failure so an un-anonymized event is never sent. - Crash references + trace linkage.
reportErrorToSentryreturns the Sentry event id; the CLI catch blocks thread it intohandleErrorso it's printed as a user-quotable reference and added to the prefilled GitHub issue. Errors thrown during a scan are linked to the run transaction by capturing them with the run's trace as the scope's propagation context —withSentryRunSpanrecords the live trace inactive-run-trace.ts(cleared only on success, so the command catch — which runs after the span ends — can still read it), andreportErrorToSentryre-attaches viascope.setPropagationContext. - Sentry metrics (CLI only). Anonymized Application Metrics (counters +
distributions) are emitted through
cli/utils/record-metric.ts(recordCount/recordDistribution), each guarded so it's a true no-op unlessSentry.isInitialized()— inert for--no-score, tests, and the@react-doctor/apilibrary — and independent oftracesSampleRate(metrics flow even when tracing is off). Metric names live in theMETRICmap incli/utils/constants.ts(dotted, domain-grouped; high-cardinality dimensions go in attributes, never the name). The run snapshot — and, once a scan discovers it, the project shape — is merged onto every metric at emit time byrecord-metric.ts'swithRunAttributes, which reprojectsbuildSentryScope().tagsper emit. This mirrors how events rebuildbuildSentryScopelazily, so metrics track runtime state (--jsonmode, a workspace scan's project rolling over, the project clearing onresetSentryRunState) rather than a stale init-time snapshot, and the attributes pass throughbeforeSendMetricscrubbing like any other. Emit sites pass only metric-specific attributes; the project shape comes fromrecordSentryProjectContext→getSentryProjectInfo(). Per-scan metrics (scan.*,rule.fired,lint.failed, …) are emitted bycli/utils/record-scan-metrics.ts;rule.firedis one high-cardinality counter keyed byrule/plugin/category/severityattributes (never a metric-name-per-rule). Anonymization:Sentry.initsetsbeforeSendMetric: scrubSentryMetric(cli/utils/scrub-sentry-metric.ts), which drops theserver.addresshostname attribute (the SDK adds it to the metric before the hook, so the strip lands) and scrubs paths/secrets from attribute values via the sharedcli/utils/anonymize-text.ts(also used byscrubSentryEvent), returningnullto drop on failure. Add new counters throughrecord-metric.ts+ theMETRICmap, and confirm any new attribute carries no username, path, or secret. - Sentry canonical run wide event (CLI only). The richest telemetry is one
high-dimensionality "wide event" per scan, not a pile of narrow counters: the
per-run root span (
withSentryRunSpan) is enriched with the full outcome bycli/utils/build-run-event.ts(recordRunEvent, plus the pure, testablebuildRunEventAttributes).inspect.tscalls it on the success path (afterrecordScanMetrics) and, via atry/catcharound the span body, on the failure path — so the event lands with anoutcome.status(clean/ok/blocked/error),outcome.exitCode, andoutcome.errorTagtaxonomy even when the scan throws. The run + project base context is already on the span (run tags fromwithSentryRunSpan, project shape fromrecordSentryProjectContext), so the event adds only what those don't — every attribute namespaced by concept viawithNamespace(cli/utils/with-namespace.ts) so the keys tree up in Sentry's attribute browser: scan config (scan.mode,scan.parallel,scan.workerCount,scan.rulesConfigured/scan.rulesDisabled,scan.ignoredTagCount,scan.hasCustomConfig, … plus thescan.fileCountextent), the verdict (outcome.wouldBlock/outcome.blocking/outcome.clean/outcome.skippedChecks), findings (diag.total,diag.errors/diag.warnings,diag.affectedFiles,diag.distinctRules,diag.topRule, per-categorydiag.category.*),score.value/score.label/score.available, thelint.*/deadCode.*/supplyChain.*pass outcomes,timing.*durations, and the CI/PR specifics (action.actorAssociation,action.runnerOs, and the forwarded action knobsaction.comment/action.reviewComments/action.versionPin). Typing matters for querying: numeric outcomes are numbers (so Sentry can dop75(score.value)), dimensions are strings/bools (so they filter/group);nullis dropped viatoSpanAttributesso absent signals never become"null". Query it in Sentry's Trace Explorer and build Dashboard widgets on the Spans dataset (filter/group by any attribute) instead of pre-aggregating counters. Put new run-level dimensions onbuild-run-context.ts→build-sentry-scope.ts(now also theeventName+viaActiontags) so they ride every event and metric; put per-scan outcome dimensions on the wide event (wrapped inwithNamespace), not new counters (we deliberately did not addci.*counters — those dims are wide-event attributes; thescan.completed/scan.duration/rule.firedmetric counters stay as the cheap, trace-sampling-independent floor alongsidecli.invoked/cli.error). Score reachability is derivable (!score.available && !lint.failed && !deadCode.failed && !scan.noScore— failed passes null the score deliberately) and score latency is theScore.computechild span's duration, so neither needs a dedicated field. CI detection + the official-action marker and forwarded inputs live incli/utils/is-ci-environment.ts;action.ymlsets theREACT_DOCTOR_GITHUB_ACTIONmarker +REACT_DOCTOR_ACTION_*env on its scan step. All attributes pass throughscrubSentryEvent; keep them free of username, path, secret, and repo/owner identity. - runId.
cli/utils/run-id.tsmints one randomrunIdper CLI run (process). It rides the Sentryruncontext (and thus the wide event) but is never a tag or metric attribute — a per-run unique value there would explode tag/counter cardinality. A workspace invocation scanning several projects shares onerunId; the per-project span attributes disambiguate. Do not add a plaintext or hashed repo id to Sentry.
- 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 schema- MUST: Never merge a Changesets release PR, including any
changeset-release/*branch, without fresh, explicit user confirmation for that exact PR and version immediately before the merge. - General instructions to merge, ship, land, or babysit green PRs do not authorize merging a release/version PR. Treat merging a PR that triggers publication as publishing the release.
- MUST: Never publish packages, push or move release tags, or trigger, approve, rerun, or merge a release/publish workflow without fresh, explicit user confirmation for the exact versions and packages involved.
- Agents may prepare, validate, and babysit a release candidate, but must stop before the first publishing action and report the exact PR, versions, packages, tags, and workflows awaiting user approval.
- These confirmation requirements also apply to GitHub Action releases described below. Once the user explicitly approves a specific release, follow all required versioning and tag steps.
The composite GitHub Action is versioned independently from the npm packages. "The action"
is action.yml (repo root) plus the scripts it shells out to (scripts/ensure-json-report.mjs,
scripts/normalize-changed-files.mjs, scripts/render-github-action-comment.mjs,
scripts/resolve-package-spec.mjs). Treat a change to any of those files as an action release,
and keep the list in sync with ACTION_RELEASE_FILES in
scripts/recommend-action-version-bump.mjs (the release guard).
- Two tag namespaces coexist — never conflate them:
- npm packages —
react-doctor@X.Y.Z,eslint-plugin-react-doctor@X.Y.Z,oxlint-plugin-react-doctor@X.Y.Z(created by Changesets in CI; see.github/workflows/publish.yml). - GitHub Action —
v-prefixed semvervX.Y.Zplus a floating majorvN(the GitHub Actions convention; thevprefix keeps these distinct from the unprefixed package tags above). Current: thev2.xline (checkgit tag --list 'v*'for the latest);v2→ the same commit. Thev0.xline is the pre-rebuild action; thef4035fcePR-reporting rebuild isv1.0.0.
- npm packages —
- MUST: cut a tag on every commit that touches the action files.
feat(action)→ minor bump; everything else (fix/refactor/chore/revert/ docs-only edits toaction.yml) → patch bump. A breaking change to inputs/outputs or the runtime contract → major bump. - MUST: after tagging a new
vX.Y.Z, move the floating majorvNto that same commit souses: millionco/react-doctor@vNkeeps resolving to the latest compatible release. - Tags are GPG-signed annotated tags (
tag.gpgsign=true), so a baregit tag vXwill demand a message and fail in scripts. Always create/move with an explicit message:
# new release at the commit that changed the action
git tag -a v2.2.3 <commit> -m "react-doctor action v2.2.3"
# move the floating major (force-update only the vN pointer)
git tag -fa v2 <commit> -m "react-doctor action v2 (floating major -> v2.2.3)"
git push origin v2.2.3
git push --force origin v2 # the force applies to the moving major tag only- MUST: never tell consumers to reference
@mainin docs/examples.@mainruns whatever HEAD points to withpull-requests: writegranted — a supply-chain risk (issue #299). Recommend a full commit-SHA pin with a trailing version comment for hardened CI (uses: millionco/react-doctor@<sha> # v2.2.2), or@vNfor convenience.
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)