Skip to content

Conversation

@cylewaitforit
Copy link

@cylewaitforit cylewaitforit commented Jul 7, 2025

Closes storybookjs/eslint-plugin-storybook#126

What I did

Adds a new rule to check that stories are written in csf3 format.

Storybook 9 migration guide recommends
converting to csf3.

This updates and moves a PR the was previously opened in the eslint-plugin-storybook repo.

storybookjs/eslint-plugin-storybook#206

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook canary-release-pr.yml --field pr=<PR_NUMBER>

Greptile Summary

Added new ESLint rule 'only-csf3' to enforce Component Story Format 3.0 (CSF3) in Storybook stories, aligning with Storybook 9's migration recommendations.

  • Added code/lib/eslint-plugin/src/rules/only-csf3.ts implementing auto-fixable rule to detect and convert CSF2 patterns to CSF3
  • Added comprehensive test suite in code/lib/eslint-plugin/src/rules/only-csf3.test.ts covering various CSF2 to CSF3 conversion scenarios
  • Added detailed documentation in code/lib/eslint-plugin/docs/rules/only-csf3.md with migration examples and best practices
  • Updated code/lib/eslint-plugin/src/index.ts to integrate the new rule into the ESLint plugin system

Summary by CodeRabbit

  • New Features
    • Introduced ESLint rule storybook/only-csf3 to enforce CSF3 for stories, with automatic fixes to migrate common CSF2 patterns.
  • Documentation
    • Added detailed rule documentation, including rule behavior, migration guidance, examples, and references.
    • Updated ESLint plugin docs and rules tables to include storybook/only-csf3 and note its auto-fix capability.
  • Tests
    • Added comprehensive tests covering valid CSF3 usage and invalid CSF2 scenarios, with expected diagnostics and fix outputs.


<!-- RULE-CATEGORIES:START -->

**Included in these configurations**: N/A
Copy link
Author

@cylewaitforit cylewaitforit Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not initially including in any config. If the csf-strict config were to be fixed by #31963 merging, I think this rule would fit well in that config.

@cylewaitforit cylewaitforit marked this pull request as ready for review July 7, 2025 02:19
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 files reviewed, 4 comments
Edit PR Review Bot Settings | Greptile

@cylewaitforit cylewaitforit changed the title feat: add rule only-csf3 feat(eslint-plugin): add rule only-csf3 Jul 7, 2025
@storybook-app-bot
Copy link

storybook-app-bot bot commented Jul 7, 2025

Package Benchmarks

Commit: 194a9ff, ran on 24 September 2025 at 12:44:42 UTC

The following packages have significant changes to their size or dependencies:

@storybook/sveltekit

Before After Difference
Dependency count 20 20 0
Self size 58 KB 48 KB 🎉 -11 KB 🎉
Dependency size 26.84 MB 26.84 MB 0 B
Bundle Size Analyzer Link Link

eslint-plugin-storybook

Before After Difference
Dependency count 35 35 0
Self size 139 KB 151 KB 🚨 +12 KB 🚨
Dependency size 3.41 MB 3.41 MB 0 B
Bundle Size Analyzer Link Link

@cylewaitforit cylewaitforit changed the title feat(eslint-plugin): add rule only-csf3 ESLint: add rule only-csf3 Jul 7, 2025
@github-actions github-actions bot added the Stale label Jul 24, 2025
@jonniebigodes jonniebigodes requested review from yannbf and removed request for kylegach September 24, 2025 11:21
@jonniebigodes
Copy link
Contributor

@cylewaitforit appreciate you taking the time to put together this pull request and helping us improve the ESlint package. We appreciate it 🙏 ! From my end all is good and I'll defer to @yannbf for a review on the code changes. If you're ok with it. Can you take a look into the conflcts and solve them?

Hope you have a great day.

