Skip to content

Commit bccb29a

Browse files
committed
Try warnings threshold mechanism.
1 parent 18a4e5f commit bccb29a

7 files changed

Lines changed: 342 additions & 12 deletions

File tree

‎Gruntfile.js‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,19 +1760,12 @@ module.exports = function(grunt) {
17601760
'qunit:compiled'
17611761
] );
17621762

1763-
grunt.registerTask( 'lint:css', 'Runs Stylelint on core CSS.', function() {
1763+
grunt.registerTask( 'lint:css', 'Runs Stylelint on core CSS and checks warning-level rule thresholds.', function() {
17641764
var done = this.async();
17651765

17661766
grunt.util.spawn( {
1767-
cmd: 'npx',
1768-
args: [
1769-
'wp-scripts',
1770-
'lint-style',
1771-
SOURCE_DIR + '**/*.{css,scss}',
1772-
// By default, the Stylelint CLI looks for files to ignore in
1773-
// .stylelintignore in process.cwd(). This way, the configuration
1774-
// is consistent with the one for the NPM script in .stylelintrc.js.
1775-
],
1767+
cmd: 'node',
1768+
args: [ 'tools/stylelint/lint-css.js' ],
17761769
opts: { stdio: 'inherit' }
17771770
}, function( error ) {
17781771
done( ! error );

‎package-lock.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"sinon": "16.1.3",
7777
"sinon-test": "~3.1.6",
7878
"source-map-loader": "5.0.0",
79+
"stylelint": "16.26.1",
7980
"stylelint-plugin-logical-css": "2.1.0",
8081
"typescript": "6.0.3",
8182
"update-browserslist-db": "1.3.1",
@@ -127,8 +128,10 @@
127128
"grunt": "grunt",
128129
"lint:jsdoc": "wp-scripts lint-js",
129130
"lint:jsdoc:fix": "wp-scripts lint-js --fix",
130-
"lint:css": "wp-scripts lint-style \"src/**/*.{css,scss}\"",
131-
"lint:css:fix": "npm run lint:css -- --fix",
131+
"lint:css": "node ./tools/stylelint/lint-css.js",
132+
"lint:css:fix": "wp-scripts lint-style \"src/**/*.{css,scss}\" --fix",
133+
"lint:css:thresholds": "node ./tools/stylelint/check-warning-thresholds.js",
134+
"lint:css:thresholds:update": "node ./tools/stylelint/check-warning-thresholds.js --update",
132135
"lint:css:themes": "wp-scripts lint-style \"src/wp-content/themes/**/*.{css,scss}\" --ignore-path .stylelintignore-themes",
133136
"lint:css:themes:fix": "npm run lint:css:themes -- --fix",
134137
"typecheck:js": "tsc --build",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* CLI entry point for checking or updating Stylelint warning-level rule thresholds.
3+
* See tools/stylelint/lib/warning-thresholds.js for the underlying logic.
4+
*/
5+
6+
const {
7+
lintCss,
8+
getWarningLevelRules,
9+
countWarnings,
10+
checkThresholds,
11+
updateThresholds,
12+
} = require( './lib/warning-thresholds' );
13+
14+
const shouldUpdate = process.argv.includes( '--update' );
15+
16+
async function main() {
17+
console.log( 'Checking Stylelint warnings thresholds...' );
18+
19+
const warningLevelRules = getWarningLevelRules();
20+
const { results } = await lintCss();
21+
const actualCounts = countWarnings( results, warningLevelRules );
22+
23+
const success = shouldUpdate
24+
? updateThresholds( actualCounts )
25+
: checkThresholds( actualCounts, warningLevelRules );
26+
27+
if ( ! success ) {
28+
process.exitCode = 1;
29+
}
30+
}
31+
32+
main().catch( ( error ) => {
33+
console.error( error );
34+
process.exitCode = 1;
35+
} );
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/**
2+
* Shared logic for enforcing thresholds on Stylelint rules configured with
3+
* `severity: 'warning'` in .stylelintrc.js, so that warning-level violations never
4+
* silently increase over time.
5+
*
6+
* The number of violations allowed for each warning-level rule is recorded in
7+
* warning-thresholds.json. The actual number of violations in the codebase is compared
8+
* against the recorded threshold:
9+
*
10+
* - actual > threshold: new violations were introduced. Fail the build.
11+
* - actual < threshold: violations were fixed, but the threshold file was not updated
12+
* to lock in the improvement. Fail the build and ask the developer to update it.
13+
* - actual === threshold: nothing to do.
14+
*
15+
* A rule that is downgraded to a warning in .stylelintrc.js but has no corresponding
16+
* entry in warning-thresholds.json also fails the build, so new warning-level rules
17+
* must have an explicit, reviewed threshold before they can be merged.
18+
*/
19+
20+
const fs = require( 'fs' );
21+
const path = require( 'path' );
22+
23+
const ROOT = path.resolve( __dirname, '..', '..', '..' );
24+
const THRESHOLDS_FILE = path.join( __dirname, '..', 'warning-thresholds.json' );
25+
const CONFIG_FILE = path.join( ROOT, '.stylelintrc.js' );
26+
const IGNORE_PATH = path.join( ROOT, '.stylelintignore' );
27+
const FILES_GLOB = path.join( ROOT, 'src/**/*.{css,scss}' );
28+
29+
/**
30+
* Lints core CSS and returns the raw Stylelint results.
31+
*
32+
* @param {Object} [options] Options.
33+
* @param {string} [options.formatter] Stylelint formatter to use for the `report` output.
34+
*
35+
* @return {Promise<Object>} Stylelint lint results (`results`, `errored`, `report`).
36+
*/
37+
async function lintCss( options = {} ) {
38+
// Use the ESM entry point dynamically to avoid stylelint's CommonJS deprecation warning.
39+
const { default: stylelint } = await import( 'stylelint' );
40+
41+
return stylelint.lint( {
42+
files: FILES_GLOB,
43+
configFile: CONFIG_FILE,
44+
ignorePath: IGNORE_PATH,
45+
formatter: options.formatter,
46+
} );
47+
}
48+
49+
/**
50+
* Returns the set of rule names configured with `severity: 'warning'` in the Stylelint
51+
* config, so newly downgraded rules are automatically picked up without editing this file.
52+
*
53+
* @return {Set<string>} Rule names configured with severity 'warning'.
54+
*/
55+
function getWarningLevelRules() {
56+
const config = require( CONFIG_FILE );
57+
const rules = config.rules || {};
58+
const warningRules = new Set();
59+
60+
for ( const [ ruleName, ruleConfig ] of Object.entries( rules ) ) {
61+
const options = Array.isArray( ruleConfig ) ? ruleConfig[ 1 ] : null;
62+
63+
if (
64+
options &&
65+
typeof options === 'object' &&
66+
options.severity === 'warning'
67+
) {
68+
warningRules.add( ruleName );
69+
}
70+
}
71+
72+
return warningRules;
73+
}
74+
75+
/**
76+
* Tallies, per rule, how many warning-level violations are present in a set of
77+
* Stylelint lint results.
78+
*
79+
* @param {Object[]} results Stylelint lint results.
80+
* @param {Set<string>} warningLevelRules Rule names configured with severity 'warning'.
81+
*
82+
* @return {Object<string, number>} Violation count per rule.
83+
*/
84+
function countWarnings( results, warningLevelRules ) {
85+
const actualCounts = {};
86+
for ( const rule of warningLevelRules ) {
87+
actualCounts[ rule ] = 0;
88+
}
89+
90+
for ( const result of results ) {
91+
for ( const warning of result.warnings ) {
92+
if (
93+
warning.severity === 'warning' &&
94+
warningLevelRules.has( warning.rule )
95+
) {
96+
actualCounts[ warning.rule ] += 1;
97+
}
98+
}
99+
}
100+
101+
return actualCounts;
102+
}
103+
104+
function readThresholds() {
105+
if ( ! fs.existsSync( THRESHOLDS_FILE ) ) {
106+
return {};
107+
}
108+
109+
return JSON.parse( fs.readFileSync( THRESHOLDS_FILE, 'utf8' ) );
110+
}
111+
112+
/**
113+
* Compares actual violation counts against the recorded thresholds and prints a report.
114+
* Returns `true` if everything is up to date, `false` if the build should fail.
115+
*
116+
* @param {Object<string, number>} actualCounts Violation count per rule.
117+
* @param {Set<string>} warningLevelRules Rule names configured with severity 'warning'.
118+
*
119+
* @return {boolean} Whether all thresholds are up to date.
120+
*/
121+
function checkThresholds( actualCounts, warningLevelRules ) {
122+
const thresholds = readThresholds();
123+
124+
const missingEntries = [];
125+
const regressions = [];
126+
const improvements = [];
127+
128+
for ( const rule of warningLevelRules ) {
129+
const actual = actualCounts[ rule ];
130+
131+
if ( ! ( rule in thresholds ) ) {
132+
missingEntries.push( { rule, actual } );
133+
continue;
134+
}
135+
136+
const threshold = thresholds[ rule ];
137+
138+
if ( actual > threshold ) {
139+
regressions.push( { rule, actual, threshold } );
140+
} else if ( actual < threshold ) {
141+
improvements.push( { rule, actual, threshold } );
142+
}
143+
}
144+
145+
if ( missingEntries.length ) {
146+
console.error(
147+
'The following Stylelint rules are configured as warnings but have no recorded threshold:\n'
148+
);
149+
for ( const { rule, actual } of missingEntries ) {
150+
console.error( ` - ${ rule } (current violations: ${ actual })` );
151+
}
152+
console.error(
153+
`\nAdd them to ${ path.relative(
154+
ROOT,
155+
THRESHOLDS_FILE
156+
) } by running:`
157+
);
158+
console.error( ' npm run lint:css:thresholds:update\n' );
159+
}
160+
161+
if ( regressions.length ) {
162+
console.error(
163+
'The number of Stylelint warnings has increased beyond the allowed threshold:\n'
164+
);
165+
for ( const { rule, actual, threshold } of regressions ) {
166+
console.error(
167+
` - ${ rule }: ${ actual } violations found, threshold is ${ threshold }`
168+
);
169+
}
170+
console.error(
171+
'\nFix the new violations introduced by this change. The threshold must never increase.\n'
172+
);
173+
}
174+
175+
if ( improvements.length ) {
176+
console.error(
177+
'Great news! Some Stylelint warnings have been fixed:\n'
178+
);
179+
for ( const { rule, actual, threshold } of improvements ) {
180+
console.error(
181+
` - ${ rule }: ${ actual } violations found, threshold is ${ threshold }`
182+
);
183+
}
184+
console.error(
185+
'\nPlease lock in this improvement by updating the threshold. Run the following command and commit the result:'
186+
);
187+
console.error( ' npm run lint:css:thresholds:update\n' );
188+
}
189+
190+
if ( missingEntries.length || regressions.length || improvements.length ) {
191+
console.error(
192+
'This check also runs in CI on every pull request and will block merging until it is resolved.\n'
193+
);
194+
return false;
195+
}
196+
197+
console.log( 'All Stylelint warning thresholds are up to date.' );
198+
return true;
199+
}
200+
201+
/**
202+
* Rewrites the thresholds file to match the actual counts. Refuses to write a higher
203+
* threshold for any rule. Returns `true` on success, `false` if the update was refused.
204+
*
205+
* @param {Object<string, number>} actualCounts Violation count per rule.
206+
*
207+
* @return {boolean} Whether the update succeeded.
208+
*/
209+
function updateThresholds( actualCounts ) {
210+
const thresholds = readThresholds();
211+
const updated = {};
212+
const blocked = [];
213+
214+
for ( const rule of Object.keys( actualCounts ).sort() ) {
215+
const actual = actualCounts[ rule ];
216+
const existing = thresholds[ rule ];
217+
218+
// A threshold may only stay the same or decrease, never increase.
219+
if ( typeof existing === 'number' && actual > existing ) {
220+
blocked.push( { rule, actual, existing } );
221+
updated[ rule ] = existing;
222+
continue;
223+
}
224+
225+
updated[ rule ] = actual;
226+
}
227+
228+
if ( blocked.length ) {
229+
console.error(
230+
'Refusing to update the following thresholds because it would increase them:\n'
231+
);
232+
for ( const { rule, actual, existing } of blocked ) {
233+
console.error(
234+
` - ${ rule }: ${ actual } violations found, current threshold is ${ existing }`
235+
);
236+
}
237+
console.error(
238+
'\nFix the new violations first. The threshold must never increase.\n'
239+
);
240+
return false;
241+
}
242+
243+
fs.writeFileSync(
244+
THRESHOLDS_FILE,
245+
JSON.stringify( updated, null, '\t' ) + '\n'
246+
);
247+
248+
console.log( `Updated ${ path.relative( ROOT, THRESHOLDS_FILE ) }:` );
249+
for ( const rule of Object.keys( updated ) ) {
250+
console.log( ` ${ rule }: ${ updated[ rule ] }` );
251+
}
252+
253+
return true;
254+
}
255+
256+
module.exports = {
257+
lintCss,
258+
getWarningLevelRules,
259+
countWarnings,
260+
checkThresholds,
261+
updateThresholds,
262+
};

‎tools/stylelint/lint-css.js‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Runs Stylelint on core CSS and checks warning-level rule thresholds using a single
3+
* Stylelint run, so linting the codebase doesn't happen twice.
4+
*/
5+
6+
const {
7+
lintCss,
8+
getWarningLevelRules,
9+
countWarnings,
10+
checkThresholds,
11+
} = require( './lib/warning-thresholds' );
12+
13+
async function main() {
14+
const warningLevelRules = getWarningLevelRules();
15+
16+
const { results, errored, report } = await lintCss( {
17+
formatter: 'string',
18+
} );
19+
20+
if ( report ) {
21+
console.log( report );
22+
}
23+
24+
const actualCounts = countWarnings( results, warningLevelRules );
25+
const thresholdsOk = checkThresholds( actualCounts, warningLevelRules );
26+
27+
process.exitCode = errored || ! thresholdsOk ? 1 : 0;
28+
}
29+
30+
main().catch( ( error ) => {
31+
console.error( error );
32+
process.exitCode = 1;
33+
} );
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"selector-no-qualifying-type": 1815
3+
}

0 commit comments

Comments
 (0)