Skip to content

Check_colocation

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

Source Content

#!/usr/bin/env python3
# =============================================================================
# check_colocation.py — verify every UI component has a co-located stylesheet
# -----------------------------------------------------------------------------
# Why this exists: component styles live next to the component, and every
# component folder owns a .css file even when it is empty — that empty file is
# the one obvious home for the component's styles, so one-off rules never leak
# into global sheets. This tool walks a tree, finds the visual components, and
# reports any that have no sibling stylesheet.
#
# Stdlib only (Python 3) — no pip installs, so it runs anywhere and in CI.
#
# What counts as a VISUAL COMPONENT (deliberately conservative — we would
# rather miss an edge case than nag about a file that renders nothing):
# * The basename is PascalCase (Button, NavBar) — the universal convention
# for a component, which keeps utilities, hooks, and configs out of scope.
# * .vue / .svelte / .astro single-file components always count: the file
# format IS the component.
# * .tsx / .jsx count only with EVIDENCE of rendering markup — a JSX tag
# (`<div`, `<Something`), a `className=` / `class=` attribute, or a
# default/named export of a PascalCase function or const. A PascalCase
# .tsx with none of these (a type-only or constants file) is skipped.
#
# What is EXCLUDED (never a component, regardless of name):
# * tests and stories: *.test.*, *.spec.*, *.stories.*
# * type decls and barrels: *.d.ts, index.*
# * config: *.config.*
# * hooks: files whose basename starts with "use" (useThing) — even though a
# hook can be PascalCase-adjacent, the "use" prefix marks it as non-visual.
# * anything with no JSX / markup evidence at all.
# * always-skipped dirs: node_modules, dist, build, .git (plus any --ignore).
#
# What SATISFIES a component (a co-located stylesheet exists):
# * a same-basename sheet — Button.css, Button.module.css, Button.scss; OR
# * ANY .css / .scss in the SAME directory. Per the co-location rule every
# component folder has a .css even if empty, so a dir-level sheet counts as
# the home for every component beside it.
#
# Usage:
# python3 check_colocation.py <dir> [more dirs] [--ext css,scss]
# [--strict] [--ignore GLOB ...]
#
# <dir>... One or more roots to scan (recursively).
# --ext LIST Comma-separated stylesheet extensions that satisfy a
# component (default "css,scss"). Leading dots optional.
# --strict Report missing stylesheets as ERROR instead of WARNING,
# so the run fails (exit 1). Default is advisory (exit 0).
# --ignore GLOB Skip files/dirs whose path matches this glob. Repeatable.
# node_modules, dist, build, and .git are always skipped.
#
# Exit: 1 only if there is at least one ERROR (i.e. a miss under --strict),
# else 0. WARNING lines never change the exit code.
# =============================================================================
import argparse
import fnmatch
import os
import re
import sys
# Directories we never descend into — build output and vendored deps would
# drown the signal and are not ours to fix.
ALWAYS_SKIP_DIRS = {"node_modules", "dist", "build", ".git"}
# Component source extensions we inspect. The first three are unconditionally
# components; .tsx/.jsx need rendering evidence (see file_is_component).
SFC_EXTS = {".vue", ".svelte", ".astro"} # single-file: format == component
JSX_EXTS = {".tsx", ".jsx"} # need markup evidence to qualify
# A basename is PascalCase when it starts uppercase and is letters/digits only
# (no separators) — Button, NavBar, H1. This is the component-name convention.
_PASCAL_RE = re.compile(r"^[A-Z][A-Za-z0-9]*$")
# Evidence that a .tsx/.jsx file actually renders markup, any one of:
# * an opening tag for an element or component: <div, <Button, <> (fragment)
# * a className= / class= attribute
# * a default or named export of a PascalCase function or const
_JSX_TAG_RE = re.compile(r"<([A-Za-z][A-Za-z0-9]*|>)") # <div <Button <>
_CLASS_ATTR_RE = re.compile(r"\b(className|class)\s*=")
# Note the trailing [a-z]: a true component name has a lowercase letter
# (Button, NavBar), which excludes SCREAMING_CASE constants (DEFAULT, MAX).
_EXPORT_COMPONENT_RE = re.compile(
r"export\s+(?:default\s+)?(?:async\s+)?(?:function|const)\s+([A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*)"
)
# Filename markers that exclude a file before we ever look inside it.
_EXCLUDE_SUFFIX_PARTS = (".test.", ".spec.", ".stories.")
def is_pascal_case(basename):
"""True if the basename (no extension) is PascalCase (Button, NavBar)."""
return bool(_PASCAL_RE.match(basename))
def is_excluded_name(filename):
"""True if the filename is a kind we never treat as a visual component."""
lower = filename.lower()
if lower.endswith(".d.ts"):
return True # type declarations
if any(part in lower for part in _EXCLUDE_SUFFIX_PARTS):
return True # tests, specs, stories
stem = filename.rsplit(".", 1)[0]
if stem.lower() == "index":
return True # barrel re-export
if ".config." in lower:
return True # *.config.*
# Hooks: a "use"-prefixed basename (useThing, useStore) is logic, not UI.
if stem.startswith("use") and len(stem) > 3 and stem[3].isupper():
return True
return False
def has_markup_evidence(text):
"""True if .tsx/.jsx text shows it renders markup (see header heuristic)."""
return bool(
_JSX_TAG_RE.search(text)
or _CLASS_ATTR_RE.search(text)
or _EXPORT_COMPONENT_RE.search(text)
)
def file_is_component(path):
"""Classify a single file. Return True if it is a visual component.
Conservative on purpose: PascalCase gate first, then exclusions, then
format/markup evidence. A file that is not clearly a component is skipped.
"""
filename = os.path.basename(path)
stem, ext = os.path.splitext(filename)
ext = ext.lower()
if ext not in SFC_EXTS and ext not in JSX_EXTS:
return False
if is_excluded_name(filename):
return False
if not is_pascal_case(stem):
# .vue/.svelte/.astro are components by format, but a non-PascalCase
# basename (e.g. app.vue) is a page/shell, not a reusable component —
# leave it out of this co-location check rather than guess.
return False
# Single-file component formats: the format itself is the evidence.
if ext in SFC_EXTS:
return True
# .tsx/.jsx: require rendering evidence so type-only or constants files
# that happen to be PascalCase do not get flagged.
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
text = fh.read()
except OSError:
return False
return has_markup_evidence(text)
def has_colocated_stylesheet(path, dir_sheets):
"""True if the component at `path` has a co-located stylesheet.
`dir_sheets` is the list of stylesheet filenames already found in the
component's directory. Any one of them satisfies the component — a
same-basename sheet (Button.css) and a dir-level sheet are the same thing
here, because every component folder is expected to carry one .css even
when empty. So the presence of ANY stylesheet beside the component is the
whole test.
"""
return bool(dir_sheets)
def _matches_any(path, patterns):
"""True if the path (or its basename) matches any fnmatch glob."""
base = os.path.basename(path)
return any(fnmatch.fnmatch(path, p) or fnmatch.fnmatch(base, p) for p in patterns)
def scan_dir(root, sheet_exts, ignore_globs):
"""Walk one root and yield (component_path, satisfied: bool) for each find."""
sheet_exts = {e if e.startswith(".") else "." + e for e in sheet_exts}
for dirpath, dirnames, filenames in os.walk(root):
# Prune always-skip and --ignore dirs in place so os.walk never enters.
dirnames[:] = [
d for d in dirnames
if d not in ALWAYS_SKIP_DIRS
and not _matches_any(os.path.join(dirpath, d), ignore_globs)
]
# Stylesheets present in THIS directory — the shared home for any
# component sitting beside them.
dir_sheets = [
f for f in filenames
if os.path.splitext(f)[1].lower() in sheet_exts
]
for filename in sorted(filenames):
path = os.path.join(dirpath, filename)
if _matches_any(path, ignore_globs):
continue
if not file_is_component(path):
continue
satisfied = has_colocated_stylesheet(path, dir_sheets)
yield path, satisfied
def main(argv=None):
parser = argparse.ArgumentParser(
description="Verify every visual component has a co-located stylesheet.",
epilog=(
"A visual component is a PascalCase .tsx/.jsx with rendering "
"evidence (a JSX tag, className=, or an exported PascalCase "
"function/const), or any PascalCase .vue/.svelte/.astro file. "
"Tests, stories, *.d.ts, index barrels, *.config.*, and use* "
"hooks are skipped. A component is satisfied by a same-basename "
".css/.scss or any stylesheet in the same directory — every "
"component folder should carry a .css even if empty."
),
)
parser.add_argument("dirs", nargs="+", help="One or more directories to scan.")
parser.add_argument("--ext", default="css,scss",
help="Comma-separated stylesheet extensions (default css,scss).")
parser.add_argument("--strict", action="store_true",
help="Report misses as ERROR (exit 1) instead of WARNING.")
parser.add_argument("--ignore", action="append", default=[], metavar="GLOB",
help="Skip paths matching this glob. Repeatable. "
"node_modules, dist, build, .git are always skipped.")
args = parser.parse_args(argv)
sheet_exts = [e.strip() for e in args.ext.split(",") if e.strip()]
if not sheet_exts:
print("error: --ext must list at least one extension", file=sys.stderr)
return 2
severity = "ERROR" if args.strict else "WARNING"
total = 0
missing = 0
for root in args.dirs:
if not os.path.isdir(root):
print(f"error: not a directory: {root}", file=sys.stderr)
return 2
for path, satisfied in scan_dir(root, sheet_exts, args.ignore):
total += 1
if not satisfied:
missing += 1
basename = os.path.splitext(os.path.basename(path))[0]
print(
f"{severity} {path}: visual component with no co-located "
f"stylesheet (add {basename}.css, even empty)"
)
print(
f"\n{total} component(s) found; {missing} missing co-located CSS."
)
# Exit 1 only when a miss is an ERROR — i.e. under --strict.
return 1 if (missing and args.strict) else 0
if __name__ == "__main__":
sys.exit(main())