From bd463a327ff48720611cd88ea95e08b6f20fa91c Mon Sep 17 00:00:00 2001 From: Kian Bazza Date: Tue, 29 Sep 2026 09:47:44 -0400 Subject: [PATCH] feat(lint): lint the shape of every `packages/react` part Adds `bazza/use-client`, `bazza/forward-ref-named`, `bazza/part-namespace` and `bazza/data-attrs-enum`, and turns on Biome `noDefaultExport` for `packages/react/src`. Files that break a rule today are allowlisted per rule. `packages/react/AGENTS.md` documents each convention. --- .oxlintrc.json | 85 +++++ biome.jsonc | 15 +- packages/react/AGENTS.md | 14 + tooling/lint/bazza-plugin.mjs | 8 + tooling/lint/bazza-plugin.test.ts | 415 ++++++++++++++++++++++- tooling/lint/harness.ts | 68 ++-- tooling/lint/rules/ast.mjs | 139 ++++++++ tooling/lint/rules/data-attrs-enum.mjs | 133 ++++++++ tooling/lint/rules/forward-ref-named.mjs | 64 ++++ tooling/lint/rules/part-namespace.mjs | 188 ++++++++++ tooling/lint/rules/rule-names.mjs | 8 +- tooling/lint/rules/use-client.mjs | 45 +++ 12 files changed, 1150 insertions(+), 32 deletions(-) create mode 100644 tooling/lint/rules/ast.mjs create mode 100644 tooling/lint/rules/data-attrs-enum.mjs create mode 100644 tooling/lint/rules/forward-ref-named.mjs create mode 100644 tooling/lint/rules/part-namespace.mjs create mode 100644 tooling/lint/rules/use-client.mjs diff --git a/.oxlintrc.json b/.oxlintrc.json index 615a87a8..8f3bb684 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -42,6 +42,38 @@ ] } }, + { + // Part-authoring rules: shipped source only. Tests define throwaway + // components and contexts that don't need to follow them. + "files": ["packages/react/src/**/*.{ts,tsx}"], + "rules": { + "bazza/forward-ref-named": "error", + "bazza/part-namespace": "error" + } + }, + { + "files": [ + "packages/react/src/**/*.tsx", + "packages/react/src/**/*-context.ts" + ], + "rules": { "bazza/use-client": "error" } + }, + { + "files": [ + "packages/react/src/**/*.data-attrs.ts", + "packages/react/src/**/*.data-attributes.ts", + "packages/react/src/**/*.css-vars.ts" + ], + "rules": { "bazza/data-attrs-enum": "error" } + }, + { + "files": ["packages/react/src/**/*.test.{ts,tsx}"], + "rules": { + "bazza/forward-ref-named": "off", + "bazza/part-namespace": "off", + "bazza/use-client": "off" + } + }, // --- Allowlists ----------------------------------------------------------- // Files that broke a rule when the rule was added. Each file is exempt from @@ -49,6 +81,59 @@ // delete its line. Never add a file here to get a new change through; use a // reasoned `oxlint-disable-next-line` instead. + { + // `as const` objects the docs can't read. Convert each to an enum. + "files": [ + "packages/react/src/combobox/clear/clear.data-attrs.ts", + "packages/react/src/combobox/icon/icon.data-attrs.ts", + "packages/react/src/combobox/input-wrapper/input-wrapper.data-attrs.ts", + "packages/react/src/combobox/input/input.data-attrs.ts", + "packages/react/src/combobox/item-indicator/item-indicator.data-attrs.ts", + "packages/react/src/combobox/item-label/item-label.data-attrs.ts", + "packages/react/src/combobox/item/item.data-attrs.ts", + "packages/react/src/combobox/positioner/positioner.data-attrs.ts", + "packages/react/src/combobox/scroll-arrow/scroll-arrow.data-attrs.ts", + "packages/react/src/combobox/surface/surface.data-attrs.ts", + "packages/react/src/context-menu/icon/icon.data-attrs.ts", + "packages/react/src/context-menu/scroll-arrow/scroll-arrow.data-attrs.ts", + "packages/react/src/dropdown-menu/icon/icon.data-attrs.ts", + "packages/react/src/dropdown-menu/scroll-arrow/scroll-arrow.data-attrs.ts", + "packages/react/src/internal/popup-menu/components/icon/icon.data-attrs.ts", + "packages/react/src/internal/popup-menu/components/scroll-arrow/scroll-arrow.data-attrs.ts", + "packages/react/src/select/icon/icon.data-attrs.ts", + "packages/react/src/select/item-indicator/item-indicator.data-attrs.ts", + "packages/react/src/select/item-label/item-label.data-attrs.ts", + "packages/react/src/select/item/item.data-attrs.ts", + "packages/react/src/select/positioner/positioner.data-attrs.ts", + "packages/react/src/select/scroll-arrow/scroll-arrow.data-attrs.ts", + "packages/react/src/select/surface/surface.data-attrs.ts", + "packages/react/src/select/trigger/trigger.data-attrs.ts", + "packages/react/src/select/value/value.data-attrs.ts" + ], + "rules": { "bazza/data-attrs-enum": "off" } + }, + { + // Exported parts with no namespace of their own. + "files": [ + "packages/react/src/internal/popup-menu/components/list/list.tsx", + "packages/react/src/internal/popup-menu/data-first/data-list.tsx", + "packages/react/src/video-player/components/captions-menu/captions-menu.tsx", + "packages/react/src/video-player/components/playback-rate-menu/playback-rate-menu.tsx", + "packages/react/src/video-player/components/quality-menu/quality-menu.tsx", + "packages/react/src/video-player/components/seek-slider/seek-slider.tsx", + "packages/react/src/video-player/components/volume-slider/volume-slider.tsx" + ], + "rules": { "bazza/part-namespace": "off" } + }, + { + // Context modules without `'use client'`. + "files": [ + "packages/react/src/combobox/input-wrapper/input-wrapper-context.ts", + "packages/react/src/internal/popup-menu/contexts/graft-point-context.ts", + "packages/react/src/internal/popup-menu/contexts/menu-tree-resolver-context.ts" + ], + "rules": { "bazza/use-client": "off" } + }, { // A class method calls `useRefWithInit`. "files": ["packages/react/src/internal/listbox/store/ListboxStore.ts"], diff --git a/biome.jsonc b/biome.jsonc index 7ece1f8a..7f99e899 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -87,5 +87,18 @@ "linter": { "enabled": false } - } + }, + "overrides": [ + { + // Parts are reached through their family's namespace + // (`DropdownMenu.Item`), never a default import. See "Component + // Pattern" in packages/react/AGENTS.md. + "includes": [ + "packages/react/src/**", + "!packages/react/src/**/*.test.ts", + "!packages/react/src/**/*.test.tsx" + ], + "linter": { "rules": { "style": { "noDefaultExport": "error" } } } + } + ] } diff --git a/packages/react/AGENTS.md b/packages/react/AGENTS.md index ea6a5d4d..93d13a13 100644 --- a/packages/react/AGENTS.md +++ b/packages/react/AGENTS.md @@ -23,7 +23,10 @@ src/ ## Component Pattern ```typescript +'use client' + import { useRender } from '@base-ui/react/use-render' +import * as React from 'react' import type { ComponentProps } from '../../utils/types.js' export interface MyComponentState extends Record { @@ -58,6 +61,13 @@ export namespace MyComponent { } ``` +Every part follows this shape, and lint checks it: + +- **`'use client'` first.** Parts and `*-context.ts` modules use hooks, so a React Server Components app can only import them across a client boundary (`bazza/use-client`, fixable with `bun run check:fix`). +- **A named render function.** Parts don't set `displayName`, so React DevTools and error messages show the function's name. Write `forwardRef(function MyComponent(…))`, or declare a named function and pass it by name, as generic parts do with `forwardRef(MyComponentImpl) as <…>` (`bazza/forward-ref-named`). +- **A namespace with the part's types.** Consumers write `DropdownMenu.Item.Props`, and the docs type tables read the same names. Export `namespace MyComponent { Props }`, plus `State` when the part passes `state` to `useRender` (`bazza/part-namespace`). +- **No default exports.** Parts are reached through their family's namespace (`DropdownMenu.Item`). Biome's `noDefaultExport` is on for `src`. + ## State to Data Attributes Automatic conversion: `highlighted: true` becomes `data-highlighted=""`. @@ -79,6 +89,8 @@ return useRender({ render, ref, state, stateAttributesMapping, props, defaultTag Filename: `.data-attrs.ts` +Export only `enum`s named `*DataAttributes`. The docs type generator (`apps/web/scripts/build-types-meta.ts`) reads nothing else, so an `as const` object renders an empty `DataAttrsTable`. To share an engine part's attributes, re-export its enum by name (`export { PopupMenuPopupDataAttributes } from '…'`), never `export *` (`bazza/data-attrs-enum`). + ```typescript export enum DropdownMenuItemDataAttributes { /** Present when the item is highlighted. */ @@ -92,6 +104,8 @@ export enum DropdownMenuItemDataAttributes { Filename: `.css-vars.ts` +Export only `enum`s named `*CssVars`, for the same reason as data attributes (`bazza/data-attrs-enum`). + ```typescript export enum DropdownMenuPositionerCssVars { /** @type {number} */ diff --git a/tooling/lint/bazza-plugin.mjs b/tooling/lint/bazza-plugin.mjs index 139e0b82..b6b1e663 100644 --- a/tooling/lint/bazza-plugin.mjs +++ b/tooling/lint/bazza-plugin.mjs @@ -5,7 +5,11 @@ * Each rule's message says why the rule exists, what to write instead, and * which section of `packages/react/AGENTS.md` describes the convention. */ +import { dataAttrsEnum } from './rules/data-attrs-enum.mjs' import { disableNeedsReason } from './rules/disable-needs-reason.mjs' +import { forwardRefNamed } from './rules/forward-ref-named.mjs' +import { partNamespace } from './rules/part-namespace.mjs' +import { useClient } from './rules/use-client.mjs' export { directiveProblem, @@ -16,6 +20,10 @@ export { bazzaRuleNames } from './rules/rule-names.mjs' export default { meta: { name: 'bazza' }, rules: { + 'data-attrs-enum': dataAttrsEnum, 'disable-needs-reason': disableNeedsReason, + 'forward-ref-named': forwardRefNamed, + 'part-namespace': partNamespace, + 'use-client': useClient, }, } diff --git a/tooling/lint/bazza-plugin.test.ts b/tooling/lint/bazza-plugin.test.ts index 6d4f4edf..c9c8f172 100644 --- a/tooling/lint/bazza-plugin.test.ts +++ b/tooling/lint/bazza-plugin.test.ts @@ -4,11 +4,27 @@ import plugin, { directiveProblem, parseDirective, } from './bazza-plugin.mjs' -import { type Finding, lintWith, lintWithRepoConfig } from './harness.ts' +import { + type Finding, + fixWith, + lintWith, + lintWithRepoConfig, +} from './harness.ts' const lines = (findings: readonly Finding[]) => findings.map((f) => f.line) const rules = (findings: readonly Finding[]) => findings.map((f) => f.rule) +/** Every `bazza/*` rule except `disable-needs-reason`, as oxlint reports them. */ +const partRules = new Set( + [...bazzaRuleNames] + .filter((name) => name !== 'disable-needs-reason') + .map((name) => `bazza(${name})`), +) +/** Lints with the repo config, dropping findings from the part-shape rules. */ +const lintIgnoringPartRules = async ( + files: Parameters[0], +) => (await lintWithRepoConfig(files)).filter((f) => !partRules.has(f.rule)) + describe('bazza/disable-needs-reason', () => { it('accepts a one-line exception that names its rule and gives a reason', async () => { const findings = await lintWith('bazza/disable-needs-reason', { @@ -145,6 +161,361 @@ export const a = 1 }) }) +describe('bazza/use-client', () => { + it("accepts a module whose prologue has 'use client'", async () => { + const findings = await lintWith('bazza/use-client', { + 'a.tsx': `// Copyright header +'use strict' +'use client' +export const a = 1 +`, + }) + expect(findings).toEqual([]) + }) + + it('reports a module without it, including one that mentions it later', async () => { + const findings = await lintWith('bazza/use-client', { + 'a.tsx': `import * as React from 'react' +'use client' +export const A = () => React.useId() +`, + }) + expect(lines(findings)).toEqual([1]) + }) + + it('can be disabled for a file with a comment above its first statement', async () => { + const findings = await lintWith('bazza/use-client', { + 'a.ts': `// oxlint-disable-next-line bazza/use-client -- server-only helper +export const a = 1 +`, + }) + expect(findings).toEqual([]) + }) + + it('adds the directive with --fix', async () => { + expect( + await fixWith('bazza/use-client', 'a.ts', "import { x } from './x'\n"), + ).toBe("'use client'\n\nimport { x } from './x'\n") + }) +}) + +describe('bazza/forward-ref-named', () => { + it('accepts named function expressions and named functions passed by name', async () => { + const findings = await lintWith('bazza/forward-ref-named', { + 'a.tsx': `import * as React from 'react' +import { forwardRef } from 'react' +function SelectItemImpl(props: { value: V }, ref: React.Ref) { + return
+} +export const A = React.forwardRef(function A(props, forwardedRef) { + return
+}) +export const B = forwardRef(SelectItemImpl) as (props: { value: V }) => React.ReactElement +`, + }) + expect(findings).toEqual([]) + }) + + it('follows a name to its declaration in the module', async () => { + const findings = await lintWith('bazza/forward-ref-named', { + 'a.tsx': `import * as React from 'react' +import { forwardRef as fr } from 'react' +import { renderItem } from './render' +const ArrowImpl = (props: object, ref: React.Ref) =>
+const NamedImpl = function NamedImpl(props: object, ref: React.Ref) { + return
+} +export const A = React.forwardRef(ArrowImpl) +export const B = React.forwardRef(NamedImpl) +export const C = React.forwardRef(renderItem) +export const D = fr((props, ref) =>
) +`, + }) + expect(findings.map((f) => [f.line, f.message.split('.')[0]])).toEqual([ + [ + 8, + '`forwardRef` wraps `ArrowImpl`, which is an arrow or anonymous function', + ], + [ + 10, + "Can't tell whether `renderItem` is a named function: declare it in this module with `function renderItem(…)`", + ], + [11, '`forwardRef` wraps an anonymous function'], + ]) + }) + + it('reports arrows, anonymous functions and shapes it cannot follow', async () => { + const findings = await lintWith('bazza/forward-ref-named', { + 'a.tsx': `import * as React from 'react' +export const A = React.forwardRef((props, ref) =>
) +export const B = React.forwardRef(function (props, ref) { + return
+}) +export const C = React.forwardRef(makeRender()) +`, + }) + expect(lines(findings)).toEqual([2, 3, 6]) + expect(findings[0]?.message).toContain('anonymous function') + expect(findings[2]?.message).toContain("Can't tell") + }) +}) + +describe('bazza/part-namespace', () => { + const part = ( + namespace: string, + extra = '', + ) => `import * as React from 'react' +import { useRender } from '@base-ui/react/use-render' +export interface PartState extends Record {} +export interface PartProps {} +export const Part = React.forwardRef(function Part(props, forwardedRef) { + const state: PartState = {} + return useRender({ ref: forwardedRef, state, props, defaultTagName: 'div' }) +}) +${namespace} +${extra}` + + it('accepts a part with State and Props on its namespace', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': part(`export namespace Part { + export type State = PartState + export interface Props extends PartProps {} +}`), + }) + expect(findings).toEqual([]) + }) + + it('reports a missing namespace, a missing Props, and a missing State', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'none.tsx': part(''), + 'no-props.tsx': part( + 'export namespace Part { export type State = PartState }', + ), + 'no-state.tsx': part( + 'export namespace Part { export interface Props extends PartProps {} }', + ), + }) + expect( + findings.map((f) => [f.file, f.message.match(/has no (.+?) type/)?.[1]]), + ).toEqual([ + ['no-props.tsx', '`Part.Props`'], + ['no-state.tsx', '`Part.State`'], + ['none.tsx', '`Part.State` or `Part.Props`'], + ]) + }) + + it('needs no State when the part passes none to useRender', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': `import * as React from 'react' +export const Part = React.forwardRef(function Part(props, ref) { + return
+}) +export namespace Part { export type Props = React.ComponentProps<'div'> } +`, + }) + expect(findings).toEqual([]) + }) + + it('requires State only for the part whose own render passes state', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': `import * as React from 'react' +import { useRender } from '@base-ui/react/use-render' +function WithStateImpl(props: object, ref: React.Ref) { + return useRender({ ref, state: {}, props, defaultTagName: 'div' }) +} +export const WithState = React.forwardRef(WithStateImpl) +export namespace WithState { export interface Props {} } +export const Plain = React.forwardRef(function Plain(props, ref) { + return
+}) +export namespace Plain { export interface Props {} } +`, + }) + expect( + findings.map((f) => [f.line, f.message.match(/has no (.+?) type/)?.[1]]), + ).toEqual([[6, '`WithState.State`']]) + }) + + it('finds parts wrapped in memo or built with an aliased forwardRef', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': `import * as React from 'react' +import { forwardRef as fr } from 'react' +export const Memo = React.memo(React.forwardRef(function Memo(props, ref) { + return
+})) +export const Aliased = fr(function Aliased(props, ref) { + return
+}) +`, + }) + expect(findings.map((f) => f.line)).toEqual([3, 6]) + }) + + it('counts only exported namespace members', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': part( + 'export namespace Part { type State = PartState; interface Props extends PartProps {} }', + ), + }) + expect(findings[0]?.message).toContain('`Part.State` or `Part.Props`') + }) + + it('recognises useRender by alias and ignores components nested in the render', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': `import * as React from 'react' +import { useRender as useBaseRender } from '@base-ui/react/use-render' +export const Aliased = React.forwardRef(function Aliased(props, ref) { + return useBaseRender({ ref, state: {}, props, defaultTagName: 'div' }) +}) +export namespace Aliased { export interface Props {} } +export const Outer = React.forwardRef(function Outer(props, ref) { + function Inner() { + return useBaseRender({ state: {}, props: {}, defaultTagName: 'span' }) + } + return
+}) +export namespace Outer { export interface Props {} } +`, + }) + expect(findings.map((f) => f.line)).toEqual([3]) + }) + + it("asks for State when it can't find or read the render function", async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': `import * as React from 'react' +import { useRender } from '@base-ui/react/use-render' +import { renderItem } from './render' +const CastImpl = function CastImpl(props: object, ref: React.Ref) { + return useRender({ ref, state: {}, props, defaultTagName: 'div' }) +} as (props: object, ref: React.Ref) => React.ReactElement +export const Cast = React.forwardRef(CastImpl) +export namespace Cast { export interface Props {} } +export const Imported = React.forwardRef(renderItem) +export namespace Imported { export interface Props {} } +export declare namespace Declared { interface Props {} } +export const Declared = React.forwardRef(function Declared(props, ref) { + return
+}) +`, + }) + expect(findings.map((f) => [f.line, f.message.slice(0, 40)])).toEqual([ + [7, 'Part `Cast` has no `Cast.State` type. Co'], + [9, "Can't tell whether part `Imported` passe"], + ]) + }) + + it('merges namespace blocks declared more than once', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': part(`export namespace Part { export type State = PartState } +export namespace Part { export interface Props extends PartProps {} }`), + }) + expect(findings).toEqual([]) + }) + + it("reports a part whose useRender options it can't read", async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': `import * as React from 'react' +import { useRender } from '@base-ui/react/use-render' +export const Part = React.forwardRef(function Part(props, ref) { + const options = { ref, props, defaultTagName: 'div' as const } + return useRender(options) +}) +export namespace Part { export interface Props {} } +`, + }) + expect(findings.map((f) => f.line)).toEqual([3]) + expect(findings[0]?.message).toContain( + "Can't tell whether part `Part` passes `state`", + ) + }) + + it('checks parts exported by name and generic parts behind a cast, not internal ones', async () => { + const findings = await lintWith('bazza/part-namespace', { + 'a.tsx': `import * as React from 'react' +function ItemImpl(props: object, ref: React.Ref) { + return
+} +const Item = React.forwardRef(ItemImpl) as (props: object) => React.ReactElement +const Inner = React.forwardRef(function Inner(props, ref) { + return
+}) +export { Item } +`, + }) + expect(findings.map((f) => f.line)).toEqual([5]) + }) +}) + +describe('bazza/data-attrs-enum', () => { + it('accepts named enums and enums re-exported by name', async () => { + const findings = await lintWith('bazza/data-attrs-enum', { + 'item.data-attrs.ts': `export { PopupMenuItemDataAttributes } from './popup.data-attrs.js' +export enum ItemDataAttributes { + highlighted = 'data-highlighted', +} +`, + 'positioner.css-vars.ts': `export enum PositionerCssVars { + availableWidth = '--available-width', +} +`, + }) + expect(findings).toEqual([]) + }) + + it('reports as-const objects, badly named enums and wildcard re-exports', async () => { + const findings = await lintWith('bazza/data-attrs-enum', { + 'item.data-attrs.ts': `export const ItemDataAttributes = { + highlighted: 'data-highlighted', +} as const +export enum ItemAttrs { a = 'data-a' } +export * from './other.js' +`, + 'positioner.css-vars.ts': `export enum PositionerVars { a = '--a' } +`, + }) + expect(findings.map((f) => [f.file, f.line])).toEqual([ + ['item.data-attrs.ts', 1], + ['item.data-attrs.ts', 4], + ['item.data-attrs.ts', 5], + ['positioner.css-vars.ts', 1], + ]) + expect(findings[0]?.message).toContain('as const') + }) + + it('checks what a local export list points at', async () => { + const findings = await lintWith('bazza/data-attrs-enum', { + 'item.data-attrs.ts': `const ItemDataAttributes = { a: 'data-a' } as const +enum ItemStateDataAttributes { b = 'data-b' } +export { ItemDataAttributes, ItemStateDataAttributes } +`, + }) + expect(findings.map((f) => f.line)).toEqual([3]) + expect(findings[0]?.message).toContain('as const') + }) + + it('accepts enums re-exported from data-attribute files only', async () => { + const findings = await lintWith('bazza/data-attrs-enum', { + 'item.data-attrs.ts': `import { PopupDataAttributes } from './popup.data-attrs.js' +import { FOO } from './constants.js' +export { PopupDataAttributes } +export { FOO as FooDataAttributes } +export { BarDataAttributes } from './bar.js' +`, + }) + expect(findings.map((f) => f.line)).toEqual([4, 5]) + expect(findings[0]?.message).toContain( + "Can't tell whether `FooDataAttributes`", + ) + }) + + it('reports a file whose name says neither kind', async () => { + const findings = await lintWith('bazza/data-attrs-enum', { + 'item.tsx': 'export enum ItemDataAttributes { a = "data-a" }\n', + }) + expect(findings[0]?.message).toContain("Can't tell whether this is") + }) +}) + describe('the plugin', () => { it('registers exactly the rules listed in bazzaRuleNames', () => { expect(Object.keys(plugin.rules).sort()).toEqual([...bazzaRuleNames].sort()) @@ -193,7 +564,7 @@ export function Part({ open }: { open: boolean }) { return null } ` - const findings = await lintWithRepoConfig({ + const findings = await lintIgnoringPartRules({ 'packages/react/src/part.tsx': hook, 'packages/react/src/part.test.tsx': hook, }) @@ -204,7 +575,7 @@ export function Part({ open }: { open: boolean }) { }) it('accepts hooks in forwardRef render functions named `*Impl`', async () => { - const findings = await lintWithRepoConfig({ + const findings = await lintIgnoringPartRules({ 'packages/react/src/item.tsx': `import * as React from 'react' function SelectItemImpl(props: { value: Value }, ref: React.Ref) { const [state] = React.useState(props.value) @@ -217,7 +588,7 @@ export const SelectItem = React.forwardRef(SelectItemImpl) }) it("blocks Base UI's private internals and the old package name", async () => { - const findings = await lintWithRepoConfig({ + const findings = await lintIgnoringPartRules({ 'packages/react/src/a.ts': `import { useDirection } from '@base-ui/react/internals/direction-context' import { Popover } from '@base-ui-components/react/popover' import { useRender } from '@base-ui/react/use-render' @@ -245,7 +616,7 @@ export function A({ open }: { open: boolean }) { }) it('honours a reasoned oxlint exception', async () => { - const findings = await lintWithRepoConfig({ + const findings = await lintIgnoringPartRules({ 'packages/react/src/part.tsx': `import * as React from 'react' export function Part({ open }: { open: boolean }) { // oxlint-disable-next-line react-hooks/rules-of-hooks -- fixture for the exception syntax @@ -258,7 +629,7 @@ export function Part({ open }: { open: boolean }) { }) it('does not honour eslint-disable comments', async () => { - const findings = await lintWithRepoConfig({ + const findings = await lintIgnoringPartRules({ 'packages/react/src/part.tsx': `import * as React from 'react' export function Part({ open }: { open: boolean }) { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -274,7 +645,7 @@ export function Part({ open }: { open: boolean }) { }) it('exempts allowlisted files from their rule only', async () => { - const findings = await lintWithRepoConfig({ + const findings = await lintIgnoringPartRules({ 'packages/react/src/select/positioner/positioner.tsx': `import { useDirection } from '@base-ui/react/internals/direction-context' export const a = useDirection // eslint-disable-line no-console `, @@ -282,6 +653,32 @@ export const a = useDirection // eslint-disable-line no-console expect(rules(findings)).toEqual(['bazza(disable-needs-reason)']) }) + it('applies the part rules to shipped source only', async () => { + const shapeless = `import * as React from 'react' +export const Part = React.forwardRef((props, ref) =>
) +` + const findings = await lintWithRepoConfig({ + 'packages/react/src/part/part.tsx': shapeless, + 'packages/react/src/part/part.test.tsx': shapeless, + 'packages/react/test/harness.tsx': shapeless, + 'packages/react/src/part/part.data-attrs.ts': + 'export const PartDataAttributes = { a: 1 } as const\n', + 'packages/react/src/part/part-context.ts': 'export const a = 1\n', + 'packages/react/src/part/helpers.ts': 'export const a = 1\n', + }) + expect( + findings + .map((f) => `${f.file.replace('packages/react/src/', '')} ${f.rule}`) + .sort(), + ).toEqual([ + 'part/part-context.ts bazza(use-client)', + 'part/part.data-attrs.ts bazza(data-attrs-enum)', + 'part/part.tsx bazza(forward-ref-named)', + 'part/part.tsx bazza(part-namespace)', + 'part/part.tsx bazza(use-client)', + ]) + }) + it.each([ [ 'packages/react/src/internal/listbox/store/ListboxStore.ts', @@ -299,8 +696,8 @@ export class Store { `, ], ])('exempts %s from its allowlisted rule', async (path, source) => { - expect(await lintWithRepoConfig({ [path]: source })).toEqual([]) + expect(await lintIgnoringPartRules({ [path]: source })).toEqual([]) const elsewhere = path.replace(/[^/]+$/, 'other.tsx') - expect(await lintWithRepoConfig({ [elsewhere]: source })).not.toEqual([]) + expect(await lintIgnoringPartRules({ [elsewhere]: source })).not.toEqual([]) }) }) diff --git a/tooling/lint/harness.ts b/tooling/lint/harness.ts index b832eaff..e27d669b 100644 --- a/tooling/lint/harness.ts +++ b/tooling/lint/harness.ts @@ -27,8 +27,12 @@ interface Diagnostic { labels: { span: { line: number } }[] } -/** Writes `files` and `config` to a temp dir, lints it, and returns the findings. */ -async function lintIn(config: object, files: Files): Promise { +/** Writes `config` and `files` to a temp dir and runs oxlint there. */ +async function inTempDir( + config: object, + files: Files, + use: (dir: string) => Promise, +): Promise { const dir = await mkdtemp(join(tmpdir(), 'bazza-lint-')) try { await writeFile(join(dir, '.oxlintrc.json'), JSON.stringify(config)) @@ -36,13 +40,25 @@ async function lintIn(config: object, files: Files): Promise { await mkdir(dirname(join(dir, path)), { recursive: true }) await writeFile(join(dir, path), source) } - // oxlint exits 1 when it finds errors; the JSON on stdout is what matters. - const { stdout } = await run( - oxlint, - ['--disable-nested-config', '--format', 'json', '.'], - { cwd: dir }, - ).catch((error: { stdout?: string }) => ({ stdout: error.stdout ?? '' })) - const parsed = JSON.parse(stdout) as { diagnostics: Diagnostic[] } + return await use(dir) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +/** Runs oxlint in `dir`. It exits 1 when it finds errors, so stdout is what matters. */ +function oxlintIn(dir: string, args: string[]): Promise { + return run(oxlint, ['--disable-nested-config', ...args, '.'], { cwd: dir }) + .then(({ stdout }) => stdout) + .catch((error: { stdout?: string }) => error.stdout ?? '') +} + +/** Lints `files` with `config` and returns the findings, sorted by file and line. */ +function lintIn(config: object, files: Files): Promise { + return inTempDir(config, files, async (dir) => { + const parsed = JSON.parse(await oxlintIn(dir, ['--format', 'json'])) as { + diagnostics: Diagnostic[] + } return parsed.diagnostics .map((d) => ({ rule: d.code, @@ -51,22 +67,32 @@ async function lintIn(config: object, files: Files): Promise { message: d.message, })) .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line) - } finally { - await rm(dir, { recursive: true, force: true }) - } + }) } +/** A config with one rule switched on everywhere. */ +const oneRule = (rule: string) => ({ + plugins: ['react'], + categories: { correctness: 'off' }, + jsPlugins: [bazzaPlugin], + rules: { [rule]: 'error' }, +}) + /** Lints `files` with one rule switched on everywhere, e.g. `bazza/use-client`. */ export function lintWith(rule: string, files: Files): Promise { - return lintIn( - { - plugins: ['react'], - categories: { correctness: 'off' }, - jsPlugins: [bazzaPlugin], - rules: { [rule]: 'error' }, - }, - files, - ) + return lintIn(oneRule(rule), files) +} + +/** Runs `oxlint --fix` with one rule switched on and returns the fixed file. */ +export function fixWith( + rule: string, + path: string, + source: string, +): Promise { + return inTempDir(oneRule(rule), { [path]: source }, async (dir) => { + await oxlintIn(dir, ['--fix']) + return readFile(join(dir, path), 'utf8') + }) } /** The repo's `.oxlintrc.json`, with comments stripped and the plugin path made absolute. */ diff --git a/tooling/lint/rules/ast.mjs b/tooling/lint/rules/ast.mjs new file mode 100644 index 00000000..a92e16c8 --- /dev/null +++ b/tooling/lint/rules/ast.mjs @@ -0,0 +1,139 @@ +/** AST helpers shared by the `bazza/*` rules. */ + +/** Unwraps TypeScript-only wrappers that don't change the runtime value. */ +export function unwrap(node) { + let current = node + while ( + current && + (current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSNonNullExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'ParenthesizedExpression') + ) { + current = current.expression + } + return current +} + +/** The declaration a top-level statement holds, looking inside `export`. */ +export function topLevelDeclaration(statement) { + return statement.type === 'ExportNamedDeclaration' + ? statement.declaration + : statement +} + +/** + * Local names that refer to `name` imported from `source`: `name` itself plus + * any alias from `import { name as alias } from 'source'`. + */ +export function importedNames(program, source, name) { + const names = new Set([name]) + for (const statement of program.body) { + if (statement.type !== 'ImportDeclaration') continue + if (statement.source.value !== source) continue + for (const specifier of statement.specifiers) { + const imported = specifier.imported?.name ?? specifier.imported?.value + if (specifier.type === 'ImportSpecifier' && imported === name) { + names.add(specifier.local.name) + } + } + } + return names +} + +/** Local names that refer to React's `forwardRef`. */ +export const forwardRefNames = (program) => + importedNames(program, 'react', 'forwardRef') + +/** The import that binds `name` in the module, with its source, if any. */ +export function findImport(program, name) { + for (const statement of program.body) { + if (statement.type !== 'ImportDeclaration') continue + for (const specifier of statement.specifiers) { + if (specifier.local.name === name) { + return { source: statement.source.value, specifier } + } + } + } + return undefined +} + +/** `forwardRef(…)` under any of `names`, or `.forwardRef(…)`. */ +export function isForwardRefCall(node, names = new Set(['forwardRef'])) { + if (node?.type !== 'CallExpression') return false + const { callee } = node + if (callee.type === 'Identifier') return names.has(callee.name) + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.name === 'forwardRef' + ) +} + +/** + * The `forwardRef(…)` call a value is built from, looking through TypeScript + * wrappers and calls that wrap it (`memo(forwardRef(…))`). + */ +export function findForwardRefCall(node, names) { + const value = unwrap(node) + if (value?.type !== 'CallExpression') return undefined + if (isForwardRefCall(value, names)) return value + for (const argument of value.arguments) { + const found = findForwardRefCall(argument, names) + if (found) return found + } + return undefined +} + +/** The top-level declaration that binds `name`: a function, class, enum, or variable declarator. */ +export function findTopLevelBinding(program, name) { + for (const statement of program.body) { + const declaration = topLevelDeclaration(statement) + if (!declaration) continue + if ( + (declaration.type === 'FunctionDeclaration' || + declaration.type === 'ClassDeclaration' || + declaration.type === 'TSEnumDeclaration') && + declaration.id?.name === name + ) { + return declaration + } + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if ( + declarator.id.type === 'Identifier' && + declarator.id.name === name + ) { + return declarator + } + } + } + } + return undefined +} + +/** + * Local names the module exports, values and types alike: `export const A`, + * `export interface B`, `export { c as C }`. Re-exports from another module + * are left out. + */ +export function exportedLocals(program) { + const locals = new Set() + for (const statement of program.body) { + if (statement.type !== 'ExportNamedDeclaration') continue + const { declaration } = statement + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier') locals.add(declarator.id.name) + } + } else if (declaration?.id?.type === 'Identifier') { + locals.add(declaration.id.name) + } + if (statement.source) continue + for (const specifier of statement.specifiers ?? []) { + locals.add(specifier.local.name) + } + } + return locals +} diff --git a/tooling/lint/rules/data-attrs-enum.mjs b/tooling/lint/rules/data-attrs-enum.mjs new file mode 100644 index 00000000..bba2a8f7 --- /dev/null +++ b/tooling/lint/rules/data-attrs-enum.mjs @@ -0,0 +1,133 @@ +/** + * `bazza/data-attrs-enum`: data-attribute and CSS-variable files export enums. + * + * The docs type generator (`apps/web/scripts/build-types-meta.ts`) reads only + * `enum` declarations named `*DataAttributes` or `*CssVars`. Anything else in + * these files is invisible to it, so its `DataAttrsTable` renders empty. + * Which files the rule applies to is set in `.oxlintrc.json`; a file whose name + * doesn't say which kind it is gets reported rather than skipped. + */ +import { findImport, findTopLevelBinding } from './ast.mjs' + +const kinds = [ + { + file: /\.data-(attrs|attributes)\.ts$/, + name: /(DataAttributes|DataAttrs)$/, + suffix: 'DataAttributes', + section: 'Data Attributes File', + }, + { + file: /\.css-vars\.ts$/, + name: /(CssVars|CSSVars)$/, + suffix: 'CssVars', + section: 'CSS Variables File', + }, +] + +/** Whether an import path points at a file this rule checks. */ +const isEnumFile = (source) => + kinds.some(({ file }) => file.test(source.replace(/\.js$/, '.ts'))) + +/** How to name what a data-attribute file exports instead of an enum. */ +function describe(node) { + if ( + node?.type === 'VariableDeclaration' || + node?.type === 'VariableDeclarator' + ) { + return 'a variable (an `as const` object?)' + } + return node + ? 'something other than an enum' + : 'a type, or a name this file never declares' +} + +export const dataAttrsEnum = { + meta: { + type: 'problem', + docs: { + description: + 'Data-attribute and CSS-variable files export only enums the docs can read.', + }, + }, + create(context) { + const kind = kinds.find(({ file }) => file.test(context.filename)) + if (!kind) { + return { + Program(program) { + context.report({ + node: program, + message: + "Can't tell whether this is a data-attribute or CSS-variable file: name it `.data-attrs.ts` or `.css-vars.ts`, or remove it from `bazza/data-attrs-enum` in `.oxlintrc.json`.", + }) + }, + } + } + let program + const tail = `The docs type tables only read \`enum\` declarations named \`*${kind.suffix}\`, so anything else here shows up empty on the docs site. See "${kind.section}" in packages/react/AGENTS.md.` + const checkName = (node, name) => { + if (kind.name.test(name)) return + context.report({ + node, + message: `\`${name}\` should end in \`${kind.suffix}\`. ${tail}`, + }) + } + const cantTell = (node, name, source) => + context.report({ + node, + message: `Can't tell whether \`${name}\` is an enum: \`${source}\` isn't a data-attribute or CSS-variable file, so nothing checks it. Declare the enum here, or in a \`.data-attrs.ts\` file you re-export from. ${tail}`, + }) + const notAnEnum = (node, what) => + context.report({ + node, + message: `Only enums belong in a ${kind.suffix} file; this exports ${what}. Convert it to \`export enum\`. ${tail}`, + }) + return { + Program(node) { + program = node + }, + ExportDefaultDeclaration(node) { + context.report({ + node, + message: `Default export in a ${kind.suffix} file. Export a named \`enum\`. ${tail}`, + }) + }, + ExportAllDeclaration(node) { + context.report({ + node, + message: `\`export *\` in a ${kind.suffix} file. Re-export each enum by name. ${tail}`, + }) + }, + ExportNamedDeclaration(node) { + const { declaration } = node + if (!declaration) { + for (const specifier of node.specifiers) { + const name = specifier.exported.name ?? specifier.exported.value + checkName(specifier.exported, name) + // An enum from another data-attribute or CSS-variable file is + // checked when that file is linted, unless that file is on this + // rule's allowlist. From anywhere else, it can't be checked. + const imported = node.source + ? { source: node.source.value } + : findImport(program, specifier.local.name) + if (imported) { + if (!isEnumFile(imported.source)) { + cantTell(specifier, name, imported.source) + } + continue + } + const binding = findTopLevelBinding(program, specifier.local.name) + if (binding?.type !== 'TSEnumDeclaration') { + notAnEnum(specifier, describe(binding)) + } + } + return + } + if (declaration.type === 'TSEnumDeclaration') { + checkName(declaration.id, declaration.id.name) + return + } + notAnEnum(declaration, describe(declaration)) + }, + } + }, +} diff --git a/tooling/lint/rules/forward-ref-named.mjs b/tooling/lint/rules/forward-ref-named.mjs new file mode 100644 index 00000000..4acaf035 --- /dev/null +++ b/tooling/lint/rules/forward-ref-named.mjs @@ -0,0 +1,64 @@ +/** + * `bazza/forward-ref-named`: `forwardRef` wraps a named function. + * + * Parts don't set `displayName`, so the render function's name is what React + * DevTools and error messages show. An arrow or anonymous function shows up as + * `ForwardRef` with no name. A function passed by name must be declared in the + * same module, so the rule can see it. + */ +import { + findTopLevelBinding, + forwardRefNames, + isForwardRefCall, + unwrap, +} from './ast.mjs' + +const message = (problem) => + `${problem} Parts don't set \`displayName\`, so React DevTools and error messages show the render function's name. Write \`forwardRef(function PartName(props, forwardedRef) { … })\`, or declare \`function PartNameImpl(…)\` in this module and pass it by name. See "Component Pattern" in packages/react/AGENTS.md.` + +const isAnonymousFunction = (node) => + node?.type === 'ArrowFunctionExpression' || + (node?.type === 'FunctionExpression' && !node.id) + +/** What's wrong with the function `forwardRef` receives, or null when it's named. */ +function problemWith(render, program) { + if (render?.type === 'FunctionExpression' && render.id) return null + if (isAnonymousFunction(render)) { + return '`forwardRef` wraps an anonymous function.' + } + if (render?.type === 'Identifier') { + const binding = findTopLevelBinding(program, render.name) + if (binding?.type === 'FunctionDeclaration') return null + const init = unwrap(binding?.init) + if (init?.type === 'FunctionExpression' && init.id) return null + if (isAnonymousFunction(init)) { + return `\`forwardRef\` wraps \`${render.name}\`, which is an arrow or anonymous function.` + } + return `Can't tell whether \`${render.name}\` is a named function: declare it in this module with \`function ${render.name}(…)\`.` + } + return "Can't tell whether `forwardRef` wraps a named function: pass a function expression or the name of a function declared in this module." +} + +export const forwardRefNamed = { + meta: { + type: 'problem', + docs: { description: '`forwardRef` wraps a named function.' }, + }, + create(context) { + let names + let program + return { + Program(node) { + program = node + names = forwardRefNames(node) + }, + CallExpression(node) { + if (!isForwardRefCall(node, names)) return + const render = unwrap(node.arguments[0]) + const problem = problemWith(render, program) + if (!problem) return + context.report({ node: render ?? node, message: message(problem) }) + }, + } + }, +} diff --git a/tooling/lint/rules/part-namespace.mjs b/tooling/lint/rules/part-namespace.mjs new file mode 100644 index 00000000..638c31ad --- /dev/null +++ b/tooling/lint/rules/part-namespace.mjs @@ -0,0 +1,188 @@ +/** + * `bazza/part-namespace`: every exported part has a namespace with its types. + * + * Consumers type props and state through the part itself + * (`DropdownMenu.Item.Props`, `DropdownMenu.Item.State`), and the docs type + * tables read the same names. A part is an exported value built from + * `forwardRef(…)`, including `memo(forwardRef(…))`. It needs + * `export namespace Part { … Props … }`, plus `State` when its render function + * passes `state` to `useRender`. + */ +import { + exportedLocals, + findForwardRefCall, + findTopLevelBinding, + forwardRefNames, + importedNames, + topLevelDeclaration, + unwrap, +} from './ast.mjs' + +/** Type names exported from `namespace Name { … }` blocks, merged across blocks. */ +function namespaceMembers(program) { + const namespaces = new Map() + for (const statement of program.body) { + const declaration = topLevelDeclaration(statement) + if (declaration?.type !== 'TSModuleDeclaration') continue + const name = declaration.id.name + const members = namespaces.get(name) ?? new Set() + for (const inner of declaration.body?.body ?? []) { + // `Part.Props` only resolves for consumers when the member is exported, + // which every member of a `declare namespace` is. + if (inner.type !== 'ExportNamedDeclaration' && !declaration.declare) { + continue + } + const member = topLevelDeclaration(inner) + if ( + member?.type === 'TSInterfaceDeclaration' || + member?.type === 'TSTypeAliasDeclaration' + ) { + members.add(member.id.name) + } + } + namespaces.set(name, members) + } + return namespaces +} + +const functionTypes = new Set([ + 'FunctionDeclaration', + 'FunctionExpression', + 'ArrowFunctionExpression', +]) + +/** + * Calls `visit` on every AST node under `root`, without entering functions + * nested inside it: a component declared inside a render function renders + * itself, not the part. + */ +function walk(root, visit) { + const stack = [root] + while (stack.length > 0) { + const node = stack.pop() + if (!node || typeof node.type !== 'string') continue + if (node !== root && functionTypes.has(node.type)) continue + visit(node) + for (const key of Object.keys(node)) { + if (key === 'parent') continue + const value = node[key] + if (Array.isArray(value)) stack.push(...value) + else if (value && typeof value === 'object') stack.push(value) + } + } +} + +/** A call to `useRender` under any of `names`, or `.useRender(…)`. */ +function isUseRenderCall(node, names) { + if (node.type !== 'CallExpression') return false + const { callee } = node + if (callee.type === 'Identifier') return names.has(callee.name) + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.name === 'useRender' + ) +} + +/** + * Whether the `useRender` calls under `root` pass `state`: `'yes'`, `'no'`, or + * `'unknown'` when a call's options aren't an object literal the rule can read. + */ +function statePassedToUseRender(root, names) { + let result = 'no' + walk(root, (node) => { + if (result === 'yes') return + if (!isUseRenderCall(node, names)) return + const options = unwrap(node.arguments[0]) + if (options?.type !== 'ObjectExpression') { + result = 'unknown' + return + } + for (const property of options.properties) { + if (property.type === 'SpreadElement') { + result = 'unknown' + } else if ( + !property.computed && + (property.key.name ?? property.key.value) === 'state' + ) { + result = 'yes' + return + } + } + }) + return result +} + +const isFunction = (node) => functionTypes.has(node?.type) + +/** + * The function that renders a part: the function passed to `forwardRef`, or + * the function it names in this module. Undefined when the rule can't tell. + */ +function renderFunction(forwardRefCall, program) { + const render = unwrap(forwardRefCall.arguments[0]) + if (isFunction(render)) return render + if (render?.type !== 'Identifier') return undefined + const binding = findTopLevelBinding(program, render.name) + if (binding?.type === 'FunctionDeclaration') return binding + const init = unwrap(binding?.init) + return isFunction(init) ? init : undefined +} + +const example = (name) => + `\`export namespace ${name} { export type State = ${name}State; export interface Props extends ${name}Props {} }\`` + +export const partNamespace = { + meta: { + type: 'problem', + docs: { + description: + 'Every exported part has `export namespace Part { State; Props }`.', + }, + }, + create(context) { + return { + Program(program) { + const exported = exportedLocals(program) + const namespaces = namespaceMembers(program) + const names = forwardRefNames(program) + const useRenderNames = importedNames( + program, + '@base-ui/react/use-render', + 'useRender', + ) + for (const statement of program.body) { + const declaration = topLevelDeclaration(statement) + if (declaration?.type !== 'VariableDeclaration') continue + for (const declarator of declaration.declarations) { + if (declarator.id.type !== 'Identifier') continue + const name = declarator.id.name + if (!exported.has(name)) continue + const call = findForwardRefCall(declarator.init, names) + if (!call) continue + const members = namespaces.get(name) ?? new Set() + const render = renderFunction(call, program) + const state = render + ? statePassedToUseRender(render, useRenderNames) + : 'unknown' + const missing = [ + ...(state === 'yes' && !members.has('State') ? ['State'] : []), + ...(!members.has('Props') ? ['Props'] : []), + ] + if (missing.length > 0) { + context.report({ + node: declarator.id, + message: `Part \`${name}\` has no ${missing.map((m) => `\`${name}.${m}\``).join(' or ')} type. Consumers and the docs type tables read a part's types from its namespace. Add ${example(name)} (\`State\` only when the part passes \`state\` to \`useRender\`). See "Component Pattern" in packages/react/AGENTS.md.`, + }) + } else if (state === 'unknown' && !members.has('State')) { + context.report({ + node: declarator.id, + message: `Can't tell whether part \`${name}\` passes \`state\` to \`useRender\`: its render function isn't declared in this module, or a \`useRender\` call's options aren't an object literal. Declare the render function here and pass the options inline (\`useRender({ state, … })\`), or add \`${name}.State\` to its namespace. See "Component Pattern" in packages/react/AGENTS.md.`, + }) + } + } + } + }, + } + }, +} diff --git a/tooling/lint/rules/rule-names.mjs b/tooling/lint/rules/rule-names.mjs index d6cd8534..88e92aa5 100644 --- a/tooling/lint/rules/rule-names.mjs +++ b/tooling/lint/rules/rule-names.mjs @@ -6,4 +6,10 @@ * when it matches a directive to a rule: `use-client` and `foo/use-client` * both disable `bazza/use-client`. */ -export const bazzaRuleNames = new Set(['disable-needs-reason']) +export const bazzaRuleNames = new Set([ + 'data-attrs-enum', + 'disable-needs-reason', + 'forward-ref-named', + 'part-namespace', + 'use-client', +]) diff --git a/tooling/lint/rules/use-client.mjs b/tooling/lint/rules/use-client.mjs new file mode 100644 index 00000000..cae53f5d --- /dev/null +++ b/tooling/lint/rules/use-client.mjs @@ -0,0 +1,45 @@ +/** + * `bazza/use-client`: parts and context modules start with `'use client'`. + * + * Every part uses hooks or context, so a React Server Components app can only + * import it across a client boundary. Which files the rule applies to is set + * in `.oxlintrc.json`. + */ +export const useClient = { + meta: { + type: 'problem', + fixable: 'code', + docs: { + description: "Parts and context modules start with 'use client'.", + }, + }, + create(context) { + return { + Program(program) { + const prologue = [] + for (const statement of program.body) { + if (typeof statement.directive !== 'string') break + prologue.push(statement.directive) + } + if (prologue.includes('use client')) return + // Report on the first statement, so an `oxlint-disable-next-line` + // comment above it can make an exception. + const [first] = program.body + context.report({ + ...(first + ? { node: first } + : { + loc: { + start: { line: 1, column: 0 }, + end: { line: 1, column: 0 }, + }, + }), + message: + "Missing `'use client'` directive. Parts and their contexts use hooks, so a React Server Components app can only import them across a client boundary. Add `'use client'` as the first statement. See \"Component Pattern\" in packages/react/AGENTS.md.", + fix: (fixer) => + fixer.insertTextBeforeRange([0, 0], "'use client'\n\n"), + }) + }, + } + }, +}