diff --git a/.changeset/no-prop-drilling.md b/.changeset/no-prop-drilling.md
new file mode 100644
index 000000000..b04f4427a
--- /dev/null
+++ b/.changeset/no-prop-drilling.md
@@ -0,0 +1,5 @@
+---
+"oxlint-plugin-react-doctor": minor
+---
+
+Add `no-prop-drilling` (Architecture / Maintainability): flags a prop forwarded untouched through 3+ same-file components — each a pure pass-through that never reads it — before it's finally used, and recommends lifting the value into a Context/Provider (or composing with `children`). The detector is scope-aware: it resolves each JSX tag to a same-file component and each forwarded attribute value to a prop parameter binding, so shadowed names, transformed values (`user.name`, `fn(user)`), conditionals, `{...spread}`, and hand-offs to DOM or imported components don't count as untouched forwarding. Sourced from the vercel-labs `composition-patterns` (compound-components) skill.
diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts
index b9cbf30b1..2d6b1b63f 100644
--- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts
+++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts
@@ -7,6 +7,15 @@ export const SEQUENTIAL_AWAIT_THRESHOLD = 3;
export const PROPERTY_ACCESS_REPEAT_THRESHOLD = 3;
export const BOOLEAN_PROP_THRESHOLD = 4;
export const RENDER_PROP_PROLIFERATION_THRESHOLD = 3;
+// A prop forwarded untouched through this many same-file components —
+// each one a pure pass-through that never reads the prop itself — before
+// it's finally used is the prop-drilling smell `no-prop-drilling` flags;
+// lift the value into a Context/Provider instead. Counts only same-file
+// component chains: handing the prop to an imported component or a DOM
+// element counts as consuming it, so the chain ends there. Deliberately
+// conservative (a 3-deep same-file forward chain is rare and unambiguous)
+// — eval data can lower it later.
+export const PROP_DRILL_CHAIN_THRESHOLD = 3;
export const GET_HANDLER_BINDING_RESOLUTION_DEPTH = 3;
// Chains rooted in a literal array `[a, b, c].map(...).filter(...)` at
// or below this length are skipped by the iteration-combination rules
diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts
index 083f7311f..ce8e7c243 100644
--- a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts
+++ b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts
@@ -194,6 +194,7 @@ import { noPermanentWillChange } from "./rules/performance/no-permanent-will-cha
import { noPolymorphicChildren } from "./rules/correctness/no-polymorphic-children.js";
import { noPreventDefault } from "./rules/correctness/no-prevent-default.js";
import { noPropCallbackInEffect } from "./rules/state-and-effects/no-prop-callback-in-effect.js";
+import { noPropDrilling } from "./rules/architecture/no-prop-drilling.js";
import { noPropTypes } from "./rules/architecture/no-prop-types.js";
import { noPureBlackBackground } from "./rules/design/no-pure-black-background.js";
import { noRandomKey } from "./rules/correctness/no-random-key.js";
@@ -2392,6 +2393,17 @@ export const reactDoctorRules = [
category: "Bugs",
},
},
+ {
+ key: "react-doctor/no-prop-drilling",
+ id: "no-prop-drilling",
+ source: "react-doctor",
+ originallyExternal: false,
+ rule: {
+ ...noPropDrilling,
+ framework: "global",
+ category: "Maintainability",
+ },
+ },
{
key: "react-doctor/no-prop-types",
id: "no-prop-types",
diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.test.ts
new file mode 100644
index 000000000..b44b935fe
--- /dev/null
+++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.test.ts
@@ -0,0 +1,297 @@
+import { describe, expect, it } from "vite-plus/test";
+import { runRule } from "../../../test-utils/run-rule.js";
+import { noPropDrilling } from "./no-prop-drilling.js";
+
+const expectDiagnosticCount = (
+ code: string,
+ expectedDiagnosticCount: number,
+ filename = "fixture.tsx",
+): void => {
+ const result = runRule(noPropDrilling, code, { filename });
+ expect(result.parseErrors).toEqual([]);
+ expect(result.diagnostics).toHaveLength(expectedDiagnosticCount);
+};
+
+describe("architecture/no-prop-drilling — fail cases", () => {
+ it("flags a prop forwarded untouched through three function components", () => {
+ expectDiagnosticCount(
+ `function Page({ user }) {
+ return ;
+}
+function Sidebar({ user }) {
+ return ;
+}
+function Profile({ user }) {
+ return ;
+}
+function Avatar({ user }) {
+ return
;
+}`,
+ 1,
+ );
+ });
+
+ it("flags a renamed pass-through chain across arrow components", () => {
+ expectDiagnosticCount(
+ `const Layout = ({ theme }) =>