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