Measuring Migration Progress With a Layer Coverage Report

A layer migration does not fail loudly. It stalls — quietly, at around seventy percent, when the easy components are done and the remaining CSS belongs to nobody. The countermeasure is a number that is published every build, because a migration nobody can see the state of is a migration that stops being prioritised. This closes out incremental migration strategies in the browser support, compatibility and migration reference.

Prerequisites

You need a build that emits CSS to a known path, and the legacy layer wrap already in place so there is something to measure against.

npm install --save-dev postcss postcss-safe-parser

Tooling: Node 18+, PostCSS, and wherever your team already publishes build metrics.

Step 1: Attribute every declaration to a layer

// scripts/layer-coverage.mjs
// WHY: PostCSS gives an AST with parent links, so walking up from a
// declaration to its enclosing @layer at-rules yields the full layer path —
// including nested children like components.checkout.
import { readFile, writeFile } from 'node:fs/promises';
import postcss from 'postcss';
import safeParser from 'postcss-safe-parser';

function layerPathOf(node) {
  const parts = [];
  for (let n = node.parent; n; n = n.parent) {
    if (n.type === 'atrule' && n.name === 'layer' && n.params) {
      // A block form (@layer x { }) has params; a statement form has no nodes.
      parts.unshift(n.params.trim());
    }
  }
  return parts.length ? parts.join('.') : '(unlayered)';
}

export async function collect(cssPath) {
  const css = await readFile(cssPath, 'utf8');
  const root = postcss.parse(css, { parser: safeParser, from: cssPath });
  const byLayer = new Map();

  root.walkDecls((decl) => {
    const layer = layerPathOf(decl);
    const bucket = byLayer.get(layer) ?? { declarations: 0, important: 0 };
    bucket.declarations += 1;
    if (decl.important) bucket.important += 1;
    byLayer.set(layer, bucket);
  });

  return byLayer;
}

What this does: turns the bundle into a per-layer declaration census. Note the (unlayered) bucket — in a migrated codebase it should be empty, and anything appearing there is a higher-priority finding than the legacy count.

Layer coverage over four reporting periods Four vertical stacked bars, one per quarter. The legacy segment shrinks from most of the bar in the first period to a small remainder in the fourth, while the reset, theme, components and utilities segments grow correspondingly. Coverage percentages are labelled above each bar. Declarations by layer, per quarter 0 8k 8% Q1 38% Q2 65% Q3 86% Q4 migrated layers still in legacy

Step 2: Compute three metrics, not one

Coverage alone can improve while the codebase gets worse.

// WHY three numbers: coverage says how much moved, important density says
// whether it moved for the right reason, and the specificity ceiling says
// whether the layer order is actually being used instead of selector weight.
export function summarise(byLayer) {
  let total = 0, layered = 0, important = 0;
  for (const [layer, b] of byLayer) {
    total += b.declarations;
    important += b.important;
    if (layer !== '(unlayered)' && !layer.startsWith('legacy')) {
      layered += b.declarations;
    }
  }
  return {
    coverage: +(layered / total * 100).toFixed(1),
    importantDensity: +(important / total * 1000).toFixed(2), // per 1000 decls
    unlayered: byLayer.get('(unlayered)')?.declarations ?? 0,
  };
}

What this does: gives the three signals that between them describe migration health. The unlayered count is a hard failure rather than a trend — anything above zero means the architecture has a hole.

Step 3: Emit a snapshot the build can compare

const byLayer = await collect('dist/assets/index.css');
const summary = summarise(byLayer);

await writeFile(
  'reports/layer-coverage.json',
  JSON.stringify({ summary, layers: Object.fromEntries(byLayer) }, null, 2)
);

console.table(Object.fromEntries(byLayer));
console.log(summary);

What this does: produces both a human view for the pull request and a machine view for the ratchet.

Step 4: Ratchet the numbers in CI

# scripts/check-coverage.sh
# WHY a ratchet rather than a target: nobody has to agree on the right number,
# and the build simply refuses to let it go backwards.
node scripts/layer-coverage.mjs

BASE=$(jq '.summary.coverage' reports/layer-coverage.baseline.json)
NOW=$(jq '.summary.coverage' reports/layer-coverage.json)
UNLAYERED=$(jq '.summary.unlayered' reports/layer-coverage.json)

if [ "$UNLAYERED" -gt 0 ]; then
  echo "FAIL: $UNLAYERED unlayered declaration(s) in the bundle"; exit 1
fi

if awk "BEGIN{exit !($NOW < $BASE)}"; then
  echo "FAIL: layer coverage fell from $BASE% to $NOW%"; exit 1
fi

echo "coverage $NOW% (baseline $BASE%)"

What this does: makes regression impossible without an explicit baseline update, which is a visible, reviewable act rather than a silent drift.

The three metrics and what each one catches Three rows, each pairing a metric with the failure it detects. Coverage detects a stalled or reversing migration. Important density detects extractions landing in the wrong layer. The unlayered count detects a hole in the architecture and is a hard failure at any value above zero. coverage % declarations outside legacy catches a stalled or reversing migration · ratchet: must never fall a few points per month completes; zero movement for a quarter does not important density per 1000 declarations catches extractions landing in the wrong layer · ratchet: must not rise a rise means someone reached for the tool the migration was meant to retire unlayered count in the emitted bundle catches a hole in the architecture · hard failure above zero unlayered CSS outranks every layer, so one declaration can undo the stack

