Skip to content

Check_css_budget

FieldValue
TypeSkill Resource
Source~/.copilot/skills/design/scripts/check_css_budget.py
DescriptionNot specified

Source Content

#!/usr/bin/env python3
# =============================================================================
# check_css_budget.py — CSS performance guardrail (size + @import depth)
# -----------------------------------------------------------------------------
# Why this exists: two CSS perf footguns are invisible until production. (1) A
# stylesheet quietly grows until it dominates the critical path. (2) @import
# chains (a imports b imports c...) SERIALIZE network requests — the browser
# can't fetch c until b lands — so deep chains tank first paint. Stylelint can
# order properties but it can't reason across files about total weight or the
# import graph, so this lives in Python.
#
# Stdlib only (Python 3) — no pip installs, so it runs anywhere and in CI.
#
# Usage:
# python3 check_css_budget.py <dir-or-file> [more...] \
# [--max-kb 50] [--warn-kb 30] [--max-import-depth 2] [--max-imports 10]
#
# --max-kb N ERROR (exit 1) when a file's source is larger than N KB
# (default 50).
# --warn-kb N WARNING when a file is larger than N KB (default 30).
# --max-import-depth N ERROR when the deepest @import chain is deeper than N
# (default 2). Depth = number of @import hops; a file
# with no imports has depth 0.
# --max-imports N ERROR when a single file has more than N @import lines
# (default 10).
#
# Note: sizes are RAW SOURCE BYTES. Over the wire CSS is gzip/brotli compressed,
# so transfer size is typically 4-6x smaller — these budgets are a deliberately
# conservative source-weight signal, not the network number.
#
# Exit: 1 on any hard budget breach (size over --max-kb, import chain too deep,
# too many imports in one file, or an @import cycle); else 0.
# =============================================================================
import argparse
import os
import re
import sys
# Directories that are never source we own — skip them while walking.
_SKIP_DIRS = {"node_modules", "dist", "build", ".git"}
# @import in any of its CSS-legal shapes, capturing the target path:
# @import "a.css";
# @import url("a.css");
# @import url(a.css) screen;
# We grab the first quoted-or-bare string after the optional url(. Media queries
# and layer names after the path are ignored — we only care about the target.
_IMPORT_RE = re.compile(
r"""@import\s+ # the at-rule
(?:url\(\s*)? # optional url( wrapper
(?: # the path, in one of three forms:
"([^"]+)" # double-quoted
| '([^']+)' # single-quoted
| ([^)\s;]+) # bare (only valid inside url())
)
""",
re.VERBOSE | re.IGNORECASE,
)
# Strip comments before scanning so a commented-out `/* @import "x"; */` never
# counts as a real dependency.
_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
def find_css_files(paths):
"""Expand the given paths into a sorted list of .css files.
A path may be a file (taken as-is if it ends in .css) or a directory
(walked recursively, skipping vendor/output dirs). Duplicates collapse.
"""
found = set()
for path in paths:
if os.path.isfile(path):
if path.endswith(".css"):
found.add(os.path.abspath(path))
elif os.path.isdir(path):
for root, dirs, files in os.walk(path):
# Mutate dirs in place so os.walk doesn't descend into them.
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
for name in files:
if name.endswith(".css"):
found.add(os.path.abspath(os.path.join(root, name)))
else:
print(f"warning: '{path}' is neither a file nor a directory; skipped",
file=sys.stderr)
return sorted(found)
def file_size_kb(path):
"""Raw source size in KB (1 KB = 1024 bytes)."""
return os.path.getsize(path) / 1024.0
def parse_imports(path):
"""Return the list of absolute paths this file @imports (relative resolved).
Relative targets resolve against the importing file's own directory, which
is how the cascade actually resolves them. Bare/absolute and remote targets
(http://, //cdn, data:) are kept as-is so they can be reported but won't be
followed in the local graph (they simply won't match a scanned file).
"""
try:
with open(path, "r", encoding="utf-8") as fh:
text = fh.read()
except OSError:
return []
text = _COMMENT_RE.sub("", text)
base_dir = os.path.dirname(path)
targets = []
for match in _IMPORT_RE.finditer(text):
target = match.group(1) or match.group(2) or match.group(3)
if target is None:
continue
target = target.strip()
# Remote/protocol/data targets aren't local files — leave them absolute.
if re.match(r"^(?:[a-z]+:)?//", target) or target.startswith("data:"):
targets.append(target)
else:
targets.append(os.path.abspath(os.path.join(base_dir, target)))
return targets
def build_graph(files):
"""Map each scanned file → the list of imports it declares (raw, unfiltered)."""
return {path: parse_imports(path) for path in files}
def deepest_chain(graph):
"""Find the longest @import chain through the scanned files, and any cycle.
Returns (max_depth, deepest_path_list, cycles). Depth is the number of hops,
so a file that imports nothing has depth 0. Only edges that land on another
scanned file are followed — a chain can't run through a file we never saw.
A DFS with an on-stack set detects cycles. We never recurse through a node
already on the current path, so a cycle can't blow the stack; each detected
cycle is recorded once.
"""
known = set(graph)
cycles = []
seen_cycle_keys = set()
# Memoize the best chain starting at each node, but only for nodes proven
# NOT to sit on a cycle — a node on a cycle has no well-defined longest path.
best_from = {}
def dfs(node, stack, on_stack):
if node in on_stack:
# Found a back-edge — record the cycle slice once (rotation-stable).
cycle = stack[stack.index(node):] + [node]
key = frozenset(cycle)
if key not in seen_cycle_keys:
seen_cycle_keys.add(key)
cycles.append(cycle)
return [node] # don't recurse; return a trivial chain
if node in best_from:
return best_from[node]
stack.append(node)
on_stack.add(node)
best = [node]
for target in graph.get(node, []):
if target in known:
sub = dfs(target, stack, on_stack)
if len(sub) + 1 > len(best):
best = [node] + sub
on_stack.discard(node)
stack.pop()
# Only memoize when no cycle touched this subtree's nodes; cheap+safe to
# just skip memoization whenever any cycle exists.
if not cycles:
best_from[node] = best
return best
max_chain = [next(iter(known))] if known else []
for start in graph:
chain = dfs(start, [], set())
if len(chain) > len(max_chain):
max_chain = chain
# Depth is hops, i.e. one less than the number of nodes on the chain.
return max(0, len(max_chain) - 1), max_chain, cycles
def _rel(path):
"""Shorten an absolute path to something readable in the report."""
try:
return os.path.relpath(path)
except ValueError:
return path
def main(argv=None):
parser = argparse.ArgumentParser(
description="CSS performance guardrail: stylesheet size + @import-chain depth. "
"Sizes are raw source bytes; gzip/brotli transfer is much smaller.",
)
parser.add_argument("paths", nargs="+", metavar="DIR-OR-FILE",
help="Files and/or directories to scan (dirs recurse).")
parser.add_argument("--max-kb", type=float, default=50.0,
help="ERROR above this per-file source size in KB (default 50).")
parser.add_argument("--warn-kb", type=float, default=30.0,
help="WARNING above this per-file source size in KB (default 30).")
parser.add_argument("--max-import-depth", type=int, default=2,
help="ERROR when the deepest @import chain is deeper (default 2).")
parser.add_argument("--max-imports", type=int, default=10,
help="ERROR when one file has more @import lines (default 10).")
args = parser.parse_args(argv)
files = find_css_files(args.paths)
if not files:
print("No .css files found.")
return 0
errors = 0
# ── Per-file size + import-count report ─────────────────────────────────
print("Per-file budget:")
print(" " + "-" * 72)
total_kb = 0.0
largest = (None, -1.0)
for path in files:
kb = file_size_kb(path)
total_kb += kb
if kb > largest[1]:
largest = (path, kb)
import_count = len(parse_imports(path))
if kb > args.max_kb:
status = f"ERROR > {args.max_kb:g} KB"
errors += 1
elif kb > args.warn_kb:
status = f"WARN > {args.warn_kb:g} KB"
else:
status = "ok"
extra = f" ({import_count} @import)" if import_count else ""
print(f" {_rel(path):<48} {kb:7.1f} KB {status}{extra}")
if import_count > args.max_imports:
print(f" ERROR — {import_count} @import lines exceeds "
f"--max-imports {args.max_imports} (each @import is a serial request)")
errors += 1
# ── Import graph: deepest chain + cycles ────────────────────────────────
graph = build_graph(files)
depth, chain, cycles = deepest_chain(graph)
print("\nImport graph:")
print(" " + "-" * 72)
if depth == 0 and not cycles:
print(" No @import chains between scanned files.")
else:
if chain and len(chain) > 1:
arrow = "\n -> ".join(_rel(p) for p in chain)
print(f" Deepest chain (depth {depth}):\n {arrow}")
if depth > args.max_import_depth:
print(f" ERROR — chain depth {depth} exceeds --max-import-depth "
f"{args.max_import_depth} (deep @import chains serialize requests)")
errors += 1
for cycle in cycles:
loop = " -> ".join(_rel(p) for p in cycle)
print(f" ERROR — @import cycle: {loop}")
errors += 1
# ── Summary ─────────────────────────────────────────────────────────────
print("\nSummary:")
print(f" CSS files: {len(files)}")
print(f" Total source: {total_kb:.1f} KB")
if largest[0] is not None:
print(f" Largest file: {_rel(largest[0])} ({largest[1]:.1f} KB)")
print(f" Deepest @import: depth {depth}"
+ (f" (limit {args.max_import_depth})" if depth else ""))
print(f"\n{errors} hard budget breach(es).")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())