nandi/frqpublic Fork 0
8ead4ea
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.

Repaint for every cell, follow the conversation, and send once

Four things reported off the phone in one sitting, and two causes.

People, Overview and hide join/part each flipped exactly the cell they were
meant to flip and repainted nothing. `cljd.flutter` rebuilds for what its
`:watch` names, that list was written by hand in the widget, and a list
written by hand drifts — those three were never on it. So `frq.cells` now
enumerates itself and the phone watches the enumeration.

The enumeration is a function rather than a def, which is the part
ClojureDart makes you care about: a def becomes a Dart top-level variable,
Dart initialises those on first read, and the first attempt at this — cells
registering themselves into a defonce as they were defined — installed no
watches at all, because nothing ever read the thing they registered into.
It printed nothing to say so either. `tools/check-common.py` now fails if
the list falls behind the definitions, since a list kept by hand is only as
good as what checks it.

Sending put the message in the room twice, and that one is mine from an
hour ago: `echo-message` was negotiated, which is the cap that makes the
server send our own line back, and send-draft! went on adding a local copy
as it had to before. It adds one only when the server will not.

And a `:scroll` had no controller at all, so the chat opened at the top of
the backlog and stayed there as lines arrived. It follows the end now, on
open and as messages land — but not when someone is reading back through
the backlog, which is the one behaviour worse than not following at all.

