nandi/frqpublic Fork 0
86c2e1b
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 what common/ may contain, on every push

common/ is compiled twice and only one of those is on the way to anything
run day to day, so shared code reaching for the JVM breaks nothing the
author can see and the phone stops compiling at a namespace nobody touched.
That has now happened three times — Integer/parseInt, .getBytes, Math/ceil
— and each was found by building an APK, which is the slowest way there is
to learn it.

Reading the source is enough, so this needs no toolchain and no builder:
python and a checkout, on every push.

Two things it strips first, and both decide whether the check is one people
keep or one they route around. Comments and strings, because `frq.clock` and
`frq.io` explain themselves by naming the very thing they avoid, and a
checker that fires on their docstrings teaches people to stop reading them.
And the reader-conditional branches ClojureDart never reads, the way the
compiler picks them — a `#?(:jolt [glimmer.ratom ...])` is not a violation,
it is the sanctioned way to say desktop-only, and flagging it would flag
exactly the code that got this right.

It catches the Math/ceil that prompted it, at the right line, and stays
quiet on the tree as it stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T00:22:52-07:00 Browse files
86c2e1b parent: 2085edc
modified .gitlab-ci.yml +22 -5
@@ -1,4 +1,5 @@
1-# One job, and it only ever runs on a schedule.
1+# Two jobs: one that checks what common/ is allowed to contain on every push,
2+# and one that re-resolves the jolt-native input on a schedule.
23 #
34 # jolt-native publishes its Android objects under a "latest" alias on every
45 # default-branch build, and frq takes them as a flake input. A flake input is
@@ -7,10 +8,26 @@
78 # this does it for the repository, so main's flake.lock names a recent build
89 # rather than whichever one it was first locked against.
910 #
10-# Nothing is built here. The job resolves one input, and if that moved, commits
11-# the lock file. Whether the new objects actually work is what an APK build
12-# answers, and that is deliberately not this job's business.
13-stages: [update]
11+# Nothing is built by either. The update job resolves one input, and if that
12+# moved, commits the lock file — whether the new objects actually work is what
13+# an APK build answers, and that is deliberately not this job's business. The
14+# check reads source and no more. So this file needs no toolchain at all, which
15+# is what keeps it honest about running on every push.
16+stages: [check, update]
17+
18+# common/ is compiled by jolt AND by ClojureDart, and only the jolt half is on
19+# the way to anything anyone runs day to day. So shared code reaching for the
20+# JVM breaks nothing the author can see, and the phone stops compiling at a
21+# namespace nobody touched — `Math/ceil` in the compose bar was the third time.
22+# Reading the source is enough to catch it, which is why this needs no toolchain
23+# and no builder: python and a checkout, a few seconds, on every push.
24+check-common:
25+ stage: check
26+ image: python:3-alpine
27+ rules:
28+ - if: $CI_PIPELINE_SOURCE != "schedule"
29+ script:
30+ - python3 tools/check-common.py common
1431
1532 update-jolt-native:
1633 stage: update
@@ -1,4 +1,5 @@
1-# One job, and it only ever runs on a schedule.1+# Two jobs: one that checks what common/ is allowed to contain on every push,
2+# and one that re-resolves the jolt-native input on a schedule.
2 #3 #
3 # jolt-native publishes its Android objects under a "latest" alias on every4 # jolt-native publishes its Android objects under a "latest" alias on every
4 # default-branch build, and frq takes them as a flake input. A flake input is5 # default-branch build, and frq takes them as a flake input. A flake input is
@@ -7,10 +8,26 @@
7 # this does it for the repository, so main's flake.lock names a recent build8 # this does it for the repository, so main's flake.lock names a recent build
8 # rather than whichever one it was first locked against.9 # rather than whichever one it was first locked against.
9 #10 #
10-# Nothing is built here. The job resolves one input, and if that moved, commits11+# Nothing is built by either. The update job resolves one input, and if that
11-# the lock file. Whether the new objects actually work is what an APK build12+# moved, commits the lock file — whether the new objects actually work is what
12-# answers, and that is deliberately not this job's business.13+# an APK build answers, and that is deliberately not this job's business. The
13-stages: [update]14+# check reads source and no more. So this file needs no toolchain at all, which
15+# is what keeps it honest about running on every push.
16+stages: [check, update]
17+
18+# common/ is compiled by jolt AND by ClojureDart, and only the jolt half is on
19+# the way to anything anyone runs day to day. So shared code reaching for the
20+# JVM breaks nothing the author can see, and the phone stops compiling at a
21+# namespace nobody touched — `Math/ceil` in the compose bar was the third time.
22+# Reading the source is enough to catch it, which is why this needs no toolchain
23+# and no builder: python and a checkout, a few seconds, on every push.
24+check-common:
25+ stage: check
26+ image: python:3-alpine
27+ rules:
28+ - if: $CI_PIPELINE_SOURCE != "schedule"
29+ script:
30+ - python3 tools/check-common.py common
14 31
15 update-jolt-native:32 update-jolt-native:
16 stage: update33 stage: update
modified justfile +7 -0
@@ -150,6 +150,13 @@ run *args:
150150 # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL
151151 # driver, which is the reason this output exists on machines that have none.
152152 #
153+# What may appear in common/, checked — the half of the tree both backends
154+# compile. Needs nothing built: it reads the source, so it is the one check
155+# that runs anywhere, and CI runs exactly this.
156+check-common:
157+ #!/usr/bin/env bash
158+ python3 tools/check-common.py common
159+
153160 # The same screens in a terminal. `just tui --headless` prints one screenshot.
154161 tui *args:
155162 #!/usr/bin/env bash
@@ -150,6 +150,13 @@ run *args:
150 # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL150 # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL
151 # driver, which is the reason this output exists on machines that have none.151 # driver, which is the reason this output exists on machines that have none.
152 #152 #
153+# What may appear in common/, checked — the half of the tree both backends
154+# compile. Needs nothing built: it reads the source, so it is the one check
155+# that runs anywhere, and CI runs exactly this.
156+check-common:
157+ #!/usr/bin/env bash
158+ python3 tools/check-common.py common
159+
153 # The same screens in a terminal. `just tui --headless` prints one screenshot.160 # The same screens in a terminal. `just tui --headless` prints one screenshot.
154 tui *args:161 tui *args:
155 #!/usr/bin/env bash162 #!/usr/bin/env bash
added tools/check-common.py +212 -0
new file mode 100755
@@ -0,0 +1,212 @@
1+#!/usr/bin/env python3
2+"""What may appear in common/, checked.
3+
4+common/ is compiled twice — by jolt and by ClojureDart — and only one of those
5+happens on the way to a desktop build. So the way this breaks is always the
6+same: shared code reaches for something only the JVM has, everything the
7+author ran still works, and the phone stops compiling at a namespace nobody
8+touched. `Math/ceil` in the compose bar was the third time.
9+
10+The rule being enforced is CLAUDE.md's, unchanged: code under common/ may not
11+require jolt.*, glimmer* or a dart: library, and if it needs the host it asks
12+frq.io. This only sees the first half of that — a host call has to be named to
13+be caught, and the list below is the ones that have actually turned up.
14+
15+Two things are stripped before anything is matched, and both are the
16+difference between a check people keep and one they route around.
17+
18+Comments and strings, because the two namespaces that got this right explain
19+themselves by naming the very thing they avoid — a checker that fires on
20+`frq.clock`'s docstring teaches people to stop reading it.
21+
22+And the reader-conditional branches ClojureDart does not read. A
23+`#?(:jolt [glimmer.ratom ...])` is not a violation, it is the sanctioned way
24+to say "desktop only": the cljd compiler never sees inside it. So this reads
25+the conditionals the way the compiler does — first branch whose feature is on,
26+with :cljd, :clj and :default on — and looks only at what is left.
27+"""
28+import re
29+import sys
30+from 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".
34+INTEROP = 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.
42+CTOR = re.compile(r"\((?:[A-Z]\w*\.)(?=[\s)])")
43+JAVA_PKG = re.compile(r"(?<![\w.-])java\.[\w.]+")
44+# .getBytes and friends: methods on a JVM object, by name.
45+METHODS = re.compile(r"(?<![\w.-])\.(?:getBytes|toUpperCase|toLowerCase|intValue|longValue|doubleValue|charAt)(?![\w-])")
46+# The requires CLAUDE.md rules out by name.
47+BAD_REQUIRE = re.compile(r"(?<![\w.-])(?:jolt\.[\w.]+|glimmer[\w.]*|\"dart:[\w.]+\")")
48+
49+CHECKS = [
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+
58+def 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+
95+ACTIVE = ("cljd", "clj", "default")
96+
97+
98+def _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+
107+def _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+
121+def _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+
151+def 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+
187+def main():
188+ root = Path(sys.argv[1] if len(sys.argv) > 1 else "common")
189+ bad = []
190+ for path in sorted(root.rglob("*.cljc")):
191+ source = select(strip(path.read_text()))
192+ for lineno, line in enumerate(source.splitlines(), 1):
193+ for pattern, why in CHECKS:
194+ m = pattern.search(line)
195+ if m:
196+ bad.append((path, lineno, m.group(0).strip(), why))
197+ for path, lineno, tok, why in bad:
198+ print(f"{path}:{lineno}: {tok!r} is {why}", file=sys.stderr)
199+ if bad:
200+ print(
201+ f"\n{len(bad)} thing(s) under {root}/ that ClojureDart cannot compile.\n"
202+ "common/ is built by both backends: ask frq.io for the host, and add\n"
203+ "the call to both implementations. See CLAUDE.md.",
204+ file=sys.stderr,
205+ )
206+ return 1
207+ print(f"{root}/ is clean: nothing here that only one backend has.")
208+ return 0
209+
210+
211+if __name__ == "__main__":
212+ sys.exit(main())
new file mode 100755
@@ -0,0 +1,212 @@
1+#!/usr/bin/env python3
2+"""What may appear in common/, checked.
3+
4+common/ is compiled twice — by jolt and by ClojureDart — and only one of those
5+happens on the way to a desktop build. So the way this breaks is always the
6+same: shared code reaches for something only the JVM has, everything the
7+author ran still works, and the phone stops compiling at a namespace nobody
8+touched. `Math/ceil` in the compose bar was the third time.
9+
10+The rule being enforced is CLAUDE.md's, unchanged: code under common/ may not
11+require jolt.*, glimmer* or a dart: library, and if it needs the host it asks
12+frq.io. This only sees the first half of that — a host call has to be named to
13+be caught, and the list below is the ones that have actually turned up.
14+
15+Two things are stripped before anything is matched, and both are the
16+difference between a check people keep and one they route around.
17+
18+Comments and strings, because the two namespaces that got this right explain
19+themselves by naming the very thing they avoid — a checker that fires on
20+`frq.clock`'s docstring teaches people to stop reading it.
21+
22+And the reader-conditional branches ClojureDart does not read. A
23+`#?(:jolt [glimmer.ratom ...])` is not a violation, it is the sanctioned way
24+to say "desktop only": the cljd compiler never sees inside it. So this reads
25+the conditionals the way the compiler does — first branch whose feature is on,
26+with :cljd, :clj and :default on — and looks only at what is left.
27+"""
28+import re
29+import sys
30+from 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".
34+INTEROP = 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.
42+CTOR = re.compile(r"\((?:[A-Z]\w*\.)(?=[\s)])")
43+JAVA_PKG = re.compile(r"(?<![\w.-])java\.[\w.]+")
44+# .getBytes and friends: methods on a JVM object, by name.
45+METHODS = re.compile(r"(?<![\w.-])\.(?:getBytes|toUpperCase|toLowerCase|intValue|longValue|doubleValue|charAt)(?![\w-])")
46+# The requires CLAUDE.md rules out by name.
47+BAD_REQUIRE = re.compile(r"(?<![\w.-])(?:jolt\.[\w.]+|glimmer[\w.]*|\"dart:[\w.]+\")")
48+
49+CHECKS = [
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+
58+def 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+
95+ACTIVE = ("cljd", "clj", "default")
96+
97+
98+def _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+
107+def _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+
121+def _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+
151+def 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+
187+def main():
188+ root = Path(sys.argv[1] if len(sys.argv) > 1 else "common")
189+ bad = []
190+ for path in sorted(root.rglob("*.cljc")):
191+ source = select(strip(path.read_text()))
192+ for lineno, line in enumerate(source.splitlines(), 1):
193+ for pattern, why in CHECKS:
194+ m = pattern.search(line)
195+ if m:
196+ bad.append((path, lineno, m.group(0).strip(), why))
197+ for path, lineno, tok, why in bad:
198+ print(f"{path}:{lineno}: {tok!r} is {why}", file=sys.stderr)
199+ if bad:
200+ print(
201+ f"\n{len(bad)} thing(s) under {root}/ that ClojureDart cannot compile.\n"
202+ "common/ is built by both backends: ask frq.io for the host, and add\n"
203+ "the call to both implementations. See CLAUDE.md.",
204+ file=sys.stderr,
205+ )
206+ return 1
207+ print(f"{root}/ is clean: nothing here that only one backend has.")
208+ return 0
209+
210+
211+if __name__ == "__main__":
212+ sys.exit(main())