# =============================================================================
# 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.
# 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
# --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
# 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.
# =============================================================================
# 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 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.
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.
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]
if name.endswith(".css"):
found.add(os.path.abspath(os.path.join(root, name)))
print(f"warning: '{path}' is neither a file nor a directory; skipped",
"""Raw source size in KB (1 KB = 1024 bytes)."""
return os.path.getsize(path) / 1024.0
"""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).
with open(path, "r", encoding="utf-8") as fh:
text = _COMMENT_RE.sub("", text)
base_dir = os.path.dirname(path)
for match in _IMPORT_RE.finditer(text):
target = match.group(1) or match.group(2) or match.group(3)
# Remote/protocol/data targets aren't local files — leave them absolute.
if re.match(r"^(?:[a-z]+:)?//", target) or target.startswith("data:"):
targets.append(os.path.abspath(os.path.join(base_dir, target)))
"""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
# 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.
def dfs(node, stack, on_stack):
# Found a back-edge — record the cycle slice once (rotation-stable).
cycle = stack[stack.index(node):] + [node]
if key not in seen_cycle_keys:
return [node] # don't recurse; return a trivial chain
for target in graph.get(node, []):
sub = dfs(target, stack, on_stack)
if len(sub) + 1 > len(best):
# Only memoize when no cycle touched this subtree's nodes; cheap+safe to
# just skip memoization whenever any cycle exists.
max_chain = [next(iter(known))] if known else []
chain = dfs(start, [], set())
if len(chain) > len(max_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
"""Shorten an absolute path to something readable in the report."""
return os.path.relpath(path)
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)
print("No .css files found.")
# ── Per-file size + import-count report ─────────────────────────────────
print("Per-file budget:")
import_count = len(parse_imports(path))
status = f"ERROR > {args.max_kb:g} KB"
status = f"WARN > {args.warn_kb:g} KB"
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)")
# ── Import graph: deepest chain + cycles ────────────────────────────────
graph = build_graph(files)
depth, chain, cycles = deepest_chain(graph)
if depth == 0 and not cycles:
print(" No @import chains between scanned files.")
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)")
loop = " -> ".join(_rel(p) for p in cycle)
print(f" ERROR — @import cycle: {loop}")
# ── Summary ─────────────────────────────────────────────────────────────
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__":