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-parserTooling: 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.
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.
Verification
- 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- Deliberately add an unlayered rule to a component file, rebuild, and confirm the check fails.
- 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.
- 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.
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.
Related
- Incremental Migration Strategies — the migration this report measures
- Migrating a Legacy Stylesheet to Layers File by File — the loop that moves the number
- Auditing the Overrides Layer in CI — a sibling ratchet for the escape-hatch layer
- Enforcing a Layer Contract With Stylelint Rules — the static half of the same guardrail
Up: Incremental Migration Strategies → Browser Support, Compatibility & Migration