1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487 | // A source lint for one silent CLJS trap the migration actually hit.
//
// A local binding named after a core MACRO shadows it inside its own body, so
// every use of that macro in scope compiles to a call on the local — with no
// warning at any optimization level. In the odeon port, audio.cljs took the
// TypeScript's `when` (an absolute AudioContext time) as a parameter name, and
// play-noise's `(when (nil? @NOISE) ...)` became `when.call(null, …)`. The build
// was green, the vectors were green, and every noise drum voice plus the
// applause threw at runtime. Cheap to check, impossible to notice by reading.
//
// Canon copy: game6/client/test/source-hygiene.test.mjs, retargeted at
// src/ardegazu/rooms.
//
// This copy exists because chat and board stopped vendoring the rooms core and
// now compile it off this repo's classpath. Their own source-hygiene tests scan
// their own src/ trees, so without this file the ~1450 lines of lib/log.cljs and
// lib/net.cljs would be linted by nobody — and the paren-depth checks below are
// exactly the ones added after two production outages whose defects lived in
// this layer. Coverage must follow the code, not the repo it used to sit in.
//
// SECOND check, one level up, from the valley-blocks (game2) port: a `defn` or
// `def` whose NAME is a core VAR shadows it for the whole namespace. shadow does
// emit a :redef warning, but the build does not fail on warnings, so it scrolls
// past in a 1300-file app build. `name`, `type`, `count`, `key`, `val` and `time`
// are the dangerous ones here — every one of them is a natural name in this
// domain (peer names, op types, entry counts, map keys).
import test from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const SRC = new URL("../src/ardegazu/rooms", import.meta.url).pathname;
const MACROS = new Set([
"when", "when-not", "when-let", "when-some", "when-first",
"if", "if-let", "if-some", "if-not", "cond", "condp", "case",
"let", "loop", "recur", "for", "doseq", "dotimes", "do", "and", "or", "while",
"try", "binding", "lazy-seq", "doto", "this-as", "declare", "fn", "defn",
"->", "->>", "some->", "some->>", "as->", "set!", "new", "var",
"time", "assert", "comment",
]);
const walk = (dir) =>
readdirSync(dir).flatMap((n) => {
const p = join(dir, n);
return statSync(p).isDirectory() ? walk(p) : p.endsWith(".cljs") ? [p] : [];
});
/** Blank out strings and comments so only code text is scanned. */
function strip(src) {
let out = "";
let inStr = false;
for (let i = 0; i < src.length; i++) {
const ch = src[i];
if (inStr) {
if (ch === "\\") { out += " "; i++; continue; }
if (ch === '"') inStr = false;
out += ch === "\n" ? "\n" : " ";
continue;
}
if (ch === '"') { inStr = true; out += " "; continue; }
if (ch === ";") { while (i < src.length && src[i] !== "\n") { out += " "; i++; } out += "\n"; continue; }
out += ch;
}
return out;
}
/** The balanced [...] that follows `from`, or null. */
function bracketAfter(src, from) {
const open = src.indexOf("[", from);
if (open < 0) return null;
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === "[") depth++;
else if (src[i] === "]" && --depth === 0) return src.slice(open + 1, i);
}
return null;
}
const TOKEN = /[A-Za-z*!?<>=+/_-][A-Za-z0-9*!?<>=+./'_-]*/g;
/** Top-level forms of a binding vector, metadata dropped. */
function forms(body) {
const out = [];
let depth = 0;
let cur = "";
for (const ch of body) {
if ("([{".includes(ch)) depth++;
if (")]}".includes(ch)) depth--;
if (depth === 0 && /\s/.test(ch)) {
if (cur) out.push(cur);
cur = "";
} else cur += ch;
}
if (cur) out.push(cur);
return out.filter((f) => f && !f.startsWith("^"));
}
test("no defn/fn parameter is named after a core macro", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const re of [/\(defn-?\s+[^\s[]+/g, /\(fn\s+(?:[^\s[]+\s+)?(?=\[)/g]) {
for (const m of src.matchAll(re)) {
const body = bracketAfter(src, m.index + m[0].length);
if (body === null) continue;
for (const t of body.match(TOKEN) ?? []) {
if (MACROS.has(t)) hits.push(`${file}:${src.slice(0, m.index).split("\n").length} param \`${t}\``);
}
}
}
}
assert.deepEqual(hits, [], `a parameter shadows a macro:\n${hits.join("\n")}`);
});
/**
* Every form that INTRODUCES BINDINGS, named one by one.
*
* The list is explicit, and it is longer than what this tree uses today, because
* of the way this check fails: silently and invisibly. A pattern matching
* `\(let\s` does not match `(p/let ` — so the moment a file adopts promesa's
* ladder-flattener the lint stops covering it, with every test still green. That
* is not hypothetical: it happened to chat's boot path, the most dangerous file
* in the app, for sixteen bindings (dev/docs/CLJS.md).
*
* promesa is deliberately absent from this kit (deps.edn says why), so the
* `p/*` entries below match nothing here. They are the point: the list must
* already cover the form before anyone reaches for it, not after.
*/
const BINDING_FORMS = [
"let", "loop", "doseq", "dotimes", "if-let", "when-let", "if-some", "when-some",
"js-await", "with-redefs", "binding",
"p/let", "p/plet", "p/loop", "p/doseq", "p/with-redefs",
];
test("no let/loop/doseq binding is named after a core macro", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const kind of BINDING_FORMS) {
for (const m of src.matchAll(new RegExp(`\\(${kind.replace("/", "\\/")}\\s`, "g"))) {
const body = bracketAfter(src, m.index + m[0].length);
if (body === null) continue;
const names = forms(body).filter((_, i) => i % 2 === 0);
for (const nm of names) {
if (MACROS.has(nm)) hits.push(`${file}:${src.slice(0, m.index).split("\n").length} ${kind} binding \`${nm}\``);
}
}
}
}
assert.deepEqual(hits, [], `a binding shadows a macro:\n${hits.join("\n")}`);
});
test("the binding-form list covers the namespaced forms a bare pattern misses", () => {
const sample = strip('(p/let [when 1]\n (when (nil? x) 1))');
// the trap, stated as an assertion: the unqualified pattern does NOT see it
assert.equal([...sample.matchAll(/\(let\s/g)].length, 0);
// ... and the list does, finding the shadowed `when` in its binding vector
const hits = [];
for (const kind of BINDING_FORMS) {
for (const m of sample.matchAll(new RegExp(`\\(${kind.replace("/", "\\/")}\\s`, "g"))) {
const body = bracketAfter(sample, m.index + m[0].length);
if (body === null) continue;
for (const nm of forms(body).filter((_, i) => i % 2 === 0)) if (MACROS.has(nm)) hits.push(nm);
}
}
assert.deepEqual(hits, ["when"]);
});
test("the lint would have caught the bug that motivated it", () => {
// the exact shape audio.cljs shipped for one build
const sample = strip(`(defn- play-noise [when dur filt g0 tc]
(let [c @AC]
(when (nil? @NOISE) (vreset! NOISE (noise-buf c)))))`);
const m = [...sample.matchAll(/\(defn-?\s+[^\s[]+/g)][0];
const body = bracketAfter(sample, m.index + m[0].length);
assert.ok((body.match(TOKEN) ?? []).some((t) => MACROS.has(t)));
});
/** Core VARS (not macros) — a `defn`/`def` with one of these names shadows it
* for the entire namespace. Kept separate from MACROS above because the
* detection site is different: the def NAME, not a binding. */
const CORE_VARS = new Set([
"name", "type", "count", "key", "val", "keys", "vals", "first", "rest", "next", "last", "second",
"list", "map", "set", "get", "str", "seq", "meta", "range", "filter", "remove", "find", "sort",
"merge", "conj", "assoc", "dissoc", "into", "reduce", "apply", "concat", "reverse", "repeat",
"max", "min", "int", "long", "float", "double", "char", "boolean", "symbol", "keyword",
"reset!", "swap!", "deref", "atom", "delay", "force", "identity", "compare", "hash", "empty",
"print", "println", "pr", "prn", "format", "read", "eval", "load", "test", "some", "every?",
"replace", "partition", "group-by", "flatten", "take", "drop", "shuffle", "rand", "rand-int",
"array", "aget", "aset", "clone", "update", "peek", "pop", "push",
]);
test("no defn/def shadows a core var", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const m of src.matchAll(/\((?:defn-?|def|defonce)\s+(?:\^[^\s]+\s+)*([^\s()[\]{}]+)/g)) {
const nm = m[1];
if (CORE_VARS.has(nm) || MACROS.has(nm)) {
hits.push(`${file}:${src.slice(0, m.index).split("\n").length} def \`${nm}\``);
}
}
}
assert.deepEqual(hits, [], `a def shadows a core var:\n${hits.join("\n")}`);
});
test("the core-var lint would have caught the bug that motivated it", () => {
// the exact shape valley-blocks' hud.cljs shipped for one build
const sample = strip(`(defn- reset! []\n (set! (.-textContent el) ""))`);
const m = [...sample.matchAll(/\((?:defn-?|def|defonce)\s+(?:\^[^\s]+\s+)*([^\s()[\]{}]+)/g)][0];
assert.equal(m[1], "reset!");
assert.ok(CORE_VARS.has(m[1]));
});
/**
* THIRD check, for the trap dev/docs/CLJS.md calls the most dangerous one:
* `#js {}` and `js-obj` preserve literal key order only up to EIGHT pairs. At
* nine or more they route through a PersistentHashMap and emit in HASH order, at
* every optimization level, with no warning — a structurally valid frame with
* scrambled keys.
*
* This namespace tree is the widest wire surface in the suite (log ops, mailbox
* envelopes, sealed presence/roster frames, signal payloads, OrbitDB manifest
* inputs), so rather than counting keys at every site it forbids the construct
* outright: every object is built with ardegazu.rooms.js/ordered, which sets keys with
* sequential unchecked-set calls regardless of width. A bare `(js-obj)` with no
* pairs is fine — there is no order to lose.
*/
test("no #js {} or js-obj literal with pairs — everything goes through j/ordered", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const m of src.matchAll(/#js\s*\{/g)) {
hits.push(`${file}:${src.slice(0, m.index).split("\n").length} #js {…}`);
}
// `(js-obj)` with no arguments is the only accepted form
for (const m of src.matchAll(/\(js-obj[^)]/g)) {
hits.push(`${file}:${src.slice(0, m.index).split("\n").length} (js-obj …)`);
}
}
assert.deepEqual(hits, [], `use ardegazu.rooms.js/ordered instead:\n${hits.join("\n")}`);
});
/**
* FOURTH and FIFTH checks, and the reason this file grew a reader: a form whose
* INDENTATION says one thing and whose PAREN DEPTH says another.
*
* v24 shipped `do-replay` with its inner `.then` body closed one paren short. The
* trailing `(.catch …)` and `(.then …)` were indented as children of the enclosing
* `(-> …)` — they were laid out perfectly — but they sat one level deeper, so they
* became extra ARGUMENTS to the `.then` above them instead of links in the chain.
* `(.catch (fn …))` with nothing threaded into it compiles to a method call on the
* function literal itself:
*
* function(c){console.warn("mailbox replay failed",c);return null}.catch()
*
* Functions have no `.catch`, so it threw the instant `do-replay` was called;
* `mailbox/start` calls it during boot, boot's promise rejected, and no room UI
* was ever built. Every user, every room, for two releases.
*
* Nothing caught it. The indentation was right, so review read straight past it.
* shadow compiles it without a murmur — a method call on a function is valid
* ClojureScript and valid JavaScript. Both black-box suites stayed green: they
* never drive the replay path. A gate is the only thing that could have.
*
* Two independent detectors, because the defect leaves two independent traces and
* a given instance may leave only one:
*
* · the LAYOUT trace — a sibling dedented below the one above it. Whoever wrote
* it was thinking of an outer form than the one the parens actually put it in.
* · the CONSEQUENCE trace — an interop call whose target position holds a `fn`
* literal. This one is not a heuristic at all: it is never valid, at any
* indentation, in any context. It is what shipped.
*
* Both are exact-count assertions over a real parse. Neither is expressible as a
* regex: distinguishing `(.then (fn …))` threaded (fine, everywhere in this tree)
* from the same text unthreaded (fatal) needs the tree, not the text.
*/
/** `'` and `#` are NON-terminating macro chars — `x'` and `g#` are one symbol. */
const TERMINATING = '()[]{}";`~@^';
const SPACE = " \t\n\r\f,";
/**
* Reads `src` into a flat list of nodes carrying {type, line, col, leading,
* start, end, children, parent}. Strings, `;` comments, `\(` char literals and
* `#"…"` regexes are consumed as units so their delimiters never move the depth.
*/
function readForms(src) {
let i = 0;
const n = src.length;
const lineAt = [], bolAt = [];
{ let l = 1, s = 0;
for (let k = 0; k <= n; k++) { lineAt[k] = l; bolAt[k] = s; if (src[k] === "\n") { l++; s = k + 1; } } }
const ws = (c) => c !== undefined && SPACE.includes(c);
const term = (c) => c === undefined || ws(c) || TERMINATING.includes(c);
const skip = () => {
for (;;) {
while (i < n && ws(src[i])) i++;
if (src[i] === ";") { while (i < n && src[i] !== "\n") i++; continue; }
break;
}
};
const eatString = () => {
i++;
while (i < n) { if (src[i] === "\\") { i += 2; continue; } if (src[i] === '"') { i++; break; } i++; }
};
const node = (type, start, children, text) => ({
type, start, end: i, children, text,
line: lineAt[start], col: start - bolAt[start],
leading: /^[\s,]*$/.test(src.slice(bolAt[start], start)),
});
function coll(close, type) {
const start = i;
i++;
const kids = [];
for (;;) {
skip();
if (i >= n) break;
if (src[i] === close) { i++; break; }
if (")]}".includes(src[i])) { i++; continue; } // stray closer: step over
const f = form();
if (f) kids.push(f);
}
const nd = node(type, start, kids);
for (const k of kids) k.parent = nd;
return nd;
}
function form() {
skip();
if (i >= n) return null;
const c = src[i];
if (c === "(") return coll(")", "list");
if (c === "[") return coll("]", "vector");
if (c === "{") return coll("}", "map");
if (c === '"') { const s = i; eatString(); return node("string", s, []); }
if (c === "\\") {
const s = i; i++;
const named = /^(newline|space|tab|formfeed|backspace|return|u[0-9a-fA-F]{4}|o[0-7]{1,3})(?![\w-])/
.exec(src.slice(i));
i += named ? named[0].length : 1;
return node("char", s, []);
}
if (c === "#") {
const nx = src[i + 1];
if (nx === "_") { i += 2; form(); return null; } // discard
if (nx === '"') { const s = i; i++; eatString(); return node("regex", s, []); }
if (nx === "{") { i++; return coll("}", "set"); }
if (nx === "(") { const s = i; i++; const l = coll(")", "list"); return { ...l, type: "fn-literal", start: s }; }
if (nx === "?") { i += 2; if (src[i] === "@") i++; return form(); }
const s = i; i++; // #js, #uuid, …
while (i < n && !term(src[i])) i++;
const f = form();
return f ? { ...f, start: s } : null;
}
if (c === "'" || c === "`" || c === "@" || c === "~") {
const s = i;
i += c === "~" && src[i + 1] === "@" ? 2 : 1;
const f = form();
return f ? { ...f, start: s, line: lineAt[s], col: s - bolAt[s],
leading: /^[\s,]*$/.test(src.slice(bolAt[s], s)) } : null;
}
if (c === "^") { i++; form(); return form(); } // metadata: keep the target
if (")]}".includes(c)) { i++; return null; }
const s = i;
while (i < n && !term(src[i])) i++;
if (i === s) i++;
return node("symbol", s, [], src.slice(s, i));
}
const top = [];
for (;;) { skip(); if (i >= n) break; const f = form(); if (f) top.push(f); }
const flat = [];
(function collect(ns) { for (const nd of ns) { flat.push(nd); if (nd.children) collect(nd.children); } })(top);
return flat;
}
/** The head symbol of a list, or null when the head is not a plain symbol. */
const sym = (nd) => (nd?.type === "list" && nd.children?.[0]?.type === "symbol" ? nd.children[0].text : null);
const COLLS = new Set(["list", "vector", "map", "set", "fn-literal"]);
/** Every form's line-leading children, for the two checks below. */
function dedents(file, src) {
const hits = [];
const lines = src.split("\n");
for (const nd of readForms(src)) {
if (!COLLS.has(nd.type)) continue;
const kids = (nd.children ?? []).filter((k) => k.leading);
for (const k of kids.slice(1)) {
if (k.col < kids[0].col) {
hits.push(`${file}:${k.line} sits at column ${k.col}, its sibling at ${kids[0].line} at column ` +
`${kids[0].col} — ${(lines[k.line - 1] ?? "").trim().slice(0, 72)}`);
}
}
}
return hits;
}
test("no sibling is dedented below the one above it — layout must agree with depth", () => {
const hits = walk(SRC).flatMap((f) => dedents(f, readFileSync(f, "utf8")));
assert.deepEqual(hits, [], `indentation and paren depth disagree:\n${hits.join("\n")}`);
});
test("the indentation lint would have caught the bug that motivated it", () => {
// do-replay as v24 shipped it, reduced: the inner `.then` body is one paren
// short, so the two trailing links became arguments to it and dedented.
const sample = [
'(defn- do-replay [self]',
' (-> (js/Promise.resolve nil)',
' (.then',
' (fn [_]',
' (page 0))',
' (.catch (fn [err] nil))',
' (.then (fn [_] nil)))))',
].join("\n");
// one hit per dedented sibling: both trailing links moved
assert.equal(dedents("sample", sample).length, 2);
// and the same code with the paren where it belongs is silent
const fixed = [
'(defn- do-replay [self]',
' (-> (js/Promise.resolve nil)',
' (.then',
' (fn [_]',
' (page 0)))',
' (.catch (fn [err] nil))',
' (.then (fn [_] nil))))',
].join("\n");
assert.equal(dedents("sample", fixed).length, 0);
});
/** Forms that supply the interop target implicitly, and the child index from
* which they do it — `(-> x (.f))`, `(as-> x y (.f y))`, `(doto x (.f))`. */
const THREADS = { "->": 1, "->>": 1, "some->": 1, "some->>": 1, "cond->": 1, "cond->>": 1,
"doto": 1, "as->": 3, "..": 2 };
const FN_LITERAL = new Set(["fn", "fn*", "defn", "defn-"]);
/** `(.m (fn …))` / `(.-f (fn …))` outside a threading form: always fatal. */
function interopOnFn(file, src) {
const hits = [];
const lines = src.split("\n");
for (const nd of readForms(src)) {
const h = sym(nd);
if (!h || !h.startsWith(".") || h === "." || h === "..") continue;
const outer = nd.parent ? sym(nd.parent) : null;
const from = outer !== null && outer in THREADS ? THREADS[outer] : null;
if (from !== null && nd.parent.children.indexOf(nd) >= from) continue; // target is threaded in
const target = nd.children?.[1];
if (!target) continue;
if (target.type === "fn-literal" || FN_LITERAL.has(sym(target))) {
hits.push(`${file}:${nd.line} \`${h}\` is called on a function literal — ` +
`${(lines[nd.line - 1] ?? "").trim().slice(0, 72)}`);
}
}
return hits;
}
test("no interop call has a function literal in target position", () => {
const hits = walk(SRC).flatMap((f) => interopOnFn(f, readFileSync(f, "utf8")));
assert.deepEqual(hits, [], `a method is called on a function, which throws:\n${hits.join("\n")}`);
});
test("the interop lint would have caught the bug that motivated it", () => {
// the second instance of the same defect, live in this file until it was
// found: the page loop's closing paren landed one form early, so `0` became a
// body form of `page` and `(.then (fn …))` became `page`'s argument.
const sample = [
'(-> ((fn page [n]',
' (if (>= n 200) nil (page (inc n)))',
' 0)',
' (.then (fn [_] nil))))',
].join("\n");
assert.equal(interopOnFn("sample", sample).length, 1);
// threaded, the identical text is correct — and must stay silent
const fixed = [
'(-> ((fn page [n]',
' (if (>= n 200) nil (page (inc n))))',
' 0)',
' (.then (fn [_] nil)))',
].join("\n");
assert.equal(interopOnFn("sample", fixed).length, 0);
});
|