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(),
});