Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/nextjs-static-export-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"react-doctor": patch
"oxlint-plugin-react-doctor": patch
---

Fix #976: Next.js projects using `output: "export"` (static export) no longer receive server-only fix recommendations that are impossible without a request-time server. `server-fetch-without-revalidate` is gated off, `nextjs-no-client-side-redirect` keeps firing but its advice drops the middleware / `getServerSideProps` clause (recommending a render-time or client-side redirect instead), and `no-prevent-default` emits the framework-neutral `<form>` message rather than recommending Server Actions.

This is delivered through a general framework-capability vocabulary that any rule can read at runtime (`nextjs:static-export`, `server-actions`, …) rather than rules hardcoding their own framework lists, so future framework-aware rules adapt the same way.
3 changes: 1 addition & 2 deletions packages/core/src/check-react-server-components-advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import {
VERCEL_NEXTJS_SECURITY_RELEASE_URL,
} from "./constants.js";
import { findMonorepoRoot, isFile, readPackageJson } from "./project-info/index.js";
import { getWorkspacePatterns } from "./project-info/get-workspace-patterns.js";
import { resolveWorkspaceDirectories } from "./project-info/resolve-workspace-directories.js";
import { getWorkspacePatterns, resolveWorkspaceDirectories } from "./project-info/workspaces.js";
import type { Diagnostic, PackageJson, ProjectInfo } from "./types/index.js";

const RULE_KEY = "no-vulnerable-react-server-components";
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/checks/expo/check-reanimated-new-arch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getLowestDependencyMajor } from "../../project-info/utils/dependency-version-spec.js";
import { getLowestDependencyMajor } from "../../project-info/version.js";
import type { Diagnostic } from "../../types/index.js";
import type { ExpoCheckContext } from "./expo-check-context.js";
import { buildExpoDiagnostic } from "./utils/build-expo-diagnostic.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/checks/expo/expo-check-context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as path from "node:path";
import { readPackageJson } from "../../project-info/index.js";
import { getLowestDependencyMajor } from "../../project-info/utils/dependency-version-spec.js";
import { getLowestDependencyMajor } from "../../project-info/version.js";
import type { PackageJson } from "../../types/index.js";
import { getDirectDependencyNames } from "./utils/get-direct-dependency-names.js";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import * as path from "node:path";
import { isDirectory } from "../../../project-info/utils/is-directory.js";
import { isFile } from "../../../project-info/utils/is-file.js";
import { readDirectoryEntries } from "../../../project-info/utils/read-directory-entries.js";
import { isDirectory, isFile, readDirectoryEntries } from "../../../project-info/fs-utils.js";

