From 51fc727fe2fb0064c94bd39df5f6a1ed55caf513 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Tue, 21 Jul 2026 01:56:36 +1000 Subject: [PATCH] G-102 M2: build.zig.zon pinned manifest + zig-native bootstrap staging suite_tcl90 gains the no-tclsh source path: - build.zig.zon: content-hashed manifest pinning each source as a fossil per-checkin tarball (tcl 1a9c3b9d96 / thread caa1d3aa30 / tclvfs f082c47f9b / tk effef6c657 / tklib 2d53beafc0 / tcllib c784f6fda4 - the exact checkouts the promoted punk905 runtime was built from) plus critcl 3.3.1 as a github archive. All lazy: the fossil dev flow never triggers a fetch. Refresh = bump a pin (zig fetch --save). Verified honored under --build-file build905.zig. - build905.zig mode split: from the staged recipe copy the full graph registers as before; from the TRACKED suite dir (sources.config present, no ../tcl905) only the staging steps register. 'zig build stage' materializes pinned sources + a recipe copy into ../_build/ (the same layout suite.tcl's fossil staging produces - both flows share the staged invocation, and the stage dir derives from the suite folder name so copy-and-tweak isolation holds). 'zig build bootstrap' chains the staged build: nested zig invocation with suite.tcl-parity cwd (parts of the recipe resolve sources cwd-relative), forward-slash prefix (the prefix flows into -DTCL_PACKAGE_PATH as a C string literal - backslashes broke the stubs compile), per-zig-version shared cache dir, hermetic-child env scrub, ReleaseFast default. - tools/stagetree.zig: host-compiled materializer. Pinned mode keys a .stage-materialized marker on the dependency's content-addressed cache path (pin bump = invalidation, stale trees deleted so orphans cannot survive); always-mode for recipe items; fossil/git checkouts at the destination are never touched (tested). Verified: 'zig build stage' against the fossil-populated stage refreshes only the recipe copy (checkouts untouched, fossil changes empty); a full copy-and-tweak suite copy (suite_tcl90zt) ran 'zig build bootstrap' from nothing but the tracked tree + pinned zig - all 7 sources fetched/staged from the manifest, all four shells built; tcl::build-info reports the same 9.0.5+1a9c3b9d... as the fossil-flow build and the zip shell boots its attached library (tzdata + autoload). Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- src/buildsuites/suite_tcl90/build.zig.zon | 68 +++++++ src/buildsuites/suite_tcl90/build905.zig | 143 +++++++++++++++ .../suite_tcl90/tools/stagetree.zig | 173 ++++++++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 src/buildsuites/suite_tcl90/build.zig.zon create mode 100644 src/buildsuites/suite_tcl90/tools/stagetree.zig diff --git a/src/buildsuites/suite_tcl90/build.zig.zon b/src/buildsuites/suite_tcl90/build.zig.zon new file mode 100644 index 00000000..0ad37f24 --- /dev/null +++ b/src/buildsuites/suite_tcl90/build.zig.zon @@ -0,0 +1,68 @@ +//build.zig.zon - suite_tcl90 pinned source manifest (G-102). +// +//This is the CONTENT-HASHED tweak surface for the no-tclsh bootstrap flow: each +//dependency pins an exact upstream state (fossil per-checkin tarball urls / +//github archive urls) plus the zig package hash of the extracted tree. 'Refresh' +//means bumping a pin: point .url at the new checkin/commit tarball and update +//.hash ('zig fetch ' prints it; 'zig fetch --save= ' edits this +//file in place). Branch-tip tarball urls are deliberately NOT used - they are not +//hash-stable, and the pin IS the provenance record. +// +//The fossil-checkout flow (suite.tcl + sources.config) remains the dev +//alternative for live-branch work; sources.config documents the relationship. +//Dependency names here match sources.config source names; the checkout dirs they +//materialize into are the recipe-coupled dir names documented there. +// +//All dependencies are lazy: nothing is fetched unless the bootstrap staging flow +//actually needs it (the fossil-staged flow never triggers a fetch). +.{ + .name = .suite_tcl90, + .version = "0.0.1", + .fingerprint = 0xacfa2b8743c3fafc, + .minimum_zig_version = "0.16.0", + .dependencies = .{ + //tcl core-9-0-branch checkout 1a9c3b9d96 2026-07-16 (9.0.5 era) + .tcl = .{ + .url = "https://core.tcl-lang.org/tcl/tarball/1a9c3b9d965d66f69f037cab6718d64bd802ef2c/tcl.tar.gz", + .hash = "N-V-__8AACdREwL2XmVaouKzc7VAtnbgvv6DiWJ9PZEpDEIA", + .lazy = true, + }, + //thread trunk checkout caa1d3aa30 2026-07-13 (3.0.1 era) + .tclthread = .{ + .url = "https://core.tcl-lang.org/thread/tarball/caa1d3aa302798de2200de7297f9d4ca40102a5d/thread.tar.gz", + .hash = "N-V-__8AALOqFAA-WUgJ777DabyIuinMYLe_jXido0KjnJbT", + .lazy = true, + }, + //tclvfs trunk checkout f082c47f9b 2023-09-05 (1.4.2 era) + .tclvfs = .{ + .url = "https://core.tcl-lang.org/tclvfs/tarball/f082c47f9b2e04566e8ae70d56c6a02980d43674/tclvfs.tar.gz", + .hash = "N-V-__8AAK_2DQDNpjv04LswPFyezzY97rkc5cuwcaqO-Nyq", + .lazy = true, + }, + //tk core-9-0-branch checkout effef6c657 2025-04-07 (9.0.2) + .tk = .{ + .url = "https://core.tcl-lang.org/tk/tarball/effef6c657952d3304048267fa40a795a318997a/tk.tar.gz", + .hash = "N-V-__8AANEfGQECMKAN0YhYXuom0BxQMprrwcZL_bc28RB5", + .lazy = true, + }, + //tklib trunk checkout 2d53beafc0 2026-07-03 (0.9 era) + .tklib = .{ + .url = "https://core.tcl-lang.org/tklib/tarball/2d53beafc0e8621dacc75b3c809898cc9f1f92f9/tklib.tar.gz", + .hash = "N-V-__8AABomLAFd2U5OW96AfpQLoLm3Q9swpQZEwslrEJx3", + .lazy = true, + }, + //tcllib trunk checkout c784f6fda4 2026-06-30 (2.0 era) + .tcllib = .{ + .url = "https://core.tcl-lang.org/tcllib/tarball/c784f6fda4516f676453a2785b3c56f1ca5999f4/tcllib.tar.gz", + .hash = "N-V-__8AAFvlBARw4fIY107nOJmfxdwAjr1Vr89cYx98czpF", + .lazy = true, + }, + //critcl 3.3.1 (tag commit 254bdffe2a) + .critcl = .{ + .url = "https://github.com/andreas-kupries/critcl/archive/254bdffe2afc78148f79f92df8d6823a24f971bf.tar.gz", + .hash = "N-V-__8AAF8-bABDlxChmtnyalKgofVfN3kzcZdmZ7pjEmE0", + .lazy = true, + }, + }, + .paths = .{""}, +} diff --git a/src/buildsuites/suite_tcl90/build905.zig b/src/buildsuites/suite_tcl90/build905.zig index f9f0e0b2..77e56481 100644 --- a/src/buildsuites/suite_tcl90/build905.zig +++ b/src/buildsuites/suite_tcl90/build905.zig @@ -83,6 +83,25 @@ const targets: []const std.Target.Query = &.{ }; pub fn build(b: *std.Build) !void { + //G-102 invocation modes. This recipe serves two build roots: + // STAGED mode - build root is the staged recipe copy (_build//build) with + // the source trees materialized beside it (../tcl905 etc) either by suite.tcl's + // fossil flow or by this recipe's own bootstrap staging. Full build graph. + // BOOTSTRAP mode - build root is the TRACKED suite dir (sources.config present, + // no ../tcl905): only the staging steps are registered - 'zig build stage' + // materializes the pinned build.zig.zon sources + a recipe copy into the + // _build stage, and 'zig build bootstrap' chains the staged build after it. + // This is the no-tclsh entry point. + if (!common.pathExists(b, tcl_source_folder)) { + if (common.pathExists(b, "sources.config")) { + try bootstrapMode(b); + return; + } + @panic("suite recipe: no source trees found beside the recipe (" ++ tcl_source_folder ++ + " missing). Use 'zig build stage' (or bootstrap) from the tracked suite dir for the " ++ + "pinned zon flow, or 'tclsh suite.tcl build' for the fossil dev flow."); + } + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -2160,3 +2179,127 @@ pub fn build(b: *std.Build) !void { //b.getInstallStep().dependOn(install_libraries); } + +//G-102 bootstrap mode: registered when this recipe is invoked from the TRACKED suite +//directory (see the mode note at the top of build()). The stage layout produced here +//is identical to suite.tcl's fossil staging, so both flows share the staged +//invocation - and the stage dir derives from THIS suite folder's name, preserving +//the copy-and-tweak isolation property (a copied suite stages into _build/). +fn bootstrapMode(b: *std.Build) !void { + //default ReleaseFast for suite.tcl parity (standardOptimizeOption would default + //Debug; the suite's runtimes are release artifacts) + const optimize = b.option(std.builtin.OptimizeMode, "optimize", "optimize mode forwarded to the staged build (default ReleaseFast)") orelse .ReleaseFast; + const suite_dirname = std.fs.path.basename(b.pathFromRoot("")); + const stage_rel = b.pathJoin(&.{ "..", "_build", suite_dirname }); + + const stage_tool = b.addExecutable(.{ + .name = "stagetree", + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/stagetree.zig"), + .target = b.graph.host, + }), + }); + + const stage_step = b.step("stage", "materialize pinned sources (build.zig.zon) + recipe copy into ../_build/ (existing fossil/git checkouts left untouched)"); + + //dependency name (build.zig.zon) -> stage dir (the recipe-coupled dir names + //documented in sources.config) + const source_dirs = [_][2][]const u8{ + .{ "tcl", "tcl905" }, + .{ "tclthread", "tclthread" }, + .{ "tclvfs", "tclvfs" }, + .{ "tk", "tk9" }, + .{ "tklib", "tklib" }, + .{ "tcllib", "tcllib" }, + .{ "critcl", "critcl" }, + }; + var deps_pending = false; + for (source_dirs) |sd| { + const dep_name = sd[0]; + const destdir = sd[1]; + const dest_from_root = b.pathJoin(&.{ stage_rel, destdir }); + //an existing fossil/git checkout is owned by the suite.tcl dev flow: skip the + //whole dependency so nothing is fetched for it (stagetree guards again at + //make time - this check just avoids needless fetches). + if (common.pathExists(b, b.pathJoin(&.{ dest_from_root, ".fslckout" })) or + common.pathExists(b, b.pathJoin(&.{ dest_from_root, "_FOSSIL_" })) or + common.pathExists(b, b.pathJoin(&.{ dest_from_root, ".git" }))) + { + continue; + } + if (b.lazyDependency(dep_name, .{})) |dep| { + const run = b.addRunArtifact(stage_tool); + run.setName(b.fmt("stage {s}", .{destdir})); + run.has_side_effects = true; + //the dependency's content-addressed cache path doubles as the pin key: + //a manifest pin bump changes it, invalidating the materialized copy + const dep_root = dep.path("").getPath(b); + run.addArgs(&.{ "-key", dep_root, "-src", dep_root, "-dest", b.pathFromRoot(dest_from_root) }); + stage_step.dependOn(&run.step); + } else { + deps_pending = true; + } + } + if (deps_pending) return; //zig fetches the lazy deps, then re-runs configure + + //recipe copy -> /build (the same item list suite.tcl stages; per-item + //replace every run - recipe files are small tracked working files, no pin) + const recipe_items = [_][]const u8{ + "build905.zig", + "build_common.zig", + "build_libtommath", + "build_zlib", + "build_tclvfs", + "build_tclthread", + "build_tk", + "patches", + "src", + "tools", + }; + for (recipe_items) |item| { + const run = b.addRunArtifact(stage_tool); + run.setName(b.fmt("stage recipe {s}", .{item})); + run.has_side_effects = true; + run.addArgs(&.{ "-key", "-", "-src", b.pathFromRoot(item), "-dest", b.pathFromRoot(b.pathJoin(&.{ stage_rel, "build", item })) }); + stage_step.dependOn(&run.step); + } + + //bootstrap: stage, then drive the staged recipe - the same nested invocation + //suite.tcl performs after its fossil staging. Per-zig-version cache dir (object + //caches must never be shared across zig versions - user-confirmed hazard), same + //name suite.tcl derives so both flows share the staged cache. + const steps_opt = b.option([]const []const u8, "steps", "steps for the staged build (repeat the flag; default: install install-libraries make-zipfs)") orelse @as([]const []const u8, &.{ "install", "install-libraries", "make-zipfs" }); + var cachever: []const u8 = builtin.zig_version_string; + cachever = common.replaceAll(b, cachever, "+", "_"); + cachever = common.replaceAll(b, cachever, "/", "_"); + cachever = common.replaceAll(b, cachever, ":", "_"); + //forward-slash paths throughout (suite.tcl parity): the prefix flows into + //-D macros like TCL_PACKAGE_PATH as a C string literal - backslashes there are + //parsed as escape sequences and break the compile + const nested = b.addSystemCommand(&.{ + b.graph.zig_exe, + "build", + "--build-file", + common.replaceAll(b, b.pathFromRoot(b.pathJoin(&.{ stage_rel, "build", "build905.zig" })), "\\", "/"), + "--cache-dir", + common.replaceAll(b, b.pathFromRoot(b.pathJoin(&.{ stage_rel, "build", b.fmt(".zig-cache-{s}", .{cachever}) })), "\\", "/"), + "--prefix", + common.replaceAll(b, b.pathFromRoot(b.pathJoin(&.{ stage_rel, "out" })), "\\", "/"), + b.fmt("-Doptimize={s}", .{@tagName(optimize)}), + }); + for (steps_opt) |s| nested.addArg(s); + nested.setName("staged build (nested zig build)"); + nested.has_side_effects = true; + //cwd = staged build dir (suite.tcl parity): parts of the recipe (e.g build_zlib's + //source iteration) resolve source paths relative to the process cwd, not the + //build root + nested.setCwd(.{ .cwd_relative = b.pathFromRoot(b.pathJoin(&.{ stage_rel, "build" })) }); + //hermetic child shells (parity with suite.tcl): steps in the staged graph run + //the freshly built tclsh, which must not see the user's machine-level tcl env + for ([_][]const u8{ "TCLLIBPATH", "TCL_LIBRARY", "TK_LIBRARY" }) |ev| { + nested.removeEnvironmentVariable(ev); + } + nested.step.dependOn(stage_step); + const bootstrap_step = b.step("bootstrap", "stage + run the staged recipe end-to-end (no pre-existing tclsh required)"); + bootstrap_step.dependOn(&nested.step); +} diff --git a/src/buildsuites/suite_tcl90/tools/stagetree.zig b/src/buildsuites/suite_tcl90/tools/stagetree.zig new file mode 100644 index 00000000..0ee7aba9 --- /dev/null +++ b/src/buildsuites/suite_tcl90/tools/stagetree.zig @@ -0,0 +1,173 @@ +//stagetree - materialize a source tree (or single file) into the build stage (G-102). +// +//Used by the suite recipe's bootstrap mode to copy zon-fetched dependency trees and +//the tracked recipe files into the _build/ stage layout that the staged build +//invocation (and the fossil dev flow) share. Invariant guarded here: an existing +//fossil/git CHECKOUT at the destination is never touched - the fossil dev flow owns +//those directories. +// +// stagetree -key -src -dest +// +// -key pinned-tree mode: if /.stage-materialized already contains +// exactly , the destination is up to date and nothing is done. +// Otherwise the destination tree is DELETED, the source tree copied, +// and the marker written. Pass the dependency's content-addressed +// cache path as the pin - a pin bump changes it, invalidating the copy. +// -key - always mode (recipe files): delete destination, copy, no marker. +// +//Informational prints go to stdout only: zig 0.16's build_runner reports any step +//with nonempty stderr under a misleading 'failed command:' block even on success. +const std = @import("std"); + +const usage = + \\Usage: ./stagetree -key -src -dest +; + +const marker_name = ".stage-materialized"; + +pub fn main(init: std.process.Init.Minimal) !void { + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var threaded: std.Io.Threaded = .init(arena, .{ + .environ = init.environ, + .argv0 = .init(init.args), + }); + defer threaded.deinit(); + const io = threaded.io(); + + const args = try init.args.toSlice(arena); + var opt_key: ?[]const u8 = null; + var opt_src: ?[]const u8 = null; + var opt_dest: ?[]const u8 = null; + { + var i: usize = 1; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "-help", arg)) { + std.debug.print("{s}\n", .{usage}); + return std.process.cleanExit(io); + } else if (std.mem.eql(u8, "-key", arg)) { + i += 1; + if (i >= args.len) fatal("expected arg after '{s}'", .{arg}); + opt_key = args[i]; + } else if (std.mem.eql(u8, "-src", arg)) { + i += 1; + if (i >= args.len) fatal("expected arg after '{s}'", .{arg}); + opt_src = args[i]; + } else if (std.mem.eql(u8, "-dest", arg)) { + i += 1; + if (i >= args.len) fatal("expected arg after '{s}'", .{arg}); + opt_dest = args[i]; + } else { + fatal("unknown argument '{s}'\n{s}", .{ arg, usage }); + } + } + } + const key = opt_key orelse fatal("missing -key", .{}); + const src = opt_src orelse fatal("missing -src", .{}); + const dest = opt_dest orelse fatal("missing -dest", .{}); + const cwd = std.Io.Dir.cwd(); + + //never touch a fossil/git checkout at the destination + inline for (.{ ".fslckout", "_FOSSIL_", ".git" }) |vcsmark| { + const p = try std.fs.path.join(arena, &.{ dest, vcsmark }); + if (pathExists(cwd, io, p)) { + try say(io, arena, "stagetree: {s} is a VCS checkout ({s} present) - left untouched\n", .{ dest, vcsmark }); + return std.process.cleanExit(io); + } + } + + const src_stat = cwd.statFile(io, src, .{}) catch |err| { + fatal("cannot stat -src '{s}': {s}", .{ src, @errorName(err) }); + }; + + const marker_path = try std.fs.path.join(arena, &.{ dest, marker_name }); + const pinned = !std.mem.eql(u8, key, "-"); + if (pinned and src_stat.kind == .directory) { + if (cwd.readFileAlloc(io, marker_path, arena, .limited(64 * 1024))) |existing| { + if (std.mem.eql(u8, std.mem.trim(u8, existing, " \r\n"), key)) { + try say(io, arena, "stagetree: {s} up to date (pin match)\n", .{dest}); + return std.process.cleanExit(io); + } + } else |_| {} + } + + if (src_stat.kind == .file) { + //single recipe file: parent dir must exist or be creatable; plain replace + if (std.fs.path.dirname(dest)) |destparent| { + cwd.createDirPath(io, destparent) catch |err| { + fatal("cannot create '{s}': {s}", .{ destparent, @errorName(err) }); + }; + } + cwd.deleteFile(io, dest) catch |err| switch (err) { + error.FileNotFound => {}, + else => fatal("cannot remove stale '{s}': {s}", .{ dest, @errorName(err) }), + }; + cwd.copyFile(src, cwd, dest, io, .{}) catch |err| { + fatal("copy '{s}' -> '{s}' failed: {s}", .{ src, dest, @errorName(err) }); + }; + try say(io, arena, "stagetree: staged file {s}\n", .{dest}); + return std.process.cleanExit(io); + } + + //directory tree: delete stale destination entirely (orphan files from a previous + //pin must not survive), then copy + cwd.deleteTree(io, dest) catch |err| { + fatal("cannot remove stale tree '{s}': {s}", .{ dest, @errorName(err) }); + }; + cwd.createDirPath(io, dest) catch |err| { + fatal("cannot create '{s}': {s}", .{ dest, @errorName(err) }); + }; + + var src_dir = cwd.openDir(io, src, .{ .iterate = true }) catch |err| { + fatal("cannot open -src dir '{s}': {s}", .{ src, @errorName(err) }); + }; + defer src_dir.close(io); + var dest_dir = cwd.openDir(io, dest, .{}) catch |err| { + fatal("cannot open -dest dir '{s}': {s}", .{ dest, @errorName(err) }); + }; + defer dest_dir.close(io); + + var nfiles: usize = 0; + var walker = try src_dir.walk(arena); + defer walker.deinit(); + while (try walker.next(io)) |entry| { + switch (entry.kind) { + .directory => { + dest_dir.createDirPath(io, entry.path) catch |err| { + fatal("mkdir '{s}' in '{s}': {s}", .{ entry.path, dest, @errorName(err) }); + }; + }, + .file => { + src_dir.copyFile(entry.path, dest_dir, entry.path, io, .{}) catch |err| { + fatal("copy '{s}' into '{s}' failed: {s}", .{ entry.path, dest, @errorName(err) }); + }; + nfiles += 1; + }, + else => {}, + } + } + if (pinned) { + try cwd.writeFile(io, .{ .sub_path = marker_path, .data = key }); + } + try say(io, arena, "stagetree: staged {d} files -> {s}\n", .{ nfiles, dest }); + return std.process.cleanExit(io); +} + +fn say(io: std.Io, arena: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void { + const msg = try std.fmt.allocPrint(arena, fmt, args); + var f = std.Io.File.stdout(); + try f.writeStreamingAll(io, msg); +} + +fn pathExists(dir: std.Io.Dir, io: std.Io, p: []const u8) bool { + dir.access(io, p, .{}) catch return false; + return true; +} + +fn fatal(comptime format: []const u8, args: anytype) noreturn { + std.debug.print(format, args); + std.process.exit(1); +}