wcc/ww: self-hosted deterministic ar-writer + archive dup-detect (M3-tail c5a, #62)

Per-package .a archives are written by a self-hosted deterministic ar
writer (zeroed mtime/uid/gid, fixed mode 100644, stable member order)
so cstage and wwstage emit byte-identical archives. The linker
force-loads the root .o positionally and pulls deps from .a; a
post-pull PASS-3 over unloaded members reports duplicate symbols
through the archive (#31). Both stages mirrored (cmd/ + selfhost/).

USER-ruled D2 (self-hosted ar writer); pike P1/P2 link model. Gate
989_separchive_run proves cs.a==ww.a byte-identity, 3x-determinism,
link-consumes-.a (exit 7), and the masked-dup-through-.a loud fire.
This commit is contained in:
2026-06-16 14:54:19 +09:00
parent 9cceb4ca8d
commit 62c7771594
8 changed files with 689 additions and 24 deletions

View File

@@ -565,6 +565,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \
$(BIN)/test_m3sep_run \
$(BIN)/test_sepbuild_run \
$(BIN)/test_sepcycle_dup \
$(BIN)/test_separchive_run \
$(BIN)/test_floatlit_run \
$(BIN)/test_checked_run \
$(BIN)/test_floatarr_run \
@@ -3118,6 +3119,16 @@ $(BIN)/test_sepcycle_dup: test/wcc/989_sepcycle_dup.c $(BIN)/ww $(BIN)/ww_ww \
$(LIB)/libwwrt.a | $(BIN)
$(CC) $(CFLAGS) -o $@ $<
# 989_separchive_run — M3-tail commit-5a gate (#46, #62): per-package `.a`
# substrate (root=.o force-load, deps=.a) + the archive-path #31 dup-detect
# (PASS 3). Asserts build+run, `.a` determinism (3x), cs.a==ww.a byte-id,
# and the masked-dup reject THROUGH the `.a` path (both stages, achievable
# parity). Needs both driver + both compiler + both linker stages + libwwrt.
$(BIN)/test_separchive_run: test/wcc/989_separchive_run.c $(BIN)/ww $(BIN)/ww_ww \
$(BIN)/w6c $(BIN)/w6c_ww $(BIN)/w6a $(BIN)/w6a_ww \
$(BIN)/w6l $(BIN)/w6l_ww $(LIB)/libwwrt.a | $(BIN)
$(CC) $(CFLAGS) -o $@ $<
$(BIN)/test_floatlit_run: test/wcc/989_floatlit_run.c $(BIN)/ww $(BIN)/w6c \
$(BIN)/w6c_ww $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN)
$(CC) $(CFLAGS) -o $@ $<

View File

@@ -227,6 +227,27 @@ load_archive(Lnk *l, const char *path, u8 *buf, u64 len)
}
}
/* Pass 3 (#31, task #62): catch a dup the selective pull MASKED.
* A member that pass 2 left unloaded yet defines a name some other
* object already `defined` is exactly the cross-package collision
* archive selective-pull would silently skip (obj.c:218). Lookup
* (never intern) so the pull predicate / selective-pull is wholly
* untouched → byte-id-neutral on every clean link. Pulled-member
* and direct-`.o` dups stay caught at load by the defined-check
* below in load_image. ww has no weak symbols → a genuine dup. */
for (ArMember *m = head; m; m = m->next) {
if (m->loaded || m->defs == NULL) continue;
for (int i = 0; m->defs[i]; i++) {
Lsym *s = l_lookup(l, m->defs[i]);
if (s != NULL && s->defined) {
fprintf(stderr,
"w6l: %s: duplicate symbol %s\n",
path, m->defs[i]);
l->errs++;
}
}
}
/* Free unloaded members; loaded ones had their bytes consumed
* by load_image (which took the copy). */
while (head) {

View File

@@ -885,11 +885,68 @@ sep_compose_unit(struct sepgraph *g, int pi, const char *scratch,
return 0;
}
/* archive_o — write a deterministic single-member SysV ar archive at
* `apath` wrapping the object at `objpath`. No armap / long-name table:
* w6l reads each member's ELF .symtab directly (obj.c elf_globals) and
* skips '/'-named members, so a package `.a` needs only the global magic,
* one 60-byte member header, and the `.o` bytes (newline-padded to even).
* Zeroed mtime/uid/gid + fixed mode + a fixed member name make the bytes
* a pure function of the `.o` content → cstage `.a` == wwstage `.a`
* (rule 10) and a stable md5 for the 5b cache key. The wwstage twin is
* archiveo (selfhost/cmd/ww/main.ww). */
static int
archive_o(const char *objpath, const char *apath)
{
FILE *in = fopen(objpath, "rb");
if (in == NULL) {
fprintf(stderr, "ww --sep: cannot read %s\n", objpath);
return -1;
}
fseek(in, 0, SEEK_END);
long n = ftell(in);
fseek(in, 0, SEEK_SET);
if (n < 0) { fclose(in); return -1; }
unsigned char *buf = malloc((size_t)n);
if (buf == NULL) { fclose(in); return -1; }
if (fread(buf, 1, (size_t)n, in) != (size_t)n) {
free(buf); fclose(in); return -1;
}
fclose(in);
FILE *out = fopen(apath, "wb");
if (out == NULL) {
fprintf(stderr, "ww --sep: cannot open %s\n", apath);
free(buf);
return -1;
}
fwrite("!<arch>\n", 1, 8, out);
/* sizelint-ok: the 60-byte ar(5) member header and its field offsets
* are a FILE-FORMAT constant, not a type size (CLAUDE.md rule 13). */
char hdr[60];
memset(hdr, ' ', sizeof hdr);
memcpy(hdr + 0, "pkg.o/", 6); /* GNU short-name '/' terminator */
hdr[16] = '0'; /* mtime (zeroed → determinism) */
hdr[28] = '0'; /* uid (zeroed) */
hdr[34] = '0'; /* gid (zeroed) */
memcpy(hdr + 40, "100644", 6); /* mode (fixed octal) */
char sz[12];
int szn = snprintf(sz, sizeof sz, "%lu", (unsigned long)n);
memcpy(hdr + 48, sz, (size_t)szn);
hdr[58] = 0x60; /* member-header magic byte */
hdr[59] = 0x0a;
fwrite(hdr, 1, sizeof hdr, out);
fwrite(buf, 1, (size_t)n, out);
if (n & 1) fputc('\n', out); /* members are 2-byte aligned */
fclose(out);
free(buf);
return 0;
}
/* build_one_sep — the --sep orchestration: discover_deps, reverse_topo,
* the transitive producer loop (one `w6c -c -I` per package, dep-first),
* then a flat `w6l` of the `.o` set (per-pkg `.a` + multi-archive link
* is commit 4). Side files land in a cold `<stem>.sepwork` scratch dir
* (the structured cache is commit 5). */
* the transitive producer loop (one `w6c -c -I` per package, dep-first,
* each DEP `.o` wrapped in its own deterministic `.a`), then a
* reverse-topo `w6l` of the root `.o` + dep `.a` set + libwwrt.a. Side
* files land in a cold `<stem>.sepwork` dir (content-keyed cache = 5b). */
static int
build_one_sep(const char *src, int entry_is_dir, const char *out,
const char *objstem, const char *extra_includes, const char *extra_libs,
@@ -992,11 +1049,26 @@ build_one_sep(const char *src, int entry_is_dir, const char *out,
g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)");
free(order); free(g); return 1;
}
/* wrap each DEP package's `.o` in its own deterministic `.a`
* (5a). The ROOT stays a positional `.o` (force-loaded — it's
* the build target, always fully linked), so `main` is defined
* before any archive is processed, matching build_one's root
* treatment. The link consumes `.o`/`.a`, never `.wwi`. */
if (pi != root) {
char apath[1024];
sep_fname(g, pi, scratch, ".a", apath, sizeof apath);
if (archive_o(obj, apath) != 0) {
fprintf(stderr, "ww --sep: archive failed for %s\n",
g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)");
free(order); free(g); return 1;
}
}
}
/* flat link: root.o first (order[norder-1]), deps after; runtime
* archive selectively pulls only undefined runtime symbols (commit 4
* adds per-pkg `.a` + multi-archive reverse-topo link). */
/* reverse-topo link: root `.o` first (order[norder-1], force-loaded),
* then transitive dep `.a` in reverse-topo order, then libwwrt.a —
* each archive selectively pulls only members satisfying a live
* undef. */
char rtargs[2048] = {0};
char rtpath[1024];
snprintf(rtpath, sizeof rtpath, "%s/libwwrt.a", libdir);
@@ -1010,10 +1082,12 @@ build_one_sep(const char *src, int entry_is_dir, const char *out,
}
char objs[8192] = {0};
for (int oi = norder - 1; oi >= 0; oi--) {
char obj[1024];
sep_fname(g, order[oi], scratch, ".o", obj, sizeof obj);
char path[1024];
/* root: positional `.o` (force-load); deps: `.a` (selective). */
sep_fname(g, order[oi], scratch,
order[oi] == root ? ".o" : ".a", path, sizeof path);
size_t n = strlen(objs);
snprintf(objs + n, sizeof objs - n, "%s%s", n ? " " : "", obj);
snprintf(objs + n, sizeof objs - n, "%s%s", n ? " " : "", path);
}
const char *libargs = (extra_libs && extra_libs[0]) ? extra_libs : "";
const char *libdirset = (extra_libdirs && extra_libdirs[0]) ? extra_libdirs : "";

