diff --git a/src/detectors/components.ts b/src/detectors/components.ts index a47e770..94978fd 100644 --- a/src/detectors/components.ts +++ b/src/detectors/components.ts @@ -63,9 +63,13 @@ const UI_PRIMITIVES = new Set([ function isUIPrimitive(filePath: string): boolean { const name = basename(filePath, extname(filePath)).toLowerCase(); + // Note: do NOT match a bare `/ui/` segment here. That collides with monorepo + // workspaces literally named `ui/` (e.g. `ui/src/components/*.tsx`), where + // every custom component would be wrongly filtered. The real shadcn case is + // `components/ui/` (caught below) or lowercase primitive filenames (caught + // via UI_PRIMITIVES). Vendor paths (`@radix-ui`, `@shadcn`) are kept. return ( UI_PRIMITIVES.has(name) || - filePath.includes("/ui/") || filePath.includes("/components/ui/") || filePath.includes("@radix-ui") || filePath.includes("@shadcn") diff --git a/tests/detectors.test.ts b/tests/detectors.test.ts index 857828f..593fec2 100644 --- a/tests/detectors.test.ts +++ b/tests/detectors.test.ts @@ -371,6 +371,43 @@ describe("Component Detection", async () => { assert.ok(components.length >= 2); assert.ok(components.some((c: any) => c.name === "UserProfile" && c.props.includes("name"))); }); + + it("detects components in a workspace named `ui/` without filtering them as shadcn primitives", async () => { + // Regression: a bare `/ui/` segment in the file path used to trigger the + // shadcn-primitive filter, which wrongly dropped every custom component + // in monorepos whose UI package is literally named `ui/` (e.g. morgan). + // The real shadcn case lives at `components/ui/.tsx` and is + // still filtered here. + const dir = await writeFixture("ui-workspace-app", { + "package.json": JSON.stringify({ name: "root", private: true }), + "pnpm-workspace.yaml": "packages:\n - ui\n", + "ui/package.json": JSON.stringify({ + name: "@app/ui", + dependencies: { react: "^19.0.0" }, + }), + // Custom app component inside the `ui` workspace. PascalCase name, so + // the UI_PRIMITIVES filename filter must not catch it either. + "ui/src/components/AppShell.tsx": `interface AppShellProps { children: React.ReactNode; width?: "default" | "narrow" } +const AppShell = ({ children, width = "default" }: AppShellProps) => { + return
{children}
; +}; +export default AppShell;`, + // shadcn primitive at the canonical path. Must still be filtered. + "ui/src/components/ui/button.tsx": `export const Button = ({ label }: { label: string }) => ;`, + }); + const project = await mods.detectProject(dir); + assert.equal(project.componentFramework, "react", "react workspace dep should be aggregated"); + const files = await mods.collectFiles(dir); + const components = await mods.detectComponents(files, project); + assert.ok( + components.some((c: any) => c.name === "AppShell"), + `expected AppShell to be detected, got: ${components.map((c: any) => c.name).join(", ") || ""}`, + ); + assert.ok( + !components.some((c: any) => c.name === "Button"), + "shadcn primitive at components/ui/button.tsx should still be filtered", + ); + }); }); // =================== DEPENDENCY GRAPH TESTS ===================