home / 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
// 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/home", 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")}`);
});

// The second level of the same trap: a `defn`/`def` named after a core VAR
// shadows it namespace-wide. shadow emits a :redef warning, but the build does
// not fail on warnings so it scrolls past — this port shipped
// `(defonce subs (atom #{}))` in social_share.cljs for one compile, shadowing
// cljs.core/subs, and only the warning (read by eye) caught it.
const CORE_VARS = new Set([
  "subs", "name", "type", "count", "key", "val", "get", "set", "list", "map",
  "filter", "remove", "find", "sort", "merge", "keys", "vals", "first", "second",
  "last", "next", "rest", "conj", "assoc", "dissoc", "update", "reduce", "range",
  "repeat", "str", "int", "long", "float", "double", "boolean", "char", "symbol",
  "keyword", "atom", "deref", "reset!", "swap!", "identity", "constantly",
  "partial", "comp", "juxt", "apply", "meta", "hash", "compare", "max", "min",
  "abs", "mod", "rem", "quot", "inc", "dec", "even?", "odd?", "empty", "peek",
  "pop", "push", "take", "drop", "split-at", "concat", "into", "seq", "test",
  "print", "println", "pr", "prn", "format", "resolve", "namespace", "record?",
  "some", "every?", "not-any?", "flatten", "shuffle", "rand", "time", "when",
]);

test("no defn/def is named after a core var", () => {
  const hits = [];
  for (const file of walk(SRC)) {
    const src = strip(readFileSync(file, "utf8"));
    for (const m of src.matchAll(/\((?:defn-?|defonce|def)\s+(?:\^[^\s]+\s+)*([^\s()\[\]]+)/g)) {
      if (CORE_VARS.has(m[1])) {
        hits.push(`${file}:${src.slice(0, m.index).split("\n").length} def \`${m[1]}\``);
      }
    }
  }
  assert.deepEqual(hits, [], `a def shadows a core var:\n${hits.join("\n")}`);
});

test("the core-var lint would have caught the bug that motivated it", () => {
  const sample = strip('(defonce ^:private subs (atom #{}))');
  const m = [...sample.matchAll(/\((?:defn-?|defonce|def)\s+(?:\^[^\s]+\s+)*([^\s()\[\]]+)/g)][0];
  assert.equal(m[1], "subs");
  assert.ok(CORE_VARS.has(m[1]));
});

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)));
});

// Remote peer text must never reach innerHTML. Rather than trusting a comment,
// pin the fact that the port has exactly ONE innerHTML site and that its input
// is the compiled-in glyph source — a hand-drawn SVG string from home.glyphs or
// an entry's glyphSvg out of the same-origin catalog, past the <svg prefix +
// no-script/no-onload check. Everything a peer sends (brains versions, peer
// names, petnames, request notes, invite text) goes through textContent.
test("innerHTML appears exactly once in the port, on the glyph", () => {
  const sites = [];
  for (const file of walk(SRC)) {
    const src = strip(readFileSync(file, "utf8")); // comments blanked out
    for (const m of src.matchAll(/innerHTML/g)) {
      const line = src.slice(0, m.index).split("\n").length;
      const stmt = src.slice(Math.max(0, m.index - 40), m.index + 80).replace(/\s+/g, " ").trim();
      sites.push({ file: file.slice(file.lastIndexOf("/") + 1), line, stmt });
    }
  }
  assert.equal(sites.length, 1, `expected one innerHTML site, found:\n${JSON.stringify(sites, null, 1)}`);
  assert.equal(sites[0].file, "catalog.cljs");
  assert.match(sites[0].stmt, /glyph-for/, "the only innerHTML input must be the glyph source");
});

// home is the ONLY writer in the suite that bypasses the /id/ bridge host, so
// every guarantee the host gives everybody else — the CAS on the seed, the size
// caps, the key order — is one this app has to give itself. It gives them in
// home.record/write-record!, which means the lint that matters is: nothing else
// assembles a record and stores it.
//
// write-bridge-record is allowed in exactly two files. In home.record it IS the
// write. In home.main it is `save!` and `adopt-seed!`, which replace the record
// WHOLE — create, fresh key, import, forget — where there is no older read to
// carry stale fields back over the top. Every partial write ({...cur, some
// fields}) has to go through the compare-and-set: the card used to hand its
// writer the record it had rendered from, and that record is captured for as
// long as the card's DOM lives, which an `apps` or `soc` write does not disturb.
test("only home.record and home.main's whole-record paths reach write-bridge-record", () => {
  const sites = [];
  for (const file of walk(SRC)) {
    const src = strip(readFileSync(file, "utf8")); // comments blanked out
    for (const m of src.matchAll(/write-bridge-record/g)) {
      sites.push({
        file: file.slice(file.lastIndexOf("/") + 1),
        line: src.slice(0, m.index).split("\n").length,
      });
    }
  }
  const byFile = {};
  for (const s of sites) byFile[s.file] = (byFile[s.file] ?? 0) + 1;
  assert.deepEqual(
    byFile,
    { "record.cljs": 2, "main.cljs": 3 },
    "write-bridge-record must stay in home.record (the ns require + the write) and home.main" +
      ` (the require + save! + adopt-seed!):\n${JSON.stringify(sites, null, 1)}`,
  );
});

test("home.main's save! is only ever handed a WHOLE record", () => {
  const src = strip(readFileSync(join(SRC, "main.cljs"), "utf8"));
  const calls = [...src.matchAll(/\(save!\s+(\S+)/g)].map((m) => m[1].replace(/\)+$/, ""));
  assert.ok(calls.length > 0, "the lint is blind if save! is gone — revisit it");
  // a blank record for a new/fresh/imported identity, the record just read back
  // unchanged (a re-import of the seed already here), or nil (forget)
  const WHOLE = new Set(["(record/new-record", "cur", "nil"]);
  const partial = calls.filter((c) => !WHOLE.has(c));
  assert.deepEqual(
    partial,
    [],
    "save! blind-writes whatever it is handed: these callers must go through" +
      ` home.record/write-record! instead\n${partial.join("\n")}`,
  );
});

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