banca / client / test / source-hygiene.test.mjs
  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
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
// A source lint over src/banca, for the CLJS traps that each shipped a bug in
// this suite. Every check carries a "would have caught the bug that motivated
// it" probe beside it, because an unprobed gate is a claim, not a check.
//
// ---------------------------------------------------------------------------
// PROVENANCE, and what was removed on the way here.
//
// The scaffold (`ardz new-app`) copies this file from chat, and chat's copy had
// grown seven checks that were about CHAT: a façade freeze pinned to chat's
// externs.js and its 21 entries, a "Phase 5c budget" naming chat's app/ui.cljs
// and the exact number of `esc` calls it still had, and a build-resolution test
// asserting chat's `:viewlib` exports. On a fresh app those cannot pass, and
// the tempting fix — relaxing each until it goes green — produces a file full
// of gates that cannot fail. A gate that cannot fail is worse than no gate: it
// occupies the slot a real one would have.
//
// So the chat-specific ones were either ADAPTED to this repo's own contract
// (the façade freeze now pins banca's `:testlib` exports and banca's own proto
// vtables, in test/vectors/facade.json) or DROPPED with the reason recorded
// here:
//
//   · the externs cross-check ("every externs.js name is still installed by
//     some proto vtable", asserting exactly 21 names) is GONE. banca builds at
//     `:optimizations :simple`, where Closure renames nothing, so there is no
//     externs.js and nothing to pin. It comes back — with a real number — the
//     day this app follows chat to `:advanced`, and shadow-cljs.edn's header
//     says so.
//
//   · the raw-HTML budget's "the Phase 5c budget would now FAIL" case has lost
//     the half that recounted chat's conversion history (an allowlist entry of
//     {app/ui.cljs: sinks 5, escCalls 34} that banca never had). The half that
//     PROBES THE COUNTER — a synthetic file that offends four ways, budgeted at
//     three — is app-agnostic and stayed, because that is the half that would
//     notice a counter which had quietly stopped counting.
//
// The scaffolder now ships an app-agnostic file in this spirit (the template's
// own copy, chat-only checks omitted), so this triage is history, not a todo.
// ---------------------------------------------------------------------------
//
// THE CHECKS, and the bug behind each:
//
//  1-4  A LOCAL NAMED AFTER A CORE MACRO shadows it inside its own body, with no
//       warning at any optimization level. In the odeon port, audio.cljs took
//       the TypeScript's `when` (an AudioContext time) as a parameter, and
//       `(when (nil? @NOISE) …)` became `when.call(null, …)`. Green build, green
//       vectors, every noise voice throwing at runtime.
//
//  5-6  A `defn`/`def` WHOSE NAME IS A CORE VAR shadows it for the whole
//       namespace. shadow emits a `:redef` warning but the build does not fail
//       on warnings, so it scrolls past. valley-blocks' hud.cljs shipped a
//       `(defn- reset! …)` that way. (This file's own history: `pos-int?` in
//       lib/protocol.cljs was caught by the compiler's warning and not by this
//       list, which is why the CORE_VARS set below now carries the core
//       PREDICATES too. The list is a LIST, not a rule.)
//
//    7  `#js {}` / `js-obj` PRESERVE LITERAL KEY ORDER ONLY TO EIGHT PAIRS. At
//       nine they route through a PersistentHashMap and emit in HASH order, at
//       every optimization level, silently. dev/docs/CLJS.md calls this the most
//       dangerous trap in the migration, and in a money app it is the difference
//       between a signature that verifies and one that does not. Forbidden
//       outright rather than counted: everything goes through
//       ardegazu.rooms.js/ordered.
//
//  8-11 A FORM WHOSE INDENTATION SAYS ONE THING AND WHOSE PAREN DEPTH SAYS
//       ANOTHER. chat v24 shipped `do-replay` one paren short; the trailing
//       `(.catch …)` became an argument rather than a link, which compiles to
//       `function(c){…}.catch()` — valid ClojureScript, valid JavaScript, and a
//       throw the instant boot touched it. No room UI was built, for every user,
//       for two releases. Two independent detectors, because the defect leaves
//       two independent traces.
//
// 12-13 THE FAÇADE FREEZE. Everything the black-box suite touches goes through
//       `:testlib :exports`, the hand-written `unchecked-set proto "…"` vtables
//       and constructor arity — none of which anything pinned.
//
// 14-20 THE XSS SURFACE, as an allowlist checked in BOTH directions. banca has
//       no view layer yet, so the list is empty and every check reads as an
//       absolute: nothing under src/banca may assign `.-innerHTML`,
//       `.-outerHTML` or `insertAdjacentHTML`, define an `esc`, or hand
//       replicant an `:innerHTML` attribute. The empty list makes one direction
//       vacuous; the probes below do the work it cannot, by running the
//       detectors over code that DOES offend.
//
// It scans THIS repo's namespaces only. The shared rooms core
// (ardegazu.rooms.*) comes off the classpath from ardegazu-rooms-kit and is
// linted there, by that repo's copy of this file. Between the two, every .cljs
// this app compiles is covered — so if you ever move code the other way, ask
// what was scanning it yesterday and whether that still reaches.

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/banca", 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) is the live example. The regex is anchored on `(`
 * immediately before the head, so `\(let\s` does NOT match `(p/let `: the moment
 * a boot path stopped being a `js-await` ladder, the shadowing lint stopped
 * covering it. That is the "when code moves, its gates do not follow" trap in
 * its smallest possible form, and the fix is one string. banca has no promesa on
 * its classpath today, so the p/ entries are inert here — they stay anyway,
 * because a list that has to be extended after the fact never is.
 */
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 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 this repo's own: 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");
});

