Auditing the Overrides Layer in CI

The overrides layer is the one place in a layered stylesheet where a single flat rule beats every component you own — which is exactly why it grows quietly until it becomes the specificity dumping ground layers were meant to abolish. This guide, part of override layer best practices within CSS layer architecture for design systems, shows how to put a hard number on that layer and defend it in continuous integration: a small Node script counts the declarations inside @layer overrides, a budget fails the build when the count climbs, and a per-pull-request diff makes every new override a visible, reviewable decision.

Prerequisites

Before wiring this up, you should be comfortable with:

  • The canonical stack and where overrides sits — it is the last-declared, highest-priority layer in @layer reset, base, themes, components, utilities, overrides;. If that ordering is unfamiliar, start with layer declaration order.
  • Why the layer needs to stay sparse rather than absorb every one-off fix, covered in the parent override layer best practices guide.
  • Tooling required: Node.js ≥ 18, postcss as a dev dependency, stylelint ≥ 15, and a CI runner (the examples use GitHub Actions).

What the audit measures

The audit reduces a governance problem to one number: the count of CSS declarations that live inside @layer overrides across your built stylesheet. Everything downstream — the budget check, the pull-request diff, the CI gate — reads from that single count. The diagram traces the flow from raw CSS to a pass/fail decision.

Overrides layer audit flow in CI A horizontal flow: parse the built CSS, walk the AST to count declarations inside @layer overrides, compare that count to a stored budget, then branch to a passing build if the count is at or below budget or a failing build if it exceeds the budget. Parse built CSS PostCSS AST Count in overrides declarations only count vs budget read budget.json Pass (≤ budget) Fail (> budget)

The key modelling decision is the unit. A rule block can hold many declarations, and a comma-separated selector can hide many rules, so counting rules or selectors is easy to game. Counting declarations — individual property: value pairs — tracks the thing that actually overrides a component, and it does not move when someone reformats a selector list.


Step-by-step procedure

Step 1 — Define what counts as an override

Decide the rule before writing code: a declaration counts if, and only if, it resolves at overrides priority. That means anything directly inside @layer overrides { ... } and anything inside a nested sub-layer such as @layer overrides.print, because a nested layer inherits its parent’s precedence and can still beat every component rule.

/* entry.css — the ONLY file allowed to define @layer overrides */
@layer reset, base, themes, components, utilities, overrides;

@layer overrides {
  /* Counts: 2 declarations at overrides priority */
  .checkout-flow :where(.btn) { inline-size: 100%; border-radius: 0; }
}

@layer overrides.print {
  /* Also counts: nested layer still resolves at overrides priority */
  :where(.no-print) { display: none; }
}

What this does: Establishes the budget’s scope. Two declarations plus one is three toward the budget. Fixing the scope up front stops later arguments about whether print rules or nested layers “really” count — they do, because the cascade treats them at the same priority.


Step 2 — Write the counting script

Parse the built CSS with PostCSS and walk the AST for every at-rule whose layer path starts with overrides, summing the declarations inside each.

// count-overrides.mjs
// WHY: PostCSS gives a real AST, so nested @layer blocks, @media queries,
//      and comments are handled correctly — a regex over @layer would miss
//      nested sub-layers and count commented-out rules.
import postcss from 'postcss';
import fs from 'node:fs';

// Does this @layer at-rule resolve at overrides priority?
// params look like: "overrides", "overrides.print", "overrides, utilities"
function isOverrides(atRule) {
  return atRule.name === 'layer' &&
    atRule.params.split(',').some(p => p.trim().split('.')[0] === 'overrides');
}

export function countOverrides(css) {
  const root = postcss.parse(css);
  let count = 0;
  root.walkAtRules('layer', at => {
    // WHY: only at-rules with a body ({ ... }) hold declarations; a bare
    //      `@layer overrides;` declaration statement has no nodes to count.
    if (!at.nodes || !isOverrides(at)) return;
    at.walkDecls(() => count++);   // every property:value pair inside
  });
  return count;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const file = process.argv[2] ?? './dist/bundle.css';
  const css = fs.readFileSync(file, 'utf-8');
  console.log(countOverrides(file ? css : ''));
}