View File

@@ -3352,6 +3352,33 @@ fn loadarchive(l: *lnk, path: *u8, buf: *u8, len: u64) i32 = {
m = m.mnext;
};
};
// Pass 3 (#31, task #62): catch a dup the selective pull MASKED. A
// member pass 2 left unloaded yet defining a name some other object
// already `defined` is exactly the cross-package collision archive
// selective-pull would silently skip. lookup (never intern) keeps
// the pull machinery untouched → byte-id-neutral on every clean
// link. Pulled-member and direct-`.o` dups stay caught in loadimage.
// ww has no weak symbols → a genuine dup. (The bare message matches
// loadimage's; the w6l path+name text divergence is #61, separate.)
let dm: *armember = head;
for (dm != nil) {
if (dm.loaded == 0) {
let de: *defent = dm.defs;
for (de != nil) {
let s: *lsym = lookup(l, de.name);
if (s != nil) {
if (s.defined != 0) {
let msg: str = "w6l: duplicate symbol\n";
os.write(2, msg.ptr, msg.len: u64);
l.errs += 1;
};
};
de = de.dnext;
};
};
dm = dm.mnext;
};
return 0;
};

View File

@@ -376,6 +376,33 @@ fn loadarchive(l: *lnk, path: *u8, buf: *u8, len: u64) i32 = {
m = m.mnext;
};
};
// Pass 3 (#31, task #62): catch a dup the selective pull MASKED. A
// member pass 2 left unloaded yet defining a name some other object
// already `defined` is exactly the cross-package collision archive
// selective-pull would silently skip. lookup (never intern) keeps
// the pull machinery untouched → byte-id-neutral on every clean
// link. Pulled-member and direct-`.o` dups stay caught in loadimage.
// ww has no weak symbols → a genuine dup. (The bare message matches
// loadimage's; the w6l path+name text divergence is #61, separate.)
let dm: *armember = head;
for (dm != nil) {
if (dm.loaded == 0) {
let de: *defent = dm.defs;
for (de != nil) {
let s: *lsym = lookup(l, de.name);
if (s != nil) {
if (s.defined != 0) {
let msg: str = "w6l: duplicate symbol\n";
os.write(2, msg.ptr, msg.len: u64);
l.errs += 1;
};
};
de = de.dnext;
};
};
dm = dm.mnext;
};
return 0;
};

