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 | /**
* The JS façade, as data.
*
* Every black-box test in this repo reaches the ClojureScript through exactly
* three things:
*
* · `shadow-cljs.edn`'s `:testlib :exports` map — the module's public names
* and, per name, the var each one points at;
* · the hand-written vtables at the bottom of every `defclass` file —
* `(unchecked-set proto "camelCase" (fn …))` and the two `Object.defineProperty`
* getters — which are ALSO what `:advanced` cannot rename and what
* `externs.js` therefore has to pin;
* · constructor arity.
*
* None of that was pinned by anything. A rename, a dropped `_` seam or a
* constructor gaining a parameter would surface as a test failure somewhere
* downstream — or, for the `_` seams no test drives yet, as nothing at all.
* `mailbox` shipped two catastrophic defects with a green suite for exactly
* that reason.
*
* So this module reduces the façade to a JSON value, and
* test/source-hygiene.test.mjs asserts it EXACTLY equals test/vectors/facade.json.
* The contract can still be changed — it just cannot be changed by accident, and
* the diff of the change is legible in the vector.
*
* Scope is this repo's own namespaces (`src/bursa`), the same tree the rest of
* source-hygiene lints. The shared rooms core lives in ardegazu-rooms-kit and is
* that repo's to freeze; what IS checked here is the one place the two meet —
* every name in `externs.js`, wherever it is defined, must still be installed by
* some `unchecked-set proto` somewhere on this build's classpath, because an
* externs entry whose definition moved is a green build and a dead app.
*
* There is deliberately NO generator script for the vector. Changing the façade
* is a decision, not a refresh: the failing `deepEqual` prints the whole diff,
* and a change you meant to make is applied by editing test/vectors/facade.json
* to match — reading every moved line as you go. (If the diff is genuinely too
* large to hand-edit, that is itself the signal to stop and ask.)
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { readForms, sym } from "./cljs-reader.mjs";
/** Every .cljs under `dir`, sorted, as [relativePath, absolutePath]. */
export function cljsFiles(dir, base = dir) {
const out = [];
for (const name of readdirSync(dir).sort()) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...cljsFiles(p, base));
else if (p.endsWith(".cljs")) out.push([p.slice(base.length + 1), p]);
}
return out.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
}
/** The text of a "string" node, unescaped enough for identifiers. */
const strText = (src, nd) => JSON.parse(src.slice(nd.start, nd.end));
/** `(fn [a b] …)` / `(fn name [a b] …)` → 2; anything else → null. */
function fnArity(nd) {
if (sym(nd) !== "fn" && sym(nd) !== "fn*") return null;
const vec = nd.children.find((c) => c.type === "vector");
if (!vec) return null;
return vec.children.filter((c) => c.type === "symbol").length;
}
/**
* The façade of one .cljs file:
* classes — defclass name → constructor arity (the `this` param dropped)
* protos — the `(let [proto (.-prototype X)] …)` blocks, IN SOURCE ORDER,
* each an ordered list of [installedName, fnArity] and
* [getterName, "get"|"set"] entries
*/
export function fileFacade(src) {
const nodes = readForms(src);
const classes = {};
const protos = {};
for (const nd of nodes) {
if (sym(nd) !== "defclass") continue;
const name = nd.children[1]?.text;
const ctor = nd.children.find((c) => sym(c) === "constructor");
const vec = ctor?.children?.find((c) => c.type === "vector");
if (!name || !vec) continue;
classes[name] = vec.children.filter((c) => c.type === "symbol").length - 1;
}
for (const nd of nodes) {
if (sym(nd) !== "let") continue;
const bindings = nd.children[1];
if (bindings?.type !== "vector") continue;
const [lhs, rhs] = bindings.children;
if (lhs?.text !== "proto" || sym(rhs) !== ".-prototype") continue;
const cls = rhs.children[1]?.text;
if (!cls) continue;
const entries = [];
for (const form of nd.children.slice(2)) {
const head = sym(form);
if (head === "unchecked-set" && form.children[1]?.text === "proto" &&
form.children[2]?.type === "string") {
entries.push([strText(src, form.children[2]), fnArity(form.children[3])]);
} else if (head === "js/Object.defineProperty" && form.children[1]?.text === "proto" &&
form.children[2]?.type === "string") {
// (j/ordered "get" (fn …) "configurable" true) — record which accessors
// the descriptor declares, in the order it declares them
const desc = form.children[3];
const accessors = [];
for (let i = 1; i < (desc?.children?.length ?? 0); i += 2) {
const k = desc.children[i];
if (k?.type !== "string") continue;
const key = strText(src, k);
if (key === "get" || key === "set") accessors.push(key);
}
entries.push([strText(src, form.children[2]), accessors.join("+") || "accessor"]);
}
}
protos[cls] = entries;
}
return { classes, protos };
}
/** The value node under `key` in map node `m`, or null. */
function valueAt(m, key) {
if (m?.type !== "map") return null;
for (let i = 0; i < m.children.length; i += 2) {
if (m.children[i]?.text === key) return m.children[i + 1] ?? null;
}
return null;
}
/** Depth-first: the first map node at or under `nd` carrying `key`. */
function findMapWith(nd, key) {
if (!nd) return null;
if (nd.type === "map" && valueAt(nd, key)) return nd;
for (const c of nd.children ?? []) {
const hit = findMapWith(c, key);
if (hit) return hit;
}
return null;
}
/**
* `:exports {Name ns/var …}` out of ONE NAMED build in shadow-cljs.edn.
*
* The build name is not decoration. This used to take the first `:exports` map
* anywhere in the file, which was unambiguous only while exactly one build had
* one — so the freeze silently depended on the ORDER builds are written in, and
* a second `:exports` map arriving above `:testlib` would have re-pointed the
* frozen contract at a different build with no test going red. `:viewlib` (the
* Phase 5 view build) is that second map. Named lookup, and source-hygiene
* probes that the two names really do resolve differently.
*/
export function exportsOf(edn, build = ":testlib") {
const top = readForms(edn).find((n) => n.type === "map");
const m = valueAt(valueAt(top, ":builds"), build);
const exports = valueAt(findMapWith(m, ":exports"), ":exports");
if (exports?.type !== "map") throw new Error(`facade: no ${build} :exports map in shadow-cljs.edn`);
const out = {};
for (let k = 0; k < exports.children.length; k += 2) {
out[exports.children[k].text] = exports.children[k + 1].text;
}
return out;
}
/** The ordered `SuetaAppExterns.<name>;` list out of externs.js. */
export const externsOf = (js) => [...js.matchAll(/^SuetaAppExterns\.([A-Za-z0-9_$]+);/gm)].map((m) => m[1]);
/**
* The whole façade snapshot. `srcDir` is this repo's own namespace tree;
* `extraProtoDirs` are trees scanned ONLY to resolve externs names (the
* classpathed rooms-kit sources), never recorded in the vector.
*/
export function buildFacade({ srcDir, shadowEdn, externsJs, extraProtoDirs = [] }) {
const files = {};
const installed = new Set();
const collect = (path) => {
const src = readFileSync(path, "utf8");
const f = fileFacade(src);
for (const entries of Object.values(f.protos)) for (const [n] of entries) installed.add(n);
return f;
};
for (const [rel, abs] of cljsFiles(srcDir)) {
const f = collect(abs);
if (Object.keys(f.classes).length === 0 && Object.keys(f.protos).length === 0) continue;
files[rel] = f;
}
for (const dir of extraProtoDirs) for (const [, abs] of cljsFiles(dir)) collect(abs);
return {
exports: exportsOf(readFileSync(shadowEdn, "utf8")),
externs: externsOf(readFileSync(externsJs, "utf8")),
files,
/** not part of the frozen value — the cross-check source-hygiene runs */
_installed: installed,
};
}
|