What this does: countOverrides returns a single integer — the total declarations at overrides priority in the built bundle. It reads the file and writes nothing, so it is safe to run against any artifact. Exporting the function lets Steps 3 and 4 reuse it instead of re-parsing.


Step 3 — Set a budget and fail the build

Store the ceiling in a committed JSON file so it lives in version control and every lowering is a reviewable diff. The gate compares the live count and exits non-zero when it is exceeded.

// overrides-gate.mjs
import fs from 'node:fs';
import { countOverrides } from './count-overrides.mjs';

const { budget } = JSON.parse(fs.readFileSync('./overrides-budget.json', 'utf-8'));
const css = fs.readFileSync('./dist/bundle.css', 'utf-8');
const count = countOverrides(css);

console.log(`overrides declarations: ${count} / budget ${budget}`);

if (count > budget) {
  // WHY: exit(1) makes CI fail the job; the message tells the author exactly
  //      how far over budget they are so the fix is obvious in the log.
  console.error(`FAIL: overrides layer is ${count - budget} declaration(s) over budget.`);
  process.exit(1);
}
// WHY: when the count drops, nudge the author to ratchet the budget down.
if (count < budget) {
  console.log(`Tip: overrides is under budget — lower it to ${count} in overrides-budget.json.`);
}
{
  "budget": 24
}

What this does: Turns the count into a pass/fail signal. The build fails the moment the overrides layer grows past 24 declarations, and the log reports the exact overage. Keeping the budget in overrides-budget.json means loosening it can never be an accident — it shows up as a line change a reviewer must approve.


Step 4 — Report the diff against the base branch

A raw count says whether you passed; a diff says what this pull request did. Build the base branch, count it, and print the delta.

#!/usr/bin/env bash
# overrides-diff.sh — compare the overrides count on HEAD vs the base branch
set -euo pipefail

# WHY: capture the current (PR) count first while the working tree is built
HEAD_COUNT=$(node count-overrides.mjs ./dist/bundle.css)

# WHY: build the base branch in a detached worktree so the working tree
#      is left untouched — CI can restore it without a dirty checkout.
git worktree add --detach .base-build "origin/${BASE_REF:-main}" >/dev/null
( cd .base-build && npm ci --silent && npm run build --silent )
BASE_COUNT=$(node count-overrides.mjs .base-build/dist/bundle.css)
git worktree remove --force .base-build

DELTA=$(( HEAD_COUNT - BASE_COUNT ))
printf 'overrides: %s (base %s, delta %+d)\n' "$HEAD_COUNT" "$BASE_COUNT" "$DELTA"

What this does: Emits a line like overrides: 26 (base 24, delta +2). A positive delta is the signal reviewers care about: this pull request added two overrides. A negative delta is a win worth celebrating and a cue to lower the budget. The base build runs in a git worktree so it never disturbs the pull-request checkout.


Step 5 — Add a Stylelint guard for stray overrides

The counter measures the built bundle, but overrides should only ever be authored in the governed entry stylesheet. Stop component source files from defining their own @layer overrides blocks with a Stylelint rule.

{
  "rules": {
    "at-rule-disallowed-list": null
  },
  "overrides": [
    {
      "files": ["src/components/**/*.css"],
      "rules": {
        "declaration-no-important": true,
        "rule-selector-property-disallowed-list": {},
        "no-unknown-animations": null,
        "comment-no-empty": true
      }
    }
  ],
  "plugins": ["stylelint-selector-bem-pattern"]
}
# WHY: a focused grep-style guard is clearer than a plugin here — fail CI if any
#      component file opens an @layer overrides block that bypasses the entry file.
if grep -rEl '@layer[[:space:]]+overrides' src/components; then
  echo "ERROR: overrides may only be declared in src/entry.css"; exit 1
