From 75309545296d09c040b1a063be566a2369794904 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 6 Oct 2026 18:08:26 +0200 Subject: [PATCH 1/8] fix(cli): `format clean` no longer deletes bin/ and build/ Every verb is a bare word that is only read as a verb while no conflicting verb is active; after one, the same word is a glob. The guard on `clean` checked the test and lint verbs and then itself, where the format verb was meant. `crafter-build format clean`, which asks for the rules matching "clean", therefore removed both trees and exited 0 without formatting anything. The guard now names format, like the three beside it. Co-Authored-By: Claude Fable 5.1 --- implementations/Crafter.Build-Clang.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index d10b158..3c48078 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -1712,7 +1712,7 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { runTests = true; } else if (!runTests && !runFormat && !runClean && arg == "lint") { runLint = true; - } else if (!runTests && !runLint && !runClean && arg == "clean") { + } else if (!runTests && !runLint && !runFormat && arg == "clean") { runClean = true; } else if (!runTests && !runLint && !runClean && arg == "format") { runFormat = true; From 6dcdad45152a179a6f021765fb88c497cd1f4ca5 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 6 Oct 2026 18:08:26 +0200 Subject: [PATCH 2/8] test(cli): a word after `format` is a glob, not the clean verb Drives Run() with `format clean` on a staged project whose project file cannot compile. Both trees must survive, and the run must fail: getting as far as loading the project is what shows it went on to format rather than cleaning and exiting 0. Co-Authored-By: Claude Fable 5.1 --- tests/CleanProject/main.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/CleanProject/main.cpp b/tests/CleanProject/main.cpp index ecad097..25f85f1 100644 --- a/tests/CleanProject/main.cpp +++ b/tests/CleanProject/main.cpp @@ -29,6 +29,12 @@ namespace { std::ofstream(root / "keep-me.txt") << "untouched"; return root; } + + std::int32_t RunWith(std::vector args) { + std::vector argv; + for (std::string& arg : args) argv.push_back(arg.data()); + return Run(static_cast(argv.size()), argv.data()); + } } int main() { @@ -71,6 +77,22 @@ int main() { Check(fs::exists(cwdBin) == cwdBinExisted, "the cwd's bin/ is untouched"); } + { + // `clean` is a verb only where a verb can stand. After `format` it + // is a rule-name glob like any other word, and a glob must never + // delete the build trees. The project file cannot compile, so the + // run fails once it gets as far as loading it — which is the proof + // that it went on to format instead of cleaning and exiting 0. + fs::path root = StageProject("format-glob"); + fs::path projectFile = root / "project.cpp"; + std::ofstream(projectFile) << "this is not valid C++ at all\n"; + + const std::int32_t exitCode = RunWith({"crafter-build", "format", "clean", std::format("--project={}", projectFile.string())}); + Check(fs::exists(root / "bin" / "app-x86_64-pc-linux-gnu-native-native-0badf00d" / "app"), "`format clean` leaves bin/ alone"); + Check(fs::exists(root / "build" / "app-x86_64-pc-linux-gnu-native-native-0badf00d" / "main_impl.o"), "`format clean` leaves build/ alone"); + Check(exitCode != 0, "`format clean` goes on to load the project instead of cleaning"); + } + if (Failures > 0) { std::println(std::cerr, "{} assertions failed", Failures); return 1; From 561398013c090011a0be443f865cce456930e6dc Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 6 Oct 2026 18:16:00 +0200 Subject: [PATCH 3/8] 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 [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 --- AGENTS.md | 7 +- build.sh | 2 +- implementations/Crafter.Build-Clang.cpp | 47 +++++++++- implementations/Crafter.Build-External.cpp | 10 --- implementations/Crafter.Build-Platform.cpp | 83 +++++++++++------ implementations/Crafter.Build-Script.cpp | 100 +++++++++++++++++++++ implementations/Crafter.Build-Test.cpp | 16 +--- interfaces/Crafter.Build-Clang.cppm | 45 ++++++++++ interfaces/Crafter.Build-Platform.cppm | 12 +++ interfaces/Crafter.Build-Script.cppm | 40 +++++++++ interfaces/Crafter.Build.cppm | 1 + project.cpp | 6 +- 12 files changed, 313 insertions(+), 56 deletions(-) create mode 100644 implementations/Crafter.Build-Script.cpp create mode 100644 interfaces/Crafter.Build-Script.cppm diff --git a/AGENTS.md b/AGENTS.md index 574abeb..7451d75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,12 +22,13 @@ crafter-build is a C++26 modules build system. A project is described by a `proj Partitions and what they own: - `:Toolchain` resolves the compiler once per process (`GetToolchain`): clang++, clang, llvm-ar, libclang, libc++'s std module source and the default sysroots, from `CRAFTER_BUILD_LLVM_DIR`, else a toolchain installed beside crafter-build, else PATH. It also holds the version baked in at build time. `SysrootStdModule` picks the std module source for a sysroot build by the sysroot's libc++ release. -- `:Clang` holds `Configuration`, `Build()`, `VariantId()`, `ApplyStandardArgs`, `HostTarget`, the CLI entry point `Crafter::Run`, and the lint and test data types. +- `:Clang` holds `Configuration`, `Build()`, `VariantId()`, `ApplyStandardArgs`, `HostTarget`, the CLI entry point `Crafter::Run`, and the lint, test and script data types. - `:Platform` runs commands, implements `LoadProject` (compile and load `project.cpp`), the host PCM cache, `#pragma crafter dependency`, and all OS-specific code. - `:Interface` and `:Implementation` model module units, their staleness checks and their compiles. - `:External` handles git + cmake dependencies, `GitProject` and `LocalProject`. - `:Test` holds `AddTest`, `RunTests` and `TestRunner`. - `:Lint` is the lint/format driver plus the libclang token and AST layer. +- `:Script` is the driver behind `crafter-build run`: it looks a name up in `cfg.scripts`, calls the callback and turns what it returns into the exit code. - `:Progress` is console output. `:Shader` compiles shaders through glslang. `:Asset` is the hook into Crafter.Asset. ## Dev environment @@ -60,6 +61,8 @@ Three things to know about this loop: ./bin/crafter-build lint # dry style gate, exit 1 on any finding ./bin/crafter-build format # apply the transform rules in place ./bin/crafter-build format --check # CI-style dry run +./bin/crafter-build run # list the scripts project.cpp defines +./bin/crafter-build run [args...] # run one; everything after the name is the script's ./bin/crafter-build clean ``` @@ -142,6 +145,7 @@ These were decided deliberately. Do not undo them without the maintainer's say-s - `project.cpp` is compiled and loaded into the crafter-build process, and every `GitProject`, `#pragma crafter dependency` and `ExternalDependency` clones a repository and runs its build on the host. Treat a dependency URL as code you are choosing to run. This repo's own dependencies are sibling Catcrafts repos tracking their default branch on purpose; pin with `commit` when adding anything else, and whenever reproducibility matters. - Unpinned dependencies `git pull` on every invocation, so builds and tests need network. A missing or private repo surfaces as a clone failure before anything is built. - `--runner=cmd:` executes the named wrapper around every test binary. Wrappers come from `--runner=` or `CRAFTER_BUILD_RUNNER_*`; do not hardcode hosts, wrappers or credentials in `project.cpp` or tests. +- `crafter-build run ` executes whatever the script does, with your permissions. Only the scripts of the project crafter-build was pointed at can be run, never a dependency's; keep it that way. - The linter loads `libclang` at runtime by name. `CRAFTER_BUILD_LIBCLANG` pins an exact path and must fail loudly when wrong, never fall back to another library. - CI force-pushes the rolling `latest` tag and publishes a prerelease on every push to `master` with the workflow's own token, and signs the Flatpak repo with the key mounted from the runner host. Do not add secrets to the workflow or echo the environment in it. - Tests write only under `fs::temp_directory_path()` and the fixture's own `bin/` and `build/`. Keep it that way. @@ -164,6 +168,7 @@ To release: add a `` to `packaging/flatpak/net.catcrafts.CrafterBuild.m ## Gotchas +- A new partition is listed in four places, in import order: `PARTITIONS` in `build.sh`, `CrafterBuildModules` in `Crafter.Build-Platform.cpp`, both arrays in `project.cpp`, and the umbrella `Crafter.Build.cppm`. Then `./build.sh`. - A `#pragma crafter dependency` target is rebuilt on every load on purpose: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the depfile check is what catches a regenerated host cache. Do not optimise the rebuild away. - A host-tooling library such as Crafter.LintRules may `import Crafter.Build;` without declaring it as a dependency, because `LoadProject` fills the cache it resolves from. Listing Crafter.Build in its `cfg.dependencies` would clone and build all of crafter-build for every consumer. Its test does link crafter-build, so that test is wired behind `IsTopLevelProject()`: without the guard, crafter-build loading the library as a dependency would load its own `project.cpp` again through the test's `GitProject` and hit the cycle guard. - `crafter-build clean` leaves a pragma dependency's build alone; it lives with the clone in the cache. diff --git a/build.sh b/build.sh index 2d7df47..312abde 100755 --- a/build.sh +++ b/build.sh @@ -48,7 +48,7 @@ cp wasi-runtime/index.html.in share/crafter-build/wasi-runtime/ # Partition order is the import order: every unit is listed before the # units that import it, and the umbrella last. -PARTITIONS="Toolchain Shader Platform Interface Implementation External Clang Test Lint Progress Asset" +PARTITIONS="Toolchain Shader Platform Interface Implementation External Clang Test Lint Script Progress Asset" cp interfaces/Crafter.Build.cppm share/crafter-build/ for p in $PARTITIONS; do diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index 3c48078..a7d12b8 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -12,6 +12,7 @@ import :Platform; import :Toolchain; import :Test; import :Lint; +import :Script; import :Progress; import :Asset; namespace fs = std::filesystem; @@ -1594,6 +1595,7 @@ R"(Usage: {0} test [test-options] [globs...] Build and run the project's tests {0} lint [lint-options] [globs...] Run the project's lint rules over its sources {0} format [format-options] [globs...] Apply the project's transform rules (rewrites files) + {0} run [script [args...]] Run a script the project defines, or list them {0} clean Delete the project's bin/ and build/ trees {0} help | -h | --help Show this help {0} --version Print the version and the compiler in use @@ -1646,6 +1648,23 @@ Format options (after the `format` subcommand): transform. `format` rewrites changed files in place; `lint` reports the same transforms as would-reformat findings without writing. +Run (after the `run` subcommand): +