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>
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. | |
| 2 | 3 | # |
| 3 | 4 | # jolt-native publishes its Android objects under a "latest" alias on every |
| 4 | 5 | # default-branch build, and frq takes them as a flake input. A flake input is |
| @@ -7,10 +8,26 @@ | ||
| 7 | 8 | # this does it for the repository, so main's flake.lock names a recent build |
| 8 | 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, 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 | |
| 14 | 31 | |
| 15 | 32 | update-jolt-native: |
| 16 | 33 | 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 every | 4 | # 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 is | 5 | # 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 build | 8 | # 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, commits | 11 | +# 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 build | 12 | +# 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: update | 33 | stage: update |
modified
justfile +7 -0 | @@ -150,6 +150,13 @@ run *args: | ||
| 150 | 150 | # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL |
| 151 | 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 | 160 | # The same screens in a terminal. `just tui --headless` prints one screenshot. |
| 154 | 161 | tui *args: |
| 155 | 162 | #!/usr/bin/env bash |
| @@ -150,6 +150,13 @@ run *args: | |||
| 150 | # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL | 150 | # 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 bash | 162 | #!/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()) | ||