/**
 * `#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.
 *
 * In banca that is not a rendering bug, it is a forgery: `canon()` sorts, so a
 * scrambled preimage would still SIGN, and the transmitted `JSON.stringify`
 * bytes a peer holds would differ from everyone else's. Rather than counting
 * keys at every site, the construct is forbidden outright: every object goes
 * through 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. `#js [...]` is fine too: an array has no
 * keys.
 */
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. chat's pattern was `\(js-obj[^)]`,
    // which matches the first six characters of `(js-object? raw)` — a
    // predicate this app's wallet seam calls twice — and reported it as a
    // nine-pair hazard. A lint that cries wolf gets its rule relaxed by the
    // next person under time pressure, and then it stops catching the real
    // thing. (Reported back: chat's copy and the scaffold's carry the fix.)
    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 wpo builder
  // written the natural way looks like.
  const offender = strip('(def o #js {"v" 1 "t" "wpo" "id" a "cur" b "amt" 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 chat's `\(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");
});

/**
 * A form whose INDENTATION says one thing and whose PAREN DEPTH says another.
 *
 * chat 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 `(-> …)` — laid out perfectly — but 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 put it in.
 *   · the CONSEQUENCE trace — an interop call whose target position holds a `fn`
 *     literal. 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: a 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);
});

/**
 * THE FAÇADE FREEZE (helpers/facade.mjs has the full 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.
 *
 * There is deliberately NO generator for the vector. Changing the façade is a
 * decision, not a refresh: the failing deepEqual prints the whole diff, and a
 * change you meant to make is applied by editing facade.json to match, reading
 * every moved line as you go.
 *
 * `externs` is recorded as an empty list because banca builds at `:simple` and
 * has no externs.js. That is a fact about the build, not a hole: nothing is
 * renamed, so nothing needs pinning. See this file's header.
 */
const FACADE_INPUTS = {
  srcDir: SRC,
  shadowEdn: new URL("../shadow-cljs.edn", 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("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.BankFold = "banca.lib.fold/BankFoldV2";
  assert.notDeepEqual(renamed, base, "a retargeted export must be caught");

  const dropped = clone();
  dropped.files["lib/fold.cljs"].protos.BankFold.pop();
  assert.notDeepEqual(dropped, base, "a dropped vtable entry must be caught");

  const reordered = clone();
  const p = reordered.files["lib/fold.cljs"].protos.BankFold;
  [p[0], p[1]] = [p[1], p[0]];
  assert.notDeepEqual(reordered, base, "a reordered vtable must be caught");

  const rearity = clone();
  rearity.files["lib/fold.cljs"].classes.BankFold += 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, 4);
  // 102 at the freeze; +4 when the banker's-ack seams were exported so node
  // could drive them (ackDispatch, ackOpFor, buildIssuance, buildSettlement);
  // +9 when the presentation layer's pure decisions were exported so paging,
  // day-grouping and account filtering are node-tested rather than clicked
  // (PAGE_INIT, PAGE_STEP, pagePlan, dayKey, groupDays, FILTER_THRESHOLD,
  // accountMatches, filterAccounts, whoDesc);
  // +3 when the lobby grew the resident bots' public-banks section, whose data
  // and pure decisions are node-tested (PUBLIC_BANKS, validPublicBank,
  // publicBanksAvailable — test/publicbanks.test.mjs);
  // +1 when the knock form learned to prefill the suite profile's name and the
  // decision was factored pure (defaultKnockName — test/view.test.mjs);
  // +4 when the protocol version moved onto the LINK for BANK/2, so a bank's
  // version, whether this build can fold it, and the salt that follows from it
  // are all node-tested rather than inferred (linkVersion, linkSupported,
  // LINK_MAX_VERSION, saltFor — test/link.test.mjs)
  // +7 when BANK/2 added escrow, so the three ops' key order and their
  // version-gated validators are node-tested rather than inferred (mkLock,
  // mkClaim, mkUnlock, validLock, validClaim, validUnlock, opVersion —
  // test/protocol.test.mjs and test/escrow.test.mjs)
  // +3 when the balance panel grew its third outstanding category, so
  // "all of it is final" is one rule with one owner rather than a condition
  // each screen assembles (lockedCount, lockedNet, allFinal)
  // +1 so a rebuilt lock receipt is checked against wallet-kit's own verifier
  // rather than merely rebuilt (verifyLockReceipt)
  assert.equal(Object.keys(base.exports).length, 134);
  assert.equal(base.files["lib/fold.cljs"].classes.BankFold, 3,
               "BankFold takes (bankerPub, myIdPub, version) — the version is the escrow gate");
  assert.equal(base.files["app/store.cljs"].classes.BankStore, 7,
               "BankStore takes (net, log, fold, identity, name, room, self)");
});

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
  // is unambiguous only while exactly one build has one — so the freeze
  // silently depended on the ORDER builds are written in, and a second
  // `:exports` map arriving above `:testlib` would have re-pointed the frozen
  // contract with no test going red. banca has one today; the property is
  // probed against a synthetic file that has two, so the gate is not waiting
  // for the second build to arrive before it starts working.
  const edn = readFileSync(new URL("../shadow-cljs.edn", import.meta.url).pathname, "utf8");
  const testlib = exportsOf(edn, ":testlib");
  assert.equal(testlib.BankFold, "banca.lib.fold/BankFold");
  assert.deepEqual(exportsOf(edn), testlib, "the default must stay :testlib");
  assert.throws(() => exportsOf(edn, ":nope"), /no :nope :exports map/);

  const two = `{:builds
 {:viewlib {:target :esm :modules {:viewlib {:exports {lobbyView x/lobby-view}}}}
  :testlib {:target :esm :modules {:testlib {:exports {BankFold banca.lib.fold/BankFold}}}}}}`;
  assert.deepEqual(exportsOf(two, ":viewlib"), { lobbyView: "x/lobby-view" });
  assert.deepEqual(exportsOf(two, ":testlib"), { BankFold: "banca.lib.fold/BankFold" });
  assert.notDeepEqual(exportsOf(two, ":viewlib"), exportsOf(two, ":testlib"),
                      "two names must not resolve to the same map");
});

/**
 * THE RECEIPT BUTTON'S RENDER CONDITION — a source gate, because the defect it
 * pins was invisible to every other suite in this repo.
 *
 * `view/receiptable?` reads a fold RECORD's `status`. The UI once called it on
 * a ROW from `view/row` — which carries the translated `state` word and no
 * `status` at all — so it answered false on every row, the `pay.receipt`
 * button never rendered anywhere (mints AND payments), and v1's entire
 * delivery mechanism (manual wri export, PROTOCOL.md §8) was silently gone.
 * The view suite stayed green throughout: it drove `receiptable?` over
 * records, which is the shape the function is FOR, and no test asked what
 * shape the UI hands it.
 *
 * Two halves close that, and both must hold:
 *   · test/view.test.mjs drives the ROW's precomputed `receiptable` field over
 *     the whole fold corpus (the value half);
 *   · THIS test pins the render condition itself (the wiring half): the row
 *     builder precomputes the decision from the record via the one true rule,
 *     the UI gates the button on that row field, and nothing under app/ ever
 *     calls the record-shape rule again — a row is the only shape a screen
 *     holds.
 *
 * RAW source, not `strip`: both fingerprints live inside string literals
 * ("receiptable", "pay.receipt"), which strip blanks out.
 */
const rawOf = (rel) => readFileSync(join(SRC, rel), "utf8");

test("the receipt button renders off the ROW's own `receiptable` field", () => {
  const ui = rawOf("app/ui.cljs");
  const view = rawOf("lib/view.cljs");
  // the one authority: view/row precomputes the decision from the RECORD,
  // through the true record-shape rule, at row-build time
  assert.match(view, /"receiptable"\s+\(receiptable\?\s+rec\)/,
               "view/row must precompute `receiptable` via (receiptable? rec)");
  // the one consumer: the button exists once, and its guard is the row field
  assert.equal(occurrences(ui, '(t "pay.receipt")'), 1,
               "exactly one receipt button in app/ui.cljs");
  assert.match(ui, /\(when\s+\(j\/truthy\?\s+\(unchecked-get\s+r\s+"receiptable"\)\)/,
               "the button must be gated on the row's precomputed field");
  // and no screen re-asks the record-shape rule of anything — a row is the
  // only shape a screen holds, and the rule answers false on every row
  assert.ok(!ui.includes("(view/receiptable?"),
            "app/ui.cljs must not call view/receiptable? — a row has no `status`");
});

test("the receipt-gate check would have caught the defect that motivated it", () => {
  // the exact shape that shipped: the record-shape rule asked of a row
  const shipped = '(when (view/receiptable? r)\n  (.appendChild wrap (dom/btn "link-btn" (t "pay.receipt") go)))';
  assert.ok(shipped.includes("(view/receiptable?"), "the call form must be seen");
  assert.doesNotMatch(shipped, /\(when\s+\(j\/truthy\?\s+\(unchecked-get\s+r\s+"receiptable"\)\)/,
                      "…and the shipped shape must NOT satisfy the required gate");
  // a row builder that stopped precomputing must fail the authority half
  const gutted = '(j/ordered "state" (payment-state rec) "clock" (unchecked-get rec "clock"))';
  assert.doesNotMatch(gutted, /"receiptable"\s+\(receiptable\?\s+rec\)/);
});

/**
 * THE XSS SURFACE, as an allowlist checked in BOTH directions.
 *
 * banca has no view layer yet, so the list is EMPTY and every check below reads
 * as an absolute: nothing under src/banca may assign `.-innerHTML`,
 * `.-outerHTML` or `insertAdjacentHTML`, and nothing may define an `esc`. When
 * a view layer arrives it should be replicant, where hiccup text becomes a DOM
 * text node and there is no escape function left to forget to call.
 *
 * The list is a BUDGET, not a permit: an entry records the EXACT number of
 * raw-HTML sinks and `esc` call sites a file may still contain, and both
 * numbers may only ever fall. Naming a file was enough while a screen converted
 * in one step; it stopped being enough the moment a screen was half-converted,
 * because a per-file entry cannot tell "the message list stopped using
 * innerHTML" from "nothing happened", nor stop the count climbing back.
 *
 * An empty list makes the second direction of the check vacuous. It stays
 * anyway — it is what makes ADDING an entry a deliberate act with a number
 * attached rather than a permit — and the probes below do the work an empty
 * list cannot, by running the detectors over code that DOES offend.
 */
const HTML_SINKS = ["-innerHTML", "-outerHTML", "insertAdjacentHTML"];

/**
 * file (relative to src/banca) -> {sinks, escCalls}, both exact.
 *
 * IT IS EMPTY, and that is the whole story of this package's view layer.
 *
 * The two entries that used to be here were the SCAFFOLD's demo UI, exactly as
 * `ardz new-app` wrote it — a lobby and a note wall built from `innerHTML`
 * string templates with a hand-rolled `esc` beside them, recorded rather than
 * exempted at {app/rooms.cljs: sinks 3, escCalls 12} and {app/ui.cljs: sinks 5,
 * escCalls 0}, with "both numbers may only ever fall" written next to them.
 *
 * The banking UI replaced both screens, and it builds NODES (app/dom.cljs):
 * `textContent` for every string, `createElement` for every element, and no
 * path at all through which a value can become markup. So both numbers fell to
 * zero and the entries went away with them, which is what the budget was for.
 * Every check below is an absolute again: nothing under src/banca may assign
 * `.-innerHTML`, `.-outerHTML` or `insertAdjacentHTML`, define an `esc`, or
 * hand replicant an `:innerHTML` attribute.
 *
 * The empty list makes one direction of the allowlist vacuous. It stays anyway
 * — it is what makes ADDING an entry back a deliberate act with a number
 * attached rather than a permit — and the probes below do the work it cannot,
 * by running the detectors over code that DOES offend.
 *
 * The finding for the scaffolder stood, was demonstrated here, and has since
 * been CONSOLIDATED: `ardz new-app` now ships a demo built on its own
 * app/dom.cljs with an empty allowlist, so a fresh scaffold starts green.
 */
const STILL_IMPERATIVE = {};

const occurrences = (src, needle) => src.split(needle).length - 1;

/** Files under src/banca, 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. chat's copy compared both
  // directions against the SAME key set, which silently assumed the two
  // offences always travel together — they do not: this scaffold's app/ui.cljs
  // has five raw-HTML sinks and no `esc` at all (it interpolates values it
  // trusts). A gate that demanded an `esc` there would be demanding the file
  // get WORSE. (Second finding for the scaffolder — since consolidated.)
  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("an understated budget FAILS — the counter really counts", () => {
  // The probe an empty allowlist owes, executable rather than a claim in a
  // comment. A file that offends four ways, budgeted at three: every
  // understatement must be red, and zero must not be a free pass.
  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");
  // and the allowlist mechanism itself: a file that offends must show up in the
  // detectors, so re-adding an entry is the only way to make one pass
  assert.ok(HTML_SINKS.some((s) => offender.includes(s)));
  assert.ok(/\(defn-?\s+esc\b/.test(offender));
});

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 replaces", () => {
  // A lobby as chat's 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") "&amp;"))',
    '(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 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 });
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/banca.git