game6 / 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
// A source lint for one silent CLJS trap this port 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. Here: audio.cljs took the TS'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.
import test from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";

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

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