构建基于 PostCSS AST 的自动化 CSS Code Review 机器人


团队的 CSS Code Review 流程中,我们反复在处理一些同样的问题:不受控的 z-index、硬编码的颜色值、以及过度嵌套的选择器。这些问题琐碎但重要,人工审查既耗时又容易遗漏,并且常常因为主观标准不一而引发不必要的讨论。依赖 Code Style 规范文档和口头提醒的效果微乎其微,开发者在紧张的排期中很容易忽略这些“软性”约定。

与其把宝贵的人力投入到这种重复性劳动中,我们决定构建一个自动化工具,一个能像真人一样在 Pull Request 中逐行评论的机器人。这个机器人的核心任务,就是在 CI 阶段静态分析 CSS 代码,并把发现的反模式精确地标记在对应的代码行上。

技术选型上,简单的正则表达式很快被排除了。它无法理解 CSS 的上下文结构,比如区分选择器内的 color 和注释里的 color。我们需要的是能够深度解析代码的工具。最终,PostCSS 成为了我们的选择。它能将 CSS 解析成抽象语法树 (Abstract Syntax Tree, AST),这让我们能够进行精确、结构化的分析,为实现复杂的、上下文相关的检查规则提供了坚实的基础。

技术痛点与初步构想

我们的目标非常明确:

  1. 自动化: 工具必须无缝集成到现有的 CI/CD 流程中(我们使用 GitHub Actions)。
  2. 精确性: 反馈必须直接关联到 PR 中有问题的代码行。
  3. 可配置性: 检查规则应该是可配置的,以适应不同项目的特定需求。
  4. 非阻塞性: 初期工具应只提出建议(评论),而不是直接让 CI 失败,避免过度影响开发流程。

整个工作流程的设计如下:

flowchart TD
    A[开发者推送代码到 PR] --> B{触发 GitHub Action};
    B --> C[Checkout 代码];
    C --> D[执行自定义分析脚本];
    D --> E[使用 PostCSS 加载自定义插件];
    E --> F{遍历 CSS AST};
    F --> G[收集违规代码的位置与信息];
    G --> H[格式化结果];
    H --> I[调用 GitHub API];
    I --> J[在 PR 对应行发表评论];

这个流程的核心在于自定义的分析脚本,它将串联起 PostCSS 的代码分析能力和 GitHub 的 API 交互能力。

第一步:搭建项目骨架与核心依赖

我们从一个标准的 Node.js 项目开始。关键的依赖包括:

  • postcss: PostCSS 核心库,用于解析 CSS 和执行插件。
  • cosmiconfig: 一个优秀的配置加载器,它能自动寻找并解析多种格式的配置文件(如 .reviewbotrc.json, .reviewbotrc.js 等),让我们的工具更具通用性。
  • @actions/core: GitHub Actions 官方工具包,用于获取输入、设置输出和记录日志。
  • @actions/github: GitHub Actions 官方工具包,封装了 Octokit,方便与 GitHub API 交互。

package.json 的核心依赖部分如下:

{
  "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"
  }
}

我们将整个逻辑封装在一个 index.js 文件中,作为 GitHub Action 的入口点。

第二步:编写 PostCSS 插件以捕捉反模式

这是整个工具的灵魂。一个 PostCSS 插件本质上是一个函数,它接收一个 root 对象(整个 CSS 文件的 AST 根节点)和 result 对象。我们可以通过遍历这棵树来检查我们关心的每一个节点。

我们计划实现三个初始规则:

  1. Z-index 值检查: z-index 必须是预定义变量,或在某个合理的小范围内(如 -1 到 10)。
  2. 硬编码颜色检查: 禁止直接使用 #..., rgb(...) 等颜色值,必须使用 CSS 变量(如 var(--color-primary))。
  3. 选择器复杂度检查: 一个选择器中的组合符(>, +, ~, )和 ID 选择器不应过多,避免过高的特异性。

以下是插件的实现 css-linter-plugin.js

// css-linter-plugin.js

const postcss = require('postcss');

// 插件接收配置作为参数
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. 检查 z-index 和硬编码颜色
    root.walkDecls(decl => {
      // 检查 z-index
      if (decl.prop.toLowerCase() === 'z-index') {
        const value = decl.value.trim();
        if (config.zIndex.allowVars && /^var\(--.*\)$/.test(value)) {
          return; // 允许 CSS 变量
        }
        const numericValue = parseInt(value, 10);
        if (isNaN(numericValue) || numericValue > config.zIndex.max || numericValue < -config.zIndex.max) {
            result.warn(`[z-index] 值 "${value}" 超出预设范围 (-${config.zIndex.max} to ${config.zIndex.max})。请考虑使用层级管理变量。`, { node: decl });
        }
      }

      // 检查颜色属性
      const colorProps = ['color', 'background-color', 'border-color', 'outline-color', 'fill', 'stroke'];
      if (colorProps.includes(decl.prop.toLowerCase())) {
        const value = decl.value.trim().toLowerCase();
        
        // 允许的关键字
        if (config.color.allowKeywords.includes(value)) {
          return;
        }

        // 允许的 CSS 变量
        if (config.color.allowVars && /^var\(--.*\)$/.test(value)) {
          return;
        }
        
        // 检查常见的颜色格式
        const colorRegex = /(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/;
        if (colorRegex.test(value)) {
          result.warn(`[color] 检测到硬编码颜色值 "${decl.value}"。请使用设计系统预定义的 CSS 变量。`, { node: decl });
        }
      }
    });

    // 2. 检查选择器复杂度
    root.walkRules(rule => {
      // 忽略 keyframes 内部的选择器
      if (rule.parent && rule.parent.type === 'atrule' && rule.parent.name.endsWith('keyframes')) {
        return;
      }
      
      const selectors = rule.selectors;
      selectors.forEach(selector => {
        // 一个常见的错误是在这里用 `selector.split(' ')`,这无法处理 `>` `+` `~` 等组合符。
        // 我们用一个更稳健的方法来计算复杂度 "分数"
        let complexity = 0;
        // 简单的计分策略:每个组合符或 ID 都算 1 分
        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}" 复杂度过高 (score: ${complexity}),建议简化以降低特异性。`, { node: rule });
        }
      });
    });
  };
});

