board / 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
// 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: chat/client/test/source-hygiene.test.mjs, retargeted at src/board.
// 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, sep } from "node:path";

const SRC = new URL("../src/board", 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")}`);
});

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 ["let", "loop", "doseq", "dotimes", "if-let", "when-let", "if-some", "when-some", "js-await"]) {
      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 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" "el" "id" a "w" b "h" 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 check, and the only one here written from a bug that actually shipped.
 *
 * The lobby's row loop has TWO branches — an in-place rename row and the normal
 * row — and the TypeScript appended the row to the list in BOTH (boards.ts:270
 * and boards.ts:306). The port kept only the rename branch's append, so every
 * saved board was built, wired with its open/rename/forget handlers, and never
 * inserted into the DOM. The list rendered empty; the "no boards yet" hint was
 * skipped too (the list is not empty), so a returning user's boards looked
 * deleted. Shipped as v10 and caught in the browser, not by a test: nothing
 * else here renders the lobby, and the render happens inside a Promise
 * executor, so the failure was completely silent — no exception, no rejection,
 * no console output.
 *
 * A DOM-level test would be better; there is no DOM stub that reaches the
 * lobby. This pins the invariant that matters and would have caught it.
 */
test("the lobby appends a row in BOTH branches of the row loop", () => {
  const src = strip(readFileSync(join(SRC, "app", "boards.cljs"), "utf8"));
  const appends = [...src.matchAll(/\(\.appendChild\s+list-el\s+row\)/g)];
  assert.equal(
    appends.length,
    2,
    `expected exactly 2 (.appendChild list-el row) — one for the rename row, one ` +
      `for the normal row — found ${appends.length}. A missing append makes saved ` +
      `boards silently invisible.`,
  );
});

/**
 * FIFTH check, also written from a bug that shipped — and from the same root
 * cause as the fourth.
 *
 * board's catalog says `"⬆ version {v} is ready (you're on {cur})"`; chat's and
 * home's say `{live}` / `{v}`. All three catalogs were ported verbatim, which is
 * correct — but board's CALL SITE was copied from chat, the rooms canon, so it
 * passed `{"live" live "v" VERSION}`. The result on every returning user's
 * screen: "version 10 is ready (you're on {cur})" — the wrong number in the
 * first slot and a raw placeholder in the second.
 *
 * A catalog is only half of an i18n contract; the argument map is the other
 * half, and nothing type-checks the pair. This asserts that every `(t "key"
 * {...})` supplies exactly the placeholders its English string interpolates —
 * no missing (raw `{token}` on screen), no extra (silently ignored, and usually
 * a sign the value went into the wrong slot).
 *
 * Plural keys (`tn`, stored as `{"one" … "other" …}`) take the union of their
 * variants' placeholders.
 */
test("every (t …) call supplies exactly the placeholders its catalog string uses", () => {
  const en = readFileSync(join(SRC, "i18n", "en.cljs"), "utf8");
  const cat = new Map();
  // flat: "key" "value with {tokens}"
  for (const m of en.matchAll(/^\s*"([^"]+)"\s+"((?:[^"\\]|\\.)*)"/gm)) {
    cat.set(m[1], new Set([...m[2].matchAll(/\{(\w+)\}/g)].map((x) => x[1])));
  }
  // plural: "key" {"one" "…{n}…" "other" "…{n}…"} — union of the variants.
  // The inner strings contain {tokens}, so this cannot use [^{}]*; take the
  // rest of the line up to its last brace instead.
  for (const m of en.matchAll(/^\s*"([^"]+)"\s+\{(.*)\}\s*$/gm)) {
    cat.set(m[1], new Set([...m[2].matchAll(/\{(\w+)\}/g)].map((x) => x[1])));
  }

  const problems = [];
  for (const file of walk(SRC)) {
    if (file.includes(`${sep}i18n${sep}`)) continue;
    // NOT strip(): that blanks string literals, which would erase every
    // ("key" {args}) pair and make this check pass vacuously. Comments only.
    const src = readFileSync(file, "utf8").replace(/^\s*;;.*$/gm, "");
    // `tn` is excluded on purpose: it takes the count POSITIONALLY and supplies
    // {n} itself, so it has no argument map to compare. Its keys are checked for
    // existence only, below. `(t ` does not match `(tn ` — the space matters.
    for (const m of src.matchAll(/\(tn\s+"([^"]+)"/g)) {
      if (!cat.has(m[1])) problems.push(`${file}: (tn "${m[1]}" …) is not in the en catalog`);
    }
    for (const m of src.matchAll(/\(t\s+"([^"]+)"\s*(\{[^{}]*\})?/g)) {
      const key = m[1];
      const want = cat.get(key);
      if (!want) {
        problems.push(`${file}: "${key}" is not in the en catalog`);
        continue;
      }
      const got = new Set(m[2] ? [...m[2].matchAll(/"(\w+)"\s/g)].map((x) => x[1]) : []);
      const missing = [...want].filter((k) => !got.has(k));
      const extra = [...got].filter((k) => !want.has(k));
      if (missing.length || extra.length) {
        problems.push(
          `${file}: (t "${key}" …) missing ${JSON.stringify(missing)} extra ${JSON.stringify(extra)}`,
        );
      }
    }
  }
  assert.deepEqual(problems, [], `i18n argument/placeholder mismatch:\n${problems.join("\n")}`);
});

/**
 * SIXTH and SEVENTH 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);
});

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