fix 🐞: fix false positives for non-empty object arguments in empty-style-blocks rule

This commit is contained in:
Ante Budimir 2025-11-22 12:13:26 +02:00
parent 1d88c12e3d
commit d5c99de191
8 changed files with 466 additions and 7 deletions

View file

@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.15.1] - 2025-11-22
- Fix [issue #7](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3) to prevent false positives for `sprinkles()`/`style()`/`recipe()` calls with non-empty object arguments while continuing to flag bare `({})` calls
- Add regression tests covering empty and non-empty call expressions in recipe base/variants to guard against future regressions
## [1.15.0] - 2025-11-14 ## [1.15.0] - 2025-11-14
- Add new rule `prefer-theme-tokens`that requires theme tokens instead of hard-coded values in vanilla-extract styles - Add new rule `prefer-theme-tokens`that requires theme tokens instead of hard-coded values in vanilla-extract styles

View file

@ -1,6 +1,6 @@
{ {
"name": "@antebudimir/eslint-plugin-vanilla-extract", "name": "@antebudimir/eslint-plugin-vanilla-extract",
"version": "1.15.0", "version": "1.15.1",
"description": "Comprehensive ESLint plugin for vanilla-extract with CSS property ordering, style validation, and best practices enforcement. Supports alphabetical, concentric and custom CSS ordering, auto-fixing, and zero-runtime safety.", "description": "Comprehensive ESLint plugin for vanilla-extract with CSS property ordering, style validation, and best practices enforcement. Supports alphabetical, concentric and custom CSS ordering, auto-fixing, and zero-runtime safety.",
"author": "Ante Budimir", "author": "Ante Budimir",
"license": "MIT", "license": "MIT",
@ -78,6 +78,7 @@
"@typescript-eslint/utils": "^8.26.1", "@typescript-eslint/utils": "^8.26.1",
"@vanilla-extract/css": "^1.17.1", "@vanilla-extract/css": "^1.17.1",
"@vanilla-extract/recipes": "^0.5.5", "@vanilla-extract/recipes": "^0.5.5",
"@vanilla-extract/sprinkles": "^1.6.0",
"@vitest/coverage-v8": "3.0.8", "@vitest/coverage-v8": "3.0.8",
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.1", "eslint-config-prettier": "^10.1.1",

View file

@ -89,6 +89,124 @@ run({
} }
}); });
`, `,
// Recipe with sprinkles() in base - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'], flexDirection: ['row'], flexWrap: ['wrap-reverse'] }
}));
export const columnsStyle = recipe({
base: sprinkles({ display: 'flex', flexDirection: 'row' }),
variants: {
wrappingDirection: {
reverse: sprinkles({ flexWrap: 'wrap-reverse' }),
},
},
});
`,
// Recipe with sprinkles() in variant values - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px', '16px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
large: sprinkles({ padding: '16px' }),
},
},
});
`,
// Recipe with style() calls in variant values - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { style } from '@vanilla-extract/css';
const myRecipe = recipe({
base: { color: 'black' },
variants: {
size: {
small: style({ fontSize: '12px' }),
large: style({ fontSize: '16px' }),
},
},
});
`,
// Recipe with mixed CallExpression and ObjectExpression in variants - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
variant: {
sprinkled: sprinkles({ padding: '8px' }),
regular: { padding: '8px' },
},
},
});
`,
// Recipe with only CallExpression variants (no default) - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { flexWrap: ['wrap-reverse'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
wrappingDirection: {
reverse: sprinkles({ flexWrap: 'wrap-reverse' }),
},
},
});
`,
// Recipe with nested CallExpression in multiple variant categories - should be valid
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'], padding: ['8px', '16px'], color: ['blue', 'gray'] }
}));
const myRecipe = recipe({
base: sprinkles({ display: 'flex' }),
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
large: sprinkles({ padding: '16px' }),
},
color: {
primary: sprinkles({ color: 'blue' }),
secondary: sprinkles({ color: 'gray' }),
},
},
});
`,
], ],
invalid: [ invalid: [
// Empty recipe // Empty recipe
@ -280,5 +398,185 @@ run({
`, `,
errors: [{ messageId: 'invalidPropertyType', data: { type: 'ArrowFunctionExpression' } }], errors: [{ messageId: 'invalidPropertyType', data: { type: 'ArrowFunctionExpression' } }],
}, },
// Recipe with empty variant category alongside CallExpression variants
// Should only report the empty category, not the CallExpression
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px', '16px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
large: sprinkles({ padding: '16px' }),
},
emptyCategory: {},
},
});
`,
errors: [{ messageId: 'emptyVariantCategory' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px', '16px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
large: sprinkles({ padding: '16px' }),
},
},
});
`,
},
// Recipe with CallExpression in base and empty variants
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
const myRecipe = recipe({
base: sprinkles({ display: 'flex' }),
variants: {},
});
`,
errors: [{ messageId: 'emptyRecipeProperty' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
const myRecipe = recipe({
base: sprinkles({ display: 'flex' }),
});
`,
},
// Recipe with mixed valid CallExpression and invalid literal in same category
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
invalid: 'invalid-string',
},
},
});
`,
errors: [{ messageId: 'invalidPropertyType' }],
},
// Recipe with sprinkles({}) in base and empty variants - entire recipe is empty
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
export const myRecipe = recipe({
base: sprinkles({}),
variants: {},
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
},
// Recipe with sprinkles({}) in variant value - should be flagged as empty
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
empty: sprinkles({}),
},
},
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
},
},
});
`,
},
// Recipe with both base and variants using empty CallExpressions - entire recipe becomes empty
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { style } from '@vanilla-extract/css';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
export const myRecipe = recipe({
base: style({}),
variants: {
layout: {
flex: sprinkles({}),
},
},
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }, { messageId: 'emptyVariantValue' }],
},
], ],
}); });

