Skip to main content

max / makenotwork

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