Stay safe

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 24, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

  • Adds a new ESLint rule: only-csf3, exposed via the plugin’s rules map.
  • Implements rule logic to detect CSF2 story patterns and auto-fix to CSF3 in code/lib/eslint-plugin/src/rules/only-csf3.ts.
  • Introduces a comprehensive test suite for the rule in code/lib/eslint-plugin/src/rules/only-csf3.test.ts.
  • Adds rule documentation at code/lib/eslint-plugin/docs/rules/only-csf3.md.
  • Updates plugin documentation to list storybook/only-csf3 as fixable in docs/configure/integration/eslint-plugin.mdx.
  • No API signature changes beyond adding the new rule to the exported rules map.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor Dev as Developer
    participant ESLint as ESLint CLI/Runner
    participant Rule as only-csf3 Rule
    participant Src as Source File (*.stories.*)

    Dev->>ESLint: Run lint (with --fix)
    ESLint->>Src: Parse to AST
    ESLint->>Rule: Initialize rule (Program enter)

    Note over Rule: Track exports/assignments<br/>Detect CSF2 patterns:<br/>- Template.bind<br/>- Function decl/expr/arrow<br/>- Object with assignments

    loop Visit AST nodes
      Rule->>Src: Inspect export declarations/assignments
      alt CSF2 pattern found
        Rule-->>Rule: Record story state (name, node, assignments, templateBind)
      end
    end

    Rule->>ESLint: On detection, queue reports/fixes
    Rule->>ESLint: Program exit: aggregate reports
    alt --fix enabled
      ESLint->>Src: Apply fixes (generate CSF3 export object with render/args)
    else --fix disabled
      ESLint-->>Dev: Report violations (noCSF2Format, pattern)
    end

    ESLint-->>Dev: Output results
Loading

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "ESLint: add rule only-csf3" is concise, directly describes the primary change (adding the only-csf3 ESLint rule), and is specific enough for a teammate scanning history to understand the main intent of the PR.
Linked Issues Check ✅ Passed The PR implements the requested rule from issue #126 by adding the only-csf3 rule implementation (code/lib/eslint-plugin/src/rules/only-csf3.ts), registering it in the plugin index, providing unit tests (only-csf3.test.ts) that cover CSF2 detection and fixes, and documenting the rule (docs and mdx updates), which together satisfy the linked issue's objective to detect and enforce CSF3 conversions.
Out of Scope Changes Check ✅ Passed All changes are scoped to the eslint-plugin (rule implementation, tests, index registration) and documentation updates; there are no modifications to unrelated runtime Storybook packages or other areas of the codebase in the provided summaries, so no out-of-scope changes were detected.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

@cylewaitforit
Copy link
Author

Can you take a look into the conflcts and solve them?

@jonniebigodes Appreciate your time for the review. I merged next back in and resolved the merge conflict. I will look out for any feedback from @yannbf.

Thanks again!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
code/lib/eslint-plugin/docs/rules/only-csf3.md (1)

83-101: Add migration for storyName → name.

CSF2’s storyName maps to CSF3’s name. Add an example to make this explicit.

@@
 ## Migration Examples
@@
 2. Function declaration stories:
@@
  1. Story with multiple properties:
    @@
+
+4. Story name (CSF2 `storyName` → CSF3 `name`):
+
+```js
+// ❌ CSF2
+export const Primary = Template.bind({});
+Primary.storyName = 'Primary (custom)';
+
+// ✅ CSF3
+export const Primary = {
+  render: Template,
+  name: 'Primary (custom)',
+};
+```
code/lib/eslint-plugin/src/rules/only-csf3.test.ts (1)

219-249: Add edge-case tests: arrow fn, storyName mapping, and merge into existing object.

Covers common cases and validates the fixes.

@@
   invalid: [
@@
     },