View file

@ -1,6 +1,6 @@
import type { Rule } from 'eslint'; import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils'; import { TSESTree } from '@typescript-eslint/utils';
import { isEmptyObject } from '../shared-utils/empty-object-processor.js'; import { isCallExpressionWithEmptyObject, isEmptyObject } from '../shared-utils/empty-object-processor.js';
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js'; import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
import { processConditionalExpression } from './conditional-processor.js'; import { processConditionalExpression } from './conditional-processor.js';
import { processEmptyNestedStyles } from './empty-nested-style-processor.js'; import { processEmptyNestedStyles } from './empty-nested-style-processor.js';
@ -58,7 +58,13 @@ export const isEffectivelyEmptyStylesObject = (stylesObject: TSESTree.ObjectExpr
if (propertyName === 'base') { if (propertyName === 'base') {
hasBaseProperty = true; hasBaseProperty = true;
if (property.value.type === 'ObjectExpression' && !isEmptyObject(property.value)) {
// CallExpression (e.g., sprinkles(), style()) is considered non-empty unless it has an empty object argument, e.g. sprinkles({})
if (property.value.type === 'CallExpression') {
if (!isCallExpressionWithEmptyObject(property.value)) {
isBaseEmpty = false;
}
} else if (property.value.type === 'ObjectExpression' && !isEmptyObject(property.value)) {
isBaseEmpty = false; isBaseEmpty = false;
} }
} else if (propertyName === 'variants') { } else if (propertyName === 'variants') {

View file

@ -1,6 +1,6 @@
import type { Rule } from 'eslint'; import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils'; import { TSESTree } from '@typescript-eslint/utils';
import { isEmptyObject } from '../shared-utils/empty-object-processor.js'; import { isCallExpressionWithEmptyObject, isEmptyObject } from '../shared-utils/empty-object-processor.js';
import { processEmptyNestedStyles } from './empty-nested-style-processor.js'; import { processEmptyNestedStyles } from './empty-nested-style-processor.js';
import { removeNodeWithComma } from './node-remover.js'; import { removeNodeWithComma } from './node-remover.js';
import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js'; import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js';
@ -88,8 +88,30 @@ export const processRecipeProperties = (
return; return;
} }
// Check for non-object variant values const valueType = variantValueProperty.value.type;
if (variantValueProperty.value.type !== 'ObjectExpression') {
// Allow CallExpression (e.g., sprinkles(), style()) as valid variant values unless it has an empty object argument, e.g. sprinkles({})
if (valueType === 'CallExpression') {
const callExpression = variantValueProperty.value as TSESTree.CallExpression;
if (isCallExpressionWithEmptyObject(callExpression)) {
// Treat sprinkles({}) or style({}) as empty
if (!reportedNodes.has(variantValueProperty)) {
reportedNodes.add(variantValueProperty);
ruleContext.report({
node: variantValueProperty as Rule.Node,
messageId: 'emptyVariantValue',
fix(fixer) {
return removeNodeWithComma(ruleContext, variantValueProperty, fixer);
},
});
}
}
// Valid CallExpressions with arguments are fine
return;
}
// Check for non-object variant values (excluding CallExpression)
if (valueType !== 'ObjectExpression') {
if (!reportedNodes.has(variantValueProperty)) { if (!reportedNodes.has(variantValueProperty)) {
reportedNodes.add(variantValueProperty); reportedNodes.add(variantValueProperty);

View file

@ -0,0 +1,114 @@
import tsParser from '@typescript-eslint/parser';
import { run } from 'eslint-vitest-rule-tester';
import noEmptyBlocksRule from '../../no-empty-blocks/rule-definition.js';
run({
name: 'vanilla-extract/empty-object-processor-tests',
rule: noEmptyBlocksRule,
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
valid: [
// sprinkles() with no arguments is valid (not empty)
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
variant: {
value: sprinkles()
}
}
});
`,
// Test for CallExpression with non-empty object argument
`
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' })
}
}
});
`,
],
invalid: [
// Test for CallExpression with empty object argument - sprinkles({})
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
empty: sprinkles({})
}
}
});
`,
errors: [{ messageId: 'emptyVariantValue' }],
output: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { padding: ['8px'] }
}));
const myRecipe = recipe({
base: { color: 'black' },
variants: {
spacing: {
small: sprinkles({ padding: '8px' }),
}
}
});
`,
},
// Test for sprinkles({}) with empty object argument in recipe base
{
code: `
import { recipe } from '@vanilla-extract/recipes';
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const sprinkles = createSprinkles(defineProperties({
properties: { display: ['flex'] }
}));
export const myRecipe = recipe({
base: sprinkles({}),
variants: {},
});
`,
errors: [{ messageId: 'emptyStyleDeclaration' }],
},
],
});

View file

@ -15,3 +15,16 @@ export const isObjectExpression = (node: TSESTree.Node): node is TSESTree.Object
export const isEmptyObject = (node: TSESTree.Node): boolean => { export const isEmptyObject = (node: TSESTree.Node): boolean => {
return isObjectExpression(node) && node.properties.length === 0; return isObjectExpression(node) && node.properties.length === 0;
}; };
/**
* Checks if a CallExpression has an empty object as its first argument.
* Examples: sprinkles({}), style({}), recipe({})
*/
export const isCallExpressionWithEmptyObject = (node: TSESTree.CallExpression): boolean => {
if (node.arguments.length === 0) {
return false;
}
const firstArgument = node.arguments[0];
return firstArgument?.type === 'ObjectExpression' && isEmptyObject(firstArgument);
};

View file

@ -12,7 +12,7 @@ import preferThemeTokensRule from './css-rules/prefer-theme-tokens/index.js';
const vanillaExtract = { const vanillaExtract = {
meta: { meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract', name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.15.0', version: '1.15.1',
}, },
rules: { rules: {
'alphabetical-order': alphabeticalOrderRule, 'alphabetical-order': alphabeticalOrderRule,