Crafter.Review/project.cpp
Jorijn van der Graaf 5dab4b49d5
ci: the Flatpak workflow, local deps for the local build, the lens tools
- .forgejo/workflows/ci.yaml is Crafter.Build's workflow for this app: the
  base installed from the Catcrafts repo, the version stamped into the
  crafter-review module, the suite inside the build, then a smoke test of
  the app as it ships - the version and home, every library resolved and no
  RUNPATH outside /app, the bundled tools, a freeze and a dry run on a
  scratch repository. Publishing holds /srv/flatpak/.publish.lock and
  writes only this app's flatpakref. Unverified until a crafter-flatpak
  runner serves this repository.
- `run flatpak --deps=local` builds against the Crafter.Network and
  Crafter.Thread checkouts next to this one, as a native --local build
  does, while their commits are not on forgejo: a gitignored manifest copy
  with three dir sources side by side and --local.
- LensTools: every program the lens allowlist names, the shell and the MCP
  server's node and npx resolve on PATH; strict in the Flatpak build, where
  the suite runs in the sandbox that ships, a skip natively when this
  machine lacks one.

Built on the stable base (Crafter.Build 3.0.0) with --deps=local: the suite
passes inside flatpak-builder (25 passed, 2 skipped), and the workflow's
smoke test passes against that build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:59:20 +01:00

