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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261 | // The module split, checked where it is DECIDED — the require graph — not only
// where it shows up.
//
// shadow-cljs puts a namespace in the deepest module that needs it, so which
// chunk a dependency lands in is decided entirely by who `:require`s it. Three
// things ride on that here:
//
// · :main is first paint (~111 KB gz). The whole point of the :room split is
// that a visitor who never opens a room downloads no libp2p, no Helia, no
// OrbitDB — and now no promesa.
// · `sueta.room` and `sueta.social` are reached ONLY through
// `shadow.lazy/loadable`, never a require. A require would collapse both
// dynamic boundaries into the initial chunk and nothing would fail: the app
// would work perfectly and cost four times as much to open.
// · promesa is the one async idiom, adopted at `sueta.room` and gated there
// (deps.edn records why). `sueta.fx` is the deliberate exception that has to
// STAY an exception: both modules use it, so it must never grow a promesa
// require of its own.
//
// scripts/build.mjs already greps the emitted chunks for the p2p and social-kit
// boundaries, and that check is the one that cannot be fooled — but it only
// runs in a release build, it reports a symptom rather than the cause, and for
// promesa there is no reliable literal to grep once `:advanced` has renamed
// everything (its strings survive, but a gate resting on a library's private
// error messages is a gate that breaks on upgrade). This runs in `npm test`,
// names the offending require, and asserts in BOTH directions: promesa must be
// absent from :main AND present in :room, so the check cannot pass because
// promesa quietly stopped being used at all.
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { strip } from "./helpers/cljs-reader.mjs";
/**
* Everything shadow compiles for the :app build, in classpath order — this list
* must mirror `client/deps.edn`'s `:paths`. Our own kits are CLASSPATH SOURCE,
* so their namespaces are part of the module split and have to be walked like
* any other: with id-kit and social-kit missing from here, every namespace they
* define would land in `unresolved` and the FIRST_PAINT assertion below would
* pass without ever having looked at what the chips drag in.
*/
const ROOTS = [
new URL("../src", import.meta.url).pathname,
new URL("../node_modules/ardegazu-rooms-kit/src", import.meta.url).pathname,
new URL("../node_modules/ardegazu-id-kit/src", import.meta.url).pathname,
new URL("../node_modules/ardegazu-social-kit/src", import.meta.url).pathname,
];
/** The balanced form starting at `open`, contents only. */
function balanced(s, open) {
let d = 0;
for (let i = open; i < s.length; i++) {
const c = s[i];
if ("([{".includes(c)) d++;
else if (")]}".includes(c) && --d === 0) return s.slice(open + 1, i);
}
return null;
}
/** The head symbol of every top-level form in a `:require` body. */
function heads(body) {
const out = [];
let d = 0;
let cur = "";
for (const ch of body) {
if ("([{".includes(ch)) d++;
if (")]}".includes(ch)) d--;
if (d === 0 && /\s/.test(ch)) {
if (cur) out.push(cur);
cur = "";
} else cur += ch;
}
if (cur) out.push(cur);
return out
.map((f) => (f.startsWith("[") || f.startsWith("(") ? f.slice(1) : f).split(/[\s\]()]/)[0])
.filter(Boolean);
}
/**
* The namespaces one file requires. `strip` blanks strings first, so a string
* specifier — `["blockstore-idb" :refer (IDBBlockstore)]`, a FOREIGN npm
* package, never a namespace — leaves nothing behind to mistake for one.
* Our own kits stopped being string requires: they are classpath source, so
* `[ardegazu.id.identity :refer (Identity)]` is a namespace like any other and
* is walked like any other.
*/
export function requiresOf(src) {
const s = strip(src);
const i = s.indexOf("(ns ");
if (i < 0) return [];
const ns = balanced(s, i);
if (ns === null) return [];
const out = [];
for (const kw of [":require", ":require-macros"]) {
let at = 0;
for (;;) {
const k = ns.indexOf(kw, at);
if (k < 0) break;
at = k + 1;
const open = ns.lastIndexOf("(", k);
if (open < 0) continue;
const body = balanced(ns, open);
if (body === null) continue;
const head = body.trimStart();
if (!head.startsWith(kw)) continue; // `(:require …)`, not `(foo :require …)`
out.push(...heads(head.slice(kw.length)));
}
}
return [...new Set(out)];
}
/** The file a namespace compiles from, or null when it is not on our paths. */
const pathOf = (ns) => {
const rel = ns.replace(/-/g, "_").replace(/\./g, "/");
for (const r of ROOTS) {
for (const ext of [".cljs", ".cljc"]) {
const p = join(r, rel + ext);
if (existsSync(p)) return p;
}
}
return null;
};
/**
* Every namespace transitively required from `entry`, plus the ones that named
* a namespace we could not open. `unresolved` is NOT swallowed: a name still
* counts as reached even when its source is somewhere we do not scan, which is
* exactly what makes the promesa assertion meaningful — promesa lives in a jar.
*/
function closure(entry) {
const seen = new Set();
const unresolved = new Set();
const q = [entry];
while (q.length) {
const ns = q.pop();
if (seen.has(ns)) continue;
seen.add(ns);
const p = pathOf(ns);
if (!p) {
unresolved.add(ns);
continue;
}
for (const r of requiresOf(readFileSync(p, "utf8"))) if (!seen.has(r)) q.push(r);
}
seen.delete(entry);
return { seen: [...seen].sort(), unresolved: [...unresolved].sort() };
}
const MAIN = closure("sueta.main");
const ROOM = closure("sueta.room");
/**
* The :main module's namespace closure, EXACT. Not a floor and not a filter —
* every name a visitor downloads before they have opened anything. Adding one
* is allowed; adding one without noticing is what this stops.
*
* The seven `ardegazu.id.*` / `ardegazu.social.*` names are what app/lobby's two
* chips cost now that the kits are classpath source instead of npm dists:
* `bridge-client` (IdBridge) pulls identity → crypto, plus profile and
* protocol; `join` (the home/paste chip) pulls only `text`. That is the whole
* of it — no `net`, no `envelope`, no `presence`, and above all no
* `ardegazu.social.index`, which would re-export the entire engine into first
* paint. This list is the gate that says so.
*/
const FIRST_PAINT = [
"ardegazu.id.bridge-client",
"ardegazu.id.crypto",
"ardegazu.id.identity",
"ardegazu.id.profile",
"ardegazu.id.protocol",
"ardegazu.rooms.js",
"ardegazu.rooms.lib.crypto",
"ardegazu.social.join",
"ardegazu.social.text",
"clojure.string",
"replicant.dom",
"shadow.cljs.modern",
"shadow.lazy",
"sueta.app.lobby",
"sueta.app.migrate-storage",
"sueta.app.rooms",
"sueta.app.view",
"sueta.config",
"sueta.fx",
"sueta.i18n",
"sueta.i18n.en",
"sueta.i18n.hu",
"sueta.i18n.ro",
"sueta.i18n.runtime",
"sueta.wire",
];
test("the :main closure is exactly the first-paint set", () => {
assert.deepEqual(MAIN.seen, FIRST_PAINT);
});
test("promesa is required from :room and from nothing :main can reach", () => {
assert.ok(!MAIN.seen.some((ns) => ns.startsWith("promesa.")), `:main reached ${MAIN.seen}`);
// the other direction: the gate must not be able to pass by promesa vanishing
assert.deepEqual(ROOM.seen.filter((ns) => ns.startsWith("promesa.")), ["promesa.core"]);
});
test("sueta.fx is shared, and stays promesa-free", () => {
// it is the one namespace both modules use, which is why it is the one that
// could silently drag promesa across the boundary
assert.ok(MAIN.seen.includes("sueta.fx"));
assert.ok(ROOM.seen.includes("sueta.fx"));
assert.deepEqual(requiresOf(readFileSync(pathOf("sueta.fx"), "utf8")), []);
});
test("the two lazy boundaries are not requires", () => {
for (const ns of ["sueta.room", "sueta.social"]) {
assert.ok(!MAIN.seen.includes(ns), `${ns} is required from :main, not lazy-loaded`);
}
assert.ok(!ROOM.seen.includes("sueta.social"), "sueta.social is required from :room");
});
test("the walker resolved what it claims to have read", () => {
// Absence of evidence is not evidence of absence: if `pathOf` had stopped
// finding this repo's own sources, every check above would pass vacuously.
assert.equal(MAIN.unresolved.length + ROOM.unresolved.length > 0, true);
assert.deepEqual(MAIN.unresolved, ["clojure.string", "replicant.dom", "shadow.cljs.modern", "shadow.lazy"]);
assert.deepEqual(ROOM.unresolved,
["clojure.string", "promesa.core", "replicant.dom", "shadow.cljs.modern", "shadow.lazy"]);
assert.ok(pathOf("sueta.main").endsWith("/src/sueta/main.cljs"));
assert.ok(pathOf("sueta.migrate-storage") === null); // `-` really becomes `_`
assert.ok(pathOf("sueta.app.migrate-storage").endsWith("/src/sueta/app/migrate_storage.cljs"));
assert.equal(pathOf("sueta.does.not.exist"), null);
});
test("the gate would catch the require it exists to forbid", () => {
// main.cljs with one line added. Nothing else about it changes; the build
// would be green, the app would work, and first paint would carry promesa.
const offending = [
"(ns sueta.main",
' (:require [sueta.i18n :as i18n :refer (t)]',
" [promesa.core :as p]",
" [sueta.fx :as fx]",
" [shadow.lazy :as lazy]))",
].join("\n");
assert.ok(requiresOf(offending).includes("promesa.core"));
// and the same shape via :require-macros, which is the quieter way in
const macros = '(ns sueta.main\n (:require-macros [promesa.core :as p]))';
assert.ok(requiresOf(macros).includes("promesa.core"));
});
test("the require reader is not fooled by strings, comments or nested forms", () => {
const src = [
";; (:require [promesa.core :as p]) <- a comment, worth nothing",
'(ns sueta.probe',
' "A docstring mentioning (:require [promesa.core :as p])."',
' (:require ["blockstore-idb" :refer (IDBBlockstore)]',
" [sueta.config :as config]",
" sueta.wire",
" [ardegazu.rooms.lib.net :as net]))",
"(defn f [] (:require x))",
].join("\n");
assert.deepEqual(requiresOf(src), ["sueta.config", "sueta.wire", "ardegazu.rooms.lib.net"]);
});
|