Skip to content

Lint_ds_component

FieldValue
TypeSkill Resource
Source~/.copilot/skills/frontend/scripts/lint_ds_component.sh
DescriptionNot specified

Source Content

#!/usr/bin/env bash
# Lint a @dmwd-io/design-system component against the ds-component house rules
# from SKILL.md: Props interface + JSDoc per prop, CVA variants, cn()
# composition, a sibling .stories.tsx covering the nine UI states, and the
# named-export shape.
#
# Usage:
# lint_ds_component.sh <component-file.tsx>
#
# Example:
# scripts/lint_ds_component.sh src/components/ui/button.tsx
#
# This is a pragmatic grep/awk-based linter, not a full TypeScript parser.
# It is intentionally line-oriented: it works on well-formatted source and
# may miss exotic formatting. Static house-rule checks below need no external
# tool, so they hard-fail (not warn) on violation.
set -uo pipefail
FILE="${1:-}"
if [[ -z "$FILE" ]]; then
echo "Usage: $0 <component-file.tsx>" >&2
exit 2
fi
if [[ ! -f "$FILE" ]]; then
echo "File not found: $FILE" >&2
exit 2
fi
fail=0
warn() { printf '⚠️ %s\n' "$1"; }
error() { printf '❌ %s\n' "$1"; fail=1; }
ok() { printf '✅ %s\n' "$1"; }
DIR="$(cd "$(dirname "$FILE")" && pwd)"
BASE="$(basename "$FILE" .tsx)"
STORIES_FILE="$DIR/$BASE.stories.tsx"
echo "== Props interface + JSDoc per prop =="
# Find the Props interface/type block (interface FooProps { ... } or type FooProps = { ... })
props_block_start=$(grep -nE '^(export )?(interface|type) [A-Za-z0-9_]*Props\b' "$FILE" | head -1 | cut -d: -f1 || true)
if [[ -z "$props_block_start" ]]; then
error "no exported Props interface/type found (expected \`interface <Name>Props\` or \`type <Name>Props\`)"
else
ok "found a Props interface/type declaration at line $props_block_start"
# Extract from the Props declaration to its closing brace (first line that is just "}" or "};" after start)
props_block_end=$(awk -v start="$props_block_start" '
NR < start { next }
NR == start { depth = 0 }
{
for (i = 1; i <= length($0); i++) {
c = substr($0, i, 1)
if (c == "{") depth++
if (c == "}") { depth--; if (depth <= 0 && NR > start) { print NR; exit } }
}
}
' "$FILE")
props_block_end="${props_block_end:-$props_block_start}"
# Collect prop declaration lines: " name: type" or " name?: type" inside the block,
# skipping the interface/type header line and lines that are pure braces.
mapfile -t prop_lines < <(awk -v start="$props_block_start" -v end="$props_block_end" '
NR > start && NR < end {
line = $0
gsub(/^[ \t]+/, "", line)
if (line ~ /^[A-Za-z_][A-Za-z0-9_]*\??[ \t]*:/ ) print NR
}
' "$FILE")
if [[ "${#prop_lines[@]}" -eq 0 ]]; then
warn "Props block found but no individual prop declarations detected (extends-only interface?)"
else
missing_jsdoc=()
for ln in "${prop_lines[@]}"; do
prev_line=$(sed -n "$((ln - 1))p" "$FILE")
# Accept a JSDoc comment ending on the line directly above (single-line /** ... */
# or the closing */ of a multi-line block).
if [[ "$prev_line" =~ \*/[[:space:]]*$ ]]; then
continue
fi
prop_name=$(sed -n "${ln}p" "$FILE" | sed -E 's/^[ \t]*([A-Za-z_][A-Za-z0-9_]*)\??:.*/\1/')
missing_jsdoc+=("$prop_name (line $ln)")
done
if [[ "${#missing_jsdoc[@]}" -eq 0 ]]; then
ok "every prop has a JSDoc comment directly above it"
else
for m in "${missing_jsdoc[@]}"; do
error "prop missing a JSDoc /** ... */ comment directly above it: $m"
done
fi
fi
fi
echo
echo "== CVA variant coverage =="
variant_prop_names=$(grep -oE '\b(variant|size|intent|tone)\??:[ \t]*("[^"]*"|[A-Za-z0-9_]+)([ \t]*\|[ \t]*("[^"]*"|[A-Za-z0-9_]+))*' "$FILE" \
| sed -E 's/\??:.*//' | sort -u || true)
if [[ -z "$variant_prop_names" ]]; then
ok "no variant-like props (variant/size/intent/tone) detected — CVA not required"
else
if grep -qE '\bcva\(' "$FILE"; then
ok "cva( found in file"
dangling=()
while IFS= read -r vp; do
[[ -z "$vp" ]] && continue
if ! grep -qE "^\s*${vp}\s*:" "$FILE"; then
dangling+=("$vp")
fi
done <<< "$variant_prop_names"
# The above check is intentionally loose: it re-scans the whole file for a
# "vp:" key line, which should appear both in the Props type AND inside the
# cva() variants config. If it appears only once, flag as a possible mismatch.
for vp in $variant_prop_names; do
count=$(grep -cE "^\s*${vp}\s*:" "$FILE" || true)
if [[ "$count" -lt 2 ]]; then
error "variant-like prop '$vp' does not appear to have a matching key inside the cva( variants config (house rule: CVA variants must cover variant-like props)"
fi
done
else
error "variant-like prop(s) found ($variant_prop_names) but no cva( usage detected — house rule requires CVA for variant props"
fi
fi
echo
echo "== cn() composition, no manual className concatenation =="
if grep -qE '\bcn\(' "$FILE"; then
ok "cn( found in file"
else
error "no cn( usage found — className composition must go through cn()"
fi
# Flag template-literal className, but not when cn( wraps it on the same line.
bad_template=$(grep -nE 'className=\{`[^`]*\$\{[^}]*\}[^`]*`\}' "$FILE" | grep -vE 'cn\(' || true)
if [[ -n "$bad_template" ]]; then
error "manual template-literal className found (not wrapped in cn()):"
echo "$bad_template"
fi
# Flag string concatenation className={a + b}, but not when cn( wraps it.
bad_concat=$(grep -nE 'className=\{[^}]*\+[^}]*\}' "$FILE" | grep -vE 'cn\(' || true)
if [[ -n "$bad_concat" ]]; then
error "manual string-concatenation className found (not wrapped in cn()):"
echo "$bad_concat"
fi
if [[ -z "$bad_template" && -z "$bad_concat" ]]; then
ok "no manual className concatenation/template-literal patterns found outside cn()"
fi
echo
echo "== sibling .stories.tsx exists =="
if [[ -f "$STORIES_FILE" ]]; then
ok "found sibling stories file: $(basename "$STORIES_FILE")"
else
error "no sibling stories file found — expected $(basename "$STORIES_FILE") in the same directory"
fi
echo
echo "== nine UI states coverage in stories =="
if [[ -f "$STORIES_FILE" ]]; then
states=(default hover focus active disabled loading error empty success)
found_states=()
missing_states=()
for s in "${states[@]}"; do
if grep -qiE "(export const [A-Za-z]*${s}[A-Za-z]*|['\"\`]${s}['\"\`]|:\s*${s}\b)" "$STORIES_FILE"; then
found_states+=("$s")
else
missing_states+=("$s")
fi
done
if [[ "${#found_states[@]}" -eq 0 ]]; then
error "stories file exists but covers NONE of the nine UI states (default/hover/focus/active/disabled/loading/error/empty/success)"
else
ok "states covered: ${found_states[*]}"
if [[ "${#missing_states[@]}" -gt 0 ]]; then
for m in "${missing_states[@]}"; do
warn "UI state not found in stories: $m"
done
fi
fi
else
warn "skipped nine-UI-states check — no stories file to inspect"
fi
echo
echo "== export shape (named export, not default-only) =="
component_name="$(echo "$BASE" | awk -F'-' '{ out=""; for (i=1;i<=NF;i++) { s=$i; out = out toupper(substr(s,1,1)) substr(s,2) } print out }')"
has_named_export=$(grep -cE "^export (function|const) ${component_name}\b" "$FILE" || true)
has_default_export=$(grep -cE '^export default\b' "$FILE" || true)
if [[ "$has_named_export" -gt 0 ]]; then
ok "found named export matching component name: $component_name"
if grep -qE '^export function [A-Za-z0-9_]+\(' "$FILE"; then
:
elif grep -qE "^export const ${component_name}\s*=" "$FILE"; then
warn "component is exported as \`export const ${component_name} = ...\` — SKILL.md requires \`export function\`, never arrow-function components"
fi
else
error "no named export matching component name '$component_name' found (\`export function ${component_name}\`) — SKILL.md requires named exports, not default-only"
fi
if [[ "$has_default_export" -gt 0 && "$has_named_export" -eq 0 ]]; then
error "component only has a default export — SKILL.md requires a named export"
fi
echo
if [[ "$fail" -eq 0 ]]; then
echo "All checks passed."
else
echo "One or more checks failed — see ❌ lines above."
fi
exit "$fail"