View File

@@ -4078,10 +4078,83 @@ fn sepcomposeunit(g: *sepgraph, pi: i32, scratch: *u8, order: []i32,
return 0;
};
// archiveo — twin of cmd/ww/main.c archive_o. Writes a deterministic
// single-member SysV ar archive at `apath` wrapping the `.o` at
// `objpath`. No armap / long-name table: w6l reads each member's ELF
// .symtab directly and skips '/'-named members, so a package `.a` is
// just the global magic + one 60-byte member header + the `.o` bytes
// (newline-padded to even). Zeroed mtime/uid/gid + fixed mode + a fixed
// member name make the bytes a pure function of the `.o` content →
// cstage `.a` == wwstage `.a` (rule 10) and a stable md5 for the 5b
// cache key.
fn archiveo(objpath: *u8, apath: *u8) i32 = {
let objp: *u8;
let objn: u64;
objp, objn = slurp(objpath);
if (objp == nil) {
cerr("ww --sep: cannot read object for archive\n");
return -1;
};
let pad: u64 = 0u64;
if ((objn & 1u64) != 0u64) { pad = 1u64; };
// sizelint-ok: 8B ar(5) magic + 60B member header are FILE-FORMAT
// constants, not type sizes (CLAUDE.md rule 13 carve-out).
let total: u64 = 8u64 + 60u64 + objn + pad;
let outs: []u8 = alloc([], total)!;
let out: *u8 = outs.ptr;
// 60-byte member header at offset 8, ASCII space-filled, fields
// left-justified; the 8-byte global magic precedes it. strinto
// copies a str's bytes (the working i32-index idiom) — a direct
// `out[i] = lit[i: i32]` store trips the cgen's str-index-rvalue arm.
let h: u64 = 8u64;
let j: u64 = 0u64;
for (j < 60u64) { out[h + j] = 32u8; j += 1u64; }; // 0x20 fill
strinto(out, 0u64, "!<arch>\n"); // global magic
strinto(out, h, "pkg.o/"); // name (GNU '/' terminator)
out[h + 16u64] = 48u8; // mtime "0" (zeroed → determinism)
out[h + 28u64] = 48u8; // uid "0"
out[h + 34u64] = 48u8; // gid "0"
strinto(out, h + 40u64, "100644"); // mode (fixed octal)
// size: decimal byte-count of the .o, left-justified at [48..58)
if (objn == 0u64) {
out[h + 48u64] = 48u8;
} else {
let ndig: u64 = 0u64;
let t: u64 = objn;
for (t > 0u64) { ndig += 1u64; t = t / 10u64; };
let d: u64 = ndig;
t = objn;
for (t > 0u64) {
d -= 1u64;
out[h + 48u64 + d] = ((t % 10u64): u8) + 48u8;
t = t / 10u64;
};
};
out[h + 58u64] = 96u8; // member-header magic 0x60
out[h + 59u64] = 10u8; // 0x0a
// the .o bytes, then a '\n' pad iff the size is odd (2-byte align).
let k: u64 = 0u64;
for (k < objn) { out[h + 60u64 + k] = objp[k]; k += 1u64; };
if (pad != 0u64) { out[h + 60u64 + objn] = 10u8; };
let fd: i32 = os.open(pathstr(apath),
os.flag.WRONLY | os.flag.CREATE | os.flag.TRUNC, 420i32); // 0o644
if (fd < 0) {
cerr("ww --sep: cannot open archive\n");
return -1;
};
os.writeall(fd, out, total);
os.close(fd);
return 0;
};
// buildonesep — the --sep orchestration: discover deps, reverse-topo,
// the transitive producer loop (one `w6c -c -I` per package, dep-first),
// then a flat `w6l` of the `.o` set. Side files land in a cold
// `<stem>.sepwork` scratch dir. Twin of cstage build_one_sep.
// the transitive producer loop (one `w6c -c -I` per package, dep-first,
// each `.o` wrapped in its own deterministic per-package `.a`), then a
// reverse-topo `w6l` of the `.a` set + libwwrt.a. Side files land in a
// cold `<stem>.sepwork` scratch dir. Twin of cstage build_one_sep.
fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8,
objstem: *u8, incs: *u8, lf: *lflags) i32 = {
let c6: *u8 = joinpathlit(selfdir, "w6c_ww");
@@ -4231,12 +4304,26 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8,
return 1;
};
};
// Wrap each DEP package's `.o` in its own deterministic `.a`
// (5a). The ROOT stays a positional `.o` (force-loaded — it's
// the build target), so `main` is defined before any archive is
// processed (mirrors buildone's root treatment). The link
// consumes `.o`/`.a`, never `.wwi`.
if (pi != root) {
let apath: *u8 = sepfname(g, pi, scratch, ".a");
if (archiveo(objf, apath) != 0) {
cerr("ww --sep: archive failed\n");
return 1;
};
};
oi += 1;
};
// Flat link: root.o first (order[norder-1]), deps after; libwwrt.a
// selectively pulls runtime symbols. argv: 4 fixed (w6l,-o,out) + 1
// per .o + 1 libwwrt + 2*nlibdirs + 2*nlibs + 1 nil.
// Reverse-topo link of the per-package `.a` set: root.a first
// (order[norder-1]), deps after, then libwwrt.a (which still
// selectively pulls only the runtime members a live undef needs).
// argv: 3 fixed (w6l,-o,out) + 1 per .a + 1 libwwrt + 2*nlibdirs
// + 2*nlibs + 1 nil.
let nldirs: i32 = 0;
let nllibs: i32 = 0;
let ldirs: **u8 = nil;
@@ -4256,7 +4343,10 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8,
let pos: i32 = 3;
let li: i32 = norder - 1;
for (li >= 0) {
largv[pos] = sepfname(g, order[li], scratch, ".o");
// root: positional `.o` (force-load); deps: `.a` (selective).
let suf: str = ".a";
if (order[li] == root) { suf = ".o"; };
largv[pos] = sepfname(g, order[li], scratch, suf);
pos += 1;
li -= 1;
};

