Skip to main content

max / makenotwork

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