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