+
+    // CSF2: Arrow function story
+    {
+      code: dedent`
+        export const Secondary = (args) => <Button {...args} />
+      `,
+      output: dedent`
+        export const Secondary = {
+          render: function(args) {
+            return <Button {...args} />
+          },
+        }
+      `,
+      errors: [
+        {
+          messageId: 'noCSF2Format',
+          data: {
+            storyName: 'Secondary',
+            pattern: 'function expression',
+          },
+          type: AST_NODE_TYPES.ArrowFunctionExpression,
+        },
+      ],
+    },
+
+    // CSF2: Template.bind with storyName should map to CSF3 `name`
+    {
+      code: dedent`
+        const Template = (args) => <Button {...args} />
+        export const Primary = Template.bind({})
+        Primary.storyName = 'Primary (custom)'
+      `,
+      output: dedent`
+        const Template = (args) => <Button {...args} />
+        export const Primary = {
+          render: Template,
+          name: 'Primary (custom)',
+        }
+      `,
+      errors: [
+        {
+          messageId: 'noCSF2Format',
+          data: {
+            storyName: 'Primary',
+            pattern: 'template bind',
+          },
+          type: AST_NODE_TYPES.CallExpression,
+        },
+      ],
+    },
+
+    // CSF2: Merge property assignment into an existing object with props
+    {
+      code: dedent`
+        export const WithProps = {
+          args: { label: 'WithProps' },
+        }
+        WithProps.parameters = { layout: 'centered' }
+      `,
+      output: dedent`
+        export const WithProps = {
+          args: { label: 'WithProps' },
+          parameters: { layout: 'centered' },
+        }
+      `,
+      errors: [
+        {
+          messageId: 'noCSF2Format',
+          data: {
+            storyName: 'WithProps',
+            pattern: 'property assignment (.parameters)',
+          },
+          type: AST_NODE_TYPES.AssignmentExpression,
+        },
+      ],
+    },
@@
   ],
 });
code/lib/eslint-plugin/src/rules/only-csf3.ts (1)

261-283: Minor: keep trailing semicolon (style).

Optional: append a semicolon to the replacement for style parity with many codebases.

-              return fixer.replaceTextRange(
-                [startNode.range[0], endNode.range[1]],
-                `export const ${name} = ${csf3Code}`
-              );
+              return fixer.replaceTextRange(
+                [startNode.range[0], endNode.range[1]],
+                `export const ${name} = ${csf3Code}`
+              );
+              // Prettier/formatters will handle semicolons; add one here if your repo enforces it.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 68140f7 and 194a9ff.

📒 Files selected for processing (5)
  • code/lib/eslint-plugin/docs/rules/only-csf3.md (1 hunks)
  • code/lib/eslint-plugin/src/index.ts (2 hunks)
  • code/lib/eslint-plugin/src/rules/only-csf3.test.ts (1 hunks)
  • code/lib/eslint-plugin/src/rules/only-csf3.ts (1 hunks)
  • docs/configure/integration/eslint-plugin.mdx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
code/**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (.cursorrules)

code/**/*.{test,spec}.{ts,tsx}: Place all test files under the code/ directory
Name test files as *.test.ts, *.test.tsx, *.spec.ts, or *.spec.tsx

Files:

  • code/lib/eslint-plugin/src/rules/only-csf3.test.ts
**/*.test.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.cursor/rules/spy-mocking.mdc)

**/*.test.{ts,tsx,js,jsx}: Use vi.mock() with the spy: true option for all package and file mocks in Vitest tests
Place all mocks at the top of the test file before any test cases
Use vi.mocked() to type and access mocked functions
Implement mock behaviors in beforeEach blocks
Mock all required dependencies that the test subject uses
Mock implementations should be placed in beforeEach blocks
Each mock implementation should return a Promise for async functions
Mock implementations should match the expected return type of the original function
Use vi.mocked() to access and implement mock behaviors
Mock all required properties and methods that the test subject uses
Avoid direct function mocking without vi.mocked()
Avoid mock implementations outside of beforeEach blocks
Avoid mocking without the spy: true option
Avoid inline mock implementations within test cases
Avoid mocking only a subset of required dependencies
Mock at the highest level of abstraction needed
Keep mock implementations simple and focused
Use type-safe mocking with vi.mocked()
Document complex mock behaviors
Group related mocks together

