In our team’s CSS Code Review process, we found ourselves repeatedly addressing the same issues: uncontrolled z-indexes, hard-coded color values, and overly nested selectors. These problems are trivial but significant. Manual review is not only time-consuming but also prone to error, often leading to unnecessary debates due to subjective standards. Relying on code style guides and verbal reminders proved ineffective; developers, under tight deadlines, easily overlook these “soft” conventions.
Instead of dedicating valuable human effort to this repetitive labor, we decided to build an automated tool—a bot that could comment on Pull Requests line-by-line, just like a human reviewer. Its core mission: to statically analyze CSS code during the CI phase and flag anti-patterns precisely at the line where they occur.
We quickly ruled out simple regular expressions. They lack the contextual understanding of CSS structure needed to, for instance, distinguish a color property from the word “color” in a comment. We needed a tool capable of deep code parsing. Ultimately, PostCSS became our tool of choice. It parses CSS into an Abstract Syntax Tree (AST), enabling precise, structured analysis and providing a solid foundation for implementing complex, context-aware validation rules.
Technical Pain Points & Initial Concept
Our goals were crystal clear:
- Automation: The tool must seamlessly integrate into our existing CI/CD pipeline (we use GitHub Actions).
- Precision: Feedback must be directly linked to the specific problematic lines of code in a PR.
- Configurability: Check rules must be configurable to adapt to the specific needs of different projects.
- Non-blocking: Initially, the tool should only offer suggestions (via comments) rather than failing the CI build, to avoid disrupting the development workflow.
The entire workflow was designed as follows:
flowchart TD
A[Developer pushes code to PR] --> B{Trigger GitHub Action};
B --> C[Checkout code];
C --> D[Execute custom analysis script];
D --> E[Load PostCSS with custom plugins];
E --> F{Traverse CSS AST};
F --> G[Collect violations with location data];
G --> H[Format results];
H --> I[Call GitHub API];
I --> J[Post comments on corresponding PR lines];
The heart of this workflow is the custom analysis script, which orchestrates PostCSS’s code analysis capabilities with the GitHub API’s interaction features.
Step 1: Project Scaffolding and Core Dependencies
We started with a standard Node.js project. The key dependencies include:
-
postcss: The core PostCSS library for parsing CSS and running plugins. -
cosmiconfig: An excellent configuration loader that automatically finds and parses various config file formats (like.reviewbotrc.json,.reviewbotrc.js), making our tool more versatile. -
@actions/core: The official GitHub Actions toolkit for getting inputs, setting outputs, and logging. -
@actions/github: The official GitHub Actions toolkit, which wraps Octokit for convenient interaction with the GitHub API.
The core dependencies in package.json look like this:
{
"name": "css-review-bot",
"version": "1.0.0",
"description": "An automated CSS code review bot using PostCSS.",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/github": "^5.1.1",
"cosmiconfig": "^8.2.0",
"fast-glob": "^3.3.1",
"postcss": "^8.4.29"
}
}
We encapsulated the entire logic in a single index.js file, serving as the entry point for our GitHub Action.
Step 2: Writing a PostCSS Plugin to Catch Anti-Patterns
This is the soul of the tool. A PostCSS plugin is essentially a function that receives a root object (the AST root node of the entire CSS file) and a result object. We can traverse this tree to inspect every node we care about.
We planned to implement three initial rules:
- Z-index Value Check:
z-indexvalues must be predefined variables or fall within a reasonably small range (e.g., -10 to 10). - Hard-coded Color Check: Prohibit direct use of color values like
#...orrgb(...). CSS variables (e.g.,var(--color-primary)) must be used instead. - Selector Complexity Check: A selector should not contain an excessive number of combinators (
>,+,~,) or ID selectors to avoid overly high specificity.
Here is the plugin implementation, css-linter-plugin.js:
// css-linter-plugin.js
const postcss = require('postcss');
// The plugin receives configuration as an argument
module.exports = postcss.plugin('css-anti-pattern-detector', (options = {}) => {
return (root, result) => {
const config = {
zIndex: {
max: options.zIndex?.max ?? 10,
allowVars: options.zIndex?.allowVars ?? true,
},
color: {
allowVars: options.color?.allowVars ?? true,
allowKeywords: options.color?.allowKeywords ?? ['transparent', 'inherit', 'currentColor'],
},
selectorComplexity: {
max: options.selectorComplexity?.max ?? 3,
},
};
// 1. Check for z-index and hard-coded colors
root.walkDecls(decl => {
// Check z-index
if (decl.prop.toLowerCase() === 'z-index') {
const value = decl.value.trim();
if (config.zIndex.allowVars && /^var\(--.*\)$/.test(value)) {
return; // Allow CSS variables
}
const numericValue = parseInt(value, 10);
if (isNaN(numericValue) || numericValue > config.zIndex.max || numericValue < -config.zIndex.max) {
result.warn(`[z-index] Value "${value}" is outside the allowed range (-${config.zIndex.max} to ${config.zIndex.max}). Consider using z-index management variables.`, { node: decl });
}
}
// Check color properties
const colorProps = ['color', 'background-color', 'border-color', 'outline-color', 'fill', 'stroke'];
if (colorProps.includes(decl.prop.toLowerCase())) {
const value = decl.value.trim().toLowerCase();
// Allowed keywords
if (config.color.allowKeywords.includes(value)) {
return;
}
// Allowed CSS variables
if (config.color.allowVars && /^var\(--.*\)$/.test(value)) {
return;
}
// Check for common color formats
const colorRegex = /(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/;
if (colorRegex.test(value)) {
result.warn(`[color] Hard-coded color value "${decl.value}" detected. Please use a predefined CSS variable from the design system.`, { node: decl });
}
}
});
// 2. Check selector complexity
root.walkRules(rule => {
// Ignore selectors inside keyframes
if (rule.parent && rule.parent.type === 'atrule' && rule.parent.name.endsWith('keyframes')) {
return;
}
const selectors = rule.selectors;
selectors.forEach(selector => {
// A common mistake here is to use `selector.split(' ')`, which fails to handle combinators like `>`, `+`, and `~`.
// We use a more robust method to calculate a complexity "score".
let complexity = 0;
// Simple scoring: each combinator or ID adds 1 to the score
const combinatorMatches = selector.match(/[ >+~]/g);
const idMatches = selector.match(/#/g);
if (combinatorMatches) {
complexity += combinatorMatches.length;
}
if (idMatches) {
complexity += idMatches.length;
}
if (complexity > config.selectorComplexity.max) {
result.warn(`[selector] Selector "${selector}" is too complex (score: ${complexity}). Consider simplifying it to reduce specificity.`, { node: rule });
}
});
});
};
});
The core of this plugin lies in the root.walkDecls and root.walkRules methods, which iterate over all CSS declarations and rules, respectively. During traversal, we execute checks based on the options passed in. When an issue is found, we call result.warn() and attach the offending AST node. PostCSS automatically extracts the line and column numbers from the node.
Step 3: Integrating the Plugin and Connecting to the GitHub API
Now, we need a main script, index.js, to drive the process. This script will:
- Load user-defined configuration.
- Get the list of files changed in the current PR.
- Run our PostCSS plugin on each CSS file.
- Collect the analysis results (warnings).
- Call the GitHub API to post the warnings as comments on the PR.
A key challenge here is that PostCSS returns absolute line numbers within a file, while the GitHub API requires line numbers relative to the diff to post comments correctly. Using absolute file line numbers will cause comments to appear in the wrong places. We must first fetch the PR’s diff, then map the file line number to the correct position within that diff. This is a common pitfall.
// index.js
const core = require('@actions/core');
const github = require('@actions/github');
const fs = require('fs').promises;
const path = require('path');
const postcss = require('postcss');
const { cosmiconfig } = require('cosmiconfig');
const fg = require('fast-glob');
const cssLinterPlugin = require('./css-linter-plugin');
async function run() {
try {
const token = core.getInput('github-token', { required: true });
const octokit = github.getOctokit(token);
const { owner, repo, number: issue_number } = github.context.issue;
if (!issue_number) {
core.info('Could not get PR number from context, exiting.');
return;
}
// 1. Load configuration
const explorer = cosmiconfig('reviewbot');
const result = await explorer.search();
const config = result ? result.config : {};
core.info(`Loaded configuration: ${JSON.stringify(config)}`);
// 2. Find all CSS files in the project
const cssFiles = await fg(['**/*.css', '!**/node_modules/**'], { dot: true });
if (cssFiles.length === 0) {
core.info('No CSS files found.');
return;
}
const processor = postcss([cssLinterPlugin(config)]);
const allDiagnostics = [];
// 3. Analyze all CSS files
for (const file of cssFiles) {
try {
const css = await fs.readFile(file, 'utf8');
const res = await processor.process(css, { from: file });
for (const warning of res.warnings()) {
allDiagnostics.push({
file: file.replace(/\\/g, '/'), // Ensure consistent path format
line: warning.line,
column: warning.column,
message: warning.text,
});
}
} catch (error) {
// In a real project, this needs more robust error handling
core.error(`Error processing file ${file}: ${error.message}`);
if (error.name === 'CssSyntaxError') {
core.error(` at line ${error.line}, column ${error.column}`);
}
}
}
if (allDiagnostics.length === 0) {
core.info('CSS review passed. No issues found.');
return;
}
core.info(`Found ${allDiagnostics.length} potential issues.`);
// 4. Get the PR diff, which is crucial for positioning comments correctly
const { data: diff } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: issue_number,
mediaType: {
format: 'diff'
}
});
const comments = [];
const changedFiles = parseDiff(diff);
for (const diagnostic of allDiagnostics) {
const fileDiff = changedFiles.find(f => f.path === diagnostic.file);
if (!fileDiff) {
continue; // File was not part of this PR's changes, skip it
}
const position = findPositionInDiff(fileDiff, diagnostic.line);
if (position !== -1) {
comments.push({
path: diagnostic.file,
position: position,
body: diagnostic.message,
});
}
}
if (comments.length > 0) {
core.info(`Posting ${comments.length} comments to PR...`);
await octokit.rest.pulls.createReview({
owner,
repo,
pull_number: issue_number,
event: 'COMMENT',
comments: comments,
});
}
} catch (error) {
core.setFailed(error.message);
}
}
// Helper function to parse diff text
function parseDiff(diff) {
const files = [];
const diffLines = diff.split('\n');
let currentFile = null;
let lineInDiff = 0;
for (const line of diffLines) {
lineInDiff++;
if (line.startsWith('--- a/')) continue;
if (line.startsWith('+++ b/')) {
currentFile = { path: line.substring(6), hunks: [] };
files.push(currentFile);
continue;
}
if (line.startsWith('@@')) {
const match = /@@ -\d+(,\d+)? \+(\d+)(,(\d+))? @@/.exec(line);
if (match) {
currentFile.hunks.push({ startLine: parseInt(match[2], 10), lines: [], diffStart: lineInDiff });
}
} else if (currentFile && currentFile.hunks.length > 0) {
currentFile.hunks[currentFile.hunks.length - 1].lines.push(line);
}
}
return files;
}
// Helper function to find the position of a file line number within a diff hunk
function findPositionInDiff(fileDiff, targetLine) {
let diffPosition = 0;
for (const hunk of fileDiff.hunks) {
let currentFileLine = hunk.startLine;
diffPosition = hunk.diffStart;
for (const lineContent of hunk.lines) {
diffPosition++;
if (lineContent.startsWith('-')) {
continue;
}
if (currentFileLine === targetLine && !lineContent.startsWith('-')) {
return diffPosition;
}
if (!lineContent.startsWith('-')) {
currentFileLine++;
}
}
}
return -1;
}
run();
The parseDiff and findPositionInDiff helper functions are critical to the success of this solution. They handle the complex mapping from absolute file line numbers to relative diff positions. In a production environment, this logic would require thorough testing, as it can easily break due to subtle variations in diff formats.
Step 4: Packaging as a GitHub Action
To make this tool easily reusable across any project, we package it as a standalone GitHub Action by creating an action.yml file:
# action.yml
name: 'CSS Review Bot'
description: 'Automatically reviews CSS files in a pull request using PostCSS and comments on potential issues.'
author: 'Your Name'
inputs:
github-token:
description: 'The GitHub token to post comments.'
required: true
default: ${{ github.token }}
runs:
using: 'node16'
main: 'index.js'
Step 5: Using the Action in a Workflow
The final step is to create a workflow file in the project’s .github/workflows directory, such as css-review.yml:
# .github/workflows/css-review.yml
name: CSS Code Review
on:
pull_request:
paths:
- '**.css'
jobs:
review:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Run CSS Review Bot
uses: ./ # Assumes the action is in the repository root
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
Additionally, provide a configuration file like .reviewbotrc.json in the project root:
{
"zIndex": {
"max": 20
},
"color": {
"allowKeywords": ["transparent", "inherit", "currentColor", "white", "black"]
},
"selectorComplexity": {
"max": 4
}
}
With that, our automated CSS Code Review bot is complete. Whenever a new PR modifies CSS files, this Action will trigger, execute our script, analyze the code, and post any findings as precise, line-specific comments—just like a tireless, standards-compliant colleague.
Limitations and Future Iterations
This tool is not a silver bullet. It cannot understand visual design intent or judge whether a layout’s logic is correct (e.g., if an element should really be position: absolute). What it can catch are the anti-patterns we’ve reached a consensus on and can be described structurally.
In a real-world project, the robustness of this tool would need continuous refinement, for instance:
- Performance Optimization: For very large projects, analyzing all CSS files at once can be slow. It could be optimized to analyze only the files changed in the PR, which requires a more sophisticated integration with
diffdata. - Preprocessor Support: The current implementation only supports native CSS. To support Sass/Less, we would need to introduce the corresponding PostCSS parsers (like
postcss-scss), and the plugin logic might need adjustments to handle syntax like nesting and@mixins. - Smarter Rules: More complex AST analysis could be introduced to detect things like redundant style declarations, style overrides, or even analyze CSS-in-JS scenarios.
- Auto-fix Suggestions: Currently, the bot only identifies problems. A future direction is to not only find issues but also provide concrete fix recommendations, perhaps even leveraging GitHub’s
suggestionfeature for one-click fixes.
The value of this tool isn’t to completely replace human code review, but to free humans from repetitive, mechanical checks. This allows us to focus on higher-level concerns like logic, architecture, and maintainability.