Verified on a Pixel 6a: both toggles light and open their panes, the chat
opens on the last line, and a sent message appears once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T00:39:41-07:00 Browse files
8ead4ea parent: 86c2e1b
modified common/frq/cells.cljc +30 -0
@@ -212,3 +212,33 @@
212212 ;; Where the picker is looking, or nil when it is closed. A path, so the
213213 ;; browsing is just this cell moving.
214214 (defonce image-picker (atom nil))
215+
216+
217+;; ------------------------------------------------------------- enumeration
218+
219+(defn all-cells
220+ "Every cell above, for a backend that has to be told what to watch.
221+
222+ glimmer needs no such list: a component that derefs a ratom is subscribed to
223+ it by the act of dereferencing, so the desktop hears about a change it never
224+ declared an interest in. `cljd.flutter` works from the other end — `:watch`
225+ names what a widget rebuilds for — and the phone kept that list in the
226+ widget, where it drifted: People, Overview and hide join/part each flipped
227+ the cell they were meant to flip and repainted nothing, because the cell was
228+ not named there.
229+
230+ A function and not a `def`, which is the part ClojureDart makes you care
231+ about: a `def` becomes a Dart top-level variable, those initialise on first
232+ read, and a list nothing has read yet is still empty at the moment the
233+ watches are installed. Calling it also forces every cell it names.
234+
235+ `tools/check-common.py` fails the build if this falls behind the
236+ definitions, because a list kept by hand is only as good as what checks it."
237+ []
238+ [screen status error connecting? form-host form-port form-tls? form-nick
239+ auth-mode form-handle form-app-password session channels current
240+ join-input search broker-token login-url draft editing replying-to
241+ attachment jump-tick show-users? hide-chat-list? overview? at-present?
242+ emoji-group emoji-search highlight jump-to lightbox overview-return
243+ reacting window-height window-width reaction-hover hide-join-part?
244+ image-picker])
@@ -212,3 +212,33 @@
212 ;; Where the picker is looking, or nil when it is closed. A path, so the212 ;; Where the picker is looking, or nil when it is closed. A path, so the
213 ;; browsing is just this cell moving.213 ;; browsing is just this cell moving.
214 (defonce image-picker (atom nil))214 (defonce image-picker (atom nil))
215+
216+
217+;; ------------------------------------------------------------- enumeration
218+
219+(defn all-cells
220+ "Every cell above, for a backend that has to be told what to watch.
221+
222+ glimmer needs no such list: a component that derefs a ratom is subscribed to
223+ it by the act of dereferencing, so the desktop hears about a change it never
224+ declared an interest in. `cljd.flutter` works from the other end — `:watch`
225+ names what a widget rebuilds for — and the phone kept that list in the
226+ widget, where it drifted: People, Overview and hide join/part each flipped
227+ the cell they were meant to flip and repainted nothing, because the cell was
228+ not named there.
229+
230+ A function and not a `def`, which is the part ClojureDart makes you care
231+ about: a `def` becomes a Dart top-level variable, those initialise on first
232+ read, and a list nothing has read yet is still empty at the moment the
233+ watches are installed. Calling it also forces every cell it names.
234+
235+ `tools/check-common.py` fails the build if this falls behind the
236+ definitions, because a list kept by hand is only as good as what checks it."
237+ []
238+ [screen status error connecting? form-host form-port form-tls? form-nick
239+ auth-mode form-handle form-app-password session channels current
240+ join-input search broker-token login-url draft editing replying-to
241+ attachment jump-tick show-users? hide-chat-list? overview? at-present?
242+ emoji-group emoji-search highlight jump-to lightbox overview-return
243+ reacting window-height window-width reaction-hover hide-join-part?
244+ image-picker])
modified flutter/src/frq/hiccup.cljd +57 -2
@@ -143,6 +143,57 @@
143143 [node]
144144 (prose? node))
145145
146+;; A ScrollController per `:scroll-key`, and what was last done with it.
147+;;
148+;; Kept outside the widget tree because `render` is a plain function, not a
149+;; `f/widget` with state of its own: the tree is rebuilt from scratch on every
150+;; repaint, and a controller made during a build would start at the top each
151+;; time and forget where the reader was.
152+(defonce ^:private scroll-controllers (atom {}))
153+(defonce ^:private scroll-marks (atom {}))
154+
155+(defn- scroll-controller-for
156+ "The one controller for `k`, made the first time it is asked for.
157+
158+ Named apart from the text-editing `controller-for` above deliberately: they
159+ are the same idea for two different widgets, and when they shared a name the
160+ compiler took the second and left every entry on screen calling it with an
161+ argument too many."
162+ [k]
163+ (or (get @scroll-controllers k)
164+ (let [c (m/ScrollController)]
165+ (swap! scroll-controllers assoc k c)
166+ c)))
167+
168+(defn- end-ward!
169+ "Put `k` at the end after this frame, when it should be.
170+
171+ Two reasons to, and they are not the same reason. The token — what the
172+ screen passes as `:scroll-to-bottom`, which is `jump-tick` — changing means
173+ someone asked to be taken to the present. Already being at the end means new
174+ lines should push the view along rather than pile up below it, which is what
175+ a conversation does and the whole reason a chat opens at the bottom.
176+
177+ And not otherwise: someone reading back through the backlog is at neither,
178+ and yanking them to the end as each message arrives is the one behaviour
179+ worse than not following at all.
180+
181+ After the frame, because the extent being scrolled to is the height of
182+ content that has not been laid out yet at the point this is called."
183+ [k ctrl token]
184+ (let [mark (get @scroll-marks k ::fresh)
185+ pos (when (.-hasClients ctrl) (.-position ctrl))
186+ at-end (or (= mark ::fresh)
187+ (nil? pos)
188+ (>= (.-pixels pos) (- (.-maxScrollExtent pos) 24.0)))]
189+ (when (or (not= mark token) at-end)
190+ (swap! scroll-marks assoc k token)
191+ (.addPostFrameCallback
192+ (.-instance m/WidgetsBinding)
193+ (fn [_]
194+ (when (.-hasClients ctrl)
195+ (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl)))))))))
196+
146197 (defn- fills-column?
147198 "Whether a node takes the height its column has left over.
148199
@@ -513,8 +564,12 @@
513564 ;; two ParentDataWidgets on one RenderObject, which Flutter calls
514565 ;; "competing" and then draws nothing.
515566 :scroll
516- (m/SingleChildScrollView
517- .child (col (dbl (:spacing p) 0.0) (body node)))
567+ (let [k (str (:scroll-key p))
568+ ctrl (when (seq k) (scroll-controller-for k))]
569+ (when ctrl (end-ward! k ctrl (:scroll-to-bottom p)))
570+ (m/SingleChildScrollView
571+ .controller ctrl
572+ .child (col (dbl (:spacing p) 0.0) (body node))))
518573
519574 ;; A tag this backend has not grown yet still shows its children — which
520575 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so
@@ -143,6 +143,57 @@
143 [node]143 [node]
144 (prose? node))144 (prose? node))
145 145
146+;; A ScrollController per `:scroll-key`, and what was last done with it.
147+;;
148+;; Kept outside the widget tree because `render` is a plain function, not a
149+;; `f/widget` with state of its own: the tree is rebuilt from scratch on every
150+;; repaint, and a controller made during a build would start at the top each
151+;; time and forget where the reader was.
152+(defonce ^:private scroll-controllers (atom {}))
153+(defonce ^:private scroll-marks (atom {}))
154+
155+(defn- scroll-controller-for
156+ "The one controller for `k`, made the first time it is asked for.
157+
158+ Named apart from the text-editing `controller-for` above deliberately: they
159+ are the same idea for two different widgets, and when they shared a name the
160+ compiler took the second and left every entry on screen calling it with an
161+ argument too many."
162+ [k]
163+ (or (get @scroll-controllers k)
164+ (let [c (m/ScrollController)]
165+ (swap! scroll-controllers assoc k c)
166+ c)))
167+
168+(defn- end-ward!
169+ "Put `k` at the end after this frame, when it should be.
170+
171+ Two reasons to, and they are not the same reason. The token — what the
172+ screen passes as `:scroll-to-bottom`, which is `jump-tick` — changing means
173+ someone asked to be taken to the present. Already being at the end means new
174+ lines should push the view along rather than pile up below it, which is what
175+ a conversation does and the whole reason a chat opens at the bottom.
176+
177+ And not otherwise: someone reading back through the backlog is at neither,
178+ and yanking them to the end as each message arrives is the one behaviour
179+ worse than not following at all.
180+
181+ After the frame, because the extent being scrolled to is the height of
182+ content that has not been laid out yet at the point this is called."
183+ [k ctrl token]
184+ (let [mark (get @scroll-marks k ::fresh)
185+ pos (when (.-hasClients ctrl) (.-position ctrl))
186+ at-end (or (= mark ::fresh)
187+ (nil? pos)
188+ (>= (.-pixels pos) (- (.-maxScrollExtent pos) 24.0)))]
189+ (when (or (not= mark token) at-end)
190+ (swap! scroll-marks assoc k token)
191+ (.addPostFrameCallback
192+ (.-instance m/WidgetsBinding)
193+ (fn [_]
194+ (when (.-hasClients ctrl)
195+ (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl)))))))))
196+
146 (defn- fills-column?197 (defn- fills-column?
147 "Whether a node takes the height its column has left over.198 "Whether a node takes the height its column has left over.
148 199
@@ -513,8 +564,12 @@
513 ;; two ParentDataWidgets on one RenderObject, which Flutter calls564 ;; two ParentDataWidgets on one RenderObject, which Flutter calls
514 ;; "competing" and then draws nothing.565 ;; "competing" and then draws nothing.
515 :scroll566 :scroll
516- (m/SingleChildScrollView567+ (let [k (str (:scroll-key p))
517- .child (col (dbl (:spacing p) 0.0) (body node)))568+ ctrl (when (seq k) (scroll-controller-for k))]
569+ (when ctrl (end-ward! k ctrl (:scroll-to-bottom p)))
570+ (m/SingleChildScrollView
571+ .controller ctrl
572+ .child (col (dbl (:spacing p) 0.0) (body node))))
518 573
519 ;; A tag this backend has not grown yet still shows its children — which574 ;; A tag this backend has not grown yet still shows its children — which
520 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so575 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so
modified flutter/src/frq/main.cljd +36 -26
@@ -88,6 +88,28 @@
8888
8989 (defonce ^:private attempt (atom 0))
9090
91+;; Ticked whenever any cell changes, so one `:watch` covers all of them.
92+;;
93+;; What this replaced was a list of cells named by hand in the widget below,
94+;; and everything not on it was a control that flipped its cell and repainted
95+;; nothing People, Overview and hide join/part all did.
96+(defonce ^:private repaint (atom 0))
97+
98+(defn- watch-cells!
99+ "Every cell in `frq.cells` ticking `repaint` when it changes.
100+
101+ Called from `main` rather than run from a top-level `defonce`, and that is
102+ not a style preference: ClojureDart compiles a `def` to a Dart top-level
103+ variable, Dart initialises those on first read, and a `defonce` whose value
104+ nothing ever reads simply never runs. The first attempt at this installed no
105+ watches at all and printed nothing to say so."
106+ []
107+ (doseq [c (cells/all-cells)]
108+ (add-watch c ::repaint
109+ (fn [_ _ old new]
110+ (when (not= old new) (swap! repaint inc))
111+ nil))))
112+
91113 (defonce ^:private caps
92114 ;; What the server has agreed to on this connection. `frq.irc.handshake`
93115 ;; holds none of it: it takes the set and hands a new one back, so the
@@ -289,9 +311,13 @@
289311 room (str @cells/current)]
290312 (when (and (seq text) (seq room) @conn)
291313 (net/send-line! @conn (str "PRIVMSG " room " :" text))
292- ;; Echoed locally: the server does not send our own PRIVMSG back.
293- (swap! cells/channels update room
294- #(update % :messages conj {:from @cells/form-nick :text text}))
314+ ;; Echoed locally only when the server will not echo it back. With
315+ ;; `echo-message` negotiated it does that is what the cap is for, and
316+ ;; it is how a client learns the msgid of its own line so adding one
317+ ;; here as well put every sent message in the room twice.
318+ (when-not (handshake/acked? @caps "echo-message")
319+ (swap! cells/channels update room
320+ #(update % :messages conj {:from @cells/form-nick :text text})))
295321 (reset! cells/draft ""))))
296322
297323 (defn ^:async main []
@@ -311,6 +337,9 @@
311337 ;; same signature OpenSSL gives on the desktop, so a signature minted
312338 ;; here verifies the same way at the server.
313339 (crypto-dart/install!)
340+ ;; Before any widget is built: a cell that changes before its watch is on
341+ ;; is a change the screen never hears about.
342+ (watch-cells!)
314343 ;; What the shared screen calls. The desktop installs frq.state's
315344 ;; reducers here; this installs the phone's.
316345 (actions/install!
@@ -369,29 +398,10 @@
369398 .body
370399 m/SafeArea
371400 (f/widget
372- :let [c-status cells/status
373- c-error cells/error
374- c-connecting cells/connecting?
375- c-mode cells/auth-mode
376- c-handle cells/form-handle
377- c-nick cells/form-nick
378- c-host cells/form-host
379- c-port cells/form-port
380- c-tls cells/form-tls?
381- c-apppw cells/form-app-password
382- c-broker cells/broker-token
383- c-login cells/login-url
384- c-screen cells/screen
385- c-draft cells/draft
386- c-channels cells/channels
387- c-current cells/current]
388- :watch [st c-status er c-error cn c-connecting am c-mode
389- fh c-handle nk c-nick hs c-host pt c-port tl c-tls
390- ap c-apppw bt c-broker lu c-login ls lines sc c-screen dr c-draft ch c-channels cu c-current]
391- ;; No scroll view around the screen. A `:page` scrolls itself now, and a
392- ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it
393- ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat
394- ;; in an unbounded column.
401+ ;; One watch, not twenty. `lines` is a local atom and not a cell, so it
402+ ;; is named beside the tick; everything under `frq.cells` arrives
403+ ;; through `repaint`.
404+ :watch [tick repaint ls lines]
395405 (h/render
396406 ;; `frq.screens.app` decides which screen shows, the same way it does
397407 ;; on the desktop. The phone was switching by hand until this moved.
@@ -88,6 +88,28 @@
88 88
89 (defonce ^:private attempt (atom 0))89 (defonce ^:private attempt (atom 0))
90 90
91+;; Ticked whenever any cell changes, so one `:watch` covers all of them.
92+;;
93+;; What this replaced was a list of cells named by hand in the widget below,
94+;; and everything not on it was a control that flipped its cell and repainted
95+;; nothing People, Overview and hide join/part all did.
96+(defonce ^:private repaint (atom 0))
97+
98+(defn- watch-cells!
99+ "Every cell in `frq.cells` ticking `repaint` when it changes.
100+
101+ Called from `main` rather than run from a top-level `defonce`, and that is
102+ not a style preference: ClojureDart compiles a `def` to a Dart top-level
103+ variable, Dart initialises those on first read, and a `defonce` whose value
104+ nothing ever reads simply never runs. The first attempt at this installed no
105+ watches at all and printed nothing to say so."
106+ []
107+ (doseq [c (cells/all-cells)]
108+ (add-watch c ::repaint
109+ (fn [_ _ old new]
110+ (when (not= old new) (swap! repaint inc))
111+ nil))))
112+
91 (defonce ^:private caps113 (defonce ^:private caps
92 ;; What the server has agreed to on this connection. `frq.irc.handshake`114 ;; What the server has agreed to on this connection. `frq.irc.handshake`
93 ;; holds none of it: it takes the set and hands a new one back, so the115 ;; holds none of it: it takes the set and hands a new one back, so the
@@ -289,9 +311,13 @@
289 room (str @cells/current)]311 room (str @cells/current)]
290 (when (and (seq text) (seq room) @conn)312 (when (and (seq text) (seq room) @conn)
291 (net/send-line! @conn (str "PRIVMSG " room " :" text))313 (net/send-line! @conn (str "PRIVMSG " room " :" text))
292- ;; Echoed locally: the server does not send our own PRIVMSG back.314+ ;; Echoed locally only when the server will not echo it back. With
293- (swap! cells/channels update room315+ ;; `echo-message` negotiated it does that is what the cap is for, and
294- #(update % :messages conj {:from @cells/form-nick :text text}))316+ ;; it is how a client learns the msgid of its own line so adding one
317+ ;; here as well put every sent message in the room twice.
318+ (when-not (handshake/acked? @caps "echo-message")
319+ (swap! cells/channels update room
320+ #(update % :messages conj {:from @cells/form-nick :text text})))
295 (reset! cells/draft ""))))321 (reset! cells/draft ""))))
296 322
297 (defn ^:async main []323 (defn ^:async main []
@@ -311,6 +337,9 @@
311 ;; same signature OpenSSL gives on the desktop, so a signature minted337 ;; same signature OpenSSL gives on the desktop, so a signature minted
312 ;; here verifies the same way at the server.338 ;; here verifies the same way at the server.
313 (crypto-dart/install!)339 (crypto-dart/install!)
340+ ;; Before any widget is built: a cell that changes before its watch is on
341+ ;; is a change the screen never hears about.
342+ (watch-cells!)
314 ;; What the shared screen calls. The desktop installs frq.state's343 ;; What the shared screen calls. The desktop installs frq.state's
315 ;; reducers here; this installs the phone's.344 ;; reducers here; this installs the phone's.
316 (actions/install!345 (actions/install!
@@ -369,29 +398,10 @@
369 .body398 .body
370 m/SafeArea399 m/SafeArea
371 (f/widget400 (f/widget
372- :let [c-status cells/status401+ ;; One watch, not twenty. `lines` is a local atom and not a cell, so it
373- c-error cells/error402+ ;; is named beside the tick; everything under `frq.cells` arrives
374- c-connecting cells/connecting?403+ ;; through `repaint`.
375- c-mode cells/auth-mode404+ :watch [tick repaint ls lines]
376- c-handle cells/form-handle
377- c-nick cells/form-nick
378- c-host cells/form-host
379- c-port cells/form-port
380- c-tls cells/form-tls?
381- c-apppw cells/form-app-password
382- c-broker cells/broker-token
383- c-login cells/login-url
384- c-screen cells/screen
385- c-draft cells/draft
386- c-channels cells/channels
387- c-current cells/current]
388- :watch [st c-status er c-error cn c-connecting am c-mode
389- fh c-handle nk c-nick hs c-host pt c-port tl c-tls
390- ap c-apppw bt c-broker lu c-login ls lines sc c-screen dr c-draft ch c-channels cu c-current]
391- ;; No scroll view around the screen. A `:page` scrolls itself now, and a
392- ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it
393- ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat
394- ;; in an unbounded column.
395 (h/render405 (h/render
396 ;; `frq.screens.app` decides which screen shows, the same way it does406 ;; `frq.screens.app` decides which screen shows, the same way it does
397 ;; on the desktop. The phone was switching by hand until this moved.407 ;; on the desktop. The phone was switching by hand until this moved.
added tools/__pycache__/check-common.cpython-314.pyc +0 -0
new file mode 100644
Binary files /dev/null and b/tools/__pycache__/check-common.cpython-314.pyc differ
new file mode 100644
Binary files /dev/null and b/tools/__pycache__/check-common.cpython-314.pyc differBinary files /dev/null and b/tools/__pycache__/check-common.cpython-314.pyc differ
modified tools/check-common.py +27 -4
@@ -184,6 +184,27 @@ def select(src):
184184 i = hit + 2
185185
186186
187+def 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+
187208 def main():
188209 root = Path(sys.argv[1] if len(sys.argv) > 1 else "common")
189210 bad = []
@@ -194,13 +215,15 @@ def main():
194215 m = pattern.search(line)
195216 if m:
196217 bad.append((path, lineno, m.group(0).strip(), why))
218+ bad += cells_enumerated(root)
197219 for path, lineno, tok, why in bad:
198- print(f"{path}:{lineno}: {tok!r} is {why}", file=sys.stderr)
220+ where = f"{path}:{lineno}" if lineno else str(path)
221+ print(f"{where}: {tok!r} is {why}", file=sys.stderr)
199222 if bad:
200223 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.",
224+ f"\n{len(bad)} thing(s) wrong under {root}/, which both backends compile.\n"
225+ "If it needs the host, ask frq.io and add the call to both\n"
226+ "implementations. See CLAUDE.md.",
204227 file=sys.stderr,
205228 )
206229 return 1
@@ -184,6 +184,27 @@ def select(src):
184 i = hit + 2184 i = hit + 2
185 185
186 186
187+def 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+
187 def main():208 def main():
188 root = Path(sys.argv[1] if len(sys.argv) > 1 else "common")209 root = Path(sys.argv[1] if len(sys.argv) > 1 else "common")
189 bad = []210 bad = []
@@ -194,13 +215,15 @@ def main():
194 m = pattern.search(line)215 m = pattern.search(line)
195 if m:216 if m:
196 bad.append((path, lineno, m.group(0).strip(), why))217 bad.append((path, lineno, m.group(0).strip(), why))
218+ bad += cells_enumerated(root)
197 for path, lineno, tok, why in bad:219 for path, lineno, tok, why in bad:
198- print(f"{path}:{lineno}: {tok!r} is {why}", file=sys.stderr)220+ where = f"{path}:{lineno}" if lineno else str(path)
221+ print(f"{where}: {tok!r} is {why}", file=sys.stderr)
199 if bad:222 if bad:
200 print(223 print(
201- f"\n{len(bad)} thing(s) under {root}/ that ClojureDart cannot compile.\n"224+ f"\n{len(bad)} thing(s) wrong under {root}/, which both backends compile.\n"
202- "common/ is built by both backends: ask frq.io for the host, and add\n"225+ "If it needs the host, ask frq.io and add the call to both\n"
203- "the call to both implementations. See CLAUDE.md.",226+ "implementations. See CLAUDE.md.",
204 file=sys.stderr,227 file=sys.stderr,
205 )228 )
206 return 1229 return 1