diff --git a/cmd/ww/main.c b/cmd/ww/main.c index 1ffc7021..4beaad92 100644 --- a/cmd/ww/main.c +++ b/cmd/ww/main.c @@ -196,7 +196,7 @@ locate_import_in(const char *dir, const char *path_form, char *out, * * #98: "a module IS the directory" — a directory-package on ANY entry * wins over a same-named sibling FILE on an EARLIER entry. The driver - * builds the searchpath srcd-first; a co-located `lib//test.ww` + * builds the searchpath srcd-first; a co-located `lib//_test.ww` * entry makes srcd = lib/, so a self-named `import ` would * else file-hit the sibling lib//.ww and fold it * inline under the wrong module-reset → "package does not match @@ -243,9 +243,9 @@ strs_cmp(const void *a, const void *b) return strcmp(sa, sb); } -/* A temporary migration rule keeps the pre-underscore corpus working without - * reserving every filename ending in "test.ww": only a real line-leading - * @test declaration makes a noncanonical source test-only. */ +/* Go's contract: only *_test.ww is a test source. A line-leading @test + * declaration anywhere else would be silently dropped by a non-T build + * (#6, the Hare model), so directory enumeration rejects it loudly. */ static int file_has_line_test(const char *path) { @@ -267,9 +267,10 @@ file_has_line_test(const char *path) } /* enumerate_dir_ww — collect production *.ww paths in `dirpath`, excluding - * canonical *_test.ww plus the explicit line-leading-@test compatibility - * sources, then sort byte-wise. This is the sole directory-membership - * discovery path; the owning seppkg retains the returned list. */ + * *_test.ww test sources, then sort byte-wise. A line-leading @test in + * any other source is diagnosed here and returns -2. This is the sole + * directory-membership discovery path; the owning seppkg retains the + * returned list. */ static int enumerate_dir_ww(const char *dirpath, char ***out_files) { @@ -285,9 +286,18 @@ enumerate_dir_ww(const char *dirpath, char ***out_files) if (strcmp(nm + nl - 3, ".ww") != 0) continue; char path[2048]; snprintf(path, sizeof path, "%s/%s", dirpath, nm); - if ((nl >= 8 && strcmp(nm + nl - 8, "_test.ww") == 0) - || file_has_line_test(path)) + if (nl >= 8 && strcmp(nm + nl - 8, "_test.ww") == 0) continue; + if (file_has_line_test(path)) { + fprintf(stderr, + "ww: %s: @test declaration outside *_test.ww\n", + path); + for (int i = 0; i < n; i++) free(arr[i]); + free(arr); + closedir(d); + *out_files = NULL; + return -2; + } if (n + 1 > cap) { cap = cap ? cap * 2 : 8; arr = realloc(arr, cap * sizeof *arr); @@ -634,7 +644,9 @@ sep_load_pkg(struct sepgraph *g, int pi, const char *searchpath) if (g->pkg[pi].is_dir) { g->pkg[pi].nsources = enumerate_dir_ww(g->pkg[pi].entry, &g->pkg[pi].sources); - if (g->pkg[pi].nsources < 0) { + if (g->pkg[pi].nsources == -2) { + rc = -1; /* diagnosed in enumerate_dir_ww */ + } else if (g->pkg[pi].nsources < 0) { fprintf(stderr, "ww: cannot read directory %s\n", g->pkg[pi].entry); rc = -1; diff --git a/docs/test-system-v2.md b/docs/test-system-v2.md index 421aac74..e656cf49 100644 --- a/docs/test-system-v2.md +++ b/docs/test-system-v2.md @@ -194,7 +194,10 @@ and nonempty-version-constant assertions. `ww test` delegates directory package requests to the native package coordinator. The coordinator owns discovery, package grouping, same-package and external-package test composition, filtering, result aggregation, and its -internal temporary workspace. A trailing `...` path element (`ww test lib/...`, +internal temporary workspace. Test sources are exclusively `*_test.ww` (Go's +`_test.go` contract): a line-leading `@test` declaration in any other source is +rejected loudly — by both driver stages at directory enumeration and by the +coordinator at source classification. A trailing `...` path element (`ww test lib/...`, Go's `./...` form) is recognized by both driver stages before path resolution and walks the tree rooted at the prefix: every subdirectory whose name does not begin with `.` or `_` is descended with the same lstat/no-symlink discipline, diff --git a/internal/wwpackage/package.ww b/internal/wwpackage/package.ww index 1993ae7f..5f4b3118 100644 --- a/internal/wwpackage/package.ww +++ b/internal/wwpackage/package.ww @@ -107,7 +107,7 @@ fn pkgfailpath(path: str, reason: str) void = { fn pkgusage() void = { let s: str = strings.concat( "usage: wwtest package [-c] [-list] [-j N] [-I DIR] [-w DIR] [-run|-filter GLOB] [-timeout-ms=N] [DIR | DIR/...] [-- GLOB ...]\n", - " *_test.ww is canonical; noncanonical files require an actual @test declaration\n", + " *_test.ww is the sole test-source form; @test elsewhere is rejected\n", " -c retains the compiled package binaries; -j N runs up to N package groups at once\n", " -w DIR keys a persistent per-package-group build workdir under DIR\n"); pkgput(os.STDERR_FILENO, s); @@ -846,8 +846,14 @@ export fn packagecommand(args: []str) int = { s.dir = strings.dup(pkgdirname(ds.paths[i])); s.pkg = strings.dup(pn); s.attest = pkgattest(body); - s.test = strings.hassuffix(pkgbase(ds.paths[i]), "_test.ww") - || s.attest; + s.test = strings.hassuffix(pkgbase(ds.paths[i]), "_test.ww"); + // Go's contract: only *_test.ww is a test source; @test + // anywhere else fails loudly rather than run or drop silently. + if (s.attest && !s.test) { + pkgfailpath(ds.paths[i], + "@test declaration outside *_test.ww"); + return 1; + }; append(srcs, s); i += 1; }; diff --git a/selfhost/cmd/ww/main.ww b/selfhost/cmd/ww/main.ww index 8191c17f..fca54947 100644 --- a/selfhost/cmd/ww/main.ww +++ b/selfhost/cmd/ww/main.ww @@ -352,10 +352,10 @@ fn locateimport(dirs: *u8, name: *u8, namelen: u64, return nil; }; -// Temporary compatibility for the repository's pre-underscore test sources: -// a noncanonical filename is test-only only when its body contains a real -// line-leading @test declaration. This is the wwstage twin of -// cmd/ww/main.c:file_has_line_test. +// Go's contract: only *_test.ww is a test source. A line-leading @test +// declaration anywhere else would be silently dropped by a non-T build +// (#6, the Hare model), so directory enumeration rejects it loudly. +// This is the wwstage twin of cmd/ww/main.c:file_has_line_test. fn dirfileattest(dirpath: *u8, name: *u8) bool = { let path: *u8 = joinpath(dirpath, name); let fd: i32 = os.open(pathstr(path), os.flag.RDONLY, 0i32); @@ -393,20 +393,20 @@ fn dirfileattest(dirpath: *u8, name: *u8) bool = { return false; }; -// Filter for production enumeration: keep `*.ww` minus canonical -// `*_test.ww` and the explicit compatibility sources above. Returns -// true to keep. -fn dirfilekeep(dirpath: *u8, name: *u8, nlen: u64) bool = { +// Classify a directory entry for production enumeration: 1 keep, +// 0 skip (non-source or *_test.ww), -1 @test outside *_test.ww +// (caller diagnoses and fails). +fn dirfileclass(dirpath: *u8, name: *u8, nlen: u64) i32 = { // nlen<=3 guard kept: a bare ".ww" (len 3) is rejected here but // would pass strings.hassuffix(".ww"); preserves cstage parity. - if (nlen <= 3u64) { return false; }; + if (nlen <= 3u64) { return 0; }; let s: str; s.ptr = name; s.len = nlen: i32; - if (!strings.hassuffix(s, ".ww")) { return false; }; - if (strings.hassuffix(s, "_test.ww") - || dirfileattest(dirpath, name)) { return false; }; - return true; + if (!strings.hassuffix(s, ".ww")) { return 0; }; + if (strings.hassuffix(s, "_test.ww")) { return 0; }; + if (dirfileattest(dirpath, name)) { return -1; }; + return 1; }; // Byte-wise memcmp returning < 0, 0, > 0. Rule-10 byte-id requires @@ -429,10 +429,11 @@ fn bytecmp(a: *u8, alen: u64, b: *u8, blen: u64) i32 = { return 0; }; -// enumeratedir — list production *.ww paths of `dirpath` (less canonical -// and compatibility test sources), byte-sort. Returns -// one exact pointer array of NUL-terminated full paths. This is the sole -// directory-membership discovery path; the owning seppkg retains the list. +// enumeratedir — list production *.ww paths of `dirpath` (less *_test.ww +// test sources), byte-sort. A line-leading @test in any other source is +// diagnosed here and returns -2. Returns one exact pointer array of +// NUL-terminated full paths. This is the sole directory-membership +// discovery path; the owning seppkg retains the list. fn enumeratedir(dirpath: *u8) (**u8, i32) = { let fd: i32 = os.open(pathstr(dirpath), os.flag.RDONLY, 0i32); if (fd < 0) { return nil: **u8, -1; }; @@ -456,7 +457,15 @@ fn enumeratedir(dirpath: *u8) (**u8, i32) = { let reclen: u64 = blo + (bhi * 256u64); let nm: *u8 = buf.ptr + off + 19u64; let nl: u64 = cstrlen(nm); - if (dirfilekeep(dirpath, nm, nl)) { + let cls: i32 = dirfileclass(dirpath, nm, nl); + if (cls < 0) { + cerr("ww: "); + cerr(pathstr(joinpath(dirpath, nm))); + cerr(": @test declaration outside *_test.ww\n"); + os.close(fd); + return nil: **u8, -2; + }; + if (cls > 0) { if (n >= cap) { let ncap: i32 = cap * 2; let nn: []*u8 = alloc([], ncap: u64)!; @@ -1000,7 +1009,10 @@ fn seploadpkg(g: *sepgraph, pi: i32, searchpath: *u8) i32 = { sources, nsources = enumeratedir(g.pkg[pi].entry); g.pkg[pi].sources = sources; g.pkg[pi].nsources = nsources; - if (g.pkg[pi].nsources < 0) { + if (g.pkg[pi].nsources == -2) { + // diagnosed in enumeratedir + rc = -1; + } else { if (g.pkg[pi].nsources < 0) { cerr("ww: cannot read directory "); cerr(pathstr(g.pkg[pi].entry)); cerr("\n"); @@ -1010,7 +1022,7 @@ fn seploadpkg(g: *sepgraph, pi: i32, searchpath: *u8) i32 = { cerr(pathstr(g.pkg[pi].entry)); cerr(": directory contains no WW package sources\n"); rc = -1; - }; }; + }; }; }; let i: i32 = 0; for (i < g.pkg[pi].nsources) { if (rc == 0) { diff --git a/test/package/dependency/dep/dep_test.ww b/test/package/dependency/dep/dep_test.ww new file mode 100644 index 00000000..b5f57a31 --- /dev/null +++ b/test/package/dependency/dep/dep_test.ww @@ -0,0 +1,7 @@ +package dep; + +// Go's contract: a package's *_test.ww sources are not part of the +// imported package. Must be excluded when dep is imported by root. +@test fn imported_dependency_test_must_not_leak() void = { + assert(false); +}; diff --git a/test/package/dependency/dep/deptest.ww b/test/package/dependency/dep/deptest.ww deleted file mode 100644 index a837646c..00000000 --- a/test/package/dependency/dep/deptest.ww +++ /dev/null @@ -1,7 +0,0 @@ -package dep; - -// Noncanonical migration spelling: the actual line-leading @test makes this -// test-only. It must be excluded when dep is imported by the root package. -@test fn imported_dependency_test_must_not_leak() void = { - assert(false); -}; diff --git a/test/package/package_test.ww b/test/package/package_test.ww index 0caf4554..821098e8 100644 --- a/test/package/package_test.ww +++ b/test/package/package_test.ww @@ -232,6 +232,26 @@ fn packagepath(relative: str) str = { clean(root); }; +// Go's contract: only *_test.ww sources are test sources; a line-leading +// @test anywhere else fails classification loudly rather than run or +// drop silently. +@test fn noncanonical_attest_rejected() void = { + let root: str = fresh(); + let pkgdir: str = strings.concat(root, "/noncanon"); + assert(os.mkdir(pkgdir, 448i32) == 0); + writefile(strings.concat(pkgdir, "/noncanon.ww"), strings.concat( + "package noncanon;\n", + "@test fn hidden() void = { assert(false); };\n")); + let av: []str = [driver("ww"), "test", pkgdir]; + let out: commandout; + runcommand(root, "noncanon", av, + (30i64 * (time.second: i64)): time.duration, &out); + expectexit(&out, 1); + assert(has(out.stderr, "@test declaration outside *_test.ww")); + assert(!has(out.stdout, "hidden")); + clean(root); +}; + @test fn empty_and_invalid_package_classes() void = { let root: str = fresh(); let av: []str = [driver("ww"), "test", packagepath("no_tests")]; diff --git a/test/sep/sepimport_test.ww b/test/sep/sepimport_test.ww index 86a107ff..0a8a7ae1 100644 --- a/test/sep/sepimport_test.ww +++ b/test/sep/sepimport_test.ww @@ -89,7 +89,7 @@ fn cmpsepwork(label: str, csdir: str, wwdir: str) void = { @test fn coloimport() void = { let td: str = testenv.fresh(); let inc: str = strings.concat(testenv.repo(), "/test/wcc/data/colo98"); - let entry: str = strings.concat(inc, "/widget/widgettest.ww"); + let entry: str = strings.concat(inc, "/widget/widget_test.ww"); let drvs: []str = ["ww", "ww_ww"]; let tags: []str = ["cs", "ww"]; let i: i32 = 0; diff --git a/test/wcc/data/colo98/widget/widgettest.ww b/test/wcc/data/colo98/widget/widget_test.ww similarity index 100% rename from test/wcc/data/colo98/widget/widgettest.ww rename to test/wcc/data/colo98/widget/widget_test.ww diff --git a/test/xmod/direnum_test.ww b/test/xmod/direnum_test.ww index 88bca2dd..e1613293 100644 --- a/test/xmod/direnum_test.ww +++ b/test/xmod/direnum_test.ww @@ -24,8 +24,9 @@ package direnum_test; // rejects — the authoritative loader-reject table over private trees: // root-conflict ("conflicting package names"), import-leaf ("does // not match import path"), root-missing and root-invalid ("invalid -// or missing package clause"); per row both stages fail, carry the -// needle, and their captured stderr is byte-identical. +// or missing package clause"), attest-noncanon ("@test declaration +// outside *_test.ww" — Go's test-file contract); per row both stages +// fail, carry the needle, and their captured stderr is byte-identical. // // Dropped C machinery, not assertions: the ww_ww-absent skip gate // (the Make target declares both drivers) and the unlink/rmdir @@ -164,11 +165,13 @@ fn rejectpair(label: str, td: str, target: str, needle: str) void = { let wanted: str = strings.concat(importbad, "/wanted"); let missing: str = strings.concat(td, "/missing"); let invalid: str = strings.concat(td, "/invalid"); + let attbad: str = strings.concat(td, "/attbad"); assert(os.mkdir(rootbad, 493) == 0); assert(os.mkdir(importbad, 493) == 0); assert(os.mkdir(wanted, 493) == 0); assert(os.mkdir(missing, 493) == 0); assert(os.mkdir(invalid, 493) == 0); + assert(os.mkdir(attbad, 493) == 0); testenv.writefile(strings.concat(rootbad, "/a.ww"), "package rootbad;\nfn main() i32 = { return 0; };\n"); testenv.writefile(strings.concat(rootbad, "/b.ww"), @@ -182,15 +185,21 @@ fn rejectpair(label: str, td: str, target: str, needle: str) void = { "fn main() i32 = { return 0; };\n"); testenv.writefile(strings.concat(invalid, "/a.ww"), "package 7bad;\nfn main() i32 = { return 0; };\n"); + testenv.writefile(strings.concat(attbad, "/a.ww"), + "package attbad;\nfn main() i32 = { return 0; };\n"); + testenv.writefile(strings.concat(attbad, "/t.ww"), strings.concat( + "package attbad;\n", + "@test fn hidden() void = { assert(false); };\n")); let tags: []str = ["root-conflict", "import-leaf", "root-missing", - "root-invalid"]; + "root-invalid", "attest-noncanon"]; let targets: []str = [rootbad, strings.concat(importbad, - "/entry.ww"), missing, invalid]; + "/entry.ww"), missing, invalid, attbad]; let needles: []str = ["conflicting package names", "does not match import path", "invalid or missing package clause", - "invalid or missing package clause"]; + "invalid or missing package clause", + "@test declaration outside *_test.ww"]; let r: i32 = 0; for (r < tags.len) { rejectpair(tags[r], td, targets[r], needles[r]);