fi

What this does: Keeps authorship centralised. Components can only contribute to components; the single entry stylesheet owns overrides. This makes the count meaningful — every declaration in the layer passed through the one file a reviewer watches, so the budget cannot be dodged by scattering overrides across component folders.


Step 6 — Wire it into GitHub Actions

Run the gate and the diff on every pull request, upload the report, and surface the delta where reviewers read it.

# .github/workflows/overrides-audit.yml
name: Overrides layer audit
on: [pull_request]
permissions:
  contents: read
  pull-requests: write   # WHY: needed to post the diff as a PR comment
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }   # WHY: full history so the base ref is available
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run build            # produces dist/bundle.css
      - name: Enforce budget
        run: node overrides-gate.mjs   # fails the job if over budget
      - name: Report diff
        env: { BASE_REF: $ }
        run: bash overrides-diff.sh | tee overrides-report.txt
      - name: Comment delta on PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const body = '```\n' + fs.readFileSync('overrides-report.txt','utf8') + '```';
            await github.rest.issues.createComment({
              ...context.repo, issue_number: context.issue.number, body
            });

What this does: Makes the overrides budget a first-class part of code review. The Enforce budget step blocks merges that push the layer past its ceiling; the comment step posts the +2 / -1 delta directly on the pull request so growth is never invisible.


Verification

Confirm the pipeline works before trusting it as a gate.

Unit-check the counter. Feed the script a fixture with a known number of overrides declarations and assert the return value:

# WHY: a fixture with exactly 3 overrides declarations proves the AST walk
#      counts nested sub-layers and ignores rules outside the layer.
node -e "import('./count-overrides.mjs').then(m => {
  const css = '@layer overrides{ .a{color:red;top:0} } @layer overrides.print{ .b{display:none} } @layer components{ .c{color:blue} }';
  console.assert(m.countOverrides(css) === 3, 'expected 3');
  console.log('counter OK');
})"

Force a failure. Temporarily lower budget to one below the current count and run node overrides-gate.mjs; the job should exit non-zero with the overage message. Restore the budget afterwards.

Read the DevTools truth. Open DevTools → Elements → Styles, select an element the override targets, and confirm the winning declaration shows the [overrides] layer label. The count in CI should match the number of [overrides] declarations you can find in the rendered cascade.


Troubleshooting

The counter returns zero even though overrides rules exist : The script parses the built bundle, but your build tool may emit the layer with a mangled or hashed name, or split it into a separate chunk the script never reads. Point the script at the final concatenated CSS (dist/bundle.css), and confirm your bundler preserves @layer at-rules rather than flattening them — some older minifiers strip layer wrappers. Inspect the artifact directly to verify @layer overrides survived the build.

Nested @layer overrides.print is not being counted : The isOverrides check splits each layer param on . and compares the first segment. If your build rewrites nested layers into anonymous or fully-qualified forms, the param may not start with overrides. Log atRule.params inside the walk to see the actual string the browser and script receive, then adjust the prefix match to fit your build output.

The base-branch diff step fails with a dirty worktree : git worktree add refuses if a stale .base-build directory already exists from a cancelled run. Add git worktree remove --force .base-build || true before the add, and ensure .base-build is in .gitignore so it never gets committed. Using fetch-depth: 0 in the checkout is required — a shallow clone will not have the base ref to build.

The count jumps when a teammate merges !important into overrides : An !important declaration still counts as one declaration, but it behaves very differently: !important inverts layer order, so an !important in a lower layer can beat overrides. If the count grew because someone reached for !important to fight another layer, the fix is architectural, not budgetary — review how the role of !important in layers applies and reorder the layers instead.

Stylelint passes locally but CI flags a stray overrides block : The grep guard scans src/components, but a build step or codegen tool may inject an @layer overrides block into a generated file outside that path. Widen the guard to every authored source directory, and exclude only the single governed entry file by exact path so nothing else can slip an override into the layer.