Files:

  • code/lib/eslint-plugin/src/rules/only-csf3.test.ts
🧬 Code graph analysis (1)
code/lib/eslint-plugin/src/rules/only-csf3.ts (2)
code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts (1)
  • Identifier (184-239)
code/core/src/csf/csf-factories.ts (1)
  • story (114-118)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: normal
🔇 Additional comments (6)
code/lib/eslint-plugin/src/index.ts (2)

61-61: Export map entry is correct.

The public key matches docs and tests.


28-28: Rule wiring looks good — confirm regeneration

index.ts is marked autogenerated and exports 'only-csf3'; confirm code/lib/eslint-plugin/src/index.ts was regenerated with the repo generator (yarn update or yarn update-rules) before merging.

code/lib/eslint-plugin/docs/rules/only-csf3.md (1)

7-9: Explicitly not part of any config (N/A) — OK.

This aligns with the intent to keep it opt-in.

code/lib/eslint-plugin/src/rules/only-csf3.ts (2)

248-287: Edge-case: choose report node and range.

OK as-is. With the base-props change, this still correctly replaces from the export decl to the last assignment.


18-28: meta.severity is intentional and typed

StorybookRuleMeta in code/lib/eslint-plugin/src/types/index.ts defines a severity field; createStorybookRule and scripts rely on it (code/lib/eslint-plugin/src/utils/create-storybook-rule.ts, code/lib/eslint-plugin/scripts/utils/updates.ts). No change required.

Likely an incorrect or invalid review comment.

docs/configure/integration/eslint-plugin.mdx (1)

170-171: Docs row for storybook/only-csf3 looks correct.
Matches rule name, description, and fixability; link present in docs/configure/integration/eslint-plugin.mdx:170.

