diff --git a/README.md b/README.md
index ec14616..22ed10c 100644
--- a/README.md
+++ b/README.md
@@ -185,6 +185,7 @@ This plugin does not support MDX files.
| [`storybook/no-stories-of`](./docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | |
csf-strict flat/csf-strict |
| [`storybook/no-title-property-in-meta`](./docs/rules/no-title-property-in-meta.md) | Do not define a title in meta | 🔧 | csf-strict flat/csf-strict |
| [`storybook/no-uninstalled-addons`](./docs/rules/no-uninstalled-addons.md) | This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name. | | recommended flat/recommended |
+| [`storybook/only-csf3`](./docs/rules/only-csf3.md) | Enforce Component Story Format 3.0 (CSF3) for stories | 🔧 | N/A |
| [`storybook/prefer-pascal-case`](./docs/rules/prefer-pascal-case.md) | Stories should use PascalCase | 🔧 | recommended flat/recommended |
| [`storybook/story-exports`](./docs/rules/story-exports.md) | A story file must contain at least one story export | | recommended flat/recommended csf flat/csf csf-strict flat/csf-strict |
| [`storybook/use-storybook-expect`](./docs/rules/use-storybook-expect.md) | Use expect from `@storybook/test`, `storybook/test` or `@storybook/jest` | 🔧 | addon-interactions flat/addon-interactions recommended flat/recommended |
diff --git a/docs/rules/only-csf3.md b/docs/rules/only-csf3.md
new file mode 100644
index 0000000..22a6193
--- /dev/null
+++ b/docs/rules/only-csf3.md
@@ -0,0 +1,151 @@
+# Enforce CSF3 format for stories (only-csf3)
+
+[Component Story Format 3.0 (CSF3)](https://storybook.js.org/blog/component-story-format-3-0/) is the latest iteration of Storybook's story format, offering a simpler and more maintainable way to write stories. This rule enforces the use of CSF3 by identifying and reporting CSF2 patterns.
+
+
+
+**Included in these configurations**: N/A
+
+
+
+## Rule Details
+
+This rule aims to prevent the use of CSF2 patterns in story files and encourage migration to CSF3.
+
+Examples of **incorrect** code:
+
+```js
+// ❌ CSF2: Using Template.bind({})
+const Template = (args) =>
+export const Primary = Template.bind({})
+Primary.args = { label: 'Primary' }
+
+// ❌ CSF2: Story function declaration
+export function Secondary(args) {
+ return
+}
+
+// ❌ CSF2: Story arrow function
+export const Tertiary = () => Click me
+
+// ❌ CSF2: Story with property assignments
+export const WithArgs = Template.bind({})
+WithArgs.args = { label: 'With Args' }
+WithArgs.parameters = { layout: 'centered' }
+
+// ❌ CSF2: Template.bind({}) with multiple stories
+const Template = (args) =>
+
+export const Primary = Template.bind({})
+Primary.args = { label: 'Primary', variant: 'primary' }
+Primary.parameters = { backgrounds: { default: 'light' } }
+
+export const Secondary = Template.bind({})
+Secondary.args = { label: 'Secondary', variant: 'secondary' }
+Secondary.parameters = { backgrounds: { default: 'dark' } }
+```
+
+Examples of **correct** code:
+
+```js
+// ✅ CSF3: Object literal with args
+export const Primary = {
+ args: {
+ label: 'Primary',
+ },
+}
+
+// ✅ CSF3: Object literal with render function
+export const Secondary = {
+ render: (args) => Secondary ,
+}
+
+// ✅ CSF3: Multiple stories sharing render logic
+const render = (args) =>
+
+export const Primary = {
+ render,
+ args: { label: 'Primary', variant: 'primary' },
+ parameters: { backgrounds: { default: 'light' } },
+}
+
+export const Secondary = {
+ render,
+ args: { label: 'Secondary', variant: 'secondary' },
+ parameters: { backgrounds: { default: 'dark' } },
+}
+```
+
+## When Not To Use It
+
+If you're maintaining a legacy Storybook project that extensively uses CSF2 patterns and cannot migrate to CSF3 yet, you might want to disable this rule.
+
+## Migration Examples
+
+Here are examples of how to migrate common CSF2 patterns to CSF3:
+
+1. Template.bind({}) with args:
+
+```js
+// ❌ CSF2
+const Template = (args) =>
+export const Primary = Template.bind({})
+Primary.args = { label: 'Primary' }
+
+// ✅ CSF3
+export const Primary = {
+ render: (args) => ,
+ args: { label: 'Primary' },
+}
+```
+
+2. Function declaration stories:
+
+```js
+// ❌ CSF2
+export function Primary(args) {
+ return Primary
+}
+
+// ✅ CSF3
+export const Primary = {
+ render: (args) => Primary ,
+}
+```
+
+3. Story with multiple properties:
+
+```js
+// ❌ CSF2
+export const Primary = Template.bind({})
+Primary.args = { label: 'Primary' }
+Primary.parameters = { layout: 'centered' }
+Primary.decorators = [
+ (Story) => (
+
+
+
+ ),
+]
+
+// ✅ CSF3
+export const Primary = {
+ render: (args) => ,
+ args: { label: 'Primary' },
+ parameters: { layout: 'centered' },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+```
+
+## Further Reading
+
+- [Component Story Format 3.0](https://storybook.js.org/blog/component-story-format-3-0/)
+- [Migrating to CSF3](https://storybook.js.org/docs/migration-guide/from-older-version#csf-2-to-csf-3)
+- [Upgrading from CSF 2.0 to 3.0](https://storybook.js.org/docs/api/csf/index#upgrading-from-csf-2-to-csf-3)
+- [Writing Stories in Storybook](https://storybook.js.org/docs/writing-stories#component-story-format)
diff --git a/lib/index.ts b/lib/index.ts
index 54bd38b..f83014c 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -25,6 +25,7 @@ import noRedundantStoryName from './rules/no-redundant-story-name'
import noStoriesOf from './rules/no-stories-of'
import noTitlePropertyInMeta from './rules/no-title-property-in-meta'
import noUninstalledAddons from './rules/no-uninstalled-addons'
+import onlyCsf3 from './rules/only-csf3'
import preferPascalCase from './rules/prefer-pascal-case'
import storyExports from './rules/story-exports'
import useStorybookExpect from './rules/use-storybook-expect'
@@ -57,6 +58,7 @@ export = {
'no-stories-of': noStoriesOf,
'no-title-property-in-meta': noTitlePropertyInMeta,
'no-uninstalled-addons': noUninstalledAddons,
+ 'only-csf3': onlyCsf3,
'prefer-pascal-case': preferPascalCase,
'story-exports': storyExports,
'use-storybook-expect': useStorybookExpect,
diff --git a/lib/rules/only-csf3.ts b/lib/rules/only-csf3.ts
new file mode 100644
index 0000000..3f0fed3
--- /dev/null
+++ b/lib/rules/only-csf3.ts
@@ -0,0 +1,293 @@
+/**
+ * @fileoverview Enforce CSF3 format for stories.
+ * @see https://storybook.js.org/blog/component-story-format-3-0/
+ */
+
+import type { TSESTree } from '@typescript-eslint/utils'
+import { createStorybookRule } from '../utils/create-storybook-rule'
+import { isIdentifier } from '../utils/ast'
+
+//------------------------------------------------------------------------------
+// Rule Definition
+//------------------------------------------------------------------------------
+
+export = createStorybookRule({
+ name: 'only-csf3',
+ defaultOptions: [],
+ meta: {
+ type: 'problem',
+ severity: 'error',
+ docs: {
+ description: 'Enforce Component Story Format 3.0 (CSF3) for stories',
+ excludeFromConfig: true,
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ noCSF2Format: 'Story "{{storyName}}" uses CSF2 {{pattern}}. Please migrate to CSF3.',
+ },
+ },
+
+ create(context) {
+ const sourceCode = context.sourceCode
+ const textCache = new Map()
+ const storyNodes = new Map()
+
+ // Types
+ interface StoryInfo {
+ node: TSESTree.Node
+ exportNode?: TSESTree.Node
+ assignments: Assignment[]
+ isTemplateBind: boolean
+ reported: boolean
+ }
+
+ interface PropertyAssignment {
+ type: 'property'
+ property: string
+ value: TSESTree.Expression
+ node: TSESTree.AssignmentExpression
+ }
+
+ interface RenderAssignment {
+ type: 'render'
+ property: 'render'
+ value: TSESTree.Identifier
+ templateNode: TSESTree.CallExpression
+ }
+
+ type Assignment = PropertyAssignment | RenderAssignment
+
+ //----------------------------------------------------------------------
+ // Helpers
+ //----------------------------------------------------------------------
+
+ const isStoryName = (name: string): boolean => {
+ // Fastest way to check uppercase first character
+ const firstChar = name.charCodeAt(0)
+ return firstChar >= 65 && firstChar <= 90
+ }
+
+ const isTemplateBind = (node: TSESTree.Node): node is TSESTree.CallExpression => {
+ return (
+ node.type === 'CallExpression' &&
+ node.callee.type === 'MemberExpression' &&
+ node.callee.property.type === 'Identifier' &&
+ node.callee.property.name === 'bind'
+ )
+ }
+
+ const getNodeText = (node: TSESTree.Node): string => {
+ let cached = textCache.get(node)
+ if (!cached) {
+ cached = sourceCode.getText(node)
+ textCache.set(node, cached)
+ }
+ return cached
+ }
+
+ const createCSF3Object = (story: StoryInfo): string => {
+ if (story.assignments.length === 0) return '{}'
+
+ let assignments = [...story.assignments]
+
+ // Handle Template.bind() case - add render property
+ if (story.isTemplateBind && story.node.type === 'CallExpression') {
+ const callExpr = story.node
+ if (
+ callExpr.callee.type === 'MemberExpression' &&
+ callExpr.callee.object.type === 'Identifier'
+ ) {
+ const template = callExpr.callee.object
+ assignments = assignments.filter((a) => a.property !== 'render')
+ assignments.unshift({
+ type: 'render',
+ property: 'render',
+ value: template,
+ templateNode: callExpr,
+ })
+ }
+ }
+
+ // Sort properties: render first
+ const renderIdx = assignments.findIndex((a) => a.property === 'render')
+ if (renderIdx > 0) {
+ const render = assignments[renderIdx]
+ if (render) {
+ assignments.splice(renderIdx, 1)
+ assignments.unshift(render)
+ }
+ } else if (renderIdx === -1) {
+ assignments.sort((a, b) => a.property.localeCompare(b.property))
+ }
+
+ // Format as multi-line for readability when multiple properties
+ if (assignments.length > 1) {
+ const props = assignments.map((a) => ` ${a.property}: ${getNodeText(a.value)},`)
+ return `{\n${props.join('\n')}\n}`
+ } else {
+ const prop = assignments[0]!
+ return `{\n ${prop.property}: ${getNodeText(prop.value)},\n}`
+ }
+ }
+
+ const createFunctionCSF3 = (
+ name: string,
+ func:
+ | TSESTree.FunctionDeclaration
+ | TSESTree.FunctionExpression
+ | TSESTree.ArrowFunctionExpression
+ ): string => {
+ const params = func.params.map((p) => getNodeText(p)).join(', ')
+
+ // For arrow functions without block statement, wrap in block
+ if (func.body.type !== 'BlockStatement') {
+ const expr = getNodeText(func.body)
+ return `export const ${name} = {\n render: function(${params}) {\n return ${expr}\n },\n}`
+ }
+
+ // For block statements, extract content and add proper indentation
+ const bodyText = getNodeText(func.body)
+ const bodyLines = bodyText.slice(1, -1).split('\n') // Remove outer braces
+
+ // Process each line to add indentation, filtering out empty lines
+ const indentedLines = bodyLines
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0) // Remove empty lines
+ .map((line) => ` ${line}`)
+
+ // Join lines
+ const bodyContent = indentedLines.join('\n')
+
+ return `export const ${name} = {\n render: function(${params}) {\n${bodyContent}\n },\n}`
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ return {
+ ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration): void {
+ const decl = node.declaration
+ if (!decl) return
+
+ // Function declarations
+ if (decl.type === 'FunctionDeclaration' && decl.id) {
+ const name = decl.id.name
+ if (!isStoryName(name)) return
+
+ context.report({
+ node: decl,
+ messageId: 'noCSF2Format',
+ data: { storyName: name, pattern: 'function declaration' },
+ fix: (fixer) => fixer.replaceText(node, createFunctionCSF3(name, decl)),
+ })
+ return
+ }
+
+ // Variable declarations
+ if (decl.type === 'VariableDeclaration') {
+ const [declarator] = decl.declarations
+ if (!declarator?.id || !isIdentifier(declarator.id) || !declarator.init) return
+
+ const name = declarator.id.name
+ if (!isStoryName(name)) return
+
+ const init = declarator.init
+ const isFuncExpr =
+ init.type === 'FunctionExpression' || init.type === 'ArrowFunctionExpression'
+ const isObjExpr = init.type === 'ObjectExpression'
+ const isTemplBind = isTemplateBind(init)
+
+ // Function expressions
+ if (isFuncExpr) {
+ const funcExpr = init as TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression
+ context.report({
+ node: init,
+ messageId: 'noCSF2Format',
+ data: { storyName: name, pattern: 'function expression' },
+ fix: (fixer) => fixer.replaceText(node, createFunctionCSF3(name, funcExpr)),
+ })
+ return
+ }
+
+ // Track for later processing
+ if (isObjExpr || isTemplBind) {
+ let hasProps = false
+ if (isObjExpr && init.type === 'ObjectExpression') {
+ hasProps = init.properties.length > 0
+ }
+
+ if (isTemplBind || !hasProps) {
+ storyNodes.set(name, {
+ node: init,
+ exportNode: node,
+ assignments: [],
+ isTemplateBind: isTemplBind,
+ reported: false,
+ })
+ }
+ }
+ }
+ },
+
+ AssignmentExpression(node: TSESTree.AssignmentExpression): void {
+ if (
+ node.left.type !== 'MemberExpression' ||
+ !isIdentifier(node.left.object) ||
+ !isIdentifier(node.left.property)
+ )
+ return
+
+ const name = node.left.object.name
+ if (!isStoryName(name)) return
+
+ const story = storyNodes.get(name)
+ if (story) {
+ story.assignments.push({
+ type: 'property',
+ property: node.left.property.name,
+ value: node.right,
+ node,
+ })
+ story.reported = false
+ }
+ },
+
+ 'Program:exit'(): void {
+ for (const [name, story] of storyNodes) {
+ if (story.reported || (!story.isTemplateBind && story.assignments.length === 0)) continue
+
+ const lastAssign = story.assignments[story.assignments.length - 1]
+ const reportNode = story.isTemplateBind
+ ? story.node
+ : lastAssign?.type === 'property'
+ ? lastAssign.node
+ : story.node
+
+ context.report({
+ node: reportNode,
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: name,
+ pattern: story.isTemplateBind
+ ? 'template bind'
+ : `property assignment (.${lastAssign?.property})`,
+ },
+ fix: (fixer) => {
+ const startNode = story.exportNode || story.node
+ const endNode = lastAssign?.type === 'property' ? lastAssign.node : story.node
+ const csf3Code = createCSF3Object(story)
+
+ return fixer.replaceTextRange(
+ [startNode.range![0], endNode.range![1]],
+ `export const ${name} = ${csf3Code}`
+ )
+ },
+ })
+ story.reported = true
+ }
+ },
+ }
+ },
+})
diff --git a/tests/lib/rules/only-csf3.test.ts b/tests/lib/rules/only-csf3.test.ts
new file mode 100644
index 0000000..e3b0d6e
--- /dev/null
+++ b/tests/lib/rules/only-csf3.test.ts
@@ -0,0 +1,241 @@
+/**
+ * @fileoverview Enforce CSF3 format for stories
+ */
+
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+import { AST_NODE_TYPES } from '@typescript-eslint/utils'
+import dedent from 'ts-dedent'
+
+import rule from '../../../lib/rules/only-csf3'
+import ruleTester from '../../utils/rule-tester'
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+ruleTester.run('only-csf3', rule, {
+ valid: [
+ // Simple CSF3 story
+ 'export const Primary = {}',
+
+ // CSF3 object with args
+ dedent`
+ export const Primary = {
+ args: {
+ primary: true,
+ label: 'Button',
+ },
+ }
+ `,
+
+ // CSF3 with render function
+ dedent`
+ export const Secondary = {
+ render: (args) => ,
+ }
+ `,
+
+ // CSF3 meta export
+ dedent`
+ export default {
+ title: 'Button',
+ component: Button,
+ tags: ['autodocs'],
+ } satisfies Meta
+ `,
+
+ // CSF3 with play function
+ dedent`
+ export const WithInteractions = {
+ play: async ({ canvasElement }) => {
+ await userEvent.click(canvasElement.querySelector('button'))
+ }
+ }
+ `,
+
+ // Non-story exports should be ignored
+ dedent`
+ export const data = { foo: 'bar' }
+ export const utils = { format: () => {} }
+ `,
+
+ // Re-exports should be ignored
+ dedent`
+ export { Button } from './Button'
+ export * from './types'
+ `,
+
+ // Default export without CSF2 patterns
+ dedent`
+ export default function MyComponent() {
+ return Hello
+ }
+ `,
+ ],
+
+ invalid: [
+ // CSF2: Template.bind({}) with args
+ {
+ code: dedent`
+ const Template = (args) =>
+ export const Primary = Template.bind({})
+ Primary.args = { label: 'Button' }
+ `,
+ output: dedent`
+ const Template = (args) =>
+ export const Primary = {
+ render: Template,
+ args: { label: 'Button' },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Primary',
+ pattern: 'template bind',
+ },
+ type: AST_NODE_TYPES.CallExpression,
+ },
+ ],
+ },
+
+ // CSF2: Function declaration
+ {
+ code: dedent`
+ export function Secondary(args) {
+ return
+ }
+ `,
+ output: dedent`
+ export const Secondary = {
+ render: function(args) {
+ return
+ },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Secondary',
+ pattern: 'function declaration',
+ },
+ type: AST_NODE_TYPES.FunctionDeclaration,
+ },
+ ],
+ },
+
+ // CSF2: Function expression
+ {
+ code: dedent`
+ export const Secondary = function(args) {
+ return Click me
+ }
+ `,
+ output: dedent`
+ export const Secondary = {
+ render: function(args) {
+ return Click me
+ },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Secondary',
+ pattern: 'function expression',
+ },
+ type: AST_NODE_TYPES.FunctionExpression,
+ },
+ ],
+ },
+
+ // CSF2: Mixed with CSF3 (should detect both)
+ {
+ code: dedent`
+ export const Valid = {
+ args: { label: 'Valid' },
+ }
+ export function Invalid(args) {
+ return
+ }
+ `,
+ output: dedent`
+ export const Valid = {
+ args: { label: 'Valid' },
+ }
+ export const Invalid = {
+ render: function(args) {
+ return
+ },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Invalid',
+ pattern: 'function declaration',
+ },
+ type: AST_NODE_TYPES.FunctionDeclaration,
+ },
+ ],
+ },
+
+ // CSF2: Property assignment mixed with CSF3
+ {
+ code: dedent`
+ export const Primary = {}
+ Primary.parameters = { foo: 'bar' }
+ `,
+ output: dedent`
+ export const Primary = {
+ parameters: { foo: 'bar' },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Primary',
+ pattern: 'property assignment (.parameters)',
+ },
+ type: AST_NODE_TYPES.AssignmentExpression,
+ },
+ ],
+ },
+
+ // CSF2: Complex story with multiple properties
+ {
+ code: dedent`
+ export const Complex = Template.bind({})
+ Complex.args = { label: 'Complex' }
+ Complex.parameters = { layout: 'centered' }
+ Complex.play = async () => { /* test interactions */ }
+ `,
+ output: dedent`
+ export const Complex = {
+ render: Template,
+ args: { label: 'Complex' },
+ parameters: { layout: 'centered' },
+ play: async () => { /* test interactions */ },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Complex',
+ pattern: 'template bind',
+ },
+ type: AST_NODE_TYPES.CallExpression,
+ },
+ ],
+ },
+ ],
+})