Freeze: identity, AI trailers by the consumer's rule (the pattern with case-insensitive folds and its \s* that crosses lines), a clean tree, no merges, a fast-forward, then format-patch, the hash, the dispositions register and the optional detached worktree; --verify at push time. The command line takes --opt=value, unique prefixes and the last of a repeat, with a usage error at exit 3 like every command that cannot start; the config is loaded and its warnings and problems reported first. The run line it prints names this binary. freeze.json records gitVersion. format-patch ends every patch with the git version, so a fresh export under another git hashes differently; --verify's MISMATCH says which git exported the artifact and which the fresh copy, reading the patches' own signature when freeze.json does not record it. freeze becomes a subreaper and takes down whatever gpg left running for %G?. tests/Freeze: 18 checks, plus the gitVersion record and both MISMATCH messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
13 KiB
C++
263 lines
13 KiB
C++
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module Crafter.Review:Driver_impl;
|
|
import :Driver;
|
|
import :Core;
|
|
import :Text;
|
|
import :Platform;
|
|
import :Process;
|
|
import :Inventory;
|
|
import :Config;
|
|
import :Freeze;
|
|
import std;
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace Crafter::Review {
|
|
namespace {
|
|
constexpr std::string_view Usage =
|
|
R"(usage: crafter-review [--config FILE] {run,freeze,slots,config} ...
|
|
crafter-review {seats,inventory,sql,totp,init,doc,browser,stop} ...
|
|
crafter-review --version
|
|
|
|
The pre-push review pipeline: adversarial lenses, one claude process per
|
|
inventory unit, against a frozen commit range.)";
|
|
|
|
std::int32_t PrintVersion() {
|
|
std::println("{}", DriverLine());
|
|
try {
|
|
std::println("home {}", Home().string());
|
|
for (const auto& [name, sha] : CharterHashes()) std::println("charter {} {}", name, sha);
|
|
} catch (const std::exception& e) {
|
|
std::println("home not found: {}", e.what());
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// A usage error: exit 3, the preflight's code, for every command that
|
|
// cannot start.
|
|
std::int32_t UsageError(std::string_view usage, std::string_view prog, std::string_view message) {
|
|
std::println(std::cerr, "{}\n{}: error: {}", usage, prog, message);
|
|
return 3;
|
|
}
|
|
|
|
// die(): the preflight's message and exit 3.
|
|
std::int32_t Die(std::string_view message) {
|
|
std::println(std::cerr, "preflight: {}", message);
|
|
return 3;
|
|
}
|
|
|
|
// ---- the option parser these commands use ---------------------------------
|
|
struct OptionSpec {
|
|
std::string_view name;
|
|
// A value follows (`--x V` or `--x=V`); else a store_true flag.
|
|
bool takesValue;
|
|
std::string_view metavar;
|
|
std::string_view help;
|
|
};
|
|
|
|
struct Parsed {
|
|
std::map<std::string, std::string, std::less<>> values;
|
|
std::set<std::string, std::less<>> flags;
|
|
bool help = false;
|
|
std::string error;
|
|
|
|
std::optional<std::string> Value(std::string_view name) const {
|
|
const auto found = values.find(name);
|
|
if (found == values.end()) return std::nullopt;
|
|
return found->second;
|
|
}
|
|
bool Flag(std::string_view name) const {
|
|
return flags.contains(name);
|
|
}
|
|
};
|
|
|
|
std::string UsageLine(std::string_view prog, std::span<const OptionSpec> specs) {
|
|
std::string line = std::format("usage: {} [-h]", prog);
|
|
for (const OptionSpec& spec : specs) {
|
|
line += spec.takesValue ? std::format(" [{} {}]", spec.name, spec.metavar) : std::format(" [{}]", spec.name);
|
|
}
|
|
return line;
|
|
}
|
|
|
|
std::string Help(std::string_view prog, std::string_view description, std::span<const OptionSpec> specs) {
|
|
std::string text = std::format("{}\n\n{}\n\noptions:\n -h, --help show this help message and exit\n", UsageLine(prog, specs), description);
|
|
for (const OptionSpec& spec : specs) {
|
|
const std::string left = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name);
|
|
text += left.size() < 20 ? std::format(" {:<20} {}\n", left, spec.help) : std::format(" {}\n {}\n", left, spec.help);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// `--opt value`, `--opt=value`, any unique prefix of a long option, the
|
|
// last of a repeated option, every unrecognized argument named at the
|
|
// end.
|
|
Parsed ParseOptions(std::span<const std::string_view> args, std::span<const OptionSpec> specs) {
|
|
Parsed parsed;
|
|
std::vector<std::string> unrecognized;
|
|
for (std::size_t i = 0; i < args.size(); ++i) {
|
|
const std::string_view arg = args[i];
|
|
if (arg == "-h") {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
if (!arg.starts_with("--") || arg == "--") {
|
|
unrecognized.emplace_back(arg);
|
|
continue;
|
|
}
|
|
const std::size_t equals = arg.find('=');
|
|
const std::string_view name = arg.substr(0, equals);
|
|
if (name.size() > 2 && std::string_view("--help").starts_with(name) && std::ranges::none_of(specs, [&](const OptionSpec& s) { return s.name.starts_with(name); })) {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
std::vector<const OptionSpec*> matches;
|
|
for (const OptionSpec& spec : specs) {
|
|
if (spec.name == name) {
|
|
matches = {&spec};
|
|
break;
|
|
}
|
|
if (spec.name.starts_with(name)) matches.push_back(&spec);
|
|
}
|
|
if (matches.empty()) {
|
|
unrecognized.emplace_back(arg);
|
|
continue;
|
|
}
|
|
if (matches.size() > 1) {
|
|
std::vector<std::string> names;
|
|
for (const OptionSpec* spec : matches) names.emplace_back(spec->name);
|
|
parsed.error = std::format("ambiguous option: {} could match {}", name, Dyn::Join(names, ", "));
|
|
return parsed;
|
|
}
|
|
const OptionSpec& spec = *matches.front();
|
|
const std::string key(spec.name.substr(2));
|
|
if (!spec.takesValue) {
|
|
if (equals != std::string_view::npos) {
|
|
parsed.error = std::format("argument {}: ignored explicit argument {}", spec.name, Dyn::Repr(arg.substr(equals + 1)));
|
|
return parsed;
|
|
}
|
|
parsed.flags.insert(key);
|
|
continue;
|
|
}
|
|
if (equals != std::string_view::npos) {
|
|
parsed.values[key] = std::string(arg.substr(equals + 1));
|
|
} else if (i + 1 < args.size() && !(args[i + 1].starts_with('-') && args[i + 1].size() > 1)) {
|
|
parsed.values[key] = std::string(args[++i]);
|
|
} else {
|
|
parsed.error = std::format("argument {}: expected one argument", spec.name);
|
|
return parsed;
|
|
}
|
|
}
|
|
if (!unrecognized.empty()) parsed.error = std::format("unrecognized arguments: {}", Dyn::Join(unrecognized, " "));
|
|
return parsed;
|
|
}
|
|
|
|
// The consumer config, loaded before every command but `config`:
|
|
// warnings on stderr, a problem stops it.
|
|
std::optional<Json> LoadConfig(const std::optional<std::string>& configPath) {
|
|
Config::Loaded loaded = Config::Load(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
|
for (const Json& warning : Dyn::Or(Dyn::Get(loaded.config, "_warnings"), Dyn::EmptyList())) std::println(std::cerr, "config: warning: {}", warning.get<std::string>());
|
|
if (!loaded.problems.empty()) {
|
|
Die(std::format("config: {}", Dyn::Join(loaded.problems, "; ")));
|
|
return std::nullopt;
|
|
}
|
|
return std::move(loaded.config);
|
|
}
|
|
|
|
constexpr std::array<OptionSpec, 9> FreezeOptions = {{
|
|
{"--repo", true, "REPO", "the repository (toplevel of a checkout; default: the working directory)"},
|
|
{"--slug", true, "SLUG", "the artifact dir name: .review-gates/push/<slug>/"},
|
|
{"--out", true, "OUT", "the artifact dir (overrides --slug)"},
|
|
{"--base", true, "BASE", "the range base; default @{upstream}, else merge-base with the configured base branch"},
|
|
{"--replace", false, "", "re-freeze into an out dir that already holds patches (gate-state/ is kept) and supersede this repo's worktrees"},
|
|
{"--worktree", false, "", "check out a detached worktree of HEAD under .review-gates/trees/ for the lenses (printed as --trees)"},
|
|
{"--require-signed", false, "", "an unsigned commit fails instead of warning"},
|
|
{"--date", true, "DATE", "the run date (default today)"},
|
|
{"--verify", false, "", "push-time check: HEAD, a clean tree and a fresh export still match freeze.json"},
|
|
}};
|
|
|
|
std::int32_t FreezeCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
|
|
constexpr std::string_view Prog = "crafter-review freeze";
|
|
const Parsed parsed = ParseOptions(args, FreezeOptions);
|
|
if (parsed.help) {
|
|
std::print("{}", Help(Prog, "cut the frozen commit-range artifact for the push gates (process/prepush.md Gate 0)", FreezeOptions));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, FreezeOptions), Prog, parsed.error);
|
|
const std::optional<Json> config = LoadConfig(configPath);
|
|
if (!config) return 3;
|
|
Freeze::Arguments arguments;
|
|
arguments.repo = parsed.Value("repo");
|
|
arguments.slug = parsed.Value("slug");
|
|
arguments.out = parsed.Value("out");
|
|
arguments.base = parsed.Value("base");
|
|
arguments.replace = parsed.Flag("replace");
|
|
arguments.worktree = parsed.Flag("worktree");
|
|
arguments.requireSigned = parsed.Flag("require-signed");
|
|
arguments.date = parsed.Value("date").value_or(Platform::TodayIso());
|
|
arguments.verify = parsed.Flag("verify");
|
|
// `%G?` starts gpg, which can leave a daemon behind: as a subreaper
|
|
// this process inherits it, and takes it down on the way out.
|
|
Process::BecomeSubreaper();
|
|
const std::int32_t code = Freeze::Main(arguments, *config);
|
|
Process::KillDescendants();
|
|
return code;
|
|
}
|
|
}
|
|
|
|
std::int32_t Main(std::span<const std::string_view> args) {
|
|
const std::span<const std::string_view> rest = args.empty() ? args : args.subspan(1);
|
|
// The supervisor first and alone: it is single-threaded and has to
|
|
// stay so, and it runs between the driver and claude, not as a
|
|
// command anyone types.
|
|
if (!rest.empty() && rest.front() == Process::SuperviseVerb) return Process::SuperviseMain(rest.subspan(1));
|
|
Platform::Init();
|
|
// The options before the command: --config FILE or --config=FILE, and
|
|
// any unique prefix of a long option.
|
|
std::optional<std::string> configPath;
|
|
std::size_t at = 0;
|
|
while (at < rest.size() && rest[at].starts_with("--") && rest[at] != "--") {
|
|
const std::string_view arg = rest[at];
|
|
const std::size_t equals = arg.find('=');
|
|
const std::string_view name = arg.substr(0, equals);
|
|
if (name.size() > 2 && std::string_view("--config").starts_with(name)) {
|
|
if (equals != std::string_view::npos) {
|
|
configPath = std::string(arg.substr(equals + 1));
|
|
} else if (at + 1 < rest.size()) {
|
|
configPath = std::string(rest[++at]);
|
|
} else {
|
|
return UsageError(Usage, "crafter-review", "argument --config: expected one argument");
|
|
}
|
|
} else if (name.size() > 2 && std::string_view("--version").starts_with(name)) {
|
|
return PrintVersion();
|
|
} else if (name.size() > 2 && std::string_view("--help").starts_with(name)) {
|
|
std::println("{}", Usage);
|
|
return 0;
|
|
} else {
|
|
return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", arg));
|
|
}
|
|
++at;
|
|
}
|
|
if (at < rest.size() && rest[at] == "-h") {
|
|
std::println("{}", Usage);
|
|
return 0;
|
|
}
|
|
if (at >= rest.size()) return UsageError(Usage, "crafter-review", "a command is required");
|
|
const std::string_view command = rest[at];
|
|
const std::span<const std::string_view> after = rest.subspan(at + 1);
|
|
try {
|
|
if (command == "inventory" && !configPath) return Inventory::Main(after);
|
|
if (command == "config") {
|
|
if (!after.empty()) return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", after.front()));
|
|
return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
|
}
|
|
if (command == "freeze") return FreezeCommand(after, configPath);
|
|
} catch (const Dyn::Error& error) {
|
|
// An error no command catches: its message, exit 1.
|
|
std::println(std::cerr, "crafter-review: {}: {}", error.Kind(), error.what());
|
|
return 1;
|
|
}
|
|
return UsageError(Usage, "crafter-review", std::format("argument cmd: invalid choice: '{}'", command));
|
|
}
|
|
}
|