nandi/frqpublic Fork 0
8c38fb8bbe192d09a2f585404b1a38d8f309d0ae
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

check-common.py · 264 lines · 9.8 KBPython Blame HistoryRaw
Check what common/ may contain, on every push 86c2e1b nandi 7d ago1#!/usr/bin/env python3
2"""What may appear in common/, checked.
3
4common/ is compiled twice — by jolt and by ClojureDart — and only one of those
5happens on the way to a desktop build. So the way this breaks is always the
6same: shared code reaches for something only the JVM has, everything the
7author ran still works, and the phone stops compiling at a namespace nobody
8touched. `Math/ceil` in the compose bar was the third time.
9
10The rule being enforced is CLAUDE.md's, unchanged: code under common/ may not
11require jolt.*, glimmer* or a dart: library, and if it needs the host it asks
12frq.io. This only sees the first half of that — a host call has to be named to
13be caught, and the list below is the ones that have actually turned up.
14
15Two things are stripped before anything is matched, and both are the
16difference between a check people keep and one they route around.
17
18Comments and strings, because the two namespaces that got this right explain
19themselves by naming the very thing they avoid — a checker that fires on
20`frq.clock`'s docstring teaches people to stop reading it.
21
22And the reader-conditional branches ClojureDart does not read. A
23`#?(:jolt [glimmer.ratom ...])` is not a violation, it is the sanctioned way
24to say "desktop only": the cljd compiler never sees inside it. So this reads
25the conditionals the way the compiler does — first branch whose feature is on,
26with :cljd, :clj and :default on — and looks only at what is left.
27"""
28import re
29import sys
30from pathlib import Path
31
32# Java that has no ClojureDart counterpart. Spelled as whole tokens: `Math/`
33# catches java.lang.Math without catching a namespace alias ending in "math".
34INTEROP = re.compile(
35 r"""(?<![\w.-])(?:Math|Integer|Long|Double|Float|Short|Byte|Character
36 |Boolean|System|Thread|Class|Arrays|Collections|StringBuilder
37 |Instant|Duration|LocalDate|LocalDateTime|ZoneId|ZonedDateTime
38 |File|Files|Paths|Base64|Charset|StandardCharsets)/""",
39 re.VERBOSE,
40)
41# (String. x), (StringBuilder.) — constructor interop, whatever the class.
42CTOR = re.compile(r"\((?:[A-Z]\w*\.)(?=[\s)])")
43JAVA_PKG = re.compile(r"(?<![\w.-])java\.[\w.]+")
44# .getBytes and friends: methods on a JVM object, by name.
45METHODS = re.compile(r"(?<![\w.-])\.(?:getBytes|toUpperCase|toLowerCase|intValue|longValue|doubleValue|charAt)(?![\w-])")
46# The requires CLAUDE.md rules out by name.
47BAD_REQUIRE = re.compile(r"(?<![\w.-])(?:jolt\.[\w.]+|glimmer[\w.]*|\"dart:[\w.]+\")")
48
49CHECKS = [
50 (INTEROP, "Java class only the JVM has"),
51 (CTOR, "Java constructor interop"),
52 (JAVA_PKG, "a java.* package"),
53 (METHODS, "a method only a JVM object has"),
54 (BAD_REQUIRE, "a backend common/ may not name"),
55]
56
57
58def strip(src):
59 """Comments and string literals blanked, newlines kept so lines still count.
60
61 Clojure's character literals are why this is a scanner and not a regex:
62 `\\"` is the double-quote character, and anything counting quotes without
63 knowing that starts reading code as string at the first one it meets.
64 """
65 out, i, n = [], 0, len(src)
66 while i < n:
67 c = src[i]
68 if c == "\\" and i + 1 < n: # character literal — consume both
69 out.append(" " if src[i + 1] != "\n" else " \n")
70 i += 2
71 elif c == ";":
72 while i < n and src[i] != "\n":
73 out.append(" ")
74 i += 1
75 elif c == '"':
76 out.append(" ")
77 i += 1
78 while i < n:
79 if src[i] == "\\" and i + 1 < n:
80 out.append(" " if src[i + 1] != "\n" else " \n")
81 i += 2
82 continue
83 if src[i] == '"':
84 out.append(" ")
85 i += 1
86 break
87 out.append("\n" if src[i] == "\n" else " ")
88 i += 1
89 else:
90 out.append(c)
91 i += 1
92 return "".join(out)
93
94
95ACTIVE = ("cljd", "clj", "default")
96
97
98def _blank(src, lo, hi):
99 """Characters lo..hi replaced with spaces, newlines kept.
100
101 Positions are preserved rather than the text rewritten, so a line number
102 in a message is the line number in the file.
103 """
104 return src[:lo] + "".join("\n" if c == "\n" else " " for c in src[lo:hi]) + src[hi:]
105
106
107def _close(src, i):
108 """Index just past the list opening at src[i], or None if unbalanced."""
109 depth = 0
110 while i < len(src):
111 if src[i] == "(":
112 depth += 1
113 elif src[i] == ")":
114 depth -= 1
115 if depth == 0:
116 return i + 1
117 i += 1
118 return None
119
120
121def _forms(src, i, end):
122 """(start, stop) of each top-level form in src[i:end]."""
123 out = []
124 while i < end:
125 if src[i].isspace():
126 i += 1
127 continue
128 if src[i] == "(" or src[i] == "[":
129 shut = {"(": ")", "[": "]"}[src[i]]
130 depth, j = 0, i
131 while j < end:
132 if src[j] in "([":
133 depth += 1
134 elif src[j] in ")]":
135 depth -= 1
136 if depth == 0:
137 j += 1
138 break
139 j += 1
140 out.append((i, j))
141 i = j
142 else:
143 j = i
144 while j < end and not src[j].isspace() and src[j] not in "()[]{}":
145 j += 1
146 out.append((i, j))
147 i = j
148 return out
149
150
151def select(src):
152 """Reader-conditional branches ClojureDart never reads, blanked out.
153
154 Splicing (`#?@`) and plain (`#?`) are the same job here: what matters is
155 which branch survives, not how it is spliced in.
156 """
157 i = 0
158 while True:
159 hit = src.find("#?", i)
160 if hit < 0:
161 return src
162 open_paren = hit + (3 if src[hit:hit + 3] == "#?@" else 2)
163 while open_paren < len(src) and src[open_paren].isspace():
164 open_paren += 1
165 if open_paren >= len(src) or src[open_paren] != "(":
166 i = hit + 2
167 continue
168 end = _close(src, open_paren)
169 if end is None:
170 return src
171 forms = _forms(src, open_paren + 1, end - 1)
172 # keyword, form, keyword, form — take the first keyword that is on.
173 keep = None
174 for k in range(0, len(forms) - 1, 2):
175 kw = src[forms[k][0]:forms[k][1]]
176 if kw.lstrip(":") in ACTIVE and keep is None:
177 keep = forms[k + 1]
178 # Blank everything in the conditional except the branch that survives,
179 # including the `#?` itself so it is not rescanned.
180 cut = end if keep is None else keep[0]
181 src = _blank(src, hit, cut)
182 if keep is not None:
183 src = _blank(src, keep[1], end)
184 i = hit + 2
185
186
Repaint for every cell, follow the conversation, and send once 8ead4ea nandi 7d ago187def cells_enumerated(root):
188 """frq.cells/all-cells against the cells actually defined.
189
190 The phone watches what this list names and nothing else, so a cell missing
191 from it is a control that flips state and repaints nothing — which is a
192 bug that looks like a dead button and gets reported as one.
193 """
194 path = root / "frq" / "cells.cljc"
195 if not path.exists():
196 return []
197 src = strip(path.read_text())
198 defined = re.findall(r"\(defonce ([\w?!*<>+-]+) \(atom ", src)
199 body = src[src.index("(defn all-cells"):] if "(defn all-cells" in src else ""
200 listed = set(re.findall(r"[\w?!*<>+-]+", body[body.index("[", body.index("[]") + 2):])) if body else set()
201 missing = [d for d in defined if d not in listed]
202 return [
203 (path, 0, d, "defined but missing from frq.cells/all-cells — the phone will not repaint for it")
204 for d in missing
205 ]
206
207
Give every entry a key, and fail the build for one without 0ee5786 nandi 7d ago208def entries_keyed(root):
209 """Every `[:entry ...]` in a shared screen carries a `:key`.
210
211 The phone keeps one TextEditingController per key, and an entry without
212 one falls back to a single shared controller — so two unkeyed entries on a
213 screen are the same controller, and whichever renders last wins. It has
214 cost two bugs: the connect screen's host field showed the port, and the
215 emoji search would not hold more than one character, because the composer
216 rendered after it with an empty draft and wiped it.
217
218 glimmer wants the key too, to match children across a render. Nothing
219 enforced it, which is why it kept coming back.
220 """
221 bad = []
222 for path in sorted(root.rglob("*.cljc")):
223 src = strip(path.read_text())
224 for i, line in enumerate(src.split("\n")):
225 if "[:entry" not in line:
226 continue
227 # the props map may run over a few lines; :key belongs in it
228 blob = "\n".join(src.split("\n")[i:i + 8])
229 if ":key" not in blob:
230 bad.append((path, i + 1, ":entry",
231 "written without a :key — unkeyed entries share one "
232 "text controller on the phone"))
233 return bad
234
235
Check what common/ may contain, on every push 86c2e1b nandi 7d ago236def main():
237 root = Path(sys.argv[1] if len(sys.argv) > 1 else "common")
238 bad = []
239 for path in sorted(root.rglob("*.cljc")):
240 source = select(strip(path.read_text()))
241 for lineno, line in enumerate(source.splitlines(), 1):
242 for pattern, why in CHECKS:
243 m = pattern.search(line)
244 if m:
245 bad.append((path, lineno, m.group(0).strip(), why))
Repaint for every cell, follow the conversation, and send once 8ead4ea nandi 7d ago246 bad += cells_enumerated(root)
Give every entry a key, and fail the build for one without 0ee5786 nandi 7d ago247 bad += entries_keyed(root)
Check what common/ may contain, on every push 86c2e1b nandi 7d ago248 for path, lineno, tok, why in bad:
Repaint for every cell, follow the conversation, and send once 8ead4ea nandi 7d ago249 where = f"{path}:{lineno}" if lineno else str(path)
250 print(f"{where}: {tok!r} is {why}", file=sys.stderr)
Check what common/ may contain, on every push 86c2e1b nandi 7d ago251 if bad:
252 print(
Repaint for every cell, follow the conversation, and send once 8ead4ea nandi 7d ago253 f"\n{len(bad)} thing(s) wrong under {root}/, which both backends compile.\n"
254 "If it needs the host, ask frq.io and add the call to both\n"
255 "implementations. See CLAUDE.md.",
Check what common/ may contain, on every push 86c2e1b nandi 7d ago256 file=sys.stderr,
257 )
258 return 1
259 print(f"{root}/ is clean: nothing here that only one backend has.")
260 return 0
261
262
263if __name__ == "__main__":
264 sys.exit(main())