Complete working example

A self-contained setup: the counter, the gate, the budget file, and a fixture bundle you can run end to end.

// audit/count-overrides.mjs
// Counts declarations that resolve at @layer overrides priority (including
// nested sub-layers). Reads a file, writes nothing — safe in any CI job.
import postcss from 'postcss';
import fs from 'node:fs';

function isOverrides(atRule) {
  // A layer param like "overrides.print" or "overrides" resolves at overrides
  // priority; "utilities" does not. Compare the first dot-segment of each name.
  return atRule.name === 'layer' &&
    atRule.params.split(',').some(p => p.trim().split('.')[0] === 'overrides');
}

export function countOverrides(css) {
  const root = postcss.parse(css);
  let count = 0;
  root.walkAtRules('layer', at => {
    if (!at.nodes || !isOverrides(at)) return;  // skip bare declarations
    at.walkDecls(() => count++);                 // one per property:value pair
  });
  return count;
}

// audit/overrides-gate.mjs
// Fails the build when the overrides layer exceeds its committed budget.
export function gate({ css, budget }) {
  const count = countOverrides(css);
  if (count > budget) {
    console.error(`FAIL: overrides ${count} > budget ${budget} (+${count - budget})`);
    return 1;   // caller maps this to process.exit(1)
  }
  console.log(`OK: overrides ${count} / budget ${budget}`);
  return 0;
}

// audit/run.mjs — glue for local + CI use
import fs from 'node:fs';
const budget = JSON.parse(fs.readFileSync('audit/overrides-budget.json','utf8')).budget;
const css = fs.readFileSync(process.argv[2] ?? 'dist/bundle.css','utf8');
process.exit(gate({ css, budget }));
/* audit/overrides-budget.json — the ratchet lives in version control */
{ "budget": 24 }
/* dist/bundle.css — an illustrative built bundle the counter reads.
   The canonical six-layer order; overrides is last and highest-priority. */
@layer reset, base, themes, components, utilities, overrides;

@layer components {
  /* :where() keeps component specificity at 0 so overrides can win cleanly */
  :where(.btn) { padding: 0.5rem 1.25rem; border-radius: 0.375rem; }
}

@layer overrides {
  /* Each declaration here is one unit of the budget. Keep this block SPARSE:
     every line is a deliberate, reviewed exception to the design system. */
  .checkout-flow :where(.btn) { inline-size: 100%; border-radius: 0; }
}

@layer overrides.print {
  /* Nested sub-layer — still overrides priority, still counts toward budget */
  :where(.no-print) { display: none; }
}

Run node audit/run.mjs dist/bundle.css; the fixture reports four overrides declarations against a budget of 24 and passes. Drop the budget below four to watch it fail.


FAQ

Should nested sub-layers like overrides.print count toward the budget?

Yes. A nested layer such as overrides.print still resolves at the priority of its parent overrides layer, so it can beat every component rule. The counting script walks the AST and treats any at-rule whose full layer path begins with overrides as part of the budget. If you want to exempt print rules, do it explicitly by name in the config rather than by ignoring nesting, so the exemption stays auditable.

Why count declarations instead of selectors or rules?

Declarations are the unit that actually overrides a component. A single rule block can carry ten property declarations, and a selector list can hide many rules behind one comma-separated line. Counting declarations gives a stable metric that resists gaming: splitting or merging selectors does not change the number, but adding a genuine new override always does.

What is a reasonable starting budget for the overrides layer?

Measure your current count and set the budget just below it, then ratchet down over time. There is no universal number, but on a mature design system the overrides layer should hold tens of declarations, not hundreds. The value is the ratchet: every pull request that removes an override lets you lower the budget, and CI stops it silently climbing back. The preventing style collisions in large frontend teams guide covers the team habits that keep the number falling.