Files
millionco__react-doctor/packages/react-doctor/tests/parse-react-major.test.ts
Aiden Bai aa61efae28 refactor: collapse @react-doctor/types and @react-doctor/project-info into core (#440)
* 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>
2026-05-23 01:32:44 -07:00

73 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import { parseReactMajor } from "@react-doctor/core";
describe("parseReactMajor", () => {
it("extracts the major from caret/tilde/exact ranges", () => {
expect(parseReactMajor("^19.0.0")).toBe(19);
expect(parseReactMajor("~18.3.1")).toBe(18);
expect(parseReactMajor("17.0.2")).toBe(17);
expect(parseReactMajor("19")).toBe(19);
expect(parseReactMajor("19.x")).toBe(19);
expect(parseReactMajor("v19.0.0")).toBe(19);
});
it("uses the lower bound on multi-comparator ranges", () => {
expect(parseReactMajor(">=18 <20")).toBe(18);
expect(parseReactMajor(">=18.3.1 <19")).toBe(18);
expect(parseReactMajor("18 || 19")).toBe(18);
expect(parseReactMajor("^18.0.0 || ^19.0.0")).toBe(18);
expect(parseReactMajor(">=17.0.0 <20.0.0")).toBe(17);
});
it("returns null for upper-bound-only ranges", () => {
expect(parseReactMajor("<19")).toBeNull();
expect(parseReactMajor("<=18")).toBeNull();
expect(parseReactMajor("< 19")).toBeNull();
expect(parseReactMajor("<20 || >=17")).toBeNull();
});
it("returns null for tags, unresolvable protocols, and missing/empty input", () => {
expect(parseReactMajor(null)).toBeNull();
expect(parseReactMajor(undefined)).toBeNull();
expect(parseReactMajor("")).toBeNull();
expect(parseReactMajor(" ")).toBeNull();
expect(parseReactMajor("latest")).toBeNull();
expect(parseReactMajor("next")).toBeNull();
expect(parseReactMajor("workspace:*")).toBeNull();
expect(parseReactMajor("github:facebook/react#1234567890")).toBeNull();
expect(parseReactMajor("file:../react-19-local.tgz")).toBeNull();
expect(parseReactMajor("npm:@scope/react19-fork@latest")).toBeNull();
expect(parseReactMajor("*")).toBeNull();
});
it("ignores leading whitespace and prefixes", () => {
expect(parseReactMajor(" ^19.0.0 ")).toBe(19);
expect(parseReactMajor("npm:react@^19")).toBe(19);
expect(parseReactMajor("npm:@vendor/react@~18.2.0")).toBe(18);
expect(parseReactMajor(" v18.3.1 ")).toBe(18);
});
it("returns null for non-lower-bound comparators", () => {
expect(parseReactMajor(">18")).toBeNull();
expect(parseReactMajor(">18 <20")).toBeNull();
expect(parseReactMajor("!=19")).toBeNull();
});
it("returns null for React experimental / canary builds (0.0.0-...)", () => {
// React ships experimental and canary builds as `0.0.0-...` so
// the dependency graph stays semver-safe. The first-integer scan
// would land on `0` and misclassify them as older than any supported
// React major; we reject 0 → null so callers can treat them as
// unknown.
expect(parseReactMajor("0.0.0-experimental-abc123")).toBeNull();
expect(parseReactMajor("0.0.0-canary-1a2b3c4d-20251230")).toBeNull();
expect(parseReactMajor("^0.0.0-experimental")).toBeNull();
});
it("still reads pre-release tags on real majors", () => {
expect(parseReactMajor("19.0.0-rc.1")).toBe(19);
expect(parseReactMajor("19.0.0-canary-abc123-20251230")).toBe(19);
expect(parseReactMajor("^19.0.0-rc.1")).toBe(19);
});
});