diff --git a/.changeset/fp104-next-pages-app-path.md b/.changeset/fp104-next-pages-app-path.md new file mode 100644 index 000000000..27a0cbf43 --- /dev/null +++ b/.changeset/fp104-next-pages-app-path.md @@ -0,0 +1,7 @@ +--- +"react-doctor": patch +"oxlint-plugin-react-doctor": patch +"eslint-plugin-react-doctor": patch +--- + +Restrict App Router path checks to the project app or src/app directory so nested Pages Router paths do not trigger App Router rules. diff --git a/packages/fuzz/corpus/regressions/nextjs-no-head-import--pages-app-path.tsx b/packages/fuzz/corpus/regressions/nextjs-no-head-import--pages-app-path.tsx new file mode 100644 index 000000000..56b1e8f77 --- /dev/null +++ b/packages/fuzz/corpus/regressions/nextjs-no-head-import--pages-app-path.tsx @@ -0,0 +1,6 @@ +import Head from "next/head"; +export default () => ( + + Page title + +); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-head-import.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-head-import.regressions.test.ts index 977a67118..8037ee980 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-head-import.regressions.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/nextjs/nextjs-no-head-import.regressions.test.ts @@ -55,3 +55,32 @@ describe("nextjs/nextjs-no-head-import — regressions", () => { expect(result.diagnostics.length).toBeGreaterThan(0); }); }); + +describe("App Router project-relative boundary", () => { + it.each([ + "pages/app/md.tsx", + "src/pages/app/index.tsx", + "components/app/head.tsx", + "lib/app/view.tsx", + ])("keeps next/head valid outside the App Router: %s", (relativePath) => { + expect( + runRule(nextjsNoHeadImport, HEAD_IMPORT_SOURCE, { + filename: `/project/${relativePath}`, + settings: { "react-doctor": { rootDirectory: "/project" } }, + }).diagnostics, + ).toHaveLength(0); + }); + it.each([ + "app/page.tsx", + "src/app/page.tsx", + "app/pages/app/page.tsx", + "src/app/(site)/page.tsx", + ])("retains App Router reports: %s", (relativePath) => { + expect( + runRule(nextjsNoHeadImport, HEAD_IMPORT_SOURCE, { + filename: `/project/${relativePath}`, + settings: { "react-doctor": { rootDirectory: "/project" } }, + }).diagnostics, + ).toHaveLength(1); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-in-project-directory.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-in-project-directory.ts index 8d16dae32..fb7bbcbfa 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-in-project-directory.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-in-project-directory.ts @@ -21,6 +21,9 @@ export const isInProjectDirectory = ( const rootDirectory = getReactDoctorStringSetting(context.settings, "rootDirectory"); const relativeFilename = getProjectRelativeFilename(filename, rootDirectory); if (relativeFilename !== filename) { + if (directoryPath === "app") { + return relativeFilename.startsWith("app/") || relativeFilename.startsWith("src/app/"); + } return ( relativeFilename.startsWith(`${directoryPath}/`) || relativeFilename.includes(directorySegment) diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-nextjs-rule.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-nextjs-rule.test.ts index ae4182706..f07357566 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-nextjs-rule.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/wrap-nextjs-rule.test.ts @@ -3,19 +3,9 @@ import os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { runRule } from "../../test-utils/run-rule.js"; -import { nextjsNoHeadImport } from "../rules/nextjs/nextjs-no-head-import.js"; import { wrapNextjsRule } from "./wrap-nextjs-rule.js"; import type { Rule } from "./rule.js"; -const wrappedNoHeadImport = wrapNextjsRule(nextjsNoHeadImport); - -const headImportCode = `import Head from "next/head"; - -export default function Page() { - return Title; -} -`; - const probeRule: Rule = { id: "nextjs-gate-probe", severity: "warn", @@ -32,7 +22,7 @@ describe("wrap-nextjs-rule", () => { let temporaryDirectory = ""; beforeEach(() => { - temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nextjs-gate-")); + temporaryDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rd-nextjs-gate-"))); }); afterEach(() => { @@ -50,7 +40,7 @@ describe("wrap-nextjs-rule", () => { }); it("fires in a package that declares next in dependencies", () => { - const result = runRule(wrappedNoHeadImport, headImportCode, { + const result = runRule(wrappedProbe, "export {};", { filename: createPackageFilename({ dependencies: { next: "15.0.0" } }), settings: rootDirectorySettings(), }); @@ -60,7 +50,7 @@ describe("wrap-nextjs-rule", () => { }); it("fires when next is only a devDependency", () => { - const result = runRule(wrappedNoHeadImport, headImportCode, { + const result = runRule(wrappedProbe, "export {};", { filename: createPackageFilename({ devDependencies: { next: "15.0.0" } }), settings: rootDirectorySettings(), }); @@ -70,7 +60,7 @@ describe("wrap-nextjs-rule", () => { }); it("stays silent in a nested workspace package that never depends on next", () => { - const result = runRule(wrappedNoHeadImport, headImportCode, { + const result = runRule(wrappedProbe, "export {};", { filename: createPackageFilename({ dependencies: { react: "19.0.0", vite: "6.0.0" } }), settings: rootDirectorySettings(), }); @@ -80,7 +70,7 @@ describe("wrap-nextjs-rule", () => { }); it("fires in a non-next package when no project root is provided", () => { - const result = runRule(wrappedNoHeadImport, headImportCode, { + const result = runRule(wrappedProbe, "export {};", { filename: createPackageFilename({ dependencies: { react: "19.0.0" } }), }); @@ -89,7 +79,7 @@ describe("wrap-nextjs-rule", () => { }); it("fires below the root when the nearest manifest is a marker without dependencies", () => { - const result = runRule(wrappedNoHeadImport, headImportCode, { + const result = runRule(wrappedProbe, "export {};", { filename: createPackageFilename({ type: "module" }), settings: rootDirectorySettings(), }); @@ -99,7 +89,7 @@ describe("wrap-nextjs-rule", () => { }); it("fires when there is no discoverable package manifest", () => { - const result = runRule(wrappedNoHeadImport, headImportCode, { + const result = runRule(wrappedProbe, "export {};", { filename: path.join(temporaryDirectory, "standalone", "app", "page.tsx"), settings: rootDirectorySettings(), });