* feat(core): add Axiom OTLP transport and an anonymizing tracer
Adds the export path for shipping traces and metrics to Axiom, plus the
anonymization guarantee that has to come with it. Nothing emits yet — the CLI
still routes telemetry to Sentry; this is the engine-side groundwork.
`layerAxiomTraces` and `layerAxiomMetrics` are composed by hand rather than
through `Otlp.layer`, because Axiom routes each signal to a different dataset
via a different header and `Otlp.layer` passes one `headers` object to every
signal. Serialization is protobuf, not JSON: Axiom's `/v1/metrics` accepts
`application/x-protobuf` only. `OtlpSerialization.layerProtobuf` ships with
Effect, so this adds no dependency.
They are separate layers on purpose. Effect tracks delta-temporality state on
the metrics exporter instance, so a second exporter in one process starts with
no previous snapshot and re-reports every counter's full value — doubling every
metric. `telemetry-payload.test.ts` pins that behavior so a future refactor that
merges them fails loudly instead of silently doubling data.
OTLP has no `beforeSend` hook, so Sentry's central scrubber has no equivalent.
`makeScrubbingTracer` wraps the tracer and runs every span name, attribute, and
event payload through `anonymizeText` on the way to the exporter, and is applied
inside `layerAxiomTraces` so an attribute added later cannot bypass it.
`anonymize-text.ts` moves to core (both its scrubbers already lived there) and
`run-inspect.ts` now scrubs `inspect.directory` at the source rather than relying
on the backstop.
`TELEMETRY_SHUTDOWN_TIMEOUT_MS` is 1s, tighter than Sentry's 2s error flush: the
metrics exporter passes `maxBatchSize: "disabled"` internally, which skips
Effect's empty-buffer short-circuit, so it POSTs on every scope close whether or
not anything was recorded. On a firewalled machine that request cannot fail fast,
and unlike the Sentry flush it would run on every scan rather than only on crashes.
Verified against a local collector: correct per-signal datasets and protobuf
content type, and no home directory, username, or secret reaches the wire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(core): close three review findings in the Axiom transport
Scrub the failure a span ended with. The OTLP tracer runs `Cause.prettyErrors`
over the exit and exports each result as `exception.type` / `exception.message` /
`exception.stacktrace`. None of those go through `span.attribute`, so they
bypassed the scrubbing tracer entirely — and stack traces are full of absolute
paths, meaning any failing scan shipped the user's home directory. This was the
one route into the payload the backstop didn't cover. Interrupt-only causes are
left alone; the exporter emits a fixed label for them and never touches the
error text.
Honor `exportIntervalMs` for traces. It was threaded through to the metrics
exporter but not the tracer, so a long-lived process — the language server —
would hold spans for the full ten-minute default no matter what interval it
asked for.
Trim trailing slashes without a regex. `/\/+$/` backtracks quadratically on a
domain made mostly of slashes, which CodeQL flags as a polynomial-regex denial
of service because the value comes from the caller.
Also corrects a doc comment that still described metrics as being built at exit,
which stopped being true when the CLI moved to a single shared scope.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: add AI training restriction to license
Adds a no-training clause to prevent use of the source code as training,
fine-tuning, or evaluation data for ML models without prior written
permission. Academic researchers can request a no-cost exemption at
founders@million.dev.
Also updates the title to reflect the additional restrictions, and removes
the "outputs" language (legally overreaching — users own output from their
own data).
* chore: simplify training restriction to uniform written-permission requirement
* feat(cli): warn when react-doctor is run inside an AI/ML training pipeline
Detects presence-based env vars for HuggingFace, CUDA/GPU, experiment
trackers (W&B, MLflow, Comet, Neptune), Ray workers, RL simulators
(MuJoCo, Gymnasium), cloud ML platforms (SageMaker, Azure ML, Vertex AI),
AI agent sandboxes (Daytona, E2B, Modal, RunPod, Harbor), and coding-agent
eval harnesses (SWE-bench, SWE-agent). Prints a warning to stderr pointing
to founders@million.dev for written permission.
* feat(cli): expand CI provider and AI/ML training environment detection
CI providers (+18): Semaphore, AppVeyor, Harness, Buddy, Codefresh,
Netlify, Railway, Vercel, Codemagic, Prow, Agola, Cirrus CI, Render,
Fly.io, Blacksmith, WarpBuild, Namespace, Ubicloud
ML/AI training (+21): Kaggle, Google Colab, Databricks, SageMaker
(SM_TRAINING_ENV + TRAINING_JOB_ARN), Azure ML (AZUREML_RUN_ID),
Vertex AI (CLOUD_ML_PROJECT_ID), W&B sweeps, DVC stages, ClearML,
Flyte, Determined AI, Lightning AI, Argo Workflows, Kubeflow Pipelines,
HuggingFace Spaces, Replicate, Vast.ai, Google TPU, ROCm
Coding agents (+3): Cline, Augment, Trae AI
* feat(api,packaging): add AI training warning to programmatic API; fix license labels
- Adds AI/ML environment detection to diagnose() (both single-dir and
batch forms) so programmatic users (e.g. CodeRabbit integrations) see
the same license warning as CLI users. Warns once per process via a
module-level guard. Kept inline to avoid a CLI→API dep.
- Changes all package.json "license" fields from "MIT" to
"SEE LICENSE IN LICENSE" so automated scanners (Fossa, Snyk,
TLDR-Legal) report the actual terms instead of plain MIT.
* feat(website): add /license page with commercial contact CTA
* feat(core): add Socket.dev supply-chain score check
New SupplyChain service + checkSupplyChain that score each direct dependency against Socket's free, keyless PURL endpoint (the same lookup sfw's free tier uses) and emit a Security diagnostic for any package below the configured minScore. Implemented with Effect (Effect.forEach bounded concurrency, Effect.tryPromise + Effect.timeout for cancellable fetches, fail-open per package).
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat: wire SupplyChain into the inspect pipeline
Run the supply-chain check as a scan phase in runInspect (skipped in diff/staged mode), and provide its layer at every composition site: gated on supplyChain.enabled in the CLI (build-runtime-layers) and api (diagnose), and a no-op layerOf([]) for editor scans and the run-inspect tests.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* chore: add changeset for Socket supply-chain score check
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat: run Socket supply-chain check by default
Flip the default so the supply-chain score gate runs out of the box; users opt out with `supplyChain: { enabled: false }`. Editor scans and --diff/--staged runs remain off regardless.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat(cli): add --sfw demo flag to list Socket scores for all dependencies
New collectSupplyChainScores core helper fetches the Socket score of every direct dependency (reusing the free-tier fetch internals). The CLI's --sfw flag short-circuits before the scan, prints a color-coded worst-first table via renderSupplyChainScores, and exits. Mutually exclusive with --json/--score.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* fix(core): anchor supply-chain diagnostic to the declared package.json entry
The diagnostic now points at the exact line/column where the dependency is declared in package.json (instead of line 0) and names the declared spec plus the entry to edit, so a finding like next@16.2.4 traces directly to its `"next": "16.2.4"` entry. Records the declared spec + key location while collecting dependencies.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* fix(cli): drop the free-API subtitle from --sfw header
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat(cli): scan every workspace package.json for --sfw in a monorepo
collectSupplyChainScores now aggregates direct dependencies across the workspace root and every workspace package (pnpm/npm/yarn/nx globs via the shared project-info resolvers), deduped by name@version and anchored at the true monorepo root even when invoked from a sub-package. The in-scan check stays per-project (the scan loop already visits each workspace).
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat(core): exclude next from the supply-chain check
next's framework-specific risks are already covered by react-doctor's Next.js / server-components rules, so a low Socket score is redundant noise. Add a SUPPLY_CHAIN_IGNORED_PACKAGES set and filter it in the shared dependency collector so both the gate and the --sfw listing skip it.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* refactor(core): drop unused breakdown field and trim Socket artifact schema
The --sfw renderer only reads name/version/overall, so the per-axis breakdown on DependencyScore was speculative dead code — remove it and its construction. Also trim SocketArtifactSchema to the only field read (score); Schema.Struct already ignores the artifact's other fields.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* fix(core): anchor supply-chain diagnostic within its dependency section
locateDependencyKey previously returned the first `"name":` match anywhere in package.json, so a same-named entry under overrides / resolutions / pnpm.overrides could win over the real dependencies/devDependencies line. Scope the search to the declaring section via its `"<section>": {` header + brace-depth tracking. Fixes Cursor Bugbot finding.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* refactor(api): derive supplyChain enablement from config in buildDiagnoseLayer
Drop the standalone supplyChainEnabled parameter; buildDiagnoseLayer now takes the effective config (and optional override directory), builds the config layer from it, and reads supplyChain.enabled from that same config. One config input drives both the Config service and the supply-chain gate.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat(core): emit a per-package OTel span for each Socket score lookup
Wrap fetchSocketScore in a SupplyChain.fetchScore span carrying socket.package / socket.version / socket.purl, and annotate the resolved axis scores (socket.score.overall + one per SCORE_AXES dimension, 0-100) plus socket.scored. Lets a trace backend group by package and query score distributions across a scan. No-op without a tracer.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* refactor(core): inline buildScoreSpanAttributes into its single call site
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat(core): run supply-chain in diff mode when the manifest changed
Add supplyChainManifestChanged to InspectInput/InspectOptions and gate the supply-chain phase on (!isDiffMode || supplyChainManifestChanged), so a diff/PR scan that edits package.json still scores its dependencies instead of skipping the check like the other whole-project passes.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat(cli): scan a workspace whose package.json changed in a PR diff
Detect per-project package.json changes (projectManifestChanged, monorepo-aware via the same prefix logic as the include-path resolver). A manifest-only-changed workspace is no longer skipped from the diff loop: it enters the scan with the manifest as the sole include (stays in diff mode, lints nothing) and supplyChainManifestChanged set, so its dependencies get scored. Threaded the flag through the inspect wrapper + scan-result cache key.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* refactor(cli): share project-relative-directory boundary logic across diff helpers
projectManifestChanged duplicated resolveProjectDiffIncludePaths's project-relative + ..-boundary handling. Extract resolveProjectRelativeDirectory and repoint both so the monorepo prefix rule lives in one place and can't drift.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* docs(changeset): reflect diff-mode manifest scoring, monorepo --sfw, and next exclusion
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* fix(cli): make --sfw exclusive with diff/staged and score the base manifest in baseline mode
Bugbot triage: (1) --sfw now errors when combined with --staged/--diff (was silently ignored). (2) Baseline diff scans now forward supplyChainManifestChanged to the base pass and include package.json in the diff include set, so the base manifest is scored and computeDiagnosticDelta filters pre-existing low-score deps instead of reporting them as newly introduced. Verified e2e: pre-existing low dep filtered, newly-added one reported.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
* feat(core): list all axis scores in the supply-chain diagnostic message
Replace the single 'Lowest area' summary with the full per-axis breakdown (supply chain / vulnerability / maintenance / quality / license, 0-100) so the message shows exactly which dimensions dragged the score down. Drops the now-redundant findLowestAxis.
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Nisarg Patel <NisargIO@users.noreply.github.com>
- Single object arg (`DiagnoseProjectsInput`) instead of two positional args
- "Projects" vocabulary matching the rest of the codebase
- Per-project scan options (`deadCode`, `lint`, etc.) flat on the entry
instead of nested in `config.deadCode`
- `config` on a project entry means `ReactDoctorConfig` directly
- Discriminated union `ProjectResult` (`ok: true | false`) instead of
separate `modules` + `errors` arrays
api/diagnose.ts had a private `buildLayerStack()` that constructed
the exact same Layer.mergeAll as core's already-exported
`layerInspectLive`. Two consumers of the same set with no
divergence.
Drop the local rebuild and import `layerInspectLive` directly.
Removes 9 lines of inline service plumbing + 9 redundant imports
from the api package (Config, DeadCode, Files, Git, Linter,
LintPartialFailures, Progress, Project, Reporter, Score).
Any future change to the default production layer stack (e.g. adding
a new service to runInspect's requirements) now propagates to both
api and the core's documented default automatically.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
The Progress service (core/src/services/progress.ts) defined layerOra,
layerNoop, and layerCapture but the CLI bypassed it: spinner state lived
at module scope (setSpinnerSilent), spinner handles flowed through
runInspect's hooks via Ref<SpinnerHandle | null>, and the dead-code
spinner ran post-orchestrator in the CLI shell.
Finish the migration:
- runInspect now requires Progress and owns the lint + dead-code
spinner lifecycle. Each phase is a separate Stream.runCollect so
the progress lifecycle can render a per-phase status line; the
per-element pipeline (auto-suppress / severity / ignore / inline /
rn-no-raw-text suppression / reporter.emit) applies uniformly to
env + lint + dead-code streams.
- CLI provides Progress.layerOra wired to the existing ora-backed
spinner() helper, or Progress.layerNoop for --silent / --score-only /
lint-skipped runs.
- api provides Progress.layerNoop (programmatic callers don't want
stderr frames).
- Drop the Ref<SpinnerHandle | null> + finalSpinnerHandle plumbing
from react-doctor/inspect.ts. The CLI's post-orchestrator code only
needs the supplementary 'upgrade Node X' / failure-reason hint
lines now.
- Drop the post-orchestrator dead-code spinner — the orchestrator
renders it via Progress.
- run-inspect.test.ts layer stacks updated with Progress.layerNoop.
setSpinnerSilent / isSpinnerSilent remain in cli/utils/spinner.ts
because printProjectDetection still calls spinner() directly for its
'static checklist' lines (Detected framework, etc.). That callsite
isn't going through the Progress service yet; a follow-up can migrate
it.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
The config-redirect + project-discovery dance (loadConfigWithSource +
resolveConfigRootDir + resolveDiagnoseTarget) was duplicated in three
places: api/diagnose.ts, react-doctor/inspect.ts, and the CLI's
cli/commands/inspect.ts. Each shell loaded its own config and then
the orchestrator's Config.layerNode loaded it again via its own cache.
Centralize the entry-point translation:
- New core/src/resolve-scan-target.ts exports resolveScanTarget() that
loads the config, honors rootDir, and walks into a nested React
subproject (raising AmbiguousProjectError as before).
- All three shells call it once at the top. The CLI command shell
surfaces `didRedirectViaRootDir` directly instead of comparing
pointers.
The legacy thrown-class round-trip (plain Error -> tagged
ReactDoctorError -> plain Error) was also duplicated verbatim in
inspect.ts and api/diagnose.ts via two identical restoreLegacyThrow
closures.
Centralize the boundary:
- Move restoreLegacyThrow into core/src/errors.ts. Both shells import
it.
- The orElse clause now re-dies the ReactDoctorError instance itself
(instead of `new Error(error.message)`). Legacy grep-stderr
callers still see the same `error.message`; advanced callers can
now narrow on `error.reason._tag` without losing the tagged
shape across the api/CLI boundary.
api dist shrank from 3.82 kB -> 2.34 kB (39% smaller).
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Adds `layerOtlp` in `core/src/observability.ts` and provides it to
both `inspect()` (CLI) and `diagnose()` (programmatic API). The
layer is a strict no-op unless BOTH env vars are set:
REACT_DOCTOR_OTLP_ENDPOINT e.g. https://api.axiom.co
REACT_DOCTOR_OTLP_AUTH_HEADER e.g. Bearer <token>
When configured, it provides `Otlp.layerJson({...})` from
`effect/unstable/observability/Otlp` with
`@effect/platform-node/NodeHttpClient.layerUndici` as the
transport. Every existing `Effect.fn("Service.method")` span and
the top-level `Effect.withSpan("runInspect", ...)` ship to the
configured backend without any further wiring.
Mirrors the canonical eval pattern from
`react-doctor-evals/src/Observability.ts → layerAxiom`. Closes the
"Bigger bet #7" item from the prioritized-improvements list.
**No behavior change for default users** — the layer drops to
`Layer.empty` when env vars are absent, so the unconfigured path
has zero network traffic and zero new dependencies at runtime.
Tests cover the no-op path. AGENTS.md and the CLI README document
the opt-in.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: collapse @react-doctor/types and @react-doctor/project-info into core
Eliminates two private workspace packages whose only role was to
namespace shared TS types and project-discovery helpers — neither
was a real consumer boundary, and the indirection cost a `workspace:*`
edge per leaf package without saving any code.
**Layout**
- `packages/types/` → `packages/core/src/types/` (10 files, pure TS types)
- `packages/project-info/` → `packages/core/src/project-info/`
(27 files, discoverProject + framework detection + monorepo helpers)
- `packages/types/src/react-native-dependency-names.ts` →
`packages/oxlint-plugin-react-doctor/src/react-native-dependency-names.ts`
(the canonical RN-aware-package list lives next to its heaviest
consumer — the rule gate — and is re-exported from core to avoid
the rule-plugin ↔ core dependency cycle the previous types package
was specifically designed to break).
The two packages are deleted from the workspace; every consumer
(`react-doctor`, `api`, `oxlint-plugin-react-doctor`, `actions/review`,
+130 source/test files) now imports from `@react-doctor/core` directly.
**Errors cleanup**
Dropped the legacy plain-class `ReactDoctorError` and `isReactDoctorError`
from `project-info`. The four narrow errors (`ProjectNotFoundError`,
`NoReactDependencyError`, `PackageJsonNotFoundError`,
`AmbiguousProjectError`) now extend `Error` directly — the only valid
hierarchy is the tagged `Schema.TaggedErrorClass` `ReactDoctorError`
from `core/errors.ts`, which has been the canonical one since the
Effect v4 refactor. Added `isProjectDiscoveryError` as a narrow type
guard for the four leaves.
The discovery errors are thrown synchronously BEFORE the Effect
runtime takes over (at `diagnose()` / CLI entry points); inside the
runtime, the Project service's `translateProjectInfoError` converts
each one to its tagged-reason equivalent.
**Schema vs type name collisions**
`schemas.ts` exports `Diagnostic` / `JsonReport` / `JsonReportMode` /
… as Schema.Class values, while `types/` exports the same names as
TS types. The core barrel re-exports the TS-type versions
(broad-strokes API) and exposes the Schema classes via a new
`@react-doctor/core/schemas` subpath export for the two callers
that need them (`Reporter` service, smoke validator, schema tests).
**Validation**
- `pnpm typecheck` ✓
- `pnpm lint` ✓
- `pnpm format` ✓
- `pnpm test` ✓ (1449 passing, 3 skipped — no regressions)
- `pnpm smoke:json-report` ✓
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(core): keep RN-dependency constants out of types barrel to avoid pulling oxlint plugin into discovery
Bugbot caught (medium severity): re-exporting `isReactNativeDependencyName` from
`oxlint-plugin-react-doctor` via the core types barrel meant every consumer of
`@react-doctor/core` (including `discoverProject` / `discoverReactSubprojects`)
would load the entire 286-rule plugin at module-init.
Adds a tiny standalone leaf at `core/src/project-info/internal-rn-dependency-names.ts`
(zero imports, just the constants the workspace-discovery gate needs) and switches
`is-package-json-react-native-aware.ts` to use it. The types barrel no longer
re-exports the plugin-side constants — rule consumers import them directly from
`oxlint-plugin-react-doctor`.
The duplication is intentional and documented at both files; the two lists are
short enough that a future regression test in the plugin can observe the union.
Validated: typecheck + test (1449 pass) + lint + format all green.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Deep audit against the Effect v4 patterns documented in
\`tmp/effect/.patterns/\` and \`tmp/effect/migration/\` (the cloned
reference repo). Three real v3-style holdovers fixed:
### 1. Manual instanceof reason dispatch → Effect.catchReasons
\`get-diff-files.ts\`, \`api/diagnose.ts\`, and \`inspect.ts\`'s
\`restoreLegacyThrow\` all had imperative \`switch (reason._tag)\`
ladders behind an \`if (cause instanceof ReactDoctorError)\` JS
catch block. v4 ships \`Effect.catchReasons(errorTag, cases, orElse?)\`
exactly for this — exhaustive, type-safe narrowing of a
\`Schema.TaggedErrorClass\` reason union, no \`instanceof\` checks.
- \`getDiffInfo\` →
\`.pipe(Effect.catchReasons("ReactDoctorError", { GitBaseBranchInvalid, GitBaseBranchMissing }))\`
- \`diagnose\` + \`inspect\`'s legacy translation →
\`.pipe(Effect.catchReasons("ReactDoctorError", { NoReactDependency, ProjectNotFound, AmbiguousProject }, orElse))\`
The \`orElse\` branch preserves the legacy "any other tagged
reason throws as a plain Error with the tagged-class message
string" contract that grep-stderr callers depend on.
The recovered branches use \`Effect.die(new LegacyClass(...))\` so
the thrown class lands on \`Effect.runPromise\`'s reject channel
unchanged.
### 2. try/catch inside Effect.gen → Effect.try + Effect.orElseSucceed
\`render-summary.ts\`'s "write the dump, swallow if it fails"
block had a literal \`try { ... yield* Console.log(...) } catch {}\`
inside an \`Effect.gen\` — a hard v4 anti-pattern (the catch only
runs for sync throws, never for Effect failures, so the shape is
misleading and the v4 patterns doc forbids it).
Replaced with:
Effect.try({ try: () => writeDiagnosticsDirectory(...), catch: (e) => e })
.pipe(Effect.orElseSucceed(() => null))
### 3. AGENTS.md updates
New \`Error dispatch / recovery — v4 idioms\` and \`Generator hygiene\`
subsections in the Effect v4 Conventions block. Documents:
- When to use \`catchReasons\` / \`catchTag\` / \`catch\` / \`die\`
- Hard rule: NEVER \`try/catch\` inside \`Effect.gen\` — use
\`Effect.try\` and \`Effect.orElseSucceed\` / \`Effect.catch\` instead
- \`return yield* Effect.fail(...)\` for terminal effects
- When \`Effect.fnUntraced\` is justified (hot paths only; not
currently needed in this codebase)
### Misc
- Removed now-unused \`isReactDoctorError\` + \`InspectOutput\`
imports from \`inspect.ts\`.
- Removed unused \`ReactDoctorError\` import from \`api/diagnose.ts\`
(catchReasons does the narrowing).
Co-authored-by: Cursor <cursoragent@cursor.com>
Self-audit after #419 documented the patterns. Caught and fixed six
issues I introduced across #414 and #417 that contradict the rules
the AGENTS.md update codified.
## P0 — AGENTS.md violations
(1) packages/react-doctor/src/inspect.ts:2 used the umbrella import
'from "effect"' — replace with individual 'import * as Effect /
Layer / Ref from "effect/X"' lines. AGENTS.md "ALWAYS: import * as
X from 'effect/X'" rule.
(2) inspect.ts had three '[...output.diagnostics] as Diagnostic[]'
casts that aren't needed — spreading a ReadonlyArray<Diagnostic>
already gives Diagnostic[]. AGENTS.md "MUST: Do not type cast 'as'
unless absolutely necessary" rule.
(3) inspect.ts catch had two consecutive identical predicates:
if (cause instanceof ReactDoctorError) restoreLegacyThrow(cause);
if (isReactDoctorError(cause)) restoreLegacyThrow(cause);
isReactDoctorError IS instanceof ReactDoctorError; the second is
dead code (restoreLegacyThrow always throws so the first match
exits). Drop the duplicate.
## P1 — dead code / unnecessary casts
(4) packages/api/src/diagnose.ts had a try/catch around
resolveDiagnoseTarget where every branch re-threw cause unchanged:
try { ... } catch (cause) {
if (cause instanceof AmbiguousProjectError) throw cause;
if (cause instanceof PackageJsonNotFoundError) throw cause;
throw cause;
}
The if-statements were no-ops. Drop the wrapper + the unused
PackageJsonNotFoundError import.
(5) packages/core/src/run-inspect.ts had two 'Stream.empty as
Stream.Stream<Diagnostic, never>' casts. Stream.empty is
Stream<never> which IS assignable to Stream<Diagnostic, never> via
subtyping; the cast was a TS-inference workaround. Replace with a
single typed local 'const emptyDiagnosticStream:
Stream.Stream<Diagnostic, never> = Stream.empty' shared across
both call sites.
## Other
(6) inspect.ts Score HACK comment was misleading — claimed the
orchestrator's Score would see the 'FULL diagnostic list', but it
actually sees the per-element-filtered list (just not score-surface-
filtered). Updated the comment to match reality. Also dropped the
redundant spread in 'lintPartialFailures: [...output.lintPartialFailures]'
(input is already ReadonlyArray<string>).
## Validation
- pnpm typecheck (12/12)
- pnpm test — 123 files / 1485 pass / 3 skipped
- pnpm lint, format:check
- pnpm build (8/8)
- pnpm smoke:json-report — schema-valid v1
- grep 'as Diagnostic|as Stream\.|from "effect"' across packages/
src — zero hits
Co-authored-by: Cursor <cursoragent@cursor.com>
Standing up packages/api/ as the home of the programmatic public
API. Moves diagnose() into it as a thin Effect.runPromise shell
around #412's runInspect orchestrator, with tagged-error translation
back to legacy thrown classes (NoReactDependencyError /
ProjectNotFoundError / AmbiguousProjectError) for backwards compat.
inspect() stays in react-doctor/src/ for now (CLI rendering coupled
in). PR 6 (cli package) moves it.
## Files
- packages/api/ (private workspace package)
- src/diagnose.ts: pre-resolves rootDir redirect + resolveDiagnoseTarget,
then runInspect, translates tagged failures, returns DiagnoseResult.
- src/index.ts: re-exports diagnose + public types + legacy errors.
- tests/diagnose.test.ts: 4 tests — happy path, NoReactDependency,
ProjectNotFound, elapsedMilliseconds positive.
- packages/react-doctor/src/index.ts: deletes ~120-line local diagnose,
re-exports from @react-doctor/api.
- packages/core/src/run-inspect.ts: rejects projects without React via
tagged NoReactDependency (used to happen in the legacy diagnose).
## Validation
- pnpm typecheck (12/12)
- pnpm test — 123 files / 1485 pass / 3 skipped
- pnpm lint, format:check, build, smoke:json-report — all green
Co-authored-by: Cursor <cursoragent@cursor.com>