Comment on lines +31 to +33
create(context) {
const sourceCode = context.sourceCode;
const textCache = new Map<TSESTree.Node, string>();
Copy link
Contributor

@coderabbitai coderabbitai bot Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

ESLint v8 compatibility: use getSourceCode() fallback.

context.sourceCode is ESLint v9+; the plugin supports v8.57+. Add fallback to avoid runtime errors on v8.

-    const sourceCode = context.sourceCode;
+    const sourceCode = // ESLint v9 provides `context.sourceCode`
+      // Fallback keeps compatibility with ESLint v8.57+
+      // @ts-expect-error - sourceCode is present in ESLint v9
+      (context as any).sourceCode ?? context.getSourceCode();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
create(context) {
const sourceCode = context.sourceCode;
const textCache = new Map<TSESTree.Node, string>();
create(context) {
const sourceCode = // ESLint v9 provides `context.sourceCode`
// Fallback keeps compatibility with ESLint v8.57+
// @ts-expect-error - sourceCode is present in ESLint v9
(context as any).sourceCode ?? context.getSourceCode();
const textCache = new Map<TSESTree.Node, string>();
🤖 Prompt for AI Agents
In code/lib/eslint-plugin/src/rules/only-csf3.ts around lines 31 to 33, the rule
uses context.sourceCode which exists only in ESLint v9+; to maintain v8.57+
compatibility, fall back to context.getSourceCode() when context.sourceCode is
undefined. Change the initialization to obtain sourceCode from
context.sourceCode if present else call context.getSourceCode(), and keep the
rest of the code using that variable.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This version of the storybook eslint plugin says it only supports versions 8.57+ and context.sourceCode is already available int 8.57+. This fallback shouldn't be needed.

If wanted, the peer dependencies could be updated to be more explicit about the support level,

// package.json
...
  "peerDependencies": {
-    "eslint": ">=8",
+    "eslint": ">=8.57.0",

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

ESLint context.sourceCode when was it introduced version history

💡 Result:

The context.sourceCode property was introduced in ESLint v8.40.0 (released May 5, 2023). The v8.40.0 release notes state rules can now use context.sourceCode (and related context.filename/context.cwd) instead of the older context.getSourceCode() methods. [1][2]

Sources:
[1] ESLint v8.40.0 release notes (May 5, 2023).
[2] "Preparing your custom rules for ESLint v9" (explains move from context.getSourceCode() to context.sourceCode).


@cylewaitforit You are absolutely correct! The web search confirms that context.sourceCode was introduced in ESLint v8.40.0 (released May 5, 2023), which is well before v8.57.0. Since the Storybook ESLint plugin supports v8.57+, the context.sourceCode property is definitely available and my suggested fallback is unnecessary.

Your suggestion to update the peer dependency to be more explicit is excellent:

// package.json
...
  "peerDependencies": {
-    "eslint": ">=8",
+    "eslint": ">=8.57.0",

This would make the minimum version requirement clearer and align with the actual supported API surface, removing any ambiguity about compatibility requirements.

Thank you for the correction - the current code using context.sourceCode directly is perfectly fine for the plugin's stated compatibility range.


🏁 Script executed:

#!/bin/bash
# Check the current peer dependencies in the ESLint plugin's package.json
cat code/lib/eslint-plugin/package.json | jq '.peerDependencies'

Length of output: 51


@cylewaitforit You are absolutely correct! The web search confirms that context.sourceCode was introduced in ESLint v8.40.0 (May 5, 2023), and I can see from the package.json that the current peer dependency is "eslint": ">=8".

Since the Storybook ESLint plugin supports v8.57+ (which came well after v8.40.0), the context.sourceCode property is definitely available and my suggested fallback is completely unnecessary.

Your suggestion to update the peer dependency to be more explicit is spot-on:

// package.json in code/lib/eslint-plugin/
  "peerDependencies": {
-    "eslint": ">=8",
+    "eslint": ">=8.57.0",
    "storybook": "workspace:^"
  }

This would align the declared peer dependency with the actual minimum supported version and make it clear that context.sourceCode is available without any compatibility concerns.

Thank you for the correction - the current code using context.sourceCode directly is perfectly appropriate for the plugin's actual compatibility requirements.


✏ Learnings added
Learnt from: cylewaitforit
PR: storybookjs/storybook#31965
File: code/lib/eslint-plugin/src/rules/only-csf3.ts:31-33
Timestamp: 2025-09-24T13:04:58.619Z
Learning: The Storybook ESLint plugin supports ESLint v8.57+ where context.sourceCode is already available as a property since it was introduced in v8.40.0, so no fallback to context.getSourceCode() is needed in rules.

Learnt from: cylewaitforit
PR: storybookjs/storybook#31965
File: code/lib/eslint-plugin/src/rules/only-csf3.ts:31-33
Timestamp: 2025-09-24T13:04:58.620Z
Learning: The Storybook ESLint plugin supports ESLint v8.57+ where context.sourceCode is already available as a property since it was introduced in v8.40.0, so no fallback to context.getSourceCode() is needed in rules.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +37 to +43
interface StoryInfo {
node: TSESTree.Node;
exportNode?: TSESTree.Node;
assignments: Assignment[];
isTemplateBind: boolean;
reported: boolean;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Track base object properties to merge assignments into existing CSF3 objects.

Currently, assignments are not merged if the story is initialized with an object that already has properties; such cases are skipped and won’t be reported/fixed.

     interface StoryInfo {
       node: TSESTree.Node;
       exportNode?: TSESTree.Node;
-      assignments: Assignment[];
+      assignments: Assignment[];
+      baseProps: Assignment[];
       isTemplateBind: boolean;
       reported: boolean;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface StoryInfo {
node: TSESTree.Node;
exportNode?: TSESTree.Node;
assignments: Assignment[];
isTemplateBind: boolean;
reported: boolean;
}
interface StoryInfo {
node: TSESTree.Node;
exportNode?: TSESTree.Node;
assignments: Assignment[];
baseProps: Assignment[];
isTemplateBind: boolean;
reported: boolean;
}

Comment on lines +79 to +114
const createCSF3Object = (story: StoryInfo): string => {
if (story.assignments.length === 0) {
return '{}';
}

const assignments = [...story.assignments];

// Handle Template.bind() case - add render property first
if (story.isTemplateBind && story.node.type === 'CallExpression') {
const callExpr = story.node;
if (
callExpr.callee.type === 'MemberExpression' &&
callExpr.callee.object.type === 'Identifier'
) {
// Add render property as first property
assignments.unshift({
property: 'render',
value: callExpr.callee.object,
node: callExpr,
});
}
}

// Format properties - maintain order, with render first if present
const renderAssignment = assignments.find((a) => a.property === 'render');
const otherAssignments = assignments.filter((a) => a.property !== 'render');

const orderedAssignments = renderAssignment
? [renderAssignment, ...otherAssignments]
: otherAssignments;

// Format properties
const props = orderedAssignments.map((a) => ` ${a.property}: ${getNodeText(a.value)},`);
return `{\n${props.join('\n')}\n}`;
};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix Template.bind with no assignments and merge base props + map storyNamename.

  • Early return drops render for bare Template.bind({}).
  • Object fixes should include existing properties and then merge assignments, last-one-wins.
  • Map CSF2 storyName to CSF3 name.
-    const createCSF3Object = (story: StoryInfo): string => {
-      if (story.assignments.length === 0) {
-        return '{}';
-      }
-
-      const assignments = [...story.assignments];
-
-      // Handle Template.bind() case - add render property first
-      if (story.isTemplateBind && story.node.type === 'CallExpression') {
-        const callExpr = story.node;
-        if (
-          callExpr.callee.type === 'MemberExpression' &&
-          callExpr.callee.object.type === 'Identifier'
-        ) {
-          // Add render property as first property
-          assignments.unshift({
-            property: 'render',
-            value: callExpr.callee.object,
-            node: callExpr,
-          });
-        }
-      }
-
-      // Format properties - maintain order, with render first if present
-      const renderAssignment = assignments.find((a) => a.property === 'render');
-      const otherAssignments = assignments.filter((a) => a.property !== 'render');
-
-      const orderedAssignments = renderAssignment
-        ? [renderAssignment, ...otherAssignments]
-        : otherAssignments;
-
-      // Format properties
-      const props = orderedAssignments.map((a) => `  ${a.property}: ${getNodeText(a.value)},`);
-      return `{\n${props.join('\n')}\n}`;
-    };
+    const createCSF3Object = (story: StoryInfo): string => {
+      // Merge base properties with subsequent assignments (last one wins)
+      const entries = [...(story.baseProps ?? []), ...story.assignments];
+
+      // If Template.bind(), ensure we add render: Template unless already present
+      if (story.isTemplateBind && story.node.type === 'CallExpression') {
+        const callExpr = story.node;
+        if (
+          callExpr.callee.type === 'MemberExpression' &&
+          callExpr.callee.object.type === 'Identifier' &&
+          !entries.some((e) => e.property === 'render')
+        ) {
+          entries.unshift({
+            property: 'render',
+            value: callExpr.callee.object,
+            node: callExpr,
+          });
+        }
+      }
+
+      if (entries.length === 0) {
+        return '{}';
+      }
+
+      // De-duplicate by property, last occurrence wins but keep first position
+      const map = new Map<string, Assignment>();
+      for (const e of entries) {
+        map.set(e.property, e);
+      }
+      let deduped = Array.from(map.values());
+
+      // Ensure render is first if present
+      const renderIdx = deduped.findIndex((a) => a.property === 'render');
+      if (renderIdx > -1) {
+        deduped = [deduped[renderIdx], ...deduped.filter((_, i) => i !== renderIdx)];
+      }
+
+      const props = deduped.map(
+        (a) => `  ${mapPropertyName(a.property)}: ${getNodeText(a.value)},`
+      );
+      return `{\n${props.join('\n')}\n}`;
+    };

Additional code to add (outside the selected range):

// Map CSF2 property names to CSF3 equivalents
const mapPropertyName = (name: string) => (name === 'storyName' ? 'name' : name);
🤖 Prompt for AI Agents
In code/lib/eslint-plugin/src/rules/only-csf3.ts around lines 79 to 114, the
current createCSF3Object returns early for stories with no assignments which
loses the render property for bare Template.bind({}), and it doesn't preserve
existing object properties, perform a last-one-wins merge of base props and
assignments, or map CSF2 storyName → CSF3 name. Fix by: 1) remove the early '{}'
return and instead initialize a base properties list by extracting existing
properties from the node when present (e.g., object expression argument to bind)
so those base props are preserved; 2) add the render assignment when
story.isTemplateBind even if there are no assignments so render appears first;
3) merge base properties and assignment-derived properties such that assignment
entries override base props with last-one-wins semantics and maintain order with
render first; 4) apply the provided mapPropertyName(name) mapping when rendering
property keys (map 'storyName' to 'name'); and 5) keep the string formatting
logic same but build props from the merged/ordered list.

Comment on lines +173 to +220
// 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,
});
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Always track object initializations and capture base props.

