refactor(capabilities): unify framework gating into one capability vocabulary (#976)#978
refactor(capabilities): unify framework gating into one capability vocabulary (#976)#978rayhanadev wants to merge 11 commits into
Conversation
Next.js `output: "export"` apps have no request-time server, so rules that recommend server `redirect()`, middleware, or Server Actions emitted impossible advice. Detect static export and surface it through a general framework-capability vocabulary so the relevant rules adapt: server-fetch-without-revalidate gates off, nextjs-no-client-side-redirect keeps firing but drops its server clause, and no-prevent-default falls back to the framework-neutral <form> message. Refactors buildCapabilities into one declarative capability table (the single source of truth across all frameworks) consumed via a runtime `capabilities` channel, replacing rules' hardcoded framework Sets. Consolidates project-info/ from 47 tiny files into 15 cohesive modules. Behavior-neutral; the public export surface is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
commit: |
Shorter name for the runtime capability accessor. Its doc comment now defines the whole vocabulary in one place: build-time gating (`requires` / `disabledBy` via `shouldEnableRule`) versus the runtime read (`hasCapability`). The `disabledBy` -> `disabledWhen` rename is left as a follow-up; it touches ~80 rule files plus the registry codegen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consistent vocabulary with `hasCapability`: `disabledWhen: ["react-compiler"]` reads "disabled when react-compiler is present." Pure rename of the rule-metadata field and the `shouldEnableRule` parameter across 21 files; behavior is unchanged (net 0 LOC). Historical CHANGELOG entries keep the old name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ned union
`requires` / `disabledWhen` / `hasCapability` were stringly typed, so a
misspelled token ("sever-actions") was a silently never-matching gate.
The vocabulary now lives in one typed `Capability` union in the plugin
(core imports the plugin, never the reverse), and core's `Framework`
aliases the plugin's `FrameworkToken` so the two unions cannot drift.
Registry codegen emits `new Set<Capability>(...)` so the merged
`requires` arrays keep their literal types through the spread.
Also adds the (not yet consumed) `recommendationFor` hook: a
capability-conditioned override of `recommendation`, evaluated later by
core's diagnostic pipeline — groundwork for moving rule prose out of
parse-output.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oized, straight-line `buildCapabilities` is a pure projection over `ProjectInfo`, consumed by the oxlint config, the security scan, and (next commit) the diagnostic pipeline — it never belonged to the oxlint runner. It now lives in `project-info/`, and `getCapabilities` memoizes one set per `ProjectInfo` identity (discoverProject caches one instance per directory, so every consumer shares a single computation). The `CapabilityRule` closure table (three mutually-exclusive row shapes + interpreter loop) is replaced by straight-line adds — the rows were closures, so the table was never data; the ifs read the same, typecheck against `Capability`, and drop ~50 lines of mechanism. One helper remains for the react/preact major ladders. New `client-only` trait row: the SPA / mobile framework list moves out of `no-prevent-default` (consumed there in the next commit) into the one projection, so rules ask a capability question instead of keeping their own framework sets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…special cases `parse-output.ts` carried a rule-name string match rewriting nextjs-no-client-side-redirect's advice under static export (reading `project.isStaticExport` directly, bypassing the capability vocabulary) plus a second special case for no-secrets-in-client-code's per-framework env-prefix advice. Both move into the rules themselves via the `recommendationFor(hasCapability)` hook; `getRuleRecommendation` collapses to one generic dispatch and core's build-no-secrets-recommendation.ts / get-public-env-prefix.ts are deleted. Core now carries zero rule-specific prose. `no-prevent-default` finishes its capability conversion: the hardcoded CLIENT_ONLY_FRAMEWORKS set (kept alongside the hasCapability check in the original conversion) becomes the `client-only` capability, checked at runtime so the `<a onClick preventDefault>` variant still fires on SPA frameworks. The framework list now lives only in buildCapabilities. All 75 regression pins (no-prevent-default framework matrix, #976 static-export e2e, no-secrets prose, rule-messages) pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-ProjectInfo snapshots over a workspace fixture matrix (pnpm named catalogs, yarn-workspaces web monorepo with an RN workspace, nx, leaf scan through monorepo catalogs, lowest-react-major merge, overlapping globs, the tailwind piggyback quirk). Every fixture is traversal-order- independent, so these pins must hold bit-for-bit across the legacy multi-walk discovery and the single-pass rewrite that follows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`discoverProject` ran up to ~7 separate workspace traversals per project — react/tailwind/zod/framework, RN awareness, reanimated, expo, flash-list, and next each re-resolved the same globs and re-visited the same manifests, and the react walk used raw readdir order (nondeterministic) while the others sorted. `collectWorkspaceFacts` now enumerates the workspace directories once (pattern order, sorted within each pattern, deduped across overlapping globs) and evaluates every workspace-derived fact per manifest, recording the source directory that supplied each dependency signal. Semantics preserved exactly: the stage-D react group still fills only when the root leaves react/framework unresolved (the tailwind/zod piggyback quirk), settles on the legacy early-exit condition (react ≤ 17 + tailwind + framework), and skips its catalog work entirely when the gate is closed; expoVersion/nextjsVersion/hasReanimated keep their semantic nulls; the leaf-vs-root fallback asymmetry in findDependencyInfoFromMonorepoRoot is unchanged (and now documented). The one intended change: framework/react workspace precedence is now sorted-deterministic instead of readdir-order. The full discovery spec (~100 cases), the new characterization pins, and all 2,022 react-doctor e2e tests pass with zero assertion edits. Deletes per-dependency-version.ts and the five per-fact walkers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The static-export probe read `next.config.*` at the scan root only, while the `nextjs` framework signal can come from a workspace — so a monorepo whose `apps/web` sets `output: "export"` kept the `server-actions` capability and the impossible server advice #976 is about (the deferred Bugbot finding on this PR). The probe now reads the config next to the manifest that supplied the `next` dependency signal, riding the source directory the single-pass collector already records: the scan root when it declares `next` itself (unchanged), otherwise the first workspace in walk order that does — the same first-match attribution `nextjsVersion` uses, pinned for the multi-Next-workspace case. Covered at both levels: discovery tests (root config, workspace config, two-apps first-match, no-export), and a no-override e2e in the #976 regression suite proving a monorepo-root scan now softens the redirect advice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce merge Validated the sorted-order determinism change against the 863-repo RDE corpus by diffing discoverProject output pre/post rewrite. Plain first-in-sorted-order let an `apps/mobile` Expo workspace claim the framework slot from an `apps/web` Next.js app purely because "mobile" sorts first — silently dropping all 23 `requires: ["nextjs"]` rules on web-primary monorepos (linkwarden, t4-app, taro in the corpus). The merge is now two-tier: web frameworks outrank expo/react-native, walk order only breaks ties within a tier — the same web-over-mobile priority detectFramework already applies within one manifest, and strictly coverage-preserving (`rn-*` / Expo rules ride hasReactNativeWorkspace / expoVersion, not the framework slot; mobile and web SPA frameworks carry `client-only` alike). A full-priority rank across all workspaces was measured too and rejected: it reclassified 34 corpus repos (vite apps flipping to their Next.js docs-site workspace). Final corpus blast radius: 23/863 repos (2.7%) — no repo loses RN/Expo coverage, several web-primary monorepos regain their Next.js rules, the two isStaticExport flips are real workspace static exports (dual-mode conditional configs, treated as export-capable by design), and the rest is deterministic version re-attribution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a3b51be. Configure here.
There was a problem hiding this comment.
Pull request overview
This PR fixes Next.js output: "export" (static export) false-positive/unactionable advice by replacing per-rule, framework-name-only gating with a single shared, typed “capability” vocabulary produced from ProjectInfo. Rules can now (a) fully disable via requires / disabledWhen and (b) tailor their recommendation text via recommendationFor(hasCapability) when the project’s capability set is known.
Changes:
- Added typed
Capability/FrameworkTokenvocabulary and plumbed a sortedcapabilitiesarray through oxlint settings for consistent gating and caching. - Added Next.js static export detection (
next.config.*→isStaticExport) and capability projection (nextjs:static-export), updating rule behavior accordingly. - Refactored project discovery to a single workspace traversal and consolidated many
project-info/helpers (version parsing, workspace scanning, RN metadata).
Reviewed changes
Copilot reviewed 106 out of 106 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react-doctor/tests/regressions/scan-resilience.test.ts | Updates gating field name in comments |
| packages/react-doctor/tests/regressions/nextjs-static-export.test.ts | New E2E regression coverage for #976 |
| packages/react-doctor/tests/regressions/_helpers.ts | Adds isStaticExport test-project knob |
| packages/oxlint-plugin-react-doctor/src/react-native-dependency-names.ts | Updates comment path after refactor |
| packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts | Types requires/disabledWhen; adds recommendationFor |
| packages/oxlint-plugin-react-doctor/src/plugin/utils/get-react-doctor-setting.ts | Adds hasCapability helper |
| packages/oxlint-plugin-react-doctor/src/plugin/utils/capability.ts | Defines typed capability vocabulary |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/redux-useselector-returns-new-collection.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/redux-useselector-inline-derivation.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/server/server-fetch-without-revalidate.ts | Disables on nextjs:static-export; renames field |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/security/no-secrets-in-client-code.ts | Adds capability-conditioned recommendation text |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-inline-object-in-list-item.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-inline-flatlist-renderitem.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-list-callback-per-row.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/jsx-no-new-object-as-prop.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/jsx-no-new-function-as-prop.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/jsx-no-new-array-as-prop.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/jsx-no-jsx-as-prop.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/jsx-no-constructed-context-values.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/performance/prefer-stable-empty-fallback.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-client-side-redirect.ts | Softens recommendation under nextjs:static-export |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/js-performance/js-tosorted-immutable.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-prevent-default.ts | Switches from framework lists to capabilities |
| packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value.ts | Renames disabledBy → disabledWhen |
| packages/oxlint-plugin-react-doctor/src/index.ts | Exposes capability vocabulary from package |
| packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs | Emits typed Capability in registry output |
| packages/core/tests/parse-zod-major.test.ts | Removes superseded parser test |
| packages/core/tests/parse-tailwind-major-minor.test.ts | Removes superseded helper tests |
| packages/core/tests/parse-react-major-minor.test.ts | Removes superseded helper tests |
| packages/core/tests/major-minor.test.ts | Adds shared version-compare tests |
| packages/core/tests/discover-project.test.ts | Adds isStaticExport detection tests |
| packages/core/tests/discover-project-characterization.test.ts | New characterization pins for discovery rewrite |
| packages/core/tests/detect-pre-es2023-target.test.ts | Updates imports after refactor |
| packages/core/tests/detect-nextjs-static-export.test.ts | New detector regression coverage |
| packages/core/tests/build-capabilities.test.ts | Expands capability projection + memoization tests |
| packages/core/src/utils/is-project-boundary.ts | Updates monorepo-root import path |
| packages/core/src/utils/get-public-env-prefix.ts | Removes helper (moved to rule) |
| packages/core/src/utils/classify-package-role.ts | Updates comment path after refactor |
| packages/core/src/utils/build-no-secrets-recommendation.ts | Removes helper (moved to rule) |
| packages/core/src/types/project-info.ts | Adds isStaticExport; Framework type alias |
| packages/core/src/types/index.ts | Updates comment path after refactor |
| packages/core/src/services/git.ts | Updates fs-utils import path |
| packages/core/src/runners/oxlint/parse-output.ts | Uses capabilities + recommendationFor for help |
| packages/core/src/runners/oxlint/config.ts | Uses getCapabilities; serializes capabilities setting |
| packages/core/src/runners/oxlint/capabilities.ts | Removes old capabilities implementation |
| packages/core/src/project-info/workspaces.ts | New consolidated workspace helpers |
| packages/core/src/project-info/version.ts | New consolidated version parsing utilities |
| packages/core/src/project-info/utils/is-plain-object.ts | Moved into fs-utils |
| packages/core/src/project-info/utils/is-package-json-reanimated-aware.ts | Moved into rn-metadata |
| packages/core/src/project-info/utils/is-package-json-react-native-aware.ts | Moved into rn-metadata |
| packages/core/src/project-info/utils/is-file.ts | Moved into fs-utils |
| packages/core/src/project-info/utils/is-directory.ts | Moved into fs-utils |
| packages/core/src/project-info/utils/get-dependency-spec.ts | Moved into dependencies |
| packages/core/src/project-info/utils/get-dependency-declaration.ts | Moved into dependencies |
| packages/core/src/project-info/utils/dependency-version-spec.ts | Moved into version |
| packages/core/src/project-info/some-workspace-package-json.ts | Removed (covered by collect pass) |
| packages/core/src/project-info/rn-metadata.ts | New consolidated RN/expo/reanimated metadata helpers |
| packages/core/src/project-info/resolve-workspace-directories.ts | Removed (moved to workspaces) |
| packages/core/src/project-info/resolve-effective-react-major.ts | Removed (moved to version) |
| packages/core/src/project-info/resolve-catalog-backed-dependency-version.ts | Removed (moved to dependencies) |
| packages/core/src/project-info/parse-zod-major.ts | Removed (inlined via getLowestDependencyMajor) |
| packages/core/src/project-info/parse-tailwind-major-minor.ts | Removed (moved to version) |
| packages/core/src/project-info/parse-react-peer-range.ts | Removed (moved to version) |
| packages/core/src/project-info/parse-react-major.ts | Removed (moved to version) |
| packages/core/src/project-info/parse-react-major-minor.ts | Removed (moved to version) |
| packages/core/src/project-info/parse-pnpm-workspace-patterns.ts | Removed (moved to workspaces) |
| packages/core/src/project-info/package-json.ts | Adds cycle-breaking comment; renamed module |
| packages/core/src/project-info/monorepo-root.ts | Renamed module; updates imports |
| packages/core/src/project-info/list-workspace-packages.ts | Removed (moved to workspaces) |
| packages/core/src/project-info/internal-rn-dependency-names.ts | Removed (moved to rn-metadata) |
| packages/core/src/project-info/index.ts | Re-exports reorganized helpers |
| packages/core/src/project-info/has-tanstack-query.ts | Removed (moved to dependencies) |
| packages/core/src/project-info/has-react-native-workspace-anywhere.ts | Removed (folded into collect pass) |
| packages/core/src/project-info/has-react-dependency.ts | Removed (moved to dependencies) |
| packages/core/src/project-info/get-workspace-patterns.ts | Removed (moved to workspaces) |
| packages/core/src/project-info/get-preact-version.ts | Removed (moved to dependencies) |
| packages/core/src/project-info/get-nx-workspace-directories.ts | Removed (moved to workspaces) |
| packages/core/src/project-info/fs-utils.ts | Consolidates fs helpers and plain-object check |
| packages/core/src/project-info/find-shopify-flash-list-version.ts | Removed (folded into collect pass) |
| packages/core/src/project-info/find-react-in-workspaces.ts | Removed (folded into collect pass) |
| packages/core/src/project-info/find-nextjs-version.ts | Removed (folded into collect pass) |
| packages/core/src/project-info/find-in-workspace-package-jsons.ts | Removed (folded into collect pass) |
| packages/core/src/project-info/find-expo-version.ts | Removed (folded into collect pass) |
| packages/core/src/project-info/find-dependency-info-from-monorepo-root.ts | Removed (replaced by collect-project-facts) |
| packages/core/src/project-info/extract-dependency-info.ts | Removed (moved into dependencies) |
| packages/core/src/project-info/errors.ts | Makes options interface file-local |
| packages/core/src/project-info/discover-react-subprojects.ts | Switches to consolidated workspace utils |
| packages/core/src/project-info/discover-project.ts | Single-pass workspace facts + static export detection |
| packages/core/src/project-info/detectors.ts | Consolidates framework/compiler/static-export/ES2023 detection |
| packages/core/src/project-info/detect-react-compiler.ts | Removed (moved to detectors) |
| packages/core/src/project-info/detect-pre-es2023-target.ts | Removed (moved to detectors) |
| packages/core/src/project-info/detect-framework.ts | Removed (moved to detectors) |
| packages/core/src/project-info/dependencies.ts | Consolidates dependency extraction + helpers |
| packages/core/src/project-info/count-source-files.ts | Updates fs-utils import |
| packages/core/src/project-info/collect-project-facts.ts | New single traversal of workspace-derived facts |
| packages/core/src/project-info/capabilities.ts | New single source of truth for capability projection |
| packages/core/src/index.ts | Re-exports capabilities from new location |
| packages/core/src/checks/security-scan/collect-security-scan-files.ts | Updates fs-utils import |
| packages/core/src/checks/expo/utils/is-expo-sdk-at-least.ts | Updates comment reference |
| packages/core/src/checks/expo/utils/find-local-module-native-files.ts | Updates fs-utils import |
| packages/core/src/checks/expo/expo-check-context.ts | Uses new version utility path |
| packages/core/src/checks/expo/check-reanimated-new-arch.ts | Uses new version utility path |
| packages/core/src/check-security-scan.ts | Uses getCapabilities + disabledWhen |
| packages/core/src/check-react-server-components-advisory.ts | Uses consolidated workspace helpers |
| .changeset/nextjs-static-export-capability.md | Changeset for published packages |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const wildcardIndex = cleanPattern.indexOf("*"); | ||
| const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex)); | ||
| const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1); | ||
|
|
||
| if (!isDirectory(baseDirectory)) { | ||
| return []; | ||
| } | ||
|
|
||
| const resolved: string[] = []; | ||
| for (const entry of readDirectoryEntries(baseDirectory)) { | ||
| const entryPath = path.join(baseDirectory, entry.name, suffixAfterWildcard); | ||
| if (isDirectory(entryPath) && isFile(path.join(entryPath, "package.json"))) { | ||
| resolved.push(entryPath); | ||
| } | ||
| } |
| const getRuleRecommendation = (ruleName: string, project: ProjectInfo): string | undefined => { | ||
| if (ruleName === "no-secrets-in-client-code") { | ||
| return buildNoSecretsRecommendation( | ||
| project, | ||
| reactDoctorPlugin.rules["no-secrets-in-client-code"]?.recommendation ?? | ||
| "Move secrets to server-only code", | ||
| const rule = reactDoctorPlugin.rules[ruleName]; | ||
| if (!rule) return undefined; |
| export type Capability = | ||
| // The bare framework name, including "unknown" — `buildCapabilities` | ||
| // emits `project.framework` unconditionally (the token feeds the | ||
| // ruleset cache key, so even "unknown" is load-bearing). | ||
| | FrameworkToken | ||
| | "react" | ||
| | "pure-preact" | ||
| | "react-native" | ||
| | "server-actions" | ||
| | "client-only" | ||
| | "nextjs:static-export" | ||
| | "nextjs:15" | ||
| | "tailwind" | ||
| | "tailwind:3.4" | ||
| | "zod" | ||
| | "zod:4" | ||
| | "typescript" | ||
| | "react-compiler" | ||
| | "tanstack-query" | ||
| | "pre-es2023" | ||
| // Major-version ladders (`react:17`…) plus minor-versioned gates like | ||
| // `react:19.2` — both parse as numeric template members. Bounds live in | ||
| // core's constants (`EARLIEST_GATED_*` / `LATEST_KNOWN_*`). | ||
| | `react:${number}` | ||
| | `preact:${number}`; |
…ne ladder discoverProject hand-threaded react/tailwind/zod through every resolution stage as three copies of the same code (declaration probe, root-catalog fill, monorepo-root-catalog fill, raw-spec fallback). The ladder is now written once over a small tracked-dependency record; the shared section- order constants move to dependencies.ts. Corpus probe diff vs the previous commit: zero — pure consolidation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Why
A Next.js app built with
output: "export"is fully static — no server runs when someone loads a page. But several rules suggested server-only fixes: a serverredirect(), middleware, or a Server Action. A static export can't do any of those, so the advice was impossible to follow (#976).The deeper problem was architectural: the fact "this project has no server" had to be expressed in five places (a detector, a
ProjectInfofield, capability rows, a rule-name-matched message rewrite inside core, and rule metadata) — and the discovery layer feeding it walked the same workspaces up to seven times, in nondeterministic filesystem order, with three different scopes for three detectors. That scope mismatch was exactly the Bugbot finding on this PR: a workspace-leveloutput: "export"was invisible to a monorepo-root scan.This PR restructures the whole path into three layers, each fact expressed once:
What changed
Capabilityis one union owned by the plugin;requires/disabledWhen/hasCapabilityall compile against it, so a misspelled token ("sever-actions") is a build error instead of a silently never-matching gate. Core'sFrameworkaliases the plugin'sFrameworkToken, so the two lists can't drift.buildCapabilitiesmoved out of the oxlint runner intoproject-info/(it's a pure projection ofProjectInfo, also used by the security scan), lost its closure-table interpreter in favor of straight-line code, and is memoized per project. Capabilities are deliberately not aProjectInfofield — the wire shape in the JSON report is unchanged.recommendationFor(hasCapability)hook lets a rule pick its advice from the capability set; core'sgetRuleRecommendationis now one generic dispatch with zero rule-name strings and zero rule prose (the static-export redirect advice and the per-framework secret-env advice both moved into their rules).no-prevent-defaultfinished its conversion: the hardcodedCLIENT_ONLY_FRAMEWORKSset became theclient-onlycapability.detectFramework's own in-manifest priority — mobile coverage rideshasReactNativeWorkspace/expoVersion, so nothing is lost).next.config.*next to the manifest that supplied thenextsignal — so a monorepo whoseapps/websetsoutput: "export"now dropsserver-actions, gainsnextjs:static-export, turnsserver-fetch-without-revalidateoff, and softens the redirect/form advice. This closes the deferred Bugbot finding.Validated against the 863-repo OSS corpus
discoverProjectoutput was diffed pre/post rewrite across all 863 cached RDE clones:isStaticExportflips (hyperdx, open-design) are real workspace static exports — dual-mode conditional configs (output: 'export'in one branch), treated as export-capable by design: advice must be possible in every configured mode.Compatibility
ProjectInfo/ JSON-report shape: unchanged (pinned by a snapshot test).client-onlytoken (deterministic; the cache key already rotates on every plugin release). NoSCAN_RESULT_CACHE_SCHEMA_VERSIONbump —CachedScanPayloadis untouched.<form>variant viasettings["react-doctor"].frameworkshould setsettings["react-doctor"].capabilities: ["client-only"](noted in the changeset). Missing settings degrade safely to the generic message.Test plan
pnpm typecheck && pnpm lint && pnpm format:check && pnpm smoke:json-report && gen:checkall pass; full suites green: core 1,115, react-doctor 2,023, oxlint-plugin 7,293, language-server 61, api 15. (Two pre-existing core failures on this machine are a known local global-gitignore artifact, present on the base commit too.)ProjectInfosnapshots over a fixture matrix (pnpm named catalogs, RN-in-web monorepo, nx, leaf-in-monorepo, overlapping globs, the tailwind piggyback quirk) that must hold bit-for-bit across the rewrite. The 100-case discovery spec passes with zero assertion edits.client-onlycapability matrix, memo identity, workspace static-export discovery (root config / workspace config / two-Next-apps first-match / no-export), web-over-mobile merge, and a no-override e2e proving a monorepo-root scan softens the redirect advice.For later, out of scope: widening react-compiler detection to workspaces (flips 12
disabledWhen: ["react-compiler"]rules — needs its own RDE-validated PR), and parsingnext.config.*with oxc instead of regex to see through comments/conditionals.🤖 Generated with Claude Code
Note
Medium Risk
Large refactor of discovery and rule-enablement paths affects monorepo classification for ~2.7% of OSS repos; behavior is heavily tested but version/framework attribution can shift deterministically.
Overview
Fixes #976 by treating Next.js
output: "export"as a first-class project fact: discovery readsnext.config.*next to the manifest that supplied thenextdependency (including workspace apps in a monorepo root scan), setsisStaticExport, and projectsnextjs:static-export/ dropsserver-actions. Rules then gate or soften advice—e.g.server-fetch-without-revalidateoff, redirect guidance without middleware/SSR,no-prevent-defaultusing framework-neutral form messaging instead of Server Actions.Capability vocabulary is centralized: a typed
Capabilityunion drivesrequires, renameddisabledWhen(wasdisabledBy), memoizedgetCapabilities, and oxlintsettings["react-doctor"].capabilities. New tokens includeclient-only,server-actions, andnextjs:static-export. Rules own conditional prose viarecommendationFor(hasCapability); core no longer rewrites specific rule messages.Project discovery is refactored into one sorted workspace pass (
collectWorkspaceFacts) instead of many redundant walks, with deterministic framework merge (web over mobile).project-infomodules are consolidated (capabilities,detectors,dependencies,version,workspaces, etc.).Reviewed by Cursor Bugbot for commit a3f218b. Bugbot is set up for automated code reviews on this repo. Configure here.