Verification

  1. Run the report against a known-good build and sanity-check the totals against the raw declaration count:
node scripts/layer-coverage.mjs && jq '.summary' reports/layer-coverage.json
  1. Deliberately add an unlayered rule to a component file, rebuild, and confirm the check fails.
  2. Confirm the report sees framework output — if your CSS-in-JS extraction produces a separate file, the script must read all emitted CSS, not just the main bundle.
  3. Post the summary as a pull-request comment for a few weeks. If nobody reacts to a falling number, the metric is not yet visible enough.

Troubleshooting

Coverage jumps ten points with no extraction work. : A build change altered what is emitted — usually tree-shaking removing unused legacy CSS. That is real progress, but note it in the baseline commit so the trend stays interpretable.

Reading an unexpected coverage jump Three explanations for a sudden change in layer coverage: dead CSS removed by tree-shaking, a build change altering what is emitted, or a new dependency adding a large unlayered block. tree-shaking removed dead CSS real progress — note it in the baseline commit a build change altered the output the metric moved without any extraction work a dependency added unlayered CSS coverage falls and the unlayered count rises together

The parser throws on the bundle. : Minified or plugin-mangled CSS can include constructs the strict parser rejects. postcss-safe-parser handles most of it; failing that, run the report against the pre-minification build artefact.

Nested layers are reported as separate top-level entries. : The walker is not joining parent paths. Confirm layerPathOf walks all the way to the root rather than stopping at the first @layer ancestor.

Important density looks fine but the codebase is full of !important. : Most of it is in utilities and reset, where it is expected. Report density per layer as well as globally; the global figure hides where the problem is.

The number never moves even though components are being migrated. : The legacy file is being extracted from and appended to at the same time. Check whether new work is still landing in legacy — that is a process problem the metric has correctly surfaced.

Complete working example

// scripts/layer-coverage.mjs — complete, runnable report generator.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { glob } from 'node:fs/promises';
import postcss from 'postcss';
import safeParser from 'postcss-safe-parser';

function layerPathOf(node) {
  const parts = [];
  for (let n = node.parent; n; n = n.parent) {
    if (n.type === 'atrule' && n.name === 'layer' && n.params) {
      parts.unshift(n.params.trim());
    }
  }
  return parts.length ? parts.join('.') : '(unlayered)';
}

// WHY every emitted file, not just the entry bundle: extracted CSS-in-JS and
// route-level chunks are where unlayered declarations usually hide.
const files = [];
for await (const f of glob('dist/**/*.css')) files.push(f);

const byLayer = new Map();
for (const file of files) {
  const root = postcss.parse(await readFile(file, 'utf8'), {
    parser: safeParser,
    from: file,
  });
  root.walkDecls((decl) => {
    const key = layerPathOf(decl);
    const b = byLayer.get(key) ?? { declarations: 0, important: 0 };
    b.declarations += 1;
    if (decl.important) b.important += 1;
    byLayer.set(key, b);
  });
}

let total = 0, layered = 0, important = 0;
for (const [layer, b] of byLayer) {
  total += b.declarations;
  important += b.important;
  if (layer !== '(unlayered)' && !layer.startsWith('legacy')) {
    layered += b.declarations;
  }
}

const summary = {
  files: files.length,
  declarations: total,
  coverage: +((layered / total) * 100).toFixed(1),
  importantDensity: +((important / total) * 1000).toFixed(2),
  unlayered: byLayer.get('(unlayered)')?.declarations ?? 0,
};

await mkdir('reports', { recursive: true });
await writeFile(
  'reports/layer-coverage.json',
  JSON.stringify(
    { summary, layers: Object.fromEntries([...byLayer].sort()) },
    null,
    2
  ) + '\n'
);

console.table(Object.fromEntries([...byLayer].sort()));
console.log(summary);

// WHY exit non-zero here rather than in a wrapper: the hard rule (no
// unlayered CSS) belongs with the measurement, so it cannot be forgotten.
if (summary.unlayered > 0) {
  console.error(`FAIL: ${summary.unlayered} unlayered declaration(s)`);
  process.exit(1);
}

Frequently Asked Questions

Should coverage be measured in files, rules or declarations?

Declarations. A file count flatters a codebase where one remaining legacy file holds as much CSS as fifty migrated ones, and rule counts are distorted by selector lists and by how the source happens to be formatted. Declarations correspond most closely to both the work left to do and the cascade risk still carried, which is what the number is meant to communicate.

Why measure the emitted bundle rather than the source?

Because the bundle is what the browser cascades. Component frameworks, CSS-in-JS extraction and PostCSS plugins all emit CSS that appears in no source file, and in a mostly-migrated codebase that generated output is frequently the largest remaining block of unlayered declarations. A source-only report can show ninety-five percent coverage while the shipped stylesheet has a hole in it.

What is a good target for important density?

Outside utilities and reset, trending toward zero. The absolute figure matters far less than the direction: if density rises while a migration is running, extractions are landing in the wrong layer and someone has reached for !important to fix an ordering problem the migration was supposed to eliminate. Report it per layer so the rise is attributable.

Up: Incremental Migration StrategiesBrowser Support, Compatibility & Migration