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 | // 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. game6 shipped exactly that: its audio
// namespace took the TypeScript's `when` (an absolute AudioContext instant) as
// a parameter name, and the `(when ...)` forms in scope became `when.call(...)`.
// The build was green, the vectors were green, and every noise drum voice threw
// at runtime.
//
// valley-blocks had the SAME landmine waiting: src/game/audio/sfx.ts passes
// `when` to blip() and sweep(). The port renamed it `at`. This lint is what
// keeps it renamed. 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/game2", 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")}`);
});
// Same hazard one level up: a `defn` named after a core VAR shadows it for the
// whole namespace. shadow does warn (:redef) — but the build does not fail on
// warnings, so the warning scrolls past. This makes it a test.
const CORE_VARS = new Set([
"reset!", "swap!", "conj", "assoc", "dissoc", "get", "set", "count", "first",
"rest", "next", "last", "map", "filter", "remove", "reduce", "into", "merge",
"update", "keys", "vals", "name", "type", "list", "range", "repeat", "sort",
"find", "key", "val", "peek", "pop", "push", "str", "print", "println",
"max", "min", "abs", "rand", "boolean", "number", "symbol", "identity",
"int", "long", "float", "double", "char", "byte", "short", "bit-or",
"hash", "meta", "seq", "vec", "empty", "flatten", "partition", "group-by",
]);
test("no defn shadows a core var (a namespace-wide :redef)", () => {
const hits = [];
for (const file of walk(SRC)) {
const src = strip(readFileSync(file, "utf8"));
for (const m of src.matchAll(/\(defn-?\s+(\S+)/g)) {
const nm = m[1].replace(/^\^\S+\s*/, "");
if (CORE_VARS.has(nm) || MACROS.has(nm)) {
hits.push(`${file}:${src.slice(0, m.index).split("\n").length} defn \`${nm}\``);
}
}
}
assert.deepEqual(hits, [], `a defn shadows a core var:\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)));
});
|