// Representative native files for a local Expo module: the iOS podspec and
// the Android Gradle build script. expo-doctor globs
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/checks/expo/utils/is-expo-sdk-at-least.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// known AND at least `minMajor`. Conservative on `null`: an unresolvable SDK returns
// `false` so SDK-gated checks stay quiet rather than false-positive on a
// project whose target SDK couldn't be determined. (The inverse of
// `isReactAtLeast`'s optimistic-on-null policy — there, gating wrong skips
// a rule; here, gating wrong would warn on an SDK the finding predates.)
// `isMajorMinorAtLeast`'s optimistic-on-null policy — there, gating wrong
// skips a rule; here, gating wrong would warn on an SDK the finding predates.)
export const isExpoSdkAtLeast = (expoSdkMajor: number | null, minMajor: number): boolean =>
expoSdkMajor !== null && expoSdkMajor >= minMajor;
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
shouldReadSecurityScanContent,
} from "oxlint-plugin-react-doctor";
import type { ScannedFile } from "oxlint-plugin-react-doctor";
import { readDirectoryEntries } from "../../project-info/utils/read-directory-entries.js";
import { readDirectoryEntries } from "../../project-info/fs-utils.js";
import { isLargeMinifiedFile } from "../../utils/is-large-minified-file.js";
import {
SECURITY_SCAN_MAX_BUNDLE_FILE_SIZE_BYTES,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/project-info/count-source-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process";
import { GIT_LS_FILES_MAX_BUFFER_BYTES, IGNORED_DIRECTORIES } from "./constants.js";
import { isLintableSourceFile } from "../utils/is-lintable-source-file.js";
import { isLargeMinifiedFile } from "../utils/is-large-minified-file.js";
import { readDirectoryEntries } from "./utils/read-directory-entries.js";
import { readDirectoryEntries } from "./fs-utils.js";

const countSourceFilesViaFilesystem = (rootDirectory: string): number => {
let count = 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { PackageJson } from "../types/index.js";
import { isFile } from "./utils/is-file.js";
import { isPlainObject } from "./utils/is-plain-object.js";
import type { DependencyInfo, PackageJson } from "../types/index.js";
import { detectFramework } from "./detectors.js";
import { isFile, isPlainObject } from "./fs-utils.js";
import { findMonorepoRoot } from "./monorepo-root.js";
import { readPackageJson } from "./package-json.js";
import { isConcreteDependencyVersion } from "./version.js";

export const isCatalogReference = (version: string): boolean => version.startsWith("catalog:");

Expand Down Expand Up @@ -214,3 +217,181 @@ export const resolveCatalogVersion = (

return null;
};

export const EMPTY_DEPENDENCY_INFO: DependencyInfo = {
reactVersion: null,
tailwindVersion: null,
zodVersion: null,
framework: "unknown",
};

const pickConcreteVersion = (
packageJson: PackageJson,
packageName: string,
sections: ReadonlyArray<"dependencies" | "peerDependencies" | "devDependencies">,
): string | null => {
for (const section of sections) {
const version = packageJson[section]?.[packageName];
if (version === undefined) continue;
if (isCatalogReference(version)) return null;
if (isConcreteDependencyVersion(version)) return version;
}
return null;
};

export const extractDependencyInfo = (packageJson: PackageJson): DependencyInfo => {
const allDependencies = {
...packageJson.peerDependencies,
...packageJson.dependencies,
...packageJson.devDependencies,
};
const reactVersion = pickConcreteVersion(packageJson, "react", [
"dependencies",
"peerDependencies",
"devDependencies",
]);
const tailwindVersion = pickConcreteVersion(packageJson, "tailwindcss", [
"dependencies",
"devDependencies",
"peerDependencies",
]);
const zodVersion = pickConcreteVersion(packageJson, "zod", [
"dependencies",
"devDependencies",
"peerDependencies",
]);
return {
reactVersion,
tailwindVersion,
zodVersion,
framework: detectFramework(allDependencies),
};
};

// Reads a package's declared version spec from any of the four dependency
// sections (runtime → dev → peer → optional), so detection matches the
// framework / RN-workspace gates that also treat `peer`/`optional` entries as
// present. The `typeof` guard keeps a malformed non-string entry (e.g.
// `"expo": 54`) from reaching downstream `.trim()` parsing and aborting the scan.
export const getDependencySpec = (packageJson: PackageJson, packageName: string): string | null => {
const spec =
packageJson.dependencies?.[packageName] ??
packageJson.devDependencies?.[packageName] ??
packageJson.peerDependencies?.[packageName] ??
packageJson.optionalDependencies?.[packageName];
return typeof spec === "string" ? spec : null;
};

interface DependencyDeclaration {
catalogReference: string | null;
hasDeclaration: boolean;
version: string | null;
}

interface GetDependencyDeclarationOptions {
packageJson: PackageJson;
packageName: string;
sections: ReadonlyArray<"dependencies" | "peerDependencies" | "devDependencies">;
}

export const getDependencyDeclaration = ({
packageJson,
packageName,
sections,
}: GetDependencyDeclarationOptions): DependencyDeclaration => {
for (const section of sections) {
const version = packageJson[section]?.[packageName];
if (version === undefined) continue;

return {
catalogReference: extractCatalogName(version) ?? null,
hasDeclaration: true,
version,
};
}

return {
catalogReference: null,
hasDeclaration: false,
version: null,
};
};

const REACT_DEPENDENCY_NAMES = new Set(["react", "react-native", "next", "preact"]);

export const hasReactDependency = (packageJson: PackageJson): boolean => {
const allDependencies = {
...packageJson.peerDependencies,
...packageJson.dependencies,
...packageJson.devDependencies,
};
return Object.keys(allDependencies).some((packageName) =>
REACT_DEPENDENCY_NAMES.has(packageName),
);
};

export const getPreactVersion = (packageJson: PackageJson): string | null => {
const allDependencies = {
...packageJson.peerDependencies,
...packageJson.dependencies,
...packageJson.devDependencies,
};
return allDependencies.preact ?? null;
};

const TANSTACK_QUERY_PACKAGES = new Set([
"@tanstack/react-query",
"@tanstack/query-core",
"react-query",
]);

export const hasTanStackQuery = (packageJson: PackageJson): boolean => {
const allDependencies = {
...packageJson.peerDependencies,
...packageJson.dependencies,
...packageJson.devDependencies,
};
return Object.keys(allDependencies).some((packageName) =>
TANSTACK_QUERY_PACKAGES.has(packageName),
);
};

interface ResolveCatalogBackedDependencyVersionOptions {
rootDirectory: string;
rootPackageJson: PackageJson;
packageName: string;
version: string | null;
}

export const resolveCatalogBackedDependencyVersion = ({
rootDirectory,
rootPackageJson,
packageName,
version,
}: ResolveCatalogBackedDependencyVersionOptions): string | null => {
if (version === null || !isCatalogReference(version)) return version;

const catalogName = extractCatalogName(version);
const resolvedLocalVersion = resolveCatalogVersion(
rootPackageJson,
packageName,
rootDirectory,
catalogName,
);
if (resolvedLocalVersion) return resolvedLocalVersion;

const monorepoRoot = findMonorepoRoot(rootDirectory);
if (!monorepoRoot) return version;

const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
if (!isFile(monorepoPackageJsonPath)) return version;

return (
resolveCatalogVersion(
readPackageJson(monorepoPackageJsonPath),
packageName,
monorepoRoot,
catalogName,
) ?? version
);
};
48 changes: 0 additions & 48 deletions packages/core/src/project-info/detect-framework.ts

This file was deleted.

Loading
Loading