| Check what common/ may contain, on every push 86c2e1b nandi 7d ago | 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()) |