View File

@@ -1204,10 +1204,83 @@ fn sepcomposeunit(g: *sepgraph, pi: i32, scratch: *u8, order: []i32,
return 0;
};
// archiveo — twin of cmd/ww/main.c archive_o. Writes a deterministic
// single-member SysV ar archive at `apath` wrapping the `.o` at
// `objpath`. No armap / long-name table: w6l reads each member's ELF
// .symtab directly and skips '/'-named members, so a package `.a` is
// just the global magic + one 60-byte member header + the `.o` bytes
// (newline-padded to even). Zeroed mtime/uid/gid + fixed mode + a fixed
// member name make the bytes a pure function of the `.o` content →
// cstage `.a` == wwstage `.a` (rule 10) and a stable md5 for the 5b
// cache key.
fn archiveo(objpath: *u8, apath: *u8) i32 = {
let objp: *u8;
let objn: u64;
objp, objn = slurp(objpath);
if (objp == nil) {
cerr("ww --sep: cannot read object for archive\n");
return -1;
};
let pad: u64 = 0u64;
if ((objn & 1u64) != 0u64) { pad = 1u64; };
// sizelint-ok: 8B ar(5) magic + 60B member header are FILE-FORMAT
// constants, not type sizes (CLAUDE.md rule 13 carve-out).
let total: u64 = 8u64 + 60u64 + objn + pad;
let outs: []u8 = alloc([], total)!;
let out: *u8 = outs.ptr;
// 60-byte member header at offset 8, ASCII space-filled, fields
// left-justified; the 8-byte global magic precedes it. strinto
// copies a str's bytes (the working i32-index idiom) — a direct
// `out[i] = lit[i: i32]` store trips the cgen's str-index-rvalue arm.
let h: u64 = 8u64;
let j: u64 = 0u64;
for (j < 60u64) { out[h + j] = 32u8; j += 1u64; }; // 0x20 fill
strinto(out, 0u64, "!<arch>\n"); // global magic
strinto(out, h, "pkg.o/"); // name (GNU '/' terminator)
out[h + 16u64] = 48u8; // mtime "0" (zeroed → determinism)
out[h + 28u64] = 48u8; // uid "0"
out[h + 34u64] = 48u8; // gid "0"
strinto(out, h + 40u64, "100644"); // mode (fixed octal)
// size: decimal byte-count of the .o, left-justified at [48..58)
if (objn == 0u64) {
out[h + 48u64] = 48u8;
} else {
let ndig: u64 = 0u64;
let t: u64 = objn;
for (t > 0u64) { ndig += 1u64; t = t / 10u64; };
let d: u64 = ndig;
t = objn;
for (t > 0u64) {
d -= 1u64;
out[h + 48u64 + d] = ((t % 10u64): u8) + 48u8;
t = t / 10u64;
};
};
out[h + 58u64] = 96u8; // member-header magic 0x60
out[h + 59u64] = 10u8; // 0x0a
// the .o bytes, then a '\n' pad iff the size is odd (2-byte align).
let k: u64 = 0u64;
for (k < objn) { out[h + 60u64 + k] = objp[k]; k += 1u64; };
if (pad != 0u64) { out[h + 60u64 + objn] = 10u8; };
let fd: i32 = os.open(pathstr(apath),
os.flag.WRONLY | os.flag.CREATE | os.flag.TRUNC, 420i32); // 0o644
if (fd < 0) {
cerr("ww --sep: cannot open archive\n");
return -1;
};
os.writeall(fd, out, total);
os.close(fd);
return 0;
};
// buildonesep — the --sep orchestration: discover deps, reverse-topo,
// the transitive producer loop (one `w6c -c -I` per package, dep-first),
// then a flat `w6l` of the `.o` set. Side files land in a cold
// `<stem>.sepwork` scratch dir. Twin of cstage build_one_sep.
// the transitive producer loop (one `w6c -c -I` per package, dep-first,
// each `.o` wrapped in its own deterministic per-package `.a`), then a
// reverse-topo `w6l` of the `.a` set + libwwrt.a. Side files land in a
// cold `<stem>.sepwork` scratch dir. Twin of cstage build_one_sep.
fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8,
objstem: *u8, incs: *u8, lf: *lflags) i32 = {
let c6: *u8 = joinpathlit(selfdir, "w6c_ww");
@@ -1357,12 +1430,26 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8,
return 1;
};
};
// Wrap each DEP package's `.o` in its own deterministic `.a`
// (5a). The ROOT stays a positional `.o` (force-loaded — it's
// the build target), so `main` is defined before any archive is
// processed (mirrors buildone's root treatment). The link
// consumes `.o`/`.a`, never `.wwi`.
if (pi != root) {
let apath: *u8 = sepfname(g, pi, scratch, ".a");
if (archiveo(objf, apath) != 0) {
cerr("ww --sep: archive failed\n");
return 1;
};
};
oi += 1;
};
// Flat link: root.o first (order[norder-1]), deps after; libwwrt.a
// selectively pulls runtime symbols. argv: 4 fixed (w6l,-o,out) + 1
// per .o + 1 libwwrt + 2*nlibdirs + 2*nlibs + 1 nil.
// Reverse-topo link of the per-package `.a` set: root.a first
// (order[norder-1]), deps after, then libwwrt.a (which still
// selectively pulls only the runtime members a live undef needs).
// argv: 3 fixed (w6l,-o,out) + 1 per .a + 1 libwwrt + 2*nlibdirs
// + 2*nlibs + 1 nil.
let nldirs: i32 = 0;
let nllibs: i32 = 0;
let ldirs: **u8 = nil;
@@ -1382,7 +1469,10 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8,
let pos: i32 = 3;
let li: i32 = norder - 1;
for (li >= 0) {
largv[pos] = sepfname(g, order[li], scratch, ".o");
// root: positional `.o` (force-load); deps: `.a` (selective).
let suf: str = ".a";
if (order[li] == root) { suf = ".o"; };
largv[pos] = sepfname(g, order[li], scratch, suf);
pos += 1;
li -= 1;
};