这个插件的核心在于 root.walkDeclsroot.walkRules 方法,它们分别用于遍历所有的 CSS 声明和规则。在遍历过程中,我们根据传入的 options 配置执行检查逻辑。当发现问题时,我们调用 result.warn(),并将违规的 AST 节点 node 附加上去。PostCSS 会自动从 node 中提取出行号和列号信息。

第三步:集成插件并与 GitHub API 对接

现在我们需要一个主脚本 index.js 来驱动整个流程。这个脚本要做几件事:

  1. 加载用户配置。
  2. 获取当前 PR 改变的文件列表。
  3. 对每个 CSS 文件运行我们的 PostCSS 插件。
  4. 获取分析结果(警告信息)。
  5. 调用 GitHub API,将警告作为评论发布到 PR。

一个关键的挑战在于,PostCSS 返回的行号是文件内的绝对行号,而 GitHub API 发表评论需要的是相对于 diff 的行号。直接使用文件行号会导致评论位置错误。我们必须先获取 PR 的 diff,然后将文件行号映射到 diff 中的正确位置。这是一个常见的坑。

// 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. 加载配置
    const explorer = cosmiconfig('reviewbot');
    const result = await explorer.search();
    const config = result ? result.config : {};
    core.info(`Loaded configuration: ${JSON.stringify(config)}`);

    // 2. 查找项目中的 CSS 文件
    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. 分析所有 CSS 文件
    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, '/'), // 保证路径格式统一
            line: warning.line,
            column: warning.column,
            message: warning.text,
          });
        }
      } catch (error) {
        // 在真实项目中,这里需要更健壮的错误处理
        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. 获取 PR 的 diff 信息,这是定位评论位置的关键
    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; // 文件不在本次 PR 变更范围内,跳过
        }

        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);
  }
}

// 辅助函数:解析 diff 文本
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;
}

// 辅助函数:在 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();

parseDifffindPositionInDiff 这两个辅助函数是这个方案能够成功的关键。它们处理了从文件行号到 diff 相对位置的复杂映射。在生产环境中,这部分逻辑需要经过充分测试,因为它很容易因为 diff 格式的细微差别而出错。

第四步:封装成 GitHub Action

为了让这个工具能在任何项目中方便地使用,我们将其封装成一个独立的 GitHub Action。需要创建 action.yml 文件:

# 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'

第五步:在工作流程中使用

最后一步,在项目的 .github/workflows 目录下创建一个工作流文件,比如 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: ./ # 假设 action 在仓库根目录
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

同时,在项目根目录提供一个配置文件 .reviewbotrc.json

{
  "zIndex": {
    "max": 20
  },
  "color": {
    "allowKeywords": ["transparent", "inherit", "currentColor", "white", "black"]
  },
  "selectorComplexity": {
    "max": 4
  }
}

至此,整个自动化 CSS Code Review 机器人就完成了。当有新的 PR 修改了 CSS 文件时,这个 Action 会被触发,执行我们的脚本,分析代码,并将发现的问题作为评论精确地附加到 PR 的代码行上,就像一个不知疲倦的、严格遵守规范的同事。

局限性与未来迭代

这个工具并非万能。它无法理解设计的视觉意图,也无法判断布局的逻辑是否正确,比如一个元素是否应该 position: absolute。它能捕捉的,是那些我们已经达成共识的、可以被结构化描述出来的反模式。

在真实项目中,这个工具的健壮性还需要持续打磨,例如:

  • 性能优化:对于超大型项目,一次性分析所有 CSS 文件可能会很慢。可以优化为只分析 PR 中变更过的文件,这需要更精细地与 diff 数据结合。
  • 支持预处理器:当前实现只支持原生 CSS。要支持 Sass/Less,需要引入对应的 PostCSS 解析器(如 postcss-scss),并且插件逻辑可能需要调整以处理嵌套、@mixin 等语法。
  • 更智能的规则:可以引入更复杂的 AST 分析,比如检测冗余的样式声明、样式覆盖关系,甚至结合 CSS-in-JS 的场景进行分析。
  • 可修复建议: 目前只是提出问题,未来的一个方向是不仅能发现问题,还能给出具体的修复建议,甚至提供一键修复的 suggestion 功能。

这个工具的价值不在于完全取代人工 Code Review,而在于将人类从重复、机械的检查中解放出来,让我们能更专注于代码的逻辑、架构和可维护性等更高层次的问题上。


  目录