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
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655 | // 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/sueta.
// It scans THIS repo's namespaces only. The shared rooms core (ardegazu.rooms.js,
// ardegazu.rooms.lib.*) now comes off the classpath from ardegazu-rooms-kit, and is
// linted there by rooms-kit/test/source-hygiene.test.mjs — a copy of this file
// retargeted at src/ardegazu/rooms, added when the vendored copies were deleted.
// If you change these checks, change that one too: between them they have to cover
// every .cljs this app compiles, and the paren-depth pair below is the gate over
// the defect class that caused two production outages.
//
// 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";
import { readForms, strip, sym, COLLS } from "./helpers/cljs-reader.mjs";
import { buildFacade, exportsOf } from "./helpers/facade.mjs";
const SRC = new URL("../src/sueta", 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] : [];
});
/** 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 in this tree whose head is a binding vector. The list is a LIST,
* not a rule, so it has to be extended whenever a new binding macro arrives —
* and the trap is that nothing announces the omission: an unlisted form is not
* an error, it is silence.
*
* `p/let` (promesa, Phase 5e, `sueta.room` and below) is the live example. The
* regex is anchored on `(` immediately before the head, so `\(let\s` does NOT
* match `(p/let `: the moment the boot path stopped being a `js-await` ladder,
* the shadowing lint stopped covering it — sixteen bindings, in the one file
* where "it compiles, the tests pass, and the app does not start" has actually
* happened. That is the "when code moves, its gates do not follow" trap in its
* smallest possible form, and the fix is one string.
*
* rooms-kit's copy of this file has no promesa on its classpath today, so the
* p/ entries are inert there — add them anyway when that changes.
*/
const BINDING_FORMS = [
"let", "loop", "doseq", "dotimes", "if-let", "when-let", "if-some", "when-some", "js-await",
"p/let", "p/plet", "p/loop", "p/doseq",
];
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}\\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 lint reaches promesa's binding forms too", () => {
// The regression this guards is not a shadowed name — it is a lint that
// quietly stopped looking. `(p/let [when …] …)` must be seen exactly as
// `(let [when …] …)` is, and `(let …)`'s own pattern must NOT be what sees it.
const sample = strip("(defn- f [] (p/let [when (g)] (when true 1)))");
const seen = (kind) => {
const m = [...sample.matchAll(new RegExp(`\\(${kind}\\s`, "g"))];
if (!m.length) return [];
const body = bracketAfter(sample, m[0].index + m[0][0].length);
return forms(body).filter((_, i) => i % 2 === 0).filter((nm) => MACROS.has(nm));
};
assert.deepEqual(seen("p/let"), ["when"], "the p/let pattern must see the binding");
assert.deepEqual(seen("let"), [], "the bare `let` pattern must NOT match `(p/let `");
assert.ok(BINDING_FORMS.includes("p/let"), "…so p/let has to be on the list");
});
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",
// The core PREDICATES. Added after `(defn- pos-int? …)` in banca's
// lib/protocol.cljs shadowed cljs.core/pos-int? and was caught by the
// compiler's `:redef` warning rather than by this gate — a warning that
// scrolls past in a large build. Every one of these is a natural name in a
// wire-validation namespace, which is precisely why they need to be here.
"pos-int?", "neg-int?", "nat-int?", "int?", "float?", "double?", "number?", "string?",
"boolean?", "ident?", "simple-ident?", "qualified-ident?", "uuid?", "inst?", "bytes?",
"object?", "array?", "fn?", "ifn?", "coll?", "seqable?", "indexed?", "counted?",
"nil?", "some?", "true?", "false?", "zero?", "pos?", "neg?", "even?", "odd?", "empty?",
]);
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 bugs that motivated it", () => {
// (a) 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]));
// (b) and banca's: a validator named after a core predicate. The compiler
// warned; the gate did not. It does now.
const pred = strip(`(defn- ^boolean pos-int? [n]\n (and (number? n) (>= n 1)))`);
const p = [...pred.matchAll(/\((?:defn-?|def|defonce)\s+(?:\^[^\s]+\s+)*([^\s()[\]{}]+)/g)][0];
assert.equal(p[1], "pos-int?");
assert.ok(CORE_VARS.has(p[1]), "a core predicate name must be caught by the LIST, not by a warning");
});
/**
* 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.
//
// The `\s+` is a FIX, not a flourish. The pattern used to be
// `\(js-obj[^)]`, which matches the first six characters of
// `(js-object? raw)` — a PREDICATE — and reported it as a nine-pair
// hazard (found by banca, whose wallet seam calls js-object? twice). A
// lint that cries wolf gets its rule relaxed by the next person under
// time pressure, and then it stops catching the real thing.
for (const m of src.matchAll(/\(js-obj\s+[^)\s]/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")}`);
});
test("the nine-pair lint would have caught the construct it bans", () => {
// The probe an absolute owes: run the detector over code that DOES offend.
// Nine pairs is where the switch happens, and this is what a frame builder
// written the natural way looks like.
const offender = strip('(def o #js {"v" 1 "t" "msg" "id" a "ch" b "body" c "seq" d "from" e "to" f "ctx" g})');
assert.equal([...offender.matchAll(/#js\s*\{/g)].length, 1);
const objForm = strip('(def o (js-obj "a" 1))');
assert.equal([...objForm.matchAll(/\(js-obj\s+[^)\s]/g)].length, 1);
// …and that the accepted forms stay silent: an empty `(js-obj)`, a `#js`
// ARRAY (no keys, so no order to lose), and j/ordered itself
const fine = strip('(def e (js-obj))\n(def a #js ["x" "y"])\n(def o (j/ordered "a" 1 "b" 2))');
assert.equal([...fine.matchAll(/#js\s*\{/g)].length, 0);
assert.equal([...fine.matchAll(/\(js-obj\s+[^)\s]/g)].length, 0);
// …and the FALSE POSITIVE the pattern used to have. `(js-object? x)` is a
// predicate, not a literal, and the old `\(js-obj[^)]` flagged it.
const predicate = strip("(defn- f [raw] (and (js-object? raw) (js-obj)))");
assert.equal([...predicate.matchAll(/\(js-obj\s+[^)\s]/g)].length, 0, "js-object? is not a js-obj literal");
assert.equal([...predicate.matchAll(/\(js-obj[^)]/g)].length, 1, "…which the old pattern got wrong");
});
/**
* FOURTH and FIFTH checks, and the reason a reader exists at all (it now lives
* in helpers/cljs-reader.mjs, shared with the façade freeze below): 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.
*/
/** 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);
});
/**
* SIXTH check — the façade freeze (helpers/facade.mjs has the rationale).
*
* Everything the black-box suite touches goes through three surfaces that
* nothing pinned: `shadow-cljs.edn`'s `:testlib :exports`, the hand-written
* `unchecked-set proto "…"` vtables (which are also what `:advanced` cannot
* rename), and constructor arity. This asserts they EXACTLY equal
* test/vectors/facade.json — not "contains", not "at least": exact, so a
* rename, a dropped `_` seam, a reordered vtable or a constructor gaining a
* parameter is a red test and a legible diff.
*/
const FACADE_INPUTS = {
srcDir: SRC,
shadowEdn: new URL("../shadow-cljs.edn", import.meta.url).pathname,
externsJs: new URL("../externs.js", import.meta.url).pathname,
extraProtoDirs: [new URL("../node_modules/ardegazu-rooms-kit/src/ardegazu", import.meta.url).pathname],
};
test("the JS façade exactly matches test/vectors/facade.json", () => {
const got = buildFacade(FACADE_INPUTS);
delete got._installed;
const want = JSON.parse(readFileSync(new URL("./vectors/facade.json", import.meta.url).pathname, "utf8"));
assert.deepEqual(got, want);
});
test("every externs.js name is still installed by some proto vtable", () => {
// An externs entry whose definition was renamed or moved is a GREEN build and
// a dead app: `:advanced` renames the dot-access call site and leaves the
// string-keyed definition alone. This is the only check that spans the repo
// boundary — the definitions live in chat's lib/media and, for the other 19,
// in the classpathed ardegazu-rooms-kit sources.
const { externs, _installed } = buildFacade(FACADE_INPUTS);
assert.deepEqual(externs.filter((n) => !_installed.has(n)), []);
assert.equal(externs.length, 21);
});
test("the façade freeze is not vacuous", () => {
// Perturb the recorded contract four ways; each must be caught. The point of
// the probe is that `deepEqual` over this shape is SENSITIVE — a comparison
// that silently ignored ordering or arity would pass all four.
const base = buildFacade(FACADE_INPUTS);
delete base._installed;
const clone = () => JSON.parse(JSON.stringify(base));
const renamed = clone();
renamed.exports.MailboxSync = "sueta.lib.mailbox/MailboxSyncV2";
assert.notDeepEqual(renamed, base, "a retargeted export must be caught");
const dropped = clone();
dropped.files["lib/mailbox.cljs"].protos.MailboxSync.pop();
assert.notDeepEqual(dropped, base, "a dropped `_` seam must be caught");
const reordered = clone();
const p = reordered.files["lib/mailbox.cljs"].protos.MailboxSync;
[p[0], p[1]] = [p[1], p[0]];
assert.notDeepEqual(reordered, base, "a reordered vtable must be caught");
const rearity = clone();
rearity.files["lib/mailbox.cljs"].classes.MailboxSync += 1;
assert.notDeepEqual(rearity, base, "a constructor arity change must be caught");
// and the extractor really did read the tree, not an empty one
assert.equal(Object.keys(base.files).length, 5);
assert.equal(Object.keys(base.exports).length, 50);
});
/**
* SEVENTH check — the XSS surface, as a shrinking BUDGET.
*
* Phase 5 replaces the view layer with replicant, and the security claim that
* comes with it is structural, not diligent: hiccup text becomes a DOM text
* node, so there is no escape function left to forget to call. The hand-rolled
* `esc` and the `innerHTML` string templates it feeds die screen by screen.
*
* A lint that named the converted files would have covered nothing — the whole
* point is code that does not exist yet. So this is an ALLOWLIST of the files
* still permitted to do it, checked in BOTH directions: nothing outside the
* list may assign `.-innerHTML` (or `.-outerHTML`, or `insertAdjacentHTML`) or
* define an `esc`, AND every file on the list must still be doing it.
*
* IT IS A BUDGET, NOT A PERMIT (Phase 5b). Naming the file was enough while a
* screen converted in one step. It stopped being enough the moment app/ui.cljs
* became half-converted: it still owned the header template and the four modal
* sheets, so it stayed on the list either way, and a per-file entry could not
* tell "the message list stopped using innerHTML" from "nothing happened" —
* nor could it stop the count climbing back. So each entry recorded the EXACT
* number of raw-HTML sinks and of `esc` call sites that file may still
* contain, and both numbers could only ever fall. Phase 5b took the sinks from
* 12 to 7 and the `esc` calls from 47 to 35; Phase 5c took the roster, which
* is 2 more sinks (the container wipe and the row template) and 1 more `esc`,
* leaving 5 and 34. The call tiles moved in the same phase and are NOT in
* those numbers: that screen built nodes and set textContent, so it never had
* either — which is worth knowing, because "the budget did not move" would
* otherwise look like the tiles conversion not landing.
*
* PHASE 5d TOOK THE LAST FIVE AND THE LAST 34, so the list is EMPTY and every
* one of the four checks below now reads as an absolute: nothing under
* src/sueta may assign `.-innerHTML`, `.-outerHTML` or `insertAdjacentHTML`,
* and nothing may define an `esc`. The five were `ask-name`, `build!`'s frame,
* the peer sheet, the identity sheet and `copy!`'s fallback; they became
* app/chrome and app/sheets.
*
* The empty list makes the second direction of the check (every allowlisted
* file must still be doing it) vacuous, and it stays anyway — it is what makes
* re-adding an entry a deliberate act with a number attached rather than a
* permit. The two probes below do the work an empty list cannot: they run the
* detectors over code that DOES offend, so a lint that had quietly stopped
* seeing anything would be red rather than green.
*/
const HTML_SINKS = ["-innerHTML", "-outerHTML", "insertAdjacentHTML"];
/** file (relative to src/sueta) -> {sinks, escCalls}, both exact. Empty since
* Phase 5d, and it may only grow back on purpose, with a number. */
const STILL_IMPERATIVE = {};
const occurrences = (src, needle) => src.split(needle).length - 1;
/** Files under src/sueta, relative, that mention any raw-HTML sink. */
const sinkFiles = () =>
walk(SRC)
.filter((p) => HTML_SINKS.some((s) => strip(readFileSync(p, "utf8")).includes(s)))
.map((p) => p.slice(SRC.length + 1))
.sort();
/** Files that define an `esc` of their own (`(defn- esc` / `(defn esc`). */
const escFiles = () =>
walk(SRC)
.filter((p) => /\(defn-?\s+esc\b/.test(strip(readFileSync(p, "utf8"))))
.map((p) => p.slice(SRC.length + 1))
.sort();
/** {sinks, escCalls} in one source string. The gate and both probes below go
* through THIS, so a probe cannot pass against a counter the gate does not use. */
const countIn = (src) => ({
sinks: HTML_SINKS.reduce((n, s) => n + occurrences(src, s), 0),
escCalls: occurrences(src, "(esc "),
});
/** {sinks, escCalls} for one allowlisted file. */
const budgetOf = (rel) => countIn(strip(readFileSync(join(SRC, rel), "utf8")));
/** Allowlisted files whose budget for `what` is non-zero. */
const budgeted = (what) =>
Object.entries(STILL_IMPERATIVE).filter(([, b]) => b[what] > 0).map(([f]) => f).sort();
test("raw-HTML sinks live only in allowlisted files, and in all of them", () => {
assert.deepEqual(sinkFiles(), budgeted("sinks"));
});
test("`esc` is defined only in allowlisted files, and in all of them", () => {
// `budgeted("escCalls")`, not every allowlist key. Both directions used to
// compare against the SAME key set, which silently assumed the two offences
// always travel together — they do not (found by banca: the scaffold's demo
// ui had five raw-HTML sinks and no `esc` at all, because it interpolated
// values it trusted). A gate that demanded an `esc` in such a file would be
// demanding the file get WORSE.
assert.deepEqual(escFiles(), budgeted("escCalls"));
});
test("each allowlisted screen is within its exact raw-HTML budget", () => {
// EXACT, not "at most": a budget that only caught growth would let a screen
// convert and leave its allowance behind for the next person to spend.
const got = Object.fromEntries(Object.keys(STILL_IMPERATIVE).map((f) => [f, budgetOf(f)]));
assert.deepEqual(got, STILL_IMPERATIVE);
});
test("the Phase 5c budget would now FAIL, in both directions", () => {
// The probe an empty allowlist owes, and it is executable rather than a
// claim in a comment. Phase 5c's entry was {"app/ui.cljs": {sinks: 5,
// escCalls: 34}}; leaving it in place has to be red, and so does any number
// that understates what a file actually contains.
const stale = { "app/ui.cljs": { sinks: 5, escCalls: 34 } };
// (a) LEAVING IT UNCHANGED: the allowlist no longer matches the tree
assert.notDeepEqual(sinkFiles(), Object.keys(stale).sort(), "app/ui.cljs still has a sink");
assert.notDeepEqual(escFiles(), Object.keys(stale).sort(), "app/ui.cljs still defines esc");
// …and even if it were still listed, the numbers are wrong now
assert.notDeepEqual({ "app/ui.cljs": budgetOf("app/ui.cljs") }, stale);
assert.deepEqual(budgetOf("app/ui.cljs"), { sinks: 0, escCalls: 0 });
// (b) UNDERSTATING: a file that offends four ways, budgeted at three
const offender = strip([
'(defn- esc [s] s)',
'(set! (.-innerHTML a) (str (esc (t "x")) (esc (t "y"))))',
'(.insertAdjacentHTML b "beforeend" (esc c))',
'(set! (.-outerHTML d) "")',
].join("\n"));
assert.deepEqual(countIn(offender), { sinks: 3, escCalls: 3 });
assert.notDeepEqual(countIn(offender), { sinks: 2, escCalls: 3 }, "an understated sink count must fail");
assert.notDeepEqual(countIn(offender), { sinks: 3, escCalls: 2 }, "an understated esc count must fail");
assert.notDeepEqual(countIn(offender), { sinks: 0, escCalls: 0 }, "zero must not be a free pass");
});
test("no hiccup node smuggles raw HTML past the renderer", () => {
// replicant honours an `:innerHTML` ATTRIBUTE, which would put the whole
// escaping problem back exactly where it was — with none of the visible
// ugliness that made the string templates easy to find.
const offenders = walk(SRC).filter((p) => strip(readFileSync(p, "utf8")).includes(":innerHTML"));
assert.deepEqual(offenders, []);
});
test("the raw-HTML lint would have caught the code it replaced", () => {
// The lobby, as app/rooms.cljs wrote it until Phase 5a. Both halves of the
// check must fire on it: the sink and the escaper.
const before = [
'(defn- esc [s] (.replace ^string s (js/RegExp. "&" "g") "&"))',
'(set! (.-innerHTML overlay) (str "<p>" (esc (t "lobby.sub1")) "</p>"))',
].join("\n");
assert.ok(HTML_SINKS.some((s) => strip(before).includes(s)), "the sink must be seen");
assert.ok(/\(defn-?\s+esc\b/.test(strip(before)), "the escaper must be seen");
// …and neither may be found inside a string or a comment
const quoted = ';; (set! (.-innerHTML x) y)\n(def s "(defn- esc [s] s)")';
assert.ok(!HTML_SINKS.some((s) => strip(quoted).includes(s)));
assert.ok(!/\(defn-?\s+esc\b/.test(strip(quoted)));
});
test("the budget check counts, and is not fooled by a comment or a string", () => {
// The probe the per-file list could not have: the numbers have to come from
// a real count over real code, or the whole point of a budget is lost.
const sample = strip([
';; (set! (.-innerHTML a) (esc "x")) <- a comment, worth nothing',
'(def doc "(set! (.-innerHTML b) (esc 1))")',
'(set! (.-innerHTML c) (str (esc (t "a")) (esc (t "b"))))',
'(.insertAdjacentHTML d "beforeend" (esc e))',
].join("\n"));
assert.deepEqual(countIn(sample), { sinks: 2, escCalls: 3 });
});
test("the façade freeze reads the :testlib build BY NAME, not by document order", () => {
// helpers/facade.mjs used to take the first `:exports` map in the file. That
// was unambiguous only while one build had one. shadow-cljs.edn now has two
// (:testlib, frozen; :viewlib, the Phase 5 view build), so the freeze has to
// resolve the build by name — otherwise reordering two builds would silently
// re-point the contract and nothing would go red.
const edn = readFileSync(new URL("../shadow-cljs.edn", import.meta.url).pathname, "utf8");
const testlib = exportsOf(edn, ":testlib");
const viewlib = exportsOf(edn, ":viewlib");
assert.equal(testlib.loadRooms, "sueta.app.rooms/load-rooms");
assert.equal(viewlib.lobbyView, "sueta.viewlib/lobby-view");
assert.equal(viewlib.msgsView, "sueta.viewlib/msgs-view");
assert.notDeepEqual(testlib, viewlib, "the two builds must not resolve to the same map");
assert.equal(viewlib.loadRooms, undefined, "the view build exports no data-layer name");
assert.deepEqual(exportsOf(edn), testlib, "the default must stay :testlib");
assert.throws(() => exportsOf(edn, ":nope"), /no :nope :exports map/);
});
|