diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 0000000000000..f454d1070cca7 --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,27 @@ +# Ignore WordPress plugin files. +src/wp-content/plugins/**/* + +# Ignore WordPress theme files. +src/wp-content/themes/**/* + +# Ignore WordPress block files. +src/wp-includes/blocks/**/* + +# Ignore admin color schemes auto generated files. +src/wp-admin/css/colors/*/*.css + +# Ignore WordPress included dist files. +src/wp-includes/css/dist/**/* + +# Ignore files copied to wp-includes js. +src/wp-includes/js/**/* + +# Ignore WordPress JS vendor files. +src/js/_enqueues/vendor/**/* + +# Ignore minified CSS files. +**/*.min.css + +# Ignore RTL CSS files. +**/*-rtl.css +**/*-rtl.min.css diff --git a/.stylelintrc.js b/.stylelintrc.js new file mode 100644 index 0000000000000..ce6346a4dfa1d --- /dev/null +++ b/.stylelintrc.js @@ -0,0 +1,114 @@ +/** @type {import('stylelint').Config} */ +module.exports = { + extends: '@wordpress/stylelint-config/scss-stylistic', + plugins: [ + 'stylelint-plugin-logical-css', + '@wordpress/theme/stylelint-plugins/no-token-fallback-values', + ], + reportNeedlessDisables: true, + rules: { + 'at-rule-empty-line-before': [ + 'always', + { + except: [ 'blockless-after-blockless' ], + ignore: [ 'first-nested', 'after-comment' ], + }, + ], + 'at-rule-no-unknown': null, + 'comment-empty-line-before': null, + 'declaration-property-unit-allowed-list': [ + { + 'line-height': [], + }, + ], + 'declaration-property-value-allowed-list': [ + { + 'flex-direction': '/^(?!(row|column)-reverse).*$/', + }, + { + message: ( property, value ) => + `Avoid "${ value }" value for the "${ property }" property. For accessibility reasons, visual, reading, and DOM order must match. Only use the reverse values when they do not affect reading order, meaning, and interaction.`, + }, + ], + 'font-family-no-missing-generic-family-keyword': [ + true, + { + ignoreFontFamilies: [ 'dashicons' ], + }, + ], + 'font-weight-notation': 'numeric', + 'function-disallowed-list': [ 'rgb' ], + 'no-descending-specificity': null, + 'no-invalid-position-at-import-rule': null, + 'property-disallowed-list': [ + [ 'order' ], + { + message: + 'Avoid the order property. For accessibility reasons, visual, reading, and DOM order must match. Only use the order property when it does not affect reading order, meaning, and interaction.', + }, + ], + 'rule-empty-line-before': [ + 'always', + { + except: [ 'first-nested' ], + ignore: [ 'after-comment' ], + }, + ], + 'selector-class-pattern': [ + '^[a-z][a-z0-9]*(?:(?:__|--|-)[a-z0-9]+)*$', + { + message: + 'Class selector should use lowercase class segments separated with hyphens, double hyphens, or double underscores', + }, + ], + 'selector-id-pattern': [ + '^([a-z][a-z0-9]*)(-[a-z0-9]+)*$', + { + message: + 'ID selector should use lowercase and separate words with hyphens', + }, + ], + 'selector-no-qualifying-type': [ + true, + { + severity: 'warning', + }, + ], + 'value-keyword-case': [ + 'lower', + { + ignoreKeywords: [ + 'currentColor', + 'optimizeLegibility' + ], + }, + ], + 'scss/at-else-empty-line-before': null, + 'scss/at-extend-no-missing-placeholder': null, + 'scss/at-if-closing-brace-newline-after': null, + 'scss/at-if-closing-brace-space-after': null, + 'scss/comment-no-empty': null, + 'scss/load-partial-extension': null, + 'scss/no-global-function-names': null, + 'scss/operator-no-newline-after': null, + 'scss/operator-no-unspaced': null, + 'scss/selector-no-redundant-nesting-selector': null, + '@stylistic/max-empty-lines': 1, + '@stylistic/max-line-length': null, + '@stylistic/selector-descendant-combinator-no-non-space': true, + // Keep these lines. Don't change their order otherwise indentation will break. + // It's not about a literal 'execution order', but rather a compatibility issue. + '@stylistic/block-closing-brace-newline-before': 'always', + '@stylistic/block-opening-brace-newline-after': 'always', + '@stylistic/declaration-block-semicolon-newline-after': 'always', + '@stylistic/selector-list-comma-space-after': 'always-single-line', + '@stylistic/selector-list-comma-newline-after': 'always', + '@stylistic/declaration-colon-newline-after': 'always-multi-line', + '@stylistic/indentation': 'tab', + // End keep these lines. + }, + reportDescriptionlessDisables: true, + ignorePath: '.stylelintignore', +}; + + diff --git a/Gruntfile.js b/Gruntfile.js index 08da38fb748e2..49002ef3bc425 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1775,8 +1775,21 @@ module.exports = function(grunt) { 'qunit:compiled' ] ); + grunt.registerTask( 'lint:css', 'Runs Stylelint on core CSS and checks warning-level rule thresholds.', function() { + var done = this.async(); + + grunt.util.spawn( { + cmd: 'node', + args: [ 'tools/stylelint/lint-css.js' ], + opts: { stdio: 'inherit' } + }, function( error ) { + done( ! error ); + } ); + } ); + grunt.registerTask( 'precommit:css', [ - 'postcss:core' + 'postcss:core', + 'lint:css', ] ); grunt.registerTask( 'precommit:php', [ diff --git a/package-lock.json b/package-lock.json index fd069ad47879c..eaba6fe9eb87a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,8 @@ "sinon": "16.1.3", "sinon-test": "~3.1.6", "source-map-loader": "5.0.0", + "stylelint": "16.26.1", + "stylelint-plugin-logical-css": "2.1.0", "typescript": "6.0.3", "update-browserslist-db": "1.3.1", "uuid": "14.0.1", @@ -30616,6 +30618,16 @@ } } }, + "node_modules/stylelint-plugin-logical-css": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/stylelint-plugin-logical-css/-/stylelint-plugin-logical-css-2.1.0.tgz", + "integrity": "sha512-625OT+p5y2kkGBaRV7uTYscuH0m1UueMXh0WcidrXgwF2DOnKov+un9tvuyNG+SUC07W0ibcn+fZvQs/keskww==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "stylelint": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/stylelint-scss": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.14.0.tgz", diff --git a/package.json b/package.json index ca760568737a3..8852d7dcb6139 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,8 @@ "sinon": "16.1.3", "sinon-test": "~3.1.6", "source-map-loader": "5.0.0", + "stylelint": "16.26.1", + "stylelint-plugin-logical-css": "2.1.0", "typescript": "6.0.3", "update-browserslist-db": "1.3.1", "uuid": "14.0.1", @@ -126,6 +128,10 @@ "grunt": "grunt", "lint:jsdoc": "wp-scripts lint-js", "lint:jsdoc:fix": "wp-scripts lint-js --fix", + "lint:css": "node ./tools/stylelint/lint-css.js", + "lint:css:fix": "wp-scripts lint-style \"src/**/*.{css,scss}\" --fix", + "lint:css:thresholds": "node ./tools/stylelint/check-warning-thresholds.js", + "lint:css:thresholds:update": "node ./tools/stylelint/check-warning-thresholds.js --update", "typecheck:js": "tsc --build", "env:start": "node ./tools/local-env/scripts/start.js && node ./tools/local-env/scripts/docker.js run -T --rm php composer update -W", "env:stop": "node ./tools/local-env/scripts/docker.js down", diff --git a/tools/stylelint/check-warning-thresholds.js b/tools/stylelint/check-warning-thresholds.js new file mode 100644 index 0000000000000..f18cf6c986939 --- /dev/null +++ b/tools/stylelint/check-warning-thresholds.js @@ -0,0 +1,35 @@ +/** + * CLI entry point for checking or updating Stylelint warning-level rule thresholds. + * See tools/stylelint/lib/warning-thresholds.js for the underlying logic. + */ + +const { + lintCss, + getWarningLevelRules, + countWarnings, + checkThresholds, + updateThresholds, +} = require( './lib/warning-thresholds' ); + +const shouldUpdate = process.argv.includes( '--update' ); + +async function main() { + console.log( 'Checking Stylelint warnings thresholds...' ); + + const warningLevelRules = getWarningLevelRules(); + const { results } = await lintCss(); + const actualCounts = countWarnings( results, warningLevelRules ); + + const success = shouldUpdate + ? updateThresholds( actualCounts ) + : checkThresholds( actualCounts, warningLevelRules ); + + if ( ! success ) { + process.exitCode = 1; + } +} + +main().catch( ( error ) => { + console.error( error ); + process.exitCode = 1; +} ); diff --git a/tools/stylelint/lib/warning-thresholds.js b/tools/stylelint/lib/warning-thresholds.js new file mode 100644 index 0000000000000..b10eff2ef6224 --- /dev/null +++ b/tools/stylelint/lib/warning-thresholds.js @@ -0,0 +1,262 @@ +/** + * Shared logic for enforcing thresholds on Stylelint rules configured with + * `severity: 'warning'` in .stylelintrc.js, so that warning-level violations never + * silently increase over time. + * + * The number of violations allowed for each warning-level rule is recorded in + * warning-thresholds.json. The actual number of violations in the codebase is compared + * against the recorded threshold: + * + * - actual > threshold: new violations were introduced. Fail the build. + * - actual < threshold: violations were fixed, but the threshold file was not updated + * to lock in the improvement. Fail the build and ask the developer to update it. + * - actual === threshold: nothing to do. + * + * A rule that is downgraded to a warning in .stylelintrc.js but has no corresponding + * entry in warning-thresholds.json also fails the build, so new warning-level rules + * must have an explicit, reviewed threshold before they can be merged. + */ + +const fs = require( 'fs' ); +const path = require( 'path' ); + +const ROOT = path.resolve( __dirname, '..', '..', '..' ); +const THRESHOLDS_FILE = path.join( __dirname, '..', 'warning-thresholds.json' ); +const CONFIG_FILE = path.join( ROOT, '.stylelintrc.js' ); +const IGNORE_PATH = path.join( ROOT, '.stylelintignore' ); +const FILES_GLOB = path.join( ROOT, 'src/**/*.{css,scss}' ).split( path.sep ).join( '/' ); + +/** + * Lints core CSS and returns the raw Stylelint results. + * + * @param {Object} [options] Options. + * @param {string} [options.formatter] Stylelint formatter to use for the `report` output. + * + * @return {Promise} Stylelint lint results (`results`, `errored`, `report`). + */ +async function lintCss( options = {} ) { + // Use the ESM entry point dynamically to avoid stylelint's CommonJS deprecation warning. + const { default: stylelint } = await import( 'stylelint' ); + + return stylelint.lint( { + files: FILES_GLOB, + configFile: CONFIG_FILE, + ignorePath: IGNORE_PATH, + formatter: options.formatter, + } ); +} + +/** + * Returns the set of rule names configured with `severity: 'warning'` in the Stylelint + * config, so newly downgraded rules are automatically picked up without editing this file. + * + * @return {Set} Rule names configured with severity 'warning'. + */ +function getWarningLevelRules() { + const config = require( CONFIG_FILE ); + const rules = config.rules || {}; + const warningRules = new Set(); + + for ( const [ ruleName, ruleConfig ] of Object.entries( rules ) ) { + const options = Array.isArray( ruleConfig ) ? ruleConfig[ 1 ] : null; + + if ( + options && + typeof options === 'object' && + options.severity === 'warning' + ) { + warningRules.add( ruleName ); + } + } + + return warningRules; +} + +/** + * Tallies, per rule, how many warning-level violations are present in a set of + * Stylelint lint results. + * + * @param {Object[]} results Stylelint lint results. + * @param {Set} warningLevelRules Rule names configured with severity 'warning'. + * + * @return {Object} Violation count per rule. + */ +function countWarnings( results, warningLevelRules ) { + const actualCounts = {}; + for ( const rule of warningLevelRules ) { + actualCounts[ rule ] = 0; + } + + for ( const result of results ) { + for ( const warning of result.warnings ) { + if ( + warning.severity === 'warning' && + warningLevelRules.has( warning.rule ) + ) { + actualCounts[ warning.rule ] += 1; + } + } + } + + return actualCounts; +} + +function readThresholds() { + if ( ! fs.existsSync( THRESHOLDS_FILE ) ) { + return {}; + } + + return JSON.parse( fs.readFileSync( THRESHOLDS_FILE, 'utf8' ) ); +} + +/** + * Compares actual violation counts against the recorded thresholds and prints a report. + * Returns `true` if everything is up to date, `false` if the build should fail. + * + * @param {Object} actualCounts Violation count per rule. + * @param {Set} warningLevelRules Rule names configured with severity 'warning'. + * + * @return {boolean} Whether all thresholds are up to date. + */ +function checkThresholds( actualCounts, warningLevelRules ) { + const thresholds = readThresholds(); + + const missingEntries = []; + const regressions = []; + const improvements = []; + + for ( const rule of warningLevelRules ) { + const actual = actualCounts[ rule ]; + + if ( ! ( rule in thresholds ) ) { + missingEntries.push( { rule, actual } ); + continue; + } + + const threshold = thresholds[ rule ]; + + if ( actual > threshold ) { + regressions.push( { rule, actual, threshold } ); + } else if ( actual < threshold ) { + improvements.push( { rule, actual, threshold } ); + } + } + + if ( missingEntries.length ) { + console.error( + 'The following Stylelint rules are configured as warnings but have no recorded threshold:\n' + ); + for ( const { rule, actual } of missingEntries ) { + console.error( ` - ${ rule } (current violations: ${ actual })` ); + } + console.error( + `\nAdd them to ${ path.relative( + ROOT, + THRESHOLDS_FILE + ) } by running:` + ); + console.error( ' npm run lint:css:thresholds:update\n' ); + } + + if ( regressions.length ) { + console.error( + 'The number of Stylelint warnings has increased beyond the allowed threshold:\n' + ); + for ( const { rule, actual, threshold } of regressions ) { + console.error( + ` - ${ rule }: ${ actual } violations found, threshold is ${ threshold }` + ); + } + console.error( + '\nFix the new violations introduced by this change. The threshold must never increase.\n' + ); + } + + if ( improvements.length ) { + console.error( + 'Great news! Some Stylelint warnings have been fixed:\n' + ); + for ( const { rule, actual, threshold } of improvements ) { + console.error( + ` - ${ rule }: ${ actual } violations found, threshold is ${ threshold }` + ); + } + console.error( + '\nPlease lock in this improvement by updating the threshold. Run the following command and commit the result:' + ); + console.error( ' npm run lint:css:thresholds:update\n' ); + } + + if ( missingEntries.length || regressions.length || improvements.length ) { + console.error( + 'This check also runs in CI on every pull request and will block merging until it is resolved.\n' + ); + return false; + } + + console.log( 'All Stylelint warning thresholds are up to date.' ); + return true; +} + +/** + * Rewrites the thresholds file to match the actual counts. Refuses to write a higher + * threshold for any rule. Returns `true` on success, `false` if the update was refused. + * + * @param {Object} actualCounts Violation count per rule. + * + * @return {boolean} Whether the update succeeded. + */ +function updateThresholds( actualCounts ) { + const thresholds = readThresholds(); + const updated = {}; + const blocked = []; + + for ( const rule of Object.keys( actualCounts ).sort() ) { + const actual = actualCounts[ rule ]; + const existing = thresholds[ rule ]; + + // A threshold may only stay the same or decrease, never increase. + if ( typeof existing === 'number' && actual > existing ) { + blocked.push( { rule, actual, existing } ); + updated[ rule ] = existing; + continue; + } + + updated[ rule ] = actual; + } + + if ( blocked.length ) { + console.error( + 'Refusing to update the following thresholds because it would increase them:\n' + ); + for ( const { rule, actual, existing } of blocked ) { + console.error( + ` - ${ rule }: ${ actual } violations found, current threshold is ${ existing }` + ); + } + console.error( + '\nFix the new violations first. The threshold must never increase.\n' + ); + return false; + } + + fs.writeFileSync( + THRESHOLDS_FILE, + JSON.stringify( updated, null, '\t' ) + '\n' + ); + + console.log( `Updated ${ path.relative( ROOT, THRESHOLDS_FILE ) }:` ); + for ( const rule of Object.keys( updated ) ) { + console.log( ` ${ rule }: ${ updated[ rule ] }` ); + } + + return true; +} + +module.exports = { + lintCss, + getWarningLevelRules, + countWarnings, + checkThresholds, + updateThresholds, +}; diff --git a/tools/stylelint/lint-css.js b/tools/stylelint/lint-css.js new file mode 100644 index 0000000000000..f9192795faedd --- /dev/null +++ b/tools/stylelint/lint-css.js @@ -0,0 +1,33 @@ +/** + * Runs Stylelint on core CSS and checks warning-level rule thresholds using a single + * Stylelint run, so linting the codebase doesn't happen twice. + */ + +const { + lintCss, + getWarningLevelRules, + countWarnings, + checkThresholds, +} = require( './lib/warning-thresholds' ); + +async function main() { + const warningLevelRules = getWarningLevelRules(); + + const { results, errored, report } = await lintCss( { + formatter: 'string', + } ); + + if ( report ) { + console.log( report ); + } + + const actualCounts = countWarnings( results, warningLevelRules ); + const thresholdsOk = checkThresholds( actualCounts, warningLevelRules ); + + process.exitCode = errored || ! thresholdsOk ? 1 : 0; +} + +main().catch( ( error ) => { + console.error( error ); + process.exitCode = 1; +} ); diff --git a/tools/stylelint/warning-thresholds.json b/tools/stylelint/warning-thresholds.json new file mode 100644 index 0000000000000..ca419339bed94 --- /dev/null +++ b/tools/stylelint/warning-thresholds.json @@ -0,0 +1,3 @@ +{ + "selector-no-qualifying-type": 1819 +}