View File

@@ -0,0 +1,325 @@
/*
* 989_separchive_run — M3-tail commit-5a gate (#46, task #62): the
* per-package `.a` substrate + its archive-path #31 dup-detect, both
* stages, COLD. Commit 5a makes `ww build --sep` wrap each DEP package's
* `.o` in a deterministic single-member `.a` (cstage archive_o /
* wwstage archiveo) and link the ROOT as a positional `.o` (force-loaded)
* + dep `.a` reverse-topo + libwwrt.a. The #31 dup the selective pull
* would mask is caught by a post-pull PASS 3 in w6l/w6l_ww load_archive.
*
* Legs (all COLD — `<stem>.sepwork` is wiped each run):
* 1. POSITIVE: root→helper builds + runs exit 7, BOTH stages, through
* the `.a` link path (root `.o` + helper `.a`).
* 2. ★ DETERMINISM (ken, load-bearing): re-archive the same package 3×
* → byte-identical `.a` (proves zeroed mtime/uid/gid + fixed mode +
* fixed member name; a floating md5 would poison the 5b cache key).
* 3. ★ cs `.a` == ww `.a` (rule 10, the NEW byte-id substrate ken binds):
* the dep `.a` from `ww --sep` is byte-identical to the one from
* `ww_ww --sep`.
* 4. ★ #31 dup THROUGH the `.a` path (ken #263 + D3, the leg that proves
* PASS 3 closed the selective-pull hole): two DEP packages each export
* the same link symbol via `@symbol("dup_sym")`, referenced by root.
* One dep's member is pulled; the OTHER lands UNPULLED and defines an
* already-`defined` name — exactly what selective-pull skips. BOTH
* stages must (exit≠0) ∧ (stderr contains "duplicate symbol") ∧ (no
* partial binary). NON-VACUITY flip: give the second dep a DISTINCT
* symbol → both stages build + run (exit 3), proving the leg actually
* discriminates (without PASS 3 the dup leg would WRONGLY pass green).
* 5. ACHIEVABLE-parity posture (same as c4's 989_sepcycle_dup, because
* #61 is a separate commit): cs vs ww is `both exit≠0 ∧ both stderrs
* contain "duplicate symbol"` — NOT exact-stderr-equal (cstage names
* `<path>: duplicate symbol <sym>`, wwstage the bare form).
*
* Light wwstage-driver test (CLAUDE.md rule 14): all fixtures + scratch
* live under /tmp, COLD each run. Models 989_sepbuild_run.c conventions.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/stat.h>
#define EXPECT_EXIT 7
static int
runwait(const char *cmd)
{
int rc = system(cmd);
if (rc == -1) return -1;
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
return 1;
}
static const char *
absbin(void)
{
const char *b = getenv("BIN");
if (!b) b = "out/bin";
if (b[0] == '/') return b;
static char buf[2048];
char cwd[1024];
if (getcwd(cwd, sizeof cwd) == NULL) return NULL;
snprintf(buf, sizeof buf, "%s/%s", cwd, b);
return buf;
}
static int
slurp(const char *path, char **outbuf, size_t *outlen)
{
FILE *f = fopen(path, "rb");
if (!f) return -1;
fseek(f, 0, SEEK_END);
long n = ftell(f);
fseek(f, 0, SEEK_SET);
if (n < 0) { fclose(f); return -1; }
char *b = malloc((size_t)n + 1);
if (!b) { fclose(f); return -1; }
if (fread(b, 1, (size_t)n, f) != (size_t)n) { free(b); fclose(f); return -1; }
b[n] = '\0';
fclose(f);
*outbuf = b;
*outlen = (size_t)n;
return 0;
}
static int
files_eq(const char *a, const char *b)
{
char *ba = NULL, *bb = NULL;
size_t na = 0, nb = 0;
if (slurp(a, &ba, &na) < 0 || slurp(b, &bb, &nb) < 0) {
free(ba); free(bb);
return -1;
}
int eq = (na == nb && memcmp(ba, bb, na) == 0);
free(ba); free(bb);
return eq ? 0 : 1;
}
static int
file_has(const char *path, const char *needle)
{
char *b = NULL;
size_t n = 0;
if (slurp(path, &b, &n) < 0) return 0;
int found = (strstr(b, needle) != NULL);
free(b);
return found;
}
static int
write_file(const char *path, const char *body)
{
FILE *f = fopen(path, "wb");
if (!f) return -1;
fputs(body, f);
fclose(f);
return 0;
}
int
main(void)
{
const char *bin = absbin();
if (!bin) return 1;
char td[64], cmd[8192];
int fail = 0;
snprintf(td, sizeof td, "/tmp/wwar_%d", getpid());
snprintf(cmd, sizeof cmd, "rm -rf %s", td);
runwait(cmd);
mkdir(td, 0755);
/* ---- root → helper fixture (one dep `.a`) ------------------------ */
char helpdir[1024], helpww[1100], rootww[1100];
snprintf(helpdir, sizeof helpdir, "%s/helper", td);
mkdir(helpdir, 0755);
snprintf(helpww, sizeof helpww, "%s/h.ww", helpdir);
snprintf(rootww, sizeof rootww, "%s/root.ww", td);
if (write_file(helpww,
"package helper;\n"
"export fn val() i32 = { return 7; };\n") ||
write_file(rootww,
"package main;\n"
"import helper;\n"
"fn main() i32 = { return helper.val(); };\n")) {
fail++; goto out;
}
struct { const char *drv, *tag; char prog[1024]; }
stg[] = { { "ww", "cs", {0} }, { "ww_ww", "ww", {0} } };
/* Leg 1: POSITIVE build + run through the `.a` path, both stages. */
for (int s = 0; s < 2; s++) {
snprintf(stg[s].prog, sizeof stg[s].prog, "%s/prog.%s", td, stg[s].tag);
snprintf(cmd, sizeof cmd,
"timeout 240 %s/%s build --sep -I %s -o %s %s >/dev/null 2>&1",
bin, stg[s].drv, td, stg[s].prog, rootww);
if (runwait(cmd) != 0) {
fprintf(stderr, "separchive FAIL: %s build --sep\n", stg[s].drv);
fail++;
continue;
}
int rc = runwait(stg[s].prog);
if (rc != EXPECT_EXIT) {
fprintf(stderr, "separchive FAIL: %s prog exit=%d expected %d\n",
stg[s].drv, rc, EXPECT_EXIT);
fail++;
}
}
/* Root is a positional `.o` (force-loaded), so NO __root.a exists;
* the dep is the only `.a`. Assert the layout the driver produced. */
{
char roota[1100], rooto[1100], helpa[1100];
snprintf(roota, sizeof roota, "%s/prog.cs.sepwork/__root.a", td);
snprintf(rooto, sizeof rooto, "%s/prog.cs.sepwork/__root.o", td);
snprintf(helpa, sizeof helpa, "%s/prog.cs.sepwork/helper.a", td);
if (access(roota, 0) == 0) {
fprintf(stderr, "separchive FAIL: root wrapped in .a "
"(should stay positional .o)\n");
fail++;
}
if (access(rooto, 0) != 0) {
fprintf(stderr, "separchive FAIL: missing root .o\n");
fail++;
}
if (access(helpa, 0) != 0) {
fprintf(stderr, "separchive FAIL: missing dep helper.a\n");
fail++;
}
}
/* Leg 3: cs `.a` == ww `.a` (rule 10, the new byte-id substrate). */
{
char a[1100], b[1100];
snprintf(a, sizeof a, "%s/prog.cs.sepwork/helper.a", td);
snprintf(b, sizeof b, "%s/prog.ww.sepwork/helper.a", td);
if (files_eq(a, b) != 0) {
fprintf(stderr, "separchive FAIL: cs helper.a != ww helper.a "
"(rule 10 .a byte-id)\n");
fail++;
}
}
/* Leg 2: DETERMINISM — re-archive the same package 3× (cold each),
* the `.a` must be byte-identical (zeroed mtime/uid/gid, fixed mode,
* fixed member name). Copy each build's helper.a aside, compare. */
{
char det[3][1100];
int ok = 1;
for (int i = 0; i < 3; i++) {
char prog[1100];
snprintf(prog, sizeof prog, "%s/det%d", td, i);
snprintf(cmd, sizeof cmd,
"timeout 240 %s/ww build --sep -I %s -o %s %s >/dev/null 2>&1",
bin, td, prog, rootww);
if (runwait(cmd) != 0) { ok = 0; break; }
snprintf(det[i], sizeof det[i], "%s/det%d.sepwork/helper.a", td, i);
}
if (!ok) {
fprintf(stderr, "separchive FAIL: determinism build\n");
fail++;
} else if (files_eq(det[0], det[1]) != 0 ||
files_eq(det[1], det[2]) != 0) {
fprintf(stderr, "separchive FAIL: .a not deterministic across "
"3 builds (floating md5 → poisons 5b cache)\n");
fail++;
}
}
/* ---- Leg 4: #31 dup THROUGH the `.a` path (PASS 3) --------------- */
/* Two DEP packages each export the SAME link symbol via @symbol;
* root references both. One member is pulled, the other lands
* UNPULLED defining an already-`defined` name → the masked dup PASS 3
* catches (#53 path-qualifies normal exports, so @symbol is the
* cleanest forced cross-package clash). */
char pkgad[1024], pkgbd[1024], paww[1100], pbww[1100], droot[1100];
snprintf(pkgad, sizeof pkgad, "%s/pkga", td);
snprintf(pkgbd, sizeof pkgbd, "%s/pkgb", td);
mkdir(pkgad, 0755); mkdir(pkgbd, 0755);
snprintf(paww, sizeof paww, "%s/a.ww", pkgad);
snprintf(pbww, sizeof pbww, "%s/b.ww", pkgbd);
snprintf(droot, sizeof droot, "%s/droot.ww", td);
if (write_file(paww,
"package pkga;\n"
"@symbol(\"dup_sym\") export fn afn() i32 = { return 1; };\n") ||
write_file(pbww,
"package pkgb;\n"
"@symbol(\"dup_sym\") export fn bfn() i32 = { return 2; };\n") ||
write_file(droot,
"package main;\n"
"import pkga;\n"
"import pkgb;\n"
"fn main() i32 = { return pkga.afn() + pkgb.bfn(); };\n")) {
fail++; goto out;
}
for (int s = 0; s < 2; s++) {
char prog[1100], errf[1100];
snprintf(prog, sizeof prog, "%s/dup.%s", td, stg[s].tag);
snprintf(errf, sizeof errf, "%s/dup.%s.err", td, stg[s].tag);
snprintf(cmd, sizeof cmd,
"timeout 240 %s/%s build --sep -I %s -o %s %s >/dev/null 2>%s",
bin, stg[s].drv, td, prog, droot, errf);
int rc = runwait(cmd);
if (rc == 0) {
fprintf(stderr, "separchive FAIL: %s accepted a masked "
"cross-pkg dup through .a (exit 0 — PASS 3 missing?)\n",
stg[s].drv);
fail++;
}
if (!file_has(errf, "duplicate symbol")) {
fprintf(stderr, "separchive FAIL: %s missing 'duplicate "
"symbol' on the .a dup path\n", stg[s].drv);
fail++;
}
if (access(prog, 0) == 0) {
fprintf(stderr, "separchive FAIL: %s produced a partial "
"binary on the dup reject\n", stg[s].drv);
fail++;
}
}
/* Leg 4 NON-VACUITY: give pkgb a DISTINCT symbol → no collision →
* both stages build + run (exit 3 = 1 + 2). Proves the dup leg
* actually discriminates (not a vacuous always-fail). */
if (write_file(pbww,
"package pkgb;\n"
"@symbol(\"uniq_sym\") export fn bfn() i32 = { return 2; };\n")) {
fail++; goto out;
}
for (int s = 0; s < 2; s++) {
char prog[1100];
snprintf(prog, sizeof prog, "%s/ok.%s", td, stg[s].tag);
snprintf(cmd, sizeof cmd,
"timeout 240 %s/%s build --sep -I %s -o %s %s >/dev/null 2>&1",
bin, stg[s].drv, td, prog, droot);
if (runwait(cmd) != 0) {
fprintf(stderr, "separchive FAIL(non-vacuity): %s could not "
"build the distinct-symbol graph\n", stg[s].drv);
fail++;
continue;
}
int rc = runwait(prog);
if (rc != 3) {
fprintf(stderr, "separchive FAIL(non-vacuity): %s prog "
"exit=%d expected 3\n", stg[s].drv, rc);
fail++;
}
}
out:
snprintf(cmd, sizeof cmd, "rm -rf %s", td);
runwait(cmd);
if (fail) {
fprintf(stderr, "separchive: %d check(s) failed\n", fail);
return 1;
}
printf("separchive: per-pkg .a (root=.o force-load, deps=.a) build+run "
"(exit %d) + .a determinism (3x identical) + cs.a==ww.a (rule 10) + "
"#31 masked-dup reject THROUGH .a (PASS 3, both stages loud+non-zero, "
"non-vacuity flip exit 3)\n", EXPECT_EXIT);
return 0;
}