From 9cceb4ca8dbf9dd6935dce6aa2ce5e4048bc4d99 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Tue, 16 Jun 2026 13:30:45 +0900 Subject: [PATCH] wcc/ww: loud dep_cycle reject + #31 dup-symbol gate (M3-tail c4, #46) Promote commit-3's tri-color topo bail into a loud dep_cycle error that names the full import cycle, byte-identical both stages (cmd/ww/main.c + selfhost/cmd/ww/main.ww, deps.ha:243 parity). Add a non-vacuous negative gate (989_sepcycle_dup) proving the pre-existing w6l duplicate-symbol reject fires loud + non-zero on a cross-package collision; no new linker code. #58(b)(c) link-arg parity deferred (system()-string vs procrun()-argv is structurally un-unifiable in this scope); #61 filed for the byte-id-blind w6l_ww dup-message divergence. --- Makefile | 11 ++ cmd/ww/main.c | 31 +++- selfhost/cmd/ww/main.combined.ww | 36 +++- selfhost/cmd/ww/main.ww | 36 +++- test/wcc/989_sepcycle_dup.c | 307 +++++++++++++++++++++++++++++++ 5 files changed, 400 insertions(+), 21 deletions(-) create mode 100644 test/wcc/989_sepcycle_dup.c diff --git a/Makefile b/Makefile index 3797d3d7..6523c24e 100644 --- a/Makefile +++ b/Makefile @@ -564,6 +564,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_m2wwi_run \ $(BIN)/test_m3sep_run \ $(BIN)/test_sepbuild_run \ + $(BIN)/test_sepcycle_dup \ $(BIN)/test_floatlit_run \ $(BIN)/test_checked_run \ $(BIN)/test_floatarr_run \ @@ -3107,6 +3108,16 @@ $(BIN)/test_sepbuild_run: test/wcc/989_sepbuild_run.c $(BIN)/ww $(BIN)/ww_ww \ $(BIN)/w6l $(BIN)/w6l_ww $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +# 989_sepcycle_dup — M3-tail commit-4 negative gates (#46, #31): a loud +# dep-cycle reject (both driver stages, cs==ww stderr) + the #31 +# duplicate-symbol reject (both linker stages). Needs both driver stages, +# both compiler stages (w6c/w6a build the dup .o), both linker stages, and +# libwwrt.a (the dup non-vacuity single-.o link). +$(BIN)/test_sepcycle_dup: test/wcc/989_sepcycle_dup.c $(BIN)/ww $(BIN)/ww_ww \ + $(BIN)/w6c $(BIN)/w6a $(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 $@ $< diff --git a/cmd/ww/main.c b/cmd/ww/main.c index e9da5ecc..70713b2e 100644 --- a/cmd/ww/main.c +++ b/cmd/ww/main.c @@ -740,21 +740,31 @@ sep_scan_pkg(struct sepgraph *g, int pi, const char *searchpath) } /* DFS post-order over the dep DAG → reverse-topo (deps before importer), - * cite Hare gather (deps.ha:123). Tri-color: a back-edge BAILS rather - * than spinning. Commit 3 assumes acyclic; the LOUD cycle reject is - * commit 4 (this just must not infinite-loop). */ + * cite Hare gather (deps.ha:123). Tri-color: a gray back-edge is a loud + * dep-cycle reject naming the chain (Hare deps.ha:243); `stack[0..depth)` + * is the live DFS path, so the cycle runs from pi's first occurrence on + * it to the top, closing back on pi. */ static int -sep_topo_visit(struct sepgraph *g, int pi, int *order, int *no) +sep_topo_visit(struct sepgraph *g, int pi, int *order, int *no, + int *stack, int depth) { if (g->pkg[pi].color == 2) return 0; if (g->pkg[pi].color == 1) { - fprintf(stderr, - "ww --sep: import cycle (loud reject lands commit 4)\n"); + int j = 0; + while (j < depth && stack[j] != pi) j++; + fprintf(stderr, "ww --sep: dependency cycle: "); + for (int s = j; s < depth; s++) + fprintf(stderr, "%s -> ", g->pkg[stack[s]].path[0] + ? g->pkg[stack[s]].path : "(root)"); + fprintf(stderr, "%s\n", + g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)"); return -1; } g->pkg[pi].color = 1; + stack[depth] = pi; for (int k = 0; k < g->pkg[pi].ndeps; k++) - if (sep_topo_visit(g, g->pkg[pi].deps[k], order, no) < 0) + if (sep_topo_visit(g, g->pkg[pi].deps[k], order, no, + stack, depth + 1) < 0) return -1; g->pkg[pi].color = 2; order[(*no)++] = pi; @@ -951,10 +961,13 @@ build_one_sep(const char *src, int entry_is_dir, const char *out, if (root < 0 || sep_scan_pkg(g, root, srcdir) < 0) { free(g); return 1; } for (int i = 0; i < g->n; i++) g->pkg[i].color = 0; int *order = calloc((size_t)g->n, sizeof *order); + int *stack = calloc((size_t)g->n, sizeof *stack); int norder = 0; - if (order == NULL || sep_topo_visit(g, root, order, &norder) < 0) { - free(order); free(g); return 1; + if (order == NULL || stack == NULL || + sep_topo_visit(g, root, order, &norder, stack, 0) < 0) { + free(stack); free(order); free(g); return 1; } + free(stack); /* producer loop — dep-first, one `w6c -c -I` pass per package. */ for (int oi = 0; oi < norder; oi++) { diff --git a/selfhost/cmd/ww/main.combined.ww b/selfhost/cmd/ww/main.combined.ww index 7b5667da..9b72d0d7 100644 --- a/selfhost/cmd/ww/main.combined.ww +++ b/selfhost/cmd/ww/main.combined.ww @@ -3905,19 +3905,41 @@ fn sepscanpkg(g: *sepgraph, pi: i32, searchpath: *u8) i32 = { return 0; }; +// Print one cycle-chain node: a package path, or "(root)" for the +// empty root path. +fn sepcyclenode(p: *u8) void = { + if (p[0] == 0u8) { cerr("(root)"); } else { cerr(pathstr(p)); }; +}; + // DFS post-order over the dep DAG → reverse-topo (deps before importer). -// Tri-color: a back-edge BAILS rather than spinning (loud cycle reject is -// commit 4). Cite Hare gather (deps.ha:123). -fn septopovisit(g: *sepgraph, pi: i32, order: []i32, no: *i32) i32 = { +// Tri-color: a gray back-edge is a loud dep-cycle reject naming the chain +// (Hare deps.ha:243); stack[0..depth) is the live DFS path, so the cycle +// runs from pi's first occurrence on it to the top, closing on pi. +// Cite Hare gather (deps.ha:123). +fn septopovisit(g: *sepgraph, pi: i32, order: []i32, no: *i32, + stack: []i32, depth: i32) i32 = { if (g.pkg[pi].color == 2) { return 0; }; if (g.pkg[pi].color == 1) { - cerr("ww --sep: import cycle (loud reject lands commit 4)\n"); + let j: i32 = 0; + for (j < depth && stack[j] != pi) { j += 1; }; + cerr("ww --sep: dependency cycle: "); + let s: i32 = j; + for (s < depth) { + sepcyclenode(g.pkg[stack[s]].path); + cerr(" -> "); + s += 1; + }; + sepcyclenode(g.pkg[pi].path); + cerr("\n"); return -1; }; g.pkg[pi].color = 1; + stack[depth] = pi; let k: i32 = 0; for (k < g.pkg[pi].ndeps) { - if (septopovisit(g, g.pkg[pi].deps[k], order, no) < 0) { return -1; }; + if (septopovisit(g, g.pkg[pi].deps[k], order, no, stack, depth + 1) < 0) { + return -1; + }; k += 1; }; g.pkg[pi].color = 2; @@ -4164,8 +4186,10 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8, for (ci < g.n) { g.pkg[ci].color = 0; ci += 1; }; let order: []i32 = alloc([], g.n: u64)!; order.len = g.n; + let stack: []i32 = alloc([], g.n: u64)!; + stack.len = g.n; let norder: i32 = 0; - if (septopovisit(g, root, order, &norder) < 0) { return 1; }; + if (septopovisit(g, root, order, &norder, stack, 0) < 0) { return 1; }; // Producer loop — dep-first, one `w6c -c -I` per package. let oi: i32 = 0; diff --git a/selfhost/cmd/ww/main.ww b/selfhost/cmd/ww/main.ww index 82f51b2a..cc7529de 100644 --- a/selfhost/cmd/ww/main.ww +++ b/selfhost/cmd/ww/main.ww @@ -1031,19 +1031,41 @@ fn sepscanpkg(g: *sepgraph, pi: i32, searchpath: *u8) i32 = { return 0; }; +// Print one cycle-chain node: a package path, or "(root)" for the +// empty root path. +fn sepcyclenode(p: *u8) void = { + if (p[0] == 0u8) { cerr("(root)"); } else { cerr(pathstr(p)); }; +}; + // DFS post-order over the dep DAG → reverse-topo (deps before importer). -// Tri-color: a back-edge BAILS rather than spinning (loud cycle reject is -// commit 4). Cite Hare gather (deps.ha:123). -fn septopovisit(g: *sepgraph, pi: i32, order: []i32, no: *i32) i32 = { +// Tri-color: a gray back-edge is a loud dep-cycle reject naming the chain +// (Hare deps.ha:243); stack[0..depth) is the live DFS path, so the cycle +// runs from pi's first occurrence on it to the top, closing on pi. +// Cite Hare gather (deps.ha:123). +fn septopovisit(g: *sepgraph, pi: i32, order: []i32, no: *i32, + stack: []i32, depth: i32) i32 = { if (g.pkg[pi].color == 2) { return 0; }; if (g.pkg[pi].color == 1) { - cerr("ww --sep: import cycle (loud reject lands commit 4)\n"); + let j: i32 = 0; + for (j < depth && stack[j] != pi) { j += 1; }; + cerr("ww --sep: dependency cycle: "); + let s: i32 = j; + for (s < depth) { + sepcyclenode(g.pkg[stack[s]].path); + cerr(" -> "); + s += 1; + }; + sepcyclenode(g.pkg[pi].path); + cerr("\n"); return -1; }; g.pkg[pi].color = 1; + stack[depth] = pi; let k: i32 = 0; for (k < g.pkg[pi].ndeps) { - if (septopovisit(g, g.pkg[pi].deps[k], order, no) < 0) { return -1; }; + if (septopovisit(g, g.pkg[pi].deps[k], order, no, stack, depth + 1) < 0) { + return -1; + }; k += 1; }; g.pkg[pi].color = 2; @@ -1290,8 +1312,10 @@ fn buildonesep(selfdir: *u8, src: *u8, entryisdir: i32, out: *u8, for (ci < g.n) { g.pkg[ci].color = 0; ci += 1; }; let order: []i32 = alloc([], g.n: u64)!; order.len = g.n; + let stack: []i32 = alloc([], g.n: u64)!; + stack.len = g.n; let norder: i32 = 0; - if (septopovisit(g, root, order, &norder) < 0) { return 1; }; + if (septopovisit(g, root, order, &norder, stack, 0) < 0) { return 1; }; // Producer loop — dep-first, one `w6c -c -I` per package. let oi: i32 = 0; diff --git a/test/wcc/989_sepcycle_dup.c b/test/wcc/989_sepcycle_dup.c new file mode 100644 index 00000000..c4b5b519 --- /dev/null +++ b/test/wcc/989_sepcycle_dup.c @@ -0,0 +1,307 @@ +/* + * 989_sepcycle_dup — M3-tail commit-4 negative gates (#46, #31). + * + * Two error PATHS the commit-4 link-hardening relies on, each asserted to + * ACTUALLY FIRE (ken #263: a happy-path-green run does not prove a reject + * works — a no-op reject ships green). Every negative leg asserts + * (exit != 0) AND (the expected stderr message) AND a non-vacuity flip + * (remove the defect → green), so the gate cannot pass for the wrong + * reason. + * + * A. LOUD dep-cycle reject (spec §2, Hare deps.ha:243). A 3-package + * import cycle root->A->B->C->A fed to `ww build --sep` must exit + * non-zero, print the `dependency cycle: A -> B -> C -> A` chain, and + * produce NO output binary. cs==ww (rule 10): the cycle message is + * pure driver code, so cstage `ww` and wwstage `ww_ww` emit + * BYTE-IDENTICAL stderr — asserted here. Non-vacuity: break the cycle + * (C stops importing A) → both stages build green and the program + * runs (exit 7). + * + * B. #31 duplicate-symbol reject (spec §3; w6l ALREADY detects it, this + * gate only proves it fires). Two trivial root units each define a + * bare `main` (the #31 bare-collision class — #53 path-qualifies + * normal exports, so `main` is the cleanest bare clash); both `.o` + * fed directly to `w6l` AND `w6l_ww` must exit non-zero with a + * `duplicate symbol` message. Non-vacuity: link a single `.o` + * (+ libwwrt.a) → clean link, runs. + * NOTE on parity: w6l and w6l_ww BOTH reject loud + non-zero and both + * name "duplicate symbol", but the EXACT text differs — cstage adds + * the path+symbol (`w6l: u2.o: duplicate symbol main`), wwstage prints + * the bare `w6l: duplicate symbol`. That is a PRE-EXISTING w6l_ww vs + * w6l message divergence on the reject path (byte-id-blind); commit 4 + * adds NO linker code and must hold w6l_ww byte-identical, so it is + * NOT reconciled here. The gate asserts the achievable reject parity: + * both exit != 0 AND both stderr contain "duplicate symbol". The + * text-divergence is filed (see report) for a sibling linker task. + * + * Light wwstage-driver test (CLAUDE.md rule 14): all fixtures + scratch + * live under /tmp, COLD each run. Models 989_sepbuild_run.c conventions. + */ +#include +#include +#include +#include +#include +#include + +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; +} + +/* True iff file `path` contains the literal substring `needle`. */ +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/wwcd_%d", getpid()); + snprintf(cmd, sizeof cmd, "rm -rf %s", td); + runwait(cmd); + mkdir(td, 0755); + + /* ---- A. dep-cycle reject ----------------------------------------- */ + char rootww[1024], dira[1024], dirb[1024], dirc[1024], fa[1100], + fb[1100], fc[1100]; + snprintf(rootww, sizeof rootww, "%s/root.ww", td); + snprintf(dira, sizeof dira, "%s/pkga", td); + snprintf(dirb, sizeof dirb, "%s/pkgb", td); + snprintf(dirc, sizeof dirc, "%s/pkgc", td); + mkdir(dira, 0755); mkdir(dirb, 0755); mkdir(dirc, 0755); + snprintf(fa, sizeof fa, "%s/a.ww", dira); + snprintf(fb, sizeof fb, "%s/b.ww", dirb); + snprintf(fc, sizeof fc, "%s/c.ww", dirc); + if (write_file(rootww, + "package main;\n" + "import pkga;\n" + "fn main() i32 = { return pkga.av(); };\n") || + write_file(fa, + "package pkga;\n" + "import pkgb;\n" + "export fn av() i32 = { return pkgb.bv(); };\n") || + write_file(fb, + "package pkgb;\n" + "import pkgc;\n" + "export fn bv() i32 = { return pkgc.cv(); };\n") || + write_file(fc, + "package pkgc;\n" + "import pkga;\n" + "export fn cv() i32 = { return pkga.av(); };\n")) { + fail++; goto out; + } + + struct { const char *drv, *tag; } stg[] = { + { "ww", "cs" }, { "ww_ww", "ww" } + }; + char cerr_path[2][1100]; + for (int s = 0; s < 2; s++) { + char prog[1100], errf[1100]; + snprintf(prog, sizeof prog, "%s/prog.%s", td, stg[s].tag); + snprintf(errf, sizeof errf, "%s/cyc.%s.err", td, stg[s].tag); + snprintf(cerr_path[s], sizeof cerr_path[s], "%s", errf); + snprintf(cmd, sizeof cmd, + "%s/%s build --sep -o %s %s >/dev/null 2>%s", + bin, stg[s].drv, prog, rootww, errf); + int rc = runwait(cmd); + if (rc == 0) { + fprintf(stderr, "cycle FAIL: %s build --sep returned 0 " + "(no-op reject)\n", stg[s].drv); + fail++; + } + if (!file_has(errf, "dependency cycle: pkga -> pkgb -> pkgc -> pkga")) { + fprintf(stderr, "cycle FAIL: %s missing cycle-chain " + "message\n", stg[s].drv); + fail++; + } + if (access(prog, 0) == 0) { + fprintf(stderr, "cycle FAIL: %s produced a partial " + "binary %s\n", stg[s].drv, prog); + fail++; + } + } + /* cs==ww (rule 10): the cycle message is pure driver code → stderr + * byte-identical between the two stages. */ + if (files_eq(cerr_path[0], cerr_path[1]) != 0) { + fprintf(stderr, "cycle FAIL: cs stderr != ww stderr (rule 10)\n"); + fail++; + } + + /* Non-vacuity: break the cycle → both stages build + run (exit 7). */ + if (write_file(fc, + "package pkgc;\n" + "export fn cv() i32 = { return 7; };\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, + "%s/%s build --sep -o %s %s >/dev/null 2>&1", + bin, stg[s].drv, prog, rootww); + if (runwait(cmd) != 0) { + fprintf(stderr, "cycle FAIL(non-vacuity): %s could not " + "build the acyclic graph\n", stg[s].drv); + fail++; + continue; + } + int rc = runwait(prog); + if (rc != 7) { + fprintf(stderr, "cycle FAIL(non-vacuity): %s prog exit=%d " + "expected 7\n", stg[s].drv, rc); + fail++; + } + } + + /* ---- B. #31 duplicate-symbol reject ------------------------------ */ + char u1[1100], u2[1100], s1[1100], s2[1100], o1[1100], o2[1100]; + snprintf(u1, sizeof u1, "%s/u1.ww", td); + snprintf(u2, sizeof u2, "%s/u2.ww", td); + snprintf(s1, sizeof s1, "%s/u1.s", td); + snprintf(s2, sizeof s2, "%s/u2.s", td); + snprintf(o1, sizeof o1, "%s/u1.o", td); + snprintf(o2, sizeof o2, "%s/u2.o", td); + if (write_file(u1, "package main;\nfn main() i32 = { return 0; };\n") || + write_file(u2, "package main;\nfn main() i32 = { return 1; };\n")) { + fail++; goto out; + } + snprintf(cmd, sizeof cmd, "%s/w6c -c -o %s %s && %s/w6a -o %s %s", + bin, s1, u1, bin, o1, s1); + if (runwait(cmd) != 0) { fprintf(stderr, "dup FAIL: build u1.o\n"); fail++; goto out; } + snprintf(cmd, sizeof cmd, "%s/w6c -c -o %s %s && %s/w6a -o %s %s", + bin, s2, u2, bin, o2, s2); + if (runwait(cmd) != 0) { fprintf(stderr, "dup FAIL: build u2.o\n"); fail++; goto out; } + + /* Both linkers must reject the dup loud + non-zero. */ + const char *lnk[] = { "w6l", "w6l_ww" }; + for (int i = 0; i < 2; i++) { + char errf[1100], prog[1100]; + snprintf(errf, sizeof errf, "%s/dup.%s.err", td, lnk[i]); + snprintf(prog, sizeof prog, "%s/dupprog.%s", td, lnk[i]); + snprintf(cmd, sizeof cmd, "%s/%s -o %s %s %s >/dev/null 2>%s", + bin, lnk[i], prog, o1, o2, errf); + int rc = runwait(cmd); + if (rc == 0) { + fprintf(stderr, "dup FAIL: %s accepted a duplicate " + "symbol (exit 0)\n", lnk[i]); + fail++; + } + if (!file_has(errf, "duplicate symbol")) { + fprintf(stderr, "dup FAIL: %s missing 'duplicate symbol' " + "message\n", lnk[i]); + fail++; + } + if (access(prog, 0) == 0) { + fprintf(stderr, "dup FAIL: %s produced a partial binary " + "%s on the reject path\n", lnk[i], prog); + fail++; + } + } + + /* Non-vacuity: a single .o + libwwrt.a links clean and runs. */ + { + char rt[1100], prog[1100]; + snprintf(rt, sizeof rt, "%s/../lib/libwwrt.a", bin); + for (int i = 0; i < 2; i++) { + snprintf(prog, sizeof prog, "%s/single.%s", td, lnk[i]); + snprintf(cmd, sizeof cmd, "%s/%s -o %s %s %s >/dev/null 2>&1", + bin, lnk[i], prog, o1, rt); + if (runwait(cmd) != 0) { + fprintf(stderr, "dup FAIL(non-vacuity): %s could " + "not link a single .o\n", lnk[i]); + fail++; + continue; + } + if (runwait(prog) != 0) { + fprintf(stderr, "dup FAIL(non-vacuity): %s single " + "prog did not run clean\n", lnk[i]); + fail++; + } + } + } + +out: + snprintf(cmd, sizeof cmd, "rm -rf %s", td); + runwait(cmd); + if (fail) { + fprintf(stderr, "sepcycle_dup: %d check(s) failed\n", fail); + return 1; + } + printf("sepcycle_dup: dep-cycle reject (chain named, cs==ww stderr, no " + "partial binary, non-vacuity flip) + #31 dup-symbol reject (w6l & " + "w6l_ww loud+non-zero, non-vacuity single-.o link)\n"); + return 0; +}