Commit Graph

4 Commits

Author SHA1 Message Date
Aiden Bai 115fccf266 fix(cli): fail incomplete plugin scans (#1185)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Rayhan Noufal Arayilakath <me@rayhanadev.com>
2026-07-13 03:20:33 -04:00
Aiden Bai cbdff6203d feat(cli): "Add to CI" handoff option + default CI setup in install (#658)
Co-authored-by: Aiden Bai <aiden.bai05@gmail.com>
Co-authored-by: Nisarg Patel <awesomenisarg@gmail.com>
2026-06-04 04:15:05 -07:00
Ray Arayilakath 9777f1ac45 feat(cli): add rules management commands and doctor-explain skill (#617)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 05:55:47 -04:00
Aiden Bai 0a406389d4 feat(core): user-plugin extension — config.plugins: [...] for custom rules (#438)
* feat(core): user-plugin extension — \`config.plugins: [...]\` for custom rules

Users can now drop their own oxlint-shaped plugin into
\`react-doctor.config.json\`'s new \`plugins: [...]\` field and
react-doctor runs the rules alongside the built-in ones. Closes
the long-standing gap where the only way to ship a custom rule
was to fork the repo.

## Config shape

\`\`\`json
{
  "plugins": [
    "./lint/team-conventions.cjs",
    "react-doctor-plugin-shopify-conventions"
  ],
  "rules": {
    "team-conventions/no-bare-fetch": "error",
    "shopify-conventions/use-polaris-tokens": "warn"
  }
}
\`\`\`

Each \`plugins\` entry is either:
- A **relative path** (\`./\`, \`../\`, or absolute) resolved relative
  to the directory of the config file that declared it — same
  resolution shape as \`rootDir\`. NOT relative to CWD.
- An **npm package name** resolved via Node module resolution
  from the config source dir's \`node_modules\`.

The module must default-export an oxlint-plugin-shaped object:
\`{ meta: { name }, rules: Record<ruleName, HostRule> }\`. The
existing \`RulePlugin\` types from \`oxlint-plugin-react-doctor\`
are the contract — third-party plugins can import \`defineRule\`
+ \`Rule\` / \`RulePlugin\` types for a typed authoring surface.

## Behavior

- **Opt-in by default**: a user-plugin rule never runs unless
  \`rules: { "<plugin-name>/<rule>": "warn" | "error" }\` explicitly
  enables it. Mirrors \`defaultEnabled: false\` for built-in rules
  so installing a third-party plugin doesn't surprise the user
  with a flood of new diagnostics.
- **Namespace**: rule keys are \`<plugin.meta.name>/<rule-name>\`.
  If the plugin omits \`meta.name\`, react-doctor slugifies the
  filename (\`./lint/team-conventions.cjs\` → \`team-conventions\`).
- **Failure tolerance**: unresolvable entries / non-plugin-shaped
  modules / duplicate plugin names log a warning to stderr and
  are skipped. The scan continues — a typo in one entry doesn't
  block the rest.
- **Full surface integration**: user-plugin rules flow through
  every existing severity / ignore / surface knob (\`rules\`,
  \`categories\`, \`ignore.{paths, rules, tags}\`,
  \`surfaces.{cli, prComment, score, ciFailure}\`) the same as
  built-in rules. Demote out of CI without touching the source.

## Implementation

- \`config.plugins?: string[]\` added to \`ReactDoctorConfig\` schema
  (\`packages/types/src/config.ts\`) with full inline docs.
- \`validateConfigTypes\` now coerces / strips the new field via
  the existing \`validateStringArrayField\` helper — typos / wrong
  types warn instead of crashing.
- \`resolveUserPlugin(spec, configSourceDir)\` + \`resolveUserPlugins\`
  in \`packages/core/src/runners/oxlint/plugin-resolution.ts\` —
  Node-module-resolution + introspection that returns
  \`{ entry: { name, specifier }, availableRuleNames }\` ready for
  inclusion in oxlint's \`jsPlugins\`. Slug fallback for plugins
  without \`meta.name\`.
- \`createOxlintConfig\` accepts \`userPlugins\` and folds each
  plugin's enabled rules + its specifier into the generated
  oxlintrc.json. Rules are opt-in via the existing
  \`severityControls\` lookup path so the existing severity /
  category / surface controls all keep working.
- \`runOxlint\` calls \`resolveUserPlugins(userConfig?.plugins, rootDirectory)\`
  and passes the result through. Zero call-site changes for
  consumers that don't opt in.

## Tests

\`packages/react-doctor/tests/user-plugins.test.ts\` (5 cases):

1. Relative-path plugin loads + surfaces diagnostics when enabled
2. User-plugin rules are opt-in by default (no severity → no fire)
3. \`rules: { "<plugin>/<rule>": "warn" }\` honored on output severity
4. Unresolvable plugin entry warns + scan continues
5. \`meta.name\` omission falls back to slugified filename

## Docs

New \`Custom rules (user plugins)\` section in
\`packages/react-doctor/README.md\` with the full author → register →
run flow + the behavioral notes above.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(user-plugins): thermo-nuclear cleanup — canonical helpers + required meta.name

Self-review against the thermo-nuclear-code-quality-review skill
caught four real code-quality issues. Fixed each.

### 1. Canonical helper duplication — `warnConfigIssue`

The codebase already exports `warnConfigIssue` from
`core/src/utils/warn-config-issue.ts`. The PR introduced four new
`process.stderr.write("[react-doctor] ...")` call sites in
`plugin-resolution.ts`, AND `validate-config-types.ts` had a
pre-existing duplicate `warnConfigField` doing the same thing.
Three copies of the same one-liner.

Replaced everything with `warnConfigIssue` imports. Net: removed
the `warnConfigField` constant + 4 inline writes, single canonical
warning helper across the config-validation surface.

### 2. Slug-from-filename fallback for `meta.name` — magical, fragile

Original PR let users omit `meta.name` and slugged the filename
(`./lint/team-rules.cjs` → namespace `team-rules`). This is
exactly the "ad-hoc magic that hides simple data-shape assumptions"
the skill flags: rename the file and your rule keys in
`config.rules` silently stop matching.

Switched to a hard contract: `meta.name` is required. A plugin
without it logs an actionable warning ("add
`module.exports = { meta: { name: '...' }, rules: {...} }`")
and is skipped. Deletes `slugifyUserPluginSpec` (the whole
function), one branch in `readPluginShape`, one bullet from the
README, and one test case (replaced with a regression test that
asserts the skip behavior).

### 3. Plugin-shape introspection duplicated

The existing `readPluginRuleNames` + `resolveReactHooksJsPlugin`
and the new `readPluginModule` + `readPluginShape` +
`resolveUserPlugin` were doing the same introspection twice.

Unified into a single `readPluginShape(specifier, loadModule)`
helper that returns `{ name, ruleNames }`. The `loadModule`
parameter lets each caller use its own `require` instance —
`resolveReactHooksJsPlugin` uses the file-local `esmRequire`,
`resolveUserPlugin` uses a `candidateRequire` rooted at the
config source directory so user-installed npm packages resolve
through the user's `node_modules`. `readPluginRuleNames` is
gone; both call sites now go through `readPluginShape`.

### 4. `MaybePluginModule` defined twice in same file

The pre-existing module-shape type (used by `readPluginRuleNames`)
and my new one (with `meta?`) were near-duplicates in
`plugin-resolution.ts`. Unified into a single permissive shape
with `meta?: { name?: unknown }` + `default?: MaybePluginModule`
that both code paths use.

### File sizes (skill's 1k rule)

No file crosses the 1000-line boundary. Touched files after this
cleanup:
  packages/core/src/runners/oxlint/plugin-resolution.ts   200
  packages/core/src/runners/oxlint/config.ts              169
  packages/core/src/validate-config-types.ts              208
  packages/core/src/run-oxlint.ts                         651
  packages/types/src/config.ts                            287
  packages/react-doctor/tests/user-plugins.test.ts        168

### Net diff

The cleanup removes more than it adds:
  - `slugifyUserPluginSpec` (entire function)
  - `warnConfigField` (entire constant + 11 call-site renames)
  - `readPluginRuleNames` (subsumed by `readPluginShape`)
  - 4 inline `process.stderr.write(...)` calls
  - Duplicate `MaybePluginModule` type

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(user-plugins): resolve from config source dir, not scan root (bugbot)

Bugbot found a real bug introduced by the original PR. \`config.plugins\`
docs and intent say "relative paths resolve against the directory of
the config file that declared them, same shape as \`rootDir\`," but
the implementation was passing \`rootDirectory\` (the post-\`rootDir\`
SCAN root) into \`resolveUserPlugins\`. The two diverge whenever
\`userConfig.rootDir\` redirects the scan to a subtree — the
canonical monorepo case.

Layout that broke:
  workspace/
    react-doctor.config.json  ← { rootDir: "apps/web", plugins: ["./lint/team.cjs"] }
    lint/team.cjs             ← plugin sits next to CONFIG
    apps/web/                 ← scan root after rootDir redirect

Before this fix: \`./lint/team.cjs\` resolved from \`apps/web/\` and
silently 404'd — every team setting up monorepo scoping would lose
their plugin without an actionable error.

### Fix: thread \`configSourceDirectory\` end-to-end

\`ResolvedConfig\` (from \`Config.resolve\`) now carries
\`configSourceDirectory: string | null\` alongside \`config\` +
\`resolvedDirectory\`. The Config layer populates it from
\`loadConfigWithSource\` (which already returns the source dir).
\`LintInput\` + \`RunOxlintOptions\` thread it through; the runner
defaults to \`rootDirectory\` for direct callers that don't load
a config file. Plugin resolution uses \`configSourceDirectory\`
so relative paths resolve against the config's location no
matter how aggressively \`rootDir\` redirects the scan.

### Regression test

New case in \`tests/user-plugins.test.ts\`:
"resolves a plugin spec from the config's source directory, not
 the scan root (rootDir redirect)" — constructs the exact
monorepo layout above (plugin at workspace root, scan target at
\`apps/web\`) and asserts the plugin's diagnostic fires.

### Test layer call-site updates

\`ResolvedConfig\` is now \`{ config, resolvedDirectory, configSourceDirectory }\`
— callers that constructed it explicitly (the 5 \`Config.layerOf({...})\`
test fixtures + \`inspect.ts\`'s \`configOverride\` path) now pass
\`configSourceDirectory: null\` to indicate "no canonical config
file source." When null, plugin resolution falls back to
\`rootDirectory\`, matching the prior behavior for the no-config-
file path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(user-plugins): keep userPlugins on extends-retry fallback (bugbot)

Bugbot regression on #438: when oxlint crashes on the user's
adopted \`.oxlintrc.json\`, react-doctor retries once without
\`extends\`. The fallback \`createOxlintConfig({...})\` call was
omitting \`userPlugins\`, so custom rules from \`config.plugins\`
ran on the first attempt and silently disappeared from the
successful retry. No warning, just missing diagnostics.

Pass \`userPlugins\` through the fallback alongside every other
field. Added a regression test that points
\`adoptExistingLintConfig: true\` at a project with a deliberately
broken \`.oxlintrc.json\` (one oxlint can't parse), forcing the
retry to fire, then asserts the user-plugin diagnostic still
surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(user-plugins): thread configSourceDirectory through inspect() (bugbot HIGH)

Bugbot HIGH on #438: even after threading \`configSourceDirectory\`
into the runtime/Linter/runOxlint layers, \`inspect.ts\` was
hard-coding \`configSourceDirectory: null\` on its
\`Config.layerOf(...)\` call — and the CLI ALWAYS goes through the
\`configOverride\` path (it loads the config itself then passes it
in via the option). So the CLI path lost the source-directory
information on every invocation, and \`config.plugins\` relative
paths silently failed under \`rootDir\` redirect, ancestor-loaded
configs, and \`--staged\` (which scans a temp tree).

The fix: \`inspect()\` already calls \`loadConfigWithSource(directory)\`
on the non-override path; capture its \`sourceDirectory\` and thread
it through \`runInspectWithRuntime\` → \`Config.layerOf({ ..., configSourceDirectory })\`
→ the runner. For the override-only path (programmatic API caller
that synthesizes a config), \`configSourceDirectory\` stays \`null\`
and the runner falls back to the scan root — the expected behavior
when no canonical config file exists.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:27:00 -07:00