From dd0929b7057f82f49ae832bffb61dfbad46e2b92 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 09:39:53 +0100 Subject: [PATCH] test(run): the store rules, stacks and memory, and the review fixes, check by check StoreRules (23 checks), StacksMemory (24) and ReviewFixes (19), each a test of its own. Run and SlotsPlan now have seams that start as the real ones: appReady, probe, query, bypassesRls, migrationsAtHead, migrationsDrift, treeDrift, promptFor, runPass, resumable, instrumentPreflight, and a Machine for SlotsPlan. MigrationsAtHead is split out of MigrationsDrift for it; the HEAD side is still worked out once per tree and base. The Postgres stand-in and the raw broker call move to tests/Common, shared by PgWire and ReviewFixes. ReviewFixes runs this binary as `sql`, and a real server that resets every connection makes the startup message either fail to go out or never get its answer - a DbError either way. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Review-Run.cpp | 94 +++--- interfaces/Crafter.Review-Run.cppm | 31 +- project.cpp | 5 +- tests/Common/FakePg.hpp | 155 ++++++++++ tests/PgWire/main.cpp | 150 +--------- tests/ReviewFixes/main.cpp | 387 +++++++++++++++++++++++++ tests/StacksMemory/main.cpp | 348 ++++++++++++++++++++++ tests/StoreRules/main.cpp | 343 ++++++++++++++++++++++ 8 files changed, 1328 insertions(+), 185 deletions(-) create mode 100644 tests/Common/FakePg.hpp create mode 100644 tests/ReviewFixes/main.cpp create mode 100644 tests/StacksMemory/main.cpp create mode 100644 tests/StoreRules/main.cpp diff --git a/implementations/Crafter.Review-Run.cpp b/implementations/Crafter.Review-Run.cpp index 2ee647f..d9ca24f 100644 --- a/implementations/Crafter.Review-Run.cpp +++ b/implementations/Crafter.Review-Run.cpp @@ -215,7 +215,8 @@ namespace Crafter::Review::Run { } } - std::pair> SlotsPlan(const Json& cfg, const fs::path& repo, const std::optional& artifact, const std::optional& scratchRoot, const Log& given) { + std::pair> SlotsPlan(const Json& cfg, const fs::path& repo, const std::optional& artifact, const std::optional& scratchRoot, const Log& given, + const Machine& machine) { const Log log = given ? given : Log([](const std::string& line) { std::println("{}", line); }); const Json& sl = cfg.at("slots"); if (!Dyn::Truthy(Dyn::Get(sl, "dir"))) { @@ -244,8 +245,8 @@ namespace Crafter::Review::Run { if (!best || Number(cost.megabytes) > Number(best->megabytes)) best = std::move(cost); } const double passMb = Number(best->megabytes); - const std::optional avail = Memory::Available(); - const std::int64_t reserve = Memory::Reserve(); + const std::optional avail = machine.available ? machine.available() : Memory::Available(); + const std::int64_t reserve = machine.reserve ? machine.reserve() : Memory::Reserve(); const auto upCount = static_cast(up.size()); if (!avail) { log("slots: no /proc/meminfo - cannot size the slots by memory"); @@ -308,6 +309,19 @@ namespace Crafter::Review::Run { // ---- the Run --------------------------------------------------------------------------- Run::Run() : repo(fs::current_path()), t0(Platform::Now()) { log = [this](const std::string& message) { Message(message); }; + appReady = [](std::string_view url, std::string_view path) { return AppReady(url, path); }; + probe = [](std::string_view url) { return PgWire::Probe(url); }; + query = [](std::string_view url, std::string_view sql) { return PgWire::Query(url, sql); }; + bypassesRls = [](std::string_view url) { return PgWire::BypassesRls(url); }; + migrationsAtHead = [this] { return MigrationsAtHead(); }; + migrationsDrift = [this](const std::string& url) { return MigrationsDrift(url); }; + treeDrift = [this] { return TreeDrift(); }; + promptFor = [this](const Json& job) { return PromptFor(job); }; + runPass = [this](const Json& job, const std::string& prompt, const fs::path& systemPath, std::string_view schema, std::int64_t maxTurns, std::int64_t timeoutMin, bool persist) { + return runner->Run(job, prompt, systemPath, schema, trees, maxTurns, timeoutMin, persist); + }; + resumable = [this](const std::string& name, const std::string& lensFile, std::string_view title) { return Resumable(name, lensFile, title); }; + instrumentPreflight = [this](std::span lensFiles) { InstrumentPreflight(lensFiles); }; } Run::Run(Arguments given, Json config) : Run() { @@ -368,7 +382,7 @@ namespace Crafter::Review::Run { for (const std::string& t : trees) { if (!Artifact::IsToplevel(t)) Die(std::format("--trees: {} is not the toplevel of a git checkout", t)); } - if (const std::optional drift = TreeDrift()) Die(std::format("{} - re-freeze, or check out the frozen worktree (freeze --worktree)", *drift)); + if (const std::optional drift = treeDrift()) Die(std::format("{} - re-freeze, or check out the frozen worktree (freeze --worktree)", *drift)); gateDir = Dyn::JoinPath(a.artifact, "gate-state"); fs::create_directories(gateDir); Inventory::Built built; @@ -738,41 +752,45 @@ namespace Crafter::Review::Run { return Dyn::Split(base, "_", 1).front(); } + MigrationsHead Run::MigrationsAtHead() { + constexpr std::string_view M = "supabase/migrations/"; + auto git = [&](std::vector args) { + std::vector argv = {"git", "-C", trees.front()}; + std::ranges::move(args, std::back_inserter(argv)); + return Process::Run(argv).out; + }; + std::map atHead; + for (const std::string& f : Dyn::Split(git({"ls-tree", "--name-only", "HEAD", std::string(M)}))) { + if (const std::optional v = MigrationVersion(f)) atHead[*v] = f; + } + std::vector inRange; + if (!a.base.empty() && !atHead.empty()) { + for (const std::string& f : Dyn::Split(git({"diff", "--name-only", "--diff-filter=AM", std::format("{}..HEAD", a.base), "--", std::string(M)}))) { + if (MigrationVersion(f)) inRange.push_back(f); + } + } + std::map headFp; + for (const std::string& f : inRange) headFp[f] = PgWire::Fingerprint(git({"show", std::format("HEAD:{}", f)})); + return {atHead, inRange, headFp}; + } + std::pair, std::optional> Run::MigrationsDrift(const std::string& url) { // The HEAD side is the same for every stack: worked out once per tree - // and base. + // and base (a tree's HEAD does not move during a run - TreeDrift + // aborts it first). constexpr std::string_view M = "supabase/migrations/"; const std::pair key = {trees.front(), a.base}; - if (!migCache.contains(key)) { - auto git = [&](std::vector args) { - std::vector argv = {"git", "-C", trees.front()}; - std::ranges::move(args, std::back_inserter(argv)); - return Process::Run(argv).out; - }; - std::map atHead; - for (const std::string& f : Dyn::Split(git({"ls-tree", "--name-only", "HEAD", std::string(M)}))) { - if (const std::optional v = MigrationVersion(f)) atHead[*v] = f; - } - std::vector inRange; - if (!a.base.empty() && !atHead.empty()) { - for (const std::string& f : Dyn::Split(git({"diff", "--name-only", "--diff-filter=AM", std::format("{}..HEAD", a.base), "--", std::string(M)}))) { - if (MigrationVersion(f)) inRange.push_back(f); - } - } - std::map headFp; - for (const std::string& f : inRange) headFp[f] = PgWire::Fingerprint(git({"show", std::format("HEAD:{}", f)})); - migCache[key] = {atHead, inRange, headFp}; - } + if (!migCache.contains(key)) migCache[key] = migrationsAtHead(); const auto& [atHead, inRange, headFp] = migCache.at(key); if (atHead.empty()) return {std::nullopt, std::format("no {} at the range HEAD - applied migrations not compared", M)}; std::vector>> rows; try { try { - rows = PgWire::Query(url, "select version, array_to_string(statements, ' ') from supabase_migrations.schema_migrations").second; + rows = query(url, "select version, array_to_string(statements, ' ') from supabase_migrations.schema_migrations").second; } catch (const PgWire::SqlError& e) { // No statements column (an old CLI): versions alone. if (!e.fields.contains('C') || e.fields.at('C') != "42703") throw; - rows = PgWire::Query(url, "select version, null from supabase_migrations.schema_migrations").second; + rows = query(url, "select version, null from supabase_migrations.schema_migrations").second; } } catch (const PgWire::SqlError& e) { const std::string code = e.fields.contains('C') ? e.fields.at('C') : std::string(); @@ -912,7 +930,7 @@ namespace Crafter::Review::Run { if (!app.empty()) apps = stacks->All(); for (const Json& s : apps) { const Json& readyPath = Dyn::Get(fcfg, "readyPath"); - const auto [ok, detail] = AppReady(Dyn::Str(s.at("appUrl")), Dyn::Truthy(readyPath) ? Dyn::Str(readyPath) : std::string("/")); + const auto [ok, detail] = appReady(Dyn::Str(s.at("appUrl")), Dyn::Truthy(readyPath) ? Dyn::Str(readyPath) : std::string("/")); if (!ok) { const Json& how = Dyn::Truthy(Dyn::Get(s, "slot")) ? Dyn::Get(sl, "start") : Dyn::Get(fcfg, "start"); Die(std::format("stacks ({}): {}: the application does not answer ({}) - start it from the frozen tree first{}", who, Dyn::Str(s.at("id")), detail, @@ -924,18 +942,18 @@ namespace Crafter::Review::Run { std::optional note; for (const Json& s : dbs) { const std::string url = Dyn::Str(s.at("dbUrl")); - const auto [ok, detail] = PgWire::Probe(url); + const auto [ok, detail] = probe(url); if (!ok) { const Json& supabaseStart = Dyn::Get(cfg.at("supabase"), "start"); const Json& how = Dyn::Truthy(Dyn::Get(s, "slot")) ? Dyn::Get(sl, "start") : (Dyn::Truthy(supabaseStart) ? supabaseStart : Dyn::Get(fcfg, "start")); Die(std::format("stacks ({}): {}: the database does not answer ({}) - start the stack from the frozen tree first{}", who, Dyn::Str(s.at("id")), detail, Dyn::Truthy(how) ? std::format(" ({})", Dyn::Str(how)) : std::string())); } - const auto [drift, n] = MigrationsDrift(url); + const auto [drift, n] = migrationsDrift(url); if (drift) Die(std::format("stacks ({}): {}: the database: {}", who, Dyn::Str(s.at("id")), *drift)); if (!firstDetail || firstDetail->empty()) firstDetail = detail; if (!note || note->empty()) note = n; - bypass.insert(PgWire::BypassesRls(url)); + bypass.insert(bypassesRls(url)); } if (!dbs.empty()) { dbChecked = dbs.size() == 1 ? std::format("it answers ({}); {}", firstDetail.value_or("None"), note.value_or("None")) @@ -978,7 +996,7 @@ namespace Crafter::Review::Run { } if (email.empty() || PgWire::CheckUrl(Dyn::Get(stack, "dbUrl"))) return std::nullopt; try { - const auto [columns, rows] = PgWire::Query(Dyn::Str(stack.at("dbUrl")), std::format("select 1 from auth.users where lower(email) = '{}'", Dyn::Replace(email, "'", "''"))); + const auto [columns, rows] = query(Dyn::Str(stack.at("dbUrl")), std::format("select 1 from auth.users where lower(email) = '{}'", Dyn::Replace(email, "'", "''"))); return rows.empty() ? std::optional(email) : std::nullopt; } catch (const PgWire::SqlError&) { return std::nullopt; @@ -1283,14 +1301,14 @@ namespace Crafter::Review::Run { abortPool(std::format("artifact changed during the run ({} != {})", now.substr(0, 8), h8)); return {{{"label", job.at("label")}, {"status", "error"}, {"reason", "artifact changed during the run - aborted"}}, nullptr}; } - if (const std::optional drift = TreeDrift()) { + if (const std::optional drift = treeDrift()) { abortPool(std::format("evidence drift during the run ({})", *drift)); return {{{"label", job.at("label")}, {"status", "error"}, {"reason", std::format("{} - aborted", *drift)}}, nullptr}; } - const auto [systemPath, prompt] = PromptFor(job); + const auto [systemPath, prompt] = promptFor(job); const bool whole = job.at("kind") == "whole"; - auto [rec, raw] = runner->Run(job, prompt, systemPath, schemas.at(whole ? "whole" : "block"), trees, Dyn::Has(job, "maxTurns") ? Int(job.at("maxTurns")) : 20, - Dyn::Has(job, "timeoutMin") ? Int(job.at("timeoutMin")) : 20, whole); + auto [rec, raw] = runPass(job, prompt, systemPath, schemas.at(whole ? "whole" : "block"), Dyn::Has(job, "maxTurns") ? Int(job.at("maxTurns")) : 20, + Dyn::Has(job, "timeoutMin") ? Int(job.at("timeoutMin")) : 20, whole); if (Dyn::Truthy(Dyn::Get(job, "stack"))) rec["stack"] = job.at("stack").at("id"); if (Dyn::Truthy(Dyn::Get(rec, "memPeakMb"))) { const Json& configured = Dyn::Get(environments, Dyn::Str(job.at("lensFile"))); @@ -1346,7 +1364,7 @@ namespace Crafter::Review::Run { std::vector resumed; std::vector live; for (const std::string& lf : lensFiles) { - if (std::optional r = Resumable(Plan::LensName(lf), lf, title)) { + if (std::optional r = resumable(Plan::LensName(lf), lf, title)) { resumed.push_back(std::move(*r)); } else { live.push_back(lf); @@ -1356,7 +1374,7 @@ namespace Crafter::Review::Run { // Nothing runs: no stack, database or application is needed for a // result already bought. if (lensFiles.empty()) return resumed; - InstrumentPreflight(lensFiles); + instrumentPreflight(lensFiles); std::map> parts; std::map> missing; std::map> planned; diff --git a/interfaces/Crafter.Review-Run.cppm b/interfaces/Crafter.Review-Run.cppm index cdaa28f..bb850b4 100644 --- a/interfaces/Crafter.Review-Run.cppm +++ b/interfaces/Crafter.Review-Run.cppm @@ -76,11 +76,21 @@ export namespace Crafter::Review::Run { // Below 500 is ok. std::pair AppReady(std::string_view url, std::string_view path = "/", double timeout = 10); + // The machine's memory as SlotsPlan reads it (the tests give their own). + struct Machine { + std::function()> available; + std::function reserve; + }; // `slots`: how many stacks to bring up - as many as free memory holds, // and no more than the range's browser passes can use at once - and the // commands for the ones not up yet. (want, commands). std::pair> SlotsPlan(const Json& cfg, const std::filesystem::path& repo, const std::optional& artifact = std::nullopt, - const std::optional& scratchRoot = std::nullopt, const Log& log = {}); + const std::optional& scratchRoot = std::nullopt, const Log& log = {}, const Machine& machine = {}); + + // ({version: path} of the migrations at the range HEAD, the range's added + // or changed migrations, {path: fingerprint at HEAD} of those). + using MigrationsHead = std::tuple, std::vector, std::map>; + using QueryResult = std::pair, std::vector>>>; class Run { public: @@ -108,6 +118,7 @@ export namespace Crafter::Review::Run { std::map DbLenses(std::span lensFiles) const; void InstrumentPreflight(std::span lensFiles); static std::optional MigrationVersion(std::string_view file); + MigrationsHead MigrationsAtHead(); // (problem, note): the migrations at the range HEAD against the ones // the database applied. std::pair, std::optional> MigrationsDrift(const std::string& url); @@ -226,10 +237,26 @@ export namespace Crafter::Review::Run { std::vector seats; std::map envMarkers; std::set> envSkips; - std::map, std::tuple, std::vector, std::map>> migCache; + std::map, MigrationsHead> migCache; // The log every line goes to (the tests put their own here). Log log; + // ---- the seams a test replaces --------------------------------------------- + // Each starts as the real one; the methods call through these. + std::function(std::string_view url, std::string_view path)> appReady; + std::function(std::string_view url)> probe; + std::function query; + std::function(std::string_view url)> bypassesRls; + std::function migrationsAtHead; + std::function, std::optional>(const std::string& url)> migrationsDrift; + std::function()> treeDrift; + std::function(const Json& job)> promptFor; + std::function(const Json& job, const std::string& prompt, const std::filesystem::path& systemPath, std::string_view schema, std::int64_t maxTurns, std::int64_t timeoutMin, + bool persist)> + runPass; + std::function(const std::string& name, const std::string& lensFile, std::string_view title)> resumable; + std::function lensFiles)> instrumentPreflight; + private: // What exec_job's threads share with the pool's: the system files, // the lens layers, the jobs run. diff --git a/project.cpp b/project.cpp index 3b3f927..16cf4a9 100644 --- a/project.cpp +++ b/project.cpp @@ -84,7 +84,7 @@ namespace { // One per directory under tests/, each an ordinary main() that links the // library: exit 0 passes, nonzero fails, 77 skips. - constexpr std::array Tests = { + constexpr std::array Tests = { "Crypto", "Text", "Platform", @@ -105,6 +105,9 @@ namespace { "OpenCode", "Runner", "Scheduler", + "StoreRules", + "StacksMemory", + "ReviewFixes", }; // The version the binary reports: CI sets it from the tag, a checkout diff --git a/tests/Common/FakePg.hpp b/tests/Common/FakePg.hpp new file mode 100644 index 0000000..fcf7271 --- /dev/null +++ b/tests/Common/FakePg.hpp @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A Postgres stand-in on 127.0.0.1 for the tests that talk the wire protocol +// (PgWire, ReviewFixes), and a raw call of the broker. Included after +// `import std;`, `import Crafter.Review;` and the socket headers. +namespace Testing { + std::string Be32(std::uint32_t v) { + return {static_cast(v >> 24), static_cast((v >> 16) & 0xFF), static_cast((v >> 8) & 0xFF), static_cast(v & 0xFF)}; + } + + std::string Msg(char type, const std::string& body = {}) { + return std::format("{}{}{}", type, Be32(static_cast(body.size() + 4)), body); + } + + std::string Err(const std::string& code, const std::string& text) { + return Msg('E', std::format("SERROR{}C{}{}M{}{}{}", std::string(1, '\0'), code, std::string(1, '\0'), text, std::string(1, '\0'), std::string(1, '\0'))); + } + + // A raw call of the broker on 127.0.0.1: the bytes as they are, the + // write side shut, everything that comes back. + std::string BrokerCall(std::uint16_t port, std::string_view bytes) { + const std::int32_t s = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(port); + connect(s, reinterpret_cast(&address), sizeof(address)); + ::send(s, bytes.data(), bytes.size(), MSG_NOSIGNAL); + shutdown(s, SHUT_WR); + std::string all; + std::array chunk {}; + ssize_t got = 0; + while ((got = recv(s, chunk.data(), chunk.size(), 0)) > 0) all.append(chunk.data(), static_cast(got)); + close(s); + return all; + } + + // A Postgres stand-in on 127.0.0.1 for one connection: cleartext + // password, simple Query, Parse/Bind/Describe/Execute/Sync. It records + // what it is sent and answers as the server does where the helper relies + // on it: two commands in one Parse are an error; COMMIT ends the + // transaction (status I); a statement after an error fails. + class FakePg { + public: + explicit FakePg(std::string password = "pw") : password(std::move(password)) { + listener = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bind(listener, reinterpret_cast(&address), sizeof(address)); + listen(listener, 1); + socklen_t length = sizeof(address); + getsockname(listener, reinterpret_cast(&address), &length); + port = ntohs(address.sin_port); + thread = std::thread([this] { Serve(); }); + } + + ~FakePg() { + Served(); + } + + // The log once the connection is over. + std::vector> Served() { + if (thread.joinable()) thread.join(); + return log; + } + + std::uint16_t port = 0; + + private: + std::string password; + std::int32_t listener = -1; + std::thread thread; + std::vector> log; + + void Serve() { + const std::int32_t c = accept(listener, nullptr, nullptr); + std::string buffer; + auto read = [&](std::size_t n) { + while (buffer.size() < n) { + std::array chunk {}; + const ssize_t got = recv(c, chunk.data(), chunk.size(), 0); + if (got <= 0) break; + buffer.append(chunk.data(), static_cast(got)); + } + const std::string out = buffer.substr(0, std::min(n, buffer.size())); + buffer.erase(0, out.size()); + return out; + }; + auto length = [](const std::string& bytes) { + return (static_cast(static_cast(bytes[0])) << 24) | (static_cast(static_cast(bytes[1])) << 16) + | (static_cast(static_cast(bytes[2])) << 8) | static_cast(bytes[3]); + }; + auto send = [&](const std::string& data) { ::send(c, data.data(), data.size(), MSG_NOSIGNAL); }; + const std::uint32_t startup = length(read(4)); + read(startup - 4); + send(Msg('R', Be32(3))); + read(1); + std::string got = read(length(read(4)) - 4); + while (!got.empty() && got.back() == '\0') got.pop_back(); + log.emplace_back("password", got); + if (got != password) { + send(Err("28P01", "password authentication failed")); + close(c); + close(listener); + return; + } + send(Msg('S', std::string("server_version\x00" "17.6\x00", 20)) + Msg('R', Be32(0)) + Msg('Z', "I")); + std::string status = "I"; + std::string pending; + while (true) { + const std::string type = read(1); + if (type.empty()) break; + const std::string body = read(length(read(4)) - 4); + if (type == "Q") { + std::string q = body; + while (!q.empty() && q.back() == '\0') q.pop_back(); + log.emplace_back("Q", q); + status = q.starts_with("BEGIN") ? "T" : (q == "ROLLBACK" ? "I" : status); + send(Msg('C', Dyn::Split(q)[0] + std::string(1, '\0')) + Msg('Z', status)); + } else if (type == "P") { + pending = body.substr(1, body.find('\0', 1) - 1); + log.emplace_back("P", pending); + } else if (type == "S") { + const std::string low = Dyn::Lower(pending); + std::string out; + if (status == "E") { + out = Err("25P02", "current transaction is aborted"); + } else if (pending.contains(';')) { + out = Err("42601", "cannot insert multiple commands into a prepared statement"); + status = "E"; + } else if (low.starts_with("commit")) { + out = Msg('C', std::string("COMMIT\0", 7)); + status = "I"; + } else if (low.starts_with("select")) { + out = Msg('T', std::string("\x00\x01x\x00", 4) + std::string(18, '\0')) + Msg('D', std::string("\x00\x01\x00\x00\x00\x01" "1", 7)) + + Msg('N', std::string("SNOTICE\0Mhello\0\0", 16)) + Msg('C', std::string("SELECT 1\0", 9)); + } else if (low.starts_with("boom")) { + out = Err("42601", "syntax error at or near \"boom\""); + status = "E"; + } else { + out = Msg('C', Dyn::Upper(Dyn::Split(pending)[0]) + std::string(1, '\0')); + } + send(Msg('1') + Msg('2') + out + Msg('Z', status)); + } else if (type == "X") { + log.emplace_back("X", ""); + break; + } + } + close(c); + close(listener); + } + }; +} diff --git a/tests/PgWire/main.cpp b/tests/PgWire/main.cpp index d98f8b5..b75b167 100644 --- a/tests/PgWire/main.cpp +++ b/tests/PgWire/main.cpp @@ -2,8 +2,8 @@ // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // The SQL helper: its statements, refusals, the URL, SCRAM, the protocol on a -// stand-in server (FakePg, below). Then what the helper prints over the same -// stand-in, line for line against the goldens (fixture/pipeline.json, +// stand-in server (FakePg, tests/Common). Then what the helper prints over +// the same stand-in, line for line against the goldens (fixture/pipeline.json, // `runs`), and the broker: the URL stays in the driver, a pass gets an // address and a token. @@ -16,6 +16,9 @@ import Crafter.Review; namespace fs = std::filesystem; using namespace Crafter::Review; +#include "../Common/FakePg.hpp" +using namespace Testing; + namespace { std::int32_t Failures = 0; @@ -24,134 +27,6 @@ namespace { if (!cond) ++Failures; } - std::string Be32(std::uint32_t v) { - return {static_cast(v >> 24), static_cast((v >> 16) & 0xFF), static_cast((v >> 8) & 0xFF), static_cast(v & 0xFF)}; - } - - std::string Msg(char type, const std::string& body = {}) { - return std::format("{}{}{}", type, Be32(static_cast(body.size() + 4)), body); - } - - std::string Err(const std::string& code, const std::string& text) { - return Msg('E', std::format("SERROR{}C{}{}M{}{}{}", std::string(1, '\0'), code, std::string(1, '\0'), text, std::string(1, '\0'), std::string(1, '\0'))); - } - - // A Postgres stand-in on 127.0.0.1 for one connection: cleartext - // password, simple Query, Parse/Bind/Describe/Execute/Sync. It records - // what it is sent and answers as the server does where the helper relies - // on it: two commands in one Parse are an error; COMMIT ends the - // transaction (status I); a statement after an error fails. - class FakePg { - public: - explicit FakePg(std::string password = "pw") : password(std::move(password)) { - listener = socket(AF_INET, SOCK_STREAM, 0); - sockaddr_in address {}; - address.sin_family = AF_INET; - address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - bind(listener, reinterpret_cast(&address), sizeof(address)); - listen(listener, 1); - socklen_t length = sizeof(address); - getsockname(listener, reinterpret_cast(&address), &length); - port = ntohs(address.sin_port); - thread = std::thread([this] { Serve(); }); - } - - ~FakePg() { - Served(); - } - - // The log once the connection is over. - std::vector> Served() { - if (thread.joinable()) thread.join(); - return log; - } - - std::uint16_t port = 0; - - private: - std::string password; - std::int32_t listener = -1; - std::thread thread; - std::vector> log; - - void Serve() { - const std::int32_t c = accept(listener, nullptr, nullptr); - std::string buffer; - auto read = [&](std::size_t n) { - while (buffer.size() < n) { - std::array chunk {}; - const ssize_t got = recv(c, chunk.data(), chunk.size(), 0); - if (got <= 0) break; - buffer.append(chunk.data(), static_cast(got)); - } - const std::string out = buffer.substr(0, std::min(n, buffer.size())); - buffer.erase(0, out.size()); - return out; - }; - auto length = [](const std::string& bytes) { - return (static_cast(static_cast(bytes[0])) << 24) | (static_cast(static_cast(bytes[1])) << 16) - | (static_cast(static_cast(bytes[2])) << 8) | static_cast(bytes[3]); - }; - auto send = [&](const std::string& data) { ::send(c, data.data(), data.size(), MSG_NOSIGNAL); }; - const std::uint32_t startup = length(read(4)); - read(startup - 4); - send(Msg('R', Be32(3))); - read(1); - std::string got = read(length(read(4)) - 4); - while (!got.empty() && got.back() == '\0') got.pop_back(); - log.emplace_back("password", got); - if (got != password) { - send(Err("28P01", "password authentication failed")); - close(c); - close(listener); - return; - } - send(Msg('S', std::string("server_version\x00" "17.6\x00", 20)) + Msg('R', Be32(0)) + Msg('Z', "I")); - std::string status = "I"; - std::string pending; - while (true) { - const std::string type = read(1); - if (type.empty()) break; - const std::string body = read(length(read(4)) - 4); - if (type == "Q") { - std::string q = body; - while (!q.empty() && q.back() == '\0') q.pop_back(); - log.emplace_back("Q", q); - status = q.starts_with("BEGIN") ? "T" : (q == "ROLLBACK" ? "I" : status); - send(Msg('C', Dyn::Split(q)[0] + std::string(1, '\0')) + Msg('Z', status)); - } else if (type == "P") { - pending = body.substr(1, body.find('\0', 1) - 1); - log.emplace_back("P", pending); - } else if (type == "S") { - const std::string low = Dyn::Lower(pending); - std::string out; - if (status == "E") { - out = Err("25P02", "current transaction is aborted"); - } else if (pending.contains(';')) { - out = Err("42601", "cannot insert multiple commands into a prepared statement"); - status = "E"; - } else if (low.starts_with("commit")) { - out = Msg('C', std::string("COMMIT\0", 7)); - status = "I"; - } else if (low.starts_with("select")) { - out = Msg('T', std::string("\x00\x01x\x00", 4) + std::string(18, '\0')) + Msg('D', std::string("\x00\x01\x00\x00\x00\x01" "1", 7)) - + Msg('N', std::string("SNOTICE\0Mhello\0\0", 16)) + Msg('C', std::string("SELECT 1\0", 9)); - } else if (low.starts_with("boom")) { - out = Err("42601", "syntax error at or near \"boom\""); - status = "E"; - } else { - out = Msg('C', Dyn::Upper(Dyn::Split(pending)[0]) + std::string(1, '\0')); - } - send(Msg('1') + Msg('2') + out + Msg('Z', status)); - } else if (type == "X") { - log.emplace_back("X", ""); - break; - } - } - close(c); - close(listener); - } - }; std::uint16_t DeadPort() { const std::int32_t s = socket(AF_INET, SOCK_STREAM, 0); @@ -185,20 +60,7 @@ namespace { // A raw call of the broker: one line in, every line back. std::vector RawCall(std::uint16_t port, const std::string& line) { - const std::int32_t s = socket(AF_INET, SOCK_STREAM, 0); - sockaddr_in address {}; - address.sin_family = AF_INET; - address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - address.sin_port = htons(port); - connect(s, reinterpret_cast(&address), sizeof(address)); - ::send(s, line.data(), line.size(), MSG_NOSIGNAL); - shutdown(s, SHUT_WR); - std::string all; - std::array chunk {}; - ssize_t got = 0; - while ((got = recv(s, chunk.data(), chunk.size(), 0)) > 0) all.append(chunk.data(), static_cast(got)); - close(s); - return Dyn::Split(Dyn::RStrip(all, "\n"), "\n"); + return Dyn::Split(Dyn::RStrip(BrokerCall(port, line), "\n"), "\n"); } } diff --git a/tests/ReviewFixes/main.cpp b/tests/ReviewFixes/main.cpp new file mode 100644 index 0000000..9e3eee6 --- /dev/null +++ b/tests/ReviewFixes/main.cpp @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The fixes of the 2026-10-22 code review: the broker, the splitter, the +// connection, the preflight, the calibration, the sampler, the resume, the +// template. The SQL helper is this binary run as `sql`; app_ready, the +// database calls and the methods of Run are replaced through the Run's +// seams. + +#include +#include +#include +#include +import std; +import Crafter.Review; +namespace fs = std::filesystem; +using namespace Crafter::Review; + +#include "../Common/FakePg.hpp" +using namespace Testing; + +namespace { + std::int32_t Failures = 0; + const std::string Fn(Plan::FunctionalLens); + + void Check(std::string_view name, bool cond, std::string_view detail = {}) { + std::println("{}{}{}", cond ? " ok " : " FAIL ", name, !cond && !detail.empty() ? std::format(" ({})", detail) : std::string()); + if (!cond) ++Failures; + } + + void Write(const fs::path& path, std::string_view text) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; + } + + std::string Show(const std::vector& lines) { + Json out = Json::array(); + for (const std::string& line : lines) out.push_back(line); + return Dyn::ReprValue(out); + } + + // A server on 127.0.0.1 that resets every connection it takes (SO_LINGER + // 0, then close) until it goes. A real reset races the startup message, + // so either it does not go out or its answer never comes. + class Resetter { + public: + Resetter() { + listener = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bind(listener, reinterpret_cast(&address), sizeof(address)); + listen(listener, 8); + socklen_t length = sizeof(address); + getsockname(listener, reinterpret_cast(&address), &length); + port = ntohs(address.sin_port); + thread = std::thread([this] { + while (true) { + const std::int32_t c = accept(listener, nullptr, nullptr); + if (c < 0) break; + const linger hard {1, 0}; + setsockopt(c, SOL_SOCKET, SO_LINGER, &hard, sizeof(hard)); + close(c); + } + }); + } + + ~Resetter() { + // A shut listening socket ends the accept that waits on it. + shutdown(listener, SHUT_RDWR); + thread.join(); + close(listener); + } + + std::uint16_t port = 0; + + private: + std::int32_t listener = -1; + std::thread thread; + }; + + bool Reset(std::string_view text) { + return text.contains("Broken pipe") || text.contains("Connection reset"); + } + + std::int32_t Checks() { + std::println("the 2026-10-22 code review: the broker, the splitter, the connection, the preflight, the calibration, the sampler, the resume, the template"); + Platform::SetMode(Platform::Mode::Native); + SetHome(fs::current_path()); + const fs::path base = fs::temp_directory_path() / std::format("crafter-review-fixes-{}", Platform::PublishedPid()); + fs::remove_all(base); + // finding 8: a SQL-standard routine body stays whole; ROLLBACK [WORK | + // TRANSACTION] TO goes through. + const std::string atomic = "create function f(x int) returns int language sql begin atomic select case when x > 0 then 1 else 0 end; select 2; end; " + "create or replace procedure p() language sql begin atomic insert into t values (1); end; select 3; " + "create function g() returns int language plpgsql as $$ begin return 1; end $$; select (case when true then 1 end); begin"; + const std::vector parts = PgWire::SplitStatements(atomic); + Check("split: a SQL-standard routine body (BEGIN ATOMIC ... END, a CASE ... END inside) stays one statement, CREATE OR REPLACE PROCEDURE too; " + "a plpgsql $$ body, a CASE outside a routine and a bare BEGIN cut as before (finding 8)", + parts == std::vector{"create function f(x int) returns int language sql begin atomic select case when x > 0 then 1 else 0 end; select 2; end", + "create or replace procedure p() language sql begin atomic insert into t values (1); end", "select 3", + "create function g() returns int language plpgsql as $$ begin return 1; end $$", "select (case when true then 1 end)", "begin"}, + Show(parts)); + Check("split: the words are counted per statement - a BEGIN after a CREATE TABLE is no routine body; an E-string after a word is still an E-string", + PgWire::SplitStatements(R"(create table t (a int); begin; select E'it\'s; here'; select 1)") + == std::vector{"create table t (a int)", "begin", R"(select E'it\'s; here')", "select 1"}); + const std::vector okRb = {"rollback to s", "rollback to savepoint s", "rollback work to s", "ROLLBACK TRANSACTION TO SAVEPOINT s"}; + const std::vector noRb = {"rollback", "rollback work", "rollback transaction", "rollback and chain", "rollback prepared 'x'", "rollback work and no chain"}; + std::vector verdicts; + for (const auto& list : {okRb, noRb}) { + for (const std::string& s : list) verdicts.push_back(std::format("{}: {}", s, PgWire::Refused(s).value_or("None"))); + } + Check("refused: ROLLBACK [WORK | TRANSACTION] TO [SAVEPOINT] goes through and every other ROLLBACK is refused; the BEGIN ATOMIC routine is not refused", + std::ranges::all_of(okRb, [](const std::string& s) { return !PgWire::Refused(s); }) && std::ranges::all_of(noRb, [](const std::string& s) { return PgWire::Refused(s).has_value(); }) + && !PgWire::Refused(parts.at(0)), + Show(verdicts)); + + // finding 7: nothing but a DbError leaves a connection, whatever + // breaks during the startup message. + { + const Resetter rst; + const std::string url0 = std::format("postgresql://u:p@127.0.0.1:{}/d", rst.port); + std::optional raised; + try { + PgWire::Connection connection(url0); + } catch (const PgWire::DbError& error) { + raised = error.what(); + } + std::vector out; + const std::int32_t code = PgWire::RunSql(url0, "select 1", [&](const std::string& line) { out.push_back(line); }, + [](std::string_view u) { return PgWire::Connect(u, 1, [](double) {}); }); + const auto probe0 = PgWire::Probe(url0); + Check("connection: a reset while the startup message goes out is a DbError, never an OSError - the helper says NO DATABASE (exit 2), the probe that it does not answer (finding 7)", + raised && Reset(*raised) && code == PgWire::ExitNoDb && !out.empty() && out[0].starts_with(PgWire::NoDatabase) && !probe0.first, + std::format("{} | {} | {}", raised.value_or("None"), Show(out), probe0.second)); + } + { + const Resetter srv; + std::optional reset; + try { + PgWire::Connect(std::format("postgresql://u:p@127.0.0.1:{}/d", srv.port), 1, [](double) {}); + } catch (const PgWire::DbError& error) { + reset = error.what(); + } + Check("connection: a server that resets the connection at once is a DbError too", reset.has_value(), reset.value_or("None")); + } + + // finding 6: the broker - the URL stays in the driver, the helper + // reaches the database through it. + { + FakePg pg; + const std::string url = std::format("postgresql://u:pw@127.0.0.1:{}/db", pg.port); + PgWire::Broker br; + br.Start(); + const std::string addr = br.Address(url); + std::map envb = Platform::Environment(); + envb.erase(std::string(PgWire::BrokerEnv)); + envb.erase(std::string(PgWire::UrlEnv)); + auto sh = [&](std::vector args, const std::optional& broker, std::string_view stdinText = {}) { + args.insert(args.begin(), {Platform::SelfExe().string(), "sql"}); + std::map env = envb; + if (broker) env[std::string(PgWire::BrokerEnv)] = *broker; + return Process::Run(args, std::nullopt, std::chrono::seconds(60), stdinText, env); + }; + const Process::Captured r1 = sh({}, addr, "select 1;\n"); + const std::vector> served = pg.Served(); + const Process::Captured r2 = sh({"select 1"}, std::format("{}#{}", addr.substr(0, addr.rfind('#')), std::string(32, '0'))); + const Process::Captured r3 = sh({"select 1"}, std::nullopt); + const Process::Captured r4 = sh({}, addr, "commit;\n"); + br.Close(); + const Process::Captured r5 = sh({"select 1"}, addr); + auto has = [&](const std::pair& entry) { return std::ranges::find(served, entry) != served.end(); }; + Check("broker: the helper's SQL runs through the driver - connected, the rows, rolled back, exit 0 - with only the broker's address and a token in the pass's environment (finding 6)", + r1.returnCode == 0 && r1.out.contains(PgWire::Answered) && r1.out.contains("\n x\n") && r1.out.contains("-- rolled back") && !addr.contains("pw") && has({"password", "pw"}) + && has({"Q", "ROLLBACK"}), + r1.out + r1.err); + Check("broker: a foreign token, no broker variable and a broker that is gone are NO DATABASE (exit 2); a refused statement is refused through it as before (exit 1, nothing connects)", + r2.returnCode == 2 && r2.out.contains("no database by this token") && r3.returnCode == 2 && r3.out.contains("this pass has no database") && r4.returnCode == 1 + && r4.out.contains("refused, nothing ran") && r5.returnCode == 2 && r5.out.contains("does not answer"), + Show({r2.out, r3.out, r4.out, r5.out})); + PgWire::Broker bp({}, [&](const std::string& u) { return std::pair(u == url, "db on 127.0.0.1 as u"); }); + bp.Start(); + const std::string probeAddr = bp.Address(url); + const Process::Captured r6 = sh({"--probe"}, probeAddr); + const std::size_t colon = probeAddr.rfind(':'); + const auto port = static_cast(std::stoi(probeAddr.substr(colon + 1, probeAddr.rfind('#') - colon - 1))); + // No newline: the call was cut off. + const std::string cut = BrokerCall(port, R"({"token": "x", "sql": "select 1"})"); + bp.Close(); + Check("broker: --probe goes through it too; a call that does not arrive whole runs nothing (exit 64)", + r6.returnCode == 0 && r6.out.starts_with(PgWire::Answered) && cut.contains("did not arrive whole") && Dyn::RStrip(cut).ends_with(std::format("{}{}", PgWire::ExitMark, PgWire::ExitUsage)), + Dyn::ReprValue(Json::array({r6.out, cut}))); + } + + // finding 4: the calibration is a record, never a dependency. + { + const fs::path td = base / "calibration"; + const fs::path blocker = td / "file"; + Write(blocker, "x"); + // Its directory is a file. + const bool wrote = Memory::Calibration(blocker / "memory.json", td, {{"none", 210}}).Record("none", 500.0); + const fs::path mp = td / "memory.json"; + Memory::Calibration cal(mp, td, {{"none", 210}, {"browser", 840}}); + const std::string me = fs::canonical(td).string(); + Json listed = Json::object(); + listed[me] = Json::array({5, 6}); + Json mixed = Json::object(); + mixed[me] = {{"browser", "x"}, {"none", Json::array({true, -3, "a", 300})}}; + Json shapes = Json::array(); + for (const std::string& content : {std::string("[1, 2]"), DumpSpaced(listed), DumpSpaced(mixed), std::string("{not json"), std::string("\xff\xfe\x00", 3)}) { + Write(mp, content); + shapes.push_back(Json::array({cal.CostOf("browser").megabytes, cal.CostOf("none").megabytes})); + } + const bool repaired = cal.Record("browser", 700.0); + Check("calibration: a peak that cannot be written is dropped, never raised - a finished pass is never thrown away for bookkeeping (finding 4)", !wrote); + Check("calibration: a damaged file, or a part of the wrong shape, reads as absent - the measured defaults, the sound peaks kept - and the next record repairs it", + shapes == Json::parse("[[840, 210], [840, 210], [840, 300], [840, 210], [840, 210]]") && repaired && cal.CostOf("browser").megabytes == 700.0, Dyn::ReprValue(shapes)); + } + + // finding 11: the sampler - one process scan a sweep, a pause that + // grows with the sweep. + { + const std::map> kids = {{1, {2, 3}}, {2, {4}}, {9, {10}}}; + std::vector tree = Platform::Tree(1, kids); + std::ranges::sort(tree); + const auto ppids = Platform::PpidMap(); + const auto mine = ppids.find(getppid()); + Check("sampler: a tree from one ppid map (no per-thread children files); the pause is the interval, or three times a sweep that took longer (finding 11)", + tree == std::vector{1, 2, 3, 4} && Platform::Tree(9, kids) == std::vector{9, 10} && Memory::Sampler::Pause(3.0, 0.2) == 3.0 + && Memory::Sampler::Pause(3.0, 2.5) == 7.5 && mine != ppids.end() && std::ranges::find(mine->second, getpid()) != mine->second.end()); + std::atomic scans = 0; + Memory::Sampler sm(3600, [](std::int64_t, const std::map>&) { return 1.0; }, [&] { + ++scans; + return std::map>(); + }); + for (std::int64_t i = 0; i < 5; ++i) sm.Watch(std::to_string(i), i); + const std::int32_t launched = scans; + sm.Sample(); + Check("sampler: one scan per sweep whatever the number of passes, and none on a launch", launched == 0 && scans == 1 && sm.Done("3") == 1.0); + } + + // findings 1, 5, 9, 12: the preflight. + { + const fs::path td = base / "preflight"; + Run::Run g; + std::vector logs; + g.repo = td; + g.log = [&](const std::string& line) { logs.push_back(line); }; + g.cfg = {{"functional", {{"start", "make run"}, {"readyPath", "/"}, {"exclude", Json::array()}}}, + {"supabase", {{"dbUrl", "postgresql://postgres:postgres@127.0.0.1:54322/postgres"}, {"start", nullptr}}}, + {"slots", {{"dir", ".local-stack/slots"}, {"start", "up --slot {n}"}, {"stop", nullptr}, {"memMb", 1500}}}}; + g.a.dryRun = false; + g.a.harness = "claude"; + g.a.base = ""; + g.slotProblems.clear(); + g.loginFixture = std::nullopt; + g.trees = {td.string()}; + Write(td / "l" / "u.json", R"({"email": "a@x.test", "password": "p"})"); + auto slot = [](std::int32_t n, bool db = true, std::string_view user = "postgres", bool isSlot = true) { + return Json {{"id", std::format("slot {}", n)}, {"appUrl", std::format("http://localhost:2{}180", n)}, {"apiUrls", Json::array()}, {"login", "l/u.json"}, {"slot", isSlot}, + {"dbUrl", db ? Json(std::format("postgresql://{}:postgres@127.0.0.1:2{}122/postgres", user, n)) : Json(nullptr)}}; + }; + auto pre = [&](const std::vector& lenses, const Json& envs, const std::vector& stacks, bool slotMode = true) -> std::pair { + g.environments = envs; + g.stacks = std::make_unique(stacks); + g.slotMode = slotMode; + try { + g.StackPreflight(lenses); + return {"ok", logs.empty() ? std::string() : logs.back()}; + } catch (const Run::Preflight& e) { + return {"3", std::format("preflight: {}\n", e.what())}; + } + }; + std::vector> queried; + std::int32_t migCalls = 0; + const Run::QueryResult::second_type users = {{"x"}}; + g.appReady = [](std::string_view u, std::string_view) { return std::pair(true, std::format("HTTP 200 from {}", u)); }; + g.probe = [](std::string_view) { return std::pair(true, "postgres on 127.0.0.1 - PostgreSQL 17.6"); }; + g.query = [&](std::string_view u, std::string_view sql) { + queried.emplace_back(u, sql); + return Run::QueryResult({"?"}, users); + }; + g.bypassesRls = [](std::string_view u) { return std::optional(!u.contains("admin")); }; + g.migrationsAtHead = [&] { + ++migCalls; + return Run::MigrationsHead(); + }; + const Json bs = {{Fn, "browser+supabase"}, {"repo-bug-prover.md", "supabase"}}; + const Json browser = {{Fn, "browser"}}; + const auto nodbApp = pre({Fn}, bs, {slot(1), slot(2, false)}); + const auto nodbFirst = pre({"repo-bug-prover.md"}, bs, {slot(1, false), slot(2)}); + const auto nodbBrowser = pre({Fn}, browser, {slot(1), slot(2, false)}); + queried.clear(); + const auto implicit = pre({Fn}, browser, {slot(1, true, "postgres", false)}, false); + const auto implicitQ = queried; + const auto implicitDb = pre({Fn}, bs, {slot(1, true, "postgres", false)}, false); + const bool implicitDbQ = std::ranges::any_of(queried, [](const auto& q) { return q.second.contains("auth.users"); }); + const auto mixed = pre({Fn}, bs, {slot(1), slot(2, true, "admin")}); + const auto mixedRole = g.dbRole; + const auto mixedBypass = g.dbBypass; + g.migCache.clear(); + migCalls = 0; + const auto three = pre({Fn}, bs, {slot(1), slot(2), slot(3)}); + const auto threeRole = g.dbRole; + const auto threeChecked = g.dbChecked; + const std::int32_t threeMigs = migCalls; + const auto one = pre({"repo-bug-prover.md"}, bs, {slot(1), slot(2)}); + const auto oneChecked = g.dbChecked; + Check("preflight: a stack without a database is refused for a lens that would run SQL there - any slot for browser+supabase, the first for supabase alone - " + "and is fine for a lens without the database (finding 1)", + nodbApp.first == "3" && nodbApp.second.contains("no database for slot 2 (no dbUrl") && nodbFirst.first == "3" && nodbFirst.second.contains("no database for slot 1 (no dbUrl") + && nodbBrowser.first == "ok", + Show({nodbApp.second, nodbFirst.second, nodbBrowser.second})); + Check("preflight: the config's one stack, with a browser lens alone, checks no login in a database nobody named (the default URL may be another project's); " + "with a lens that uses its database it does (finding 5)", + implicit.first == "ok" && std::ranges::none_of(implicitQ, [](const auto& q) { return q.second.contains("auth.users"); }) && implicitDb.first == "ok" && implicitDbQ, + Show({implicit.second, implicitDb.second})); + Check("preflight: the role the block names is the stacks' own, none when they differ; 'each of the 3 stacks' only when 3 were probed, 'it answers' for the one " + "a lens with the database alone uses (finding 9)", + mixed.first == "ok" && !mixedRole && !mixedBypass && three.first == "ok" && threeRole == "postgres" && threeChecked + && threeChecked->starts_with("the database of each of the 3 stacks answers") && one.first == "ok" && oneChecked && oneChecked->starts_with("it answers ("), + Show({mixedRole.value_or("None"), threeChecked.value_or("None"), oneChecked.value_or("None")})); + Check("preflight: the HEAD side of the migrations check is worked out once for all the stacks (finding 12)", threeMigs == 1, std::to_string(threeMigs)); + } + + // finding 10: a gate whose every lens resumes needs no stack. + { + Run::Run g; + std::vector> calls; + std::vector logs; + g.log = [&](const std::string& line) { logs.push_back(line); }; + g.resumable = [](const std::string& name, const std::string&, std::string_view) { return std::optional(Json {{"lens", name}, {"verdict", "SEND-READY"}}); }; + g.instrumentPreflight = [&](std::span lfs) { calls.emplace_back(lfs.begin(), lfs.end()); }; + const std::vector res = g.RunGate("Gate 2 — Functional", {Fn}); + const auto resumedCalls = calls; + g.resumable = [](const std::string&, const std::string&, std::string_view) { return std::optional(); }; + g.instrumentPreflight = [&](std::span lfs) { + calls.emplace_back(lfs.begin(), lfs.end()); + throw std::runtime_error("stop here"); + }; + try { + g.RunGate("Gate 2 — Functional", {Fn}); + } catch (const std::runtime_error&) { + } + Check("run_gate: a gate whose every lens resumes from the checkpoint runs no preflight - nothing will touch the stack; a lens that runs gets it (finding 10)", + resumedCalls.empty() && res.size() == 1 && Dyn::Equal(res[0], {{"lens", "repo-functional"}, {"verdict", "SEND-READY"}}) && calls == std::vector>{{Fn}}, + std::format("{} resumed call(s), {} call(s)", resumedCalls.size(), calls.size())); + } + + // finding 13: the block states :PgWire's own limits. They are + // constexpr and the block formats them, so the check pins the text it + // formats and today's values. + { + const std::string b1 = Prompt::DbBlock(Json::object(), "c", std::string("postgres")); + const std::string limits = + std::format("statement_timeout {} s and lock_timeout {} s per statement; at most {} rows", PgWire::StatementTimeoutS, PgWire::LockTimeoutS, PgWire::MaxRows); + const std::string differs = Prompt::DbBlock(Json::object(), "c", std::nullopt); + Check("db block: the limits are pgwire's own constants, so the prompt and the helper cannot drift apart (finding 13); a role that differs between stacks is not named", + b1.contains("statement_timeout 20 s and lock_timeout 3 s per statement; at most 100 rows") && b1.contains(limits) && differs.contains("differs between the stacks") + && !differs.contains("`None`")); + } + + // finding 2: the template loads the way a consumer meets it. + { + const fs::path td = base / "template"; + fs::create_directories(td / ".review-gates"); + fs::copy_file(fs::current_path() / "process" / "templates" / "config.json", td / ".review-gates" / "config.json"); + const Config::Loaded loaded = Config::Load(td); + Check("template: process/templates/config.json, copied as the README says, loads with no problem and no warning - unknown keys included (finding 2)", + loaded.problems.empty() && loaded.config.at("_warnings").empty(), Show(loaded.problems)); + } + fs::remove_all(base); + return Failures ? 1 : 0; + } +} + +int main(int argc, char** argv) { + const std::vector args(argv, argv + argc); + // The helper a lens runs, for the broker checks. + if (args.size() > 1 && args[1] == "sql") return PgWire::SqlMain(std::span(args).subspan(2)); + try { + return Checks(); + } catch (const std::exception& error) { + std::println(std::cerr, "review fixes: {}", error.what()); + return 1; + } +} diff --git a/tests/StacksMemory/main.cpp b/tests/StacksMemory/main.cpp new file mode 100644 index 0000000..3a81887 --- /dev/null +++ b/tests/StacksMemory/main.cpp @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Stacks and memory: the slots config, slot.json, free memory, a pass's +// measured cost, the admission, the prompts, the preflight, `slots`. +// app_ready, the database calls, the memory readings and the methods of Run +// are replaced through the Run's seams. + +#include +#include +import std; +import Crafter.Review; +namespace fs = std::filesystem; +using namespace Crafter::Review; + +namespace { + std::int32_t Failures = 0; + const std::string Fn(Plan::FunctionalLens); + + void Check(std::string_view name, bool cond, std::string_view detail = {}) { + std::println("{}{}{}", cond ? " ok " : " FAIL ", name, !cond && !detail.empty() ? std::format(" ({})", detail) : std::string()); + if (!cond) ++Failures; + } + + void Write(const fs::path& path, std::string_view text) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; + } + + bool AnyContains(const std::vector& lines, std::string_view part) { + return std::ranges::any_of(lines, [&](const std::string& line) { return line.contains(part); }); + } + + Json With(Json value, const Json& changes) { + for (auto it = changes.begin(); it != changes.end(); ++it) value[it.key()] = it.value(); + return value; + } +} + +int main() { + std::println("stacks and memory: the slots config, slot.json, free memory, a pass's measured cost, the admission, the prompts, the preflight, `slots`"); + Platform::SetMode(Platform::Mode::Native); + SetHome(fs::current_path()); + const fs::path base = fs::temp_directory_path() / std::format("crafter-review-stacks-{}", Platform::PublishedPid()); + fs::remove_all(base); + { + const fs::path td = base / "one"; + fs::create_directories(td / ".review-gates"); + const fs::path p = td / ".review-gates" / "config.json"; + auto bad = [&](const Json& d) { + Write(p, DumpSpaced(d)); + return Config::Load(td).problems; + }; + auto sl = [](const Json& kw) { return Json {{"slots", kw}}; }; + Check("config: slots default to none; a dir inside the repository, start/stop with {n}, and the measured memMb with it; anything else is a problem", + Dyn::Equal(Config::Defaults().at("slots"), {{"dir", nullptr}, {"start", nullptr}, {"stop", nullptr}, {"memMb", nullptr}, {"startMb", nullptr}}) + && AnyContains(bad(sl({{"dir", "s"}, {"memMb", 1}, {"startMb", 0}})), "slots.startMb") + && bad(sl({{"dir", ".local-stack/slots"}, {"start", "bash s.sh full --slot {n}"}, {"stop", "bash s.sh stop --slot {n}"}, {"memMb", 1500}})).empty() + && AnyContains(bad(sl({{"dir", "/abs"}, {"memMb", 1}})), "inside the repository") && AnyContains(bad(sl({{"dir", "s"}, {"start", "bash s.sh"}, {"memMb", 1}})), "{n}") + && AnyContains(bad(sl({{"dir", "s"}})), "slots.memMb") && AnyContains(bad(sl({{"dir", "s"}, {"memMb", true}})), "slots.memMb") && bad({{"slots", {{"nope", 1}}}}).size() == 1); + Write(p, R"({"functional": {"concurrency": 4}})"); + const Config::Loaded cr = Config::Load(td); + const Config::Loaded cr2 = Config::Load("/", p); + const Json& warnings = cr.config.at("_warnings"); + const std::string w0 = warnings.empty() ? std::string() : warnings.at(0).get(); + Check("config: functional.concurrency is retired - no problem (a freeze or a run goes on), the key dropped, a warning that says what replaced it; the --config path kept; no _warnings in the dump", + cr.problems.empty() && cr2.problems.empty() && warnings.size() == 1 && w0.starts_with("functional.concurrency is no longer read - remove it from config.json:") && w0.contains("free memory") + && w0.contains("slots") && !cr.config.at("functional").contains("concurrency") && cr2.config.at("_path") == p.string() && !Config::Redacted(cr.config).contains("_warnings") + && Config::Load(td / "nowhere").config.at("_warnings").empty(), + w0); + const fs::path sd = td / ".local-stack" / "slots"; + const std::vector> slots = { + {"10", {{"appUrl", "http://localhost:21080"}, {"apiUrls", {"http://127.0.0.1:21021"}}, {"dbUrl", "postgresql://postgres:postgres@127.0.0.1:21022/postgres"}, {"login", ".local-stack/slots/10/u.json"}}}, + {"2", {{"appUrl", "http://localhost:20280/"}, {"apiUrls", Json::array()}, {"dbUrl", nullptr}, {"login", nullptr}}}, + {"1", {{"appUrl", "http://localhost:20180"}, {"dbUrl", "postgresql://u:p@db.example.supabase.co:5432/postgres"}}}, + {"3", {{"apiUrls", Json::array()}}}, + {"4", nullptr}, + }; + for (const auto& [n, body] : slots) { + fs::create_directories(sd / n); + if (!body.is_null()) Write(sd / n / "slot.json", DumpSpaced(body)); + } + fs::create_directories(sd / "5"); + Write(sd / "5" / "slot.json", "{bad"); + Write(sd / "10" / "u.json", "{}"); + const Json cfg = {{"slots", {{"dir", ".local-stack/slots"}, {"start", "up --slot {n}"}, {"stop", "down --slot {n}"}, {"memMb", 1500}}}}; + const Config::Slots found = Config::SlotStacks(cfg, td); + std::vector ids; + for (const Json& s : found.stacks) ids.push_back(s.at("id").get()); + Check("slot_stacks: the slots that are up in slot order (2, 10), the URL without its trailing slash; a hosted database, no appUrl and bad JSON are problems; a dir without slot.json is not up", + ids == std::vector{"slot 2", "slot 10"} && found.stacks.at(0).at("appUrl") == "http://localhost:20280" && found.stacks.at(1).at("login") == ".local-stack/slots/10/u.json" + && found.stacks.at(1).at("dbUrl").get().ends_with(":21022/postgres") && std::ranges::all_of(found.stacks, [](const Json& s) { return s.at("slot") == true; }) + && found.problems.size() == 3 && AnyContains(found.problems, "slots/1/slot.json: dbUrl") && AnyContains(found.problems, "slots/3/slot.json: no appUrl") + && AnyContains(found.problems, "slots/5/slot.json: not JSON"), + Dyn::Join(found.problems, " | ")); + const std::vector threeFour = {3, 4}; + const std::vector justOne = {1}; + Check("slot_commands: the start and stop commands for the slots named; none without a command", + Config::SlotCommands(cfg, "start", threeFour) == std::vector{"up --slot 3", "up --slot 4"} && Config::SlotCommands({{"slots", {{"stop", nullptr}}}}, "stop", justOne).empty()); + // Memory: what is free, the reserve, a tree's PSS, the sampler, the + // calibration. + const fs::path mi = td / "meminfo"; + Write(mi, "MemTotal: 131371024 kB\nMemFree: 3000000 kB\nMemAvailable: 82119216 kB\nHugePages_Total: 0\n"); + Check("memory: MemAvailable and MemTotal in MB; the reserve is 10 % of MemTotal (at least 2048 MB) unless REVIEW_GATES_MEM_RESERVE_MB says otherwise; no file, no number", + Memory::Available(mi) == 80194 && Platform::MemInfo(mi).at("MemTotal") == 128292 && Memory::Reserve(std::nullopt, mi) == 12829 && Memory::Reserve(std::string("4096"), mi) == 4096 + && Memory::Reserve(std::nullopt, td / "none") == 2048 && !Memory::Available(td / "none")); + const std::int64_t me = getpid(); + const std::vector tree = Platform::Tree(me, Platform::PpidMap()); + const std::vector gone = {(1 << 22) + 7}; + Check("memory: a process tree's PSS - this process measures above zero, a pid that is gone measures zero", Platform::PssMb(tree) > 1 && Platform::PssMb(gone) == 0 && std::ranges::contains(tree, me)); + std::deque vals = {100.0, 400.0, 250.0}; + Memory::Sampler sm(3600, [&](std::int64_t, const auto&) { + const double v = vals.front(); + vals.pop_front(); + return v; + }, [] { return std::map> {}; }); + sm.Watch("a", 1); + sm.Sample(); + sm.Sample(); + sm.Sample(); + const std::optional peak = sm.Done("a"); + Check("Sampler: the peak of the samples of a watched pass, and nothing once it is done", peak == 400.0 && !sm.Done("a")); + Memory::Calibration cal(td / "memory.json", td, {{"none", 300}, {"browser", 900}}); + const Memory::Calibration::Cost d0 = cal.CostOf("browser"); + cal.Record("browser", 500.0); + cal.Record("browser", 1200.0); + for (std::size_t i = 0; i < Memory::Keep; ++i) cal.Record("browser", 700.0); + const Memory::Calibration other(td / "memory.json", td / "elsewhere", {{"none", 300}, {"browser", 900}}); + Check("Calibration: the measured default before any peak; then the highest of the last 30 peaks of this repository and environment (an old outlier ages out); another repository keeps its own", + d0.megabytes == 900 && d0.source == "the default measured 2026-10-22; none measured in this repository yet" && cal.CostOf("browser").megabytes == 700.0 && cal.Peaks("browser").size() == Memory::Keep + && other.CostOf("browser").megabytes == 900 && cal.CostOf("none").megabytes == 300); + } + // Stacks: exclusive, least recently returned first, the preferred one + // only. + { + Pool::Stacks st({{{"id", "s1"}}, {{"id", "s2"}}, {{"id", "s3"}}}); + const Json a = st.Take(); + const Json b = st.Take(); + st.Give(a.at("id")); + const bool third = st.Take().at("id") == "s3"; + const bool busy = !st.Can(std::string("s2")); + const bool back = st.Can(std::string("s1")); + const bool preferred = st.Take(std::string("s1")).at("id") == "s1"; + const bool none = !st.Can(); + st.Give("s2"); + Check("Stacks: two takes are two stacks; a returned one goes to the back; prefer takes that one or reports it busy", + a.at("id") == "s1" && b.at("id") == "s2" && third && busy && back && preferred && none && st.Can()); + } + // Admission: stacks, the memory budget, the machine's free memory now, + // nothing in flight. + { + std::vector logs; + double free = 10'000; + Pool::Stacks st2({{{"id", "slot 1"}}}); + Pool::Admission adm([](const Json& j) { return j.at("mb").get(); }, [](const Json& j) { return Dyn::Truthy(Dyn::Get(j, "app")); }, &st2, 1000.0, 500, + [&]() -> std::optional { return free; }, [&](const std::string& line) { logs.push_back(line); }); + Json j1 = {{"label", "a"}, {"mb", 600}, {"app", true}}; + const Json j2 = {{"label", "b"}, {"mb", 600}}; + const Json j3 = {{"label", "c"}, {"mb", 300}, {"app", true}}; + const bool first = adm.Can(j1); + adm.Take(j1); + Check("Admission: a pass with an application takes a stack of its own and its cost; the next one waits for the stack, one over the budget waits for memory", + first && j1.at("stack").at("id") == "slot 1" && j1.at("prevStack") == "slot 1" && !adm.Can(j3) && !adm.Can(j2) && adm.Held() == 600 && adm.Inflight() == 1); + const Json j4 = {{"label", "d"}, {"mb", 300}}; + free = 700; + const bool low = adm.Can(j4); + free = 10'000; + Check("Admission: inside the budget but the machine has less than cost + reserve free right now -> wait; with room -> go", !low && adm.Can(j4)); + adm.Give(j1); + Json big = {{"label", "e"}, {"mb", 5000}}; + const bool alone = adm.Can(big); + adm.Take(big); + Check("Admission: given back, the stack and the memory are free again; with nothing in flight a pass over the budget runs alone, and the log says so once", + st2.Can() && adm.Held() == 5000 && alone && logs.size() == 1 && logs.front().contains("launched alone") && !j1.contains("stack")); + adm.Give(big); + Pool::Stacks st3({{{"id", "slot 1"}}, {{"id", "slot 2"}}}); + Pool::Admission adm3([](const Json&) { return 1.0; }, [](const Json&) { return true; }, &st3); + Json taken = {{"label", "x"}}; + adm3.Take(taken); + Json rj = {{"label", "r"}, {"resumeSession", "sid"}, {"prevStack", "slot 1"}}; + const bool waits = !adm3.Can(rj); + adm3.Give(taken); + const bool can = adm3.Can(rj); + adm3.Take(rj); + Check("Admission: a resumed pass waits for the stack its transcript ran on, not for any", waits && can && rj.at("stack").at("id") == "slot 1"); + } + // The prompts: the shared block names no stack in slot mode; the pass's + // own goes into its user message. + const Json fcfg = {{"appUrl", "http://localhost:8080"}, {"apiUrls", {"http://127.0.0.1:54321"}}, {"login", {{"notes", "TOTP enrolled"}}}}; + const std::string abSlots = Prompt::AppBlock(fcfg, std::string(".local-stack/test-user.json"), std::string("/p/bin/crafter-review totp .local-stack/test-user.json"), true); + const std::string abOne = Prompt::AppBlock(fcfg, std::string(".local-stack/test-user.json"), std::string("/p/bin/crafter-review totp .local-stack/test-user.json")); + Check("app block with slots: a stack of this pass's own, STACK FOR THIS PASS named, no URL, origin, fixture or TOTP command in the shared block; the rules: yours alone, not reset between passes", + abSlots.contains("a stack of this pass's own") && abSlots.contains("STACK FOR THIS PASS") && !abSlots.contains("localhost:8080") && !abSlots.contains("54321") && !abSlots.contains("test-user.json") + && !abSlots.contains("/p/bin/crafter-review") && abSlots.contains("TOTP enrolled") && abSlots.ends_with(Prompt::ShotRules) && abSlots.contains("not reset between passes") && abOne.contains("localhost:8080") + && abOne.contains("test-user.json") && abOne.contains("not reset between passes") && !abOne.contains("STACK FOR THIS PASS")); + const Json stk = {{"id", "slot 2"}, {"appUrl", "http://localhost:20280"}, {"apiUrls", {"http://127.0.0.1:20221"}}, {"dbUrl", "postgresql://postgres:secret@127.0.0.1:20222/postgres"}, + {"login", ".local-stack/slots/2/test-user.json"}}; + const std::string stackLogin = stk.at("login").get(); + const std::string tx = Prompt::StackText(stk, Pool::TotpCommand(stackLogin)); + const Json seven = {{"maxTurns", 7}}; + const std::string up = Prompt::UserPrompt("TAIL", seven, tx); + const std::string up0 = Prompt::UserPrompt("TAIL", seven); + Check("user prompt: the pass's stack - its URL, origins, login and exact TOTP command, never its database URL - between the scope and the budget line; without one the message is what it was", + up.find("TAIL") < up.find("STACK FOR THIS PASS (slot 2, yours alone while this pass runs)") && up.find("STACK FOR THIS PASS (slot 2, yours alone while this pass runs)") < up.find("Tool-call budget") + && up.contains("http://localhost:20280") && up.contains("20221") && up.contains(std::format("`{}`", *Pool::TotpCommand(stackLogin))) && !up.contains("20222") && !up.contains("secret") + && up0 == std::format("TAIL{}\n\nReturn the structured output described in your instructions.\n", Prompt::BudgetLine(seven)) && Prompt::StackText(With(stk, {{"login", nullptr}}), std::nullopt).contains("no login fixture")); + Check("functional block hint: the stack is the pass's own and not reset between passes", + Prompt::FunctionalBlockHint.contains("yours alone while this pass runs, but it is not reset between passes") && !Prompt::FunctionalBlockHint.contains("at the same time")); + { + const fs::path td = base / "runner"; + fs::create_directories(td); + PgWire::Broker broker; + broker.Start(); + const Pool::Runner r({.repo = fs::current_path(), .scratch = td, .bin = "/bin/true", .effort = "xhigh", .log = [](const std::string&) {}, .dryRun = true, + .functional = {{"mcp", {{"command", "npx"}, {"args", nullptr}}}, {"extraTools", Json::array()}}, .loginFixture = ".local-stack/test-user.json", + .environments = {{Fn, "browser+supabase"}, {"repo-bug-prover.md", "supabase"}}, .dbUrl = "postgresql://postgres:postgres@127.0.0.1:54322/postgres", .broker = &broker}); + const std::vector here = {"."}; + auto allowed = [](const std::vector& c) { return c[static_cast(std::ranges::find(c, "--allowedTools") - c.begin()) + 1]; }; + const std::string al2 = allowed(r.Cmd({{"label", "f"}, {"model", "m"}, {"lensFile", Fn}, {"kind", "block"}, {"stack", stk}}, "/dev/null", "{}", here, 20, false, "sid")); + const std::string al1 = allowed(r.Cmd({{"label", "f"}, {"model", "m"}, {"lensFile", Fn}, {"kind", "block"}}, "/dev/null", "{}", here, 20, false, "sid")); + Check("runner: a pass on a slot may run the TOTP command of that slot's login alone; a pass without a stack keeps the run's fixture", + al2.contains(std::format("Bash({}:*)", *Pool::TotpCommand(stackLogin))) && !Dyn::Replace(al2, stackLogin, "").contains("test-user.json:*") + && al1.contains(std::format("Bash({}:*)", *Pool::TotpCommand(".local-stack/test-user.json")))); + const std::string brokerEnv(PgWire::BrokerEnv); + auto dbOf = [&](const std::map& e) { + const std::string v = e.at(brokerEnv); + return broker.UrlOf(v.substr(v.rfind('#') + 1)).value_or(""); + }; + Check("runner: the database a pass reaches is its stack's; a pass with the database alone reaches the run's first", + dbOf(r.ProcessEnv({{"lensFile", Fn}, {"stack", stk}}, {})) == stk.at("dbUrl").get() && dbOf(r.ProcessEnv({{"lensFile", "repo-bug-prover.md"}}, {})).ends_with(":54322/postgres")); + broker.Close(); + } + // The preflight: slots up, every stack answers, its login in its + // auth.users. + { + const fs::path td = base / "preflight"; + fs::create_directories(td); + Run::Run g; + std::vector logs; + g.repo = td; + g.log = [&](const std::string& line) { logs.push_back(line); }; + g.cfg = {{"functional", {{"start", "make run"}, {"readyPath", "/"}, {"exclude", Json::array()}}}, + {"supabase", {{"dbUrl", "postgresql://postgres:postgres@127.0.0.1:1/postgres"}, {"start", nullptr}}}, + {"slots", {{"dir", ".local-stack/slots"}, {"start", "up --slot {n}"}, {"stop", nullptr}, {"memMb", 1500}}}}; + g.environments = {{Fn, "browser+supabase"}, {"repo-bug-prover.md", "supabase"}}; + g.a.dryRun = false; + g.a.harness = "claude"; + g.trees = {"."}; + auto pre = [&](const std::vector& lenses) -> std::pair { + try { + g.StackPreflight(lenses); + return {"ok", logs.empty() ? std::string() : logs.back()}; + } catch (const Run::Preflight& e) { + return {"3", std::format("preflight: {}\n", e.what())}; + } + }; + g.slotMode = true; + g.stacks = std::make_unique(std::vector {{{"id", "slot 1 (dry run: none is up)"}, {"appUrl", "http://x"}, {"apiUrls", Json::array()}, {"dbUrl", nullptr}, {"login", nullptr}, {"slot", true}}}); + const auto noneUp = pre({Fn}); + g.slotProblems = {".local-stack/slots/3/slot.json: no appUrl"}; + const auto broken = pre({Fn}); + g.slotProblems.clear(); + Write(td / "l" / "u.json", R"({"email": "A@x.test", "password": "p"})"); + std::vector two; + for (const std::int32_t n : {1, 2}) { + two.push_back({{"id", std::format("slot {}", n)}, {"appUrl", std::format("http://localhost:2{}180", n)}, {"apiUrls", Json::array()}, + {"dbUrl", std::format("postgresql://postgres:postgres@127.0.0.1:2{}122/postgres", n)}, {"login", "l/u.json"}, {"slot", true}}); + } + g.stacks = std::make_unique(two); + std::vector answered; + std::vector probed; + Run::QueryResult::second_type users = {{"x"}}; + g.appReady = [&](std::string_view url, std::string_view) { + answered.emplace_back(url); + return std::pair(true, std::format("HTTP 200 from {}", url)); + }; + g.probe = [&](std::string_view url) { + probed.emplace_back(url); + return std::pair(true, "postgres on 127.0.0.1 as postgres - PostgreSQL 17.6"); + }; + g.query = [&](std::string_view, std::string_view) { return Run::QueryResult({"?"}, users); }; + g.bypassesRls = [](std::string_view) { return std::optional(true); }; + g.migrationsDrift = [](const std::string&) { return std::pair, std::optional>(std::nullopt, "its 2 applied migrations are the range HEAD's"); }; + const auto good = pre({Fn}); + const std::vector appSeen = answered; + const std::vector dbSeen = probed; + answered.clear(); + probed.clear(); + const auto onlyDb = pre({"repo-bug-prover.md"}); + const std::vector dbOnlySeen = probed; + users.clear(); + const auto stale = pre({Fn}); + users.push_back({"x"}); + g.appReady = [](std::string_view url, std::string_view) { return std::pair(!url.contains(":22180"), std::format("connection refused by {}", url)); }; + const auto down = pre({Fn}); + Check("preflight: slots configured and none up stops the run and names ` slots`; a broken slot.json stops it", + noneUp.first == "3" && noneUp.second.contains("no slot is up under .local-stack/slots") && noneUp.second.contains(std::format("`{} slots`", Invocation())) && broken.first == "3" + && broken.second.contains("slots/3/slot.json"), + noneUp.second + broken.second); + Check("preflight: a lens that drives the application checks every stack - its app, its database, its migrations; a lens with the database alone checks the first", + good.first == "ok" && appSeen.size() == 2 && dbSeen.size() == 2 && dbOnlySeen == std::vector{two.front().at("dbUrl").get()} && good.second.contains("2 slot(s) under .local-stack/slots") + && g.dbChecked && g.dbChecked->starts_with("the database of each of the 2 stacks answers") && onlyDb.first == "ok", + good.second); + Check("preflight: a stack whose login is not in its auth.users stops the run (a fixture left from another bring-up); an app that does not answer names its slot and the start command", + stale.first == "3" && stale.second.contains("slot 1: its login l/u.json (a@x.test) is not in that stack's auth.users") && down.first == "3" && down.second.contains("slot 2: the application does not answer") + && down.second.contains("(up --slot {n})"), + stale.second + down.second); + // exec_job: the stack the admission gave, the measured peak into the + // calibration. + const fs::path fx = fs::current_path() / "tests" / "Goldens" / "fixture" / "commits"; + g.a.artifact = fx.string(); + g.a.dryRun = false; + g.a.effort = "xhigh"; + g.a.noSalvage = true; + g.hash = Artifact::ArtifactHash("commits", fx); + g.testMode = true; + g.schemas = {{"block", "{}"}, {"whole", "{}"}}; + g.treeDrift = [] { return std::optional(); }; + g.promptFor = [](const Json&) { return std::pair("/dev/null", "PROMPT"); }; + g.calibration = std::make_unique(td / "memory.json", td, std::map {{"none", 300}, {"browser+supabase", 900}}); + g.runPass = [](const Json& job, const std::string&, const fs::path&, std::string_view, std::int64_t, std::int64_t, bool) { + return std::pair({{"label", job.at("label")}, {"status", "ok"}, {"memPeakMb", 812.5}}, nullptr); + }; + const auto [rec, raw] = g.ExecJob({{"label", "f1"}, {"lens", "repo-functional"}, {"lensFile", Fn}, {"kind", "block"}, {"model", "m"}, {"stack", g.stacks->All().at(1)}}, 1); + Check("exec_job: the record names the pass's stack, and its measured peak becomes its environment's calibration", rec.at("stack") == "slot 2" && g.calibration->CostOf("browser+supabase").megabytes == 812.5); + // `slots`: as many as free memory holds, no more than the range's + // browser passes, the commands for the missing ones. + Write(td / ".local-stack" / "slots" / "1" / "slot.json", R"({"appUrl": "http://localhost:20180", "apiUrls": [], "dbUrl": null, "login": null})"); + Json cfg = Config::Load(td).config; + cfg["slots"] = {{"dir", ".local-stack/slots"}, {"start", "up --slot {n}"}, {"stop", nullptr}, {"memMb", 1500}, {"startMb", 6000}}; + cfg["lenses"]["repo-functional"]["environment"] = "browser+supabase"; + std::vector out; + const Run::Machine machine = {.available = [] { return std::optional(20'000); }, .reserve = [] { return std::int64_t {4000}; }}; + auto sink = [&](const std::string& line) { out.push_back(line); }; + const auto [want, cmds] = Run::SlotsPlan(cfg, td, std::nullopt, td, sink, machine); + const auto [want2, cmds2] = Run::SlotsPlan(cfg, td, fx, td, sink, machine); + std::vector expected; + for (std::int32_t n = 2; n < 8; ++n) expected.push_back(std::format("up --slot {}", n)); + // 20000 free - 4000 reserve - 1 slot up x 812.5 = 15187.5 for (1500 + + // 812.5) each -> 6 more. + Check("slots: room = free - reserve - the passes of the slots up, each new slot at memMb plus one measured pass -> 6 more, numbered after the ones up; with the fixture range no more than its 3 browser block passes", + want == 7 && cmds == expected && want2 == 3 && cmds2 == std::vector{"up --slot 2", "up --slot 3"} && AnyContains(out, "room for 6 more by memory"), Dyn::Join(out, " | ")); + Check("slots: no more start at once than free memory holds at slots.startMb each (15187 MB / 6000 -> 2), and one at a time without it", AnyContains(out, "at most 2 at once (slots.startMb 6000 MB each"), + Dyn::Join(out, " | ")); + } + fs::remove_all(base); + return Failures == 0 ? 0 : 1; +} diff --git a/tests/StoreRules/main.cpp b/tests/StoreRules/main.cpp new file mode 100644 index 0000000..f7b2172 --- /dev/null +++ b/tests/StoreRules/main.cpp @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Store rules: what unit-state stores as reusable, reads per round, the +// reads a unit needs, salvage by environment, the environment on the store +// side, resume by environment, the migrations preflight and the instrument +// preflight. A Run is built part by part. + +#include +import std; +import Crafter.Review; +namespace fs = std::filesystem; +using namespace Crafter::Review; + +namespace { + std::int32_t Failures = 0; + const std::string Fn(Plan::FunctionalLens); + + void Check(std::string_view name, bool cond, std::string_view detail = {}) { + std::println("{}{}{}", cond ? " ok " : " FAIL ", name, !cond && !detail.empty() ? std::format(" ({})", detail) : std::string()); + if (!cond) ++Failures; + } + + void Write(const fs::path& path, std::string_view text) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; + } + + std::set Keys(const Json& object) { + std::set keys; + for (auto it = object.begin(); it != object.end(); ++it) keys.insert(it.key()); + return keys; + } + + Json Defaults() { + Json out = Json::object(); + for (const Plan::Lens& l : Plan::Lenses) out[std::string(l.file)] = std::string(l.env); + return out; + } + + std::string Git(const fs::path& repo, std::vector args) { + std::vector argv = {"git", "-C", repo.string(), "-c", "user.email=t@t", "-c", "user.name=t"}; + std::ranges::move(args, std::back_inserter(argv)); + const Process::Captured run = Process::Run(argv); + if (run.returnCode != 0) throw std::runtime_error(std::format("{}: {}", Dyn::Join(argv, " "), run.err)); + return Dyn::Strip(run.out); + } +} + +int main() { + std::println("unit-state: what is stored as reusable; reads per round; reads needed per lens"); + Platform::SetMode(Platform::Mode::Native); + // No git of this test reads a global config. + setenv("GIT_CONFIG_GLOBAL", "/dev/null", 1); + setenv("GIT_CONFIG_SYSTEM", "/dev/null", 1); + const fs::path td = fs::temp_directory_path() / std::format("crafter-review-store-rules-{}", Platform::PublishedPid()); + fs::remove_all(td); + fs::create_directories(td); + auto noLog = [](const std::string&) {}; + + auto stored = [&](std::vector recs, const Json& parts, const Json& coverage, const Json& findings, const Json& before = Json::object(), std::int64_t rnd = 1, bool twice = false) { + Run::Run r; + r.records = std::move(recs); + r.hash = "H"; + r.h8 = "H"; + r.round = rnd; + r.dumpPath = td / std::format("run-{}.json", rnd); + r.repo = td; + r.results = {{{"lens", "L"}, {"coverage", coverage}, {"findings", findings}, {"notes", Json::array()}, {"parts", parts}}}; + r.state = {{"version", 1}, {"entries", before}, {"personaPass", nullptr}}; + r.statePath = td / "unit-state.json"; + r.a.date = "2026-10-15"; + r.log = noLog; + // The file is the record the store merges into (2026-10-31). + State::SaveWhole(r.statePath, r.state); + r.StoreState(); + if (twice) r.StoreState(); + return r.state.at("entries"); + }; + auto rec = [](const std::string& u, const std::string& k) { + return Json {{"label", std::format("L:block:{}", u)}, {"lens", "L"}, {"lensFile", "repo-bug-prover.md"}, {"unit", u}, {"kind", "block"}, {"status", "ok"}, {"reuseKey", k}}; + }; + auto part = [](const std::string& u, const std::string& v) { + return Json {{"label", std::format("L:block:{}", u)}, {"kind", "block"}, {"unit", u}, {"verdict", v}, {"findings", v != "SEND-READY" ? 1 : 0}}; + }; + auto cov = [](const std::string& u, const std::string& v) { return Json {{"item", u}, {"verdict", v}, {"basis", ""}}; }; + auto finding = [](const std::string& item, bool discharged, const std::optional& by = std::nullopt) { + Json f = {{"finding", "f"}, {"items", Json::array({item})}, {"discharged", discharged}}; + if (by) f["dischargedBy"] = *by; + return f; + }; + + Json e = stored({rec("X", "kx"), rec("Y", "ky")}, Json::array({part("X", "SEND-READY"), part("Y", "SEND-READY")}), Json::array({cov("X", "clear"), cov("Y", "clear")}), Json::array()); + Check("two clean passes -> both stored", Keys(e) == std::set{"kx", "ky"} && e.at("kx").at("unit") == "X"); + e = stored({rec("X", "kx"), rec("Y", "ky")}, Json::array({part("X", "FIX FIRST"), part("Y", "SEND-READY")}), Json::array({cov("X", "clear"), cov("Y", "clear")}), Json::array({finding("Y", false)}), + {{"kx", {{"unit", "X"}}}}); + Check("the pass on X returns a finding that cites only Y -> neither stored, X loses its earlier entry", e.empty(), e.dump()); + e = stored({rec("X", "kx"), rec("Y", "ky")}, Json::array({part("X", "SEND-READY"), part("Y", "SEND-READY")}), Json::array({cov("X", "defect"), cov("Y", "clear")}), Json::array({finding("X", true, "w")})); + Check("a defect verdict whose only citing finding is discharged -> stored", Keys(e) == std::set{"kx", "ky"} && e.at("kx").at("coverage").at("verdict") == "defect"); + e = stored({rec("X", "kx"), rec("Y", "ky")}, Json::array({part("X", "SEND-READY"), part("Y", "SEND-READY")}), Json::array({cov("X", "defect"), cov("Y", "clear")}), Json::array()); + Check("a defect verdict with no citing finding -> not stored", Keys(e) == std::set{"ky"}); + e = stored({rec("p3:absent", "k"), rec("p4:absent", "k")}, Json::array({part("p3:absent", "SEND-READY"), part("p4:absent", "FIX FIRST")}), Json::array({cov("p3:absent", "clear"), cov("p4:absent", "defect")}), + Json::array({finding("p4:absent", false)}), {{"k", {{"unit", "p3:absent"}}}, {"other", {{"unit", "Z"}}}}); + Check("two units under one key, one defective -> the key is not stored and its earlier entry is dropped", Dyn::Equal(e, {{"other", {{"unit", "Z"}}}}), e.dump()); + e = stored({rec("p3:x#hdr", "k"), rec("p8:x#hdr", "k")}, Json::array({part("p3:x#hdr", "SEND-READY"), part("p8:x#hdr", "SEND-READY")}), Json::array({cov("p3:x#hdr", "clear"), cov("p8:x#hdr", "n/a")}), Json::array()); + Check("two units under one key, both clean -> stored once", Keys(e) == std::set{"k"}); + const Json oneRead = Json::array({{{"hash", "H"}, {"round", 1}, {"run", "run-1.json"}}}); + const Json e1 = stored({rec("X", "kx")}, Json::array({part("X", "SEND-READY")}), Json::array({cov("X", "clear")}), Json::array(), Json::object(), 1, true); + Check("reads: a clean pass is one read however often the run stores it", + Plan::ReadsOf(e1.at("kx")) == 1 && Dyn::Equal(e1.at("kx").at("reads"), oneRead) && Plan::ReadIn(e1.at("kx"), "H", 1) && !Plan::ReadIn(e1.at("kx"), "H", 2)); + const Json e2 = stored({rec("X", "kx")}, Json::array({part("X", "SEND-READY")}), Json::array({cov("X", "clear")}), Json::array(), e1, 2); + Check("reads: the next round on the same hash is the second read; a relaunch of the same round adds nothing", + Plan::ReadsOf(e2.at("kx")) == 2 && Plan::ReadsOf(stored({rec("X", "kx")}, Json::array({part("X", "SEND-READY")}), Json::array({cov("X", "clear")}), Json::array(), e1).at("kx")) == 1); + const Json e3 = stored({rec("X", "kx")}, Json::array({part("X", "FIX FIRST")}), Json::array({cov("X", "defect")}), Json::array({finding("X", false)}), e2, 3); + Check("reads: a finding on the third read drops the count to nothing", !e3.contains("kx")); + auto need = [](const std::string& lf, const std::string& kind = "block", const Json& unitKind = "code", std::int64_t x = 3) { + Run::Run r; + r.x = x; + return r.ReadsNeeded({{"lensFile", lf}, {"kind", kind}, {"unitKind", unitKind}}); + }; + Check("reads needed: 3 for a static code unit, 1 for a functional unit, a whole pass, and everything at --reads 1", + need("repo-bug-prover.md") == 3 && need("repo-better-solution.md") == 3 && need(Fn) == 1 && need("repo-better-solution.md", "whole", nullptr) == 1 && need("repo-bug-prover.md", "block", "code", 1) == 1 && Plan::Reads == 3); + + // Salvage: an earlier run's output is evidence only if its environment + // was there (exercised), whatever classify says. + { + Run::Run r; + const fs::path sd = td / "old-run"; + fs::create_directories(sd / "out"); + r.salvageDirs = {sd}; + r.testMode = false; + r.x = 1; + r.repo = td; + r.environments = {{Fn, "browser"}, {"repo-bug-prover.md", "none"}}; + r.log = noLog; + r.a.dryRun = false; + r.a.noSalvage = false; + const std::string res = R"({"type":"result","subtype":"success","is_error":false,"structured_output":{"verdict":"SEND-READY","findings":[],"notes":[],"coverage":[]},"usage":{"input_tokens":1,"output_tokens":1}})"; + const std::string initNo = R"({"type":"system","subtype":"init","model":"m","mcp_servers":[],"tools":["Read"]})"; + const std::string initOk = R"({"type":"system","subtype":"init","model":"m","mcp_servers":[{"name":"playwright","status":"connected"}],"tools":["mcp__playwright__browser_snapshot"]})"; + const std::string call = R"({"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"mcp__playwright__browser_snapshot","input":{}}]}})" + "\n" + R"({"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"- page"}]}})"; + const Json job = {{"label", "repo-functional:whole"}, {"lensFile", Fn}, {"kind", "whole"}, {"model", "m"}}; + Write(sd / "out" / "repo-functional:whole.jsonl", std::format("{}\n{}\n", initNo, res)); + Check("salvage: a functional whole pass whose server never connected is not salvaged (no browser = no evidence, salvaged or not)", !r.Salvage(job)); + Write(sd / "out" / "repo-functional:whole.jsonl", std::format("{}\n{}\n{}\n", initOk, call, res)); + std::optional> got = r.Salvage(job); + Check("...with the server connected and a browser call it is, with its environment on the record", + got && got->first.at("environment") == "browser" && Dyn::Truthy(got->first.at("salvaged")) && got->second.at("verdict") == "SEND-READY"); + Write(sd / "out" / "repo-bug-prover:block:X.jsonl", std::format("{}\n{}\n", initNo, res)); + const Json bj = {{"label", "repo-bug-prover:block:X"}, {"lensFile", "repo-bug-prover.md"}, {"kind", "block"}, {"model", "m"}}; + got = r.Salvage(bj); + Check("a static pass (environment none) is salvaged as classified", got && got->first.at("environment") == "none"); + // Across environments: the scratch dir's marker says what its passes + // had; no marker = the defaults. + r.environments = {{Fn, "browser"}, {"repo-bug-prover.md", "supabase"}}; + const bool unmarked = r.Salvage(bj).has_value(); + Write(sd / "environments", R"({"repo-bug-prover": "supabase"})"); + r.envMarkers.clear(); + const std::optional> marked = r.Salvage(bj); + r.environments = {{Fn, "browser"}, {"repo-bug-prover.md", "none"}}; + const bool back = r.Salvage(bj).has_value(); + Check("salvage: an output is reused only in the environment its scratch dir's marker names (a dir without one ran the defaults)", + !unmarked && marked && marked->first.at("environment") == "supabase" && !back); + } + + // The environment on the STORE side. + const fs::path fx = fs::current_path() / "tests" / "Goldens" / "fixture" / "commits"; + const Inventory::Built built = Inventory::Build("commits", fx.string()); + Run::Run g; + g.a.artifact = fx.string(); + g.a.dryRun = false; + g.a.date = "2026-10-22"; + g.units = built.out.at("units"); + g.lines = built.lines; + for (const Plan::Lens& l : Plan::Lenses) g.charterSha[std::string(l.file)] = "c"; + g.state = State::EmptyState(); + g.x = 1; + g.h8 = "H"; + g.round = 1; + g.hash = "H"; + g.repo = td; + g.statePath = td / "us-env.json"; + g.dumpPath = td / "run-env.json"; + g.log = noLog; + const std::string U = "p2:hello.py#1"; + auto unitJob = [&] { + for (Json j : Plan::PlanLens("repo-bug-prover.md", g.units, g.lines).blocks) { + if (j.at("unit") == U) { + j["gate"] = "G"; + return j; + } + } + throw std::runtime_error("no unit job"); + }; + // One round of the bug-prover on U in env: PrepareBlocks (reuse or a + // job), then - if it ran - a pass with that verdict, stored. + auto round = [&](const std::string& env, const std::string& verdict) -> std::pair { + g.environments = Defaults(); + g.environments["repo-bug-prover.md"] = env; + g.records.clear(); + g.results.clear(); + g.wholeParts.clear(); + std::map> parts = {{"repo-bug-prover", {}}}; + const std::vector jobs = g.PrepareBlocks("repo-bug-prover", {unitJob()}, parts); + if (jobs.empty()) return {"reused", nullptr}; + const Json& j = jobs.front(); + g.records = {{{"label", j.at("label")}, {"lens", "repo-bug-prover"}, {"lensFile", "repo-bug-prover.md"}, {"unit", U}, {"kind", "block"}, {"status", "ok"}, {"reuseKey", j.at("reuseKey")}, {"otherKeys", j.at("otherKeys")}}}; + const bool bad = verdict != "SEND-READY"; + g.results = {{{"lens", "repo-bug-prover"}, {"coverage", Json::array({{{"item", U}, {"verdict", bad ? "defect" : "clear"}, {"basis", ""}}})}, {"notes", Json::array()}, + {"findings", bad ? Json::array({finding(U, false)}) : Json::array()}, {"parts", Json::array({{{"label", j.at("label")}, {"verdict", verdict}}})}}}; + g.StoreState(); + return {"ran", j}; + }; + auto inEntries = [&](const Json& key) { return g.state.at("entries").contains(key.get()); }; + const auto [s1, j1] = round("none", "SEND-READY"); + const auto [s2, j2] = round("supabase", "SEND-READY"); + Check("store: a clean verdict bought in one environment is not reused in another (none -> supabase runs the unit again); each is stored under its own key", + s1 == "ran" && s2 == "ran" && j1.at("reuseKey") != j2.at("reuseKey") && inEntries(j1.at("reuseKey")) && inEntries(j2.at("reuseKey")) + && std::ranges::contains(j2.at("otherKeys"), j1.at("reuseKey"))); + Check("...and in its own environment it is reused", round("none", "SEND-READY").first == "reused" && round("supabase", "SEND-READY").first == "reused"); + g.state["entries"].erase(j2.at("reuseKey").get()); + const auto [s3, j3] = round("supabase", "FIX FIRST"); + Check("store: two environments, one unit, different verdicts - the supabase pass finds a defect the none pass read clean: neither verdict survives, so switching the lens back to none re-reads the unit", + s3 == "ran" && !inEntries(j1.at("reuseKey")) && !inEntries(j3.at("reuseKey")) && round("none", "SEND-READY").first == "ran"); + // The whole pass: its key carries the environment, and a whole pass that + // is not clean drops the other environments' whole verdicts. + Json wj = Plan::PlanLens("repo-better-solution.md", g.units, g.lines).whole; + wj["gate"] = "G"; + g.environments = Defaults(); + g.records.clear(); + g.ReuseWhole("repo-better-solution", wj); + const std::string kwNone = wj.at("reuseKey").get(); + g.state["entries"][kwNone] = {{"lens", "repo-better-solution.md"}, {"unit", nullptr}, {"kind", "whole"}, {"hash", "H"}, {"run", "r"}, + {"coverage", Json::array({{{"item", "whole:order"}, {"verdict", "clear"}, {"basis", ""}}, {{"item", "whole:absent"}, {"verdict", "clear"}, {"basis", ""}}})}, + {"findings", Json::array()}, {"notes", Json::array()}, {"summary", "s"}}; + Json copy = wj; + const bool reusedNone = g.ReuseWhole("repo-better-solution", copy).has_value(); + g.environments["repo-better-solution.md"] = "supabase"; + Json ws = wj; + const bool reusedSup = g.ReuseWhole("repo-better-solution", ws).has_value(); + g.records = {{{"label", ws.at("label")}, {"lens", "repo-better-solution"}, {"lensFile", "repo-better-solution.md"}, {"unit", nullptr}, {"kind", "whole"}, {"status", "ok"}, {"reuseKey", ws.at("reuseKey")}, {"otherKeys", ws.at("otherKeys")}}}; + g.results = {{{"lens", "repo-better-solution"}, {"coverage", Json::array()}, {"findings", Json::array({finding("whole:order", false)})}, {"notes", Json::array()}, {"parts", Json::array()}}}; + g.wholeParts = {{"repo-better-solution", {{"verdict", "FIX FIRST"}, {"coverageMissing", Json::array()}, {"status", "ok"}}}}; + g.StoreState(); + Check("store: the whole pass - a clean whole verdict is reused in its environment and not in another; a whole pass that is not clean in supabase drops the clean one none held", + reusedNone && !reusedSup && ws.at("reuseKey") != kwNone && std::ranges::contains(ws.at("otherKeys"), Json(kwNone)) && !g.state.at("entries").contains(kwNone)); + // Resume across environments: the checkpoint's `environments` (none in a + // checkpoint written before 2026-10-16.1 = the defaults). + g.resume = {{"run", "r1"}, + {"results", Json::array({{{"lens", "repo-bug-prover"}, {"verdict", "SEND-READY"}, {"findings", Json::array()}, {"coverageMissing", Json::array()}, {"parts", Json::array()}}})}, + {"environments", {{"repo-bug-prover", "supabase"}}}}; + g.newRound = false; + g.records.clear(); + g.environments = Defaults(); + const bool no = g.Resumable("repo-bug-prover", "repo-bug-prover.md", "G").has_value(); + g.environments["repo-bug-prover.md"] = "supabase"; + const std::optional yes = g.Resumable("repo-bug-prover", "repo-bug-prover.md", "G"); + g.resume.erase("environments"); + const bool old = g.Resumable("repo-bug-prover", "repo-bug-prover.md", "G").has_value(); + g.environments["repo-bug-prover.md"] = "none"; + const bool oldDefault = g.Resumable("repo-bug-prover", "repo-bug-prover.md", "G").has_value(); + Check("resume: a lens result is resumed only in the environment it was bought in (an old checkpoint without environments = the defaults)", + !no && yes && yes->at("verdict") == "SEND-READY" && !old && oldDefault); + + // The preflight of a gate with the database under test. + const fs::path repo = td / "mig"; + fs::create_directories(repo / "supabase" / "migrations"); + for (const std::string_view f : {"20261001000000_a.sql", "20261002000000_b.sql"}) Write(repo / "supabase" / "migrations" / f, "select 1;\n"); + Write(repo / "supabase" / "migrations" / "README", "not a migration\n"); + Git(repo, {"init", "-q"}); + Git(repo, {"add", "."}); + Git(repo, {"commit", "-qm", "base"}); + const std::string mbase = Git(repo, {"rev-parse", "HEAD"}); + Write(repo / "supabase" / "migrations" / "20261002000000_b.sql", "-- b, now with a filter\nselect 2 where true;\n"); + Git(repo, {"commit", "-qam", "the range changes b"}); + g.trees = {repo.string()}; + g.a.base = mbase; + // What the CLI records: the statements, its own spacing, no comment. + const std::string appliedA = "select 1"; + const std::string appliedB = "select 2 where true"; + using Rows = std::vector>>; + auto rows = [](Rows r) { return [r](std::string_view, std::string_view) { return Run::QueryResult({"version", "s"}, r); }; }; + g.query = rows({{"20261001000000", appliedA}, {"20261002000000", appliedB}}); + const auto same = g.MigrationsDrift("u"); + g.query = rows({{"20261001000000", appliedA}}); + const auto behind = g.MigrationsDrift("u"); + g.query = rows({{"20261001000000", appliedA}, {"20261002000000", appliedB}, {"20260901000000", "x"}}); + const auto ahead = g.MigrationsDrift("u"); + g.query = rows({{"20261001000000", "select 1 -- other text, a base migration: versions alone"}, {"20261002000000", "select 1"}}); + const auto stale = g.MigrationsDrift("u"); + g.query = [](std::string_view, std::string_view sql) -> Run::QueryResult { + if (sql.contains("statements")) throw PgWire::SqlError({{'C', "42703"}, {'M', "column \"statements\" does not exist"}}); + return {{"version", "s"}, {{"20261001000000", std::nullopt}, {"20261002000000", std::nullopt}}}; + }; + const auto oldCli = g.MigrationsDrift("u"); + g.query = [](std::string_view, std::string_view) -> Run::QueryResult { throw PgWire::SqlError({{'C', "42P01"}, {'M', "relation \"supabase_migrations.schema_migrations\" does not exist"}}); }; + const auto plain = g.MigrationsDrift("u"); + auto has = [](const std::optional& text, std::string_view part) { return text && text->contains(part); }; + Check("preflight: the database's applied migrations must be the range HEAD's - one not applied, one applied that HEAD lacks, or a migration of the range applied with other content (amended, the stack not reset) is evidence drift", + !same.first && has(same.second, "2 applied migrations") && has(same.second, "1 of the range compared by content") && has(behind.first, "at HEAD, not applied: 20261002000000") + && has(ahead.first, "applied, not at HEAD: 20260901000000") && has(stale.first, "applied with other content than HEAD has: 20261002000000") && !has(stale.first, "20261001000000"), + std::format("{} | {} | {} | {}", same.second.value_or("-"), behind.first.value_or("-"), ahead.first.value_or("-"), stale.first.value_or("-"))); + Check("preflight: the content is compared blind to comments, whitespace and semicolons; an old CLI without the statements column compares versions; a database without the table is a note", + PgWire::Fingerprint("-- b, now with a filter\nselect 2 where true;\n") == PgWire::Fingerprint("select 2 where true") && PgWire::Fingerprint("select 2 where true") != PgWire::Fingerprint("select 2 where false") + && PgWire::Fingerprint("select '--not a comment'") != PgWire::Fingerprint("select") && !oldCli.first && !plain.first && has(plain.second, "not compared")); + g.trees = {td.string()}; + Check("preflight: a tree without supabase/migrations/ at HEAD compares nothing", !g.MigrationsDrift("u").first); + g.query = [](std::string_view url, std::string_view sql) { return PgWire::Query(url, sql); }; + g.cfg = {{"supabase", {{"dbUrl", "postgresql://postgres:postgres@127.0.0.1:1/postgres"}, {"start", nullptr}, {"stop", nullptr}, {"notes", ""}}}, + {"functional", {{"start", "bash x.sh full"}, {"readyPath", "/"}, {"exclude", Json::array()}}}, + {"slots", {{"dir", nullptr}, {"start", nullptr}, {"stop", nullptr}, {"memMb", nullptr}}}}; + g.slotMode = false; + g.slotProblems.clear(); + g.loginFixture = std::nullopt; + g.stacks = std::make_unique(std::vector { + {{"id", "the stack"}, {"appUrl", "http://localhost:1"}, {"apiUrls", Json::array()}, {"dbUrl", g.cfg.at("supabase").at("dbUrl")}, {"login", nullptr}, {"slot", false}}}); + g.environments = Defaults(); + g.environments["repo-bug-prover.md"] = "supabase"; + std::vector logs; + g.log = [&](const std::string& line) { logs.push_back(line); }; + auto preflight = [&](const std::string& harness, bool dryRun) -> std::pair, std::string> { + g.a.harness = harness; + g.a.dryRun = dryRun; + try { + g.InstrumentPreflight(std::vector {"repo-bug-prover.md"}); + return {std::nullopt, ""}; + } catch (const Run::Preflight& e) { + return {3, std::format("preflight: {}\n", e.what())}; + } + }; + const auto [refusedOc, whyOc] = preflight("opencode", true); + preflight("claude", true); + const auto [dead, whyDead] = preflight("claude", false); + Check("preflight: the opencode harness refuses a lens with the database (exit 3); a dry run names the masked URL and probes nothing; a database that does not answer stops the run before its gate (exit 3)", + refusedOc == 3 && whyOc.contains("needs the claude harness") && std::ranges::any_of(logs, [](const std::string& x) { return x.contains("dry run") && x.contains("postgres:***@"); }) && dead == 3 + && whyDead.contains("does not answer") && whyDead.contains("(bash x.sh full)") && !whyDead.contains(":postgres@"), + whyDead); + + fs::remove_all(td); + return Failures == 0 ? 0 : 1; +}