Skip to content

Commit 6356faf

Browse files
committed
fix(build): build-time stdout overwrote itself on a file; non-wasm bootstrap could not compile
Smell-audited: 1: Smallest-diff bias rejected — the wasm gate became ONE predicate, not a second skip The one-line `if (build_options.wasm) continue` in cache_gen would have been the smaller fix, but that shape IS the bug (two sites disagreeing about one gate), so it would have re-armed the same failure for the next gated file. Two independent defects, both found while measuring the build path. (1) `cljw build` lost build-time output when stdout was a regular file. Build-time eval runs the entry's top-level forms (ADR-0034 A1-D2), but `buildArtifact` left `rt.stdout` null, so each `println` built a fresh offset-tracking writer that restarted at file offset 0. On a seekable stdout every form overwrote the previous one: printf '(println 1)(println 2)(println 3)(println 4)(println 5)\n' > five.clj cljw build five.clj -o f.bin > f.log # f.log was "5\n", 2 bytes A pipe hid it (no seek), and the e2e sent build output to /dev/null or captured it through command substitution, so nothing in the suite ever redirected a build to a file. Same fix `tryRunEmbedded` already carries (D-096): thread the CLI's one process-shared writer in and flush it before the ADR-0176 exit barrier, which may hard-exit and drop a buffered tail. New e2e cases (12)/(13) pin the file-redirect case on both the build and run sides. (2) A build without `-Dwasm` could not compile its own bootstrap. `cache_gen` AOT-compiled every `FILES` entry while `lookupEmbeddedFile` refused to serve `cljw.wasm` without the flag, so the flagless build died analysing a file it could never load ("No namespace: 'wasm'" — `cljw.wasm`'s `wasm/…` call sites resolve at ANALYZE time, so the comment claiming load-time safety in any build was wrong). The gate is now one predicate, `isActiveFile`, read by both the resolver and `ACTIVE_FILES`; every walk that compiles, emits, or replays bundled files iterates `ACTIVE_FILES`, while `FILES` keeps its full shape so `lookupEmbeddedFile` stays index-keyed. A unit test pins the coupling in either build config so the next gated file cannot be added to one walk and forgotten in the other. Verified: the `-Dwasm` binary is byte-identical in size (7,368,808 B), so the shipped configuration is untouched; the flagless build now succeeds at 5,035,432 B (-31.7%) with `cljw.wasm` honestly unresolvable.
1 parent ab84126 commit 6356faf

5 files changed

Lines changed: 128 additions & 28 deletions

File tree

‎src/app/builder.zig‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,9 @@ pub fn buildEnvelope(
185185
return serialize.serializeEnvelope(allocator, all.items, null, components.items, &pb, true);
186186
}
187187

188-
/// AOT-compile the WHOLE eager bootstrap (`bootstrap.FILES` — clojure.core +
189-
/// the 23 non-core bundled `.clj` libs) to one bytecode envelope, mirroring
188+
/// AOT-compile the WHOLE eager bootstrap (`bootstrap.ACTIVE_FILES` — clojure.core
189+
/// + the non-core bundled `.clj` libs this build carries) to one bytecode
190+
/// envelope, mirroring
190191
/// `bootstrap.loadCoreFiles`'s per-file read→analyze→compile→eval loop but
191192
/// CAPTURING each form's compiled chunk (D-452 Part B / ADR-0056 Cycle 3). The
192193
/// load path (`bootstrap.loadCoreAot`) runs this via `driver.runEnvelope`
@@ -361,9 +362,17 @@ const BuildSpec = union(enum) {
361362
/// per `spec`, and append it to a copy of the running cljw binary as a
362363
/// self-contained `"CLJC"`-trailered executable. `buildFile` / `buildMainFile`
363364
/// are the two thin entry points (F-009/F-011 — one setup, one write tail).
364-
fn buildArtifact(io: std.Io, gpa: std.mem.Allocator, arena: std.mem.Allocator, out_path: []const u8, load_paths: []const []const u8, spec: BuildSpec) !void {
365+
///
366+
/// `stdout` is the CLI's one process-shared writer, threaded in for the same
367+
/// reason `tryRunEmbedded` takes it (D-096): build-time eval runs the entry's
368+
/// top-level forms, and their `println` must land on that single offset-tracking
369+
/// writer. A fresh per-call writer restarts at file offset 0, so on a seekable
370+
/// stdout (`cljw build app.clj > build.log`) every form overwrote the previous
371+
/// one and only the last survived.
372+
fn buildArtifact(io: std.Io, gpa: std.mem.Allocator, arena: std.mem.Allocator, stdout: *std.Io.Writer, out_path: []const u8, load_paths: []const []const u8, spec: BuildSpec) !void {
365373
var rt = Runtime.init(io, gpa);
366374
defer rt.deinit();
375+
rt.stdout = stdout;
367376
var env = try Env.init(&rt);
368377
defer env.deinit();
369378
driver.installVTable(&rt);
@@ -395,6 +404,9 @@ fn buildArtifact(io: std.Io, gpa: std.mem.Allocator, arena: std.mem.Allocator, o
395404
var ow = out.writer(io, &wbuf);
396405
try ow.interface.writeAll(artifact);
397406
try ow.interface.flush();
407+
// Build-time eval's own output, flushed BEFORE the exit barrier below —
408+
// that barrier may hard-exit, which would drop a buffered tail.
409+
try stdout.flush();
398410
// Exit barrier (ADR-0176): build-time eval may have spawned workers;
399411
// hard-exit if any is live rather than tear down under it (D-548(a)).
400412
@import("../runtime/thread.zig").exitBarrier(&rt);
@@ -404,18 +416,18 @@ fn buildArtifact(io: std.Io, gpa: std.mem.Allocator, arena: std.mem.Allocator, o
404416
/// payload envelope and append it to a copy of the running cljw binary as a
405417
/// self-contained artifact with a `"CLJC"` trailer (ADR-0034 amendment 1/2).
406418
/// Build-time eval runs top-level side effects (A1-D2). The output is executable.
407-
pub fn buildFile(io: std.Io, gpa: std.mem.Allocator, arena: std.mem.Allocator, in_path: []const u8, out_path: []const u8, load_paths: []const []const u8) !void {
419+
pub fn buildFile(io: std.Io, gpa: std.mem.Allocator, arena: std.mem.Allocator, stdout: *std.Io.Writer, in_path: []const u8, out_path: []const u8, load_paths: []const []const u8) !void {
408420
const source = try readFileAll(io, gpa, in_path);
409421
defer gpa.free(source);
410-
return buildArtifact(io, gpa, arena, out_path, load_paths, .{ .script = .{ .src = source, .label = in_path } });
422+
return buildArtifact(io, gpa, arena, stdout, out_path, load_paths, .{ .script = .{ .src = source, .label = in_path } });
411423
}
412424

413425
/// `cljw build -m <ns> [args…] -o <out>` (main mode, ADR-0034 am4): embed the
414426
/// require closure for `<ns>` + an entry manifest; the produced binary invokes
415427
/// `(<ns>/-main args)` at run, NOT at build (so a server `-main` does not hang
416428
/// the build).
417-
pub fn buildMainFile(io: std.Io, gpa: std.mem.Allocator, arena: std.mem.Allocator, ns: []const u8, args: []const []const u8, out_path: []const u8, load_paths: []const []const u8) !void {
418-
return buildArtifact(io, gpa, arena, out_path, load_paths, .{ .main = .{ .ns = ns, .args = args } });
429+
pub fn buildMainFile(io: std.Io, gpa: std.mem.Allocator, arena: std.mem.Allocator, stdout: *std.Io.Writer, ns: []const u8, args: []const []const u8, out_path: []const u8, load_paths: []const []const u8) !void {
430+
return buildArtifact(io, gpa, arena, stdout, out_path, load_paths, .{ .main = .{ .ns = ns, .args = args } });
419431
}
420432

421433
/// Startup hook: if the running binary carries an embedded payload trailer,
@@ -657,7 +669,7 @@ test "aot: full bootstrap round-trips — a NON-core lib restores from bytecode
657669
var table = macro_dispatch.Table.init(A);
658670
defer table.deinit();
659671
try bootstrap.setupCorePrefix(&rt, &env, &table);
660-
blob = try buildBootstrapEnvelope(A, &rt, &env, &table, arena, bootstrap.FILES);
672+
blob = try buildBootstrapEnvelope(A, &rt, &env, &table, arena, bootstrap.ACTIVE_FILES);
661673
}
662674
defer A.free(blob);
663675

@@ -676,8 +688,8 @@ test "aot: full bootstrap round-trips — a NON-core lib restores from bytecode
676688
try bootstrap.setupCorePrefix(&rt, &env, &table);
677689
// The whole eager bootstrap from the region blob (no .clj re-parse) — mirrors
678690
// the loadCoreAot path: pre-register loaded_libs, then run every region in order.
679-
try bootstrap.markFilesLoaded(&rt, bootstrap.FILES);
680-
try bootstrap.runEagerRegions(&rt, &env, arena, blob, bootstrap.FILES);
691+
try bootstrap.markFilesLoaded(&rt, bootstrap.ACTIVE_FILES);
692+
try bootstrap.runEagerRegions(&rt, &env, arena, blob, bootstrap.ACTIVE_FILES);
681693

682694
// clojure.string/upper-case is a non-core lib var: present ONLY if the
683695
// non-core AOT chunks ran. Call it to prove the restored fn dispatches.

‎src/app/cli.zig‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,13 @@ pub fn dispatch(init: std.process.Init) !void {
241241
std.process.exit(1);
242242
}
243243
if (main_ns) |mns| {
244-
builder.buildMainFile(io, gpa, arena, mns, main_args.items, out, deps.load_paths) catch |err| {
244+
builder.buildMainFile(io, gpa, arena, stdout, mns, main_args.items, out, deps.load_paths) catch |err| {
245245
try stderr.print("build failed: {s}\n", .{@errorName(err)});
246246
try stderr.flush();
247247
std.process.exit(1);
248248
};
249249
} else {
250-
builder.buildFile(io, gpa, arena, in_path.?, out, deps.load_paths) catch |err| {
250+
builder.buildFile(io, gpa, arena, stdout, in_path.?, out, deps.load_paths) catch |err| {
251251
try stderr.print("build failed: {s}\n", .{@errorName(err)});
252252
try stderr.flush();
253253
std.process.exit(1);

‎src/cache_gen.zig‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ pub fn main(init: std.process.Init) !void {
4646
// Part B — the non-core libs no longer re-parse from source every startup).
4747
try bootstrap.setupCorePrefix(&rt, &env, &macro_table);
4848

49-
const payload = try builder.buildBootstrapEnvelope(gpa, &rt, &env, &macro_table, arena, bootstrap.FILES);
49+
const payload = try builder.buildBootstrapEnvelope(gpa, &rt, &env, &macro_table, arena, bootstrap.ACTIVE_FILES);
5050
defer gpa.free(payload);
5151

5252
const out = try std.Io.Dir.cwd().createFile(io, out_path, .{ .truncate = true });
@@ -64,8 +64,8 @@ pub fn main(init: std.process.Init) !void {
6464
var aw: std.Io.Writer.Allocating = .init(gpa);
6565
errdefer aw.deinit();
6666
const w = &aw.writer;
67-
try w.writeInt(u32, @intCast(bootstrap.FILES.len), .little);
68-
for (bootstrap.FILES) |file| {
67+
try w.writeInt(u32, @intCast(bootstrap.ACTIVE_FILES.len), .little);
68+
for (bootstrap.ACTIVE_FILES) |file| {
6969
const src = file.source.?;
7070
const packed_bytes = try serialize.flateCompress(gpa, src);
7171
defer gpa.free(packed_bytes);

‎src/lang/bootstrap.zig‎

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
//!
77
//! ### Multi-file loader (ADR-0032)
88
//!
9-
//! `loadCore` iterates `FILES`, a flat table of `{label, source}`
10-
//! pairs. The loader carries **no** namespace knowledge — each `.clj`
9+
//! `loadCore` iterates `ACTIVE_FILES` — `FILES` (a flat table of
10+
//! `{label, source, wasm_only}`) minus the entries this build's options
11+
//! gate out. The loader carries **no** namespace knowledge — each `.clj`
1112
//! file declares its own namespace via a leading `(in-ns 'foo.bar)`
1213
//! form (analyzer special form per ADR-0032). After the last file,
1314
//! `current_ns` resets to `user/` so the REPL prompt lands there.
@@ -57,8 +58,19 @@ pub const FileEntry = struct {
5758
/// flate-compressed in the `bootstrap_sources` blob; read via
5859
/// `sourceText` (C5'-b, ADR-0173).
5960
source: ?[]const u8,
61+
/// The file rides the `wasm/` primitive namespace, which is registered
62+
/// only under `-Dwasm`. Its `wasm/…` call sites resolve at ANALYZE time,
63+
/// so a build without the flag cannot compile it at all.
64+
wasm_only: bool = false,
6065
};
6166

67+
/// Is `entry` part of THIS build's bundled set? The ONE predicate behind both
68+
/// the walks (`ACTIVE_FILES`) and the resolver (`lookupEmbeddedFile`), so a
69+
/// gated file can never be active in one and inactive in the other.
70+
pub fn isActiveFile(entry: FileEntry) bool {
71+
return build_options.wasm or !entry.wasm_only;
72+
}
73+
6274
/// Bootstrap source table — load order matters. `core.clj` must be
6375
/// first because it lands `(def not ...)` and the future
6476
/// `clojure.core` companions that subsequent files may reference.
@@ -79,8 +91,10 @@ pub const EAGER_NS = std.StaticStringMap(void).initComptime(.{
7991
// cljw-SPECIFIC (not in clj's auto-set): the Wasm-component `:require` desugar
8092
// (ADR-0135 am1) emits `(cljw.wasm/require-component-libspec …)` which is resolved
8193
// at ANALYZE time — before its own `(require 'cljw.wasm)` prelude EVALs — so the
82-
// ns must already be loaded. Eager (91 lines, def-only top level — its `wasm/`
83-
// primitive refs are resolved at call time, so loading is safe in any build).
94+
// ns must already be loaded. Eager only where it EXISTS: its own `wasm/…` refs
95+
// are likewise analyze-time, so the file is `wasm_only` and leaves ACTIVE_FILES
96+
// (hence this eager set, which is only ever consulted per ACTIVE_FILES entry)
97+
// in a build without `-Dwasm`.
8498
.{"cljw.wasm"},
8599
});
86100

@@ -142,9 +156,11 @@ pub const FILES: []const FileEntry = &.{
142156
// clojure.test (FILES[10]) + clojure.stacktrace (FILES[19]). Appended last.
143157
.{ .label = "<clojure.test.tap>", .source = embedSrc("clj/clojure/test/tap.clj") },
144158
// cljw.wasm (W1, D-404) — require-a-component over the wasm/ primitives.
145-
// Require-on-demand AND wasm-gated (the lookup below only serves it under
146-
// `-Dwasm`, since the wasm/ ns it rides is absent otherwise). Appended last.
147-
.{ .label = "<cljw.wasm>", .source = embedSrc("clj/cljw/wasm.clj") },
159+
// Require-on-demand AND wasm-gated: `wasm_only` drops it from ACTIVE_FILES
160+
// and from the lookup below in a build without `-Dwasm`. Its `wasm/…` call
161+
// sites resolve at ANALYZE time, so a non-`-Dwasm` build cannot compile it
162+
// at all — the file must leave every walk, not merely the resolver.
163+
.{ .label = "<cljw.wasm>", .source = embedSrc("clj/cljw/wasm.clj"), .wasm_only = true },
148164
// clojure.spec.gen.alpha + clojure.spec.alpha — official stdlib (ships in
149165
// clojure.jar), so eager-bundled (the stdlib-eager / contrib-completeness
150166
// policy). gen loads FIRST (alpha `(:require clojure.spec.gen.alpha)`).
@@ -172,13 +188,28 @@ pub const FILES: []const FileEntry = &.{
172188
.{ .label = "<clojure.repl>", .source = embedSrc("clj/clojure/repl.clj") },
173189
};
174190

191+
/// The build-active subset of `FILES`. **Every walk that compiles, emits, or
192+
/// replays bundled files iterates this, never `FILES`** — `FILES` keeps its
193+
/// full shape (and its stable indices) so `lookupEmbeddedFile` stays index-keyed.
194+
pub const ACTIVE_FILES: []const FileEntry = blk: {
195+
var active: [FILES.len]FileEntry = undefined;
196+
var n: usize = 0;
197+
for (FILES) |file| {
198+
if (!isActiveFile(file)) continue;
199+
active[n] = file;
200+
n += 1;
201+
}
202+
const frozen = active;
203+
break :blk frozen[0..n];
204+
};
205+
175206
/// The bundled `.clj` text for `label` (C5'-b). Raw mode (cache_gen)
176207
/// returns the embedded slice; the shipped cljw decompresses from the
177208
/// `bootstrap_sources` blob into `rt.load_arena` (session lifetime — the
178209
/// text backs SourceContext renders and source replays). Returns null for
179210
/// labels outside the bundled set.
180211
pub fn sourceText(rt: *Runtime, label: []const u8) ?[]const u8 {
181-
for (FILES) |file| {
212+
for (ACTIVE_FILES) |file| {
182213
if (!std.mem.eql(u8, file.label, label)) continue;
183214
if (file.source) |raw| return raw;
184215
break;
@@ -257,7 +288,8 @@ fn lookupEmbeddedFile(ns_name: []const u8) ?FileEntry {
257288
// cljw.wasm rides the `wasm/` primitive ns, which only exists in a `-Dwasm`
258289
// build — so it is resolvable only there (a non-wasm build reports the ns as
259290
// not found, honest, rather than failing on an unresolvable `wasm/…` later).
260-
if (build_options.wasm and std.mem.eql(u8, ns_name, "cljw.wasm")) return FILES[23];
291+
// The gate is `isActiveFile`, the SAME predicate ACTIVE_FILES uses.
292+
if (std.mem.eql(u8, ns_name, "cljw.wasm")) return if (isActiveFile(FILES[23])) FILES[23] else null;
261293
if (std.mem.eql(u8, ns_name, "clojure.spec.gen.alpha")) return FILES[24];
262294
if (std.mem.eql(u8, ns_name, "clojure.spec.alpha")) return FILES[25];
263295
if (std.mem.eql(u8, ns_name, "clojure.core.specs.alpha")) return FILES[26];
@@ -305,8 +337,8 @@ pub fn loadCore(
305337
// ADR-0163: pre-register before the source files eval so each file's
306338
// intra-bootstrap `(:require other-bootstrap-lib)` is a loaded_libs no-op
307339
// (mirrors the AOT path; without it the new loadOrFindNs guard re-parses).
308-
try markFilesLoaded(rt, FILES);
309-
try loadCoreFiles(arena, rt, env, macro_table, FILES);
340+
try markFilesLoaded(rt, ACTIVE_FILES);
341+
try loadCoreFiles(arena, rt, env, macro_table, ACTIVE_FILES);
310342
try finalizeUserNs(rt, env);
311343
}
312344

@@ -643,15 +675,15 @@ pub fn loadCoreAot(
643675
// (e.g. spec.alpha requires walk — both eager). Without this the loaded_libs
644676
// guard would re-parse. Run only the eager regions; every other ns replays from
645677
// its region on first `require` (loadOrFindNs → loadRegionNamespace).
646-
for (FILES) |file| {
678+
for (ACTIVE_FILES) |file| {
647679
const ns_name = nsNameFromLabel(file.label);
648680
if (isEagerNs(ns_name)) try markOneLoaded(rt, ns_name);
649681
}
650682
// v7 (ADR-0173): the blob-level shared constant pool — parsed once for
651683
// the whole eager set; lazy requires re-parse per replay (cheap slice walk).
652684
var blob_pool = try serialize.readBlobPool(rt.gpa, bootstrap_blob);
653685
defer if (blob_pool) |*cp| cp.deinit(rt.gpa);
654-
for (FILES) |file| {
686+
for (ACTIVE_FILES) |file| {
655687
const ns_name = nsNameFromLabel(file.label);
656688
if (!isEagerNs(ns_name)) continue;
657689
// A missing region means the embedded blob is malformed (build bug).
@@ -817,6 +849,33 @@ test "embeddedResolver returns null for unknown namespaces" {
817849
try testing.expect((try embeddedResolver(&rt, "clojure")) == null);
818850
}
819851

852+
test "ACTIVE_FILES and lookupEmbeddedFile gate on the same predicate" {
853+
// The non-wasm bootstrap break: cache_gen AOT-compiled every FILES entry
854+
// while lookupEmbeddedFile refused to serve `cljw.wasm` without `-Dwasm`,
855+
// so a build with the flag off died analysing a file it could never load
856+
// ("No namespace: 'wasm'"). Both sides now read `isActiveFile`, and this
857+
// test pins the coupling in EITHER build config — so the next gated file
858+
// cannot be added to one walk and forgotten in the other.
859+
//
860+
// Only the INACTIVE direction is asserted: an active file need not be
861+
// name-resolvable (`<clojure.core-meta>` and `<clojure.repl>` deliberately
862+
// have no `lookupEmbeddedFile` row), but an inactive one must be absent
863+
// from BOTH — that asymmetry is exactly what the break violated.
864+
for (FILES) |file| {
865+
if (isActiveFile(file)) continue;
866+
// Inactive ⇒ unresolvable, and absent from the walked set.
867+
try testing.expect(lookupEmbeddedFile(nsNameFromLabel(file.label)) == null);
868+
for (ACTIVE_FILES) |active| {
869+
try testing.expect(!std.mem.eql(u8, active.label, file.label));
870+
}
871+
}
872+
// Exactly one gated file today; ACTIVE_FILES is FILES under `-Dwasm`.
873+
try testing.expectEqual(
874+
@as(usize, if (build_options.wasm) FILES.len else FILES.len - 1),
875+
ACTIVE_FILES.len,
876+
);
877+
}
878+
820879
test "installEmbeddedResolver sets rt.require_resolver" {
821880
var th = std.Io.Threaded.init(testing.allocator, .{});
822881
defer th.deinit();

‎test/e2e/phase14_cljw_build.sh‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,33 @@ got_mo=$(cd "$TMP/empty" && "$TMP/mo" 7)
151151
[[ "$got_mo" == "via main-opts: [7]" ]] || fail "main_opts_drive: got '$got_mo', want 'via main-opts: [7]'"
152152
echo "PASS main_opts_drive -> via main-opts: [7]"
153153

154+
# (12) BUILD-TIME stdout must survive a redirect to a REGULAR FILE. Build-time
155+
# eval runs the entry's top-level forms (A1-D2), and buildArtifact left
156+
# rt.stdout null, so each println built a fresh offset-tracking writer that
157+
# restarted at offset 0 — on a seekable fd every form's output overwrote
158+
# the previous one (5 forms landed as "5\n"). A pipe hid it (no seek), and
159+
# every other case here captures through command substitution, so the whole
160+
# file redirected the build to /dev/null and never saw it. Assert the FILE
161+
# case explicitly: the same one process-shared writer fix as (4), build side.
162+
cat >"$TMP/btstdout.clj" <<'CLJ'
163+
(println 1)
164+
(println 2)
165+
(println 3)
166+
(println 4)
167+
(println 5)
168+
CLJ
169+
"$BIN" build "$TMP/btstdout.clj" -o "$TMP/btstdout" >"$TMP/btstdout.log" 2>&1
170+
got_bt=$(cat "$TMP/btstdout.log")
171+
want_bt=$'1\n2\n3\n4\n5'
172+
[[ "$got_bt" == "$want_bt" ]] || fail "buildtime_stdout_to_file: got '$got_bt', want '$want_bt'"
173+
echo "PASS buildtime_stdout_to_file -> 5 lines, ordered + complete"
174+
175+
# (13) …and the built binary still prints the same 5 lines at RUN, to a file
176+
# too. Pins that the build-side writer fix did not consume or reorder the
177+
# payload's own run-time output.
178+
"$TMP/btstdout" >"$TMP/btstdout.run.log" 2>&1
179+
got_btrun=$(cat "$TMP/btstdout.run.log")
180+
[[ "$got_btrun" == "$want_bt" ]] || fail "buildtime_stdout_run: got '$got_btrun', want '$want_bt'"
181+
echo "PASS buildtime_stdout_run -> 5 lines at run too"
182+
154183
echo "ALL phase14_cljw_build PASS"

0 commit comments

Comments
 (0)