Skip to main content

max / makenotwork

8.2 KB · 204 lines History Blame Raw
1 #!/bin/bash
2 # Frontend design-system lint guards for the MNW server.
3 #
4 # The server had no frontend lint at all, which is how 64 local rules
5 # re-specifying a generated primitive accumulated without anything noticing,
6 # and how two per-page sheets spent months referencing custom properties that
7 # had been deleted. Both classes are mechanical to detect, so they are.
8 #
9 # See docs/design-system.md. Exit 0 = clean, non-zero = violations (file:line).
10
11 set -u
12 ROOT="$(cd "$(dirname "$0")/.." && pwd)"
13 STATIC="$ROOT/static"
14 # The site's own sheets. The generated ones (geometry.css, layout.css,
15 # typography.css) are makeover's output and are never linted; they are the
16 # standard, not the code. They are still READ, because they are where the
17 # tokens the site's sheets spend are defined.
18 SITE_SHEETS="$STATIC/style.css $STATIC/wizard.css $STATIC/media-player.css"
19 GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css $STATIC/typography.css"
20
21 violations=0
22
23 report() {
24 local rule="$1"; shift
25 local msg="$1"; shift
26 if [ -n "$*" ]; then
27 echo
28 echo "[$rule] $msg"
29 echo "$*"
30 violations=$((violations + 1))
31 fi
32 }
33
34 # 1. No local rule may re-specify a property the generated sheet already sets
35 # for the same primitive.
36 #
37 # Unlayered CSS used to beat @layer makeover by construction, so the
38 # generated sheet lost every contest it entered; the site said .card and
39 # then told itself what a card is. style.css is in the `components` layer
40 # now, which is still ahead of `makeover`, so the contest is stated rather
41 # than accidental but the local rule still wins it. This gate is what makes
42 # winning deliberate.
43 #
44 # The primitives and their properties are read out of layout.css at lint
45 # time rather than listed here, so regenerating makeover updates the rule.
46 #
47 # A survivor carries `/* respec-ok: <reason naming what the generated sheet
48 # cannot express> */` inside the rule body. "Different from what we had" is
49 # the expected outcome of adopting a design system and is not a reason.
50 hits=$(python3 - "$GENERATED_SHEETS" "$SITE_SHEETS" <<'PY'
51 import re, sys
52
53 def rules(path):
54 """(selector, body, line) for every rule with declarations, @media included."""
55 raw = open(path).read()
56 # Blank comments out rather than deleting them, so line numbers survive.
57 src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), raw, flags=re.S)
58 out, stack, cur, i = [], [], '', 0
59 while i < len(src):
60 c = src[i]
61 if c == '{':
62 stack.append((cur.strip(), i)); cur = ''
63 elif c == '}':
64 if stack:
65 sel, start = stack.pop()
66 body = src[start + 1:i]
67 if '{' not in body:
68 out.append((sel, body, raw[start + 1:i], src.count('\n', 0, start) + 1))
69 cur = ''
70 else:
71 cur += c
72 i += 1
73 return out
74
75 def props(body):
76 return {d.split(':')[0].strip() for d in body.split(';') if ':' in d and d.split(':')[0].strip().startswith(('-', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'))}
77
78 def keys(sel):
79 """(class, state) per compound in the selector, so a rule is only ever
80 compared against the generated rule for the SAME state. Unioning the
81 states instead reports .card { color } against the generated
82 .card:disabled, which is not a contest: they never both apply."""
83 out = set()
84 for compound in re.split(r'[\s>+~,]+', sel):
85 m = re.match(r'\.([a-z][a-z0-9-]*)', compound)
86 if not m:
87 continue
88 # Everything else in the compound is state: further classes as much as
89 # pseudo-classes. Dropping the extra classes keys .tab.chosen as plain
90 # .tab and lends the base primitive every property its modifiers set.
91 state = ''.join(sorted(
92 re.findall(r'(::?[a-z-]+(?:\([^)]*\))?|\[[^\]]*\])', compound)
93 + re.findall(r'\.[a-z][a-z0-9-]*', compound)[1:]))
94 out.add((m.group(1), state))
95 return out
96
97 generated = {}
98 for path in sys.argv[1].split():
99 for sel, body, _raw, _line in rules(path):
100 for k in keys(sel):
101 generated.setdefault(k, set()).update(props(body))
102
103 bad = []
104 for path in sys.argv[2].split():
105 for sel, body, raw_body, line in rules(path):
106 if 'respec-ok:' in raw_body:
107 continue
108 for cls, state in keys(sel):
109 clash = generated.get((cls, state), set()) & props(body)
110 if clash:
111 short = ' '.join(sel.split())[:70]
112 bad.append(f" {path.split('/')[-1]}:{line} {short}\n"
113 f" re-specifies .{cls}{state}: {', '.join(sorted(clash))}")
114 for b in sorted(set(bad)):
115 print(b)
116 PY
117 )
118 report "no-primitive-respec" \
119 "A local rule re-specifies what the generated sheet already sets. Delete it and take the generated look, or add /* respec-ok: <reason> */ naming what the generated sheet cannot express." \
120 "$hits"
121
122 # 2. Every var(--token) must resolve to a property something defines.
123 #
124 # An undefined custom property is invalid at computed-value time, which
125 # drops the WHOLE declaration rather than falling back — so this fails
126 # silently and looks like a layout bug months later. 770f38c0 deleted the
127 # brand-alias block and converted style.css but not the two per-page
128 # sheets; both spent from then until 2026-08-10 dropping every colour they
129 # declared. A var() with a fallback is fine and is skipped: those are the
130 # properties JS sets at runtime.
131 hits=$(python3 - "$GENERATED_SHEETS $SITE_SHEETS" <<'PY'
132 import re, sys
133
134 paths = sys.argv[1].split()
135 defined = set()
136 for p in paths:
137 defined |= set(re.findall(r'^\s*(--[a-z0-9-]+)\s*:', open(p).read(), re.M))
138
139 for p in paths:
140 for n, line in enumerate(open(p), 1):
141 for m in re.finditer(r'var\(\s*(--[a-z0-9-]+)\s*([,)])', line):
142 if m.group(2) == ',':
143 continue # has a fallback: JS-set at runtime
144 if m.group(1) not in defined:
145 print(f" {p.split('/')[-1]}:{n} {m.group(1)} is used but never defined")
146 PY
147 )
148 report "no-undefined-token" \
149 "var(--token) with no definition and no fallback. The whole declaration is dropped at computed-value time." \
150 "$hits"
151
152 # 3. No raw colour literal outside the two blocks documented to hold them.
153 #
154 # A literal picked against parchment and then applied to all 31 themes a
155 # creator can select is the defect the bevel pair and then the elevation
156 # intent were each introduced to undo, and it grew back both times. The
157 # intent :root and the APP-LOCAL CONSTANTS block are where a literal is
158 # allowed to live; everywhere else wants a token.
159 hits=$(python3 - "$SITE_SHEETS" <<'PY'
160 import re, sys
161
162 LITERAL = re.compile(r'(#[0-9a-fA-F]{3,8}\b|rgba?\(\s*[0-9])')
163 for p in sys.argv[1].split():
164 src = open(p).read()
165 src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), src, flags=re.S)
166 for n, line in enumerate(src.split('\n'), 1):
167 if not LITERAL.search(line):
168 continue
169 decl = line.split(':')[0].strip()
170 # A literal is allowed as the value of a custom property: that is what
171 # a token IS. It is not allowed as the value of anything else.
172 if decl.startswith('--'):
173 continue
174 print(f" {p.split('/')[-1]}:{n} {line.strip()[:80]}")
175 PY
176 )
177 report "no-colour-literal" \
178 "Raw colour literal outside the intent :root and the APP-LOCAL CONSTANTS block. Use a token so it re-themes." \
179 "$hits"
180
181 # 4. The site's sheets stay in the components layer.
182 #
183 # An unlayered sheet outranks every named layer whatever the specificity,
184 # so one un-wrapped file silently takes back every contest the layer order
185 # was declared to settle. Cheap to check, so check it.
186 hits=""
187 for sheet in $SITE_SHEETS; do
188 if ! grep -qE '^@layer components \{' "$sheet"; then
189 hits="$hits ${sheet##*/} does not open a components layer"$'\n'
190 fi
191 done
192 report "layer-adoption" \
193 "A site sheet is unlayered, so it beats every named layer regardless of specificity." \
194 "$(echo "$hits" | sed '/^$/d')"
195
196 if [ $violations -eq 0 ]; then
197 echo "frontend lint: clean"
198 exit 0
199 else
200 echo
201 echo "frontend lint: $violations rule(s) failed"
202 exit 1
203 fi
204