Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions packages/core/src/check-security-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const createSecurityScanSession = (
const scan = rule.scan;
if (typeof scan !== "function") return [];
if (rule.defaultEnabled === false) return [];
if (!shouldEnableRule(rule.requires, rule.tags, capabilities, ignoredTags, rule.disabledBy)) {
if (!shouldEnableRule(rule.requires, rule.tags, capabilities, ignoredTags, rule.disabledWhen)) {
return [];
}
return [{ entry, scan, committedFilesOnly: rule.committedFilesOnly === true }];
Expand Down Expand Up @@ -89,7 +89,7 @@ const createSecurityScanSession = (
// over one bounded whole-tree walk (shipped artifacts, dotenv/config files,
// SQL — paths lint never sees). Selection goes through the same
// `shouldEnableRule` capability/tag gate as lint rules, so `--ignore-tag
// security-scan` and `disabledBy` behave identically across both engines.
// security-scan` and `disabledWhen` behave identically across both engines.
export const checkSecurityScan = (
rootDirectory: string,
options: CheckSecurityScanOptions = {},
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