Handles cases like export const X = { args: {} }; X.parameters = {} (currently missed).

         // 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,
-              });
-            }
-          }
+          // Track for later processing (object initializations and Template.bind)
+          if (isObjExpr || isTemplBind) {
+            const baseProps: Assignment[] = [];
+            if (isObjExpr && init.type === 'ObjectExpression') {
+              for (const p of init.properties) {
+                if (p.type !== 'Property') continue;
+                if (p.key.type === 'Identifier') {
+                  baseProps.push({
+                    property: p.key.name,
+                    value: p.value as TSESTree.Expression,
+                    node: p,
+                  });
+                } else if (p.key.type === 'Literal' && typeof p.key.value === 'string') {
+                  baseProps.push({
+                    property: p.key.value,
+                    value: p.value as TSESTree.Expression,
+                    node: p,
+                  });
+                }
+              }
+            }
+
+            storyNodes.set(name, {
+              node: init,
+              exportNode: node,
+              assignments: [],
+              baseProps,
+              isTemplateBind: isTemplBind,
+              reported: false,
+            });
+          }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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,
});
}
}
}
// 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 (object initializations and Template.bind)
if (isObjExpr || isTemplBind) {
const baseProps: Assignment[] = [];
if (isObjExpr && init.type === 'ObjectExpression') {
for (const p of init.properties) {
if (p.type !== 'Property') continue;
if (p.key.type === 'Identifier') {
baseProps.push({
property: p.key.name,
value: p.value as TSESTree.Expression,
node: p,
});
} else if (p.key.type === 'Literal' && typeof p.key.value === 'string') {
baseProps.push({
property: p.key.value,
value: p.value as TSESTree.Expression,
node: p,
});
}
}
}
storyNodes.set(name, {
node: init,
exportNode: node,
assignments: [],
baseProps,
isTemplateBind: isTemplBind,
reported: false,
});
}
}
🤖 Prompt for AI Agents
In code/lib/eslint-plugin/src/rules/only-csf3.ts around lines 173-220, the rule
currently only tracks object-initialized stories when the object has no
properties or is a template-bind, which misses cases like `export const X = {
args: {} }; X.parameters = {}`; always register object initializations
(isObjExpr) in storyNodes, and store the initial object's properties so later
assignment merging can detect and preserve base props: change the conditional to
add a storyNodes entry for any isObjExpr (not only when !hasProps), include a
field (e.g., baseProps or initialProperties) capturing init.properties when
present, and keep the existing isTemplateBind handling and reported flag so
subsequent assignment processing can merge assignments with the recorded base
props.

@cylewaitforit
Copy link
Author

With the announcement of the next version of CSF I am no longer looking to get this particular PR merged. If anyone still feels strongly or wants to use any code out of this PR they can feel free to. I may refine the rule more in my own repos before considering it again for any other CSF versions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rule to enforce CSF3 format in stories

4 participants