364 lines
18 KiB
C++

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
// Pinned: the Flatpak builds with the base app's crafter-build, which can be
// older than the rules' master. Move the pin together with the base.
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "7d932892017800b4e1f1a15fe7aa49b3daee9ceb"
import Crafter.LintRules;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
// The umbrella, then the partitions in import order: each after the ones
// it imports, which is also the order the module map in AGENTS.md uses.
constexpr std::array<std::string_view, 31> Interfaces = {
"interfaces/Crafter.Review",
"interfaces/Crafter.Review-Unicode",
"interfaces/Crafter.Review-Json",
"interfaces/Crafter.Review-Text",
"interfaces/Crafter.Review-Crypto",
"interfaces/Crafter.Review-Platform",
"interfaces/Crafter.Review-Process",
"interfaces/Crafter.Review-Http",
"interfaces/Crafter.Review-Core",
"interfaces/Crafter.Review-Schema",
"interfaces/Crafter.Review-Tools",
"interfaces/Crafter.Review-Plan",
"interfaces/Crafter.Review-Artifact",
"interfaces/Crafter.Review-Inventory",
"interfaces/Crafter.Review-Dispositions",
"interfaces/Crafter.Review-State",
"interfaces/Crafter.Review-Normalize",
"interfaces/Crafter.Review-PgWire",
"interfaces/Crafter.Review-Totp",
"interfaces/Crafter.Review-Stream",
"interfaces/Crafter.Review-Prompt",
"interfaces/Crafter.Review-Config",
"interfaces/Crafter.Review-Memory",
"interfaces/Crafter.Review-Mcp",
"interfaces/Crafter.Review-Seats",
"interfaces/Crafter.Review-SeatPool",
"interfaces/Crafter.Review-OpenCode",
"interfaces/Crafter.Review-Pool",
"interfaces/Crafter.Review-Freeze",
"interfaces/Crafter.Review-Run",
"interfaces/Crafter.Review-Driver",
};
constexpr std::array<std::string_view, 28> Implementations = {
"implementations/Crafter.Review-Text",
"implementations/Crafter.Review-Crypto",
"implementations/Crafter.Review-Platform",
"implementations/Crafter.Review-Process",
"implementations/Crafter.Review-Http",
"implementations/Crafter.Review-Core",
"implementations/Crafter.Review-Schema",
"implementations/Crafter.Review-Tools",
"implementations/Crafter.Review-Plan",
"implementations/Crafter.Review-Artifact",
"implementations/Crafter.Review-Inventory",
"implementations/Crafter.Review-Dispositions",
"implementations/Crafter.Review-State",
"implementations/Crafter.Review-Normalize",
"implementations/Crafter.Review-PgWire",
"implementations/Crafter.Review-Totp",
"implementations/Crafter.Review-Stream",
"implementations/Crafter.Review-Prompt",
"implementations/Crafter.Review-Config",
"implementations/Crafter.Review-Memory",
"implementations/Crafter.Review-Mcp",
"implementations/Crafter.Review-Seats",
"implementations/Crafter.Review-SeatPool",
"implementations/Crafter.Review-OpenCode",
"implementations/Crafter.Review-Pool",
"implementations/Crafter.Review-Freeze",
"implementations/Crafter.Review-Run",
"implementations/Crafter.Review-Driver",
};
// With -march=native clang would fuse a multiply and an add: a cost
// estimate, a usage total or a memory threshold must come out to the
// same last bit on every machine, as the goldens record it.
constexpr std::string_view NoContraction = "-ffp-contract=off";
// One per directory under tests/, each an ordinary main() that links the
// library: exit 0 passes, nonzero fails, 77 skips.
constexpr std::array<std::string_view, 26> Tests = {
"Crypto",
"Text",
"Platform",
"Process",
"Goldens",
"Stream",
"Normalize",
"Merge",
"Dispositions",
"Config",
"CommitsMode",
"CodeKeys",
"Freeze",
"PgWire",
"Seats",
"SeatPool",
"Drivers",
"OpenCode",
"Runner",
"Scheduler",
"StoreRules",
"StacksMemory",
"ReviewFixes",
"Runs",
"Commands",
"LensTools",
};
// The version the binary reports: CI sets it from the tag, a checkout
// describes itself, anything else is a development build. A define on
// the library, so each version is its own library variant.
std::string Version() {
if (const char* env = std::getenv("CRAFTER_REVIEW_VERSION"); env && *env) return env;
auto described = RunCommandWithTimeout("git describe --tags --always --dirty --exclude=latest 2>/dev/null", std::chrono::seconds(10));
std::string version = described.output;
while (!version.empty() && (version.back() == '\n' || version.back() == '\r')) version.pop_back();
if (described.exitCode != 0 || version.empty()) return "0.0.0-dev";
return version;
}
// The local Flatpak: `crafter-build run flatpak` builds it from this
// checkout and `crafter-build run install` installs that build, as
// Crafter.Build's own scripts do. --base=<branch> builds on another branch
// of the Crafter.Build base app than stable: until a Crafter.Build release
// with scripts is on stable, `--base=local` (the branch Crafter.Build's own
// `run install` makes) is what builds this project. --deps=local takes
// Crafter.Network and Crafter.Thread from the checkouts next to this one,
// as a native --local build does, for commits not on forgejo yet.
struct FlatpakPaths {
fs::path manifest;
// flatpak-builder's downloads, ccache and per-module cache.
fs::path state;
// The OSTree repo the build is exported to; `install` reads it.
fs::path repo;
fs::path buildDir;
};
// Everything under <project>/build/flatpak, a fixed name rather than a
// BuildDir(): the Flatpak is compiled by the toolchain inside the sandbox,
// not by the host variant. flatpak-builder keys its cache on the
// manifest's absolute path, so a branch other than stable, or local deps,
// get a manifest copy of their own next to the original (gitignored), and
// with it a cache of their own.
FlatpakPaths FlatpakLayout(const fs::path& projectDir, std::string_view base, bool localDeps = false) {
const fs::path root = projectDir / "build" / "flatpak";
const fs::path packaging = projectDir / "packaging" / "flatpak";
const bool original = base == "stable" && !localDeps;
return {
.manifest = original ? packaging / "net.catcrafts.CrafterReview.yaml" : packaging / std::format("{}{}-base.yaml", base, localDeps ? "-localdeps" : ""),
.state = root / "state",
.repo = root / "repo",
.buildDir = root / "build-dir",
};
}
// Both scripts drive the host's flatpak, which the sandbox of a Flatpak
// crafter-build does not have.
bool RefusedInSandbox(std::string_view script) {
if (!fs::exists("/.flatpak-info")) return false;
std::println(std::cerr, "run {0}: needs the host's flatpak, which the Flatpak build of crafter-build cannot reach. Use a native crafter-build", script);
return true;
}
// flatpak-builder as a host package, else the org.flatpak.Builder app.
// Empty when neither is installed.
std::string FlatpakBuilder() {
if (RunCommandWithTimeout("flatpak-builder --version", std::chrono::seconds(10)).exitCode == 0) return "flatpak-builder";
if (RunCommandWithTimeout("flatpak info org.flatpak.Builder", std::chrono::seconds(10)).exitCode == 0) return "flatpak run org.flatpak.Builder";
return "";
}
// A repo path as the file:// URL of a remote. Percent-encoded, because
// `remote-modify --url=` stores what it is given as it is.
std::string FileUrl(const fs::path& path) {
std::string url = "file://";
for (const char c : path.string()) {
const bool plain = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || std::string_view("/-._~").contains(c);
if (plain) {
url.push_back(c);
} else {
url += std::format("%{:02X}", static_cast<std::uint8_t>(c));
}
}
return url;
}
// `old` replaced by `replacement` once in `text`; false when it is not
// there.
bool ReplaceOnce(std::string& text, std::string_view old, std::string_view replacement) {
const std::size_t at = text.find(old);
if (at == std::string::npos) return false;
text.replace(at, old.size(), replacement);
return true;
}
// The crafter-review module built from three dir sources side by side,
// so --local finds ../Crafter.Network and ../Crafter.Thread.
bool WithLocalDeps(std::string& manifest) {
constexpr std::string_view Source = R"( - type: dir
path: ../..
skip:
- .git
- bin
- build
)";
std::string sources;
for (const std::string_view project : {"Crafter.Review", "Crafter.Network", "Crafter.Thread"}) {
const std::string path = project == "Crafter.Review" ? std::string("../..") : std::format("../../../{}", project);
sources += std::format(" - type: dir\n path: {}\n dest: {}\n skip:\n - .git\n - bin\n - build\n", path, project);
}
return ReplaceOnce(manifest, " - name: crafter-review\n", " - name: crafter-review\n subdir: Crafter.Review\n")
&& ReplaceOnce(manifest, " - crafter-build\n - crafter-build test\n", " - crafter-build --local\n - crafter-build --local test\n")
&& ReplaceOnce(manifest, Source, sources);
}
std::int32_t BuildFlatpak(ScriptContext& ctx) {
if (RefusedInSandbox("flatpak")) return 1;
std::string jobs;
std::string base = "stable";
bool localDeps = false;
for (const std::string& arg : ctx.args) {
if (arg.starts_with("--base=") && arg.size() > 7) {
base = arg.substr(7);
continue;
}
if (arg == "--deps=local") {
localDeps = true;
continue;
}
const std::string_view count = arg.starts_with("--jobs=") ? std::string_view(arg).substr(7) : std::string_view();
if (count.empty() || !std::ranges::all_of(count, [](char c) { return c >= '0' && c <= '9'; })) {
std::println(std::cerr, "run flatpak: unknown argument '{}' (only --jobs=<N>, --base=<branch> and --deps=local are accepted)", arg);
return 1;
}
jobs = std::format(" --jobs={}", count);
}
if (!std::ranges::all_of(base, [](char c) { return std::isalnum(static_cast<std::uint8_t>(c)) || c == '-' || c == '_' || c == '.'; })) {
std::println(std::cerr, "run flatpak: '{}' is not a branch name", base);
return 1;
}
if (RunCommandWithTimeout(std::format("flatpak info net.catcrafts.CrafterBuild//{}", base), std::chrono::seconds(10)).exitCode != 0) {
std::println(std::cerr, "run flatpak: the base app is not installed: flatpak install catcrafts net.catcrafts.CrafterBuild//{}", base);
return 1;
}
const std::string builder = FlatpakBuilder();
if (builder.empty()) {
std::println(std::cerr, "run flatpak: neither flatpak-builder nor the org.flatpak.Builder app is installed (flatpak install flathub org.flatpak.Builder)");
return 1;
}
const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, base, localDeps);
if (base != "stable" || localDeps) {
// The manifest with base-version changed, and the local deps.
std::ifstream in(FlatpakLayout(ctx.projectDir, "stable").manifest);
std::string manifest((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
if (!ReplaceOnce(manifest, "base-version: stable\n", std::format("base-version: {}\n", base))) {
std::println(std::cerr, "run flatpak: the manifest has no `base-version: stable` line to replace");
return 1;
}
if (localDeps && !WithLocalDeps(manifest)) {
std::println(std::cerr, "run flatpak: the crafter-review module is not in the shape --deps=local rewrites (its dir source and its two crafter-build lines)");
return 1;
}
std::ofstream(paths.manifest) << manifest;
}
const std::string buildDir = ShellQuote(paths.buildDir.string());
if (const std::int32_t code = ctx.Run(std::format("{}{} --user --force-clean --ccache --state-dir={} --repo={} --default-branch=local {} {}", builder, jobs, ShellQuote(paths.state.string()), ShellQuote(paths.repo.string()), buildDir, ShellQuote(paths.manifest.string()))); code != 0) return code;
// The smoke test, through `flatpak build` and not `<builder> --run`:
// the org.flatpak.Builder app reports --run's exit status inverted.
if (const std::int32_t code = ctx.Run(std::format("flatpak build {} crafter-review --version", buildDir)); code != 0) return code;
return ctx.Run(std::format("flatpak build {} sh -c 'ldd /app/bin/crafter-review | grep \"not found\" && exit 1 || exit 0'", buildDir));
}
std::int32_t InstallFlatpak(ScriptContext& ctx) {
if (RefusedInSandbox("install")) return 1;
if (!ctx.args.empty()) {
std::println(std::cerr, "run install: takes no arguments (got '{}')", ctx.args.front());
return 1;
}
const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, "stable");
if (!fs::exists(paths.repo / "config")) {
std::println(std::cerr, "run install: nothing built yet, run: crafter-build run flatpak");
return 1;
}
const std::string url = ShellQuote(FileUrl(paths.repo));
if (const std::int32_t code = ctx.Run(std::format("flatpak remote-add --user --if-not-exists --no-gpg-verify crafter-review-local {}", url)); code != 0) return code;
if (const std::int32_t code = ctx.Run(std::format("flatpak remote-modify --user --url={} crafter-review-local", url)); code != 0) return code;
if (const std::int32_t code = ctx.Run("flatpak install --user --noninteractive --or-update crafter-review-local net.catcrafts.CrafterReview//local"); code != 0) return code;
if (const std::int32_t code = ctx.Run("flatpak make-current --user net.catcrafts.CrafterReview local"); code != 0) return code;
std::println(
R"(
`flatpak run net.catcrafts.CrafterReview` now starts this build.
Back to a published branch, keeping this one installed:
flatpak make-current --user net.catcrafts.CrafterReview <branch>
Remove it (do this before deleting build/, which holds the repo the remote points at):
flatpak uninstall --user net.catcrafts.CrafterReview//local
flatpak remote-delete --user crafter-review-local)");
return 0;
}
}
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
// Crafter.Network for its TCP and HTTP/1.1 clients and listener, without
// QUIC: msquic is most of a fresh build and nothing here speaks HTTP/3.
// From forgejo at a pinned commit, or with --local from the checkout next
// to this one (which is also how Crafter.Network then finds
// Crafter.Thread).
std::vector<std::string> depArgs(args.begin(), args.end());
depArgs.emplace_back("--no-quic");
const bool useLocal = std::ranges::find(args, std::string_view("--local")) != args.end();
Configuration* network = useLocal
? LocalProject({ .projectFile = "../Crafter.Network/project.cpp", .args = depArgs })
: GitProject({
.source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git", .commit = "f68321685006be65f6bc0436fd0175611f1e0a55" },
.args = depArgs,
});
static auto ReviewLib = std::make_unique<Configuration>();
ReviewLib->path = "./";
ReviewLib->name = "crafter.review-lib";
ReviewLib->outputName = "crafter-review";
ApplyStandardArgs(*ReviewLib, args);
ReviewLib->type = ConfigurationType::LibraryStatic;
ReviewLib->dependencies = { network };
ReviewLib->compileFlags.emplace_back(NoContraction);
// Backslash-escaped quotes: the define travels through the shell before
// it reaches clang, which must still see a string literal.
ReviewLib->defines.push_back({"CRAFTER_REVIEW_VERSION", std::format("\\\"{}\\\"", Version())});
{
std::vector<fs::path> interfaces(Interfaces.begin(), Interfaces.end());
std::vector<fs::path> implementations(Implementations.begin(), Implementations.end());
ReviewLib->GetInterfacesAndImplementations(interfaces, implementations);
}
Configuration cfg;
cfg.path = "./";
cfg.name = "crafter.review-exe";
cfg.outputName = "crafter-review";
ApplyStandardArgs(cfg, args);
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ReviewLib.get() };
cfg.compileFlags.emplace_back(NoContraction);
{
std::array<fs::path, 0> interfaces = {};
std::array<fs::path, 1> implementations = { "implementations/main" };
cfg.GetInterfacesAndImplementations(interfaces, implementations);
}
for (const std::string_view test : Tests) {
cfg.AddTest(test).Dependencies({ ReviewLib.get() }).CompileFlag(std::string(NoContraction));
}
LintRules::AddLiteRules(cfg);
cfg.scripts.push_back({"flatpak", BuildFlatpak});
cfg.scripts.push_back({"install", InstallFlatpak});
return cfg;
}