Crafter.Build/implementations/Crafter.Build-Platform.cpp

1044 lines
48 KiB
C++
Raw Normal View History

2026-07-23 01:24:42 +02:00
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
2026-07-22 18:25:39 +02:00
2026-04-27 07:04:42 +02:00
module;
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <unistd.h>
#include <dlfcn.h>
#include <sys/wait.h>
2026-05-30 19:28:06 +02:00
#include <sys/file.h>
#include <fcntl.h>
#include <errno.h>
2026-04-27 07:04:42 +02:00
module Crafter.Build:Platform_impl;
2026-04-23 01:57:25 +02:00
import std;
import :Platform;
feat(toolchain): resolve the compiler in one place Every clang++, clang and llvm-ar the tool spawned was a bare name on PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI sysroot /usr/share/wasi-sysroot and the mingw headers /usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A toolchain installed anywhere else, which is what the Flatpak ships under /app, had no way in. Crafter.Build:Toolchain resolves all of it once per process: CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build (<prefix>/bin, found through the same walk GetCrafterBuildHome does), else PATH. What sits beside the compiler is used when present: its libclang.so, its clang-c headers, its std module source, its WASI sysroot. An explicit prefix without a clang++ is an error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other than the compiler's own, and CRAFTER_MINGW_DIR now does what its help line always claimed: it is passed as --sysroot for the mingw target. Binaries linked by a toolchain outside /usr get an rpath to its lib dir, since its libc++.so.1 is not on the loader's default path. HostTarget() is the resolved clang's -print-target-triple. A clang built without a vendor reports x86_64-unknown-linux-gnu where Arch's reports x86_64-pc-linux-gnu, so target equality that decides whether a test runs locally and which targets a bare `test` sweeps goes through SameTarget, which ignores the vendor field. The strings themselves are never rewritten: clang rejects a PCM whose triple differs from the importing unit's. The examples and README stop hardcoding the host triple; the default is already the host. `crafter-build --version` prints the version baked in at build time (CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the compiler and the host triple, and is answered before any argument is forwarded to project.cpp. The version is a define on the library, so each version is its own library variant and relinks the executable and tests once per commit. build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already there, and writes a fresh archive instead of appending to one that may hold members from an older layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
import :Toolchain;
2026-04-23 01:57:25 +02:00
import :Clang;
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
import :Interface;
import :External;
2026-04-29 03:27:11 +02:00
import :Progress;
2026-04-23 01:57:25 +02:00
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
2026-05-30 19:28:06 +02:00
// Exclusive advisory lock over one PCM cache dir (`<cache>/<target>-<march>/`),
// held for the duration of a (re)build. Concurrent crafter-build invocations —
// and the worker threads inside a single invocation — that share a cache
// serialize their builds through it: the first builder to acquire the lock
// precompiles std.pcm / the Crafter.Build-*.pcm modules while everyone else
// blocks, then re-checks freshness and reuses the finished file instead of
// re-running clang and racing to overwrite the same path. Each builder must
// pass through the lock before it reaches the compile steps that *read* those
// PCMs, so a reader never observes a half-written file.
//
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
// A FileLock on a `.lock` file in the cache dir.
struct CacheLock {
FileLock lock;
explicit CacheLock(const fs::path& cacheDir) : lock(cacheDir / ".lock") {}
2026-05-30 19:28:06 +02:00
};
fix: key the host PCM cache on source content, add clean, hash project args Three follow-ons to the stale-build report, all cases of an identity not capturing something that changes the output. The host PCM cache under <cache>/crafter.build/<target>-<march>/ is shared by every crafter-build on the machine, and freshness was a per-file mtime comparison. That cannot tell "this PCM is newer than my source" from "this PCM was built from different sources that happen to be newer", so a package install and a working checkout — or two checkouts of different versions — silently compiled their project.cpp against each other's declarations. Invalidation now keys on a stamp over the bytes of every module source, which also covers the case one file's mtime never could: the cached PCMs import each other, so a change to :Interface invalidates :Clang's PCM with Crafter.Build-Clang.cppm untouched. Project args ApplyStandardArgs does not itself interpret are now folded into VariantId. Such a flag typically decides what gets compiled or bundled — the report's example is --no-webgpu dropping entries from cfg.files — and without it both settings shared one bin dir and interleaved their outputs there, leaving a bundle matching neither. Sorted and deduplicated so flag order doesn't split the cache, and inherited by test Configurations. `crafter-build clean` removes the project's bin/ and build/ trees. It deliberately does not load project.cpp: cleaning is most often reached when something is already wrong, and a clean that first needs the project to compile is useless exactly then.
2026-07-30 17:43:09 +00:00
// The cached Crafter.Build PCMs are keyed by `<target>-<march>` alone, so
// every crafter-build on the machine writes to the same files regardless of
// which share/crafter-build its module sources came from. Freshness used to
// be a per-file mtime comparison, which cannot distinguish "this PCM is
// newer than my source" from "this PCM was built from *different* sources
// that happen to be newer" — so a second install or checkout silently
// compiled its project against the other's declarations. Same failure shape
// as issue #27: no error, a binary built against a layout nobody linked.
//
// A stamp over the bytes of every module source answers what the mtime
// can't. It covers the whole set rather than one file at a time because the
// PCMs import each other: a change to :Interface invalidates :Clang's PCM
// even though Crafter.Build-Clang.cppm is untouched.
std::string CrafterBuildSourceStamp(const fs::path& sourceDir, std::span<const std::string_view> moduleNames) {
std::string all;
auto append = [&all, &sourceDir](const fs::path& relative) {
std::ifstream in(sourceDir / relative, std::ios::binary);
std::ostringstream buffer;
buffer << in.rdbuf();
all += relative.string();
all += '\0';
all += buffer.str();
all += '\0';
};
for (std::string_view name : moduleNames) {
append(fs::path(std::format("{}.cppm", name)));
}
return std::format("{:016x}", std::hash<std::string>{}(all));
}
fs::path CacheStampPath(const fs::path& cacheDir) {
return cacheDir / "crafter-build-sources.stamp";
}
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
// A stamp is a one-line content hash kept next to the artifact it
// describes. Written last, after the artifact, so an interrupted build
// leaves a stamp that disagrees and the next run starts over.
std::string ReadStamp(const fs::path& file) {
std::ifstream in(file, std::ios::binary);
fix: key the host PCM cache on source content, add clean, hash project args Three follow-ons to the stale-build report, all cases of an identity not capturing something that changes the output. The host PCM cache under <cache>/crafter.build/<target>-<march>/ is shared by every crafter-build on the machine, and freshness was a per-file mtime comparison. That cannot tell "this PCM is newer than my source" from "this PCM was built from different sources that happen to be newer", so a package install and a working checkout — or two checkouts of different versions — silently compiled their project.cpp against each other's declarations. Invalidation now keys on a stamp over the bytes of every module source, which also covers the case one file's mtime never could: the cached PCMs import each other, so a change to :Interface invalidates :Clang's PCM with Crafter.Build-Clang.cppm untouched. Project args ApplyStandardArgs does not itself interpret are now folded into VariantId. Such a flag typically decides what gets compiled or bundled — the report's example is --no-webgpu dropping entries from cfg.files — and without it both settings shared one bin dir and interleaved their outputs there, leaving a bundle matching neither. Sorted and deduplicated so flag order doesn't split the cache, and inherited by test Configurations. `crafter-build clean` removes the project's bin/ and build/ trees. It deliberately does not load project.cpp: cleaning is most often reached when something is already wrong, and a clean that first needs the project to compile is useless exactly then.
2026-07-30 17:43:09 +00:00
std::string stamp;
std::getline(in, stamp);
return stamp;
}
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
void WriteStamp(const fs::path& file, std::string_view stamp) {
std::ofstream(file, std::ios::binary | std::ios::trunc) << stamp << '\n';
}
fs::path StampPath(const fs::path& artifact) {
fs::path stamp = artifact;
stamp += ".stamp";
return stamp;
}
std::string HashStamp(std::string_view content) {
return std::format("{:016x}", std::hash<std::string_view>{}(content));
}
std::string FileBytes(const fs::path& file) {
std::ifstream in(file, std::ios::binary);
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
fix: key the host PCM cache on source content, add clean, hash project args Three follow-ons to the stale-build report, all cases of an identity not capturing something that changes the output. The host PCM cache under <cache>/crafter.build/<target>-<march>/ is shared by every crafter-build on the machine, and freshness was a per-file mtime comparison. That cannot tell "this PCM is newer than my source" from "this PCM was built from different sources that happen to be newer", so a package install and a working checkout — or two checkouts of different versions — silently compiled their project.cpp against each other's declarations. Invalidation now keys on a stamp over the bytes of every module source, which also covers the case one file's mtime never could: the cached PCMs import each other, so a change to :Interface invalidates :Clang's PCM with Crafter.Build-Clang.cppm untouched. Project args ApplyStandardArgs does not itself interpret are now folded into VariantId. Such a flag typically decides what gets compiled or bundled — the report's example is --no-webgpu dropping entries from cfg.files — and without it both settings shared one bin dir and interleaved their outputs there, leaving a bundle matching neither. Sorted and deduplicated so flag order doesn't split the cache, and inherited by test Configurations. `crafter-build clean` removes the project's bin/ and build/ trees. It deliberately does not load project.cpp: cleaning is most often reached when something is already wrong, and a clean that first needs the project to compile is useless exactly then.
2026-07-30 17:43:09 +00:00
}
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
// The string value of `"key": "..."` in `object`, if it has one.
std::optional<std::string> JsonString(std::string_view object, std::string_view key) {
const std::string quoted = std::format("\"{}\"", key);
std::size_t at = object.find(quoted);
if (at == std::string_view::npos) return std::nullopt;
at = object.find_first_not_of(" \t\r\n", at + quoted.size());
if (at == std::string_view::npos || object[at] != ':') return std::nullopt;
at = object.find_first_not_of(" \t\r\n", at + 1);
if (at == std::string_view::npos || object[at] != '"') return std::nullopt;
std::string value;
for (std::size_t i = at + 1; i < object.size(); ++i) {
if (object[i] == '"') return value;
if (object[i] == '\\' && i + 1 < object.size()) ++i;
value += object[i];
}
return std::nullopt;
}
// The std module's "source-path" in a libstdc++.modules.json: the object
// in its "modules" array whose "logical-name" is "std". The manifest is
// a flat list of such objects, so each one is the text between its
// braces.
std::optional<std::string> StdModuleSourcePath(std::string_view manifest) {
for (std::size_t open = manifest.find('{', manifest.find("\"modules\"")); open != std::string_view::npos; open = manifest.find('{', open + 1)) {
const std::size_t close = manifest.find('}', open);
if (close == std::string_view::npos) break;
const std::string_view object = manifest.substr(open, close - open);
if (JsonString(object, "logical-name") == "std") return JsonString(object, "source-path");
}
return std::nullopt;
}
}
Crafter::FileLock::FileLock(const fs::path& path) {
fd = ::open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644);
if (fd >= 0) {
while (::flock(fd, LOCK_EX) != 0 && errno == EINTR) {}
}
}
Crafter::FileLock::~FileLock() {
if (fd >= 0) {
::close(fd); // closing the descriptor drops the flock
}
}
V2: WASI, -r flag, CI pipeline, examples & tests cleanup WASI / wasm32 target support - Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32") - Skip -march/-mtune for wasm (clang rejects them) - Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync) - .wasm output extension in expectedOutputFor and link command - EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html + runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL set in the templated index.html so a single shim handles any output name -r run flag in the CLI: build then exec the artifact (host targets only; rejects libraries; auto .exe/.wasm extension handling) CI pipeline (.forgejo/workflows/ci.yaml) - Triggers: PR/push to master + manual dispatch - Single arch-latest container job: install deps, bootstrap, self-rebuild, run tests, cross-compile mingw, package both archives, upload artifacts - Rolling 'latest' release published only on push/dispatch to master mingw cross-compile from Linux now works end-to-end: - ExternalDependency cache key includes target so per-target glslang builds don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends 'd' to lib names and breaks linking); cross-compile cmake flags (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...) - project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh link pulls pthread which mingw doesn't link by default) - mingw compile uses -femulated-tls so std::__once_callable etc reference the same emutls symbols libstdc++ provides - mingw link auto-adds -lstdc++exp -lpthread GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux + Windows) now both use it instead of duplicating the resolution. Examples reorg: hello-world, library, with-module, wasi, tests — each with its own README. Tests reorg: per-test directory with inner/ fixture, no shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
fs::path Crafter::GetCrafterBuildHome() {
if (const char* envHome = std::getenv("CRAFTER_BUILD_HOME")) {
return fs::path(envHome);
}
fs::path hostExe = fs::read_symlink("/proc/self/exe");
2026-04-29 03:00:53 +02:00
// Walk up from the exe's directory looking for share/crafter-build with
// a known module source present as a sentinel. Handles:
// <prefix>/bin/crafter-build -> <prefix>/share/crafter-build (FHS install)
// <repo>/bin/crafter-build -> <repo>/share/crafter-build (build.sh)
// <repo>/bin/<arch>/crafter-build -> <repo>/share/crafter-build (self-host)
fs::path dir = hostExe.parent_path();
std::vector<fs::path> tried;
for (;;) {
fs::path candidate = dir / "share" / "crafter-build";
tried.push_back(candidate);
std::error_code ec;
if (fs::exists(candidate / "Crafter.Build.cppm", ec)) {
return candidate;
}
fs::path parent = dir.parent_path();
if (parent == dir) break;
dir = parent;
}
2026-07-23 01:24:42 +02:00
std::string msg = std::format("could not locate crafter-build runtime assets relative to {} (set CRAFTER_BUILD_HOME). Tried:", hostExe.string());
2026-04-29 03:00:53 +02:00
for (const auto& p : tried) {
2026-07-23 01:24:42 +02:00
msg += std::format("\n {}", p.string());
2026-04-29 03:00:53 +02:00
}
throw std::runtime_error(msg);
V2: WASI, -r flag, CI pipeline, examples & tests cleanup WASI / wasm32 target support - Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32") - Skip -march/-mtune for wasm (clang rejects them) - Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync) - .wasm output extension in expectedOutputFor and link command - EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html + runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL set in the templated index.html so a single shim handles any output name -r run flag in the CLI: build then exec the artifact (host targets only; rejects libraries; auto .exe/.wasm extension handling) CI pipeline (.forgejo/workflows/ci.yaml) - Triggers: PR/push to master + manual dispatch - Single arch-latest container job: install deps, bootstrap, self-rebuild, run tests, cross-compile mingw, package both archives, upload artifacts - Rolling 'latest' release published only on push/dispatch to master mingw cross-compile from Linux now works end-to-end: - ExternalDependency cache key includes target so per-target glslang builds don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends 'd' to lib names and breaks linking); cross-compile cmake flags (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...) - project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh link pulls pthread which mingw doesn't link by default) - mingw compile uses -femulated-tls so std::__once_callable etc reference the same emutls symbols libstdc++ provides - mingw link auto-adds -lstdc++exp -lpthread GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux + Windows) now both use it instead of duplicating the resolution. Examples reorg: hello-world, library, with-module, wasi, tests — each with its own README. Tests reorg: per-test directory with inner/ fixture, no shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
}
2026-04-23 01:57:25 +02:00
fs::file_time_type Crafter::NewestPrerequisite(const fs::path& depFile) {
std::ifstream in(depFile, std::ios::binary);
if (!in) return fs::file_time_type::max();
std::ostringstream buffer;
buffer << in.rdbuf();
const std::string text = std::move(buffer).str();
// Make-rule tokenizing, as clang writes it: `target: prereq prereq`, with a
// trailing '\' continuing the rule onto the next line (clang wraps at ~80
// columns, so any real depfile has several), '\ ' and '\#' escaping a
// character into a filename, and '$$' standing for a literal '$'. A
// backslash that is none of those is a Windows path separator and stays.
std::vector<std::string> tokens;
std::string token;
auto flush = [&tokens, &token]() {
if (!token.empty()) {
tokens.push_back(token);
token.clear();
}
};
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
const char next = i + 1 < text.size() ? text[i + 1] : '\0';
if (c == '\\' && (next == '\n' || next == '\r')) {
flush();
++i;
} else if (c == '\\' && (next == ' ' || next == '\t' || next == '#')) {
token += next;
++i;
} else if (c == '$' && next == '$') {
token += '$';
++i;
} else if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
flush();
} else {
token += c;
}
}
flush();
// A token ending in ':' names a rule target, not an input. The first one
// closes the target list (and a Windows `C:\build\x.o:` still ends in ':',
// where splitting on the first colon would not); a later one opens another
// rule, as `-MP` phony targets do, whose name is not a file to stat.
bool sawTarget = false;
fs::file_time_type newest = fs::file_time_type::min();
for (const std::string& prerequisite : tokens) {
if (prerequisite.ends_with(':')) {
sawTarget = true;
continue;
}
if (!sawTarget) continue;
std::error_code ec;
const fs::file_time_type stamp = fs::last_write_time(prerequisite, ec);
// A prerequisite that has since been deleted makes the recorded set
// unusable as evidence — the source that included it must have changed
// too, and rebuilding is what writes a depfile that no longer names it.
if (ec) return fs::file_time_type::max();
if (stamp > newest) newest = stamp;
}
// No rule at all: a truncated or empty depfile proves nothing.
if (!sawTarget) return fs::file_time_type::max();
return newest;
}
2026-07-23 01:24:42 +02:00
bool Crafter::MatchGlob(std::string_view glob, std::string_view name) {
std::size_t gi = 0;
std::size_t ni = 0;
std::size_t star = std::string_view::npos;
std::size_t mark = 0;
while (ni < name.size()) {
if (gi < glob.size() && (glob[gi] == '?' || glob[gi] == name[ni])) {
++gi; ++ni;
} else if (gi < glob.size() && glob[gi] == '*') {
star = gi++;
mark = ni;
} else if (star != std::string_view::npos) {
gi = star + 1;
ni = ++mark;
} else {
return false;
}
}
while (gi < glob.size() && glob[gi] == '*') ++gi;
return gi == glob.size();
}
bool Crafter::MatchAny(std::span<const std::string> globs, std::string_view name) {
if (globs.empty()) return true;
for (const auto& g : globs) {
if (MatchGlob(g, name)) return true;
}
return false;
}
// See the declaration in Crafter.Build-Platform.cppm for why this branches on
// the exit code rather than on whether the command said anything.
std::string Crafter::RunBuildCommand(std::string_view cmd) {
2026-04-29 03:27:11 +02:00
Progress::EchoCommand(cmd);
CommandResult r = RunCommandChecked(cmd);
2026-04-23 01:57:25 +02:00
if (r.exitCode == 0) {
// Warnings from a step that succeeded. Show them — until now they were
// invisible on an incremental build and fatal on a cold one.
if (!r.output.empty()) Progress::Diagnostic(r.output);
return "";
2026-04-23 01:57:25 +02:00
}
// A compiler killed outright (the OOM killer is the common one) prints
// nothing, so the old "output means failure" rule read it as success and
// let the build carry on with a missing object. Say what happened instead.
if (r.output.empty()) {
return r.crashed
? std::format("terminated by signal {}: {}", r.signal, cmd)
: std::format("exited with code {}: {}", r.exitCode, cmd);
2026-04-23 01:57:25 +02:00
}
if (r.crashed) {
return std::format("{}\nterminated by signal {}", r.output, r.signal);
}
return r.output;
2026-04-23 01:57:25 +02:00
}
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
// --- Dependencies of project.cpp itself -------------------------------------
//
// #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"]
//
// names a sibling crafter-build project that project.cpp imports a module
// from. Anything a file needs before it can compile cannot be C++ inside that
// file, so this is a directive next to the `import` it enables — where
// `#include` lives. Each one is fetched, built for the host, and put on
// project.so's module path and link line before project.cpp is compiled. The
// dependency's own project.cpp goes through LoadProject, so its pragmas
// resolve recursively.
namespace {
struct PragmaDependency {
std::string source;
std::string branch;
std::string commit;
std::string project;
std::size_t line = 0;
};
[[noreturn]] void PragmaError(const fs::path& file, std::size_t line, std::string_view message) {
throw std::runtime_error(std::format("{}:{}: {}", file.string(), line, message));
}
// The tokens of one directive: bare words and double-quoted strings (no
// escapes — a path or URL never needs one). Reading stops at `//`.
class PragmaCursor {
public:
PragmaCursor(std::string_view text, const fs::path& file, std::size_t line) : text_(text), file_(file), line_(line) {}
bool AtEnd() {
SkipSpace();
return pos_ >= text_.size() || text_.substr(pos_).starts_with("//");
}
bool Take(char c) {
SkipSpace();
if (pos_ >= text_.size() || text_[pos_] != c) return false;
++pos_;
return true;
}
std::string_view Word() {
SkipSpace();
const std::size_t start = pos_;
while (pos_ < text_.size() && IsWordChar(text_[pos_])) ++pos_;
return text_.substr(start, pos_ - start);
}
std::optional<std::string_view> Quoted() {
SkipSpace();
if (pos_ >= text_.size() || text_[pos_] != '"') return std::nullopt;
const std::size_t close = text_.find('"', pos_ + 1);
if (close == std::string_view::npos) Fail("unterminated string");
const std::string_view inner = text_.substr(pos_ + 1, close - pos_ - 1);
pos_ = close + 1;
return inner;
}
[[noreturn]] void Fail(std::string_view message) const { PragmaError(file_, line_, message); }
private:
static bool IsWordChar(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; }
void SkipSpace() {
while (pos_ < text_.size() && (text_[pos_] == ' ' || text_[pos_] == '\t' || text_[pos_] == '\r')) ++pos_;
}
std::string_view text_;
const fs::path& file_;
std::size_t line_;
std::size_t pos_ = 0;
};
// One directive per line, anywhere in the file. Comments aren't parsed:
// a directive inside a block comment is honoured, and `//` lines fall out
// because they don't start with `#`.
std::vector<PragmaDependency> ScanProjectFilePragmas(const fs::path& absProject) {
std::vector<PragmaDependency> found;
std::ifstream in(absProject);
std::string raw;
for (std::size_t lineNo = 1; std::getline(in, raw); ++lineNo) {
PragmaCursor cur(raw, absProject, lineNo);
if (!cur.Take('#') || cur.Word() != "pragma" || cur.Word() != "crafter") continue;
const std::string_view verb = cur.Word();
if (verb != "dependency") cur.Fail(std::format("unknown crafter pragma '{}'", verb));
PragmaDependency dep;
dep.line = lineNo;
const std::optional<std::string_view> source = cur.Quoted();
if (!source) cur.Fail("expected a quoted source after 'dependency'");
dep.source = *source;
while (!cur.AtEnd()) {
const std::string_view key = cur.Word();
std::string* field = key == "branch" ? &dep.branch : key == "commit" ? &dep.commit : key == "project" ? &dep.project : nullptr;
if (!field) cur.Fail(key.empty() ? "unexpected text after the source" : std::format("unknown keyword '{}' (expected branch, commit or project)", key));
if (!field->empty()) cur.Fail(std::format("'{}' given twice", key));
const std::optional<std::string_view> value = cur.Quoted();
if (!value || value->empty()) cur.Fail(std::format("expected a quoted value after '{}'", key));
*field = *value;
}
if (dep.source.empty()) cur.Fail("the source is empty");
if (!dep.branch.empty() && !dep.commit.empty()) cur.Fail("'branch' and 'commit' are mutually exclusive");
found.push_back(std::move(dep));
}
return found;
}
struct ProjectFileFrame {
fs::path project;
// What LoadProject was given, for ProjectArgs(), and only once
// CrafterBuildProject is about to be called (see LoadProject). A view
// is enough: the caller's storage outlives LoadProject, and the frame
// is popped before LoadProject returns.
feat(external): forward a project's args to its sibling projects by default Every project.cpp with a sibling dependency opened with the same line, std::vector<std::string> depArgs(args.begin(), args.end()); and handed depArgs to each GitProject and LocalProject. CrafterBuildProject receives a span of string_views and the specs wanted owned strings, and without the copy the sibling got none of the project's args: it was built for the host while the project cross-compiled, and in release while it built --debug. GitProject then turned the copy straight back into string_views for LoadProject. Both specs now default their args to ProjectArgs(), the args LoadProject was given for the project file whose CrafterBuildProject is running. It reads the per-thread stack LoadProject already keeps for IsTopLevelProject and ProjectDependency, whose frames now record the args. A default member initializer rather than a new type or an optional: the member stays a std::vector<std::string>, so every existing `.args = depArgs` keeps its exact meaning (each of the ~50 calls across the Catcrafts projects passes one), and `.args = {}` still forwards nothing. It is the shape of Configuration::target = HostTarget(). A project.cpp that drops its .args needs a crafter-build with this change: an older one compiles it and forwards nothing. ProjectArgs() is declared in :External, next to the defaults that call it. :Platform, where it is defined, cannot be imported there: its unexported `struct Configuration;` would make :External's exported one an ill-formed redeclaration, which clang rejects. The #pragma crafter dependency path now passes `.args = {}` explicitly. Host tooling is built for the host whatever the consumer targets and was loaded with no args; the default would otherwise hand it the consumer's --target= and project flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:56:14 +01:00
std::span<const std::string_view> args;
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
std::vector<Configuration*> dependencies;
};
// LoadProject nests: a dependency's project.cpp loads inside its
// consumer's pre-pass, and GitProject inside CrafterBuildProject nests
// again. Several threads may also load at once (ConcurrentCacheRace), so
// the stack is per thread. ProjectDependency() reads the top frame.
thread_local std::vector<ProjectFileFrame> LoadingFrames;
feat(agents): keep dependencies' AGENTS.consumer.md in AGENTS.md An agent working on a crafter-build project reads its AGENTS.md, and learns nothing there about the libraries the project builds against unless someone copied that in by hand. This repository shows the cost: the code style section of its AGENTS.md restates what Crafter.LintRules enforces, and goes stale whenever the rules change. A library now ships those notes itself, as AGENTS.consumer.md beside its project.cpp; nothing in its project.cpp changes. When a consumer has an AGENTS.md beside its own project.cpp, `crafter-build` and `crafter-build test` keep a section in it, between two marker comments, holding its dependencies' notes. The section is appended the first time and rewritten in place after that. The file is written only when the section changes, and an AGENTS.md is never created. lint, format, run and clean leave it alone. The update runs after loading, which has just fetched every dependency, and before compiling, so a build that fails still has the notes. cfg.dependencyNotes picks the dependencies. Transitive, the default, follows `dependencies` all the way down from the project's own configurations, its tests and its `#pragma crafter dependency` tooling; Direct stops at the first loaded project on each path; None removes the section. A dependency's own pragma tooling never counts: it is about how that dependency is built, not how it is used, and being no `dependencies` edge it is never reached. Nor are a dependency's tests: nothing builds them for the consumer, and their configurations can point at a local their project file has since moved out of. The walk has to know which Configurations belong to another project, and where that project lives, and cfg.path cannot say: only the Configuration GitProject and LocalProject return is rebased, so a dependency's internal configurations keep "./", which resolves to the consumer. The loader records it instead, LoadedProjectOf beside the project cache, and keeps what the root's pragmas resolved to past the frame LoadProject pops, PragmaDependencies. Neither is exported. The markers count only as whole lines outside fenced code blocks, so documentation can quote them. Unbalanced markers and a document ending inside a code block leave the file alone with a warning: guessing which part of someone's file is generated would overwrite their text, and missing the section would add another one on every build. A note that would break the section when it is read back, through a marker line of its own or a code block it never closes, is left out with a warning. The file is replaced through a temporary renamed onto the symlink's target, so a linked AGENTS.md stays a link and a write cut short leaves the old file rather than part of the new one. The code is a new partition, Crafter.Build:Agents, listed in build.sh, CrafterBuildModules, project.cpp and the umbrella. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:18:06 +01:00
// A frame's dependencies, kept past the frame for PragmaDependencies().
// Process-wide rather than per thread: whoever asks need not be the
// thread that loaded the file. The Configurations live in the project
// cache, which outlives every load.
std::mutex PragmaDependenciesMutex;
std::unordered_map<fs::path, std::vector<Configuration*>> PragmaDependenciesByProject;
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
// Held for the whole of one LoadProject call. Pushing a file that is
// already on the stack is a cycle, which used to recurse forever.
class ProjectFileGuard {
public:
explicit ProjectFileGuard(const fs::path& absProject) {
for (const ProjectFileFrame& frame : LoadingFrames) {
if (frame.project != absProject) continue;
std::string chain;
for (const ProjectFileFrame& f : LoadingFrames) chain += std::format("{} -> ", f.project.string());
throw std::runtime_error(std::format("circular project dependency: {}{}", chain, absProject.string()));
}
index_ = LoadingFrames.size();
LoadingFrames.push_back({ absProject, {}, {} });
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
}
~ProjectFileGuard() { LoadingFrames.pop_back(); }
ProjectFileGuard(const ProjectFileGuard&) = delete;
ProjectFileGuard& operator=(const ProjectFileGuard&) = delete;
ProjectFileFrame& Frame() { return LoadingFrames[index_]; }
private:
std::size_t index_ = 0;
};
// How many `#pragma crafter dependency` loads this thread is inside. The
// tool's project file runs within its load, and so does every sibling it
// loads in turn, which is what LoadingHostTooling() reports to GitProject
// and LocalProject. Per thread like LoadingFrames: a sibling loaded on a
// thread of the tool's own goes uncounted, as it gets no ProjectArgs().
thread_local std::int32_t HostToolingLoads = 0;
class HostToolingScope {
public:
HostToolingScope() { ++HostToolingLoads; }
~HostToolingScope() { --HostToolingLoads; }
HostToolingScope(const HostToolingScope&) = delete;
HostToolingScope& operator=(const HostToolingScope&) = delete;
};
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
// A pre-dependency is host tooling: project.so is loaded into the running
// crafter-build, so the dependency is built for the host and against the
// same PCM cache LoadProject populates — the hostConfig LoadProject itself
// compiles project.so with, so the dependency's std/Crafter.Build PCMs
// land in the cache dir project.so is built against. Recursive, because
feat(external): forward a project's args to its sibling projects by default Every project.cpp with a sibling dependency opened with the same line, std::vector<std::string> depArgs(args.begin(), args.end()); and handed depArgs to each GitProject and LocalProject. CrafterBuildProject receives a span of string_views and the specs wanted owned strings, and without the copy the sibling got none of the project's args: it was built for the host while the project cross-compiled, and in release while it built --debug. GitProject then turned the copy straight back into string_views for LoadProject. Both specs now default their args to ProjectArgs(), the args LoadProject was given for the project file whose CrafterBuildProject is running. It reads the per-thread stack LoadProject already keeps for IsTopLevelProject and ProjectDependency, whose frames now record the args. A default member initializer rather than a new type or an optional: the member stays a std::vector<std::string>, so every existing `.args = depArgs` keeps its exact meaning (each of the ~50 calls across the Catcrafts projects passes one), and `.args = {}` still forwards nothing. It is the shape of Configuration::target = HostTarget(). A project.cpp that drops its .args needs a crafter-build with this change: an older one compiles it and forwards nothing. ProjectArgs() is declared in :External, next to the defaults that call it. :Platform, where it is defined, cannot be imported there: its unexported `struct Configuration;` would make :External's exported one an ill-formed redeclaration, which clang rejects. The #pragma crafter dependency path now passes `.args = {}` explicitly. Host tooling is built for the host whatever the consumer targets and was loaded with no args; the default would otherwise hand it the consumer's --target= and project flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:56:14 +01:00
// the dependency's project.cpp is loaded with no arguments and so passes
// none on, while its own deps still read CRAFTER_BUILD_MARCH from the
// environment.
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
void ForceHostTooling(Configuration& cfg, const Configuration& host, std::unordered_set<Configuration*>& seen) {
if (!seen.insert(&cfg).second) return;
cfg.target = host.target;
cfg.march = host.march;
cfg.mtune = host.mtune;
cfg.debug = false;
// project.so is a shared object, so the archive it links has to be
// position-independent; static libraries aren't by default. Part of
// VariantId, so this build never shares a bin dir with a normal one.
// Checked before pushing: the same Configuration comes back from the
// project cache for every consumer in this process. Linux hosts only,
// glibc or musl: Windows targets have no use for the flag.
const bool linuxHost = host.target.ends_with("-linux-gnu") || host.target.ends_with("-linux-musl");
if (linuxHost && !std::ranges::contains(cfg.compileFlags, "-fPIC")) {
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
cfg.compileFlags.emplace_back("-fPIC");
}
for (Configuration* dep : cfg.dependencies) ForceHostTooling(*dep, host, seen);
}
struct ProjectFileDependencies {
std::vector<Configuration*> configs;
std::string compileFlags; // " -fprebuilt-module-path=…" for each dependency and its transitive deps
std::string linkFlags; // every dependency's BuildResult.libs, verbatim
fs::file_time_type newestArtifact = fs::file_time_type::min();
};
bool IsGitSource(std::string_view source) {
return source.contains("://") || source.starts_with("git@");
}
ProjectFileDependencies ResolveProjectFileDependencies(const fs::path& absProject, const Configuration& host) {
ProjectFileDependencies out;
const std::vector<PragmaDependency> pragmas = ScanProjectFilePragmas(absProject);
if (pragmas.empty()) return out;
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
std::unordered_set<Configuration*> forced;
std::unordered_set<Configuration*> walked;
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
std::set<std::string> libs;
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
for (const PragmaDependency& pragma : pragmas) {
// Neither spec sets args, and the tool still gets none of the
// consumer's: this pre-pass runs before LoadProject hands the
// frame its args, so ProjectArgs() is empty. Given the consumer's
// --target=, the tool's project.cpp would pick sources and flags
// for the wrong triple, and its flags would split the tool's
// variant from the one every other consumer shares. The scope
// keeps the tool and everything it loads in a project cache of
// their own: ForceHostTooling rewrites them in place, and a
// consumer loading the same sibling for itself got that host
// build back whenever the two cache keys matched.
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
Configuration* cfg = nullptr;
{
HostToolingScope hostTooling;
if (IsGitSource(pragma.source)) {
GitProjectSpec spec{ .source = { pragma.source, pragma.branch, pragma.commit } };
if (!pragma.project.empty()) spec.projectFile = pragma.project;
cfg = GitProject(spec);
} else {
if (!pragma.branch.empty() || !pragma.commit.empty()) PragmaError(absProject, pragma.line, "'branch' and 'commit' only apply to a git source");
// Relative to the project file, not the cwd: LoadProject runs
// from wherever the consumer was invoked.
fs::path local = (absProject.parent_path() / pragma.source).lexically_normal();
if (fs::is_directory(local)) {
local /= pragma.project.empty() ? fs::path("project.cpp") : fs::path(pragma.project);
} else if (!pragma.project.empty()) {
PragmaError(absProject, pragma.line, "'project' only applies to a directory source");
}
if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string()));
cfg = LocalProject({ .projectFile = local });
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
}
}
if (std::ranges::contains(out.configs, cfg)) continue;
ForceHostTooling(*cfg, host, forced);
BuildResult built;
{
Progress::Task task(std::format("Building project dependency {}", cfg->name));
built = Build(*cfg, depResults, depMutex);
}
if (!built.result.empty()) PragmaError(absProject, pragma.line, std::format("project dependency '{}' failed to build: {}", cfg->name, built.result));
out.configs.push_back(cfg);
libs.insert(built.libs.begin(), built.libs.end());
// The module path needs the dependency and everything it depends
// on, same as GetCompileCommand does for a consumer; the newest
// artifact over the same set is what project.so is stale against.
std::function<void(Configuration*)> walk = [&](Configuration* c) {
if (!walked.insert(c).second) return;
out.compileFlags += std::format(" -fprebuilt-module-path={}", c->PcmDir().string());
std::error_code ec;
const fs::file_time_type t = fs::last_write_time(ExpectedOutput(*c), ec);
if (!ec && t > out.newestArtifact) out.newestArtifact = t;
for (Configuration* sub : c->dependencies) walk(sub);
};
walk(cfg);
}
for (const std::string& lib : libs) out.linkFlags += std::format(" {}", lib);
return out;
}
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
// project.so is stale when its stamp (crafter-build version, toolchain,
// module sources and compile command) disagrees, or when it predates its
// source or a pre-dependency artifact. The executable's mtime is not an
// input any more: an OSTree deployment dates every installed file at the
// epoch, so a comparison against it never fired after an update and the
// old project.so was loaded into a binary it did not match.
bool ProjectFileStale(const fs::path& artifact, const fs::path& project, std::string_view stamp, const ProjectFileDependencies& deps) {
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
if (!fs::exists(artifact)) return true;
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
if (ReadStamp(StampPath(artifact)) != stamp) return true;
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
const fs::file_time_type built = fs::last_write_time(artifact);
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
return built < fs::last_write_time(project) || built < deps.newestArtifact;
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
}
}
Configuration* Crafter::ProjectDependency(std::string_view name) {
if (LoadingFrames.empty()) throw std::runtime_error(std::format("ProjectDependency('{}'): called outside a project file", name));
const ProjectFileFrame& frame = LoadingFrames.back();
for (Configuration* cfg : frame.dependencies) {
if (cfg->name == name) return cfg;
for (const std::unique_ptr<Module>& iface : cfg->interfaces) {
if (iface->name == name) return cfg;
}
}
std::string declared;
for (Configuration* cfg : frame.dependencies) {
if (!declared.empty()) declared += ", ";
declared += cfg->name;
}
throw std::runtime_error(std::format("ProjectDependency('{}'): not declared by {} (declared: {})", name, frame.project.string(), declared.empty() ? "none" : declared));
}
bool Crafter::IsTopLevelProject() {
// One frame is the project file crafter-build was pointed at; a second
// is only ever pushed for a dependency's project file.
return LoadingFrames.size() <= 1;
}
feat(agents): keep dependencies' AGENTS.consumer.md in AGENTS.md An agent working on a crafter-build project reads its AGENTS.md, and learns nothing there about the libraries the project builds against unless someone copied that in by hand. This repository shows the cost: the code style section of its AGENTS.md restates what Crafter.LintRules enforces, and goes stale whenever the rules change. A library now ships those notes itself, as AGENTS.consumer.md beside its project.cpp; nothing in its project.cpp changes. When a consumer has an AGENTS.md beside its own project.cpp, `crafter-build` and `crafter-build test` keep a section in it, between two marker comments, holding its dependencies' notes. The section is appended the first time and rewritten in place after that. The file is written only when the section changes, and an AGENTS.md is never created. lint, format, run and clean leave it alone. The update runs after loading, which has just fetched every dependency, and before compiling, so a build that fails still has the notes. cfg.dependencyNotes picks the dependencies. Transitive, the default, follows `dependencies` all the way down from the project's own configurations, its tests and its `#pragma crafter dependency` tooling; Direct stops at the first loaded project on each path; None removes the section. A dependency's own pragma tooling never counts: it is about how that dependency is built, not how it is used, and being no `dependencies` edge it is never reached. Nor are a dependency's tests: nothing builds them for the consumer, and their configurations can point at a local their project file has since moved out of. The walk has to know which Configurations belong to another project, and where that project lives, and cfg.path cannot say: only the Configuration GitProject and LocalProject return is rebased, so a dependency's internal configurations keep "./", which resolves to the consumer. The loader records it instead, LoadedProjectOf beside the project cache, and keeps what the root's pragmas resolved to past the frame LoadProject pops, PragmaDependencies. Neither is exported. The markers count only as whole lines outside fenced code blocks, so documentation can quote them. Unbalanced markers and a document ending inside a code block leave the file alone with a warning: guessing which part of someone's file is generated would overwrite their text, and missing the section would add another one on every build. A note that would break the section when it is read back, through a marker line of its own or a code block it never closes, is left out with a warning. The file is replaced through a temporary renamed onto the symlink's target, so a linked AGENTS.md stays a link and a write cut short leaves the old file rather than part of the new one. The code is a new partition, Crafter.Build:Agents, listed in build.sh, CrafterBuildModules, project.cpp and the umbrella. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:18:06 +01:00
std::vector<Configuration*> Crafter::PragmaDependencies(const fs::path& projectFile) {
std::error_code ec;
const fs::path absProject = fs::canonical(projectFile, ec);
if (ec) return {};
std::lock_guard lock(PragmaDependenciesMutex);
if (auto it = PragmaDependenciesByProject.find(absProject); it != PragmaDependenciesByProject.end()) return it->second;
return {};
}
feat(external): forward a project's args to its sibling projects by default Every project.cpp with a sibling dependency opened with the same line, std::vector<std::string> depArgs(args.begin(), args.end()); and handed depArgs to each GitProject and LocalProject. CrafterBuildProject receives a span of string_views and the specs wanted owned strings, and without the copy the sibling got none of the project's args: it was built for the host while the project cross-compiled, and in release while it built --debug. GitProject then turned the copy straight back into string_views for LoadProject. Both specs now default their args to ProjectArgs(), the args LoadProject was given for the project file whose CrafterBuildProject is running. It reads the per-thread stack LoadProject already keeps for IsTopLevelProject and ProjectDependency, whose frames now record the args. A default member initializer rather than a new type or an optional: the member stays a std::vector<std::string>, so every existing `.args = depArgs` keeps its exact meaning (each of the ~50 calls across the Catcrafts projects passes one), and `.args = {}` still forwards nothing. It is the shape of Configuration::target = HostTarget(). A project.cpp that drops its .args needs a crafter-build with this change: an older one compiles it and forwards nothing. ProjectArgs() is declared in :External, next to the defaults that call it. :Platform, where it is defined, cannot be imported there: its unexported `struct Configuration;` would make :External's exported one an ill-formed redeclaration, which clang rejects. The #pragma crafter dependency path now passes `.args = {}` explicitly. Host tooling is built for the host whatever the consumer targets and was loaded with no args; the default would otherwise hand it the consumer's --target= and project flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:56:14 +01:00
std::vector<std::string> Crafter::ProjectArgs() {
if (LoadingFrames.empty()) return {};
const std::span<const std::string_view> args = LoadingFrames.back().args;
return std::vector<std::string>(args.begin(), args.end());
}
bool Crafter::LoadingHostTooling() {
return HostToolingLoads > 0;
}
feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
namespace {
// The one reading of a wait status (pclose, system) for every helper
// that runs a command: a signal death is reported the way a shell
// reports it, as 128+signal.
void DecodeWaitStatus(std::int32_t status, CommandResult& result) {
if (WIFEXITED(status)) {
result.exitCode = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
result.signal = WTERMSIG(status);
result.exitCode = 128 + result.signal;
result.crashed = true;
} else {
result.exitCode = -1;
}
}
}
std::string Crafter::ShellQuote(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('\'');
for (char c : s) {
if (c == '\'') out += "'\\''";
else out.push_back(c);
}
out.push_back('\'');
return out;
}
2026-04-27 07:04:42 +02:00
CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
std::array<char, 128> buffer;
CommandResult result{};
2026-04-27 07:04:42 +02:00
2026-07-23 01:24:42 +02:00
std::string with = std::format("{} 2>&1", cmd);
2026-04-27 07:04:42 +02:00
FILE* pipe = popen(with.c_str(), "r");
if (!pipe) throw std::runtime_error("popen() failed!");
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
result.output += buffer.data();
}
feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
DecodeWaitStatus(pclose(pipe), result);
return result;
}
feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
std::int32_t Crafter::RunCommandAttached(std::string_view cmd) {
// The child writes straight to the terminal. Anything of ours that is
// still pending — the in-place status line, buffered stdout when it is
// a pipe — has to be out before the child's first byte, or it lands in
// the middle of the child's output or after it.
Progress::Clear();
std::fflush(stdout);
std::fflush(stderr);
const std::string command(cmd);
const std::int32_t status = std::system(command.c_str());
if (status == -1) throw std::runtime_error(std::format("could not start a shell for: {}", cmd));
CommandResult result{};
DecodeWaitStatus(status, result);
return result.exitCode;
}
CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::seconds timeout) {
std::array<char, 128> buffer;
CommandResult result{};
musl host support: bootstrap and run on Alpine (x86_64-alpine-linux-musl) The Linux host code paths were gated on the x86_64_pc_linux_gnu target define alone, and LoadProject hardcoded --target=x86_64-pc-linux-gnu when compiling project.cpp and the module PCMs. Both break on musl hosts: the glibc release launcher cannot run there at all (no glibc loader; gcompat lacks libgcc_s and the __isoc23_* symbols), and a musl-built launcher would still compile project.cpp for the wrong libc. - The host gates test clang's __linux__ instead of one triple's define: they mean "Linux host", whatever the libc (or, later, the CPU). - LoadProject derives the host triple from HostTarget() (clang's -print-target-triple), which is also more correct on glibc distros whose triple isn't x86_64-pc-linux-gnu. - project.cpp / the cmake libc++ flags treat any *-linux-gnu or *-linux-musl target as Linux. - build.sh targets the host toolchain's triple (CRAFTER_BUILD_TARGET overrides) and derives the per-target define from it. On Arch this is byte-for-byte the previous behaviour. - CI: a release-musl job bootstraps on alpine:edge, runs the tests, and attaches crafter-build-linux-x86_64-musl-v2.tar.gz to the rolling latest release (via the API, so the glibc assets survive). Dynamic against musl libc++: the launcher dlopens project.so, so it cannot be fully static. First consumer: imsd's package CI, which cross-compiles for aarch64 from an Alpine container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:49:00 +02:00
// -k (not --kill-after=): the short form is what GNU coreutils and busybox
// (Alpine) have in common.
std::string wrapped = std::format("timeout -k 2 {} {} 2>&1", timeout.count(), cmd);
FILE* pipe = popen(wrapped.c_str(), "r");
if (!pipe) throw std::runtime_error("popen() failed!");
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
result.output += buffer.data();
}
feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
DecodeWaitStatus(pclose(pipe), result);
// What comes back is `timeout`'s own exit, and it speaks through the
// code: 124 for a command it had to stop, 128+signal for one that died.
if (!result.crashed) {
if (result.exitCode == 124) {
result.timedOut = true;
feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
} else if (result.exitCode >= 128) {
result.signal = result.exitCode - 128;
result.crashed = true;
}
2026-04-27 07:04:42 +02:00
}
return result;
}
std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
2026-05-30 19:28:06 +02:00
CacheLock lock(stdPcm.parent_path());
2026-04-23 01:57:25 +02:00
if(config.target == "x86_64-w64-mingw32") {
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
// libstdc++ 15 and newer install libstdc++.modules.json beside the
// library, naming the std module's source relative to itself. It is
// looked for in the library directories the compiler searches for
// this target, as it would look for libstdc++ itself, but only in
// those that belong to the mingw toolchain: the list also has the
// generic <prefix>/lib, where the host's own (Linux) libstdc++ keeps
// its manifest. A toolchain that isn't there is a build error, not
// an exception: Build() runs this with its worker threads started.
const Toolchain& tc = GetToolchain();
fix: give the mingw target a sysroot that holds mingw-w64 alone Inside the Flatpak the mingw-w64 prefix is the host's /usr, and it was clang's --sysroot as is. clang's mingw driver takes a sysroot for a tree that holds mingw-w64 alone, and adds its include/ to the include path and its lib/ to the library path. For /usr those are the Linux system's own: every header mingw-w64 lacks came from glibc, and libstdc++'s <ext/atomicity.h>, finding <sys/single_threaded.h>, had shared_ptr read __libc_single_threaded, which nothing on Windows defines. Every program that used one failed to link, among them every Crafter.Graphics Win32 executable, through Crafter.Asset's GLB loader. CRAFTER_MINGW_DIR=/usr did the same outside the Flatpak (#43). The sysroot is now MingwSysroot(): a directory in the cache, one per prefix, holding only links to the prefix's x86_64-w64-mingw32/, lib/gcc/ and lib64/gcc/ (where openSUSE keeps gcc). Through it clang finds mingw-w64, gcc's version and libstdc++ exactly as it does through the mingw gcc on PATH, and nothing else. It is made by the first mingw compile, link or CMake configure of a process, so no other command writes to the cache for it and a failure to make it is that build's error. The Toolchain field is now mingwPrefix, since clang never gets the prefix itself, and it is made absolute when the toolchain is resolved, as it ends up in link targets and directory names. Messages keep naming paths under the prefix rather than under the view. The prefix is also part of the mingw target's VariantId and of its std PCM directory. Units do not track the std PCM they import: when the fixed release built in a directory a broken build had left, the std module was rebuilt and the objects compiled against glibc's headers were only relinked, failing as before. As with a compiler update, a new directory is what gets them rebuilt. It also keeps two releases that share a cache, as the Flatpak's branches do, from rebuilding one std PCM under each other's PCMs, and two prefixes apart. Without a prefix both stay as they were: clang's own discovery has not changed. Two shorter ways do not work: --sysroot=<prefix>/x86_64-w64-mingw32 loses the directory gcc's version names, so <string> is not found, and clang ignores --gcc-toolchain and --gcc-install-dir for this target. Fixes #43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:48:06 +01:00
const std::string where = tc.mingwPrefix.empty()
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
? std::string("where clang finds it by the x86_64-w64-mingw32 gcc on PATH")
fix: give the mingw target a sysroot that holds mingw-w64 alone Inside the Flatpak the mingw-w64 prefix is the host's /usr, and it was clang's --sysroot as is. clang's mingw driver takes a sysroot for a tree that holds mingw-w64 alone, and adds its include/ to the include path and its lib/ to the library path. For /usr those are the Linux system's own: every header mingw-w64 lacks came from glibc, and libstdc++'s <ext/atomicity.h>, finding <sys/single_threaded.h>, had shared_ptr read __libc_single_threaded, which nothing on Windows defines. Every program that used one failed to link, among them every Crafter.Graphics Win32 executable, through Crafter.Asset's GLB loader. CRAFTER_MINGW_DIR=/usr did the same outside the Flatpak (#43). The sysroot is now MingwSysroot(): a directory in the cache, one per prefix, holding only links to the prefix's x86_64-w64-mingw32/, lib/gcc/ and lib64/gcc/ (where openSUSE keeps gcc). Through it clang finds mingw-w64, gcc's version and libstdc++ exactly as it does through the mingw gcc on PATH, and nothing else. It is made by the first mingw compile, link or CMake configure of a process, so no other command writes to the cache for it and a failure to make it is that build's error. The Toolchain field is now mingwPrefix, since clang never gets the prefix itself, and it is made absolute when the toolchain is resolved, as it ends up in link targets and directory names. Messages keep naming paths under the prefix rather than under the view. The prefix is also part of the mingw target's VariantId and of its std PCM directory. Units do not track the std PCM they import: when the fixed release built in a directory a broken build had left, the std module was rebuilt and the objects compiled against glibc's headers were only relinked, failing as before. As with a compiler update, a new directory is what gets them rebuilt. It also keeps two releases that share a cache, as the Flatpak's branches do, from rebuilding one std PCM under each other's PCMs, and two prefixes apart. Without a prefix both stay as they were: clang's own discovery has not changed. Two shorter ways do not work: --sysroot=<prefix>/x86_64-w64-mingw32 loses the directory gcc's version names, so <string> is not found, and clang ignores --gcc-toolchain and --gcc-install-dir for this target. Fixes #43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:48:06 +01:00
: std::format("under {}", tc.mingwPrefix.string());
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
const std::string missing = std::format("no mingw-w64 libstdc++ with the std module (libstdc++ 15 or newer) {}. Install mingw-w64, or point CRAFTER_MINGW_DIR at its prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/", where);
// Asked once a run for each compiler and target: every mingw
// configuration comes through here, up to date or not, and the answer
// costs a clang.
static std::mutex probeMutex;
static std::unordered_map<std::string, CommandResult> probes;
const std::string probe = std::format("{} --target={} -print-search-dirs", GetBaseCommand(config), config.target);
CommandResult dirs;
{
std::lock_guard guard(probeMutex);
auto found = probes.find(probe);
if (found == probes.end()) found = probes.emplace(probe, RunCommandChecked(probe)).first;
dirs = found->second;
}
if (dirs.exitCode != 0) return std::format("{}: {}", missing, dirs.output);
fs::path manifestPath;
constexpr std::string_view librariesLine = "libraries: =";
if (const std::size_t at = dirs.output.find(librariesLine); at != std::string::npos) {
const std::size_t end = dirs.output.find('\n', at);
const std::string list = dirs.output.substr(at + librariesLine.size(), end == std::string::npos ? std::string::npos : end - at - librariesLine.size());
for (const auto part : std::views::split(list, ':')) {
const fs::path dir(std::string_view(part.begin(), part.end()));
const bool mingwOwn = std::ranges::contains(dir, fs::path("x86_64-w64-mingw32"));
if (mingwOwn && fs::exists(dir / "libstdc++.modules.json")) {
manifestPath = dir / "libstdc++.modules.json";
break;
}
2026-04-23 01:57:25 +02:00
}
}
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
if (manifestPath.empty()) return missing;
fix: give the mingw target a sysroot that holds mingw-w64 alone Inside the Flatpak the mingw-w64 prefix is the host's /usr, and it was clang's --sysroot as is. clang's mingw driver takes a sysroot for a tree that holds mingw-w64 alone, and adds its include/ to the include path and its lib/ to the library path. For /usr those are the Linux system's own: every header mingw-w64 lacks came from glibc, and libstdc++'s <ext/atomicity.h>, finding <sys/single_threaded.h>, had shared_ptr read __libc_single_threaded, which nothing on Windows defines. Every program that used one failed to link, among them every Crafter.Graphics Win32 executable, through Crafter.Asset's GLB loader. CRAFTER_MINGW_DIR=/usr did the same outside the Flatpak (#43). The sysroot is now MingwSysroot(): a directory in the cache, one per prefix, holding only links to the prefix's x86_64-w64-mingw32/, lib/gcc/ and lib64/gcc/ (where openSUSE keeps gcc). Through it clang finds mingw-w64, gcc's version and libstdc++ exactly as it does through the mingw gcc on PATH, and nothing else. It is made by the first mingw compile, link or CMake configure of a process, so no other command writes to the cache for it and a failure to make it is that build's error. The Toolchain field is now mingwPrefix, since clang never gets the prefix itself, and it is made absolute when the toolchain is resolved, as it ends up in link targets and directory names. Messages keep naming paths under the prefix rather than under the view. The prefix is also part of the mingw target's VariantId and of its std PCM directory. Units do not track the std PCM they import: when the fixed release built in a directory a broken build had left, the std module was rebuilt and the objects compiled against glibc's headers were only relinked, failing as before. As with a compiler update, a new directory is what gets them rebuilt. It also keeps two releases that share a cache, as the Flatpak's branches do, from rebuilding one std PCM under each other's PCMs, and two prefixes apart. Without a prefix both stay as they were: clang's own discovery has not changed. Two shorter ways do not work: --sysroot=<prefix>/x86_64-w64-mingw32 loses the directory gcc's version names, so <string> is not found, and clang ignores --gcc-toolchain and --gcc-install-dir for this target. Fixes #43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:48:06 +01:00
// Found through the view MingwSysroot() makes of the prefix, and
// named from here on by its place in the prefix, which is the
// directory the user knows and can go and look at.
if (!tc.mingwPrefix.empty()) {
const fs::path inPrefix = manifestPath.lexically_relative(MingwSysroot());
if (!inPrefix.empty() && *inPrefix.begin() != "..") manifestPath = tc.mingwPrefix / inPrefix;
}
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
const std::optional<std::string> source = StdModuleSourcePath(FileBytes(manifestPath));
if (!source) return std::format("{} names no std module", manifestPath.string());
const fs::path stdCc = (manifestPath.parent_path() / *source).lexically_normal();
if (!fs::exists(stdCc)) return std::format("{}, which {} names as the std module, is missing", stdCc.string(), manifestPath.string());
2026-04-23 01:57:25 +02:00
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
// -femulated-tls keeps PCM TLS access matching libstdc++'s emutls
// definitions; mismatch surfaces as undefined std::__once_callable.
// The std.cc → std.cppm copy uses a plain filename: held under the
// cache lock, no other builder writes this path concurrently.
fs::path stdCppm = stdPcm.parent_path() / "std.cppm";
std::string command = std::format("{} --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", GetBaseCommand(config), config.target, config.march, config.mtune, stdCppm.string(), stdPcm.string());
std::string stamp = HashStamp(command + '\0' + FileBytes(stdCc));
if (fs::exists(stdPcm) && ReadStamp(StampPath(stdPcm)) == stamp) return "";
std::error_code copyEc;
fs::copy_file(stdCc, stdCppm, fs::copy_options::overwrite_existing, copyEc);
if (copyEc) {
return std::format("copy {} -> {}: {}", stdCc.string(), stdCppm.string(), copyEc.message());
2026-04-23 01:57:25 +02:00
}
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
std::string result = RunBuildCommand(command);
if (result.empty()) WriteStamp(StampPath(stdPcm), stamp);
return result;
2026-04-23 01:57:25 +02:00
} else {
feat(lint): const-local and constexpr-constant rules Two rules the AST makes possible, plus the mutation analysis behind them. const-local reports a local that is never written. It is restricted to SCALARS — integers, bools, enums, floating types — and that restriction is what makes the answer exact rather than a guess: a scalar has no member functions, so the only ways to write one are assignment, ++/--, having its address taken, or binding to a non-const reference. All four are now tracked in the walk: - assignment and compound assignment visit their LEFT operand in a write context, the right one normally; - ++/-- and & write their operand; - a call argument is checked against the callee's parameter type, so passing to `const int&` or by value is a read while `int&` is a write; - initialising a non-const reference writes what it binds to. For a class type a non-const method call could mutate it, and deciding that is the whole-program analysis clang-tidy does, so those are simply out of scope rather than guessed at. constexpr-constant promotes a const constant whose initialiser is made only of literals and operators, so `const int A = 1 << 4;` qualifies and `const int B = Compute();` does not. On this repository const-local found 103 candidates, which was too many to be useful, and the reason was informative: most were range-for bindings and pointer locals. `for (T* const x : …)` and `T* const p` are not spellings anybody writes, and the useful constness for a pointer is on the pointee, which this rule cannot advise on. Excluding both leaves 36, all plain bool or enum locals worth fixing — isWasm, isPe, exists, writes, isC and so on. Those 36 are fixed in this commit; the compiler verified every one. Both rules are report-only. The analysis is exact, but adding const is a judgement about intent as much as mechanics, and a wrong suggestion should cost a glance rather than a build. const-local also deliberately does not become a transform: inserting `const` before a shared type would apply it to every declarator in a multi-declarator statement, including any that IS written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:50:48 +02:00
const bool isWasm = config.target.starts_with("wasm32");
V2: WASI, -r flag, CI pipeline, examples & tests cleanup WASI / wasm32 target support - Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32") - Skip -march/-mtune for wasm (clang rejects them) - Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync) - .wasm output extension in expectedOutputFor and link command - EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html + runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL set in the templated index.html so a single shim handles any output name -r run flag in the CLI: build then exec the artifact (host targets only; rejects libraries; auto .exe/.wasm extension handling) CI pipeline (.forgejo/workflows/ci.yaml) - Triggers: PR/push to master + manual dispatch - Single arch-latest container job: install deps, bootstrap, self-rebuild, run tests, cross-compile mingw, package both archives, upload artifacts - Rolling 'latest' release published only on push/dispatch to master mingw cross-compile from Linux now works end-to-end: - ExternalDependency cache key includes target so per-target glslang builds don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends 'd' to lib names and breaks linking); cross-compile cmake flags (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...) - project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh link pulls pthread which mingw doesn't link by default) - mingw compile uses -femulated-tls so std::__once_callable etc reference the same emutls symbols libstdc++ provides - mingw link auto-adds -lstdc++exp -lpthread GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux + Windows) now both use it instead of duplicating the resolution. Examples reorg: hello-world, library, with-module, wasi, tests — each with its own README. Tests reorg: per-test directory with inner/ fixture, no shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
// wasi-sdk drops std.cppm at <sysroot>/share/libc++/v1/, the rest of
// the libc++ ecosystem (e.g. /opt/aarch64-rootfs) follows FHS at
// <sysroot>/usr/share/libc++/v1/.
std::string stdCppm;
if (isWasm) {
stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot);
} else if (config.sysroot.empty()) {
feat(toolchain): resolve the compiler in one place Every clang++, clang and llvm-ar the tool spawned was a bare name on PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI sysroot /usr/share/wasi-sysroot and the mingw headers /usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A toolchain installed anywhere else, which is what the Flatpak ships under /app, had no way in. Crafter.Build:Toolchain resolves all of it once per process: CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build (<prefix>/bin, found through the same walk GetCrafterBuildHome does), else PATH. What sits beside the compiler is used when present: its libclang.so, its clang-c headers, its std module source, its WASI sysroot. An explicit prefix without a clang++ is an error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other than the compiler's own, and CRAFTER_MINGW_DIR now does what its help line always claimed: it is passed as --sysroot for the mingw target. Binaries linked by a toolchain outside /usr get an rpath to its lib dir, since its libc++.so.1 is not on the loader's default path. HostTarget() is the resolved clang's -print-target-triple. A clang built without a vendor reports x86_64-unknown-linux-gnu where Arch's reports x86_64-pc-linux-gnu, so target equality that decides whether a test runs locally and which targets a bare `test` sweeps goes through SameTarget, which ignores the vendor field. The strings themselves are never rewritten: clang rejects a PCM whose triple differs from the importing unit's. The examples and README stop hardcoding the host triple; the default is already the host. `crafter-build --version` prints the version baked in at build time (CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the compiler and the host triple, and is answered before any argument is forwarded to project.cpp. The version is a define on the library, so each version is its own library variant and relinks the executable and tests once per commit. build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already there, and writes a fresh archive instead of appending to one that may hold members from an older layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
stdCppm = GetToolchain().stdCppm.string();
V2: WASI, -r flag, CI pipeline, examples & tests cleanup WASI / wasm32 target support - Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32") - Skip -march/-mtune for wasm (clang rejects them) - Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync) - .wasm output extension in expectedOutputFor and link command - EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html + runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL set in the templated index.html so a single shim handles any output name -r run flag in the CLI: build then exec the artifact (host targets only; rejects libraries; auto .exe/.wasm extension handling) CI pipeline (.forgejo/workflows/ci.yaml) - Triggers: PR/push to master + manual dispatch - Single arch-latest container job: install deps, bootstrap, self-rebuild, run tests, cross-compile mingw, package both archives, upload artifacts - Rolling 'latest' release published only on push/dispatch to master mingw cross-compile from Linux now works end-to-end: - ExternalDependency cache key includes target so per-target glslang builds don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends 'd' to lib names and breaks linking); cross-compile cmake flags (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...) - project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh link pulls pthread which mingw doesn't link by default) - mingw compile uses -femulated-tls so std::__once_callable etc reference the same emutls symbols libstdc++ provides - mingw link auto-adds -lstdc++exp -lpthread GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux + Windows) now both use it instead of duplicating the resolution. Examples reorg: hello-world, library, with-module, wasi, tests — each with its own README. Tests reorg: per-test directory with inner/ fixture, no shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
} else {
fix(sysroot): take the std module source from the sysroot's libc++ release A cross build against a sysroot whose libc++ differs from the toolchain's failed while precompiling the std module, with a page of "no member named 'runtime_format' in namespace 'std'". The module source is a list of using-declarations for what one libc++ release declares, so it only compiles against that release's headers. BuildStdPcm wants the sysroot's own <sysroot>/usr/share/libc++/v1/std.cppm, but Alpine packages the headers without it, and the fallback was the toolchain's copy whatever its release. That held while the compiler came from the same distro as the sysroot; with the Flatpak's libc++ 22.1.8 and Alpine edge on 23.1.2 it no longer does. The release is now read from _LIBCPP_VERSION in the sysroot's and the toolchain's <__config>, and SysrootStdModule picks in this order: the sysroot's own source, the toolchain's when both are the same major release (a release branch only takes fixes), the copy crafter-build ships for that release under share/crafter-build/libc++-modules/<major>. When none fits the build stops with one line naming both releases and the directory the matching source belongs in, before clang is run. If either release cannot be read the toolchain's source is tried as before, since there is nothing to compare. Matching sources live in crafter-build's own share directory rather than the LLVM prefix: they are data this tool ships for sysroots, not part of any one compiler install, and "sources ship" already describes that directory. `--version` prints the toolchain's libc++ release and the releases sources are shipped for, the first thing to look at when such a build fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 16:37:00 +02:00
// The source has to belong to the libc++ release whose headers
// the sysroot carries. Handing clang the toolchain's own copy
// regardless used to end in a page of "no member named ... in
// namespace 'std'" once the two releases differed.
StdModuleSource source = SysrootStdModule(GetToolchain(), config.sysroot);
if (!source.error.empty()) return source.error;
stdCppm = source.stdCppm.string();
V2: WASI, -r flag, CI pipeline, examples & tests cleanup WASI / wasm32 target support - Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32") - Skip -march/-mtune for wasm (clang rejects them) - Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync) - .wasm output extension in expectedOutputFor and link command - EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html + runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL set in the templated index.html so a single shim handles any output name -r run flag in the CLI: build then exec the artifact (host targets only; rejects libraries; auto .exe/.wasm extension handling) CI pipeline (.forgejo/workflows/ci.yaml) - Triggers: PR/push to master + manual dispatch - Single arch-latest container job: install deps, bootstrap, self-rebuild, run tests, cross-compile mingw, package both archives, upload artifacts - Rolling 'latest' release published only on push/dispatch to master mingw cross-compile from Linux now works end-to-end: - ExternalDependency cache key includes target so per-target glslang builds don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends 'd' to lib names and breaks linking); cross-compile cmake flags (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...) - project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh link pulls pthread which mingw doesn't link by default) - mingw compile uses -femulated-tls so std::__once_callable etc reference the same emutls symbols libstdc++ provides - mingw link auto-adds -lstdc++exp -lpthread GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux + Windows) now both use it instead of duplicating the resolution. Examples reorg: hello-world, library, with-module, wasi, tests — each with its own README. Tests reorg: per-test directory with inner/ fixture, no shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
}
std::string sysrootFlag = config.sysroot.empty()
? std::string()
: std::format(" --sysroot={}", config.sysroot);
feat(wasm): compile C++ with WebAssembly exception handling wasm builds were compiled with -fno-exceptions. A throw did not compile, libc++'s own throws (vector::at, std::format, filesystem) aborted the program, and code that relies on exceptions could not be built for the browser at all. Every wasm unit and the std module now get -fwasm-exceptions, and the link adds -lunwind, which code built that way calls into and the driver does not add. The flag also selects the libc++ under the sysroot's lib/<triple>/eh, which wasi-sdk 33 and later ship next to the noeh one. Those libraries use the standardized encoding (try_table, exnref) while LLVM 23 still emits the legacy one by default, which wasmtime refuses to load and which would put both in one binary, so the flags ask for the standard form with -wasm-use-legacy-eh=false. That makes the setjmp lowering loadable under wasmtime too. The flags live in one constant, WasmBaselineFlags, which the compile command and the std module both take through WasmCompileFlags: a BMI is refused by a unit whose exception setting differs from its own. VariantId and StdPcmDir hash it, so the first build after the update starts fresh instead of importing PCMs built without exceptions, and two releases sharing the Flatpak's cache don't take turns rebuilding one std PCM. A sysroot without the exception-enabled libraries stops the build with a line saying so, rather than at the link with "unable to find library -lunwind". The result needs an engine with exnref: Chrome 137, Firefox 131, Safari 18.4, the bundled wasmtime. The runtime's exception-handling probe tested the legacy encoding; it now tests try_table, every variants.json entry carries it, and a browser without it is told which feature it lacks instead of failing with a CompileError. There is no switch back to -fno-exceptions: engines without exnref are old enough not to keep a second build for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:54:39 +01:00
// wasm32 rejects -march, and the std module has to be compiled the way
// the units importing it are, a variant pass's flags (e.g.
// -mrelaxed-simd) included, or the BMI is not feature-compatible.
V2: WASI, -r flag, CI pipeline, examples & tests cleanup WASI / wasm32 target support - Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32") - Skip -march/-mtune for wasm (clang rejects them) - Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync) - .wasm output extension in expectedOutputFor and link command - EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html + runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL set in the templated index.html so a single shim handles any output name -r run flag in the CLI: build then exec the artifact (host targets only; rejects libraries; auto .exe/.wasm extension handling) CI pipeline (.forgejo/workflows/ci.yaml) - Triggers: PR/push to master + manual dispatch - Single arch-latest container job: install deps, bootstrap, self-rebuild, run tests, cross-compile mingw, package both archives, upload artifacts - Rolling 'latest' release published only on push/dispatch to master mingw cross-compile from Linux now works end-to-end: - ExternalDependency cache key includes target so per-target glslang builds don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends 'd' to lib names and breaks linking); cross-compile cmake flags (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...) - project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh link pulls pthread which mingw doesn't link by default) - mingw compile uses -femulated-tls so std::__once_callable etc reference the same emutls symbols libstdc++ provides - mingw link auto-adds -lstdc++exp -lpthread GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux + Windows) now both use it instead of duplicating the resolution. Examples reorg: hello-world, library, with-module, wasi, tests — each with its own README. Tests reorg: per-test directory with inner/ fixture, no shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
std::string archFlags = isWasm
feat(wasm): compile C++ with WebAssembly exception handling wasm builds were compiled with -fno-exceptions. A throw did not compile, libc++'s own throws (vector::at, std::format, filesystem) aborted the program, and code that relies on exceptions could not be built for the browser at all. Every wasm unit and the std module now get -fwasm-exceptions, and the link adds -lunwind, which code built that way calls into and the driver does not add. The flag also selects the libc++ under the sysroot's lib/<triple>/eh, which wasi-sdk 33 and later ship next to the noeh one. Those libraries use the standardized encoding (try_table, exnref) while LLVM 23 still emits the legacy one by default, which wasmtime refuses to load and which would put both in one binary, so the flags ask for the standard form with -wasm-use-legacy-eh=false. That makes the setjmp lowering loadable under wasmtime too. The flags live in one constant, WasmBaselineFlags, which the compile command and the std module both take through WasmCompileFlags: a BMI is refused by a unit whose exception setting differs from its own. VariantId and StdPcmDir hash it, so the first build after the update starts fresh instead of importing PCMs built without exceptions, and two releases sharing the Flatpak's cache don't take turns rebuilding one std PCM. A sysroot without the exception-enabled libraries stops the build with a line saying so, rather than at the link with "unable to find library -lunwind". The result needs an engine with exnref: Chrome 137, Firefox 131, Safari 18.4, the bundled wasmtime. The runtime's exception-handling probe tested the legacy encoding; it now tests try_table, every variants.json entry carries it, and a browser without it is told which feature it lacks instead of failing with a CompileError. There is no switch back to -fno-exceptions: engines without exnref are old enough not to keep a second build for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:54:39 +01:00
? WasmCompileFlags(config)
V2: WASI, -r flag, CI pipeline, examples & tests cleanup WASI / wasm32 target support - Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32") - Skip -march/-mtune for wasm (clang rejects them) - Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync) - .wasm output extension in expectedOutputFor and link command - EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html + runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL set in the templated index.html so a single shim handles any output name -r run flag in the CLI: build then exec the artifact (host targets only; rejects libraries; auto .exe/.wasm extension handling) CI pipeline (.forgejo/workflows/ci.yaml) - Triggers: PR/push to master + manual dispatch - Single arch-latest container job: install deps, bootstrap, self-rebuild, run tests, cross-compile mingw, package both archives, upload artifacts - Rolling 'latest' release published only on push/dispatch to master mingw cross-compile from Linux now works end-to-end: - ExternalDependency cache key includes target so per-target glslang builds don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends 'd' to lib names and breaks linking); cross-compile cmake flags (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...) - project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh link pulls pthread which mingw doesn't link by default) - mingw compile uses -femulated-tls so std::__once_callable etc reference the same emutls symbols libstdc++ provides - mingw link auto-adds -lstdc++exp -lpthread GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux + Windows) now both use it instead of duplicating the resolution. Examples reorg: hello-world, library, with-module, wasi, tests — each with its own README. Tests reorg: per-test directory with inner/ fixture, no shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
: std::format(" -march={} -mtune={}", config.march, config.mtune);
2026-07-22 18:25:39 +02:00
// non-wasm cross builds: the driver ranks the host toolchain's own
// libc++ headers (<install>/../include/c++/v1) above --sysroot, so
// the PCM would mix host libc++ with the foreign libc — force the
refactor: drop Windows host support crafter-build is moving to a single Flatpak distribution, and there is no Windows host in that picture: no build.cmd, no MSVC or mingw host build, no DLL plus launcher. Delete every host-Windows path instead of leaving it to rot unverified: the cmd.exe command runner and quoting, the LOCALAPPDATA cache, the MSVC and mingw-host LoadProject variants with their LIBCXX_DIR and CRAFTER_MINGW_DIR plumbing, the winsock port probe, the LoadLibrary libclang loader, the console-width and isatty shims, and the preprocessor guards around the Linux bodies. The x86_64-w64-mingw32 cross-compile target stays. It is target logic (output names, -femulated-tls, the libstdc++ link extras, copying a dependency DLL next to a mingw exe, wine as the runner) and did not depend on any of the removed code. The msvc target goes with the host that was the only thing able to use it. Three target-path warts fixed on the way: ExpectedOutput named a mingw static library <name>.lib while the archive step wrote lib<name>.a, so those libraries relinked on every run; the import lib is no longer copied next to a mingw exe, since the dependency's own bin dir is already on the link line; and llvm-ar now writes every archive, not only the LTO ones, so Debug and Release archives are made the same way. CRAFTER_API and Crafter.Build-Api.h existed only for the DLL boundary. Nothing downstream used them, so they are removed rather than kept as an empty macro nobody can verify. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 21:56:38 +02:00
// sysroot's copy.
2026-07-22 18:25:39 +02:00
if (!isWasm && !config.sysroot.empty()) {
archFlags += std::format(" -nostdinc++ -isystem {}/usr/include/c++/v1", config.sysroot);
}
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
// Keyed on the command and the module source's bytes, never on
// mtimes: an installed std.cppm can be dated at the epoch (OSTree),
// and a libc++ update under the same clang changes the bytes but
// not the path.
std::string command = std::format("{} --target={} -std=c++26{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", GetBaseCommand(config), config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string());
std::string stamp = HashStamp(command + '\0' + FileBytes(stdCppm));
if (fs::exists(stdPcm) && ReadStamp(StampPath(stdPcm)) == stamp) return "";
std::string result = RunBuildCommand(command);
if (result.empty()) WriteStamp(StampPath(stdPcm), stamp);
return result;
2026-04-23 01:57:25 +02:00
}
}
2026-04-27 07:04:42 +02:00
fs::path Crafter::GetCacheDir() {
2026-04-23 01:57:25 +02:00
if (const char* xdg = std::getenv("XDG_CACHE_HOME"); xdg && *xdg) {
return fs::path(xdg) / "crafter.build";
}
2026-04-27 07:04:42 +02:00
2026-04-23 01:57:25 +02:00
if (const char* home = std::getenv("HOME")) {
return fs::path(home) / ".cache" / "crafter.build";
}
2026-04-27 07:04:42 +02:00
2026-04-23 01:57:25 +02:00
throw std::runtime_error("Neither XDG_CACHE_HOME nor HOME set");
}
2026-04-27 07:04:42 +02:00
std::string Crafter::GetBaseCommand(const Configuration& config) {
feat(toolchain): resolve the compiler in one place Every clang++, clang and llvm-ar the tool spawned was a bare name on PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI sysroot /usr/share/wasi-sysroot and the mingw headers /usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A toolchain installed anywhere else, which is what the Flatpak ships under /app, had no way in. Crafter.Build:Toolchain resolves all of it once per process: CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build (<prefix>/bin, found through the same walk GetCrafterBuildHome does), else PATH. What sits beside the compiler is used when present: its libclang.so, its clang-c headers, its std module source, its WASI sysroot. An explicit prefix without a clang++ is an error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other than the compiler's own, and CRAFTER_MINGW_DIR now does what its help line always claimed: it is passed as --sysroot for the mingw target. Binaries linked by a toolchain outside /usr get an rpath to its lib dir, since its libc++.so.1 is not on the loader's default path. HostTarget() is the resolved clang's -print-target-triple. A clang built without a vendor reports x86_64-unknown-linux-gnu where Arch's reports x86_64-pc-linux-gnu, so target equality that decides whether a test runs locally and which targets a bare `test` sweeps goes through SameTarget, which ignores the vendor field. The strings themselves are never rewritten: clang rejects a PCM whose triple differs from the importing unit's. The examples and README stop hardcoding the host triple; the default is already the host. `crafter-build --version` prints the version baked in at build time (CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the compiler and the host triple, and is answered before any argument is forwarded to project.cpp. The version is a define on the library, so each version is its own library variant and relinks the executable and tests once per commit. build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already there, and writes a fresh archive instead of appending to one that may hold members from an older layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
const Toolchain& tc = GetToolchain();
std::string command = tc.clangxx.string();
if (config.target == "x86_64-w64-mingw32") {
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
// libstdc++ from the mingw toolchain, said outright: a clang built to
// default to libc++ (the Flatpak's) would otherwise reach for that.
fix: give the mingw target a sysroot that holds mingw-w64 alone Inside the Flatpak the mingw-w64 prefix is the host's /usr, and it was clang's --sysroot as is. clang's mingw driver takes a sysroot for a tree that holds mingw-w64 alone, and adds its include/ to the include path and its lib/ to the library path. For /usr those are the Linux system's own: every header mingw-w64 lacks came from glibc, and libstdc++'s <ext/atomicity.h>, finding <sys/single_threaded.h>, had shared_ptr read __libc_single_threaded, which nothing on Windows defines. Every program that used one failed to link, among them every Crafter.Graphics Win32 executable, through Crafter.Asset's GLB loader. CRAFTER_MINGW_DIR=/usr did the same outside the Flatpak (#43). The sysroot is now MingwSysroot(): a directory in the cache, one per prefix, holding only links to the prefix's x86_64-w64-mingw32/, lib/gcc/ and lib64/gcc/ (where openSUSE keeps gcc). Through it clang finds mingw-w64, gcc's version and libstdc++ exactly as it does through the mingw gcc on PATH, and nothing else. It is made by the first mingw compile, link or CMake configure of a process, so no other command writes to the cache for it and a failure to make it is that build's error. The Toolchain field is now mingwPrefix, since clang never gets the prefix itself, and it is made absolute when the toolchain is resolved, as it ends up in link targets and directory names. Messages keep naming paths under the prefix rather than under the view. The prefix is also part of the mingw target's VariantId and of its std PCM directory. Units do not track the std PCM they import: when the fixed release built in a directory a broken build had left, the std module was rebuilt and the objects compiled against glibc's headers were only relinked, failing as before. As with a compiler update, a new directory is what gets them rebuilt. It also keeps two releases that share a cache, as the Flatpak's branches do, from rebuilding one std PCM under each other's PCMs, and two prefixes apart. Without a prefix both stay as they were: clang's own discovery has not changed. Two shorter ways do not work: --sysroot=<prefix>/x86_64-w64-mingw32 loses the directory gcc's version names, so <string> is not found, and clang ignores --gcc-toolchain and --gcc-install-dir for this target. Fixes #43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:48:06 +01:00
// Found by clang's own mingw discovery unless there is a prefix.
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
command += " -stdlib=libstdc++";
fix: give the mingw target a sysroot that holds mingw-w64 alone Inside the Flatpak the mingw-w64 prefix is the host's /usr, and it was clang's --sysroot as is. clang's mingw driver takes a sysroot for a tree that holds mingw-w64 alone, and adds its include/ to the include path and its lib/ to the library path. For /usr those are the Linux system's own: every header mingw-w64 lacks came from glibc, and libstdc++'s <ext/atomicity.h>, finding <sys/single_threaded.h>, had shared_ptr read __libc_single_threaded, which nothing on Windows defines. Every program that used one failed to link, among them every Crafter.Graphics Win32 executable, through Crafter.Asset's GLB loader. CRAFTER_MINGW_DIR=/usr did the same outside the Flatpak (#43). The sysroot is now MingwSysroot(): a directory in the cache, one per prefix, holding only links to the prefix's x86_64-w64-mingw32/, lib/gcc/ and lib64/gcc/ (where openSUSE keeps gcc). Through it clang finds mingw-w64, gcc's version and libstdc++ exactly as it does through the mingw gcc on PATH, and nothing else. It is made by the first mingw compile, link or CMake configure of a process, so no other command writes to the cache for it and a failure to make it is that build's error. The Toolchain field is now mingwPrefix, since clang never gets the prefix itself, and it is made absolute when the toolchain is resolved, as it ends up in link targets and directory names. Messages keep naming paths under the prefix rather than under the view. The prefix is also part of the mingw target's VariantId and of its std PCM directory. Units do not track the std PCM they import: when the fixed release built in a directory a broken build had left, the std module was rebuilt and the objects compiled against glibc's headers were only relinked, failing as before. As with a compiler update, a new directory is what gets them rebuilt. It also keeps two releases that share a cache, as the Flatpak's branches do, from rebuilding one std PCM under each other's PCMs, and two prefixes apart. Without a prefix both stay as they were: clang's own discovery has not changed. Two shorter ways do not work: --sysroot=<prefix>/x86_64-w64-mingw32 loses the directory gcc's version names, so <string> is not found, and clang ignores --gcc-toolchain and --gcc-install-dir for this target. Fixes #43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:48:06 +01:00
if (const fs::path& sysroot = MingwSysroot(); !sysroot.empty()) command += std::format(" --sysroot={}", sysroot.string());
feat(toolchain): resolve the compiler in one place Every clang++, clang and llvm-ar the tool spawned was a bare name on PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI sysroot /usr/share/wasi-sysroot and the mingw headers /usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A toolchain installed anywhere else, which is what the Flatpak ships under /app, had no way in. Crafter.Build:Toolchain resolves all of it once per process: CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build (<prefix>/bin, found through the same walk GetCrafterBuildHome does), else PATH. What sits beside the compiler is used when present: its libclang.so, its clang-c headers, its std module source, its WASI sysroot. An explicit prefix without a clang++ is an error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other than the compiler's own, and CRAFTER_MINGW_DIR now does what its help line always claimed: it is passed as --sysroot for the mingw target. Binaries linked by a toolchain outside /usr get an rpath to its lib dir, since its libc++.so.1 is not on the loader's default path. HostTarget() is the resolved clang's -print-target-triple. A clang built without a vendor reports x86_64-unknown-linux-gnu where Arch's reports x86_64-pc-linux-gnu, so target equality that decides whether a test runs locally and which targets a bare `test` sweeps goes through SameTarget, which ignores the vendor field. The strings themselves are never rewritten: clang rejects a PCM whose triple differs from the importing unit's. The examples and README stop hardcoding the host triple; the default is already the host. `crafter-build --version` prints the version baked in at build time (CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the compiler and the host triple, and is answered before any argument is forwarded to project.cpp. The version is a define on the library, so each version is its own library variant and relinks the executable and tests once per commit. build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already there, and writes a fresh archive instead of appending to one that may hold members from an older layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
return command;
}
command += " -stdlib=libc++";
// A sysroot build (wasm, a cross rootfs) takes libc++ from the sysroot;
// only host builds can be pointed at an explicit libc++ install.
if (config.sysroot.empty()) command += tc.LibcxxCompileFlags();
return command;
2026-04-27 07:04:42 +02:00
}
namespace {
feat(agents): keep dependencies' AGENTS.consumer.md in AGENTS.md An agent working on a crafter-build project reads its AGENTS.md, and learns nothing there about the libraries the project builds against unless someone copied that in by hand. This repository shows the cost: the code style section of its AGENTS.md restates what Crafter.LintRules enforces, and goes stale whenever the rules change. A library now ships those notes itself, as AGENTS.consumer.md beside its project.cpp; nothing in its project.cpp changes. When a consumer has an AGENTS.md beside its own project.cpp, `crafter-build` and `crafter-build test` keep a section in it, between two marker comments, holding its dependencies' notes. The section is appended the first time and rewritten in place after that. The file is written only when the section changes, and an AGENTS.md is never created. lint, format, run and clean leave it alone. The update runs after loading, which has just fetched every dependency, and before compiling, so a build that fails still has the notes. cfg.dependencyNotes picks the dependencies. Transitive, the default, follows `dependencies` all the way down from the project's own configurations, its tests and its `#pragma crafter dependency` tooling; Direct stops at the first loaded project on each path; None removes the section. A dependency's own pragma tooling never counts: it is about how that dependency is built, not how it is used, and being no `dependencies` edge it is never reached. Nor are a dependency's tests: nothing builds them for the consumer, and their configurations can point at a local their project file has since moved out of. The walk has to know which Configurations belong to another project, and where that project lives, and cfg.path cannot say: only the Configuration GitProject and LocalProject return is rebased, so a dependency's internal configurations keep "./", which resolves to the consumer. The loader records it instead, LoadedProjectOf beside the project cache, and keeps what the root's pragmas resolved to past the frame LoadProject pops, PragmaDependencies. Neither is exported. The markers count only as whole lines outside fenced code blocks, so documentation can quote them. Unbalanced markers and a document ending inside a code block leave the file alone with a warning: guessing which part of someone's file is generated would overwrite their text, and missing the section would add another one on every build. A note that would break the section when it is read back, through a marker line of its own or a code block it never closes, is left out with a warning. The file is replaced through a temporary renamed onto the symlink's target, so a linked AGENTS.md stays a link and a write cut short leaves the old file rather than part of the new one. The code is a new partition, Crafter.Build:Agents, listed in build.sh, CrafterBuildModules, project.cpp and the umbrella. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:18:06 +01:00
constexpr std::array<std::string_view, 14> CrafterBuildModules = {
feat(toolchain): resolve the compiler in one place Every clang++, clang and llvm-ar the tool spawned was a bare name on PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI sysroot /usr/share/wasi-sysroot and the mingw headers /usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A toolchain installed anywhere else, which is what the Flatpak ships under /app, had no way in. Crafter.Build:Toolchain resolves all of it once per process: CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build (<prefix>/bin, found through the same walk GetCrafterBuildHome does), else PATH. What sits beside the compiler is used when present: its libclang.so, its clang-c headers, its std module source, its WASI sysroot. An explicit prefix without a clang++ is an error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other than the compiler's own, and CRAFTER_MINGW_DIR now does what its help line always claimed: it is passed as --sysroot for the mingw target. Binaries linked by a toolchain outside /usr get an rpath to its lib dir, since its libc++.so.1 is not on the loader's default path. HostTarget() is the resolved clang's -print-target-triple. A clang built without a vendor reports x86_64-unknown-linux-gnu where Arch's reports x86_64-pc-linux-gnu, so target equality that decides whether a test runs locally and which targets a bare `test` sweeps goes through SameTarget, which ignores the vendor field. The strings themselves are never rewritten: clang rejects a PCM whose triple differs from the importing unit's. The examples and README stop hardcoding the host triple; the default is already the host. `crafter-build --version` prints the version baked in at build time (CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the compiler and the host triple, and is answered before any argument is forwarded to project.cpp. The version is a define on the library, so each version is its own library variant and relinks the executable and tests once per commit. build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already there, and writes a fresh archive instead of appending to one that may hold members from an older layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
"Crafter.Build-Toolchain",
2026-04-27 07:04:42 +02:00
"Crafter.Build-Shader",
"Crafter.Build-Platform",
"Crafter.Build-Interface",
"Crafter.Build-Implementation",
"Crafter.Build-External",
feat(agents): keep dependencies' AGENTS.consumer.md in AGENTS.md An agent working on a crafter-build project reads its AGENTS.md, and learns nothing there about the libraries the project builds against unless someone copied that in by hand. This repository shows the cost: the code style section of its AGENTS.md restates what Crafter.LintRules enforces, and goes stale whenever the rules change. A library now ships those notes itself, as AGENTS.consumer.md beside its project.cpp; nothing in its project.cpp changes. When a consumer has an AGENTS.md beside its own project.cpp, `crafter-build` and `crafter-build test` keep a section in it, between two marker comments, holding its dependencies' notes. The section is appended the first time and rewritten in place after that. The file is written only when the section changes, and an AGENTS.md is never created. lint, format, run and clean leave it alone. The update runs after loading, which has just fetched every dependency, and before compiling, so a build that fails still has the notes. cfg.dependencyNotes picks the dependencies. Transitive, the default, follows `dependencies` all the way down from the project's own configurations, its tests and its `#pragma crafter dependency` tooling; Direct stops at the first loaded project on each path; None removes the section. A dependency's own pragma tooling never counts: it is about how that dependency is built, not how it is used, and being no `dependencies` edge it is never reached. Nor are a dependency's tests: nothing builds them for the consumer, and their configurations can point at a local their project file has since moved out of. The walk has to know which Configurations belong to another project, and where that project lives, and cfg.path cannot say: only the Configuration GitProject and LocalProject return is rebased, so a dependency's internal configurations keep "./", which resolves to the consumer. The loader records it instead, LoadedProjectOf beside the project cache, and keeps what the root's pragmas resolved to past the frame LoadProject pops, PragmaDependencies. Neither is exported. The markers count only as whole lines outside fenced code blocks, so documentation can quote them. Unbalanced markers and a document ending inside a code block leave the file alone with a warning: guessing which part of someone's file is generated would overwrite their text, and missing the section would add another one on every build. A note that would break the section when it is read back, through a marker line of its own or a code block it never closes, is left out with a warning. The file is replaced through a temporary renamed onto the symlink's target, so a linked AGENTS.md stays a link and a write cut short leaves the old file rather than part of the new one. The code is a new partition, Crafter.Build:Agents, listed in build.sh, CrafterBuildModules, project.cpp and the umbrella. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:18:06 +01:00
"Crafter.Build-Agents",
2026-04-27 07:04:42 +02:00
"Crafter.Build-Clang",
"Crafter.Build-Test",
2026-07-23 01:24:42 +02:00
"Crafter.Build-Lint",
feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
"Crafter.Build-Script",
2026-04-29 03:37:09 +02:00
"Crafter.Build-Progress",
2026-05-12 01:16:40 +02:00
"Crafter.Build-Asset",
2026-04-27 07:04:42 +02:00
"Crafter.Build",
};
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
// Returns the stamp of the module sources the cache was built from; it is
// one of project.so's inputs.
std::string EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir, const Configuration& host) {
2026-05-30 19:28:06 +02:00
CacheLock lock(cacheDir);
fix: key the host PCM cache on source content, add clean, hash project args Three follow-ons to the stale-build report, all cases of an identity not capturing something that changes the output. The host PCM cache under <cache>/crafter.build/<target>-<march>/ is shared by every crafter-build on the machine, and freshness was a per-file mtime comparison. That cannot tell "this PCM is newer than my source" from "this PCM was built from different sources that happen to be newer", so a package install and a working checkout — or two checkouts of different versions — silently compiled their project.cpp against each other's declarations. Invalidation now keys on a stamp over the bytes of every module source, which also covers the case one file's mtime never could: the cached PCMs import each other, so a change to :Interface invalidates :Clang's PCM with Crafter.Build-Clang.cppm untouched. Project args ApplyStandardArgs does not itself interpret are now folded into VariantId. Such a flag typically decides what gets compiled or bundled — the report's example is --no-webgpu dropping entries from cfg.files — and without it both settings shared one bin dir and interleaved their outputs there, leaving a bundle matching neither. Sorted and deduplicated so flag order doesn't split the cache, and inherited by test Configurations. `crafter-build clean` removes the project's bin/ and build/ trees. It deliberately does not load project.cpp: cleaning is most often reached when something is already wrong, and a clean that first needs the project to compile is useless exactly then.
2026-07-30 17:43:09 +00:00
// Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited.
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
// The std PCM is an input to every module PCM: its stamp is folded in
// so a rebuilt std.pcm (a libc++ update under the same clang) rebuilds
// them too, instead of leaving PCMs that import a std.pcm that is gone.
std::string stamp = HashStamp(CrafterBuildSourceStamp(sourceDir, CrafterBuildModules) + '\0' + ReadStamp(StampPath(cacheDir / "std.pcm")));
const bool upToDate = ReadStamp(CacheStampPath(cacheDir)) == stamp;
2026-07-23 01:24:42 +02:00
for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
2026-04-27 07:04:42 +02:00
if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
}
fix: key the host PCM cache on source content, add clean, hash project args Three follow-ons to the stale-build report, all cases of an identity not capturing something that changes the output. The host PCM cache under <cache>/crafter.build/<target>-<march>/ is shared by every crafter-build on the machine, and freshness was a per-file mtime comparison. That cannot tell "this PCM is newer than my source" from "this PCM was built from different sources that happen to be newer", so a package install and a working checkout — or two checkouts of different versions — silently compiled their project.cpp against each other's declarations. Invalidation now keys on a stamp over the bytes of every module source, which also covers the case one file's mtime never could: the cached PCMs import each other, so a change to :Interface invalidates :Clang's PCM with Crafter.Build-Clang.cppm untouched. Project args ApplyStandardArgs does not itself interpret are now folded into VariantId. Such a flag typically decides what gets compiled or bundled — the report's example is --no-webgpu dropping entries from cfg.files — and without it both settings shared one bin dir and interleaved their outputs there, leaving a bundle matching neither. Sorted and deduplicated so flag order doesn't split the cache, and inherited by test Configurations. `crafter-build clean` removes the project's bin/ and build/ trees. It deliberately does not load project.cpp: cleaning is most often reached when something is already wrong, and a clean that first needs the project to compile is useless exactly then.
2026-07-30 17:43:09 +00:00
if (upToDate && fs::exists(pcmPath)) {
2026-04-27 07:04:42 +02:00
continue;
}
std::string cmd = std::format(
feat(toolchain): resolve the compiler in one place Every clang++, clang and llvm-ar the tool spawned was a bare name on PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI sysroot /usr/share/wasi-sysroot and the mingw headers /usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A toolchain installed anywhere else, which is what the Flatpak ships under /app, had no way in. Crafter.Build:Toolchain resolves all of it once per process: CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build (<prefix>/bin, found through the same walk GetCrafterBuildHome does), else PATH. What sits beside the compiler is used when present: its libclang.so, its clang-c headers, its std module source, its WASI sysroot. An explicit prefix without a clang++ is an error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other than the compiler's own, and CRAFTER_MINGW_DIR now does what its help line always claimed: it is passed as --sysroot for the mingw target. Binaries linked by a toolchain outside /usr get an rpath to its lib dir, since its libc++.so.1 is not on the loader's default path. HostTarget() is the resolved clang's -print-target-triple. A clang built without a vendor reports x86_64-unknown-linux-gnu where Arch's reports x86_64-pc-linux-gnu, so target equality that decides whether a test runs locally and which targets a bare `test` sweeps goes through SameTarget, which ignores the vendor field. The strings themselves are never rewritten: clang rejects a PCM whose triple differs from the importing unit's. The examples and README stop hardcoding the host triple; the default is already the host. `crafter-build --version` prints the version baked in at build time (CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the compiler and the host triple, and is answered before any argument is forwarded to project.cpp. The version is a define on the library, so each version is its own library variant and relinks the executable and tests once per commit. build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already there, and writes a fresh archive instead of appending to one that may hold members from an older layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
"{} --target={} -march=native -mtune=native "
"-std=c++26 -O3 "
2026-04-27 07:04:42 +02:00
"-Wno-reserved-identifier -Wno-reserved-module-identifier "
"-fprebuilt-module-path={} "
"--precompile {} -o {}",
feat(toolchain): resolve the compiler in one place Every clang++, clang and llvm-ar the tool spawned was a bare name on PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI sysroot /usr/share/wasi-sysroot and the mingw headers /usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A toolchain installed anywhere else, which is what the Flatpak ships under /app, had no way in. Crafter.Build:Toolchain resolves all of it once per process: CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build (<prefix>/bin, found through the same walk GetCrafterBuildHome does), else PATH. What sits beside the compiler is used when present: its libclang.so, its clang-c headers, its std module source, its WASI sysroot. An explicit prefix without a clang++ is an error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other than the compiler's own, and CRAFTER_MINGW_DIR now does what its help line always claimed: it is passed as --sysroot for the mingw target. Binaries linked by a toolchain outside /usr get an rpath to its lib dir, since its libc++.so.1 is not on the loader's default path. HostTarget() is the resolved clang's -print-target-triple. A clang built without a vendor reports x86_64-unknown-linux-gnu where Arch's reports x86_64-pc-linux-gnu, so target equality that decides whether a test runs locally and which targets a bare `test` sweeps goes through SameTarget, which ignores the vendor field. The strings themselves are never rewritten: clang rejects a PCM whose triple differs from the importing unit's. The examples and README stop hardcoding the host triple; the default is already the host. `crafter-build --version` prints the version baked in at build time (CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the compiler and the host triple, and is answered before any argument is forwarded to project.cpp. The version is a define on the library, so each version is its own library variant and relinks the executable and tests once per commit. build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already there, and writes a fresh archive instead of appending to one that may hold members from an older layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
GetBaseCommand(host), host.target, cacheDir.string(), cppmPath.string(), pcmPath.string());
2026-04-27 07:04:42 +02:00
CommandResult r = Crafter::RunCommandChecked(cmd);
if (r.exitCode != 0) {
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
}
}
fix: key the host PCM cache on source content, add clean, hash project args Three follow-ons to the stale-build report, all cases of an identity not capturing something that changes the output. The host PCM cache under <cache>/crafter.build/<target>-<march>/ is shared by every crafter-build on the machine, and freshness was a per-file mtime comparison. That cannot tell "this PCM is newer than my source" from "this PCM was built from different sources that happen to be newer", so a package install and a working checkout — or two checkouts of different versions — silently compiled their project.cpp against each other's declarations. Invalidation now keys on a stamp over the bytes of every module source, which also covers the case one file's mtime never could: the cached PCMs import each other, so a change to :Interface invalidates :Clang's PCM with Crafter.Build-Clang.cppm untouched. Project args ApplyStandardArgs does not itself interpret are now folded into VariantId. Such a flag typically decides what gets compiled or bundled — the report's example is --no-webgpu dropping entries from cfg.files — and without it both settings shared one bin dir and interleaved their outputs there, leaving a bundle matching neither. Sorted and deduplicated so flag order doesn't split the cache, and inherited by test Configurations. `crafter-build clean` removes the project's bin/ and build/ trees. It deliberately does not load project.cpp: cleaning is most often reached when something is already wrong, and a clean that first needs the project to compile is useless exactly then.
2026-07-30 17:43:09 +00:00
// Last, so an interrupted rebuild leaves the stamp disagreeing with the
// sources and the next run starts over rather than trusting a half-built
// set of PCMs.
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
if (!upToDate) WriteStamp(CacheStampPath(cacheDir), stamp);
return stamp;
2026-04-27 07:04:42 +02:00
}
}
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
feat(toolchain): resolve the compiler in one place Every clang++, clang and llvm-ar the tool spawned was a bare name on PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI sysroot /usr/share/wasi-sysroot and the mingw headers /usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A toolchain installed anywhere else, which is what the Flatpak ships under /app, had no way in. Crafter.Build:Toolchain resolves all of it once per process: CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build (<prefix>/bin, found through the same walk GetCrafterBuildHome does), else PATH. What sits beside the compiler is used when present: its libclang.so, its clang-c headers, its std module source, its WASI sysroot. An explicit prefix without a clang++ is an error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other than the compiler's own, and CRAFTER_MINGW_DIR now does what its help line always claimed: it is passed as --sysroot for the mingw target. Binaries linked by a toolchain outside /usr get an rpath to its lib dir, since its libc++.so.1 is not on the loader's default path. HostTarget() is the resolved clang's -print-target-triple. A clang built without a vendor reports x86_64-unknown-linux-gnu where Arch's reports x86_64-pc-linux-gnu, so target equality that decides whether a test runs locally and which targets a bare `test` sweeps goes through SameTarget, which ignores the vendor field. The strings themselves are never rewritten: clang rejects a PCM whose triple differs from the importing unit's. The examples and README stop hardcoding the host triple; the default is already the host. `crafter-build --version` prints the version baked in at build time (CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the compiler and the host triple, and is answered before any argument is forwarded to project.cpp. The version is a define on the library, so each version is its own library variant and relinks the executable and tests once per commit. build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already there, and writes a fresh archive instead of appending to one that may hold members from an older layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
RequireToolchain();
2026-04-27 07:04:42 +02:00
fs::path absProject = fs::canonical(projectFile);
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
ProjectFileGuard guard(absProject);
2026-04-27 07:04:42 +02:00
fs::path buildDir = absProject.parent_path() / "build";
if (!fs::exists(buildDir)) {
fs::create_directories(buildDir);
}
2026-07-23 01:24:42 +02:00
fs::path soPath = buildDir / std::format("{}.so", absProject.stem().string());
2026-04-27 07:04:42 +02:00
V2: WASI, -r flag, CI pipeline, examples & tests cleanup WASI / wasm32 target support - Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32") - Skip -march/-mtune for wasm (clang rejects them) - Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync) - .wasm output extension in expectedOutputFor and link command - EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html + runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL set in the templated index.html so a single shim handles any output name -r run flag in the CLI: build then exec the artifact (host targets only; rejects libraries; auto .exe/.wasm extension handling) CI pipeline (.forgejo/workflows/ci.yaml) - Triggers: PR/push to master + manual dispatch - Single arch-latest container job: install deps, bootstrap, self-rebuild, run tests, cross-compile mingw, package both archives, upload artifacts - Rolling 'latest' release published only on push/dispatch to master mingw cross-compile from Linux now works end-to-end: - ExternalDependency cache key includes target so per-target glslang builds don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends 'd' to lib names and breaks linking); cross-compile cmake flags (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...) - project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh link pulls pthread which mingw doesn't link by default) - mingw compile uses -femulated-tls so std::__once_callable etc reference the same emutls symbols libstdc++ provides - mingw link auto-adds -lstdc++exp -lpthread GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux + Windows) now both use it instead of duplicating the resolution. Examples reorg: hello-world, library, with-module, wasi, tests — each with its own README. Tests reorg: per-test directory with inner/ fixture, no shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
fs::path sourceDir = Crafter::GetCrafterBuildHome();
2026-04-27 07:04:42 +02:00
musl host support: bootstrap and run on Alpine (x86_64-alpine-linux-musl) The Linux host code paths were gated on the x86_64_pc_linux_gnu target define alone, and LoadProject hardcoded --target=x86_64-pc-linux-gnu when compiling project.cpp and the module PCMs. Both break on musl hosts: the glibc release launcher cannot run there at all (no glibc loader; gcompat lacks libgcc_s and the __isoc23_* symbols), and a musl-built launcher would still compile project.cpp for the wrong libc. - The host gates test clang's __linux__ instead of one triple's define: they mean "Linux host", whatever the libc (or, later, the CPU). - LoadProject derives the host triple from HostTarget() (clang's -print-target-triple), which is also more correct on glibc distros whose triple isn't x86_64-pc-linux-gnu. - project.cpp / the cmake libc++ flags treat any *-linux-gnu or *-linux-musl target as Linux. - build.sh targets the host toolchain's triple (CRAFTER_BUILD_TARGET overrides) and derives the per-target define from it. On Arch this is byte-for-byte the previous behaviour. - CI: a release-musl job bootstraps on alpine:edge, runs the tests, and attaches crafter-build-linux-x86_64-musl-v2.tar.gz to the rolling latest release (via the API, so the glibc assets survive). Dynamic against musl libc++: the launcher dlopens project.so, so it cannot be fully static. First consumer: imsd's package CI, which cross-compiles for aarch64 from an Alpine container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:49:00 +02:00
// The host toolchain's own triple (x86_64-pc-linux-gnu on glibc distros,
// x86_64-alpine-linux-musl on Alpine): project.cpp and the module PCMs
// it imports must be built for the libc crafter-build itself runs on.
2026-04-27 07:04:42 +02:00
Configuration hostConfig;
musl host support: bootstrap and run on Alpine (x86_64-alpine-linux-musl) The Linux host code paths were gated on the x86_64_pc_linux_gnu target define alone, and LoadProject hardcoded --target=x86_64-pc-linux-gnu when compiling project.cpp and the module PCMs. Both break on musl hosts: the glibc release launcher cannot run there at all (no glibc loader; gcompat lacks libgcc_s and the __isoc23_* symbols), and a musl-built launcher would still compile project.cpp for the wrong libc. - The host gates test clang's __linux__ instead of one triple's define: they mean "Linux host", whatever the libc (or, later, the CPU). - LoadProject derives the host triple from HostTarget() (clang's -print-target-triple), which is also more correct on glibc distros whose triple isn't x86_64-pc-linux-gnu. - project.cpp / the cmake libc++ flags treat any *-linux-gnu or *-linux-musl target as Linux. - build.sh targets the host toolchain's triple (CRAFTER_BUILD_TARGET overrides) and derives the per-target define from it. On Arch this is byte-for-byte the previous behaviour. - CI: a release-musl job bootstraps on alpine:edge, runs the tests, and attaches crafter-build-linux-x86_64-musl-v2.tar.gz to the rolling latest release (via the API, so the glibc assets survive). Dynamic against musl libc++: the launcher dlopens project.so, so it cannot be fully static. First consumer: imsd's package CI, which cross-compiles for aarch64 from an Alpine container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:49:00 +02:00
hostConfig.target = HostTarget();
2026-04-27 07:04:42 +02:00
hostConfig.march = "native";
hostConfig.mtune = "native";
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
// The same directory Build() resolves the std PCM from, toolchain key
// included (see StdPcmDir).
fs::path cacheDir = StdPcmDir(hostConfig);
2026-04-27 07:04:42 +02:00
if (!fs::exists(cacheDir)) fs::create_directories(cacheDir);
std::string stdResult = BuildStdPcm(hostConfig, cacheDir / "std.pcm");
if (!stdResult.empty()) {
throw std::runtime_error(std::format("Failed to build std.pcm: {}", stdResult));
}
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
const std::string sourcesStamp = EnsureCrafterBuildPcms(sourceDir, cacheDir, hostConfig);
2026-04-27 07:04:42 +02:00
feat: project.cpp declares its own dependencies with #pragma crafter dependency A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
// After the host PCMs exist (a dependency's build imports Crafter.Build
// from that cache) and before the staleness check (its artifacts are
// inputs to project.so). Built on every load, fresh or not: a dependency
// BMI whose own imports were rebuilt underneath it doesn't fail cleanly,
// clang crashes on it.
ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig);
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
// -fuse-ld=lld like every other link the tool performs: lld is a hard
// dependency, binutils' ld is not (Alpine's clang would default to it),
// and a dependency archive built in release holds ThinLTO bitcode,
// which GNU ld can't read.
std::string compileCmd = std::format(
"{} --target={} -march=native -mtune=native "
"-std=c++26 -shared -fPIC -O3 -fuse-ld=lld "
"-Wno-return-type-c-linkage "
"-fprebuilt-module-path={}{} "
"{} -o {}{}{}",
GetBaseCommand(hostConfig), hostConfig.target, cacheDir.string(), preDeps.compileFlags,
absProject.string(), soPath.string(), preDeps.linkFlags, GetToolchain().LibcxxLinkFlags());
// What project.so was compiled against. The module source stamp stands
// in for the executable: project.so resolves Crafter:: symbols from the
// running binary, so only the declarations (the .cppm files) matter,
// and an implementation-only rebuild of crafter-build leaves it valid.
// The separators are appended, not written into a format string: a
// format string is read as a C string, so one with "\0" in it ends at the
// first NUL and everything after the version silently dropped out of the
// stamp. A project.so compiled against other declarations (a Shader with
// a different layout, say) was then loaded as current and corrupted the
// heap.
std::string projectStampInput(CrafterBuildVersion());
for (std::string_view part : {std::string_view(GetToolchain().Key()), std::string_view(sourcesStamp), std::string_view(compileCmd)}) {
projectStampInput += '\0';
projectStampInput += part;
}
const std::string projectStamp = HashStamp(projectStampInput);
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
if (ProjectFileStale(soPath, absProject, projectStamp, preDeps)) {
std::string result = RunBuildCommand(compileCmd);
2026-04-27 07:04:42 +02:00
if (!result.empty()) {
throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
}
fix: key PCM and project.so freshness on content, not installed mtimes std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:32:35 +02:00
WriteStamp(StampPath(soPath), projectStamp);
2026-04-27 07:04:42 +02:00
}
// Before dlopen, so the file's static initialisers can already call
// ProjectDependency(): its dependencies are the same on every load.
{
std::lock_guard lock(PragmaDependenciesMutex);
PragmaDependenciesByProject[absProject] = preDeps.configs;
}
guard.Frame().dependencies = std::move(preDeps.configs);
2026-04-27 07:04:42 +02:00
void* handle = dlopen(soPath.c_str(), RTLD_NOW | RTLD_GLOBAL);
if (!handle) {
throw std::runtime_error(std::format("Failed to load project {}: {}", soPath.string(), dlerror()));
}
using ProjectFn = Configuration (*)(std::span<const std::string_view>);
dlerror();
auto fn = reinterpret_cast<ProjectFn>(dlsym(handle, "CrafterBuildProject"));
if (const char* err = dlerror()) {
throw std::runtime_error(std::format("CrafterBuildProject not found in {}: {}", soPath.string(), err));
}
// The args only for the call itself, unlike the dependencies. A static
// initialiser runs once per process, on the first load of the file: a
// later load gets the same mapping back from dlopen, so a spec built at
// namespace scope kept the first load's args for every load after it.
// Now it gets none, whichever load ran it. The pragma pre-pass above
// also ran on this frame and relies on the args not being there yet.
guard.Frame().args = args;
2026-04-27 07:04:42 +02:00
return fn(args);
2026-04-23 01:57:25 +02:00
}