{"owner":"millionco","repo":"react-doctor","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md","CLAUDE.md"],"skills":{"AGENTS.md":"## General Rules\n\n- MUST: Use @antfu/ni. Use `ni` to install, `nr SCRIPT_NAME` to run. `nun` to uninstall.\n- MUST: Use TypeScript interfaces over types.\n- MUST: Keep all types in the global scope.\n- MUST: Use arrow functions over function declarations\n- MUST: Never comment unless absolutely necessary.\n  - If the code is a hack (like a setTimeout or potentially confusing code), it must be prefixed with // HACK: reason for hack\n- MUST: Use kebab-case for files\n- MUST: Use descriptive names for variables (avoid shorthands, or 1-2 character names).\n  - Example: for .map(), you can use `innerX` instead of `x`\n  - Example: instead of `moved` use `didPositionChange`\n- MUST: Frequently re-evaluate and refactor variable names to be more accurate and descriptive.\n- MUST: Do not type cast (\"as\") unless absolutely necessary\n- MUST: Remove unused code and don't repeat yourself.\n- MUST: Use `truffler` to 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\").\n- MUST: Always search the codebase, think of many solutions, then implement the most _elegant_ solution.\n- 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-thinking` pass (`.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.\n- MUST: Put all magic numbers in `constants.ts` using `SCREAMING_SNAKE_CASE` with unit suffixes (`_MS`, `_PX`).\n- MUST: Put small, focused utility functions in `utils/` with one utility per file.\n- MUST: Use Boolean over !!.\n\n## Symbol Search & Deduplication (truffler)\n\n`@rayhanadev/truffler` (dev dependency) is fuzzy JS/TS symbol search powered by `oxc-parser`.\nUse it to avoid duplicating existing code. The `find-similar-functions` skill\n(`.agents/skills/find-similar-functions/`) carries the full workflow; the short version:\n\n- WHEN PLANNING / SCOPING — before adding a utility, helper, type, constant, or rule, search\n  for an existing symbol to reuse or extend. Derive a few queries from the behavior (proposed\n  name + domain noun + verb), search the narrowest root first, then read the top matches before\n  writing anything. It is how you reuse an existing helper instead of duplicating it, per \"don't\n  repeat yourself\" and the one-utility-per-file `utils/` convention.\n- AFTER FINISHING A TASK — re-run searches for the symbols you added to confirm you did not\n  duplicate an existing helper, and delete any code your change superseded.\n\n```bash\nbunx @rayhanadev/truffler \"<query>\" packages --kind function,method,interface,type,constant --limit 20\n```\n\nRun it with `bunx @rayhanadev/truffler` (the published `bin` is a TypeScript entry Bun runs\ndirectly, and the pinned dev dependency is reused rather than re-downloaded). Narrow `<query>`\nand the root (e.g. `packages/core/src`) for precision; broaden only when nothing matches.\n\n## Package Layout\n\n```\npackages/\n  core/                          PRIVATE  the diagnostic engine\n    src/\n      types/                     PRIVATE shared cross-package TS types (DiagnoseOptions,\n                                 ProjectInfo, JsonReport, …) — no runtime code\n      project-info/              project discovery (discoverProject, findMonorepoRoot,\n                                 framework detection, narrow Error subclasses thrown\n                                 BEFORE the Effect runtime takes over)\n      errors.ts                  tagged Schema.TaggedErrorClass leaves + ReactDoctorError union\n      schemas.ts                 Diagnostic / Severity / JsonReport / buildDiagnosticIdentity\n                                 (also exposed as `@react-doctor/core/schemas` subpath\n                                 since the names overlap the TS types above)\n      refs.ts                    Context.Reference for ambient env config\n      run-inspect.ts             streaming orchestrator (the heart)\n      build-diagnostic-pipeline  per-element filter pipeline (single source of truth)\n      services/                  Context.Service implementations (Files, Git, Project,\n                                 Config, Linter, Maintainability, Score, Reporter,\n                                 Progress, NodeResolver, StagedFiles, SupplyChain)\n                                 + LintPartialFailures\n      ...                        rest of the lint / score / suppression engine\n  api/                           PRIVATE  programmatic diagnose() (Effect.runPromise shell)\n  react-doctor/                  PUBLISHED  CLI + public inspect() + bin\n  oxlint-plugin-react-doctor/    PUBLISHED  the 100+ rules, owns the canonical\n                                 `react-native-dependency-names.ts` (re-exported from\n                                 core to break the rule-package ↔ core cycle)\n  eslint-plugin-react-doctor/    PUBLISHED  ESLint mirror of the oxlint plugin\n```\n\n## Effect v4 Conventions\n\nBuilt on `effect@4.0.0-beta.102`. See `tmp/effect/.patterns/effect.md` (cloned reference)\nand `~/Developer/react-doctor-evals/src/` (the application that pioneered these patterns\nfor this codebase) for canonical examples.\n\n### Imports\n\n- ALWAYS: `import * as Schema from \"effect/Schema\"`, `import * as Effect from \"effect/Effect\"`,\n  `import * as Cause from \"effect/Cause\"`, etc. — one module per import line.\n- NEVER: `import { Schema, Effect } from \"effect\"` — the umbrella import inflates the\n  type-resolution graph and contradicts what every other Effect codebase does.\n\n### Errors\n\n- Every fallible service fails with `ReactDoctorError` (`reason: Schema.Union([...])`)\n- Each leaf is a `Schema.TaggedErrorClass<Self>()(\"Tag\", { fields })` with a\n  `get message()` getter (NOT `message =`) returning a human string.\n- Opaque causes use `Cause.pretty(Cause.fail(this.cause))` in the message body.\n- Renderers dispatch on `error.reason._tag`, NEVER on `error.message.includes(...)`.\n- `formatReactDoctorError(error)` / `isReactDoctorError(error)` / `isSplittableReactDoctorError(error)`\n  live in `core/src/errors.ts`. Use them; don't add new error-shape helpers.\n\n### Error dispatch / recovery — v4 idioms\n\n- **`Effect.catchReasons(errorTag, cases, orElse?)`** — the v4-canonical way to\n  dispatch on a `Schema.TaggedErrorClass` reason union. Each entry catches one\n  reason `_tag`; the optional `orElse` handles unmatched reasons. NEVER write\n  manual `if (cause.reason instanceof X)` ladders inside a `catch` block — the\n  Effect pipeline gives you exhaustive, type-safe narrowing for free. See\n  `inspect.ts → restoreLegacyThrow` and `api/diagnose.ts` for the canonical\n  shape.\n- **`Effect.catchTag(tag, handler)`** — for a single tagged error (e.g.\n  `Effect.catchTag(\"PlatformError\", ...)` in `services/git.ts` to fold the\n  `ChildProcess` platform error into a `ReactDoctorError`).\n- **`Effect.catch`** (renamed from v3 `Effect.catchAll`) — for catch-all.\n- **`Effect.die(error)`** — promote a recovered value into a defect that\n  `runPromise` re-throws unchanged. Used in `catchReasons` handlers when the\n  programmatic contract still wants the legacy `Error` class on the throw.\n- **NEVER** `try/catch` inside `Effect.gen` (v4 hard rule). Wrap the sync\n  throw in `Effect.try({ try, catch })` and recover via\n  `Effect.orElseSucceed` / `Effect.catch` instead. See\n  `render-summary.ts → printSummary` for the canonical shape.\n\n### Generator hygiene\n\n- **`return yield* Effect.fail(...)`** — terminal effects (Effect.fail,\n  Effect.interrupt, Effect.die) must be `return yield*` so TypeScript sees\n  the unreachable-code property. Bare `yield*` of a terminal lets unreachable\n  code accumulate after it. See `services/git.ts` `diffSelection` for examples.\n- **`Effect.gen({ self: this }, function* () { ... })`** — v4 changed the\n  `self`-bound form. The plain `Effect.gen(function* () { ... })` form is\n  unchanged; only class-method generators bound to `this` need the options\n  object.\n- **`Effect.fnUntraced(function* () { ... })`** — prefer over a function\n  whose body is `Effect.gen` when the function is called many times per\n  operation (hot path). Cuts tracing overhead. Not currently used in this\n  codebase — Git invocations and inspect-pipeline calls run once per scan,\n  not in a hot loop.\n\n### Services\n\n- `Context.Service<Self, Interface>()(\"react-doctor/Name\", { make: ... })` — short\n  prefix in the identifier (matches react-doctor-evals' `rde/X` shape).\n- Service method bodies use `Effect.fnUntraced` for hot paths, `Effect.sync` for\n  one-liners. Test layers + orchestration use `Effect.gen`.\n- **`Effect.fn(\"Service.method\")`** for non-trivial methods so they surface as\n  named spans in OTel traces. Production cost is zero when no tracer layer is\n  provided; with `Otlp.layerJson(...)` users see one span per service call.\n  Canonical eval pattern (`react-doctor-evals/src/Runner.ts` → every method).\n- `Service.of({ ... })` everywhere inside `Layer.succeed` / `make:` — never\n  `{ ... } as const`.\n- `Layer.effect` when the service has init work (e.g. `Cache.make`); `Layer.succeed`\n  when stateless.\n- Method takes a single object arg when there are >1 parameters\n  (e.g. `Files.readLines({ filePath, rootDirectory })`).\n\n### Layer naming\n\n- `layerNode` for the production Node.js implementation.\n- `layerOf(value)` for the test layer that returns a pre-supplied value.\n- `layerInMemory(Map)` for filesystem-shaped services backed by an in-memory tree.\n- `layerCapture` for the test layer that records calls into a `Ref` exposed via a\n  sibling `*Capture` service (e.g. `ReporterCapture`, `ProgressCapture`).\n- `layerNoop` for the production layer that has void-return / discard semantics\n  (Reporter, Progress). Analyzers (Linter, Maintainability) use `layerOf([])` instead.\n- Implementation-specific names: `layerOxlint`, `layerHttp`, `layerOra(factory)`.\n\n### Schemas\n\n- Use `Schema.Class<Self>(\"Name\")({ fields })` for wire records.\n- Use `Schema.Literals([\"a\", \"b\"])` for unions of literals (plural), `Schema.Literal(1)`\n  for single literals.\n- `Schema.NullOr(X)` for `X | null`; `Schema.optional(X)` for `X?`.\n- `Schema.brand(\"X\")` via `.pipe()` for branded primitives.\n- Schema for wire types (Diagnostic, JsonReport); interfaces for arg types\n  (InspectInput, LintInput) — avoid runtime encode/decode cost on hot paths.\n\n### Ambient config\n\n- Env-var reads + cache paths go through `Context.Reference<T>(\"react-doctor/X\", { defaultValue })`.\n  See `core/src/refs.ts`. Tests override via `Layer.succeed(MyRef, ...)`.\n- Secrets (API tokens, signing keys) should prefer `Config.redacted(\"ENV_NAME\")` over\n  `Context.Reference` so they auto-redact in logs / traces. Group with `Config.all({ ... })`\n  at the service constructor when you need several. (Pattern from\n  `react-doctor-evals/src/GitHub.ts` — not yet used in this codebase; document\n  the convention so the first secret-shaped config does it right.)\n\n### Observability\n\nTelemetry is split across two backends, deliberately:\n\n- **Axiom** takes traces and metrics — the per-run wide event, every\n  `Effect.fn(\"Service.method\")` span, and all counters/distributions. Axiom bills\n  by ingested volume with no active-series limits, which is the model\n  high-cardinality wide events want.\n- **Sentry** takes crashes only. It keeps what it is good at: source-map\n  symbolication (`scripts/sentry-sourcemaps.mjs`), issue grouping, and a\n  quotable event id. Axiom has none of those.\n\nEffect exposes a single `Tracer` reference, so the two are mutually exclusive\nfor spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span.\n\n- Wrap the top-level entry of a multi-step operation in `Effect.withSpan(\"name\", { attributes })`.\n  See `core/src/run-inspect.ts → runInspect` for the canonical shape. Attribute\n  keys use dotted namespacing (`inspect.directory`, `inspect.isCi`).\n- Per-service-method spans come from `Effect.fn(\"Service.method\")` — see Services section\n  above. The two compose: `runInspect` is the parent span, every `Service.method` is a child.\n- **Transport.** `core/src/observability.ts` owns three layers.\n  `layerAxiomTraces` and `layerAxiomMetrics` are composed by hand rather than via\n  `Otlp.layer`, because Axiom routes each signal to a different dataset through a\n  different header (`X-Axiom-Dataset` vs `x-axiom-metrics-dataset`) and\n  `Otlp.layer` passes one `headers` object to every signal. `layerObservability`\n  merges them and picks the backend: a user-configured `REACT_DOCTOR_OTLP_*`\n  endpoint wins over first-party Axiom, else Axiom, else `Layer.empty`.\n  Serialization is **protobuf**, not JSON — Axiom's `/v1/metrics` accepts\n  `application/x-protobuf` only. `OtlpSerialization.layerProtobuf` ships with\n  Effect and adds no dependency.\n- **Build the layer exactly once per process.** `cli/utils/telemetry-runtime.ts`\n  builds it into a long-lived scope and shares the resulting `Context`; the run\n  root span, the scan program, and the renderer all draw their tracer from it.\n  Two reasons this is not optional. Child spans nest under the run span natively,\n  with no `ExternalSpan` stitching. And Effect tracks delta-temporality state on\n  the _metrics exporter instance_, so a second exporter starts with no previous\n  snapshot and re-reports every counter's full value — silently doubling every\n  metric. `shutdownTelemetry()` is memoized for the same reason: several exit\n  paths can fire (a `--debug` user error flushes, then the top-level catch\n  flushes again). A regression test in `core/tests/telemetry-payload.test.ts`\n  pins the double-count behavior so a refactor that merges the layers fails loudly.\n- **Exit flush.** Closing the telemetry scope is what ships buffered spans and\n  metrics, bounded by `TELEMETRY_SHUTDOWN_TIMEOUT_MS` (1s — deliberately tighter\n  than Sentry's 2s error flush). The periodic `TELEMETRY_EXPORT_INTERVAL_MS` is\n  longer than any realistic run so the scope close is the only export. Note the\n  metrics exporter passes `maxBatchSize: \"disabled\"` internally, which skips\n  Effect's empty-buffer short-circuit — it POSTs on every scope close whether or\n  not anything was recorded, so on a firewalled machine that request cannot fail\n  fast.\n- **Anonymization.** Telemetry must stay anonymized, and OTLP has **no**\n  `beforeSend`-style hook — the safety net Sentry gave us for free had to be\n  rebuilt. Two mechanisms now carry it:\n  - `core/src/utils/make-scrubbing-tracer.ts` wraps the tracer so every span\n    name, attribute value, and event payload passes through `anonymizeText`\n    (home dir / username → `~`, then secrets and emails redacted) on the way to\n    the exporter. It is applied inside `layerAxiomTraces`, so an attribute added\n    later cannot bypass it.\n  - `cli/utils/record-metric.ts` scrubs attribute values at the emit site, since\n    metrics never touch the tracer.\n\n  Call sites should still scrub at the source — `run-inspect.ts` wraps\n  `inspect.directory` in `scrubSensitivePaths` — and the tracer is the backstop\n  for the ones that don't. `scrub-sentry-event.ts` still runs as Sentry's\n  `beforeSend` for crash reports. If you add a field to any payload, confirm it\n  carries no username, hostname, IP, secret, or absolute path.\n\n- **Sentry scope shape.** `cli/utils/build-sentry-scope.ts` is the one place that\n  projects the run snapshot (and the scanned project, once known) into `tags` +\n  `contexts`. Both `instrument.ts` (`initialScope`) and `report-error.ts` consume\n  it, and `record-metric.ts` reprojects its `tags` onto every metric at emit\n  time — so add new run-level metadata there, not at call sites. Project info is\n  captured via `recordSentryProjectContext` (`with-run-span.ts`), which both\n  remembers it for the lazy error path and sets it as root-span attributes.\n- **Crash references + trace linkage.** `reportErrorToSentry` returns the Sentry\n  event id; the CLI catch blocks thread it into `handleError` so it's printed as\n  a user-quotable reference and added to the prefilled GitHub issue. Because\n  spans now live in Axiom, Sentry's native trace linkage no longer reaches them —\n  instead the crash is tagged with `axiomTraceId` and `runId`, so a Sentry issue\n  pivots straight into the run's Axiom trace. `--debug` prints that same trace id.\n- **Metrics.** `cli/utils/record-metric.ts` (`recordCount` / `recordDistribution`)\n  writes into Effect's process-global `MetricRegistry`, which the OTLP exporter\n  snapshots at flush. Both are guarded by `isTelemetryEnabled()` so they are true\n  no-ops under `--no-score` / `--no-telemetry`, in tests, and for the\n  `@react-doctor/api` library. Metric names live in the `METRIC` map in\n  `cli/utils/constants.ts` (dotted, domain-grouped; high-cardinality dimensions\n  go in attributes, never the name). Two constraints differ from Sentry:\n  Effect metric attributes are **string-only**, so numbers and booleans are\n  stringified (`null`/`undefined` are dropped, never coerced to `\"null\"`); and\n  histograms need explicit bucket boundaries, so all distributions share\n  `METRIC_DISTRIBUTION_BOUNDARIES`. Per-scan metrics (`scan.*`, `rule.fired`,\n  `lint.failed`, …) are emitted by `cli/utils/record-scan-metrics.ts`;\n  `rule.fired` is one high-cardinality counter keyed by\n  `rule`/`plugin`/`category`/`severity` attributes (never a metric-name-per-rule).\n- **The canonical run wide event.** The richest telemetry is one\n  high-dimensionality wide event per scan, not a pile of narrow counters: the\n  per-run root span (`with-run-span.ts`) is enriched with the full outcome by\n  `cli/utils/build-run-event.ts` (`recordRunEvent`, plus the pure, testable\n  `buildRunEventAttributes`). `inspect.ts` calls it on the success path (after\n  `recordScanMetrics`) and, via a `try/catch` around the span body, on the\n  failure path — so the event lands with an `outcome.status` (`clean`/`ok`/\n  `blocked`/`error`), `outcome.exitCode`, and `outcome.errorTag` taxonomy even\n  when the scan throws. The run + project base context is already on the span, so\n  the event adds only what that doesn't — every attribute namespaced by concept\n  via `withNamespace` (`cli/utils/with-namespace.ts`) so the keys tree up in the\n  attribute browser: scan config (`scan.mode`, `scan.parallel`, `scan.workerCount`,\n  `scan.rulesConfigured`/`scan.rulesDisabled`, `scan.ignoredTagCount`,\n  `scan.hasCustomConfig`, … plus the `scan.fileCount` extent), the verdict\n  (`outcome.wouldBlock`/`outcome.blocking`/`outcome.clean`/`outcome.skippedChecks`),\n  findings (`diag.total`, `diag.errors`/`diag.warnings`, `diag.affectedFiles`,\n  `diag.distinctRules`, `diag.topRule`, per-category `diag.category.*`),\n  `score.value`/`score.label`/`score.available`, the `lint.*`/`maintainability.*`/\n  `supplyChain.*` pass outcomes, `timing.*` durations, and the CI/PR specifics\n  (`action.actorAssociation`, `action.runnerOs`, and the forwarded action knobs\n  `action.comment`/`action.reviewComments`/`action.versionPin`). Typing matters\n  for querying: numeric outcomes are numbers (so you can take `p75(score.value)`),\n  dimensions are strings/bools (so they filter/group); `null` is dropped via\n  `toSpanAttributes` so absent signals never become `\"null\"`. Query it with APL\n  over the traces dataset and build dashboards there instead of pre-aggregating\n  counters. Put new run-level dimensions on `build-run-context.ts` →\n  `build-sentry-scope.ts` so they ride every event and metric; put per-scan\n  outcome dimensions on the wide event (wrapped in `withNamespace`), **not** new\n  counters — the `scan.completed`/`scan.duration`/`rule.fired` counters stay as\n  the cheap floor alongside `cli.invoked`/`cli.error`. Score reachability is\n  derivable (`!score.available && !lint.failed && !maintainability.failed && !scan.noScore`)\n  and score latency is the `Score.compute` child span's duration, so neither\n  needs a dedicated field. CI detection + the official-action marker and\n  forwarded inputs live in `cli/utils/is-ci-environment.ts`; `action.yml` sets the\n  `REACT_DOCTOR_GITHUB_ACTION` marker + `REACT_DOCTOR_ACTION_*` env on its scan\n  step. Keep every attribute free of username, path, secret, and repo/owner identity.\n- **Opt-out.** `cli/utils/is-telemetry-enabled.ts` is the single gate for every\n  backend: `--no-score`, `--no-telemetry`, `REACT_DOCTOR_NO_TELEMETRY`, or a test\n  run disables all of it. Blanking `SENTRY_DSN` alone no longer silences\n  anything, so internal tooling that must not report (the evals harness, the\n  benchmark environment, the delta-audit runner) sets `REACT_DOCTOR_NO_TELEMETRY`.\n- **Credentials.** The Axiom ingest token is embedded in `cli/utils/constants.ts`\n  (`AXIOM_INGEST_TOKEN`), mirroring the public Sentry DSN — but unlike a DSN it is\n  a real credential, so it is minted **ingest-only and scoped to the two\n  datasets**. It ships in the tarball and is therefore extractable: rotation means\n  cutting a release, and an Axiom monitor on anomalous ingest volume is the\n  detection. `cli/utils/resolve-axiom-telemetry-options.ts` resolves it (with\n  `REACT_DOCTOR_AXIOM_TOKEN` / `_DOMAIN` / `_DATASET` overrides for local\n  testing — deliberately prefixed, since the bare `AXIOM_*` names are Axiom's\n  own and reading them would hijack the telemetry of anyone already running it)\n  and returns `null` when unset, so an unconfigured build simply doesn't export.\n  Core never reads these itself — keeping them CLI-side is what makes\n  `@react-doctor/api` silent by construction rather than by a runtime guard.\n- **runId.** `cli/utils/run-id.ts` mints one random `runId` per CLI run\n  (process). It rides the Sentry `run` context and the wide event, and is a tag\n  only on crash reports (where it links to the Axiom trace) — never a metric\n  attribute, where a per-run unique value would explode counter cardinality. A\n  workspace invocation scanning several projects shares one `runId`; the\n  per-project span attributes disambiguate. Do not add a plaintext or hashed repo\n  id to either backend.\n\n### Console / logging\n\n- ALWAYS: `import * as Console from \"effect/Console\"` and `yield* Console.log(...)` /\n  `Console.warn(...)` / `Console.error(...)` from inside renderers, services, and any\n  Effect-typed code. Effect's `Console` is a `Context.Reference` whose default sink is\n  `globalThis.console`, so the production path is identical to a raw `console.log`\n  while remaining swappable for tests / silent mode.\n- NEVER: invent a parallel `Logger` / `LoggerWriter` abstraction. The historical custom\n  Logger service was removed when the renderer pipeline went Effect-typed; the only\n  remaining bridge is `cli/utils/cli-logger.ts`, a thin sync wrapper around\n  `Effect.runSync(Console.X)` for imperative CLI helpers that aren't yet `Effect.gen`.\n- Silent mode is `Effect.provideService(Console.Console, silentConsole)` (renderer\n  pipeline) or `installSilentConsole()` (JSON mode, which monkey-patches the global\n  console because the surrounding CLI command body is imperative). Both routes leave\n  the underlying `Console.*` Effect intact — there is no `if (silent) return` check\n  at any call site.\n\n## Testing\n\nTests live alongside source in each package's `tests/` directory:\n\n- `packages/core/tests/` — service tests + run-inspect orchestration tests\n- `packages/api/tests/` — api shell tests\n- `packages/react-doctor/tests/` — CLI + end-to-end fixture tests\n\nTest framework is `vite-plus/test` (the existing vitest wrapper).\n\nRun checks always before committing with:\n\n```bash\npnpm test         # all packages\npnpm lint\npnpm typecheck\npnpm format       # use `format:check` to verify only\npnpm smoke:json-report   # validates the built CLI's JSON output against the schema\n```\n\n## Release authorization\n\n- MUST: Discourage minor and major Changesets. Do not add one unless the user explicitly requests\n  that release level. Patch Changesets may be added without a separate request when appropriate.\n- MUST: Never merge a Changesets release PR, including any `changeset-release/*` branch, without\n  fresh, explicit user confirmation for that exact PR and version immediately before the merge.\n- General instructions to merge, ship, land, or babysit green PRs do not authorize merging a\n  release/version PR. Treat merging a PR that triggers publication as publishing the release.\n- MUST: Never publish packages, push or move release tags, or trigger, approve, rerun, or merge a\n  release/publish workflow without fresh, explicit user confirmation for the exact versions and\n  packages involved.\n- Agents may prepare, validate, and babysit a release candidate, but must stop before the first\n  publishing action and report the exact PR, versions, packages, tags, and workflows awaiting user\n  approval.\n- These confirmation requirements also apply to GitHub Action releases described below. Once the\n  user explicitly approves a specific release, follow all required versioning and tag steps.\n\n## GitHub Action versioning\n\nThe composite GitHub Action is **versioned independently from the npm packages**. \"The action\"\nis `action.yml` (repo root) plus the scripts it shells out to (`scripts/ensure-json-report.mjs`,\n`scripts/normalize-changed-files.mjs`, `scripts/render-github-action-comment.mjs`,\n`scripts/resolve-package-spec.mjs`). Treat a change to any of those files as an action release,\nand keep the list in sync with `ACTION_RELEASE_FILES` in\n`scripts/recommend-action-version-bump.mjs` (the release guard).\n\n- Two tag namespaces coexist — never conflate them:\n  - npm packages — `react-doctor@X.Y.Z`, `eslint-plugin-react-doctor@X.Y.Z`,\n    `oxlint-plugin-react-doctor@X.Y.Z` (created by Changesets in CI; see\n    `.github/workflows/publish.yml`).\n  - GitHub Action — `v`-prefixed semver `vX.Y.Z` plus a floating major `vN` (the GitHub Actions\n    convention; the `v` prefix keeps these distinct from the unprefixed package tags above).\n    Current: the `v2.x` line (check `git tag --list 'v*'` for the latest); `v2` → the same\n    commit. The `v0.x` line is the pre-rebuild action; the `f4035fce` PR-reporting rebuild is\n    `v1.0.0`.\n- MUST: cut a tag on every commit that touches the action files. `feat(action)` → minor bump;\n  everything else (`fix` / `refactor` / `chore` / `revert` / docs-only edits to `action.yml`) →\n  patch bump. A breaking change to inputs/outputs or the runtime contract → major bump.\n- MUST: after tagging a new `vX.Y.Z`, move the floating major `vN` to that same commit so\n  `uses: millionco/react-doctor@vN` keeps resolving to the latest compatible release.\n- Tags are GPG-signed annotated tags (`tag.gpgsign=true`), so a bare `git tag vX` will demand a\n  message and fail in scripts. Always create/move with an explicit message:\n\n```bash\n# new release at the commit that changed the action\ngit tag -a v2.2.3 <commit> -m \"react-doctor action v2.2.3\"\n# move the floating major (force-update only the vN pointer)\ngit tag -fa v2 <commit> -m \"react-doctor action v2 (floating major -> v2.2.3)\"\ngit push origin v2.2.3\ngit push --force origin v2   # the force applies to the moving major tag only\n```\n\n- MUST: never tell consumers to reference `@main` in docs/examples. `@main` runs whatever HEAD\n  points to with `pull-requests: write` granted — a supply-chain risk (issue #299). Recommend a\n  full commit-SHA pin with a trailing version comment for hardened CI\n  (`uses: millionco/react-doctor@<sha> # v2.2.2`), or `@vN` for convenience.\n\n## Reference reading\n\n- `tmp/effect/.patterns/effect.md` — canonical Effect v4 idioms (cloned for reference,\n  gitignored)\n- `~/Developer/react-doctor-evals/src/` — sister application this codebase's runtime\n  patterns are modeled on (Schemas.ts, Runner.ts, Worker.ts, errors.ts shapes)\n","CLAUDE.md":"See [`AGENTS.md`](./AGENTS.md) for the contributor guide — conventions, package\nlayout, the rule pipeline, and release steps.\n\n@AGENTS.md\n"},"files":{"AGENTS.md":"## General Rules\n\n- MUST: Use @antfu/ni. Use `ni` to install, `nr SCRIPT_NAME` to run. `nun` to uninstall.\n- MUST: Use TypeScript interfaces over types.\n- MUST: Keep all types in the global scope.\n- MUST: Use arrow functions over function declarations\n- MUST: Never comment unless absolutely necessary.\n  - If the code is a hack (like a setTimeout or potentially confusing code), it must be prefixed with // HACK: reason for hack\n- MUST: Use kebab-case for files\n- MUST: Use descriptive names for variables (avoid shorthands, or 1-2 character names).\n  - Example: for .map(), you can use `innerX` instead of `x`\n  - Example: instead of `moved` use `didPositionChange`\n- MUST: Frequently re-evaluate and refactor variable names to be more accurate and descriptive.\n- MUST: Do not type cast (\"as\") unless absolutely necessary\n- MUST: Remove unused code and don't repeat yourself.\n- MUST: Use `truffler` to 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\").\n- MUST: Always search the codebase, think of many solutions, then implement the most _elegant_ solution.\n- 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-thinking` pass (`.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.\n- MUST: Put all magic numbers in `constants.ts` using `SCREAMING_SNAKE_CASE` with unit suffixes (`_MS`, `_PX`).\n- MUST: Put small, focused utility functions in `utils/` with one utility per file.\n- MUST: Use Boolean over !!.\n\n## Symbol Search & Deduplication (truffler)\n\n`@rayhanadev/truffler` (dev dependency) is fuzzy JS/TS symbol search powered by `oxc-parser`.\nUse it to avoid duplicating existing code. The `find-similar-functions` skill\n(`.agents/skills/find-similar-functions/`) carries the full workflow; the short version:\n\n- WHEN PLANNING / SCOPING — before adding a utility, helper, type, constant, or rule, search\n  for an existing symbol to reuse or extend. Derive a few queries from the behavior (proposed\n  name + domain noun + verb), search the narrowest root first, then read the top matches before\n  writing anything. It is how you reuse an existing helper instead of duplicating it, per \"don't\n  repeat yourself\" and the one-utility-per-file `utils/` convention.\n- AFTER FINISHING A TASK — re-run searches for the symbols you added to confirm you did not\n  duplicate an existing helper, and delete any code your change superseded.\n\n```bash\nbunx @rayhanadev/truffler \"<query>\" packages --kind function,method,interface,type,constant --limit 20\n```\n\nRun it with `bunx @rayhanadev/truffler` (the published `bin` is a TypeScript entry Bun runs\ndirectly, and the pinned dev dependency is reused rather than re-downloaded). Narrow `<query>`\nand the root (e.g. `packages/core/src`) for precision; broaden only when nothing matches.\n\n## Package Layout\n\n```\npackages/\n  core/                          PRIVATE  the diagnostic engine\n    src/\n      types/                     PRIVATE shared cross-package TS types (DiagnoseOptions,\n                                 ProjectInfo, JsonReport, …) — no runtime code\n      project-info/              project discovery (discoverProject, findMonorepoRoot,\n                                 framework detection, narrow Error subclasses thrown\n                                 BEFORE the Effect runtime takes over)\n      errors.ts                  tagged Schema.TaggedErrorClass leaves + ReactDoctorError union\n      schemas.ts                 Diagnostic / Severity / JsonReport / buildDiagnosticIdentity\n                                 (also exposed as `@react-doctor/core/schemas` subpath\n                                 since the names overlap the TS types above)\n      refs.ts                    Context.Reference for ambient env config\n      run-inspect.ts             streaming orchestrator (the heart)\n      build-diagnostic-pipeline  per-element filter pipeline (single source of truth)\n      services/                  Context.Service implementations (Files, Git, Project,\n                                 Config, Linter, Maintainability, Score, Reporter,\n                                 Progress, NodeResolver, StagedFiles, SupplyChain)\n                                 + LintPartialFailures\n      ...                        rest of the lint / score / suppression engine\n  api/                           PRIVATE  programmatic diagnose() (Effect.runPromise shell)\n  react-doctor/                  PUBLISHED  CLI + public inspect() + bin\n  oxlint-plugin-react-doctor/    PUBLISHED  the 100+ rules, owns the canonical\n                                 `react-native-dependency-names.ts` (re-exported from\n                                 core to break the rule-package ↔ core cycle)\n  eslint-plugin-react-doctor/    PUBLISHED  ESLint mirror of the oxlint plugin\n```\n\n## Effect v4 Conventions\n\nBuilt on `effect@4.0.0-beta.102`. See `tmp/effect/.patterns/effect.md` (cloned reference)\nand `~/Developer/react-doctor-evals/src/` (the application that pioneered these patterns\nfor this codebase) for canonical examples.\n\n### Imports\n\n- ALWAYS: `import * as Schema from \"effect/Schema\"`, `import * as Effect from \"effect/Effect\"`,\n  `import * as Cause from \"effect/Cause\"`, etc. — one module per import line.\n- NEVER: `import { Schema, Effect } from \"effect\"` — the umbrella import inflates the\n  type-resolution graph and contradicts what every other Effect codebase does.\n\n### Errors\n\n- Every fallible service fails with `ReactDoctorError` (`reason: Schema.Union([...])`)\n- Each leaf is a `Schema.TaggedErrorClass<Self>()(\"Tag\", { fields })` with a\n  `get message()` getter (NOT `message =`) returning a human string.\n- Opaque causes use `Cause.pretty(Cause.fail(this.cause))` in the message body.\n- Renderers dispatch on `error.reason._tag`, NEVER on `error.message.includes(...)`.\n- `formatReactDoctorError(error)` / `isReactDoctorError(error)` / `isSplittableReactDoctorError(error)`\n  live in `core/src/errors.ts`. Use them; don't add new error-shape helpers.\n\n### Error dispatch / recovery — v4 idioms\n\n- **`Effect.catchReasons(errorTag, cases, orElse?)`** — the v4-canonical way to\n  dispatch on a `Schema.TaggedErrorClass` reason union. Each entry catches one\n  reason `_tag`; the optional `orElse` handles unmatched reasons. NEVER write\n  manual `if (cause.reason instanceof X)` ladders inside a `catch` block — the\n  Effect pipeline gives you exhaustive, type-safe narrowing for free. See\n  `inspect.ts → restoreLegacyThrow` and `api/diagnose.ts` for the canonical\n  shape.\n- **`Effect.catchTag(tag, handler)`** — for a single tagged error (e.g.\n  `Effect.catchTag(\"PlatformError\", ...)` in `services/git.ts` to fold the\n  `ChildProcess` platform error into a `ReactDoctorError`).\n- **`Effect.catch`** (renamed from v3 `Effect.catchAll`) — for catch-all.\n- **`Effect.die(error)`** — promote a recovered value into a defect that\n  `runPromise` re-throws unchanged. Used in `catchReasons` handlers when the\n  programmatic contract still wants the legacy `Error` class on the throw.\n- **NEVER** `try/catch` inside `Effect.gen` (v4 hard rule). Wrap the sync\n  throw in `Effect.try({ try, catch })` and recover via\n  `Effect.orElseSucceed` / `Effect.catch` instead. See\n  `render-summary.ts → printSummary` for the canonical shape.\n\n### Generator hygiene\n\n- **`return yield* Effect.fail(...)`** — terminal effects (Effect.fail,\n  Effect.interrupt, Effect.die) must be `return yield*` so TypeScript sees\n  the unreachable-code property. Bare `yield*` of a terminal lets unreachable\n  code accumulate after it. See `services/git.ts` `diffSelection` for examples.\n- **`Effect.gen({ self: this }, function* () { ... })`** — v4 changed the\n  `self`-bound form. The plain `Effect.gen(function* () { ... })` form is\n  unchanged; only class-method generators bound to `this` need the options\n  object.\n- **`Effect.fnUntraced(function* () { ... })`** — prefer over a function\n  whose body is `Effect.gen` when the function is called many times per\n  operation (hot path). Cuts tracing overhead. Not currently used in this\n  codebase — Git invocations and inspect-pipeline calls run once per scan,\n  not in a hot loop.\n\n### Services\n\n- `Context.Service<Self, Interface>()(\"react-doctor/Name\", { make: ... })` — short\n  prefix in the identifier (matches react-doctor-evals' `rde/X` shape).\n- Service method bodies use `Effect.fnUntraced` for hot paths, `Effect.sync` for\n  one-liners. Test layers + orchestration use `Effect.gen`.\n- **`Effect.fn(\"Service.method\")`** for non-trivial methods so they surface as\n  named spans in OTel traces. Production cost is zero when no tracer layer is\n  provided; with `Otlp.layerJson(...)` users see one span per service call.\n  Canonical eval pattern (`react-doctor-evals/src/Runner.ts` → every method).\n- `Service.of({ ... })` everywhere inside `Layer.succeed` / `make:` — never\n  `{ ... } as const`.\n- `Layer.effect` when the service has init work (e.g. `Cache.make`); `Layer.succeed`\n  when stateless.\n- Method takes a single object arg when there are >1 parameters\n  (e.g. `Files.readLines({ filePath, rootDirectory })`).\n\n### Layer naming\n\n- `layerNode` for the production Node.js implementation.\n- `layerOf(value)` for the test layer that returns a pre-supplied value.\n- `layerInMemory(Map)` for filesystem-shaped services backed by an in-memory tree.\n- `layerCapture` for the test layer that records calls into a `Ref` exposed via a\n  sibling `*Capture` service (e.g. `ReporterCapture`, `ProgressCapture`).\n- `layerNoop` for the production layer that has void-return / discard semantics\n  (Reporter, Progress). Analyzers (Linter, Maintainability) use `layerOf([])` instead.\n- Implementation-specific names: `layerOxlint`, `layerHttp`, `layerOra(factory)`.\n\n### Schemas\n\n- Use `Schema.Class<Self>(\"Name\")({ fields })` for wire records.\n- Use `Schema.Literals([\"a\", \"b\"])` for unions of literals (plural), `Schema.Literal(1)`\n  for single literals.\n- `Schema.NullOr(X)` for `X | null`; `Schema.optional(X)` for `X?`.\n- `Schema.brand(\"X\")` via `.pipe()` for branded primitives.\n- Schema for wire types (Diagnostic, JsonReport); interfaces for arg types\n  (InspectInput, LintInput) — avoid runtime encode/decode cost on hot paths.\n\n### Ambient config\n\n- Env-var reads + cache paths go through `Context.Reference<T>(\"react-doctor/X\", { defaultValue })`.\n  See `core/src/refs.ts`. Tests override via `Layer.succeed(MyRef, ...)`.\n- Secrets (API tokens, signing keys) should prefer `Config.redacted(\"ENV_NAME\")` over\n  `Context.Reference` so they auto-redact in logs / traces. Group with `Config.all({ ... })`\n  at the service constructor when you need several. (Pattern from\n  `react-doctor-evals/src/GitHub.ts` — not yet used in this codebase; document\n  the convention so the first secret-shaped config does it right.)\n\n### Observability\n\nTelemetry is split across two backends, deliberately:\n\n- **Axiom** takes traces and metrics — the per-run wide event, every\n  `Effect.fn(\"Service.method\")` span, and all counters/distributions. Axiom bills\n  by ingested volume with no active-series limits, which is the model\n  high-cardinality wide events want.\n- **Sentry** takes crashes only. It keeps what it is good at: source-map\n  symbolication (`scripts/sentry-sourcemaps.mjs`), issue grouping, and a\n  quotable event id. Axiom has none of those.\n\nEffect exposes a single `Tracer` reference, so the two are mutually exclusive\nfor spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span.\n\n- Wrap the top-level entry of a multi-step operation in `Effect.withSpan(\"name\", { attributes })`.\n  See `core/src/run-inspect.ts → runInspect` for the canonical shape. Attribute\n  keys use dotted namespacing (`inspect.directory`, `inspect.isCi`).\n- Per-service-method spans come from `Effect.fn(\"Service.method\")` — see Services section\n  above. The two compose: `runInspect` is the parent span, every `Service.method` is a child.\n- **Transport.** `core/src/observability.ts` owns three layers.\n  `layerAxiomTraces` and `layerAxiomMetrics` are composed by hand rather than via\n  `Otlp.layer`, because Axiom routes each signal to a different dataset through a\n  different header (`X-Axiom-Dataset` vs `x-axiom-metrics-dataset`) and\n  `Otlp.layer` passes one `headers` object to every signal. `layerObservability`\n  merges them and picks the backend: a user-configured `REACT_DOCTOR_OTLP_*`\n  endpoint wins over first-party Axiom, else Axiom, else `Layer.empty`.\n  Serialization is **protobuf**, not JSON — Axiom's `/v1/metrics` accepts\n  `application/x-protobuf` only. `OtlpSerialization.layerProtobuf` ships with\n  Effect and adds no dependency.\n- **Build the layer exactly once per process.** `cli/utils/telemetry-runtime.ts`\n  builds it into a long-lived scope and shares the resulting `Context`; the run\n  root span, the scan program, and the renderer all draw their tracer from it.\n  Two reasons this is not optional. Child spans nest under the run span natively,\n  with no `ExternalSpan` stitching. And Effect tracks delta-temporality state on\n  the _metrics exporter instance_, so a second exporter starts with no previous\n  snapshot and re-reports every counter's full value — silently doubling every\n  metric. `shutdownTelemetry()` is memoized for the same reason: several exit\n  paths can fire (a `--debug` user error flushes, then the top-level catch\n  flushes again). A regression test in `core/tests/telemetry-payload.test.ts`\n  pins the double-count behavior so a refactor that merges the layers fails loudly.\n- **Exit flush.** Closing the telemetry scope is what ships buffered spans and\n  metrics, bounded by `TELEMETRY_SHUTDOWN_TIMEOUT_MS` (1s — deliberately tighter\n  than Sentry's 2s error flush). The periodic `TELEMETRY_EXPORT_INTERVAL_MS` is\n  longer than any realistic run so the scope close is the only export. Note the\n  metrics exporter passes `maxBatchSize: \"disabled\"` internally, which skips\n  Effect's empty-buffer short-circuit — it POSTs on every scope close whether or\n  not anything was recorded, so on a firewalled machine that request cannot fail\n  fast.\n- **Anonymization.** Telemetry must stay anonymized, and OTLP has **no**\n  `beforeSend`-style hook — the safety net Sentry gave us for free had to be\n  rebuilt. Two mechanisms now carry it:\n  - `core/src/utils/make-scrubbing-tracer.ts` wraps the tracer so every span\n    name, attribute value, and event payload passes through `anonymizeText`\n    (home dir / username → `~`, then secrets and emails redacted) on the way to\n    the exporter. It is applied inside `layerAxiomTraces`, so an attribute added\n    later cannot bypass it.\n  - `cli/utils/record-metric.ts` scrubs attribute values at the emit site, since\n    metrics never touch the tracer.\n\n  Call sites should still scrub at the source — `run-inspect.ts` wraps\n  `inspect.directory` in `scrubSensitivePaths` — and the tracer is the backstop\n  for the ones that don't. `scrub-sentry-event.ts` still runs as Sentry's\n  `beforeSend` for crash reports. If you add a field to any payload, confirm it\n  carries no username, hostname, IP, secret, or absolute path.\n\n- **Sentry scope shape.** `cli/utils/build-sentry-scope.ts` is the one place that\n  projects the run snapshot (and the scanned project, once known) into `tags` +\n  `contexts`. Both `instrument.ts` (`initialScope`) and `report-error.ts` consume\n  it, and `record-metric.ts` reprojects its `tags` onto every metric at emit\n  time — so add new run-level metadata there, not at call sites. Project info is\n  captured via `recordSentryProjectContext` (`with-run-span.ts`), which both\n  remembers it for the lazy error path and sets it as root-span attributes.\n- **Crash references + trace linkage.** `reportErrorToSentry` returns the Sentry\n  event id; the CLI catch blocks thread it into `handleError` so it's printed as\n  a user-quotable reference and added to the prefilled GitHub issue. Because\n  spans now live in Axiom, Sentry's native trace linkage no longer reaches them —\n  instead the crash is tagged with `axiomTraceId` and `runId`, so a Sentry issue\n  pivots straight into the run's Axiom trace. `--debug` prints that same trace id.\n- **Metrics.** `cli/utils/record-metric.ts` (`recordCount` / `recordDistribution`)\n  writes into Effect's process-global `MetricRegistry`, which the OTLP exporter\n  snapshots at flush. Both are guarded by `isTelemetryEnabled()` so they are true\n  no-ops under `--no-score` / `--no-telemetry`, in tests, and for the\n  `@react-doctor/api` library. Metric names live in the `METRIC` map in\n  `cli/utils/constants.ts` (dotted, domain-grouped; high-cardinality dimensions\n  go in attributes, never the name). Two constraints differ from Sentry:\n  Effect metric attributes are **string-only**, so numbers and booleans are\n  stringified (`null`/`undefined` are dropped, never coerced to `\"null\"`); and\n  histograms need explicit bucket boundaries, so all distributions share\n  `METRIC_DISTRIBUTION_BOUNDARIES`. Per-scan metrics (`scan.*`, `rule.fired`,\n  `lint.failed`, …) are emitted by `cli/utils/record-scan-metrics.ts`;\n  `rule.fired` is one high-cardinality counter keyed by\n  `rule`/`plugin`/`category`/`severity` attributes (never a metric-name-per-rule).\n- **The canonical run wide event.** The richest telemetry is one\n  high-dimensionality wide event per scan, not a pile of narrow counters: the\n  per-run root span (`with-run-span.ts`) is enriched with the full outcome by\n  `cli/utils/build-run-event.ts` (`recordRunEvent`, plus the pure, testable\n  `buildRunEventAttributes`). `inspect.ts` calls it on the success path (after\n  `recordScanMetrics`) and, via a `try/catch` around the span body, on the\n  failure path — so the event lands with an `outcome.status` (`clean`/`ok`/\n  `blocked`/`error`), `outcome.exitCode`, and `outcome.errorTag` taxonomy even\n  when the scan throws. The run + project base context is already on the span, so\n  the event adds only what that doesn't — every attribute namespaced by concept\n  via `withNamespace` (`cli/utils/with-namespace.ts`) so the keys tree up in the\n  attribute browser: scan config (`scan.mode`, `scan.parallel`, `scan.workerCount`,\n  `scan.rulesConfigured`/`scan.rulesDisabled`, `scan.ignoredTagCount`,\n  `scan.hasCustomConfig`, … plus the `scan.fileCount` extent), the verdict\n  (`outcome.wouldBlock`/`outcome.blocking`/`outcome.clean`/`outcome.skippedChecks`),\n  findings (`diag.total`, `diag.errors`/`diag.warnings`, `diag.affectedFiles`,\n  `diag.distinctRules`, `diag.topRule`, per-category `diag.category.*`),\n  `score.value`/`score.label`/`score.available`, the `lint.*`/`maintainability.*`/\n  `supplyChain.*` pass outcomes, `timing.*` durations, and the CI/PR specifics\n  (`action.actorAssociation`, `action.runnerOs`, and the forwarded action knobs\n  `action.comment`/`action.reviewComments`/`action.versionPin`). Typing matters\n  for querying: numeric outcomes are numbers (so you can take `p75(score.value)`),\n  dimensions are strings/bools (so they filter/group); `null` is dropped via\n  `toSpanAttributes` so absent signals never become `\"null\"`. Query it with APL\n  over the traces dataset and build dashboards there instead of pre-aggregating\n  counters. Put new run-level dimensions on `build-run-context.ts` →\n  `build-sentry-scope.ts` so they ride every event and metric; put per-scan\n  outcome dimensions on the wide event (wrapped in `withNamespace`), **not** new\n  counters — the `scan.completed`/`scan.duration`/`rule.fired` counters stay as\n  the cheap floor alongside `cli.invoked`/`cli.error`. Score reachability is\n  derivable (`!score.available && !lint.failed && !maintainability.failed && !scan.noScore`)\n  and score latency is the `Score.compute` child span's duration, so neither\n  needs a dedicated field. CI detection + the official-action marker and\n  forwarded inputs live in `cli/utils/is-ci-environment.ts`; `action.yml` sets the\n  `REACT_DOCTOR_GITHUB_ACTION` marker + `REACT_DOCTOR_ACTION_*` env on its scan\n  step. Keep every attribute free of username, path, secret, and repo/owner identity.\n- **Opt-out.** `cli/utils/is-telemetry-enabled.ts` is the single gate for every\n  backend: `--no-score`, `--no-telemetry`, `REACT_DOCTOR_NO_TELEMETRY`, or a test\n  run disables all of it. Blanking `SENTRY_DSN` alone no longer silences\n  anything, so internal tooling that must not report (the evals harness, the\n  benchmark environment, the delta-audit runner) sets `REACT_DOCTOR_NO_TELEMETRY`.\n- **Credentials.** The Axiom ingest token is embedded in `cli/utils/constants.ts`\n  (`AXIOM_INGEST_TOKEN`), mirroring the public Sentry DSN — but unlike a DSN it is\n  a real credential, so it is minted **ingest-only and scoped to the two\n  datasets**. It ships in the tarball and is therefore extractable: rotation means\n  cutting a release, and an Axiom monitor on anomalous ingest volume is the\n  detection. `cli/utils/resolve-axiom-telemetry-options.ts` resolves it (with\n  `REACT_DOCTOR_AXIOM_TOKEN` / `_DOMAIN` / `_DATASET` overrides for local\n  testing — deliberately prefixed, since the bare `AXIOM_*` names are Axiom's\n  own and reading them would hijack the telemetry of anyone already running it)\n  and returns `null` when unset, so an unconfigured build simply doesn't export.\n  Core never reads these itself — keeping them CLI-side is what makes\n  `@react-doctor/api` silent by construction rather than by a runtime guard.\n- **runId.** `cli/utils/run-id.ts` mints one random `runId` per CLI run\n  (process). It rides the Sentry `run` context and the wide event, and is a tag\n  only on crash reports (where it links to the Axiom trace) — never a metric\n  attribute, where a per-run unique value would explode counter cardinality. A\n  workspace invocation scanning several projects shares one `runId`; the\n  per-project span attributes disambiguate. Do not add a plaintext or hashed repo\n  id to either backend.\n\n### Console / logging\n\n- ALWAYS: `import * as Console from \"effect/Console\"` and `yield* Console.log(...)` /\n  `Console.warn(...)` / `Console.error(...)` from inside renderers, services, and any\n  Effect-typed code. Effect's `Console` is a `Context.Reference` whose default sink is\n  `globalThis.console`, so the production path is identical to a raw `console.log`\n  while remaining swappable for tests / silent mode.\n- NEVER: invent a parallel `Logger` / `LoggerWriter` abstraction. The historical custom\n  Logger service was removed when the renderer pipeline went Effect-typed; the only\n  remaining bridge is `cli/utils/cli-logger.ts`, a thin sync wrapper around\n  `Effect.runSync(Console.X)` for imperative CLI helpers that aren't yet `Effect.gen`.\n- Silent mode is `Effect.provideService(Console.Console, silentConsole)` (renderer\n  pipeline) or `installSilentConsole()` (JSON mode, which monkey-patches the global\n  console because the surrounding CLI command body is imperative). Both routes leave\n  the underlying `Console.*` Effect intact — there is no `if (silent) return` check\n  at any call site.\n\n## Testing\n\nTests live alongside source in each package's `tests/` directory:\n\n- `packages/core/tests/` — service tests + run-inspect orchestration tests\n- `packages/api/tests/` — api shell tests\n- `packages/react-doctor/tests/` — CLI + end-to-end fixture tests\n\nTest framework is `vite-plus/test` (the existing vitest wrapper).\n\nRun checks always before committing with:\n\n```bash\npnpm test         # all packages\npnpm lint\npnpm typecheck\npnpm format       # use `format:check` to verify only\npnpm smoke:json-report   # validates the built CLI's JSON output against the schema\n```\n\n## Release authorization\n\n- MUST: Discourage minor and major Changesets. Do not add one unless the user explicitly requests\n  that release level. Patch Changesets may be added without a separate request when appropriate.\n- MUST: Never merge a Changesets release PR, including any `changeset-release/*` branch, without\n  fresh, explicit user confirmation for that exact PR and version immediately before the merge.\n- General instructions to merge, ship, land, or babysit green PRs do not authorize merging a\n  release/version PR. Treat merging a PR that triggers publication as publishing the release.\n- MUST: Never publish packages, push or move release tags, or trigger, approve, rerun, or merge a\n  release/publish workflow without fresh, explicit user confirmation for the exact versions and\n  packages involved.\n- Agents may prepare, validate, and babysit a release candidate, but must stop before the first\n  publishing action and report the exact PR, versions, packages, tags, and workflows awaiting user\n  approval.\n- These confirmation requirements also apply to GitHub Action releases described below. Once the\n  user explicitly approves a specific release, follow all required versioning and tag steps.\n\n## GitHub Action versioning\n\nThe composite GitHub Action is **versioned independently from the npm packages**. \"The action\"\nis `action.yml` (repo root) plus the scripts it shells out to (`scripts/ensure-json-report.mjs`,\n`scripts/normalize-changed-files.mjs`, `scripts/render-github-action-comment.mjs`,\n`scripts/resolve-package-spec.mjs`). Treat a change to any of those files as an action release,\nand keep the list in sync with `ACTION_RELEASE_FILES` in\n`scripts/recommend-action-version-bump.mjs` (the release guard).\n\n- Two tag namespaces coexist — never conflate them:\n  - npm packages — `react-doctor@X.Y.Z`, `eslint-plugin-react-doctor@X.Y.Z`,\n    `oxlint-plugin-react-doctor@X.Y.Z` (created by Changesets in CI; see\n    `.github/workflows/publish.yml`).\n  - GitHub Action — `v`-prefixed semver `vX.Y.Z` plus a floating major `vN` (the GitHub Actions\n    convention; the `v` prefix keeps these distinct from the unprefixed package tags above).\n    Current: the `v2.x` line (check `git tag --list 'v*'` for the latest); `v2` → the same\n    commit. The `v0.x` line is the pre-rebuild action; the `f4035fce` PR-reporting rebuild is\n    `v1.0.0`.\n- MUST: cut a tag on every commit that touches the action files. `feat(action)` → minor bump;\n  everything else (`fix` / `refactor` / `chore` / `revert` / docs-only edits to `action.yml`) →\n  patch bump. A breaking change to inputs/outputs or the runtime contract → major bump.\n- MUST: after tagging a new `vX.Y.Z`, move the floating major `vN` to that same commit so\n  `uses: millionco/react-doctor@vN` keeps resolving to the latest compatible release.\n- Tags are GPG-signed annotated tags (`tag.gpgsign=true`), so a bare `git tag vX` will demand a\n  message and fail in scripts. Always create/move with an explicit message:\n\n```bash\n# new release at the commit that changed the action\ngit tag -a v2.2.3 <commit> -m \"react-doctor action v2.2.3\"\n# move the floating major (force-update only the vN pointer)\ngit tag -fa v2 <commit> -m \"react-doctor action v2 (floating major -> v2.2.3)\"\ngit push origin v2.2.3\ngit push --force origin v2   # the force applies to the moving major tag only\n```\n\n- MUST: never tell consumers to reference `@main` in docs/examples. `@main` runs whatever HEAD\n  points to with `pull-requests: write` granted — a supply-chain risk (issue #299). Recommend a\n  full commit-SHA pin with a trailing version comment for hardened CI\n  (`uses: millionco/react-doctor@<sha> # v2.2.2`), or `@vN` for convenience.\n\n## Reference reading\n\n- `tmp/effect/.patterns/effect.md` — canonical Effect v4 idioms (cloned for reference,\n  gitignored)\n- `~/Developer/react-doctor-evals/src/` — sister application this codebase's runtime\n  patterns are modeled on (Schemas.ts, Runner.ts, Worker.ts, errors.ts shapes)\n","CLAUDE.md":"See [`AGENTS.md`](./AGENTS.md) for the contributor guide — conventions, package\nlayout, the rule pipeline, and release steps.\n\n@AGENTS.md\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","title":"AGENTS.md","content":"## General Rules\n\n- MUST: Use @antfu/ni. Use `ni` to install, `nr SCRIPT_NAME` to run. `nun` to uninstall.\n- MUST: Use TypeScript interfaces over types.\n- MUST: Keep all types in the global scope.\n- MUST: Use arrow functions over function declarations\n- MUST: Never comment unless absolutely necessary.\n  - If the code is a hack (like a setTimeout or potentially confusing code), it must be prefixed with // HACK: reason for hack\n- MUST: Use kebab-case for files\n- MUST: Use descriptive names for variables (avoid shorthands, or 1-2 character names).\n  - Example: for .map(), you can use `innerX` instead of `x`\n  - Example: instead of `moved` use `didPositionChange`\n- MUST: Frequently re-evaluate and refactor variable names to be more accurate and descriptive.\n- MUST: Do not type cast (\"as\") unless absolutely necessary\n- MUST: Remove unused code and don't repeat yourself.\n- MUST: Use `truffler` to 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\").\n- MUST: Always search the codebase, think of many solutions, then implement the most _elegant_ solution.\n- 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-thinking` pass (`.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.\n- MUST: Put all magic numbers in `constants.ts` using `SCREAMING_SNAKE_CASE` with unit suffixes (`_MS`, `_PX`).\n- MUST: Put small, focused utility functions in `utils/` with one utility per file.\n- MUST: Use Boolean over !!.\n\n## Symbol Search & Deduplication (truffler)\n\n`@rayhanadev/truffler` (dev dependency) is fuzzy JS/TS symbol search powered by `oxc-parser`.\nUse it to avoid duplicating existing code. The `find-similar-functions` skill\n(`.agents/skills/find-similar-functions/`) carries the full workflow; the short version:\n\n- WHEN PLANNING / SCOPING — before adding a utility, helper, type, constant, or rule, search\n  for an existing symbol to reuse or extend. Derive a few queries from the behavior (proposed\n  name + domain noun + verb), search the narrowest root first, then read the top matches before\n  writing anything. It is how you reuse an existing helper instead of duplicating it, per \"don't\n  repeat yourself\" and the one-utility-per-file `utils/` convention.\n- AFTER FINISHING A TASK — re-run searches for the symbols you added to confirm you did not\n  duplicate an existing helper, and delete any code your change superseded.\n\n```bash\nbunx @rayhanadev/truffler \"<query>\" packages --kind function,method,interface,type,constant --limit 20\n```\n\nRun it with `bunx @rayhanadev/truffler` (the published `bin` is a TypeScript entry Bun runs\ndirectly, and the pinned dev dependency is reused rather than re-downloaded). Narrow `<query>`\nand the root (e.g. `packages/core/src`) for precision; broaden only when nothing matches.\n\n## Package Layout\n\n```\npackages/\n  core/                          PRIVATE  the diagnostic engine\n    src/\n      types/                     PRIVATE shared cross-package TS types (DiagnoseOptions,\n                                 ProjectInfo, JsonReport, …) — no runtime code\n      project-info/              project discovery (discoverProject, findMonorepoRoot,\n                                 framework detection, narrow Error subclasses thrown\n                                 BEFORE the Effect runtime takes over)\n      errors.ts                  tagged Schema.TaggedErrorClass leaves + ReactDoctorError union\n      schemas.ts                 Diagnostic / Severity / JsonReport / buildDiagnosticIdentity\n                                 (also exposed as `@react-doctor/core/schemas` subpath\n                                 since the names overlap the TS types above)\n      refs.ts                    Context.Reference for ambient env config\n      run-inspect.ts             streaming orchestrator (the heart)\n      build-diagnostic-pipeline  per-element filter pipeline (single source of truth)\n      services/                  Context.Service implementations (Files, Git, Project,\n                                 Config, Linter, Maintainability, Score, Reporter,\n                                 Progress, NodeResolver, StagedFiles, SupplyChain)\n                                 + LintPartialFailures\n      ...                        rest of the lint / score / suppression engine\n  api/                           PRIVATE  programmatic diagnose() (Effect.runPromise shell)\n  react-doctor/                  PUBLISHED  CLI + public inspect() + bin\n  oxlint-plugin-react-doctor/    PUBLISHED  the 100+ rules, owns the canonical\n                                 `react-native-dependency-names.ts` (re-exported from\n                                 core to break the rule-package ↔ core cycle)\n  eslint-plugin-react-doctor/    PUBLISHED  ESLint mirror of the oxlint plugin\n```\n\n## Effect v4 Conventions\n\nBuilt on `effect@4.0.0-beta.102`. See `tmp/effect/.patterns/effect.md` (cloned reference)\nand `~/Developer/react-doctor-evals/src/` (the application that pioneered these patterns\nfor this codebase) for canonical examples.\n\n### Imports\n\n- ALWAYS: `import * as Schema from \"effect/Schema\"`, `import * as Effect from \"effect/Effect\"`,\n  `import * as Cause from \"effect/Cause\"`, etc. — one module per import line.\n- NEVER: `import { Schema, Effect } from \"effect\"` — the umbrella import inflates the\n  type-resolution graph and contradicts what every other Effect codebase does.\n\n### Errors\n\n- Every fallible service fails with `ReactDoctorError` (`reason: Schema.Union([...])`)\n- Each leaf is a `Schema.TaggedErrorClass<Self>()(\"Tag\", { fields })` with a\n  `get message()` getter (NOT `message =`) returning a human string.\n- Opaque causes use `Cause.pretty(Cause.fail(this.cause))` in the message body.\n- Renderers dispatch on `error.reason._tag`, NEVER on `error.message.includes(...)`.\n- `formatReactDoctorError(error)` / `isReactDoctorError(error)` / `isSplittableReactDoctorError(error)`\n  live in `core/src/errors.ts`. Use them; don't add new error-shape helpers.\n\n### Error dispatch / recovery — v4 idioms\n\n- **`Effect.catchReasons(errorTag, cases, orElse?)`** — the v4-canonical way to\n  dispatch on a `Schema.TaggedErrorClass` reason union. Each entry catches one\n  reason `_tag`; the optional `orElse` handles unmatched reasons. NEVER write\n  manual `if (cause.reason instanceof X)` ladders inside a `catch` block — the\n  Effect pipeline gives you exhaustive, type-safe narrowing for free. See\n  `inspect.ts → restoreLegacyThrow` and `api/diagnose.ts` for the canonical\n  shape.\n- **`Effect.catchTag(tag, handler)`** — for a single tagged error (e.g.\n  `Effect.catchTag(\"PlatformError\", ...)` in `services/git.ts` to fold the\n  `ChildProcess` platform error into a `ReactDoctorError`).\n- **`Effect.catch`** (renamed from v3 `Effect.catchAll`) — for catch-all.\n- **`Effect.die(error)`** — promote a recovered value into a defect that\n  `runPromise` re-throws unchanged. Used in `catchReasons` handlers when the\n  programmatic contract still wants the legacy `Error` class on the throw.\n- **NEVER** `try/catch` inside `Effect.gen` (v4 hard rule). Wrap the sync\n  throw in `Effect.try({ try, catch })` and recover via\n  `Effect.orElseSucceed` / `Effect.catch` instead. See\n  `render-summary.ts → printSummary` for the canonical shape.\n\n### Generator hygiene\n\n- **`return yield* Effect.fail(...)`** — terminal effects (Effect.fail,\n  Effect.interrupt, Effect.die) must be `return yield*` so TypeScript sees\n  the unreachable-code property. Bare `yield*` of a terminal lets unreachable\n  code accumulate after it. See `services/git.ts` `diffSelection` for examples.\n- **`Effect.gen({ self: this }, function* () { ... })`** — v4 changed the\n  `self`-bound form. The plain `Effect.gen(function* () { ... })` form is\n  unchanged; only class-method generators bound to `this` need the options\n  object.\n- **`Effect.fnUntraced(function* () { ... })`** — prefer over a function\n  whose body is `Effect.gen` when the function is called many times per\n  operation (hot path). Cuts tracing overhead. Not currently used in this\n  codebase — Git invocations and inspect-pipeline calls run once per scan,\n  not in a hot loop.\n\n### Services\n\n- `Context.Service<Self, Interface>()(\"react-doctor/Name\", { make: ... })` — short\n  prefix in the identifier (matches react-doctor-evals' `rde/X` shape).\n- Service method bodies use `Effect.fnUntraced` for hot paths, `Effect.sync` for\n  one-liners. Test layers + orchestration use `Effect.gen`.\n- **`Effect.fn(\"Service.method\")`** for non-trivial methods so they surface as\n  named spans in OTel traces. Production cost is zero when no tracer layer is\n  provided; with `Otlp.layerJson(...)` users see one span per service call.\n  Canonical eval pattern (`react-doctor-evals/src/Runner.ts` → every method).\n- `Service.of({ ... })` everywhere inside `Layer.succeed` / `make:` — never\n  `{ ... } as const`.\n- `Layer.effect` when the service has init work (e.g. `Cache.make`); `Layer.succeed`\n  when stateless.\n- Method takes a single object arg when there are >1 parameters\n  (e.g. `Files.readLines({ filePath, rootDirectory })`).\n\n### Layer naming\n\n- `layerNode` for the production Node.js implementation.\n- `layerOf(value)` for the test layer that returns a pre-supplied value.\n- `layerInMemory(Map)` for filesystem-shaped services backed by an in-memory tree.\n- `layerCapture` for the test layer that records calls into a `Ref` exposed via a\n  sibling `*Capture` service (e.g. `ReporterCapture`, `ProgressCapture`).\n- `layerNoop` for the production layer that has void-return / discard semantics\n  (Reporter, Progress). Analyzers (Linter, Maintainability) use `layerOf([])` instead.\n- Implementation-specific names: `layerOxlint`, `layerHttp`, `layerOra(factory)`.\n\n### Schemas\n\n- Use `Schema.Class<Self>(\"Name\")({ fields })` for wire records.\n- Use `Schema.Literals([\"a\", \"b\"])` for unions of literals (plural), `Schema.Literal(1)`\n  for single literals.\n- `Schema.NullOr(X)` for `X | null`; `Schema.optional(X)` for `X?`.\n- `Schema.brand(\"X\")` via `.pipe()` for branded primitives.\n- Schema for wire types (Diagnostic, JsonReport); interfaces for arg types\n  (InspectInput, LintInput) — avoid runtime encode/decode cost on hot paths.\n\n### Ambient config\n\n- Env-var reads + cache paths go through `Context.Reference<T>(\"react-doctor/X\", { defaultValue })`.\n  See `core/src/refs.ts`. Tests override via `Layer.succeed(MyRef, ...)`.\n- Secrets (API tokens, signing keys) should prefer `Config.redacted(\"ENV_NAME\")` over\n  `Context.Reference` so they auto-redact in logs / traces. Group with `Config.all({ ... })`\n  at the service constructor when you need several. (Pattern from\n  `react-doctor-evals/src/GitHub.ts` — not yet used in this codebase; document\n  the convention so the first secret-shaped config does it right.)\n\n### Observability\n\nTelemetry is split across two backends, deliberately:\n\n- **Axiom** takes traces and metrics — the per-run wide event, every\n  `Effect.fn(\"Service.method\")` span, and all counters/distributions. Axiom bills\n  by ingested volume with no active-series limits, which is the model\n  high-cardinality wide events want.\n- **Sentry** takes crashes only. It keeps what it is good at: source-map\n  symbolication (`scripts/sentry-sourcemaps.mjs`), issue grouping, and a\n  quotable event id. Axiom has none of those.\n\nEffect exposes a single `Tracer` reference, so the two are mutually exclusive\nfor spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span.\n\n- Wrap the top-level entry of a multi-step operation in `Effect.withSpan(\"name\", { attributes })`.\n  See `core/src/run-inspect.ts → runInspect` for the canonical shape. Attribute\n  keys use dotted namespacing (`inspect.directory`, `inspect.isCi`).\n- Per-service-method spans come from `Effect.fn(\"Service.method\")` — see Services section\n  above. The two compose: `runInspect` is the parent span, every `Service.method` is a child.\n- **Transport.** `core/src/observability.ts` owns three layers.\n  `layerAxiomTraces` and `layerAxiomMetrics` are composed by hand rather than via\n  `Otlp.layer`, because Axiom routes each signal to a different dataset through a\n  different header (`X-Axiom-Dataset` vs `x-axiom-metrics-dataset`) and\n  `Otlp.layer` passes one `headers` object to every signal. `layerObservability`\n  merges them and picks the backend: a user-configured `REACT_DOCTOR_OTLP_*`\n  endpoint wins over first-party Axiom, else Axiom, else `Layer.empty`.\n  Serialization is **protobuf**, not JSON — Axiom's `/v1/metrics` accepts\n  `application/x-protobuf` only. `OtlpSerialization.layerProtobuf` ships with\n  Effect and adds no dependency.\n- **Build the layer exactly once per process.** `cli/utils/telemetry-runtime.ts`\n  builds it into a long-lived scope and shares the resulting `Context`; the run\n  root span, the scan program, and the renderer all draw their tracer from it.\n  Two reasons this is not optional. Child spans nest under the run span natively,\n  with no `ExternalSpan` stitching. And Effect tracks delta-temporality state on\n  the _metrics exporter instance_, so a second exporter starts with no previous\n  snapshot and re-reports every counter's full value — silently doubling every\n  metric. `shutdownTelemetry()` is memoized for the same reason: several exit\n  paths can fire (a `--debug` user error flushes, then the top-level catch\n  flushes again). A regression test in `core/tests/telemetry-payload.test.ts`\n  pins the double-count behavior so a refactor that merges the layers fails loudly.\n- **Exit flush.** Closing the telemetry scope is what ships buffered spans and\n  metrics, bounded by `TELEMETRY_SHUTDOWN_TIMEOUT_MS` (1s — deliberately tighter\n  than Sentry's 2s error flush). The periodic `TELEMETRY_EXPORT_INTERVAL_MS` is\n  longer than any realistic run so the scope close is the only export. Note the\n  metrics exporter passes `maxBatchSize: \"disabled\"` internally, which skips\n  Effect's empty-buffer short-circuit — it POSTs on every scope close whether or\n  not anything was recorded, so on a firewalled machine that request cannot fail\n  fast.\n- **Anonymization.** Telemetry must stay anonymized, and OTLP has **no**\n  `beforeSend`-style hook — the safety net Sentry gave us for free had to be\n  rebuilt. Two mechanisms now carry it:\n  - `core/src/utils/make-scrubbing-tracer.ts` wraps the tracer so every span\n    name, attribute value, and event payload passes through `anonymizeText`\n    (home dir / username → `~`, then secrets and emails redacted) on the way to\n    the exporter. It is applied inside `layerAxiomTraces`, so an attribute added\n    later cannot bypass it.\n  - `cli/utils/record-metric.ts` scrubs attribute values at the emit site, since\n    metrics never touch the tracer.\n\n  Call sites should still scrub at the source — `run-inspect.ts` wraps\n  `inspect.directory` in `scrubSensitivePaths` — and the tracer is the backstop\n  for the ones that don't. `scrub-sentry-event.ts` still runs as Sentry's\n  `beforeSend` for crash reports. If you add a field to any payload, confirm it\n  carries no username, hostname, IP, secret, or absolute path.\n\n- **Sentry scope shape.** `cli/utils/build-sentry-scope.ts` is the one place that\n  projects the run snapshot (and the scanned project, once known) into `tags` +\n  `contexts`. Both `instrument.ts` (`initialScope`) and `report-error.ts` consume\n  it, and `record-metric.ts` reprojects its `tags` onto every metric at emit\n  time — so add new run-level metadata there, not at call sites. Project info is\n  captured via `recordSentryProjectContext` (`with-run-span.ts`), which both\n  remembers it for the lazy error path and sets it as root-span attributes.\n- **Crash references + trace linkage.** `reportErrorToSentry` returns the Sentry\n  event id; the CLI catch blocks thread it into `handleError` so it's printed as\n  a user-quotable reference and added to the prefilled GitHub issue. Because\n  spans now live in Axiom, Sentry's native trace linkage no longer reaches them —\n  instead the crash is tagged with `axiomTraceId` and `runId`, so a Sentry issue\n  pivots straight into the run's Axiom trace. `--debug` prints that same trace id.\n- **Metrics.** `cli/utils/record-metric.ts` (`recordCount` / `recordDistribution`)\n  writes into Effect's process-global `MetricRegistry`, which the OTLP exporter\n  snapshots at flush. Both are guarded by `isTelemetryEnabled()` so they are true\n  no-ops under `--no-score` / `--no-telemetry`, in tests, and for the\n  `@react-doctor/api` library. Metric names live in the `METRIC` map in\n  `cli/utils/constants.ts` (dotted, domain-grouped; high-cardinality dimensions\n  go in attributes, never the name). Two constraints differ from Sentry:\n  Effect metric attributes are **string-only**, so numbers and booleans are\n  stringified (`null`/`undefined` are dropped, never coerced to `\"null\"`); and\n  histograms need explicit bucket boundaries, so all distributions share\n  `METRIC_DISTRIBUTION_BOUNDARIES`. Per-scan metrics (`scan.*`, `rule.fired`,\n  `lint.failed`, …) are emitted by `cli/utils/record-scan-metrics.ts`;\n  `rule.fired` is one high-cardinality counter keyed by\n  `rule`/`plugin`/`category`/`severity` attributes (never a metric-name-per-rule).\n- **The canonical run wide event.** The richest telemetry is one\n  high-dimensionality wide event per scan, not a pile of narrow counters: the\n  per-run root span (`with-run-span.ts`) is enriched with the full outcome by\n  `cli/utils/build-run-event.ts` (`recordRunEvent`, plus the pure, testable\n  `buildRunEventAttributes`). `inspect.ts` calls it on the success path (after\n  `recordScanMetrics`) and, via a `try/catch` around the span body, on the\n  failure path — so the event lands with an `outcome.status` (`clean`/`ok`/\n  `blocked`/`error`), `outcome.exitCode`, and `outcome.errorTag` taxonomy even\n  when the scan throws. The run + project base context is already on the span, so\n  the event adds only what that doesn't — every attribute namespaced by concept\n  via `withNamespace` (`cli/utils/with-namespace.ts`) so the keys tree up in the\n  attribute browser: scan config (`scan.mode`, `scan.parallel`, `scan.workerCount`,\n  `scan.rulesConfigured`/`scan.rulesDisabled`, `scan.ignoredTagCount`,\n  `scan.hasCustomConfig`, … plus the `scan.fileCount` extent), the verdict\n  (`outcome.wouldBlock`/`outcome.blocking`/`outcome.clean`/`outcome.skippedChecks`),\n  findings (`diag.total`, `diag.errors`/`diag.warnings`, `diag.affectedFiles`,\n  `diag.distinctRules`, `diag.topRule`, per-category `diag.category.*`),\n  `score.value`/`score.label`/`score.available`, the `lint.*`/`maintainability.*`/\n  `supplyChain.*` pass outcomes, `timing.*` durations, and the CI/PR specifics\n  (`action.actorAssociation`, `action.runnerOs`, and the forwarded action knobs\n  `action.comment`/`action.reviewComments`/`action.versionPin`). Typing matters\n  for querying: numeric outcomes are numbers (so you can take `p75(score.value)`),\n  dimensions are strings/bools (so they filter/group); `null` is dropped via\n  `toSpanAttributes` so absent signals never become `\"null\"`. Query it with APL\n  over the traces dataset and build dashboards there instead of pre-aggregating\n  counters. Put new run-level dimensions on `build-run-context.ts` →\n  `build-sentry-scope.ts` so they ride every event and metric; put per-scan\n  outcome dimensions on the wide event (wrapped in `withNamespace`), **not** new\n  counters — the `scan.completed`/`scan.duration`/`rule.fired` counters stay as\n  the cheap floor alongside `cli.invoked`/`cli.error`. Score reachability is\n  derivable (`!score.available && !lint.failed && !maintainability.failed && !scan.noScore`)\n  and score latency is the `Score.compute` child span's duration, so neither\n  needs a dedicated field. CI detection + the official-action marker and\n  forwarded inputs live in `cli/utils/is-ci-environment.ts`; `action.yml` sets the\n  `REACT_DOCTOR_GITHUB_ACTION` marker + `REACT_DOCTOR_ACTION_*` env on its scan\n  step. Keep every attribute free of username, path, secret, and repo/owner identity.\n- **Opt-out.** `cli/utils/is-telemetry-enabled.ts` is the single gate for every\n  backend: `--no-score`, `--no-telemetry`, `REACT_DOCTOR_NO_TELEMETRY`, or a test\n  run disables all of it. Blanking `SENTRY_DSN` alone no longer silences\n  anything, so internal tooling that must not report (the evals harness, the\n  benchmark environment, the delta-audit runner) sets `REACT_DOCTOR_NO_TELEMETRY`.\n- **Credentials.** The Axiom ingest token is embedded in `cli/utils/constants.ts`\n  (`AXIOM_INGEST_TOKEN`), mirroring the public Sentry DSN — but unlike a DSN it is\n  a real credential, so it is minted **ingest-only and scoped to the two\n  datasets**. It ships in the tarball and is therefore extractable: rotation means\n  cutting a release, and an Axiom monitor on anomalous ingest volume is the\n  detection. `cli/utils/resolve-axiom-telemetry-options.ts` resolves it (with\n  `REACT_DOCTOR_AXIOM_TOKEN` / `_DOMAIN` / `_DATASET` overrides for local\n  testing — deliberately prefixed, since the bare `AXIOM_*` names are Axiom's\n  own and reading them would hijack the telemetry of anyone already running it)\n  and returns `null` when unset, so an unconfigured build simply doesn't export.\n  Core never reads these itself — keeping them CLI-side is what makes\n  `@react-doctor/api` silent by construction rather than by a runtime guard.\n- **runId.** `cli/utils/run-id.ts` mints one random `runId` per CLI run\n  (process). It rides the Sentry `run` context and the wide event, and is a tag\n  only on crash reports (where it links to the Axiom trace) — never a metric\n  attribute, where a per-run unique value would explode counter cardinality. A\n  workspace invocation scanning several projects shares one `runId`; the\n  per-project span attributes disambiguate. Do not add a plaintext or hashed repo\n  id to either backend.\n\n### Console / logging\n\n- ALWAYS: `import * as Console from \"effect/Console\"` and `yield* Console.log(...)` /\n  `Console.warn(...)` / `Console.error(...)` from inside renderers, services, and any\n  Effect-typed code. Effect's `Console` is a `Context.Reference` whose default sink is\n  `globalThis.console`, so the production path is identical to a raw `console.log`\n  while remaining swappable for tests / silent mode.\n- NEVER: invent a parallel `Logger` / `LoggerWriter` abstraction. The historical custom\n  Logger service was removed when the renderer pipeline went Effect-typed; the only\n  remaining bridge is `cli/utils/cli-logger.ts`, a thin sync wrapper around\n  `Effect.runSync(Console.X)` for imperative CLI helpers that aren't yet `Effect.gen`.\n- Silent mode is `Effect.provideService(Console.Console, silentConsole)` (renderer\n  pipeline) or `installSilentConsole()` (JSON mode, which monkey-patches the global\n  console because the surrounding CLI command body is imperative). Both routes leave\n  the underlying `Console.*` Effect intact — there is no `if (silent) return` check\n  at any call site.\n\n## Testing\n\nTests live alongside source in each package's `tests/` directory:\n\n- `packages/core/tests/` — service tests + run-inspect orchestration tests\n- `packages/api/tests/` — api shell tests\n- `packages/react-doctor/tests/` — CLI + end-to-end fixture tests\n\nTest framework is `vite-plus/test` (the existing vitest wrapper).\n\nRun checks always before committing with:\n\n```bash\npnpm test         # all packages\npnpm lint\npnpm typecheck\npnpm format       # use `format:check` to verify only\npnpm smoke:json-report   # validates the built CLI's JSON output against the schema\n```\n\n## Release authorization\n\n- MUST: Discourage minor and major Changesets. Do not add one unless the user explicitly requests\n  that release level. Patch Changesets may be added without a separate request when appropriate.\n- MUST: Never merge a Changesets release PR, including any `changeset-release/*` branch, without\n  fresh, explicit user confirmation for that exact PR and version immediately before the merge.\n- General instructions to merge, ship, land, or babysit green PRs do not authorize merging a\n  release/version PR. Treat merging a PR that triggers publication as publishing the release.\n- MUST: Never publish packages, push or move release tags, or trigger, approve, rerun, or merge a\n  release/publish workflow without fresh, explicit user confirmation for the exact versions and\n  packages involved.\n- Agents may prepare, validate, and babysit a release candidate, but must stop before the first\n  publishing action and report the exact PR, versions, packages, tags, and workflows awaiting user\n  approval.\n- These confirmation requirements also apply to GitHub Action releases described below. Once the\n  user explicitly approves a specific release, follow all required versioning and tag steps.\n\n## GitHub Action versioning\n\nThe composite GitHub Action is **versioned independently from the npm packages**. \"The action\"\nis `action.yml` (repo root) plus the scripts it shells out to (`scripts/ensure-json-report.mjs`,\n`scripts/normalize-changed-files.mjs`, `scripts/render-github-action-comment.mjs`,\n`scripts/resolve-package-spec.mjs`). Treat a change to any of those files as an action release,\nand keep the list in sync with `ACTION_RELEASE_FILES` in\n`scripts/recommend-action-version-bump.mjs` (the release guard).\n\n- Two tag namespaces coexist — never conflate them:\n  - npm packages — `react-doctor@X.Y.Z`, `eslint-plugin-react-doctor@X.Y.Z`,\n    `oxlint-plugin-react-doctor@X.Y.Z` (created by Changesets in CI; see\n    `.github/workflows/publish.yml`).\n  - GitHub Action — `v`-prefixed semver `vX.Y.Z` plus a floating major `vN` (the GitHub Actions\n    convention; the `v` prefix keeps these distinct from the unprefixed package tags above).\n    Current: the `v2.x` line (check `git tag --list 'v*'` for the latest); `v2` → the same\n    commit. The `v0.x` line is the pre-rebuild action; the `f4035fce` PR-reporting rebuild is\n    `v1.0.0`.\n- MUST: cut a tag on every commit that touches the action files. `feat(action)` → minor bump;\n  everything else (`fix` / `refactor` / `chore` / `revert` / docs-only edits to `action.yml`) →\n  patch bump. A breaking change to inputs/outputs or the runtime contract → major bump.\n- MUST: after tagging a new `vX.Y.Z`, move the floating major `vN` to that same commit so\n  `uses: millionco/react-doctor@vN` keeps resolving to the latest compatible release.\n- Tags are GPG-signed annotated tags (`tag.gpgsign=true`), so a bare `git tag vX` will demand a\n  message and fail in scripts. Always create/move with an explicit message:\n\n```bash\n# new release at the commit that changed the action\ngit tag -a v2.2.3 <commit> -m \"react-doctor action v2.2.3\"\n# move the floating major (force-update only the vN pointer)\ngit tag -fa v2 <commit> -m \"react-doctor action v2 (floating major -> v2.2.3)\"\ngit push origin v2.2.3\ngit push --force origin v2   # the force applies to the moving major tag only\n```\n\n- MUST: never tell consumers to reference `@main` in docs/examples. `@main` runs whatever HEAD\n  points to with `pull-requests: write` granted — a supply-chain risk (issue #299). Recommend a\n  full commit-SHA pin with a trailing version comment for hardened CI\n  (`uses: millionco/react-doctor@<sha> # v2.2.2`), or `@vN` for convenience.\n\n## Reference reading\n\n- `tmp/effect/.patterns/effect.md` — canonical Effect v4 idioms (cloned for reference,\n  gitignored)\n- `~/Developer/react-doctor-evals/src/` — sister application this codebase's runtime\n  patterns are modeled on (Schemas.ts, Runner.ts, Worker.ts, errors.ts shapes)\n","category":"root","tokens":6955},{"name":"CLAUDE.md","path":"CLAUDE.md","title":"CLAUDE.md","content":"See [`AGENTS.md`](./AGENTS.md) for the contributor guide — conventions, package\nlayout, the rule pipeline, and release steps.\n\n@AGENTS.md\n","category":"root","tokens":35}]}