feat(pipeline): the pure pipeline, checked against its goldens
The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b02de3ceff
commit
741e3a656d
70 changed files with 30565 additions and 21 deletions
116
implementations/Crafter.Review-Artifact.cpp
Normal file
116
implementations/Crafter.Review-Artifact.cpp
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Artifact_impl;
|
||||
import :Artifact;
|
||||
import :Text;
|
||||
import :Crypto;
|
||||
import :Platform;
|
||||
import :Process;
|
||||
import std;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace Crafter::Review::Artifact {
|
||||
namespace {
|
||||
std::string ReadBytes(const fs::path& path) {
|
||||
std::optional<std::string> bytes = Platform::ReadFile(path);
|
||||
if (!bytes) throw std::runtime_error(std::format("{}: cannot read", path.string()));
|
||||
return std::move(*bytes);
|
||||
}
|
||||
|
||||
// Four decimal digits and a '-' at `at`, as (\d{4})-: the number.
|
||||
std::optional<std::int64_t> FourDigits(std::string_view name, std::size_t at) {
|
||||
std::size_t end = at;
|
||||
for (std::int32_t i = 0; i < 4; ++i) {
|
||||
if (end >= name.size()) return std::nullopt;
|
||||
const auto [c, used] = Dyn::CodePointAt(name, end);
|
||||
if (!Dyn::IsDecimal(c)) return std::nullopt;
|
||||
end += used;
|
||||
}
|
||||
if (end >= name.size() || name[end] != '-') return std::nullopt;
|
||||
return Dyn::ParseInt(name.substr(at, end - at));
|
||||
}
|
||||
}
|
||||
|
||||
std::string Sha256File(const fs::path& path) {
|
||||
return Sha256Hex(ReadBytes(path));
|
||||
}
|
||||
|
||||
std::string ArtifactHash(std::string_view mode, const fs::path& path) {
|
||||
if (mode == "reply") return Sha256File(path);
|
||||
std::string all;
|
||||
for (const std::string& name : PatchFiles(path)) all += ReadBytes(path / name);
|
||||
return Sha256Hex(all);
|
||||
}
|
||||
|
||||
std::vector<std::string> PatchFiles(const fs::path& path) {
|
||||
std::vector<std::string> out;
|
||||
for (const fs::directory_entry& entry : fs::directory_iterator(path)) {
|
||||
std::string name = entry.path().filename().string();
|
||||
if (name.ends_with(".patch")) out.push_back(std::move(name));
|
||||
}
|
||||
std::ranges::sort(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::int64_t> PatchNumber(std::string_view name) {
|
||||
if (name.starts_with('v')) {
|
||||
const std::size_t digits = Dyn::DecimalRun(name, 1);
|
||||
if (digits > 0 && 1 + digits < name.size() && name[1 + digits] == '-') {
|
||||
if (const std::optional<std::int64_t> k = FourDigits(name, 2 + digits)) return k;
|
||||
}
|
||||
}
|
||||
return FourDigits(name, 0);
|
||||
}
|
||||
|
||||
std::map<std::int64_t, std::string> PatchTexts(const fs::path& path) {
|
||||
std::map<std::int64_t, std::string> out;
|
||||
for (const std::string& name : PatchFiles(path)) {
|
||||
const std::optional<std::int64_t> k = PatchNumber(name);
|
||||
if (!k) throw Dyn::Error("AttributeError", "'NoneType' object has no attribute 'group'");
|
||||
out[*k] = Dyn::ReadText(path / name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::string> PatchHead(const fs::path& path) {
|
||||
const std::vector<std::string> files = PatchFiles(path);
|
||||
if (files.empty()) return std::nullopt;
|
||||
// open(errors='replace').readline().split(): the first line, universal
|
||||
// newlines and all.
|
||||
const std::string text = Dyn::UniversalNewlines(Dyn::DecodeReplace(ReadBytes(path / files.back())));
|
||||
const std::vector<std::string> first = Dyn::Split(std::string_view(text).substr(0, text.find('\n')));
|
||||
if (first.size() > 1 && first[0] == "From" && Dyn::Length(first[1]) == 40) return first[1];
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string Git(const fs::path& tree, std::span<const std::string> args) {
|
||||
std::vector<std::string> argv = {"git", "-C", tree.string()};
|
||||
argv.insert(argv.end(), args.begin(), args.end());
|
||||
const Process::Captured run = Process::Run(argv);
|
||||
if (run.returnCode != 0) {
|
||||
const std::string& said = run.err.empty() ? run.out : run.err;
|
||||
throw Dyn::Error("RuntimeError", std::format("git -C {} {}: {}", tree.string(), Dyn::Join(args, " "),
|
||||
Dyn::Strip(Dyn::UniversalNewlines(Dyn::DecodeReplace(said)))));
|
||||
}
|
||||
return Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out));
|
||||
}
|
||||
|
||||
bool IsToplevel(const fs::path& tree) {
|
||||
try {
|
||||
const std::string top = Dyn::Strip(Git(tree, std::array<std::string, 2>{"rev-parse", "--show-toplevel"}));
|
||||
return fs::weakly_canonical(fs::absolute(top)) == fs::weakly_canonical(fs::absolute(tree));
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::string TreeHead(const fs::path& tree) {
|
||||
return Dyn::Strip(Git(tree, std::array<std::string, 2>{"rev-parse", "HEAD"}));
|
||||
}
|
||||
|
||||
std::string TreeDirty(const fs::path& tree) {
|
||||
return Dyn::Strip(Git(tree, std::array<std::string, 3>{"status", "--porcelain", "--untracked-files=no"}));
|
||||
}
|
||||
}
|
||||
464
implementations/Crafter.Review-Config.cpp
Normal file
464
implementations/Crafter.Review-Config.cpp
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Config_impl;
|
||||
import :Config;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import :Core;
|
||||
import :Plan;
|
||||
import :Tools;
|
||||
import :PgWire;
|
||||
import :Process;
|
||||
import std;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace Crafter::Review::Config {
|
||||
namespace {
|
||||
// Keys a consumer's config may still carry, with what replaced them.
|
||||
struct Retired {
|
||||
std::string_view section;
|
||||
std::string_view key;
|
||||
std::string why;
|
||||
};
|
||||
|
||||
std::vector<Retired> RetiredKeys() {
|
||||
return {{
|
||||
"functional", "concurrency",
|
||||
std::format("passes run as many at once as free memory allows, each at its environment's measured cost, and a pass that drives "
|
||||
"the application holds a stack of its own (2026-10-22); for more of them at once bring up more stacks "
|
||||
"(slots.*, `{} slots`)", Invocation()),
|
||||
}};
|
||||
}
|
||||
|
||||
std::string EnvironmentNames(std::string_view separator) {
|
||||
std::vector<std::string> names;
|
||||
for (const Plan::Environment& environment : Plan::Environments) names.emplace_back(environment.name);
|
||||
return Dyn::Join(names, separator);
|
||||
}
|
||||
|
||||
Json EnvironmentTable() {
|
||||
Json table = Json::object();
|
||||
for (const Plan::Environment& environment : Plan::Environments) table[std::string(environment.name)] = true;
|
||||
return table;
|
||||
}
|
||||
|
||||
bool AllStrings(const Json& list) {
|
||||
return list.is_array() && std::ranges::all_of(list, [](const Json& item) { return item.is_string(); });
|
||||
}
|
||||
|
||||
// ^(Bash\([^()]+:\*\)|mcp__[A-Za-z0-9_-]+(__[A-Za-z0-9_-]+)?)$; `$`
|
||||
// also matches before a final newline.
|
||||
bool IsToolEntry(std::string_view tool) {
|
||||
std::string_view body = tool;
|
||||
if (body.ends_with('\n')) body.remove_suffix(1);
|
||||
if (body.starts_with("Bash(") && body.ends_with(":*)") && body.size() > 8) {
|
||||
const std::string_view prefix = body.substr(5, body.size() - 8);
|
||||
return !prefix.contains('(') && !prefix.contains(')');
|
||||
}
|
||||
if (body.starts_with("mcp__") && body.size() > 5) {
|
||||
return std::ranges::all_of(body.substr(5), [](char c) {
|
||||
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-';
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// re.match(r'^https?://', text)
|
||||
bool IsHttpUrl(std::string_view text) {
|
||||
return text.starts_with("http://") || text.starts_with("https://");
|
||||
}
|
||||
|
||||
// An integer or a float, not a bool.
|
||||
bool IsRealNumber(const Json& value) {
|
||||
return value.is_number();
|
||||
}
|
||||
|
||||
bool Positive(const Json& value) {
|
||||
return value.is_number_float() ? value.get<double>() > 0.0 : (value.is_number_unsigned() ? value.get<std::uint64_t>() > 0 : value.get<std::int64_t>() > 0);
|
||||
}
|
||||
|
||||
std::vector<std::string> Merge(Json& destination, const Json& source, const std::string& path) {
|
||||
std::vector<std::string> problems;
|
||||
for (auto it = source.begin(); it != source.end(); ++it) {
|
||||
const std::string& key = it.key();
|
||||
if (!Dyn::Has(destination, key)) {
|
||||
problems.push_back(std::format("unknown key {}{}", path, key));
|
||||
continue;
|
||||
}
|
||||
Json& target = destination[key];
|
||||
if (target.is_object() && it.value().is_object()) {
|
||||
std::ranges::move(Merge(target, it.value(), std::format("{}{}.", path, key)), std::back_inserter(problems));
|
||||
} else {
|
||||
target = it.value();
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// The config's entry for the lens, else its default one.
|
||||
const Json& LensEntry(const Json& config, std::string_view lensFile) {
|
||||
static const Json defaults = Defaults()["lenses"];
|
||||
const Json& lenses = Dyn::Get(config, "lenses");
|
||||
const std::string name = Plan::LensName(lensFile);
|
||||
const Json* entry = lenses.is_object() ? &Dyn::Get(lenses, name) : nullptr;
|
||||
return entry && entry->is_object() ? *entry : defaults.at(name);
|
||||
}
|
||||
|
||||
// A config or a slot file as JSON: the value, or the text that goes
|
||||
// into the "not JSON (...)" problem.
|
||||
std::variant<Json, std::string> LoadJson(const fs::path& path) {
|
||||
try {
|
||||
return Dyn::LoadJsonFile(path);
|
||||
} catch (const std::exception& error) {
|
||||
return std::string(error.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Json Defaults() {
|
||||
Json lenses = Json::object();
|
||||
for (const Plan::Lens& lens : Plan::Lenses) lenses[Plan::LensName(lens.file)] = {{"run", true}, {"environment", lens.env}};
|
||||
return {
|
||||
// The integration branch a range is measured against (e.g.
|
||||
// origin/dev): merge-base HEAD <base>.
|
||||
{"base", nullptr},
|
||||
// upstream: the push delta @{upstream}..HEAD, the base branch only
|
||||
// when HEAD tracks nothing; base: always merge-base HEAD
|
||||
// <base>..HEAD, the whole pull request.
|
||||
{"rangeFrom", "upstream"},
|
||||
// Author and committer emails the freeze accepts; null: the
|
||||
// repository's `git config user.email`.
|
||||
{"authors", nullptr},
|
||||
// forbid | allow: Assisted-by / Co-Authored-By trailers in the
|
||||
// range.
|
||||
{"aiTrailers", "forbid"},
|
||||
// An unsigned commit fails the freeze (else a warning).
|
||||
{"requireSigned", false},
|
||||
// Tracked paths the local stack rewrites in place while the lenses
|
||||
// run.
|
||||
{"allowDirty", Json::array()},
|
||||
// The commands Gate 0 runs by hand, in order (documentation for the
|
||||
// skill; the driver runs none of them).
|
||||
{"gate0", Json::array()},
|
||||
// Per lens: run (false: left out of every run of this repository)
|
||||
// and environment (Plan::Environments).
|
||||
{"lenses", lenses},
|
||||
// The database under test, for every lens whose environment
|
||||
// reaches it. The URL reaches a db pass's broker, never a prompt,
|
||||
// and the dump and the log show it with the password masked.
|
||||
{"supabase", {{"dbUrl", "postgresql://postgres:postgres@127.0.0.1:54322/postgres"}, {"start", nullptr}, {"stop", nullptr}, {"notes", ""}}},
|
||||
// Stacks side by side (2026-10-22): a pass that drives an
|
||||
// application holds one of its own.
|
||||
{"slots", {{"dir", nullptr}, {"start", nullptr}, {"stop", nullptr}, {"memMb", nullptr}, {"startMb", nullptr}}},
|
||||
// The application under test, for every lens whose environment
|
||||
// reaches one.
|
||||
{"functional", {
|
||||
{"appUrl", "http://localhost:8080"},
|
||||
{"readyPath", "/"},
|
||||
{"apiUrls", Json::array()},
|
||||
{"start", nullptr},
|
||||
{"stop", nullptr},
|
||||
{"login", {{"fixtures", Json::array()}, {"notes", ""}}},
|
||||
{"exclude", Json::array()},
|
||||
{"extraTools", Json::array()},
|
||||
{"mcp", {{"command", "npx"}, {"args", nullptr}}},
|
||||
{"notes", ""},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
Loaded Load(const fs::path& repo, const fs::path& path) {
|
||||
Loaded loaded{Defaults(), {}};
|
||||
Json& config = loaded.config;
|
||||
const fs::path p = path.empty() ? repo / ConfigFile : path;
|
||||
std::vector<std::string> warnings;
|
||||
if (fs::exists(p)) {
|
||||
std::variant<Json, std::string> read = LoadJson(p);
|
||||
if (std::holds_alternative<std::string>(read)) {
|
||||
loaded.problems.push_back(std::format("{}: not JSON ({})", p.string(), std::get<std::string>(read)));
|
||||
return loaded;
|
||||
}
|
||||
Json data = std::move(std::get<Json>(read));
|
||||
if (!data.is_object()) {
|
||||
loaded.problems.push_back(std::format("{}: the top level must be an object", p.string()));
|
||||
return loaded;
|
||||
}
|
||||
for (const Retired& retired : RetiredKeys()) {
|
||||
const Json& section = Dyn::Get(data, retired.section);
|
||||
if (section.is_object() && Dyn::Has(section, retired.key)) {
|
||||
warnings.push_back(std::format("{}.{} is no longer read - remove it from {}: {}", retired.section, retired.key, p.filename().string(), retired.why));
|
||||
data[std::string(retired.section)].erase(std::string(retired.key));
|
||||
}
|
||||
}
|
||||
loaded.problems = Merge(config, data, "");
|
||||
} else if (!path.empty()) {
|
||||
loaded.problems.push_back(std::format("{}: no such file", p.string()));
|
||||
}
|
||||
config["_path"] = fs::exists(p) ? Json(p.string()) : Json();
|
||||
Json warningList = Json::array();
|
||||
for (const std::string& warning : warnings) warningList.push_back(warning);
|
||||
config["_warnings"] = std::move(warningList);
|
||||
std::ranges::move(Validate(config), std::back_inserter(loaded.problems));
|
||||
return loaded;
|
||||
}
|
||||
|
||||
std::vector<std::string> Validate(const Json& config) {
|
||||
std::vector<std::string> out;
|
||||
const Json& trailers = Dyn::At(config, "aiTrailers");
|
||||
if (trailers != "forbid" && trailers != "allow") out.emplace_back("aiTrailers must be 'forbid' or 'allow'");
|
||||
const Json& rangeFrom = Dyn::At(config, "rangeFrom");
|
||||
if (rangeFrom != "upstream" && rangeFrom != "base") out.emplace_back("rangeFrom must be 'upstream' or 'base'");
|
||||
if (rangeFrom == "base" && !Dyn::Truthy(Dyn::At(config, "base"))) out.emplace_back("rangeFrom 'base' needs a base branch");
|
||||
const Json& authors = Dyn::At(config, "authors");
|
||||
if (!authors.is_null() && (!authors.is_array() || !std::ranges::all_of(authors, [](const Json& a) {
|
||||
return a.is_string() && a.get_ref<const std::string&>().contains('@');
|
||||
}))) {
|
||||
out.emplace_back("authors must be null or a list of email addresses");
|
||||
}
|
||||
for (const std::string_view key : {"allowDirty", "gate0"}) {
|
||||
if (!AllStrings(Dyn::At(config, key))) out.push_back(std::format("{} must be a list of strings", key));
|
||||
}
|
||||
const Json environments = EnvironmentTable();
|
||||
Json lenses = Dyn::At(config, "lenses");
|
||||
if (!lenses.is_object()) {
|
||||
out.push_back(std::format("lenses must be an object: {{<lens name>: {{run: bool, environment: {}}}}}", EnvironmentNames("|")));
|
||||
lenses = Json::object();
|
||||
}
|
||||
for (auto it = lenses.begin(); it != lenses.end(); ++it) {
|
||||
const Json& entry = it.value();
|
||||
if (!entry.is_object()) {
|
||||
out.push_back(std::format("lenses.{} must be an object {{run: bool, environment: {}}}", it.key(), EnvironmentNames("|")));
|
||||
continue;
|
||||
}
|
||||
if (!Dyn::Get(entry, "run").is_boolean()) out.push_back(std::format("lenses.{}.run must be true or false", it.key()));
|
||||
if (!Dyn::In(Dyn::Get(entry, "environment"), environments)) out.push_back(std::format("lenses.{}.environment must be one of {}", it.key(), EnvironmentNames(", ")));
|
||||
}
|
||||
auto runs = [](const Json& entry) { return entry.is_object() && Dyn::Get(entry, "run") == true; };
|
||||
if (!lenses.empty() && !std::ranges::any_of(lenses, runs)) out.emplace_back("lenses: every lens is off - at least one must run");
|
||||
for (const Plan::Lens& lens : Plan::Lenses) {
|
||||
const Json& entry = Dyn::Get(lenses, Plan::LensName(lens.file));
|
||||
if (!lens.empirical || !runs(entry)) continue;
|
||||
const Json& name = Dyn::Get(entry, "environment");
|
||||
if (!Dyn::In(name, environments)) continue;
|
||||
if (!Plan::FindEnvironment(name.get_ref<const std::string&>())->app) {
|
||||
std::vector<std::string> withApp;
|
||||
for (const Plan::Environment& environment : Plan::Environments) {
|
||||
if (environment.app) withApp.emplace_back(environment.name);
|
||||
}
|
||||
out.push_back(std::format("lenses.{}: an empirical lens needs an environment with an application under test ({}) - or turn it off", Plan::LensName(lens.file),
|
||||
Dyn::Join(withApp, ", ")));
|
||||
}
|
||||
}
|
||||
const Json& supabase = Dyn::At(config, "supabase");
|
||||
if (!supabase.is_object()) {
|
||||
out.emplace_back("supabase must be an object {dbUrl, start, stop, notes}");
|
||||
} else {
|
||||
if (const std::optional<std::string> problem = PgWire::CheckUrl(Dyn::Get(supabase, "dbUrl"))) out.push_back(std::format("supabase.dbUrl: {}", *problem));
|
||||
for (const std::string_view key : {"start", "stop"}) {
|
||||
const Json& value = Dyn::Get(supabase, key);
|
||||
if (!value.is_null() && !value.is_string()) out.push_back(std::format("supabase.{} must be null or a command line", key));
|
||||
}
|
||||
if (!Dyn::Get(supabase, "notes").is_string()) out.emplace_back("supabase.notes must be text");
|
||||
}
|
||||
const Json& functional = Dyn::At(config, "functional");
|
||||
if (!functional.is_object()) {
|
||||
out.emplace_back("functional must be an object");
|
||||
return out;
|
||||
}
|
||||
// The application's URL matters only to a lens that drives it.
|
||||
const bool drivesApp = std::ranges::any_of(lenses, [&](const Json& entry) {
|
||||
if (!runs(entry)) return false;
|
||||
const Json& name = Dyn::Get(entry, "environment");
|
||||
if (name.is_array() || name.is_object()) throw Dyn::Error("TypeError", std::format("unhashable type: '{}'", Dyn::TypeName(name)));
|
||||
const Plan::Environment* environment = name.is_string() ? Plan::FindEnvironment(name.get_ref<const std::string&>()) : nullptr;
|
||||
return environment && environment->app;
|
||||
});
|
||||
if (drivesApp && !IsHttpUrl(Dyn::StrOr(Dyn::At(functional, "appUrl"), ""))) out.emplace_back("functional.appUrl must be an http(s) URL");
|
||||
if (!AllStrings(Dyn::At(functional, "exclude"))) out.emplace_back("functional.exclude must be a list of globs");
|
||||
const Json& extraTools = Dyn::At(functional, "extraTools");
|
||||
if (!extraTools.is_array() || !std::ranges::all_of(extraTools, [](const Json& x) { return x.is_string() && IsToolEntry(x.get_ref<const std::string&>()); })) {
|
||||
out.emplace_back("functional.extraTools entries must be Bash(<prefix>:*) or mcp__<server>[__<tool>]");
|
||||
} else {
|
||||
std::vector<std::string> dead;
|
||||
for (const Json& tool : extraTools) {
|
||||
if (Tools::Denied(tool.get_ref<const std::string&>())) dead.push_back(tool.get<std::string>());
|
||||
}
|
||||
if (!dead.empty()) {
|
||||
out.push_back(std::format("functional.extraTools: {} - denied to every pass (pool.DENY wins over an allowlist); "
|
||||
"a lens reaches the database through the supabase environment, every call rolled back", Dyn::Join(dead, ", ")));
|
||||
}
|
||||
}
|
||||
const Json& login = Dyn::At(functional, "login");
|
||||
if (!login.is_object() || !(Dyn::Has(login, "fixtures") ? Dyn::At(login, "fixtures").is_array() : true)) out.emplace_back("functional.login must be {fixtures: [paths], notes: text}");
|
||||
const Json& slots = Dyn::At(config, "slots");
|
||||
if (!slots.is_object()) {
|
||||
out.emplace_back("slots must be an object {dir, start, stop, memMb, startMb}");
|
||||
} else if (!Dyn::Get(slots, "dir").is_null()) {
|
||||
const Json& dir = Dyn::At(slots, "dir");
|
||||
bool bad = !dir.is_string();
|
||||
if (!bad) {
|
||||
const std::string& d = dir.get_ref<const std::string&>();
|
||||
const std::vector<std::string> parts = Dyn::Split(Dyn::Replace(d, "\\", "/"), "/");
|
||||
bad = Dyn::Strip(d).empty() || d.starts_with('/') || std::ranges::contains(parts, std::string(".."));
|
||||
}
|
||||
if (bad) out.emplace_back("slots.dir must be null or a directory inside the repository (a relative path)");
|
||||
for (const std::string_view key : {"start", "stop"}) {
|
||||
const Json& value = Dyn::Get(slots, key);
|
||||
if (!value.is_null() && (!value.is_string() || !value.get_ref<const std::string&>().contains("{n}"))) {
|
||||
out.push_back(std::format("slots.{} must be null or a command line with {{n}}, the slot number", key));
|
||||
}
|
||||
}
|
||||
const Json& memMb = Dyn::Get(slots, "memMb");
|
||||
if (!IsRealNumber(memMb) || !Positive(memMb)) {
|
||||
out.push_back(std::format("slots.memMb must be the measured memory of one slot in MB (a number > 0): `{} slots` sizes the slots by it", Invocation()));
|
||||
}
|
||||
const Json& startMb = Dyn::Get(slots, "startMb");
|
||||
if (!startMb.is_null() && (!IsRealNumber(startMb) || !Positive(startMb))) {
|
||||
out.emplace_back("slots.startMb must be null or the measured peak of one slot coming up in MB (a number > 0)");
|
||||
}
|
||||
}
|
||||
const Json& args = Dyn::Get(Dyn::At(functional, "mcp"), "args");
|
||||
if (!args.is_null() && !AllStrings(args)) out.emplace_back("functional.mcp.args must be null or a list of strings");
|
||||
return out;
|
||||
}
|
||||
|
||||
Json Redacted(const Json& config) {
|
||||
Json copy = Json::object();
|
||||
for (auto it = config.begin(); it != config.end(); ++it) {
|
||||
if (!it.key().starts_with('_')) copy[it.key()] = it.value();
|
||||
}
|
||||
if (Dyn::Get(copy, "supabase").is_object() && Dyn::Get(copy["supabase"], "dbUrl").is_string()) {
|
||||
copy["supabase"]["dbUrl"] = PgWire::Redact(copy["supabase"]["dbUrl"].get<std::string>());
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
std::string GitUserEmail(const fs::path& repo) {
|
||||
const Process::Captured run = Process::Run({"git", "-C", repo.string(), "config", "user.email"});
|
||||
return run.returnCode == 0 ? Dyn::Strip(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out))) : std::string();
|
||||
}
|
||||
|
||||
std::vector<std::string> Authors(const Json& config, const fs::path& repo) {
|
||||
const Json& authors = Dyn::Get(config, "authors");
|
||||
if (Dyn::Truthy(authors)) {
|
||||
std::vector<std::string> out;
|
||||
for (const Json& author : authors) out.push_back(Dyn::Str(author));
|
||||
return out;
|
||||
}
|
||||
const std::string email = GitUserEmail(repo);
|
||||
if (email.empty()) return {};
|
||||
return {email};
|
||||
}
|
||||
|
||||
std::vector<std::string> LensesOff(const Json& config) {
|
||||
std::vector<std::string> out;
|
||||
for (const Plan::Lens& lens : Plan::Lenses) {
|
||||
if (Dyn::Get(LensEntry(config, lens.file), "run") == false) out.emplace_back(lens.file);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Json Environments(const Json& config) {
|
||||
Json out = Json::object();
|
||||
for (const Plan::Lens& lens : Plan::Lenses) {
|
||||
const Json& entry = LensEntry(config, lens.file);
|
||||
out[std::string(lens.file)] = Dyn::Has(entry, "environment") ? Dyn::At(entry, "environment") : Json("none");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Slots SlotStacks(const Json& config, const fs::path& repo) {
|
||||
Slots slots;
|
||||
const Json& dir = Dyn::Get(Dyn::Or(Dyn::Get(config, "slots"), Dyn::EmptyDict()), "dir");
|
||||
if (!Dyn::Truthy(dir) || !fs::is_directory(repo / dir.get<std::string>())) return slots;
|
||||
const std::string d = dir.get<std::string>();
|
||||
std::vector<std::string> names;
|
||||
for (const fs::directory_entry& entry : fs::directory_iterator(repo / d)) names.push_back(entry.path().filename().string());
|
||||
std::ranges::stable_sort(names, {}, [](const std::string& name) { return Dyn::Natural(name); });
|
||||
for (const std::string& n : names) {
|
||||
const fs::path file = repo / d / n / SlotFile;
|
||||
if (!fs::is_regular_file(file)) continue;
|
||||
const std::string rel = Dyn::JoinPath(Dyn::JoinPath(d, n), SlotFile);
|
||||
std::variant<Json, std::string> read = LoadJson(file);
|
||||
if (std::holds_alternative<std::string>(read)) {
|
||||
slots.problems.push_back(std::format("{}: not JSON ({})", rel, std::get<std::string>(read)));
|
||||
continue;
|
||||
}
|
||||
const Json& s = std::get<Json>(read);
|
||||
if (!s.is_object() || !IsHttpUrl(Dyn::StrOr(Dyn::Get(s, "appUrl"), ""))) {
|
||||
slots.problems.push_back(std::format("{}: no appUrl", rel));
|
||||
continue;
|
||||
}
|
||||
const Json& api = Dyn::Or(Dyn::Get(s, "apiUrls"), Dyn::EmptyList());
|
||||
if (!AllStrings(api)) {
|
||||
slots.problems.push_back(std::format("{}: apiUrls must be a list of URLs", rel));
|
||||
continue;
|
||||
}
|
||||
const Json& db = Dyn::Get(s, "dbUrl");
|
||||
if (!db.is_null()) {
|
||||
if (const std::optional<std::string> problem = PgWire::CheckUrl(db)) {
|
||||
slots.problems.push_back(std::format("{}: dbUrl: {}", rel, *problem));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const Json& login = Dyn::Get(s, "login");
|
||||
if (!login.is_null() && (!login.is_string() || !fs::exists(repo / login.get<std::string>()))) {
|
||||
slots.problems.push_back(std::format("{}: login {} is not a file", rel, Dyn::ReprValue(login)));
|
||||
continue;
|
||||
}
|
||||
slots.stacks.push_back({
|
||||
{"id", std::format("slot {}", n)},
|
||||
{"appUrl", Dyn::RStrip(Dyn::At(s, "appUrl").get<std::string>(), "/")},
|
||||
{"apiUrls", api},
|
||||
{"dbUrl", db},
|
||||
{"login", login},
|
||||
{"slot", true},
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
std::vector<std::string> SlotCommands(const Json& config, std::string_view which, std::span<const std::int64_t> slots) {
|
||||
const Json& command = Dyn::Get(Dyn::Or(Dyn::Get(config, "slots"), Dyn::EmptyDict()), which);
|
||||
if (!Dyn::Truthy(command)) return {};
|
||||
std::vector<std::string> out;
|
||||
for (const std::int64_t n : slots) out.push_back(Dyn::Replace(command.get<std::string>(), "{n}", std::to_string(n)));
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::string> LoginFixture(const Json& config, const fs::path& repo) {
|
||||
const Json& login = Dyn::Or(Dyn::Get(Dyn::At(config, "functional"), "login"), Dyn::EmptyDict());
|
||||
for (const Json& fixture : Dyn::Or(Dyn::Get(login, "fixtures"), Dyn::EmptyList())) {
|
||||
if (fs::exists(repo / fixture.get<std::string>())) return fixture.get<std::string>();
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Json Example() {
|
||||
Json example = Defaults();
|
||||
example["base"] = "origin/main";
|
||||
example["authors"] = {"you@example.com"};
|
||||
example["aiTrailers"] = "forbid";
|
||||
example["gate0"] = {"make check"};
|
||||
Json& functional = example["functional"];
|
||||
functional["appUrl"] = "http://localhost:8080";
|
||||
functional["start"] = "make run";
|
||||
functional["stop"] = "make stop";
|
||||
functional["login"] = {{"fixtures", {".local-stack/test-user.json"}}, {"notes", "the fixture holds a TOTP-verified login"}};
|
||||
functional["exclude"] = {"**/*.test.*", "**/*.spec.*", "docs/**", ".github/**", "*.md"};
|
||||
return example;
|
||||
}
|
||||
|
||||
std::int32_t Main(const fs::path& repo, const fs::path& path) {
|
||||
const Loaded loaded = Load(repo, path);
|
||||
Json shown = Redacted(loaded.config);
|
||||
// The database password masked, as in the dump.
|
||||
shown["_path"] = Dyn::Get(loaded.config, "_path");
|
||||
std::println("{}", DumpFile(shown));
|
||||
for (const Json& warning : Dyn::Or(Dyn::Get(loaded.config, "_warnings"), Dyn::EmptyList())) std::println("warning: {}", Dyn::Str(warning));
|
||||
for (const std::string& problem : loaded.problems) std::println("problem: {}", problem);
|
||||
return loaded.problems.empty() ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,11 @@ namespace Crafter::Review {
|
|||
return std::format("crafter-review {} (prompt v{})", Version(), PromptVersion);
|
||||
}
|
||||
|
||||
std::string Invocation() {
|
||||
if (Platform::InSandbox()) return "flatpak run net.catcrafts.CrafterReview";
|
||||
return Platform::SelfExe().string();
|
||||
}
|
||||
|
||||
const fs::path& Home() {
|
||||
if (!HomeOverride()) HomeOverride() = FindHome();
|
||||
return *HomeOverride();
|
||||
|
|
|
|||
158
implementations/Crafter.Review-Dispositions.cpp
Normal file
158
implementations/Crafter.Review-Dispositions.cpp
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Dispositions_impl;
|
||||
import :Dispositions;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import :Platform;
|
||||
import std;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace Crafter::Review::Dispositions {
|
||||
namespace {
|
||||
// A map key, as text: ids are compared by value and type.
|
||||
std::string KeyOf(const Json& value) {
|
||||
return DumpCompactSorted(value);
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> Statuses(std::span<const Json> rows) {
|
||||
std::map<std::string, std::string> out;
|
||||
for (const Json& row : rows) out[KeyOf(Dyn::At(row, "id"))] = Dyn::At(row, "status").get<std::string>();
|
||||
return out;
|
||||
}
|
||||
|
||||
// A string, or Error (TypeError): only a string is concatenated.
|
||||
const std::string& Text(const Json& value) {
|
||||
if (!value.is_string()) throw Dyn::Error("TypeError", std::format("can only concatenate str (not \"{}\") to str", Dyn::TypeName(value)));
|
||||
return value.get_ref<const std::string&>();
|
||||
}
|
||||
|
||||
std::string JoinStrings(const Json& list, std::string_view separator) {
|
||||
std::vector<std::string> parts;
|
||||
for (const Json& item : list) {
|
||||
if (!item.is_string()) throw Dyn::Error("TypeError", std::format("sequence item {}: expected str instance, {} found", parts.size(), Dyn::TypeName(item)));
|
||||
parts.push_back(item.get<std::string>());
|
||||
}
|
||||
return Dyn::Join(parts, separator);
|
||||
}
|
||||
|
||||
std::size_t Len(const Json& value) {
|
||||
return value.is_string() ? Dyn::Length(value.get_ref<const std::string&>()) : value.size();
|
||||
}
|
||||
|
||||
constexpr std::string_view Rules =
|
||||
"Rules: if you detect a finding one of these entries names, STILL REPORT IT and set dischargedBy to its id and its unit id in items:[] - detection never changes, only disposition, and Gate 4 must see it. "
|
||||
"Before setting dischargedBy, VERIFY that entry's grounds against its cited evidence (the repository, the evidence paths when INPUTS names them, the running application for the functional lens). "
|
||||
"If the grounds do not survive, return the finding WITHOUT dischargedBy and set contests {id, why} with the evidence - a contest is a claim and will itself be verified. "
|
||||
"Record that verification inside the finding's evidence field. For a refuted entry you agree with, return a note kind=refutation-verified with its id instead of a finding. "
|
||||
"An entry with no matching finding of yours: ignore it - nothing is owed for it.";
|
||||
}
|
||||
|
||||
std::vector<Json> Load(std::string_view path) {
|
||||
if (path.empty()) return {};
|
||||
const std::optional<std::string> text = Platform::ReadFile(fs::path(path));
|
||||
if (!text) throw Dyn::Error("FileNotFoundError", std::format("[Errno 2] No such file or directory: {}", Dyn::Repr(path)));
|
||||
const Json document = Json::parse(*text);
|
||||
std::vector<Json> out;
|
||||
auto keep = [&](const Json& entry) {
|
||||
if (Dyn::Truthy(entry) && Dyn::Truthy(Dyn::Get(entry, "id"))) out.push_back(entry);
|
||||
};
|
||||
if (document.is_array()) {
|
||||
for (const Json& entry : document) keep(entry);
|
||||
} else {
|
||||
keep(document);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<Json> Classify(std::span<const Json> entries, std::string_view hash, const fs::path& artifact) {
|
||||
std::vector<Json> rows;
|
||||
for (const Json& entry : entries) {
|
||||
if (Dyn::Equal(Dyn::Get(entry, "hash"), Json(hash))) {
|
||||
rows.push_back({{"id", Dyn::At(entry, "id")}, {"status", "in-force"}, {"detail", "hash match"}});
|
||||
continue;
|
||||
}
|
||||
const Json& carry = Dyn::Get(entry, "carry");
|
||||
if (carry.is_boolean() && carry.get<bool>() && Dyn::Truthy(Dyn::Get(entry, "anchors"))) {
|
||||
bool ok = true;
|
||||
std::vector<std::string> details;
|
||||
for (const Json& anchor : Dyn::At(entry, "anchors")) {
|
||||
const std::string& file = Text(Dyn::At(anchor, "file"));
|
||||
const fs::path path = fs::is_directory(artifact) ? artifact / Dyn::BaseName(file) : fs::path(file);
|
||||
std::string text;
|
||||
try {
|
||||
text = Dyn::ReadText(path);
|
||||
} catch (const std::exception&) {
|
||||
ok = false;
|
||||
details.push_back(std::format("{}: unreadable", file));
|
||||
continue;
|
||||
}
|
||||
const std::size_t found = Dyn::Count(text, Text(Dyn::At(anchor, "text")));
|
||||
const Json& recorded = Dyn::Get(anchor, "count");
|
||||
details.push_back(std::format("{}: recorded {} found {}", file, Dyn::Str(recorded), found));
|
||||
if (!Dyn::Equal(Json(found), recorded)) ok = false;
|
||||
}
|
||||
rows.push_back({{"id", Dyn::At(entry, "id")}, {"status", ok ? "carried" : "lapsed"}, {"detail", Dyn::Join(details, "; ")}});
|
||||
} else {
|
||||
rows.push_back({{"id", Dyn::At(entry, "id")}, {"status", "lapsed"}, {"detail", "hash mismatch; carry disabled or no anchors"}});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
std::vector<Json> InForce(std::span<const Json> entries, std::span<const Json> rows) {
|
||||
const std::map<std::string, std::string> status = Statuses(rows);
|
||||
std::vector<Json> out;
|
||||
for (const Json& entry : entries) {
|
||||
const auto found = status.find(KeyOf(Dyn::At(entry, "id")));
|
||||
if (found != status.end() && (found->second == "in-force" || found->second == "carried")) out.push_back(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string Block(std::span<const Json> inForce, std::span<const Json> rows, std::string_view lensFile, std::string_view hash) {
|
||||
std::vector<const Json*> visible;
|
||||
for (const Json& entry : inForce) {
|
||||
const Json& lenses = Dyn::Get(entry, "lenses");
|
||||
if (!Dyn::Equal(Dyn::Get(entry, "kind"), Json("refuted")) || (Dyn::Truthy(lenses) && Dyn::In(Json(lensFile), lenses))) visible.push_back(&entry);
|
||||
}
|
||||
const std::string head = std::format("DISPOSITIONS IN FORCE at hash {} - the register is the authority. Nothing outside this list discharges anything.", hash);
|
||||
if (visible.empty()) return std::format("{}\n(none)", head);
|
||||
const std::map<std::string, std::string> status = Statuses(rows);
|
||||
std::vector<std::string> lines;
|
||||
for (const Json* entry : visible) {
|
||||
const Json& d = *entry;
|
||||
std::vector<std::string> anchorTexts;
|
||||
if (Dyn::Has(d, "anchors")) {
|
||||
for (const Json& anchor : Dyn::At(d, "anchors")) {
|
||||
anchorTexts.push_back(std::format("{} x{}", Dyn::Str(Dyn::At(anchor, "file")), Dyn::Str(Dyn::Get(anchor, "count"))));
|
||||
}
|
||||
}
|
||||
std::string anchors = Dyn::Join(anchorTexts, ", ");
|
||||
if (anchors.empty()) anchors = "none";
|
||||
const auto found = status.find(KeyOf(Dyn::At(d, "id")));
|
||||
const bool carried = found != status.end() && found->second == "carried";
|
||||
std::string tag = std::format("{} [{}", Dyn::Str(Dyn::At(d, "id")), Dyn::Str(Dyn::Get(d, "kind")));
|
||||
if (Dyn::Truthy(Dyn::Get(d, "by"))) tag += std::format(", by {}", Text(Dyn::At(d, "by")));
|
||||
if (Dyn::Truthy(Dyn::Get(d, "boundAt"))) tag += std::format(", bound {}", Text(Dyn::At(d, "boundAt")));
|
||||
if (carried) tag += ", CARRIED to this hash";
|
||||
if (Dyn::Truthy(Dyn::Get(d, "lenses"))) tag += std::format(", lenses: {}", JoinStrings(Dyn::At(d, "lenses"), " "));
|
||||
tag += "]";
|
||||
std::string line = std::format("- {}\n finding: {}\n grounds: {}", tag, Dyn::StrOr(Dyn::Get(d, "finding"), "(unnamed)"),
|
||||
Dyn::StrOr(Dyn::Get(d, "grounds"), "(none recorded)"));
|
||||
if (Dyn::Truthy(Dyn::Get(d, "supersedes"))) line += std::format("\n supersedes: {}", Text(Dyn::At(d, "supersedes")));
|
||||
line += std::format("\n anchors: {}", anchors);
|
||||
if (Dyn::Truthy(Dyn::Get(d, "reaffirmed"))) {
|
||||
line += std::format("\n REAFFIRMED by the owner {}x - final for this binding; a further contest is recorded for Gate 4, it does not block", Len(Dyn::At(d, "reaffirmed")));
|
||||
}
|
||||
if (Dyn::Truthy(Dyn::Get(d, "contestRejected"))) {
|
||||
line += std::format("\n contest REJECTED {}x after re-checking the cited fact - a repeat contest without new evidence is recorded, it does not block",
|
||||
Len(Dyn::At(d, "contestRejected")));
|
||||
}
|
||||
lines.push_back(std::move(line));
|
||||
}
|
||||
return std::format("{}\n{}\n{}", head, Dyn::Join(lines, "\n"), Rules);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,12 @@ import :Driver;
|
|||
import :Core;
|
||||
import :Platform;
|
||||
import :Process;
|
||||
import :Inventory;
|
||||
import :Config;
|
||||
import std;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace Crafter::Review {
|
||||
namespace {
|
||||
constexpr std::string_view Usage =
|
||||
|
|
@ -28,6 +32,11 @@ inventory unit, against a frozen commit range.)";
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::int32_t UsageError(std::string_view message) {
|
||||
std::println(std::cerr, "{}\ncrafter-review: error: {}", Usage, message);
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
std::int32_t Main(std::span<const std::string_view> args) {
|
||||
|
|
@ -37,12 +46,44 @@ inventory unit, against a frozen commit range.)";
|
|||
// command anyone types.
|
||||
if (!rest.empty() && rest.front() == Process::SuperviseVerb) return Process::SuperviseMain(rest.subspan(1));
|
||||
Platform::Init();
|
||||
if (!rest.empty() && rest.front() == "--version") return PrintVersion();
|
||||
if (!rest.empty() && (rest.front() == "-h" || rest.front() == "--help")) {
|
||||
// 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("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(std::format("unrecognized arguments: {}", arg));
|
||||
}
|
||||
++at;
|
||||
}
|
||||
if (at < rest.size() && rest[at] == "-h") {
|
||||
std::println("{}", Usage);
|
||||
return 0;
|
||||
}
|
||||
std::println(std::cerr, "{}", Usage);
|
||||
return 3;
|
||||
if (at >= rest.size()) return UsageError("a command is required");
|
||||
const std::string_view command = rest[at];
|
||||
const std::span<const std::string_view> after = rest.subspan(at + 1);
|
||||
if (command == "inventory" && !configPath) return Inventory::Main(after);
|
||||
if (command == "config") {
|
||||
if (!after.empty()) return UsageError(std::format("unrecognized arguments: {}", after.front()));
|
||||
return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
||||
}
|
||||
return UsageError(std::format("argument cmd: invalid choice: '{}'", command));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
293
implementations/Crafter.Review-Inventory.cpp
Normal file
293
implementations/Crafter.Review-Inventory.cpp
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Inventory_impl;
|
||||
import :Inventory;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import :Artifact;
|
||||
import :Plan;
|
||||
import std;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace Crafter::Review::Inventory {
|
||||
namespace {
|
||||
constexpr std::string_view Usage = R"(Partition a frozen commit range (a `git format-patch` export) into review units for the coverage contract.
|
||||
|
||||
The inventory is MECHANICAL: no agent decides what the units are. Every non-blank line of the artifact belongs to
|
||||
exactly one unit, and the tool fails if that invariant does not hold. Fixed slots are added so that what is NOT in
|
||||
the text still has a row in the coverage matrix.
|
||||
|
||||
crafter-review inventory commits <dir-with-*.patch> > gate-state/inventory-<hash8>.json
|
||||
|
||||
Units (ids):
|
||||
pK:subject commit K's subject
|
||||
pK:msg commit K's message body (before the trailers) - only when non-empty
|
||||
pK:trailers commit K's trailer block - only when non-empty
|
||||
pK:diffstat commit K's diffstat
|
||||
pK:<file>#hdr the diff header of <file> in commit K (mode, rename, blobs)
|
||||
pK:<file>#H hunk H of <file> in commit K; a hunk of a documentation file (DOC_FILE) that holds more than one
|
||||
heading-delimited part is cut into #H.1, #H.2, ... (doc_parts)
|
||||
pK:outside fixed slot: the in-repository users/callers of what commit K changes (outside its diff)
|
||||
pK:absent fixed slot: what a reviewer expects in commit K and does not find
|
||||
whole:* fixed slots (WHOLE_COMMITS)
|
||||
)";
|
||||
|
||||
// re.match(r'\s{0,3}(```|~~~)', body): a fence, indented by three
|
||||
// whitespace characters at most.
|
||||
bool OpensFence(std::string_view body) {
|
||||
std::size_t at = 0;
|
||||
for (std::int32_t i = 0; i < 3 && at < body.size(); ++i) {
|
||||
const auto [c, used] = Dyn::CodePointAt(body, at);
|
||||
if (!Dyn::IsSpace(c)) break;
|
||||
at += used;
|
||||
}
|
||||
const std::string_view rest = body.substr(at);
|
||||
return rest.starts_with("```") || rest.starts_with("~~~");
|
||||
}
|
||||
|
||||
// re.match(r'#{1,6}\s', body): an ATX heading.
|
||||
bool IsHeading(std::string_view body) {
|
||||
std::size_t hashes = 0;
|
||||
while (hashes < body.size() && body[hashes] == '#') ++hashes;
|
||||
if (hashes == 0 || hashes > 6 || hashes >= body.size()) return false;
|
||||
return Dyn::IsSpace(Dyn::CodePointAt(body, hashes).first);
|
||||
}
|
||||
|
||||
// re.match(r'^[A-Z][A-Za-z-]+: ', line): a trailer such as
|
||||
// `Signed-off-by: `.
|
||||
bool IsTrailer(std::string_view line) {
|
||||
if (line.empty() || line[0] < 'A' || line[0] > 'Z') return false;
|
||||
std::size_t at = 1;
|
||||
while (at < line.size() && ((line[at] >= 'A' && line[at] <= 'Z') || (line[at] >= 'a' && line[at] <= 'z') || line[at] == '-')) ++at;
|
||||
return at > 1 && line.substr(at).starts_with(": ");
|
||||
}
|
||||
|
||||
bool AnyNonBlank(std::span<const std::string> lines, std::int64_t from, std::int64_t to) {
|
||||
const auto [a, b] = Dyn::SliceBounds(lines.size(), from, to);
|
||||
return std::any_of(lines.begin() + static_cast<std::ptrdiff_t>(a), lines.begin() + static_cast<std::ptrdiff_t>(b),
|
||||
[](const std::string& line) { return !Dyn::Strip(line).empty(); });
|
||||
}
|
||||
|
||||
std::span<const std::string> Lines(std::span<const std::string> lines, std::int64_t from, std::int64_t to) {
|
||||
const auto [a, b] = Dyn::SliceBounds(lines.size(), from, to);
|
||||
return lines.subspan(a, b - a);
|
||||
}
|
||||
|
||||
Json Unit(std::string id, std::int64_t first, std::int64_t last, std::string excerpt) {
|
||||
return {{"id", std::move(id)}, {"lines", {first, last}}, {"excerpt", std::move(excerpt)}};
|
||||
}
|
||||
|
||||
std::string ReprInts(std::span<const std::int64_t> values) {
|
||||
std::string out = "[";
|
||||
for (std::size_t i = 0; i < values.size(); ++i) out += std::format("{}{}", i ? ", " : "", values[i]);
|
||||
return std::format("{}]", out);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::size_t, std::size_t>> DocParts(std::span<const std::string> lines, std::size_t a, std::size_t b) {
|
||||
std::vector<std::pair<std::size_t, std::size_t>> parts;
|
||||
std::size_t start = a;
|
||||
bool hasBody = false;
|
||||
bool fenced = false;
|
||||
for (std::size_t i = a + 1; i < b; ++i) {
|
||||
const std::string& line = lines[i];
|
||||
// l[:1] in (' ', '+') and l[1:]: the marker and the line after it.
|
||||
const bool isNew = line.starts_with(' ') || line.starts_with('+');
|
||||
const std::string_view body = line.empty() ? std::string_view() : std::string_view(line).substr(Dyn::CodePointAt(line, 0).second);
|
||||
if (isNew && OpensFence(body)) fenced = !fenced;
|
||||
if (isNew && !fenced && IsHeading(body)) {
|
||||
if (hasBody) {
|
||||
parts.emplace_back(start, i);
|
||||
start = i;
|
||||
hasBody = false;
|
||||
}
|
||||
} else if (!Dyn::Strip(line).empty() && !Dyn::Strip(body).empty()) {
|
||||
hasBody = true;
|
||||
}
|
||||
}
|
||||
parts.emplace_back(start, b);
|
||||
return parts;
|
||||
}
|
||||
|
||||
std::string Excerpt(std::span<const std::string> lines) {
|
||||
for (const std::string& line : lines) {
|
||||
const std::string stripped = Dyn::Strip(line);
|
||||
if (!stripped.empty()) return Dyn::Slice(stripped, 0, 90);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
Series PartitionSeries(const fs::path& dir, bool splitDocs) {
|
||||
Series series;
|
||||
series.units = Json::array();
|
||||
Json& units = series.units;
|
||||
std::int64_t offset = 0;
|
||||
for (const std::string& name : Artifact::PatchFiles(dir)) {
|
||||
const std::string text = Dyn::ReadText(dir / name);
|
||||
const std::vector<std::string> lines = Dyn::Split(text, "\n");
|
||||
const auto count = static_cast<std::int64_t>(lines.size());
|
||||
const std::optional<std::int64_t> number = Artifact::PatchNumber(name);
|
||||
const std::string pid = std::format("p{}", number ? *number : -1);
|
||||
const std::int64_t base = offset;
|
||||
std::optional<std::int64_t> subject;
|
||||
for (std::int64_t i = 0; i < count; ++i) {
|
||||
if (lines[static_cast<std::size_t>(i)].starts_with("Subject:")) {
|
||||
subject = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::int64_t body0 = 1;
|
||||
if (subject) {
|
||||
for (std::int64_t i = *subject + 1; i < count; ++i) {
|
||||
if (Dyn::Strip(lines[static_cast<std::size_t>(i)]).empty()) {
|
||||
body0 = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// >= : a body-less commit's first body line IS the '---'.
|
||||
std::int64_t diff0 = count;
|
||||
for (std::int64_t i = body0; i < count; ++i) {
|
||||
if (lines[static_cast<std::size_t>(i)].starts_with("---")) {
|
||||
diff0 = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
units.push_back(Unit(std::format("{}:subject", pid), base + 1, base + body0,
|
||||
subject ? Dyn::Slice(Dyn::Strip(lines[static_cast<std::size_t>(*subject)]), 0, 90) : std::string()));
|
||||
std::int64_t t = diff0;
|
||||
while (t - 1 > body0 && (IsTrailer(lines[static_cast<std::size_t>(t - 1)]) || Dyn::Strip(lines[static_cast<std::size_t>(t - 1)]).empty())) --t;
|
||||
if (AnyNonBlank(lines, body0, t)) {
|
||||
units.push_back(Unit(std::format("{}:msg", pid), base + body0 + 1, base + t, Excerpt(Lines(lines, body0, t))));
|
||||
}
|
||||
if (AnyNonBlank(lines, t, diff0)) {
|
||||
units.push_back(Unit(std::format("{}:trailers", pid), base + t + 1, base + diff0, Excerpt(Lines(lines, t, diff0))));
|
||||
}
|
||||
const std::int64_t statStart = diff0;
|
||||
std::int64_t firstDiff = count;
|
||||
for (std::int64_t i = diff0 + 1; i < count; ++i) {
|
||||
if (lines[static_cast<std::size_t>(i)].starts_with("diff --git")) {
|
||||
firstDiff = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
units.push_back(Unit(std::format("{}:diffstat", pid), base + statStart + 1, base + firstDiff, Excerpt(Lines(lines, statStart, firstDiff))));
|
||||
|
||||
std::string currentFile;
|
||||
std::int64_t hunk = 0;
|
||||
std::optional<std::int64_t> hunkStart;
|
||||
auto closeHunk = [&](std::int64_t upto) {
|
||||
if (!hunkStart) return;
|
||||
std::vector<std::pair<std::size_t, std::size_t>> parts;
|
||||
if (splitDocs && Plan::IsDocPath(currentFile)) {
|
||||
parts = DocParts(lines, static_cast<std::size_t>(*hunkStart), static_cast<std::size_t>(upto));
|
||||
} else {
|
||||
parts = {{static_cast<std::size_t>(*hunkStart), static_cast<std::size_t>(upto)}};
|
||||
}
|
||||
for (std::size_t n = 0; n < parts.size(); ++n) {
|
||||
const auto [s, e] = parts[n];
|
||||
const std::string suffix = parts.size() > 1 ? std::format(".{}", n + 1) : std::string();
|
||||
units.push_back(Unit(std::format("{}:{}#{}{}", pid, currentFile, hunk, suffix), base + static_cast<std::int64_t>(s) + 1,
|
||||
base + static_cast<std::int64_t>(e), Excerpt(Lines(lines, static_cast<std::int64_t>(s), static_cast<std::int64_t>(s) + 1))));
|
||||
}
|
||||
};
|
||||
std::int64_t i = firstDiff;
|
||||
while (i < count) {
|
||||
const std::string& line = lines[static_cast<std::size_t>(i)];
|
||||
if (line.starts_with("diff --git")) {
|
||||
closeHunk(i);
|
||||
hunkStart.reset();
|
||||
currentFile = Dyn::Strip(Dyn::Split(line, " b/").back());
|
||||
hunk = 0;
|
||||
const std::int64_t header = i;
|
||||
std::int64_t j = i + 1;
|
||||
while (j < count && !lines[static_cast<std::size_t>(j)].starts_with("@@") && !lines[static_cast<std::size_t>(j)].starts_with("diff --git")) ++j;
|
||||
units.push_back(Unit(std::format("{}:{}#hdr", pid, currentFile), base + header + 1, base + j, Dyn::Slice(Dyn::Strip(line), 0, 90)));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (line.starts_with("@@")) {
|
||||
closeHunk(i);
|
||||
++hunk;
|
||||
hunkStart = i;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
closeHunk(count);
|
||||
units.push_back(Unit(std::format("{}:outside", pid), 0, 0, "FIXED SLOT: in-repository users/callers of what this commit changes, outside its diff"));
|
||||
units.push_back(Unit(std::format("{}:absent", pid), 0, 0, "FIXED SLOT: what a reviewer expects in this commit and does not find"));
|
||||
series.lines.insert(series.lines.end(), lines.begin(), lines.end());
|
||||
offset += count;
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
Built Build(std::string_view mode, std::string_view artifact) {
|
||||
if (mode != "commits") throw InvariantError(std::format("inventory: mode {} is not supported here (commits only)", Dyn::Repr(mode)));
|
||||
Series series = PartitionSeries(fs::path(artifact), true);
|
||||
Json& units = series.units;
|
||||
const std::string h8 = Artifact::ArtifactHash("commits", fs::path(artifact)).substr(0, 8);
|
||||
std::map<std::int64_t, std::string> owner;
|
||||
for (const Json& unit : units) {
|
||||
const std::int64_t a = Dyn::At(unit, "lines")[0].get<std::int64_t>();
|
||||
const std::int64_t b = Dyn::At(unit, "lines")[1].get<std::int64_t>();
|
||||
const std::string& id = Dyn::At(unit, "id").get_ref<const std::string&>();
|
||||
for (std::int64_t line = a; line <= b; ++line) {
|
||||
if (line == 0) continue;
|
||||
if (const auto held = owner.find(line); held != owner.end()) {
|
||||
throw InvariantError(std::format("INVARIANT: line {} in both {} and {}", line, held->second, id));
|
||||
}
|
||||
owner[line] = id;
|
||||
}
|
||||
}
|
||||
std::vector<std::int64_t> uncovered;
|
||||
for (std::size_t i = 0; i < series.lines.size(); ++i) {
|
||||
const auto line = static_cast<std::int64_t>(i) + 1;
|
||||
if (!Dyn::Strip(series.lines[i]).empty() && !owner.contains(line)) uncovered.push_back(line);
|
||||
}
|
||||
if (!uncovered.empty()) {
|
||||
const std::span<const std::int64_t> first(uncovered.data(), std::min<std::size_t>(uncovered.size(), 10));
|
||||
throw InvariantError(std::format("INVARIANT: {} non-blank lines in no unit, first: {}", uncovered.size(), ReprInts(first)));
|
||||
}
|
||||
for (const WholeSlot& slot : WholeCommits) units.push_back(Unit(std::string(slot.id), 0, 0, std::format("FIXED SLOT: {}", slot.description)));
|
||||
std::map<std::string, std::int32_t> seen;
|
||||
for (const Json& unit : units) ++seen[Dyn::At(unit, "id").get<std::string>()];
|
||||
Json duplicates = Json::array();
|
||||
for (const auto& [id, times] : seen) {
|
||||
if (times > 1) duplicates.push_back(id);
|
||||
}
|
||||
if (!duplicates.empty()) throw InvariantError(std::format("INVARIANT: duplicate ids {}", Dyn::ReprValue(duplicates)));
|
||||
const std::size_t total = units.size();
|
||||
Built built;
|
||||
built.out = {
|
||||
{"mode", mode},
|
||||
{"artifact", artifact},
|
||||
{"hash8", h8},
|
||||
{"units", std::move(units)},
|
||||
{"invariant", "every non-blank line belongs to exactly one unit; fixed slots have lines [0,0]"},
|
||||
{"count", total},
|
||||
};
|
||||
built.lines = std::move(series.lines);
|
||||
return built;
|
||||
}
|
||||
|
||||
std::int32_t Main(std::span<const std::string_view> args) {
|
||||
if (args.size() != 2 || args[0] != "commits") {
|
||||
std::print(std::cerr, "{}", Usage);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const Built built = Build(args[0], args[1]);
|
||||
std::println("{}", DumpFile(built.out));
|
||||
return 0;
|
||||
} catch (const InvariantError& error) {
|
||||
std::println(std::cerr, "{}", error.what());
|
||||
return 1;
|
||||
} catch (const std::exception& error) {
|
||||
std::println(std::cerr, "inventory: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
450
implementations/Crafter.Review-Normalize.cpp
Normal file
450
implementations/Crafter.Review-Normalize.cpp
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Normalize_impl;
|
||||
import :Normalize;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import :Schema;
|
||||
import std;
|
||||
|
||||
namespace Crafter::Review::Normalize {
|
||||
namespace {
|
||||
// A map key or set member, as text: equal values give equal keys. A
|
||||
// list or an object cannot be one (TypeError).
|
||||
std::string KeyOf(const Json& value) {
|
||||
if (value.is_array() || value.is_object()) throw Dyn::Error("TypeError", std::format("unhashable type: '{}'", Dyn::TypeName(value)));
|
||||
return DumpCompactSorted(value);
|
||||
}
|
||||
|
||||
// The key's value, or the fallback when the key is absent.
|
||||
const Json& GetOr(const Json& dict, std::string_view key, const Json& fallback) {
|
||||
return Dyn::Has(dict, key) ? Dyn::At(dict, key) : fallback;
|
||||
}
|
||||
|
||||
// `list + list`
|
||||
Json Concat(const Json& a, const Json& b) {
|
||||
if (!a.is_array() || !b.is_array()) throw Dyn::Error("TypeError", "can only concatenate list (not \"NoneType\") to list");
|
||||
Json out = a;
|
||||
for (const Json& item : b) out.push_back(item);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The hashes a disposition is bound to: this run's, its own and the
|
||||
// ones it was carried from.
|
||||
Json Chain(std::string_view hash, const Json& d) {
|
||||
Json chain = Json::array();
|
||||
auto add = [&](const Json& h) {
|
||||
if (Dyn::Truthy(h)) chain.push_back(h);
|
||||
};
|
||||
add(Json(hash));
|
||||
add(Dyn::Get(d, "hash"));
|
||||
for (const Json& carried : GetOr(d, "carried", Dyn::EmptyList())) add(Dyn::Get(carried, "hash"));
|
||||
return chain;
|
||||
}
|
||||
|
||||
// Reaffirmed by the owner, or a contest of it rejected, on this
|
||||
// binding.
|
||||
bool Settled(std::string_view hash, const Json& d) {
|
||||
const Json chain = Chain(hash, d);
|
||||
const Json records = Concat(GetOr(d, "reaffirmed", Dyn::EmptyList()), GetOr(d, "contestRejected", Dyn::EmptyList()));
|
||||
return std::ranges::any_of(records, [&](const Json& record) { return Dyn::In(Dyn::Get(record, "hash"), chain); });
|
||||
}
|
||||
|
||||
bool In(std::string_view verdict, std::span<const std::string_view> vocabulary) {
|
||||
return std::ranges::contains(vocabulary, verdict);
|
||||
}
|
||||
|
||||
bool InVocabulary(const Json& verdict, std::span<const std::string_view> vocabulary) {
|
||||
return verdict.is_string() && In(verdict.get_ref<const std::string&>(), vocabulary);
|
||||
}
|
||||
|
||||
std::string JoinStrings(std::span<const Json> items, std::string_view separator) {
|
||||
std::vector<std::string> parts;
|
||||
for (const Json& item : items) {
|
||||
if (!item.is_string()) throw Dyn::Error("TypeError", std::format("sequence item {}: expected str instance, {} found", parts.size(), Dyn::TypeName(item)));
|
||||
parts.push_back(item.get<std::string>());
|
||||
}
|
||||
return Dyn::Join(parts, separator);
|
||||
}
|
||||
|
||||
// An ordered map keyed by value (KeyOf): the order of first insertion,
|
||||
// and the last value set.
|
||||
struct OrderedMap {
|
||||
std::vector<std::pair<Json, Json>> items;
|
||||
std::map<std::string, std::size_t> index;
|
||||
|
||||
Json* Find(const Json& key) {
|
||||
const auto found = index.find(KeyOf(key));
|
||||
return found == index.end() ? nullptr : &items[found->second].second;
|
||||
}
|
||||
|
||||
void Set(const Json& key, Json value) {
|
||||
if (Json* existing = Find(key)) {
|
||||
*existing = std::move(value);
|
||||
return;
|
||||
}
|
||||
index[KeyOf(key)] = items.size();
|
||||
items.emplace_back(key, std::move(value));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Json ResolveId(const Json& id, std::span<const std::string> universe) {
|
||||
if (id.is_string()) {
|
||||
const std::string& text = id.get_ref<const std::string&>();
|
||||
if (std::ranges::contains(universe, text)) return id;
|
||||
std::vector<const std::string*> matches;
|
||||
for (const std::string& candidate : universe) {
|
||||
if (candidate.starts_with(text)) matches.push_back(&candidate);
|
||||
}
|
||||
return matches.size() == 1 ? Json(*matches.front()) : id;
|
||||
}
|
||||
// `s in universe` is false for anything but a str; u.startswith(s)
|
||||
// then raises, unless there is no u to ask.
|
||||
if (universe.empty()) return id;
|
||||
throw Dyn::Error("TypeError", std::format("startswith first arg must be str or a tuple of str, not {}", Dyn::TypeName(id)));
|
||||
}
|
||||
|
||||
Json NormalizeOutput(Json& v, std::span<const Json> inForce, std::string_view lensFile, std::string_view hash, const Json& inventory,
|
||||
std::span<const std::string> allIds) {
|
||||
std::map<std::string, const Json*> byId;
|
||||
for (const Json& d : inForce) byId[KeyOf(Dyn::At(d, "id"))] = &d;
|
||||
auto disposition = [&](const Json& id) -> const Json* {
|
||||
const auto found = byId.find(KeyOf(id));
|
||||
return found == byId.end() ? nullptr : found->second;
|
||||
};
|
||||
Json violations = Json::array();
|
||||
Json contests = Json::array();
|
||||
Json cross = Json::array();
|
||||
Json logs = Json::array();
|
||||
Json findings = Json::array();
|
||||
for (const Json& f : Dyn::Or(Dyn::Get(v, "findings"), Dyn::EmptyList())) {
|
||||
if (!f.is_object()) throw Dyn::Error("TypeError", std::format("'{}' object is not iterable", Dyn::TypeName(f)));
|
||||
Json out = f;
|
||||
bool discharged = false;
|
||||
if (Dyn::Truthy(Dyn::Get(f, "dischargedBy"))) {
|
||||
const Json* d = disposition(Dyn::At(f, "dischargedBy"));
|
||||
if (!d || !Dyn::Truthy(*d)) {
|
||||
logs.push_back(std::format("{}: dischargedBy={} names no disposition in force - treated as undischarged", lensFile, Dyn::Str(Dyn::At(f, "dischargedBy"))));
|
||||
out.erase("dischargedBy");
|
||||
} else {
|
||||
discharged = true;
|
||||
const Json& lenses = Dyn::Get(*d, "lenses");
|
||||
if (Dyn::Truthy(lenses) && !Dyn::In(Json(lensFile), Dyn::At(*d, "lenses"))) {
|
||||
cross.push_back({{"id", Dyn::At(*d, "id")}, {"lens", lensFile}, {"dispositionLenses", Dyn::At(*d, "lenses")}});
|
||||
}
|
||||
}
|
||||
}
|
||||
const Json& c = Dyn::Get(f, "contests");
|
||||
if (Dyn::Truthy(c) && Dyn::Truthy(Dyn::Get(c, "id"))) {
|
||||
const Json* d = disposition(Dyn::At(c, "id"));
|
||||
if (!d || !Dyn::Truthy(*d)) {
|
||||
logs.push_back(std::format("{}: contests id={} names no disposition in force - ignored", lensFile, Dyn::Str(Dyn::At(c, "id"))));
|
||||
out.erase("contests");
|
||||
} else if (Settled(hash, *d)) {
|
||||
discharged = true;
|
||||
out["dischargedBy"] = Dyn::At(*d, "id");
|
||||
contests.push_back({{"id", Dyn::At(*d, "id")}, {"lens", lensFile}, {"why", Dyn::Get(c, "why")},
|
||||
{"outcome", "recorded - entry already reaffirmed/contest-rejected on this binding; finding discharged (owner outranks lens; see Gate 4)"}});
|
||||
} else {
|
||||
discharged = false;
|
||||
out.erase("dischargedBy");
|
||||
contests.push_back({{"id", Dyn::At(*d, "id")}, {"lens", lensFile}, {"why", Dyn::Get(c, "why")},
|
||||
{"outcome", "DISPOSITION CONTESTED - blocks until the contest is verified and the entry reaffirmed, rejected, withdrawn or converted"}});
|
||||
}
|
||||
}
|
||||
out["discharged"] = discharged;
|
||||
findings.push_back(std::move(out));
|
||||
}
|
||||
|
||||
Json coverage;
|
||||
Json missing = Json::array();
|
||||
if (!inventory.is_null()) {
|
||||
std::vector<std::string> ids;
|
||||
for (const Json& unit : inventory) ids.push_back(Dyn::At(unit, "id").get<std::string>());
|
||||
const std::span<const std::string> universe = allIds.empty() ? std::span<const std::string>(ids) : allIds;
|
||||
auto resolveItems = [&](Json& holder) {
|
||||
if (!Dyn::Get(holder, "items").is_array()) return;
|
||||
Json resolved = Json::array();
|
||||
for (const Json& item : holder["items"]) resolved.push_back(ResolveId(item, universe));
|
||||
holder["items"] = std::move(resolved);
|
||||
};
|
||||
for (Json& f : findings) resolveItems(f);
|
||||
// v.get('notes') or []: the notes of the output itself, resolved in
|
||||
// place.
|
||||
Json noNotes = Json::array();
|
||||
Json& notes = Dyn::Truthy(Dyn::Get(v, "notes")) ? v["notes"] : noNotes;
|
||||
for (Json& n : notes) {
|
||||
if (!n.is_object()) throw Dyn::Error("AttributeError", std::format("'{}' object has no attribute 'get'", Dyn::TypeName(n)));
|
||||
resolveItems(n);
|
||||
}
|
||||
OrderedMap byItem;
|
||||
for (const Json& entry : Dyn::Or(Dyn::Get(v, "coverage"), Dyn::EmptyList())) {
|
||||
if (!Dyn::Truthy(entry) || !Dyn::Truthy(Dyn::Get(entry, "item"))) continue;
|
||||
const Json cid = ResolveId(Dyn::At(entry, "item"), universe);
|
||||
if (!byItem.Find(cid)) {
|
||||
Json copy = entry;
|
||||
copy["item"] = cid;
|
||||
byItem.Set(cid, std::move(copy));
|
||||
}
|
||||
}
|
||||
std::vector<Json> unknown;
|
||||
for (const auto& [key, value] : byItem.items) {
|
||||
if (!key.is_string() || !std::ranges::contains(ids, key.get_ref<const std::string&>())) unknown.push_back(key);
|
||||
}
|
||||
if (!unknown.empty()) {
|
||||
const std::span<const Json> shown(unknown.data(), std::min<std::size_t>(unknown.size(), 5));
|
||||
logs.push_back(std::format("{}: coverage names {} id(s) outside this pass's units ({}) - ignored", lensFile, unknown.size(), JoinStrings(shown, ", ")));
|
||||
}
|
||||
for (const std::string& id : ids) {
|
||||
if (!byItem.Find(Json(id))) missing.push_back(id);
|
||||
}
|
||||
std::set<std::string> cited;
|
||||
for (const Json& f : findings) {
|
||||
for (const Json& item : Dyn::Or(Dyn::Get(f, "items"), Dyn::EmptyList())) cited.insert(KeyOf(item));
|
||||
}
|
||||
std::set<std::string> noted;
|
||||
for (const Json& n : notes) {
|
||||
for (const Json& item : Dyn::Or(Dyn::Get(n, "items"), Dyn::EmptyList())) noted.insert(KeyOf(item));
|
||||
}
|
||||
const std::string vocabulary = Dyn::Join(std::vector<std::string>(Schema::CoverageVerdicts.begin(), Schema::CoverageVerdicts.end()), "/");
|
||||
for (const std::string& id : ids) {
|
||||
const Json* c = byItem.Find(Json(id));
|
||||
if (!c || !Dyn::Truthy(*c)) continue;
|
||||
const Json& verdict = Dyn::At(*c, "verdict");
|
||||
const std::string key = KeyOf(Json(id));
|
||||
if (!InVocabulary(verdict, Schema::CoverageVerdicts)) {
|
||||
logs.push_back(std::format("{}: coverage verdict \"{}\" on {} is not in the vocabulary ({}) - kept as given", lensFile, Dyn::Str(verdict), id, vocabulary));
|
||||
}
|
||||
if (verdict == "immaterial" && !noted.contains(key)) {
|
||||
logs.push_back(std::format("{}: immaterial on {} with no note citing it (items:[]) - the reason is not on record", lensFile, id));
|
||||
}
|
||||
if (InVocabulary(verdict, Schema::CoverageBlocking) && !cited.contains(key)) {
|
||||
findings.push_back({
|
||||
{"finding", std::format("coverage: verdict {} on unit {} with no finding written for it (basis: {})", Dyn::Str(verdict), id, Dyn::Str(Dyn::Get(*c, "basis")))},
|
||||
{"evidence", std::format("coverage entry for {}", id)},
|
||||
{"proposedAction", "the lens marked this unit defective and did not write the finding - write it or re-run"},
|
||||
{"items", {id}},
|
||||
{"discharged", false},
|
||||
{"synthesized", true},
|
||||
});
|
||||
logs.push_back(std::format("{}: {} on {} with no finding - synthesized an undischarged finding (fail closed)", lensFile, Dyn::Str(verdict), id));
|
||||
}
|
||||
}
|
||||
coverage = Json::array();
|
||||
const Json empty = "";
|
||||
for (const std::string& id : ids) {
|
||||
const Json* c = byItem.Find(Json(id));
|
||||
if (!c) {
|
||||
coverage.push_back({{"item", id}, {"verdict", "MISSING"}, {"basis", ""}});
|
||||
} else {
|
||||
coverage.push_back({{"item", Dyn::At(*c, "item")}, {"verdict", Dyn::At(*c, "verdict")}, {"basis", GetOr(*c, "basis", empty)}});
|
||||
}
|
||||
}
|
||||
if (!missing.empty()) {
|
||||
const std::span<const Json> shown(&*missing.begin(), std::min<std::size_t>(missing.size(), 8));
|
||||
violations.push_back({{"lens", lensFile}, {"kind", std::format("coverage incomplete: {} of {} units unaddressed ({}{}) - lens treated as NOT RETURNED", missing.size(),
|
||||
ids.size(), JoinStrings(shown, ", "), missing.size() > 8 ? ", ..." : "")}});
|
||||
}
|
||||
}
|
||||
const auto undischarged = std::ranges::count_if(findings, [](const Json& f) { return !Dyn::Truthy(Dyn::At(f, "discharged")); });
|
||||
const std::string derived = undischarged ? "FIX FIRST" : "SEND-READY";
|
||||
const Json& declared = Dyn::Get(v, "verdict");
|
||||
if (Dyn::Truthy(declared) && declared != derived) {
|
||||
if (declared == "SEND-READY") {
|
||||
violations.push_back({{"lens", lensFile}, {"declared", declared}, {"derived", derived},
|
||||
{"kind", "SEND-READY declared over undischarged finding(s) - failed closed to FIX FIRST"}});
|
||||
} else {
|
||||
violations.push_back({{"lens", lensFile}, {"declared", declared}, {"derived", derived},
|
||||
{"kind", "FIX FIRST declared with every finding discharged and no blocking contest (silent veto) - derived pass stands"}});
|
||||
}
|
||||
}
|
||||
return {
|
||||
{"verdict", derived},
|
||||
{"declaredVerdict", declared},
|
||||
{"findings", std::move(findings)},
|
||||
{"notes", Dyn::Or(Dyn::Get(v, "notes"), Dyn::EmptyList())},
|
||||
{"coverage", std::move(coverage)},
|
||||
{"coverageMissing", std::move(missing)},
|
||||
{"undischarged", undischarged},
|
||||
{"contests", std::move(contests)},
|
||||
{"contractViolations", std::move(violations)},
|
||||
{"crossLensDischarges", std::move(cross)},
|
||||
{"normalizeLogs", std::move(logs)},
|
||||
{"summary", Dyn::Get(v, "summary")},
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<Json> Dedupe(std::span<const Json> items) {
|
||||
std::set<std::string> seen;
|
||||
std::vector<Json> out;
|
||||
for (const Json& item : items) {
|
||||
if (seen.insert(DumpCompactSorted(item)).second) out.push_back(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Json MergeParts(std::string_view lens, std::string_view lensFile, std::span<const Json> parts, std::span<const std::string> missingUnits,
|
||||
const Json& stoppedEarly) {
|
||||
// De-duplicated: a reused part carries the notes and findings its
|
||||
// unit-state entry stored, and a note citing two units is stored under
|
||||
// both keys - concatenating the parts doubled it every round
|
||||
// (2026-10-11).
|
||||
std::vector<Json> allFindings;
|
||||
std::vector<Json> allNotes;
|
||||
for (const Json& part : parts) {
|
||||
for (const Json& f : GetOr(part, "findings", Dyn::EmptyList())) allFindings.push_back(f);
|
||||
for (const Json& n : GetOr(part, "notes", Dyn::EmptyList())) allNotes.push_back(n);
|
||||
}
|
||||
const std::vector<Json> findings = Dedupe(allFindings);
|
||||
const std::vector<Json> notes = Dedupe(allNotes);
|
||||
const auto undischarged = std::ranges::count_if(findings, [](const Json& f) { return !Dyn::Truthy(Dyn::Get(f, "discharged")); });
|
||||
auto rank = [](const Json& verdict) {
|
||||
if (InVocabulary(verdict, Schema::CoverageBlocking)) return 2;
|
||||
return verdict == "immaterial" ? 1 : 0;
|
||||
};
|
||||
OrderedMap merged;
|
||||
for (const Json& part : parts) {
|
||||
for (const Json& c : Dyn::Or(Dyn::Get(part, "coverage"), Dyn::EmptyList())) {
|
||||
const Json& item = Dyn::At(c, "item");
|
||||
const Json* previous = merged.Find(item);
|
||||
if (!previous || !Dyn::Truthy(*previous) || rank(Dyn::At(c, "verdict")) >= rank(Dyn::At(*previous, "verdict"))) merged.Set(item, c);
|
||||
}
|
||||
}
|
||||
Json crossLogs = Json::array();
|
||||
for (const Json& f : findings) {
|
||||
if (Dyn::Truthy(Dyn::Get(f, "discharged"))) continue;
|
||||
for (const Json& item : Dyn::Or(Dyn::Get(f, "items"), Dyn::EmptyList())) {
|
||||
const Json* entry = merged.Find(item);
|
||||
if (entry && !InVocabulary(Dyn::At(*entry, "verdict"), Schema::CoverageBlocking)) {
|
||||
const Json& finding = Dyn::Get(f, "finding");
|
||||
const std::string cited = Dyn::Truthy(finding) ? Dyn::Slice(finding.get<std::string>(), 0, 80) : std::string();
|
||||
const std::string was = Dyn::Str(Dyn::At(*entry, "verdict"));
|
||||
merged.Set(item, {{"item", item}, {"verdict", "defect"}, {"basis", std::format("cited by finding: {}", cited)}});
|
||||
crossLogs.push_back(std::format("{}: {} raised to defect - cited by an undischarged finding while its own entry was {}", lens, Dyn::Str(item), was));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deduplicated, in the order of first appearance.
|
||||
Json missing = Json::array();
|
||||
std::set<std::string> seenMissing;
|
||||
auto addMissing = [&](const Json& id) {
|
||||
if (seenMissing.insert(KeyOf(id)).second) missing.push_back(id);
|
||||
};
|
||||
for (const Json& part : parts) {
|
||||
for (const Json& id : GetOr(part, "coverageMissing", Dyn::EmptyList())) addMissing(id);
|
||||
}
|
||||
for (const std::string& id : missingUnits) addMissing(Json(id));
|
||||
Json violations = Json::array();
|
||||
for (const Json& part : parts) {
|
||||
for (const Json& violation : GetOr(part, "contractViolations", Dyn::EmptyList())) violations.push_back(violation);
|
||||
}
|
||||
if (!missingUnits.empty()) {
|
||||
const std::span<const std::string> shown = missingUnits.first(std::min<std::size_t>(missingUnits.size(), 8));
|
||||
violations.push_back({{"lens", lensFile}, {"kind", std::format("process(es) did not return for unit(s) {}{} - lens treated as NOT RETURNED", Dyn::Join(shown, ", "),
|
||||
missingUnits.size() > 8 ? ", ..." : "")}});
|
||||
}
|
||||
Json contests = Json::array();
|
||||
Json crossDischarges = Json::array();
|
||||
Json normalizeLogs = Json::array();
|
||||
Json partRows = Json::array();
|
||||
Json summary;
|
||||
for (const Json& part : parts) {
|
||||
for (const Json& c : GetOr(part, "contests", Dyn::EmptyList())) contests.push_back(c);
|
||||
for (const Json& c : GetOr(part, "crossLensDischarges", Dyn::EmptyList())) crossDischarges.push_back(c);
|
||||
for (const Json& l : GetOr(part, "normalizeLogs", Dyn::EmptyList())) normalizeLogs.push_back(l);
|
||||
const Json ok = "ok";
|
||||
partRows.push_back({
|
||||
{"label", Dyn::Get(part, "partLabel")},
|
||||
{"kind", Dyn::Get(part, "partKind")},
|
||||
{"unit", Dyn::Get(part, "partUnit")},
|
||||
{"verdict", Dyn::Get(part, "verdict")},
|
||||
{"findings", GetOr(part, "findings", Dyn::EmptyList()).size()},
|
||||
{"status", GetOr(part, "status", ok)},
|
||||
{"reused", Dyn::Get(part, "reused")},
|
||||
{"turns", Dyn::Get(part, "turns")},
|
||||
{"usage", Dyn::Get(part, "usage")},
|
||||
});
|
||||
if (summary.is_null() && Dyn::Truthy(Dyn::Get(part, "summary"))) summary = Dyn::At(part, "summary");
|
||||
}
|
||||
for (const Json& l : crossLogs) normalizeLogs.push_back(l);
|
||||
Json coverage = Json::array();
|
||||
for (const auto& [key, value] : merged.items) coverage.push_back(value);
|
||||
// A lens with unaddressed units is NOT RETURNED (never a pass); real
|
||||
// undischarged findings still read FIX FIRST.
|
||||
const std::string verdict = undischarged ? "FIX FIRST" : (missing.empty() ? "SEND-READY" : "NOT RETURNED");
|
||||
return {
|
||||
{"lens", lens},
|
||||
{"lensFile", lensFile},
|
||||
{"verdict", verdict},
|
||||
{"findings", findings},
|
||||
{"notes", notes},
|
||||
{"coverage", std::move(coverage)},
|
||||
{"coverageMissing", std::move(missing)},
|
||||
{"undischarged", undischarged},
|
||||
{"contests", std::move(contests)},
|
||||
{"contractViolations", std::move(violations)},
|
||||
{"crossLensDischarges", std::move(crossDischarges)},
|
||||
{"normalizeLogs", std::move(normalizeLogs)},
|
||||
{"parts", std::move(partRows)},
|
||||
{"summary", std::move(summary)},
|
||||
{"stoppedEarly", stoppedEarly},
|
||||
};
|
||||
}
|
||||
|
||||
Json Matrix(const Json& inventory, std::span<const Json> results, std::span<const std::string> notReviewed) {
|
||||
auto owned = [](const Json& entry) { return !InVocabulary(Dyn::At(entry, "verdict"), Schema::CoverageUnowned); };
|
||||
Json columns = Json::array();
|
||||
for (const Json& result : results) {
|
||||
if (Dyn::Get(result, "coverage").is_array()) columns.push_back(Dyn::At(result, "lens"));
|
||||
}
|
||||
Json mx = Json::object();
|
||||
const Json empty = "";
|
||||
for (const Json& unit : inventory) {
|
||||
const std::string id = Dyn::At(unit, "id").get<std::string>();
|
||||
Json row = Json::object();
|
||||
for (const Json& result : results) {
|
||||
if (!Dyn::Get(result, "coverage").is_array()) continue;
|
||||
const Json* entry = nullptr;
|
||||
for (const Json& x : Dyn::At(result, "coverage")) {
|
||||
if (Dyn::Equal(Dyn::At(x, "item"), Json(id))) {
|
||||
entry = &x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const std::string lens = Dyn::At(result, "lens").get<std::string>();
|
||||
row[lens] = entry ? Json{{"verdict", Dyn::At(*entry, "verdict")}, {"basis", GetOr(*entry, "basis", empty)}} : Json{{"verdict", "MISSING"}, {"basis", ""}};
|
||||
}
|
||||
mx[id] = std::move(row);
|
||||
}
|
||||
const std::set<std::string> excused(notReviewed.begin(), notReviewed.end());
|
||||
Json holes = Json::array();
|
||||
for (const Json& unit : inventory) {
|
||||
const std::string id = Dyn::At(unit, "id").get<std::string>();
|
||||
if (excused.contains(id)) continue;
|
||||
const Json& row = mx[id];
|
||||
if (!std::ranges::any_of(row, owned)) holes.push_back(id);
|
||||
}
|
||||
Json defects = Json::object();
|
||||
for (const Json& unit : inventory) {
|
||||
const std::string id = Dyn::At(unit, "id").get<std::string>();
|
||||
Json lenses = Json::array();
|
||||
const Json& row = mx[id];
|
||||
for (auto it = row.begin(); it != row.end(); ++it) {
|
||||
if (InVocabulary(Dyn::At(it.value(), "verdict"), Schema::CoverageBlocking)) lenses.push_back(it.key());
|
||||
}
|
||||
if (!lenses.empty()) defects[id] = std::move(lenses);
|
||||
}
|
||||
Json sortedExcused = Json::array();
|
||||
for (const std::string& id : excused) sortedExcused.push_back(id);
|
||||
return {
|
||||
{"units", inventory.size()},
|
||||
{"lenses", std::move(columns)},
|
||||
{"holes", std::move(holes)},
|
||||
{"notReviewed", std::move(sortedExcused)},
|
||||
{"defects", std::move(defects)},
|
||||
{"matrix", std::move(mx)},
|
||||
};
|
||||
}
|
||||
}
|
||||
60
implementations/Crafter.Review-PgWire.cpp
Normal file
60
implementations/Crafter.Review-PgWire.cpp
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:PgWire_impl;
|
||||
import :PgWire;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import std;
|
||||
|
||||
namespace Crafter::Review::PgWire {
|
||||
Url ParseUrl(std::string_view text) {
|
||||
const Dyn::SplitUrl split = Dyn::UrlSplit(text);
|
||||
Url url;
|
||||
url.scheme = split.scheme;
|
||||
url.host = Dyn::Lower(split.Hostname().value_or(""));
|
||||
const std::optional<std::int32_t> port = split.Port();
|
||||
url.port = port && *port != 0 ? *port : 5432;
|
||||
const std::optional<std::string> user = split.Username();
|
||||
url.user = Dyn::Unquote(user && !user->empty() ? *user : "postgres");
|
||||
url.password = Dyn::Unquote(split.Password().value_or(""));
|
||||
const std::string path = split.path.empty() ? "/" : split.path;
|
||||
const std::string database = Dyn::LStrip(path, "/");
|
||||
url.database = Dyn::Unquote(database.empty() ? "postgres" : database);
|
||||
return url;
|
||||
}
|
||||
|
||||
std::optional<std::string> CheckUrl(const Json& url) {
|
||||
if (!url.is_string() || url.get_ref<const std::string&>().empty()) return "no database URL";
|
||||
Url parsed;
|
||||
try {
|
||||
parsed = ParseUrl(url.get_ref<const std::string&>());
|
||||
} catch (const std::invalid_argument& error) {
|
||||
return std::format("not a URL ({})", error.what());
|
||||
}
|
||||
if (parsed.scheme != "postgres" && parsed.scheme != "postgresql") return "not a postgres:// or postgresql:// URL";
|
||||
if (!std::ranges::contains(Loopback, parsed.host)) {
|
||||
const std::vector<std::string> names(Loopback.begin(), Loopback.end());
|
||||
return std::format("host {} is not this machine ({}): the database under test is a local stack, never a hosted one",
|
||||
parsed.host.empty() ? "(none)" : parsed.host, Dyn::Join(names, ", "));
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string Redact(std::string_view url) {
|
||||
try {
|
||||
const Dyn::SplitUrl split = Dyn::UrlSplit(url);
|
||||
if (!split.Password()) return std::string(url);
|
||||
const std::string host = split.netloc.substr(split.netloc.rfind('@') + 1);
|
||||
const std::string netloc = std::format("{}:***@{}", split.Username().value_or(""), host);
|
||||
return Dyn::UrlUnsplit(split.scheme, netloc, split.path, split.query, split.fragment);
|
||||
} catch (const std::invalid_argument&) {
|
||||
return "(unparsable URL)";
|
||||
}
|
||||
}
|
||||
|
||||
std::string Where(std::string_view url) {
|
||||
const Url parsed = ParseUrl(url);
|
||||
return std::format("{} on {}:{} as {}", parsed.database, parsed.host, parsed.port, parsed.user);
|
||||
}
|
||||
}
|
||||
680
implementations/Crafter.Review-Plan.cpp
Normal file
680
implementations/Crafter.Review-Plan.cpp
Normal file
|
|
@ -0,0 +1,680 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Plan_impl;
|
||||
import :Plan;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import :Crypto;
|
||||
import std;
|
||||
|
||||
namespace Crafter::Review::Plan {
|
||||
namespace {
|
||||
// The bytes of `p\d+:` at the start of a unit id, where \d is any
|
||||
// Unicode decimal digit, as re.match reads it.
|
||||
std::optional<std::size_t> PkPrefix(std::string_view id) {
|
||||
if (!id.starts_with('p')) return std::nullopt;
|
||||
const std::size_t digits = Dyn::DecimalRun(id, 1);
|
||||
if (digits == 0 || 1 + digits >= id.size() || id[1 + digits] != ':') return std::nullopt;
|
||||
return 2 + digits;
|
||||
}
|
||||
|
||||
// group(1) of ^p\d+:(.+)#[^#]+$: the path between the prefix and the
|
||||
// last '#', which must have something after it. `.` stops at a
|
||||
// newline; [^#] does not.
|
||||
std::optional<std::string_view> FileOfUnit(std::string_view id) {
|
||||
const std::optional<std::size_t> prefix = PkPrefix(id);
|
||||
if (!prefix) return std::nullopt;
|
||||
const std::string_view rest = id.substr(*prefix);
|
||||
const std::size_t hash = rest.rfind('#');
|
||||
if (hash == std::string_view::npos || hash == 0 || hash + 1 >= rest.size()) return std::nullopt;
|
||||
const std::string_view path = rest.substr(0, hash);
|
||||
if (path.contains('\n')) return std::nullopt;
|
||||
return path;
|
||||
}
|
||||
|
||||
bool IsHex(std::string_view text) {
|
||||
return !text.empty() && std::ranges::all_of(text, [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); });
|
||||
}
|
||||
|
||||
// ^\d+\.\d+
|
||||
bool StartsWithVersion(std::string_view line) {
|
||||
const std::size_t major = Dyn::DecimalRun(line, 0);
|
||||
if (major == 0 || major >= line.size() || line[major] != '.') return false;
|
||||
return Dyn::DecimalRun(line, major + 1) > 0;
|
||||
}
|
||||
|
||||
// ^(base-commit|prerequisite-patch-id): [0-9a-f]+$
|
||||
bool IsTailLine(std::string_view line) {
|
||||
for (const std::string_view head : {std::string_view("base-commit: "), std::string_view("prerequisite-patch-id: ")}) {
|
||||
if (line.starts_with(head) && IsHex(line.substr(head.size()))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ^index [0-9a-f]+\.\.[0-9a-f]+( \d+)?$ -> group(1) ("" when absent)
|
||||
std::optional<std::string_view> IndexMode(std::string_view line) {
|
||||
if (!line.starts_with("index ")) return std::nullopt;
|
||||
const std::string_view rest = line.substr(6);
|
||||
const std::size_t dots = rest.find("..");
|
||||
if (dots == std::string_view::npos || !IsHex(rest.substr(0, dots))) return std::nullopt;
|
||||
const std::string_view after = rest.substr(dots + 2);
|
||||
const std::size_t space = after.find(' ');
|
||||
if (space == std::string_view::npos) {
|
||||
if (!IsHex(after)) return std::nullopt;
|
||||
return std::string_view();
|
||||
}
|
||||
if (!IsHex(after.substr(0, space))) return std::nullopt;
|
||||
const std::string_view mode = after.substr(space);
|
||||
if (mode.size() < 2 || Dyn::DecimalRun(mode, 1) != mode.size() - 1) return std::nullopt;
|
||||
return mode;
|
||||
}
|
||||
|
||||
// \d+(?:,\d+)? at `at`: the bytes it takes, 0 for no match.
|
||||
std::size_t LineRange(std::string_view text, std::size_t at) {
|
||||
const std::size_t start = Dyn::DecimalRun(text, at);
|
||||
if (start == 0) return 0;
|
||||
std::size_t end = at + start;
|
||||
if (end < text.size() && text[end] == ',') {
|
||||
const std::size_t count = Dyn::DecimalRun(text, end + 1);
|
||||
if (count > 0) end += 1 + count;
|
||||
}
|
||||
return end - at;
|
||||
}
|
||||
|
||||
// ^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(.*)$ -> group(1)
|
||||
std::optional<std::string_view> HunkTail(std::string_view line) {
|
||||
if (!line.starts_with("@@ -")) return std::nullopt;
|
||||
std::size_t at = 4;
|
||||
const std::size_t old = LineRange(line, at);
|
||||
if (old == 0) return std::nullopt;
|
||||
at += old;
|
||||
if (!line.substr(at).starts_with(" +")) return std::nullopt;
|
||||
at += 2;
|
||||
const std::size_t now = LineRange(line, at);
|
||||
if (now == 0) return std::nullopt;
|
||||
at += now;
|
||||
if (!line.substr(at).starts_with(" @@")) return std::nullopt;
|
||||
return line.substr(at + 3);
|
||||
}
|
||||
|
||||
// Every line start that `head` follows: where the text is cut.
|
||||
// Position 0 is one when the text starts with it, which makes the
|
||||
// first piece empty.
|
||||
std::vector<std::string> SplitBeforeLines(std::string_view text, std::string_view head) {
|
||||
std::vector<std::size_t> cuts;
|
||||
for (std::size_t at = 0; at <= text.size();) {
|
||||
if (text.substr(at).starts_with(head)) cuts.push_back(at);
|
||||
const std::size_t newline = text.find('\n', at);
|
||||
if (newline == std::string_view::npos) break;
|
||||
at = newline + 1;
|
||||
}
|
||||
std::vector<std::string> pieces;
|
||||
std::size_t from = 0;
|
||||
for (const std::size_t cut : cuts) {
|
||||
pieces.emplace_back(text.substr(from, cut - from));
|
||||
from = cut;
|
||||
}
|
||||
pieces.emplace_back(text.substr(from));
|
||||
return pieces;
|
||||
}
|
||||
|
||||
// re.sub(r'^Subject: (\[[^\]]*\]\s*)*', 'Subject: ', line): the
|
||||
// `[PATCH n/m]` tags after `Subject: ` dropped, and the space after
|
||||
// each.
|
||||
std::string StripSubjectTags(std::string_view line) {
|
||||
if (!line.starts_with("Subject: ")) return std::string(line);
|
||||
std::size_t at = 9;
|
||||
while (at < line.size() && line[at] == '[') {
|
||||
const std::size_t close = line.find(']', at + 1);
|
||||
if (close == std::string_view::npos) break;
|
||||
at = close + 1;
|
||||
at += Dyn::SpaceRun(line, at);
|
||||
}
|
||||
return std::format("Subject: {}", line.substr(at));
|
||||
}
|
||||
|
||||
// ^base-commit: [0-9a-f]{40}$
|
||||
bool IsBaseCommit(std::string_view line) {
|
||||
constexpr std::string_view Head = "base-commit: ";
|
||||
return line.size() == Head.size() + 40 && line.starts_with(Head) && IsHex(line.substr(Head.size()));
|
||||
}
|
||||
|
||||
std::int64_t IntItem(const Json& range, std::size_t index) {
|
||||
const Json& value = range.at(index);
|
||||
if (value.is_boolean()) return value.get<bool>() ? 1 : 0;
|
||||
return value.get<std::int64_t>();
|
||||
}
|
||||
|
||||
Json Mechanical(std::string_view name, std::string_view lensFile, const Json& unit, std::string_view text, std::string_view basis) {
|
||||
const std::string id = Dyn::At(unit, "id").get<std::string>();
|
||||
return {
|
||||
{"label", std::format("{}:na:{}", name, id)},
|
||||
{"lens", name},
|
||||
{"lensFile", lensFile},
|
||||
{"kind", "auto-na"},
|
||||
{"unit", id},
|
||||
{"expectedIds", {id}},
|
||||
{"unitSha", Sha(text)},
|
||||
{"basis", basis},
|
||||
};
|
||||
}
|
||||
|
||||
bool Contains(std::span<const std::string> list, std::string_view item) {
|
||||
return std::ranges::contains(list, item);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<ModelTier>& Models() {
|
||||
static std::vector<ModelTier> models = {{"opus", "claude-opus-5-5"}, {"fable", "claude-opus-5-5"}};
|
||||
return models;
|
||||
}
|
||||
|
||||
const std::string& ModelFor(std::string_view tier) {
|
||||
for (const ModelTier& model : Models()) {
|
||||
if (model.tier == tier) return model.id;
|
||||
}
|
||||
throw Dyn::Error("KeyError", Dyn::Repr(tier));
|
||||
}
|
||||
|
||||
bool IsLens(std::string_view file) {
|
||||
return std::ranges::any_of(Lenses, [&](const Lens& lens) { return lens.file == file; });
|
||||
}
|
||||
|
||||
const Lens& LensOf(std::string_view file) {
|
||||
for (const Lens& lens : Lenses) {
|
||||
if (lens.file == file) return lens;
|
||||
}
|
||||
throw Dyn::Error("KeyError", Dyn::Repr(file));
|
||||
}
|
||||
|
||||
const std::vector<Gate>& Gates() {
|
||||
static const std::vector<Gate> gates = {
|
||||
{"Gate 1 — Approach", {"repo-better-solution.md"}},
|
||||
{"Gate 2 — Functional", {FunctionalLens}},
|
||||
{"Gate 3 — Proof", {"repo-bug-prover.md", "repo-security.md"}},
|
||||
};
|
||||
return gates;
|
||||
}
|
||||
|
||||
const Environment* FindEnvironment(std::string_view name) {
|
||||
for (const Environment& environment : Environments) {
|
||||
if (environment.name == name) return &environment;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> EnvDeps(std::string_view lensFile, std::string_view environment) {
|
||||
const std::string_view fallback = LensOf(lensFile).env;
|
||||
const std::string_view used = environment.empty() ? fallback : environment;
|
||||
if (used == fallback) return {};
|
||||
return {std::format("env={}", used)};
|
||||
}
|
||||
|
||||
Budget BudgetFor(std::string_view kind, std::string_view unitKind, std::string_view lensFile) {
|
||||
if (kind == "whole") return lensFile == FunctionalLens ? Budget{220, 120} : Budget{180, 105};
|
||||
if (unitKind != "code") throw Dyn::Error("KeyError", Dyn::Repr(std::format("block:{}", unitKind)));
|
||||
return lensFile == FunctionalLens ? Budget{90, 60} : Budget{60, 40};
|
||||
}
|
||||
|
||||
std::string_view DefaultEffort() {
|
||||
return Effort;
|
||||
}
|
||||
|
||||
std::string_view UnitKindOf(std::string_view unitId) {
|
||||
if (unitId.starts_with("whole:")) return "slot";
|
||||
const std::optional<std::size_t> prefix = PkPrefix(unitId);
|
||||
if (!prefix) return "text";
|
||||
// `$` also matches before a final newline.
|
||||
std::string_view name = unitId.substr(*prefix);
|
||||
if (name.ends_with('\n')) name.remove_suffix(1);
|
||||
if (name == "subject" || name == "msg" || name == "trailers" || name == "diffstat") return "text";
|
||||
return "code";
|
||||
}
|
||||
|
||||
std::string_view UnitKind(const Json& unit) {
|
||||
return UnitKindOf(Dyn::At(unit, "id").get_ref<const std::string&>());
|
||||
}
|
||||
|
||||
bool IsDocPath(std::string_view path) {
|
||||
auto endsWithMd = [](std::string_view text) {
|
||||
const std::u32string decoded = Dyn::Decode(text);
|
||||
if (decoded.size() < 3) return false;
|
||||
const std::size_t n = decoded.size();
|
||||
return decoded[n - 3] == '.' && Dyn::IgnoreCaseLetter(decoded[n - 2]) == 'm' && Dyn::IgnoreCaseLetter(decoded[n - 1]) == 'd';
|
||||
};
|
||||
return endsWithMd(path) || (path.ends_with('\n') && endsWithMd(path.substr(0, path.size() - 1)));
|
||||
}
|
||||
|
||||
bool IsDocUnit(std::string_view unitId) {
|
||||
const std::optional<std::string_view> path = FileOfUnit(unitId);
|
||||
return path && IsDocPath(*path);
|
||||
}
|
||||
|
||||
// The lens is not consulted (yet): no lens of this pipeline reads a
|
||||
// documentation file since the facts lens was removed.
|
||||
std::string_view KindFor(std::string_view, const Json& unit) {
|
||||
const std::string_view kind = UnitKind(unit);
|
||||
return kind == "code" && IsDocUnit(Dyn::At(unit, "id").get_ref<const std::string&>()) ? "doc" : kind;
|
||||
}
|
||||
|
||||
std::optional<std::string> UnitPath(std::string_view unitId) {
|
||||
const std::optional<std::string_view> path = FileOfUnit(unitId);
|
||||
if (!path) return std::nullopt;
|
||||
return std::string(*path);
|
||||
}
|
||||
|
||||
std::vector<std::string> NotReviewed(const Json& units, std::span<const std::string> lines, std::span<const std::string> lensFiles,
|
||||
std::span<const std::string> exclude) {
|
||||
std::set<std::string> covered;
|
||||
for (const std::string& lensFile : lensFiles) {
|
||||
const LensPlan plan = PlanLens(lensFile, units, lines, {}, exclude);
|
||||
if (Dyn::Truthy(plan.whole)) {
|
||||
for (const Json& id : Dyn::At(plan.whole, "expectedIds")) covered.insert(id.get<std::string>());
|
||||
}
|
||||
for (const Json& block : plan.blocks) {
|
||||
if (Dyn::At(block, "kind") != "auto-na") covered.insert(Dyn::At(block, "unit").get<std::string>());
|
||||
}
|
||||
}
|
||||
std::vector<std::string> out;
|
||||
for (const Json& unit : units) {
|
||||
const std::string& id = Dyn::At(unit, "id").get_ref<const std::string&>();
|
||||
if (!covered.contains(id)) out.push_back(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::string> ExcludedBy(std::string_view path, std::span<const std::string> globs) {
|
||||
for (const std::string& glob : globs) {
|
||||
if (Dyn::FnMatch(path, glob) || (glob.starts_with("**/") && Dyn::FnMatch(path, std::string_view(glob).substr(3)))) return glob;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string UnitText(std::span<const std::string> lines, const Json& unit) {
|
||||
const Json& range = Dyn::At(unit, "lines");
|
||||
const std::int64_t a = IntItem(range, 0);
|
||||
const std::int64_t b = IntItem(range, 1);
|
||||
if (a == 0) return "";
|
||||
const auto [from, to] = Dyn::SliceBounds(lines.size(), a - 1, b);
|
||||
return Dyn::Join(lines.subspan(from, to - from), "\n");
|
||||
}
|
||||
|
||||
std::string Sha(std::string_view text) {
|
||||
return Sha256Hex(text);
|
||||
}
|
||||
|
||||
std::string LensName(std::string_view lensFile) {
|
||||
return Dyn::Replace(lensFile, ".md", "");
|
||||
}
|
||||
|
||||
std::vector<std::string> ModeLenses() {
|
||||
std::vector<std::string> out;
|
||||
for (const Gate& gate : Gates()) {
|
||||
for (const std::string_view lens : gate.lenses) out.emplace_back(lens);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<Gate> GatesFor(std::span<const std::string> only, std::span<const std::string> without, std::span<const std::string> off) {
|
||||
std::vector<Gate> out;
|
||||
for (const Gate& gate : Gates()) {
|
||||
Gate kept{gate.title, {}};
|
||||
for (const std::string_view lens : gate.lenses) {
|
||||
if (!only.empty() && !Contains(only, lens)) continue;
|
||||
if (Contains(without, lens) || Contains(off, lens)) continue;
|
||||
kept.lenses.push_back(lens);
|
||||
}
|
||||
if (!kept.lenses.empty()) out.push_back(std::move(kept));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
LensPlan PlanLens(std::string_view lensFile, const Json& units, std::span<const std::string> lines, std::span<const std::string> unitsFilter,
|
||||
std::span<const std::string> exclude) {
|
||||
const Lens& lens = LensOf(lensFile);
|
||||
const std::string name = LensName(lensFile);
|
||||
LensPlan plan;
|
||||
if (!lens.whole.empty()) {
|
||||
const Budget budget = BudgetFor("whole", "", lensFile);
|
||||
Json slots = Json::array();
|
||||
for (const Json& unit : units) {
|
||||
const std::string& id = Dyn::At(unit, "id").get_ref<const std::string&>();
|
||||
if (UnitKind(unit) == "slot" && std::ranges::contains(lens.slots, std::string_view(id))) slots.push_back(id);
|
||||
}
|
||||
plan.whole = {
|
||||
{"label", std::format("{}:whole", name)},
|
||||
{"lens", name},
|
||||
{"lensFile", lensFile},
|
||||
{"kind", "whole"},
|
||||
{"unit", nullptr},
|
||||
{"expectedIds", slots},
|
||||
{"model", ModelFor(lens.model)},
|
||||
{"maxTurns", budget.maxTurns},
|
||||
{"timeoutMin", budget.timeoutMin},
|
||||
};
|
||||
}
|
||||
for (const Json& unit : units) {
|
||||
const std::string_view kind = KindFor(lensFile, unit);
|
||||
if (!std::ranges::contains(lens.block, kind)) continue;
|
||||
const std::string& id = Dyn::At(unit, "id").get_ref<const std::string&>();
|
||||
if (!unitsFilter.empty() && !Contains(unitsFilter, id)) continue;
|
||||
const std::string text = UnitText(lines, unit);
|
||||
const std::optional<std::string> path = UnitPath(id);
|
||||
if (!lens.perCommitSlots && (id.ends_with(":outside") || id.ends_with(":absent"))) {
|
||||
// The functional lens judges adjacent flows and what is missing
|
||||
// in its whole pass, not per commit.
|
||||
continue;
|
||||
}
|
||||
if (lensFile == FunctionalLens && path && id.ends_with("#hdr")) {
|
||||
plan.blocks.push_back(Mechanical(name, lensFile, unit, text,
|
||||
"mechanical: a diff header (mode, rename, new or deleted file) carries no behaviour of its own; the hunks of the file do"));
|
||||
continue;
|
||||
}
|
||||
if (lensFile == FunctionalLens && path) {
|
||||
if (const std::optional<std::string> glob = ExcludedBy(*path, exclude)) {
|
||||
plan.blocks.push_back(Mechanical(name, lensFile, unit, text,
|
||||
std::format("mechanical: no runtime behaviour - the file matches functional.exclude ({})", *glob)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const Budget budget = BudgetFor("block", kind, lensFile);
|
||||
plan.blocks.push_back({
|
||||
{"label", std::format("{}:block:{}", name, id)},
|
||||
{"lens", name},
|
||||
{"lensFile", lensFile},
|
||||
{"kind", "block"},
|
||||
{"unit", id},
|
||||
{"unitKind", kind},
|
||||
{"expectedIds", {id}},
|
||||
{"unitSha", Sha(text)},
|
||||
{"model", ModelFor(lens.model)},
|
||||
{"maxTurns", budget.maxTurns},
|
||||
{"timeoutMin", budget.timeoutMin},
|
||||
});
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
std::optional<std::string> ReuseKey(const Json& job, std::string_view lensFile, std::string_view unitText, std::vector<std::string> deps,
|
||||
std::vector<std::string> inForceIds, std::string_view charterSha, std::int32_t promptVersion,
|
||||
std::string_view ident) {
|
||||
if (!LensOf(lensFile).reuse || Dyn::At(job, "kind") != "block") return std::nullopt;
|
||||
std::ranges::sort(deps);
|
||||
std::ranges::sort(inForceIds);
|
||||
const Json& model = Dyn::Get(job, "model");
|
||||
const std::vector<std::string> parts = {
|
||||
std::string(lensFile),
|
||||
ident.empty() ? Dyn::At(job, "unit").get<std::string>() : std::string(ident),
|
||||
Sha(unitText),
|
||||
Sha(Dyn::Join(deps, "\n")),
|
||||
Dyn::Join(inForceIds, ","),
|
||||
std::string(charterSha),
|
||||
std::to_string(promptVersion),
|
||||
Dyn::Has(job, "model") ? model.get<std::string>() : std::string(),
|
||||
std::string(ReuseKeyEffortSlot),
|
||||
};
|
||||
return Sha(Dyn::Join(parts, "|")).substr(0, 16);
|
||||
}
|
||||
|
||||
std::string CodeNorm(std::string_view text) {
|
||||
std::vector<std::string> lines = Dyn::Split(text, "\n");
|
||||
while (!lines.empty() && lines.back().empty()) lines.pop_back();
|
||||
if (lines.size() >= 2 && lines[lines.size() - 2] == "-- " && StartsWithVersion(lines.back())) {
|
||||
lines.resize(lines.size() - 2);
|
||||
while (!lines.empty() && (lines.back().empty() || IsTailLine(lines.back()))) lines.pop_back();
|
||||
}
|
||||
for (std::string& line : lines) {
|
||||
if (const std::optional<std::string_view> mode = IndexMode(line)) {
|
||||
line = std::format("index{}", *mode);
|
||||
} else if (const std::optional<std::string_view> tail = HunkTail(line)) {
|
||||
line = std::format("@@{}", *tail);
|
||||
}
|
||||
}
|
||||
return Dyn::Join(lines, "\n");
|
||||
}
|
||||
|
||||
PatchParts PartsOf(std::string_view patchText) {
|
||||
const std::vector<std::string> lines = Dyn::Split(patchText, "\n");
|
||||
std::optional<std::size_t> subject;
|
||||
for (std::size_t i = 0; i < lines.size(); ++i) {
|
||||
if (lines[i].starts_with("Subject:")) {
|
||||
subject = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::size_t body0 = 1;
|
||||
if (subject) {
|
||||
for (std::size_t i = *subject + 1; i < lines.size(); ++i) {
|
||||
if (Dyn::Strip(lines[i]).empty()) {
|
||||
body0 = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::size_t diff0 = lines.size();
|
||||
for (std::size_t i = body0; i < lines.size(); ++i) {
|
||||
if (lines[i].starts_with("---")) {
|
||||
diff0 = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::size_t first = lines.size();
|
||||
for (std::size_t i = diff0 + 1; i < lines.size(); ++i) {
|
||||
if (lines[i].starts_with("diff --git")) {
|
||||
first = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
PatchParts parts;
|
||||
for (const std::string& line : lines) {
|
||||
if (IsBaseCommit(line)) {
|
||||
parts.base = Dyn::Split(line)[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const std::span<const std::string> code(lines.begin() + static_cast<std::ptrdiff_t>(std::min(first, lines.size())), lines.end());
|
||||
for (const std::string& section : SplitBeforeLines(CodeNorm(Dyn::Join(code, "\n")), "diff --git ")) {
|
||||
const std::string firstLine = Dyn::Split(section, "\n", 1)[0];
|
||||
const std::string path = Dyn::Strip(Dyn::Split(firstLine, " b/").back());
|
||||
if (section.starts_with("diff --git ") && IsDocPath(path)) {
|
||||
// re.split(r'(?m)^@@', section, maxsplit=1)[0].rstrip('\n')
|
||||
std::size_t header = section.size();
|
||||
for (std::size_t at = 0; at < section.size();) {
|
||||
if (std::string_view(section).substr(at).starts_with("@@")) {
|
||||
header = at;
|
||||
break;
|
||||
}
|
||||
const std::size_t newline = section.find('\n', at);
|
||||
if (newline == std::string::npos) break;
|
||||
at = newline + 1;
|
||||
}
|
||||
parts.docs.push_back(Dyn::RStrip(std::string_view(section).substr(0, header), "\n"));
|
||||
} else {
|
||||
parts.code += section;
|
||||
}
|
||||
}
|
||||
std::ranges::sort(parts.docs);
|
||||
const auto [messageFrom, messageTo] = Dyn::SliceBounds(lines.size(), static_cast<std::int64_t>(subject ? *subject : body0), static_cast<std::int64_t>(diff0));
|
||||
std::vector<std::string> message;
|
||||
for (std::size_t i = messageFrom; i < messageTo; ++i) message.push_back(StripSubjectTags(lines[i]));
|
||||
parts.message = Dyn::Join(message, "\n");
|
||||
const auto [bodyFrom, bodyTo] = Dyn::SliceBounds(lines.size(), static_cast<std::int64_t>(body0), static_cast<std::int64_t>(diff0));
|
||||
for (std::size_t i = bodyFrom; i < bodyTo; ++i) {
|
||||
if (!Dyn::Strip(lines[i]).empty()) {
|
||||
parts.hasBody = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
CommitCode CommitCodeOf(const std::map<std::int64_t, std::string>& patches) {
|
||||
CommitCode cc;
|
||||
std::set<std::string> bases;
|
||||
std::vector<std::string> codes;
|
||||
for (const auto& [k, text] : patches) {
|
||||
PatchParts parts = PartsOf(text);
|
||||
cc.body[k] = parts.hasBody;
|
||||
cc.code[k] = Sha(parts.code);
|
||||
cc.docs[k] = std::move(parts.docs);
|
||||
cc.message[k] = Sha(parts.message);
|
||||
if (!parts.base.empty()) bases.insert(parts.base);
|
||||
codes.push_back(cc.code[k]);
|
||||
cc.upto[k] = Sha(Dyn::Join(codes, "\n"));
|
||||
}
|
||||
cc.range = Sha(Dyn::Join(codes, "\n"));
|
||||
cc.base = Dyn::Join(std::vector<std::string>(bases.begin(), bases.end()), ",");
|
||||
return cc;
|
||||
}
|
||||
|
||||
std::pair<std::string, std::vector<std::string>> CodeKeyParts(std::string_view unitId, const CommitCode& cc) {
|
||||
const std::size_t colon = unitId.find(':');
|
||||
if (colon == std::string_view::npos) throw Dyn::Error("ValueError", "not enough values to unpack (expected 2, got 1)");
|
||||
const std::string_view pk = unitId.substr(0, colon);
|
||||
const std::string_view rest = unitId.substr(colon + 1);
|
||||
const std::string_view number = pk.empty() ? pk : pk.substr(1);
|
||||
const std::optional<std::int64_t> k = Dyn::ParseInt(number);
|
||||
if (!k) throw Dyn::Error("ValueError", Dyn::IntError(number));
|
||||
std::vector<std::string> deps = {std::format("base={}", cc.base)};
|
||||
if (rest == "outside" || rest == "absent") {
|
||||
const auto code = cc.code.find(*k);
|
||||
if (code == cc.code.end()) throw Dyn::Error("KeyError", std::to_string(*k));
|
||||
deps.push_back(std::format("commit={}:{}", *k, code->second));
|
||||
deps.push_back(std::format("range={}", cc.range));
|
||||
deps.push_back(std::format("docs={}", Dyn::Join(cc.docs.at(*k), "|")));
|
||||
if (rest == "absent") deps.push_back(std::format("body={}", cc.body.at(*k) ? "yes" : "no"));
|
||||
return {std::format("code:{}", rest), deps};
|
||||
}
|
||||
const std::size_t hash = rest.rfind('#');
|
||||
if (hash == std::string_view::npos) throw Dyn::Error("ValueError", "not enough values to unpack (expected 2, got 1)");
|
||||
const std::string_view tag = rest.substr(hash + 1);
|
||||
return {std::format("code:{}#{}", rest.substr(0, hash), tag == "hdr" ? "hdr" : "hunk"), deps};
|
||||
}
|
||||
|
||||
std::optional<std::string> CodeReuseKey(const Json& job, std::string_view lensFile, std::string_view unitText, const CommitCode& cc,
|
||||
std::span<const std::string> inForceIds, std::string_view charterSha, std::int32_t promptVersion,
|
||||
std::string_view environment) {
|
||||
auto [ident, deps] = CodeKeyParts(Dyn::At(job, "unit").get_ref<const std::string&>(), cc);
|
||||
std::ranges::move(EnvDeps(lensFile, environment), std::back_inserter(deps));
|
||||
return ReuseKey(job, lensFile, CodeNorm(unitText), deps, {inForceIds.begin(), inForceIds.end()}, charterSha, promptVersion, ident);
|
||||
}
|
||||
|
||||
std::optional<std::string> FunctionalReuseKey(const Json& job, std::string_view lensFile, std::string_view unitText, const CommitCode& cc,
|
||||
std::span<const std::string> inForceIds, std::string_view charterSha, std::int32_t promptVersion,
|
||||
std::string_view environment) {
|
||||
auto [ident, deps] = CodeKeyParts(Dyn::At(job, "unit").get_ref<const std::string&>(), cc);
|
||||
deps.push_back(std::format("range={}", cc.range));
|
||||
std::ranges::move(EnvDeps(lensFile, environment), std::back_inserter(deps));
|
||||
return ReuseKey(job, lensFile, CodeNorm(unitText), deps, {inForceIds.begin(), inForceIds.end()}, charterSha, promptVersion,
|
||||
std::format("functional:{}", ident));
|
||||
}
|
||||
|
||||
std::optional<std::string> BlockReuseKey(const Json& job, std::string_view lensFile, std::string_view unitText, const CommitCode& cc,
|
||||
std::span<const std::string> inForceIds, std::string_view charterSha, std::int32_t promptVersion,
|
||||
std::string_view environment) {
|
||||
if (LensOf(lensFile).key == "functional") {
|
||||
return FunctionalReuseKey(job, lensFile, unitText, cc, inForceIds, charterSha, promptVersion, environment);
|
||||
}
|
||||
return CodeReuseKey(job, lensFile, unitText, cc, inForceIds, charterSha, promptVersion, environment);
|
||||
}
|
||||
|
||||
std::optional<std::string> WholeCodeKey(const Json& job, std::string_view lensFile, const CommitCode& cc, std::span<const std::string> inForceIds,
|
||||
std::string_view charterSha, std::int32_t promptVersion, std::string_view environment) {
|
||||
if (!LensOf(lensFile).wholeReuse || Dyn::At(job, "kind") != "whole") return std::nullopt;
|
||||
std::string body;
|
||||
for (const auto& [k, has] : cc.body) {
|
||||
if (!body.empty()) body.push_back(',');
|
||||
body.push_back(has ? 'y' : 'n');
|
||||
}
|
||||
std::vector<std::string> docs;
|
||||
for (const auto& [k, files] : cc.docs) docs.push_back(Dyn::Join(files, "|"));
|
||||
std::vector<std::string> deps = {
|
||||
std::format("base={}", cc.base),
|
||||
std::format("range={}", cc.range),
|
||||
std::format("body={}", body),
|
||||
std::format("docs={}", Dyn::Join(docs, ";")),
|
||||
};
|
||||
std::ranges::move(EnvDeps(lensFile, environment), std::back_inserter(deps));
|
||||
std::vector<std::string> ids(inForceIds.begin(), inForceIds.end());
|
||||
std::ranges::sort(ids);
|
||||
const std::vector<std::string> parts = {
|
||||
std::string(lensFile),
|
||||
"code:whole",
|
||||
Sha(Dyn::Join(deps, "\n")),
|
||||
Dyn::Join(ids, ","),
|
||||
std::string(charterSha),
|
||||
std::to_string(promptVersion),
|
||||
Dyn::Has(job, "model") ? Dyn::Get(job, "model").get<std::string>() : std::string(),
|
||||
std::string(ReuseKeyEffortSlot),
|
||||
};
|
||||
return Sha(Dyn::Join(parts, "|")).substr(0, 16);
|
||||
}
|
||||
|
||||
std::int64_t ReadsOf(const Json& entry) {
|
||||
if (!Dyn::Truthy(entry)) return 0;
|
||||
const Json& reads = Dyn::Get(entry, "reads");
|
||||
if (!Dyn::Truthy(reads)) return 1;
|
||||
if (reads.is_string()) return static_cast<std::int64_t>(Dyn::Length(reads.get_ref<const std::string&>()));
|
||||
return static_cast<std::int64_t>(reads.size());
|
||||
}
|
||||
|
||||
bool ReadIn(const Json& entry, std::string_view hash8, std::int64_t round) {
|
||||
const Json& reads = Dyn::Or(Dyn::Get(entry, "reads"), Dyn::EmptyList());
|
||||
return std::ranges::any_of(reads, [&](const Json& read) {
|
||||
return Dyn::Equal(Dyn::Get(read, "hash"), Json(hash8)) && Dyn::Equal(Dyn::Get(read, "round"), Json(round));
|
||||
});
|
||||
}
|
||||
|
||||
Json Rebind(const Json& entry, std::string_view unitId) {
|
||||
Json out = entry;
|
||||
out["unit"] = unitId;
|
||||
Json coverage = Dyn::At(out, "coverage");
|
||||
if (!coverage.is_object()) throw Dyn::Error("TypeError", std::format("'{}' object is not iterable", Dyn::TypeName(coverage)));
|
||||
coverage["item"] = unitId;
|
||||
out["coverage"] = std::move(coverage);
|
||||
for (const std::string_view field : {std::string_view("findings"), std::string_view("notes")}) {
|
||||
if (!Dyn::Has(out, field)) continue;
|
||||
for (Json& item : out[std::string(field)]) {
|
||||
if (Dyn::Get(item, "items").is_array()) item["items"] = Json::array({unitId});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Json Estimate(std::span<const Json> jobs, std::int64_t prefixBytes, const std::map<std::string, std::int64_t>& lensLayerBytes,
|
||||
std::int64_t tailBytes, double tpb, std::int64_t sysTokens, std::int64_t outPerTurn) {
|
||||
const double prefix = static_cast<double>(prefixBytes) / tpb + static_cast<double>(sysTokens);
|
||||
double cacheRead = 0.0;
|
||||
double cacheWrite = 0.0;
|
||||
double output = 0.0;
|
||||
std::int64_t processes = 0;
|
||||
std::set<std::pair<std::string, std::string>> seen;
|
||||
for (const Json& job : jobs) {
|
||||
const std::string kind = Dyn::At(job, "kind").get<std::string>();
|
||||
if (kind == "auto-na") continue;
|
||||
const std::string lens = Dyn::At(job, "lens").get<std::string>();
|
||||
const auto layer = lensLayerBytes.find(lens);
|
||||
const double lensTokens = static_cast<double>(layer == lensLayerBytes.end() ? 3000 : layer->second) / tpb;
|
||||
const double tail = static_cast<double>(tailBytes) / tpb;
|
||||
const std::int64_t turns = kind == "whole" ? 40 : (Dyn::At(job, "lensFile") == FunctionalLens ? 25 : 10);
|
||||
if (seen.insert({lens, kind}).second) cacheWrite += prefix + lensTokens;
|
||||
cacheWrite += tail + static_cast<double>(turns * 3000);
|
||||
std::vector<double> reads;
|
||||
for (std::int64_t i = 0; i < turns; ++i) reads.push_back(prefix + lensTokens + tail + static_cast<double>(3000 * i));
|
||||
cacheRead += Dyn::Sum(reads);
|
||||
output += static_cast<double>(turns * outPerTurn);
|
||||
++processes;
|
||||
}
|
||||
const double costUnits = 1.25 * cacheWrite + 0.1 * cacheRead + 5.0 * output;
|
||||
const double listUsd = Dyn::Round((6.25 * cacheWrite + 0.5 * cacheRead + 25.0 * output) / 1e6, 2);
|
||||
return {
|
||||
{"cacheRead", cacheRead},
|
||||
{"cacheWrite", cacheWrite},
|
||||
{"output", output},
|
||||
{"processes", processes},
|
||||
{"costUnits", costUnits},
|
||||
{"listUsd", listUsd},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ namespace Crafter::Review::Platform {
|
|||
FileLock::FileLock(const fs::path& lockPath) {
|
||||
std::error_code error;
|
||||
if (lockPath.has_parent_path()) fs::create_directories(lockPath.parent_path(), error);
|
||||
fd = open(lockPath.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
|
||||
fd = open(lockPath.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0666);
|
||||
if (fd < 0) {
|
||||
problem = Dyn::ErrnoText(errno);
|
||||
return;
|
||||
|
|
|
|||
239
implementations/Crafter.Review-Prompt.cpp
Normal file
239
implementations/Crafter.Review-Prompt.cpp
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Prompt_impl;
|
||||
import :Prompt;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import :Core;
|
||||
import :Plan;
|
||||
import :PgWire;
|
||||
import std;
|
||||
|
||||
namespace Crafter::Review::Prompt {
|
||||
const std::string_view GenericVocab =
|
||||
"Coverage vocabulary: clear (you checked this unit by your charter and found nothing - the basis says what you checked it against: the tree ref, the command run, the steps taken in the application and the evidence file), "
|
||||
"defect (a finding with items:[id] exists), immaterial (a note kind=immaterial with items:[id] exists), n/a (the unit carries nothing your charter judges - the basis says why). "
|
||||
"For the functional lens \"clear\" means the behaviour of the unit was exercised in the running application and observed as the code intends, never read as plausible; a unit with no reachable behaviour is n/a with the reason, and a behaviour you could not reach for a reason that is not by design is an `unverified` note with n/a, never clear.";
|
||||
|
||||
const std::string_view WholeFirstRepo =
|
||||
"SCOPE - WHOLE PASS, and it gates the block passes of this lens: judge the range as an APPROACH for THIS repository - is this the right solution at all (an existing mechanism in the repository or its platform, the right layer or component, the right commit shape, compatibility with what users already have installed)? "
|
||||
"Do not review individual hunks; per-block passes of this lens run only if you pass. Return `summary` (5-10 sentences: the approach as you judged it and the constraints the block passes must respect). Cover exactly the units listed below (the whole:* slots).";
|
||||
|
||||
const std::string_view WholeFunctional =
|
||||
"SCOPE - WHOLE PASS, and it gates the block passes of this lens: exercise the range as a USER of the running application named under APPLICATION UNDER TEST. From the code and the commit messages derive every behaviour the range adds, changes or removes and every flow it touches; "
|
||||
"reach each one in the browser, act, observe, and compare what you see with what the code says; then exercise one adjacent flow per shared component, store, RPC or function the range touches. A behaviour that does not do what the code intends, a flow the range breaks, or a promised behaviour the application does not deliver is a finding with the steps and the evidence. "
|
||||
"Do not judge individual hunks in isolation; the block passes of this lens do that and run only if you pass. Return `summary` (5-10 sentences the block passes rest on: how to reach each touched screen, the login used, what was exercised, what the block passes must not touch or change). Cover exactly the units listed below (the whole:* slots).";
|
||||
|
||||
const std::string_view FunctionalBlockHint =
|
||||
"This pass judges ONE hunk in the running application: find the behaviour this hunk changes (what a user does, what the application answers), reach it as a user - the whole pass of this lens says how - exercise it, including the error or empty path the hunk touches, and compare what you observe with what the code says it does. "
|
||||
"A hunk whose change has no reachable behaviour of its own (a type, a helper only other hunks make visible, a test, a build script) is n/a with the reason - never clear. "
|
||||
"The stack (the application, its database, the account) is yours alone while this pass runs, but it is not reset between passes: earlier passes may have left records in it and later ones will see yours - create only what you need, name it with this unit id, never delete or change what you did not create in this pass, and leave the account as you found it.";
|
||||
|
||||
const std::string_view ShotRules =
|
||||
"Take a screenshot (browser_take_screenshot, WITHOUT a filename - the server stores it under this pass's output directory and returns the path; a filename you choose is written into the repository's working tree) of every state a finding or a clear verdict rests on; the returned path is your evidence. browser_snapshot before every action - element refs come from the snapshot and die on navigation. After two failed attempts at the same target, stop and record an `unverified` note saying what blocked you.";
|
||||
|
||||
const std::string_view HintOutside =
|
||||
"Judge the in-repository users and callers of what this commit changes, outside its own diff - at the base (`git -C <tree> show <base>:<path>`) and at HEAD.";
|
||||
const std::string_view HintAbsent =
|
||||
"Judge what a reviewer of this repository expects in this commit and does not find: a commit body at all (a body-less commit has no pK:msg unit), a test, a version or changelog bump, a doc or packaging update, an error path, a migration for existing installs.";
|
||||
|
||||
namespace {
|
||||
// An optional string as the prompt prints it: None when absent.
|
||||
std::string OrNone(const std::optional<std::string>& value) {
|
||||
return value ? *value : "None";
|
||||
}
|
||||
|
||||
// A string, or Error (TypeError): only a string is concatenated.
|
||||
const std::string& Text(const Json& value) {
|
||||
if (!value.is_string()) throw Dyn::Error("TypeError", std::format("can only concatenate str (not \"{}\") to str", Dyn::TypeName(value)));
|
||||
return value.get_ref<const std::string&>();
|
||||
}
|
||||
|
||||
std::string JoinStrings(const Json& list, std::string_view separator) {
|
||||
std::vector<std::string> parts;
|
||||
for (const Json& item : list) parts.push_back(Text(item));
|
||||
return Dyn::Join(parts, separator);
|
||||
}
|
||||
|
||||
std::int64_t IntItem(const Json& range, std::size_t index) {
|
||||
const Json& value = range.at(index);
|
||||
if (value.is_boolean()) return value.get<bool>() ? 1 : 0;
|
||||
return value.get<std::int64_t>();
|
||||
}
|
||||
}
|
||||
|
||||
std::string SystemText() {
|
||||
return Dyn::Replace(Dyn::ReadText(LensDir() / "_common-repo.md"), "{{ARTIFACT_KIND}}", "commit range");
|
||||
}
|
||||
|
||||
std::string Charter(std::string_view lensFile) {
|
||||
return Dyn::ReadText(LensDir() / std::string(lensFile));
|
||||
}
|
||||
|
||||
std::string RunLayer(std::string_view artifactRel, std::string_view base, std::string_view date, std::span<const std::string> trees, std::string_view hash,
|
||||
std::span<const std::string> lines, const Json& units, std::span<const std::string> contextPaths, std::string_view repoName) {
|
||||
std::vector<std::string> inputs;
|
||||
inputs.push_back(std::format("- The frozen artifact (review THIS, at hash {}): the *.patch files in {} - `git format-patch` of the commit range under review, inlined below in sorted order; nothing else in that dir is an input",
|
||||
hash, artifactRel));
|
||||
inputs.push_back(std::format("- Repository under review ({}), checked out at the range HEAD (the last commit of the artifact): {} - file content, history and blame come from this path; spell it exactly like this in every git -C call{}",
|
||||
repoName, Dyn::Join(trees, ", "), !trees.empty() && trees.front() == "." ? " (`.` is the working directory)" : ""));
|
||||
inputs.push_back(base.empty() ? std::string("- Range base: the base-commit line of the last patch")
|
||||
: std::format("- Range base (the state before the first commit; `git -C <tree> show <base>:<path>`): {}", base));
|
||||
if (!contextPaths.empty()) {
|
||||
inputs.push_back(std::format("- Material the artifact discusses (read-only evidence; do not spend turns locating it): {}", Dyn::Join(contextPaths, ", ")));
|
||||
}
|
||||
inputs.push_back("- No mailing list, no network beyond the application under test: what is not on disk or in the running application is unverifiable and becomes a note");
|
||||
inputs.push_back(std::format("- Run date: {}", date));
|
||||
std::vector<std::string> artifact;
|
||||
artifact.reserve(lines.size());
|
||||
for (std::size_t i = 0; i < lines.size(); ++i) artifact.push_back(std::format("{:04d}| {}", i + 1, lines[i]));
|
||||
std::vector<std::string> inventory;
|
||||
for (const Json& unit : units) {
|
||||
const Json& range = Dyn::At(unit, "lines");
|
||||
inventory.push_back(std::format("- {} [{}-{}] {}", Dyn::Str(Dyn::At(unit, "id")), Dyn::Str(range.at(0)), Dyn::Str(range.at(1)), Dyn::Str(Dyn::At(unit, "excerpt"))));
|
||||
}
|
||||
return std::format("INPUTS:\n{}\n\nARTIFACT (frozen, hash {}), inlined with line numbers:\n{}\n\nINVENTORY - {} units, a MECHANICAL partition of the artifact (every non-blank line belongs to exactly one unit; fixed slots with lines 0-0 stand for what is NOT in the text):\n{}",
|
||||
Dyn::Join(inputs, "\n"), hash, Dyn::Join(artifact, "\n"), units.size(), Dyn::Join(inventory, "\n"));
|
||||
}
|
||||
|
||||
std::string AppBlock(const Json& functional, const std::optional<std::string>& loginFixture, const std::optional<std::string>& totpCommand, bool slots) {
|
||||
const Json& f = Dyn::Or(functional, Dyn::EmptyDict());
|
||||
std::vector<std::string> lines = {"APPLICATION UNDER TEST (this lens drives it with the Playwright MCP tools mcp__playwright__*):"};
|
||||
if (slots) {
|
||||
lines.emplace_back("- The application: a stack of this pass's own (the application, its API, its database and a login), one of several the operator brought up side by side from the tree named under INPUTS at the range HEAD; STACK FOR THIS PASS, at the end of the prompt, names its URL, the other origins it answers on and its login (the driver verified each stack answers before the gate started)");
|
||||
} else {
|
||||
lines.push_back(std::format("- The application: {} - served from the tree named under INPUTS at the range HEAD (the driver verified the tree is at that HEAD and that this URL answers before the gate started)",
|
||||
Dyn::Str(Dyn::Get(f, "appUrl"))));
|
||||
if (Dyn::Truthy(Dyn::Get(f, "apiUrls"))) lines.push_back(std::format("- Other local origins you may call (the API, mail): {}", JoinStrings(Dyn::At(f, "apiUrls"), ", ")));
|
||||
if (loginFixture && !loginFixture->empty()) {
|
||||
lines.push_back(std::format("- Login: the fixture {} (JSON: email, password, totpSecret - a throwaway test account on the local stack); read it with the Read tool, and for the one-time code run exactly `{}` (prints a 6-digit code and the seconds left in its window; regenerate when it rolled over)",
|
||||
*loginFixture, OrNone(totpCommand)));
|
||||
} else {
|
||||
lines.emplace_back("- Login: no fixture was found on disk; what the application shows without a session is what you can exercise, and a behaviour behind a login is an `unverified` note with n/a");
|
||||
}
|
||||
}
|
||||
if (Dyn::Truthy(Dyn::Get(Dyn::Or(Dyn::Get(f, "login"), Dyn::EmptyDict()), "notes"))) {
|
||||
lines.push_back(std::format("- Login notes: {}", Text(Dyn::At(Dyn::At(f, "login"), "notes"))));
|
||||
}
|
||||
if (Dyn::Truthy(Dyn::Get(f, "notes"))) lines.push_back(std::format("- Notes from the repository owner: {}", Text(Dyn::At(f, "notes"))));
|
||||
lines.push_back(std::format("- Rules: navigate only to the origins named here; the browser and the stack (the application, its database, the account) are yours alone while this pass runs, but the stack is not reset between passes: earlier passes of this gate may have left records in it and later ones will see what you leave - create only what you need, name it with your unit id, never delete or change what you did not create in this pass, and leave the account as you found it (where the range makes you change its password, an MFA factor, an organisation setting or the subscription, restore it; where you cannot, say so in an `info` note). Never sign up a new account unless the range is about signup. {}",
|
||||
ShotRules));
|
||||
return Dyn::Join(lines, "\n");
|
||||
}
|
||||
|
||||
std::string StackText(const Json& stack, const std::optional<std::string>& totpCommand) {
|
||||
std::vector<std::string> lines = {
|
||||
std::format("STACK FOR THIS PASS ({}, yours alone while this pass runs):", Dyn::Str(Dyn::At(stack, "id"))),
|
||||
std::format("- The application: {}", Dyn::Str(Dyn::At(stack, "appUrl"))),
|
||||
};
|
||||
if (Dyn::Truthy(Dyn::Get(stack, "apiUrls"))) lines.push_back(std::format("- Other local origins you may call (the API, mail): {}", JoinStrings(Dyn::At(stack, "apiUrls"), ", ")));
|
||||
if (Dyn::Truthy(Dyn::Get(stack, "login"))) {
|
||||
lines.push_back(std::format("- Login: the fixture {} (JSON: email, password, totpSecret - a throwaway test account on this stack); read it with the Read tool, and for the one-time code run exactly `{}` (prints a 6-digit code and the seconds left in its window; regenerate when it rolled over)",
|
||||
Dyn::Str(Dyn::At(stack, "login")), OrNone(totpCommand)));
|
||||
} else {
|
||||
lines.emplace_back("- Login: this stack has no login fixture; what the application shows without a session is what you can exercise, and a behaviour behind a login is an `unverified` note with n/a");
|
||||
}
|
||||
return Dyn::Join(lines, "\n");
|
||||
}
|
||||
|
||||
std::string DbBlock(const Json& supabase, std::string_view sqlCommand, const std::optional<std::string>& dbUser, bool withApp,
|
||||
const std::optional<std::string>& checked, std::optional<bool> bypass) {
|
||||
const Json& s = Dyn::Or(supabase, Dyn::EmptyDict());
|
||||
std::string role;
|
||||
if (dbUser && !dbUser->empty()) {
|
||||
role = std::format("The connection is the role `{}`{}", *dbUser,
|
||||
!bypass ? "." : (*bypass ? ", which bypasses row-level security." : ", which row-level security applies to."));
|
||||
} else {
|
||||
role = "The connection's role differs between the stacks: `select current_user` says which is yours, and its pg_roles row whether row-level security applies to it.";
|
||||
}
|
||||
std::vector<std::string> lines = {
|
||||
std::format("DATABASE UNDER TEST (this lens runs SQL against it with the exact command `{}`):", sqlCommand),
|
||||
std::format("- The database: the Postgres of the local Supabase stack the operator brought up from the tree named under INPUTS - a throwaway stack, never a hosted project. Checked by the driver before this gate: {}. {}",
|
||||
checked && !checked->empty() ? *checked : std::string("nothing (a dry run)"), role),
|
||||
std::format("- Run SQL with exactly `{} <<'SQL'`, the statements on the lines after it, and a last line `SQL`. Every call is ONE transaction that the helper rolls back at its end: what a statement writes is seen by the later statements of the same call and by nothing else - not by the application, not by the other passes running at the same time, not by your next call. So a proof goes into one call: set up the state, act, read the outcome. BEGIN, COMMIT, ROLLBACK, END, ABORT, PREPARE TRANSACTION and COPY are refused (SAVEPOINT and ROLLBACK TO are fine); the first statement that fails stops the call.",
|
||||
sqlCommand),
|
||||
"- Acting as a caller of the API: `set local role authenticated;` then `select set_config('request.jwt.claims', '{\"sub\":\"<a user id from auth.users>\",\"role\":\"authenticated\"}', true);` makes row-level security, auth.uid() and auth.jwt() behave as for that signed-in user; `set local role anon` as for a visitor without a session; `set local role service_role` as the service key. Say in your evidence which role each query ran as.",
|
||||
std::format("- Limits: statement_timeout {} s and lock_timeout {} s per statement; at most {} rows of a result are printed. A lock you take (DDL, an UPDATE) holds until your call ends while the other passes and the application wait on it - keep such calls short.",
|
||||
PgWire::StatementTimeoutS, PgWire::LockTimeoutS, PgWire::MaxRows),
|
||||
"- Evidence: the SQL you ran and the lines the helper printed for it - quote both in the finding or the coverage basis. Output that starts `review-gates sql: NO DATABASE` means the database did not answer: what rests on it is unverified, never clear.",
|
||||
};
|
||||
if (withApp) {
|
||||
lines.emplace_back("- Nothing you write here reaches the application: create what the browser must see through the application itself, and use this database to read back what the application persisted and to inspect state no screen shows.");
|
||||
}
|
||||
if (Dyn::Truthy(Dyn::Get(s, "notes"))) lines.push_back(std::format("- Notes from the repository owner: {}", Text(Dyn::At(s, "notes"))));
|
||||
return Dyn::Join(lines, "\n");
|
||||
}
|
||||
|
||||
std::string LensLayer(std::string_view lensFile, std::string_view charterText, std::string_view dispositionsBlock, std::string_view app,
|
||||
std::string_view wholeSummary, std::string_view db) {
|
||||
std::vector<std::string> parts = {std::format("YOUR CHARTER (process/lenses/{}), verbatim:\n\n{}", lensFile, Dyn::Strip(charterText))};
|
||||
if (!app.empty()) parts.emplace_back(app);
|
||||
if (!db.empty()) parts.emplace_back(db);
|
||||
parts.emplace_back(dispositionsBlock);
|
||||
if (!wholeSummary.empty()) {
|
||||
parts.push_back(std::format("The whole pass of this lens on this hash concluded (the constraints your block pass rests on):\n{}", Dyn::Slice(wholeSummary, 0, 2500)));
|
||||
}
|
||||
return Dyn::Join(parts, "\n\n");
|
||||
}
|
||||
|
||||
std::string Tail(const Json& job, const Json& units, std::span<const std::string> lines, std::string_view wholeSummary) {
|
||||
const std::string lensFile = Dyn::At(job, "lensFile").get<std::string>();
|
||||
const Json& kind = Dyn::At(job, "kind");
|
||||
if (kind == "whole") {
|
||||
const std::string_view whole = Plan::LensOf(lensFile).whole;
|
||||
if (whole != "first-repo" && whole != "functional") throw Dyn::Error("KeyError", Dyn::ReprValue(whole.empty() ? Json() : Json(whole)));
|
||||
std::vector<std::string> listed;
|
||||
const Json& expected = Dyn::At(job, "expectedIds");
|
||||
for (const Json& unit : units) {
|
||||
if (Dyn::In(Dyn::At(unit, "id"), expected)) listed.push_back(std::format("- {} [0-0] {}", Dyn::Str(Dyn::At(unit, "id")), Dyn::Str(Dyn::At(unit, "excerpt"))));
|
||||
}
|
||||
return std::format("{}\n{}\nUnits to cover:\n{}", whole == "first-repo" ? WholeFirstRepo : WholeFunctional, GenericVocab, Dyn::Join(listed, "\n"));
|
||||
}
|
||||
const Json* found = nullptr;
|
||||
for (const Json& unit : units) {
|
||||
if (Dyn::Equal(Dyn::At(unit, "id"), Dyn::At(job, "unit"))) {
|
||||
found = &unit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) throw Dyn::Error("StopIteration", "");
|
||||
const Json& u = *found;
|
||||
const std::string id = Dyn::Str(Dyn::At(u, "id"));
|
||||
if (kind == "slot") {
|
||||
const std::string description = Dyn::Replace(Text(Dyn::At(u, "excerpt")), "FIXED SLOT: ", "");
|
||||
return std::format("SCOPE - SLOT PASS, unit \"{}\": this unit has no lines; it stands for a question about the whole artifact: {}. Answer it for your lens. Coverage: exactly this one unit.\n{}",
|
||||
id, description, GenericVocab);
|
||||
}
|
||||
const Json& range = Dyn::At(u, "lines");
|
||||
const std::int64_t a = IntItem(range, 0);
|
||||
const std::int64_t b = IntItem(range, 1);
|
||||
std::string_view hint;
|
||||
if (id.ends_with(":outside")) {
|
||||
hint = HintOutside;
|
||||
} else if (id.ends_with(":absent")) {
|
||||
hint = HintAbsent;
|
||||
} else if (lensFile == Plan::FunctionalLens) {
|
||||
hint = FunctionalBlockHint;
|
||||
}
|
||||
const std::string text = Plan::UnitText(lines, u);
|
||||
const std::string scope = std::format("SCOPE - BLOCK PASS, unit \"{}\" [lines {}-{}]: judge ONLY this unit, with your full attention by your charter - a hunk against the standard - reading the rest of the artifact only as context. Do not re-litigate the approach as a whole{}. Coverage: exactly this one unit.",
|
||||
id, a, b, wholeSummary.empty() ? "" : " - the whole pass of this lens concluded as stated above");
|
||||
return std::format("{}{}\n\nUNIT TEXT (verbatim, lines {}-{}):\n{}\n\n{}", scope, hint.empty() ? std::string() : std::format("\n{}", hint), a, b, text, GenericVocab);
|
||||
}
|
||||
|
||||
std::string BudgetLine(const Json& job) {
|
||||
return std::format("\n\nTool-call budget for this pass: {} (every tool call is one request; the pipeline stops the process at that count and a stopped pass "
|
||||
"returns NOTHING - the unit is then unaddressed and the lens NOT RETURNED). Batch independent reads into one call and return the structured output with margin to spare.",
|
||||
Dyn::Str(Dyn::Get(job, "maxTurns")));
|
||||
}
|
||||
|
||||
std::string SystemPrompt(std::string_view systemText, std::string_view run, std::string_view lens) {
|
||||
return std::format("{}{}{}{}{}", systemText, SepRun, run, SepLens, lens);
|
||||
}
|
||||
|
||||
std::string UserPrompt(std::string_view tail, const Json& job, std::string_view stack) {
|
||||
return std::format("{}{}{}\n\nReturn the structured output described in your instructions.\n", tail, stack.empty() ? std::string() : std::format("\n\n{}", stack),
|
||||
Dyn::Truthy(job) ? BudgetLine(job) : std::string());
|
||||
}
|
||||
}
|
||||
121
implementations/Crafter.Review-Schema.cpp
Normal file
121
implementations/Crafter.Review-Schema.cpp
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Schema_impl;
|
||||
import :Schema;
|
||||
import :Json;
|
||||
import std;
|
||||
|
||||
namespace Crafter::Review::Schema {
|
||||
namespace {
|
||||
template <std::size_t N>
|
||||
Json Strings(const std::array<std::string_view, N>& values) {
|
||||
Json out = Json::array();
|
||||
for (const std::string_view value : values) out.push_back(value);
|
||||
return out;
|
||||
}
|
||||
|
||||
Json String(std::string_view description = {}) {
|
||||
Json out = {{"type", "string"}};
|
||||
if (!description.empty()) out["description"] = description;
|
||||
return out;
|
||||
}
|
||||
|
||||
Json StringList(std::string_view description) {
|
||||
return {{"type", "array"}, {"items", {{"type", "string"}}}, {"description", description}};
|
||||
}
|
||||
|
||||
Json Verdict(std::string_view description) {
|
||||
Json out = {{"type", "string"}, {"enum", Strings(VerdictEnum)}};
|
||||
if (!description.empty()) out["description"] = description;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
Json Finding() {
|
||||
Json contests = {
|
||||
{"type", "object"},
|
||||
{"additionalProperties", false},
|
||||
{"required", {"id", "why"}},
|
||||
{"properties", {
|
||||
{"id", String()},
|
||||
{"why", String("NEW evidence the disposition's recorded grounds do not survive")},
|
||||
}},
|
||||
};
|
||||
return {
|
||||
{"type", "object"},
|
||||
{"additionalProperties", false},
|
||||
{"required", {"finding", "evidence", "proposedAction", "items"}},
|
||||
{"properties", {
|
||||
{"finding", String("the defect - by your lens's charter question (objective wrongness)")},
|
||||
{"evidence", String("file:line, a verbatim quote, the steps taken in the application and what was observed (a screenshot or snapshot path) - no vibes")},
|
||||
{"proposedAction", String()},
|
||||
{"items", StringList("the INVENTORY unit ids this finding concerns, copied exactly; a finding that spans units lists them all")},
|
||||
{"dischargedBy", String("id of a disposition listed under DISPOSITIONS IN FORCE that discharges this finding (you verified its grounds); omit when none applies")},
|
||||
{"contests", contests},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
Json Note() {
|
||||
return {
|
||||
{"type", "object"},
|
||||
{"additionalProperties", false},
|
||||
{"required", {"kind", "note", "evidence", "action"}},
|
||||
{"properties", {
|
||||
{"kind", {{"type", "string"}, {"enum", Strings(NoteKinds)}}},
|
||||
{"note", String()},
|
||||
{"evidence", String()},
|
||||
{"action", String("what the orchestrator does with it (bank / record in the manifest / check at push time / nothing)")},
|
||||
{"id", String("refutation-verified only: the disposition id")},
|
||||
{"items", StringList("INVENTORY unit ids this note concerns")},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
Json Coverage() {
|
||||
return {
|
||||
{"type", "object"},
|
||||
{"additionalProperties", false},
|
||||
{"required", {"item", "verdict", "basis"}},
|
||||
{"properties", {
|
||||
{"item", String("an INVENTORY unit id, verbatim")},
|
||||
{"verdict", {{"type", "string"}, {"enum", Strings(CoverageVerdicts)}}},
|
||||
{"basis", String("what this verdict rests on: the tree ref, the command run, the steps taken in the application and the evidence file - or the literal string \"no record\"")},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
Json BlockSchema() {
|
||||
return {
|
||||
{"type", "object"},
|
||||
{"additionalProperties", false},
|
||||
{"required", {"verdict", "findings", "notes", "coverage"}},
|
||||
{"properties", {
|
||||
{"verdict", Verdict("FIX FIRST iff any finding is undischarged - the pipeline derives the same and fails closed on disagreement")},
|
||||
{"findings", {{"type", "array"}, {"items", Finding()}}},
|
||||
{"notes", {{"type", "array"}, {"items", Note()}}},
|
||||
{"coverage", {{"type", "array"}, {"items", Coverage()}, {"minItems", 1}, {"maxItems", 1}, {"description", "exactly one entry: the unit this pass covers"}}},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
Json WholeSchema() {
|
||||
return {
|
||||
{"type", "object"},
|
||||
{"additionalProperties", false},
|
||||
{"required", {"verdict", "findings", "notes", "coverage", "summary"}},
|
||||
{"properties", {
|
||||
{"verdict", Verdict({})},
|
||||
{"findings", {{"type", "array"}, {"items", Finding()}}},
|
||||
{"notes", {{"type", "array"}, {"items", Note()}}},
|
||||
{"coverage", {{"type", "array"}, {"items", Coverage()}, {"description", "one entry per unit you were asked to cover"}}},
|
||||
{"summary", String("5-10 sentences: the range as you judged it and the constraints the block passes must respect (the functional lens: how to reach each touched screen, the login used, what the block passes must not touch)")},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
std::string SchemaJson(std::string_view kind) {
|
||||
return DumpCompactSorted(kind == "block" ? BlockSchema() : WholeSchema());
|
||||
}
|
||||
}
|
||||
282
implementations/Crafter.Review-State.cpp
Normal file
282
implementations/Crafter.Review-State.cpp
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module;
|
||||
#include <cerrno>
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h>
|
||||
#include <unistd.h>
|
||||
|
||||
module Crafter.Review:State_impl;
|
||||
import :State;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import :Platform;
|
||||
import std;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace Crafter::Review::State {
|
||||
namespace {
|
||||
std::string Formatted(double value, std::int32_t precision) {
|
||||
return std::format("{:.{}f}", value, precision);
|
||||
}
|
||||
|
||||
double AsDouble(const Json& value) {
|
||||
if (value.is_number_float()) return value.get<double>();
|
||||
if (value.is_boolean()) return value.get<bool>() ? 1.0 : 0.0;
|
||||
if (value.is_number_unsigned()) return static_cast<double>(value.get<std::uint64_t>());
|
||||
if (value.is_number_integer()) return static_cast<double>(value.get<std::int64_t>());
|
||||
throw Dyn::Error("TypeError", std::format("unsupported operand type(s) for /: '{}' and 'float'", Dyn::TypeName(value)));
|
||||
}
|
||||
|
||||
// `value or 0` for a number that is summed.
|
||||
Json OrZero(const Json& value) {
|
||||
return Dyn::Truthy(value) ? value : Json(0);
|
||||
}
|
||||
|
||||
// The key's value, or the fallback only when the key is absent.
|
||||
const Json& GetOr(const Json& dict, std::string_view key, const Json& fallback) {
|
||||
return Dyn::Has(dict, key) ? Dyn::At(dict, key) : fallback;
|
||||
}
|
||||
|
||||
std::size_t Len(const Json& value) {
|
||||
return value.is_string() ? Dyn::Length(value.get_ref<const std::string&>()) : value.size();
|
||||
}
|
||||
|
||||
// A string, or Error (TypeError): only a string is concatenated.
|
||||
const std::string& Text(const Json& value) {
|
||||
if (!value.is_string()) throw Dyn::Error("TypeError", std::format("can only concatenate str (not \"{}\") to str", Dyn::TypeName(value)));
|
||||
return value.get_ref<const std::string&>();
|
||||
}
|
||||
|
||||
// f'{value:<width}' of a str.
|
||||
std::string Padded(const Json& value, std::size_t width) {
|
||||
if (!value.is_string()) throw Dyn::Error("TypeError", std::format("unsupported format string passed to {}.__format__", Dyn::TypeName(value)));
|
||||
return Dyn::LJust(value.get_ref<const std::string&>(), width);
|
||||
}
|
||||
}
|
||||
|
||||
Json EmptyState() {
|
||||
return {{"version", 1}, {"entries", Json::object()}, {"personaPass", nullptr}};
|
||||
}
|
||||
|
||||
Json LoadState(const fs::path& path) {
|
||||
if (!path.empty() && fs::exists(path)) {
|
||||
try {
|
||||
return Json::parse(Dyn::ReadText(path));
|
||||
} catch (const std::exception&) {
|
||||
// A damaged file is the empty state.
|
||||
}
|
||||
}
|
||||
return EmptyState();
|
||||
}
|
||||
|
||||
Json MergeState(const Json& disk, const Json& sets, std::span<const std::string> drops, const Json& read) {
|
||||
const bool ok = disk.is_object() && Dyn::Get(disk, "entries").is_object();
|
||||
Json state = {
|
||||
{"version", 1},
|
||||
{"entries", ok ? Dyn::At(disk, "entries") : Json::object()},
|
||||
{"personaPass", ok ? Dyn::Get(disk, "personaPass") : Json()},
|
||||
};
|
||||
Json& entries = state["entries"];
|
||||
for (const std::string& key : drops) entries.erase(key);
|
||||
for (auto it = sets.begin(); it != sets.end(); ++it) {
|
||||
if (std::ranges::contains(drops, it.key())) continue;
|
||||
Json entry = it.value();
|
||||
if (!entry.is_object()) throw Dyn::Error("TypeError", std::format("'{}' object is not iterable", Dyn::TypeName(entry)));
|
||||
if (!read.is_null() && !Dyn::Equal(Dyn::Get(entry, "kind"), Json("whole"))) {
|
||||
// One clean read per round: a second store of the same run, or
|
||||
// the relaunch of an unfinished run, adds nothing.
|
||||
const Json& previous = Dyn::Get(entries, it.key());
|
||||
Json reads = Json::array();
|
||||
if (Dyn::Truthy(previous)) {
|
||||
const Json& stored = Dyn::Get(previous, "reads");
|
||||
if (Dyn::Truthy(stored)) {
|
||||
reads = stored;
|
||||
} else {
|
||||
const Json& hash = Dyn::Get(previous, "hash");
|
||||
const std::string head = Dyn::Truthy(hash) ? Dyn::Slice(Text(hash), 0, 8) : std::string();
|
||||
reads.push_back({{"hash", head}, {"round", 0}, {"run", Dyn::Get(previous, "run")}});
|
||||
}
|
||||
}
|
||||
const bool counted = std::ranges::any_of(reads, [&](const Json& x) {
|
||||
return Dyn::Equal(Dyn::Get(x, "hash"), Dyn::At(read, "hash")) && Dyn::Equal(Dyn::Get(x, "round"), Dyn::At(read, "round"));
|
||||
});
|
||||
if (!counted) reads.push_back(read);
|
||||
entry["reads"] = std::move(reads);
|
||||
}
|
||||
entries[it.key()] = std::move(entry);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
void SaveWhole(const fs::path& path, const Json& state) {
|
||||
const fs::path temp = std::format("{}.tmp", path.string());
|
||||
{
|
||||
std::ofstream out(temp, std::ios::binary | std::ios::trunc);
|
||||
if (!out) throw std::runtime_error(std::format("{}: cannot write", temp.string()));
|
||||
out << DumpFile(state);
|
||||
}
|
||||
fs::rename(temp, path);
|
||||
}
|
||||
|
||||
Json SaveState(const fs::path& path, const Json& sets, std::span<const std::string> drops, const Json& read) {
|
||||
// Without the lock the write is still made; a failed write throws.
|
||||
const Platform::FileLock lock(std::format("{}.lock", path.string()));
|
||||
Json merged = MergeState(LoadState(path), sets, drops, read);
|
||||
Platform::WriteAtomic(path, DumpFile(merged), 0666);
|
||||
return merged;
|
||||
}
|
||||
|
||||
fs::path LockPath(const fs::path& gateDir, std::string_view mode, std::string_view hash8) {
|
||||
return gateDir / std::format(".run-{}-{}.lock", mode, hash8);
|
||||
}
|
||||
|
||||
Json ReadLockInfo(const fs::path& path) {
|
||||
try {
|
||||
const std::string text = Dyn::ReadText(path);
|
||||
const Json info = Dyn::Strip(text).empty() ? Json::object() : Json::parse(text);
|
||||
return info.is_object() ? info : Json::object();
|
||||
} catch (const std::exception&) {
|
||||
return Json::object();
|
||||
}
|
||||
}
|
||||
|
||||
std::string Describe(const Json& info) {
|
||||
if (!Dyn::Truthy(Dyn::Get(info, "pid"))) return "holder not identified yet (it has just started)";
|
||||
const Json unknown = "?";
|
||||
return std::format("pid {} since {}, run {}, scratch {}", Dyn::Str(Dyn::At(info, "pid")), Dyn::Str(GetOr(info, "since", unknown)),
|
||||
Dyn::Str(GetOr(info, "run_n", unknown)), Dyn::Str(GetOr(info, "scratch", unknown)));
|
||||
}
|
||||
|
||||
RunLock::RunLock(const fs::path& gateDir, std::string_view mode, std::string_view hash8) {
|
||||
const fs::path path = LockPath(gateDir, mode, hash8);
|
||||
fd = open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644);
|
||||
if (fd < 0) throw std::runtime_error(std::format("{}: {}", Dyn::ErrnoText(errno), Dyn::Repr(std::string_view(path.string()))));
|
||||
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
||||
const Json info = ReadLockInfo(path);
|
||||
close(fd);
|
||||
fd = -1;
|
||||
throw RunLocked(Describe(info));
|
||||
}
|
||||
}
|
||||
|
||||
RunLock::~RunLock() {
|
||||
if (fd >= 0) close(fd);
|
||||
}
|
||||
|
||||
void RunLock::WriteInfo(const Json& info) {
|
||||
const std::string text = DumpSpaced(info);
|
||||
if (lseek(fd, 0, SEEK_SET) < 0 || ftruncate(fd, 0) != 0) return;
|
||||
std::size_t written = 0;
|
||||
while (written < text.size()) {
|
||||
const ssize_t step = write(fd, text.data() + written, text.size() - written);
|
||||
if (step < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return;
|
||||
}
|
||||
written += static_cast<std::size_t>(step);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Json> LockHolder(const fs::path& gateDir, std::string_view mode, std::string_view hash8) {
|
||||
const fs::path path = LockPath(gateDir, mode, hash8);
|
||||
const std::int32_t handle = open(path.c_str(), O_RDONLY | O_CLOEXEC);
|
||||
if (handle < 0) return std::nullopt;
|
||||
std::optional<Json> holder;
|
||||
if (flock(handle, LOCK_EX | LOCK_NB) == 0) {
|
||||
flock(handle, LOCK_UN);
|
||||
} else {
|
||||
holder = ReadLockInfo(path);
|
||||
}
|
||||
close(handle);
|
||||
return holder;
|
||||
}
|
||||
|
||||
std::int64_t NextRunNumber(const fs::path& gateDir, std::string_view mode, std::string_view hash8) {
|
||||
std::int64_t n = 1;
|
||||
while (fs::exists(gateDir / std::format("{}-{}-run-{}.json", mode, hash8, n)) || fs::exists(gateDir / std::format("{}-{}-run-{}-test.json", mode, hash8, n))) ++n;
|
||||
return n;
|
||||
}
|
||||
|
||||
Json UsageTotals(std::span<const Json> records) {
|
||||
Json totals = {
|
||||
{"input", 0}, {"cacheCreation", 0}, {"cacheRead", 0}, {"output", 0}, {"thinking", 0},
|
||||
{"turns", 0}, {"processes", 0}, {"costUsd", 0.0}, {"denials", 0},
|
||||
};
|
||||
const Json zero = 0;
|
||||
for (const Json& record : records) {
|
||||
const Json& usage = Dyn::Or(Dyn::Get(record, "usage"), Dyn::EmptyDict());
|
||||
for (const std::string_view key : {"input", "cacheCreation", "cacheRead", "output", "thinking"}) {
|
||||
totals[std::string(key)] = Dyn::Add(totals[std::string(key)], OrZero(GetOr(usage, key, zero)));
|
||||
}
|
||||
totals["costUsd"] = Dyn::Add(totals["costUsd"], OrZero(Dyn::Get(record, "costUsd")));
|
||||
totals["denials"] = Dyn::Add(totals["denials"], Json(Len(Dyn::Or(Dyn::Get(record, "denials"), Dyn::EmptyList()))));
|
||||
totals["turns"] = Dyn::Add(totals["turns"], OrZero(GetOr(record, "turns", zero)));
|
||||
const Json& status = Dyn::Get(record, "status");
|
||||
const bool counted = !status.is_null() && status != "dry-run" && status != "reused";
|
||||
totals["processes"] = Dyn::Add(totals["processes"], Json(counted ? 1 : 0));
|
||||
}
|
||||
// input + 1.25 * cacheCreation + 0.1 * cacheRead + 5 * output: 5 * an
|
||||
// integer stays an integer until it meets the float, so the dump
|
||||
// prints it the same.
|
||||
const Json& output = totals["output"];
|
||||
const double five = output.is_number_integer() ? static_cast<double>(5 * output.get<std::int64_t>()) : 5.0 * AsDouble(output);
|
||||
double costUnits = AsDouble(totals["input"]) + 1.25 * AsDouble(totals["cacheCreation"]);
|
||||
costUnits = costUnits + 0.1 * AsDouble(totals["cacheRead"]);
|
||||
costUnits = costUnits + five;
|
||||
totals["costUnits"] = costUnits;
|
||||
return totals;
|
||||
}
|
||||
|
||||
std::string Summary(const Json& dump) {
|
||||
std::vector<std::string> lines;
|
||||
std::string overall = std::format("overall: {}", Dyn::Str(Dyn::At(dump, "overall")));
|
||||
if (Dyn::Truthy(Dyn::Get(dump, "stoppedAt"))) overall += std::format(" (stopped at {})", Dyn::Str(Dyn::At(dump, "stoppedAt")));
|
||||
lines.push_back(std::move(overall));
|
||||
for (const Json& result : Dyn::At(dump, "results")) {
|
||||
const Json& parts = Dyn::Or(Dyn::Get(result, "parts"), Dyn::EmptyList());
|
||||
const auto reused = std::ranges::count_if(parts, [](const Json& part) { return Dyn::Truthy(Dyn::Get(part, "reused")); });
|
||||
std::string line = std::format(" {} {} ", Padded(Dyn::At(result, "lens"), 28), Padded(Dyn::At(result, "verdict"), 12));
|
||||
if (Dyn::Truthy(Dyn::Get(result, "resumed"))) line += std::format("[resumed {}] ", Text(Dyn::At(result, "resumed")));
|
||||
line += std::format("findings {} (undischarged {}), notes {}, passes {}", Len(Dyn::At(result, "findings")), Dyn::Str(Dyn::At(result, "undischarged")),
|
||||
Len(Dyn::At(result, "notes")), Len(parts));
|
||||
if (reused) line += std::format(" ({} reused)", reused);
|
||||
if (Dyn::Truthy(Dyn::Get(result, "coverageMissing"))) line += std::format(", missing {}", Len(Dyn::At(result, "coverageMissing")));
|
||||
if (Dyn::Truthy(Dyn::Get(result, "stoppedEarly"))) line += std::format(" <- {}", Text(Dyn::At(result, "stoppedEarly")));
|
||||
lines.push_back(std::move(line));
|
||||
}
|
||||
const Json& coverage = Dyn::Or(Dyn::Get(dump, "coverage"), Dyn::EmptyDict());
|
||||
if (Dyn::Truthy(coverage)) {
|
||||
lines.push_back(std::format("matrix: {} units x {} lenses; holes {}, not reviewed by any lens that runs (messages, diffstats, documentation, the units of a lens the config turns off) {}, units with a defect verdict {}",
|
||||
Dyn::Str(Dyn::At(coverage, "units")), Len(Dyn::At(coverage, "lenses")), Len(Dyn::At(coverage, "holes")),
|
||||
Len(Dyn::Or(Dyn::Get(coverage, "notReviewed"), Dyn::EmptyList())), Len(Dyn::At(coverage, "defects"))));
|
||||
}
|
||||
const Json& usageAll = Dyn::Has(dump, "usage") ? Dyn::At(dump, "usage") : Dyn::EmptyDict();
|
||||
const Json& usage = Dyn::Or(Dyn::Get(usageAll, "run"), Dyn::EmptyDict());
|
||||
if (Dyn::Truthy(usage)) {
|
||||
const Json zero = 0;
|
||||
lines.push_back(std::format("usage: {} processes, {} turns, cache write {}M, cache read {}M, output {}K, cost-units {}M, list cost ${}, denied calls {}, wall {} min, prefix hit rate {}",
|
||||
Dyn::Str(Dyn::At(usage, "processes")), Dyn::Str(Dyn::At(usage, "turns")),
|
||||
Formatted(AsDouble(Dyn::At(usage, "cacheCreation")) / 1e6, 2), Formatted(AsDouble(Dyn::At(usage, "cacheRead")) / 1e6, 2),
|
||||
Formatted(AsDouble(Dyn::At(usage, "output")) / 1e3, 0), Formatted(AsDouble(Dyn::At(usage, "costUnits")) / 1e6, 2),
|
||||
Formatted(AsDouble(Dyn::At(usage, "costUsd")), 2), Dyn::Str(Dyn::At(usage, "denials")),
|
||||
Formatted(AsDouble(GetOr(dump, "wallMin", zero)), 0), Dyn::Str(Dyn::Get(usageAll, "prefixHitRate"))));
|
||||
}
|
||||
const Json& reads = Dyn::Get(dump, "reads");
|
||||
if (Dyn::Truthy(reads)) {
|
||||
std::vector<std::string> owed;
|
||||
const Json& owedBy = Dyn::At(reads, "owed");
|
||||
for (auto it = owedBy.begin(); it != owedBy.end(); ++it) {
|
||||
owed.push_back(std::format("{} {} unit(s) / {} read(s)", it.key(), Dyn::Str(Dyn::At(it.value(), "units")), Dyn::Str(Dyn::At(it.value(), "reads"))));
|
||||
}
|
||||
std::string owedText = Dyn::Join(owed, "; ");
|
||||
if (owedText.empty()) owedText = "none";
|
||||
lines.push_back(std::format("reads: x = {}, round {} on this hash{}; read this run: {}; owed: {}", Dyn::Str(Dyn::At(reads, "x")), Dyn::Str(Dyn::At(reads, "round")),
|
||||
Dyn::Truthy(Dyn::Get(reads, "continued")) ? " (continued)" : "", Dyn::Str(Dyn::Or(Dyn::Get(reads, "readThisRun"), Dyn::EmptyDict())), owedText));
|
||||
}
|
||||
lines.push_back(std::format("dump: {}", Dyn::Str(Dyn::At(dump, "dumpPath"))));
|
||||
return Dyn::Join(lines, "\n");
|
||||
}
|
||||
}
|
||||
374
implementations/Crafter.Review-Stream.cpp
Normal file
374
implementations/Crafter.Review-Stream.cpp
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Stream_impl;
|
||||
import :Stream;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import :PgWire;
|
||||
import std;
|
||||
|
||||
namespace Crafter::Review::Stream {
|
||||
namespace {
|
||||
constexpr std::size_t None = std::u32string_view::npos;
|
||||
|
||||
// An ASCII literal of a re.IGNORECASE pattern at `at`: a letter matches
|
||||
// what re.I lets it match, anything else itself.
|
||||
bool MatchAt(std::u32string_view text, std::size_t at, std::string_view literal) {
|
||||
if (at + literal.size() > text.size()) return false;
|
||||
for (std::size_t i = 0; i < literal.size(); ++i) {
|
||||
const char p = literal[i];
|
||||
const char32_t t = text[at + i];
|
||||
if (p >= 'a' && p <= 'z') {
|
||||
if (Dyn::IgnoreCaseLetter(t) != p) return false;
|
||||
} else if (t != static_cast<char32_t>(static_cast<std::uint8_t>(p))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::size_t FindCi(std::u32string_view text, std::string_view literal, std::size_t from, std::size_t to) {
|
||||
if (to < literal.size()) return None;
|
||||
for (std::size_t at = from; at + literal.size() <= to; ++at) {
|
||||
if (MatchAt(text, at, literal)) return at;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
bool ContainsCi(std::u32string_view text, std::string_view literal) {
|
||||
return FindCi(text, literal, 0, text.size()) != None;
|
||||
}
|
||||
|
||||
// `first.*(second|...)`: `.` stops at \n only, so both on one line. The
|
||||
// first occurrence of `first` on a line leaves the most room after it.
|
||||
bool ThenOnLine(std::u32string_view text, std::string_view first, std::initializer_list<std::string_view> seconds) {
|
||||
std::size_t lineStart = 0;
|
||||
while (lineStart <= text.size()) {
|
||||
std::size_t lineEnd = text.find(U'\n', lineStart);
|
||||
if (lineEnd == None) lineEnd = text.size();
|
||||
const std::size_t at = FindCi(text, first, lineStart, lineEnd);
|
||||
if (at != None) {
|
||||
for (const std::string_view second : seconds) {
|
||||
if (FindCi(text, second, at + first.size(), lineEnd) != None) return true;
|
||||
}
|
||||
}
|
||||
if (lineEnd == text.size()) break;
|
||||
lineStart = lineEnd + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// \b<digits>\b
|
||||
bool WordNumber(std::u32string_view text, std::u32string_view number) {
|
||||
for (std::size_t at = text.find(number); at != None; at = text.find(number, at + 1)) {
|
||||
const bool before = at == 0 || !Dyn::IsWord(text[at - 1]);
|
||||
const bool after = at + number.size() == text.size() || !Dyn::IsWord(text[at + number.size()]);
|
||||
if (before && after) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::size_t Spaces(std::u32string_view text, std::size_t at) {
|
||||
std::size_t end = at;
|
||||
while (end < text.size() && Dyn::IsSpace(text[end])) ++end;
|
||||
return end - at;
|
||||
}
|
||||
|
||||
// (^|[\s;&|(])\S*crafter-review\s+sql(\s|$): this binary's helper,
|
||||
// spelled with any path before it.
|
||||
bool HelperCall(std::u32string_view text) {
|
||||
constexpr std::u32string_view Binary = U"crafter-review";
|
||||
for (std::size_t at = text.find(Binary); at != None; at = text.find(Binary, at + 1)) {
|
||||
const std::size_t gap = Spaces(text, at + Binary.size());
|
||||
if (gap == 0) continue;
|
||||
const std::size_t verb = at + Binary.size() + gap;
|
||||
if (text.substr(verb, 3) != U"sql") continue;
|
||||
if (verb + 3 == text.size() || Dyn::IsSpace(text[verb + 3])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A tool_result's content as text: a string, or a list of text blocks.
|
||||
std::string ContentText(const Json& content) {
|
||||
if (content.is_string()) return content.get<std::string>();
|
||||
if (!content.is_array()) return "";
|
||||
std::vector<std::string> texts;
|
||||
for (const Json& block : content) {
|
||||
if (block.is_object() && Dyn::Get(block, "text").is_string()) texts.push_back(Dyn::At(block, "text").get<std::string>());
|
||||
}
|
||||
return Dyn::Join(texts, "\n");
|
||||
}
|
||||
|
||||
// An object key as JSON writes it: a string as it is, null, true and
|
||||
// false as themselves, a number as Dyn::ReprValue prints it.
|
||||
std::string JsonKey(const Json& value) {
|
||||
if (value.is_string()) return value.get<std::string>();
|
||||
if (value.is_null()) return "null";
|
||||
if (value.is_boolean()) return value.get<bool>() ? "true" : "false";
|
||||
if (value.is_number()) return Dyn::ReprValue(value);
|
||||
throw Dyn::Error("TypeError", std::format("unhashable type: '{}'", Dyn::TypeName(value)));
|
||||
}
|
||||
|
||||
// A map key or set member, as text.
|
||||
std::string KeyOf(const Json& value) {
|
||||
if (value.is_array() || value.is_object()) throw Dyn::Error("TypeError", std::format("unhashable type: '{}'", Dyn::TypeName(value)));
|
||||
return DumpCompactSorted(value);
|
||||
}
|
||||
|
||||
const Json& GetOr(const Json& dict, std::string_view key, const Json& fallback) {
|
||||
return Dyn::Has(dict, key) ? Dyn::At(dict, key) : fallback;
|
||||
}
|
||||
|
||||
// `d.get(k, 0) or 0`
|
||||
Json Tokens(const Json& usage, std::string_view key) {
|
||||
const Json zero = 0;
|
||||
const Json& value = GetOr(usage, key, zero);
|
||||
return Dyn::Truthy(value) ? value : zero;
|
||||
}
|
||||
|
||||
// The value as a float: ValueError for text that is no number,
|
||||
// TypeError for anything that is neither.
|
||||
double ToFloat(const Json& value) {
|
||||
if (value.is_number_float()) return value.get<double>();
|
||||
if (value.is_boolean()) return value.get<bool>() ? 1.0 : 0.0;
|
||||
if (value.is_number_unsigned()) return static_cast<double>(value.get<std::uint64_t>());
|
||||
if (value.is_number_integer()) return static_cast<double>(value.get<std::int64_t>());
|
||||
if (value.is_string()) {
|
||||
if (const std::optional<double> parsed = Dyn::ParseFloat(value.get_ref<const std::string&>())) return *parsed;
|
||||
throw Dyn::Error("ValueError", Dyn::FloatError(value.get_ref<const std::string&>()));
|
||||
}
|
||||
throw Dyn::Error("TypeError", std::format("float() argument must be a string or a real number, not '{}'", Dyn::TypeName(value)));
|
||||
}
|
||||
}
|
||||
|
||||
bool IsSqlCall(std::string_view command) {
|
||||
const std::u32string text = Dyn::Decode(command);
|
||||
return HelperCall(text);
|
||||
}
|
||||
|
||||
bool HasAnsweredLine(std::string_view text) {
|
||||
for (std::size_t at = 0; at <= text.size();) {
|
||||
if (text.substr(at).starts_with(PgWire::Answered)) return true;
|
||||
const std::size_t newline = text.find('\n', at);
|
||||
if (newline == std::string_view::npos) break;
|
||||
at = newline + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool LimitText(std::string_view text) {
|
||||
const std::u32string t = Dyn::Decode(text);
|
||||
return ThenOnLine(t, "out of", {"usage"}) || ContainsCi(t, "usage limit") || ContainsCi(t, "quota exceeded") || ContainsCi(t, "rate limit")
|
||||
|| ContainsCi(t, "rate-limit") || ContainsCi(t, "session limit");
|
||||
}
|
||||
|
||||
bool AuthText(std::string_view text) {
|
||||
const std::u32string t = Dyn::Decode(text);
|
||||
return WordNumber(t, U"401") || WordNumber(t, U"403") || ThenOnLine(t, "invalid", {"api key", "token", "credential"})
|
||||
|| ContainsCi(t, "authentication_error") || ContainsCi(t, "authentication error") || ContainsCi(t, "unauthorised") || ContainsCi(t, "unauthorized")
|
||||
|| ThenOnLine(t, "token", {"expired", "revoked"}) || ContainsCi(t, "not logged in") || ContainsCi(t, "/login");
|
||||
}
|
||||
|
||||
bool CreditText(std::string_view text) {
|
||||
const std::u32string t = Dyn::Decode(text);
|
||||
// The last two: OpenRouter's 402.
|
||||
constexpr std::array<std::string_view, 11> Phrases = {
|
||||
"credit balance too low", "credit balance is too low", "add funds",
|
||||
"reached your api usage limits", "reached your specified api usage limits", "reached your workspace api usage limits",
|
||||
"reached your specified workspace api usage limits", "crossed its monthly api usage threshold", "enforced_spend_limit_reached",
|
||||
"insufficient credits", "requires more credits",
|
||||
};
|
||||
return std::ranges::any_of(Phrases, [&](std::string_view phrase) { return ContainsCi(t, phrase); });
|
||||
}
|
||||
|
||||
Json ParseStream(std::string_view text) {
|
||||
Json result;
|
||||
Json rate;
|
||||
Json sessionId;
|
||||
Json init;
|
||||
Json usage = {{"input", 0}, {"cacheCreation", 0}, {"cacheRead", 0}, {"output", 0}};
|
||||
Json first;
|
||||
Json turns = 0;
|
||||
Json toolCalls = Json::object();
|
||||
std::int64_t toolErrors = 0;
|
||||
std::map<std::string, Json> names;
|
||||
std::set<std::string> sqlIds;
|
||||
// The mcp__playwright__* calls and how many the server answered with
|
||||
// an error; the SQL helper's calls and how many reached the database.
|
||||
std::int64_t browserCalls = 0;
|
||||
std::int64_t browserErrors = 0;
|
||||
std::int64_t dbCalls = 0;
|
||||
std::int64_t dbAnswered = 0;
|
||||
for (const std::string& raw : Dyn::Split(text, "\n")) {
|
||||
const std::string line = Dyn::Strip(raw);
|
||||
if (!line.starts_with('{')) continue;
|
||||
Json o;
|
||||
try {
|
||||
o = ParseLoose(line);
|
||||
} catch (const std::exception&) {
|
||||
continue;
|
||||
}
|
||||
if (!o.is_object()) continue;
|
||||
const Json& type = Dyn::Get(o, "type");
|
||||
if (!Dyn::Truthy(sessionId) && Dyn::Get(o, "session_id").is_string()) sessionId = Dyn::At(o, "session_id");
|
||||
if (type == "system" && Dyn::Get(o, "subtype") == "init") {
|
||||
init = o;
|
||||
} else if (type == "result") {
|
||||
result = o;
|
||||
} else if (type == "rate_limit_event") {
|
||||
const Json& info = Dyn::Get(o, "rate_limit_info");
|
||||
if (Dyn::Truthy(info)) rate = info;
|
||||
}
|
||||
const Json& message = Dyn::Get(o, "message");
|
||||
if (!message.is_object() || !Dyn::Truthy(message)) continue;
|
||||
if (type == "assistant") {
|
||||
const Json& u = Dyn::Or(Dyn::Get(message, "usage"), Dyn::EmptyDict());
|
||||
if (Dyn::Truthy(u)) {
|
||||
turns = Dyn::Add(turns, Json(1));
|
||||
usage["input"] = Dyn::Add(usage["input"], Tokens(u, "input_tokens"));
|
||||
usage["cacheCreation"] = Dyn::Add(usage["cacheCreation"], Tokens(u, "cache_creation_input_tokens"));
|
||||
usage["cacheRead"] = Dyn::Add(usage["cacheRead"], Tokens(u, "cache_read_input_tokens"));
|
||||
usage["output"] = Dyn::Add(usage["output"], Tokens(u, "output_tokens"));
|
||||
if (first.is_null()) {
|
||||
first = Json::object();
|
||||
for (const std::string_view key : {"input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "output_tokens"}) {
|
||||
first[std::string(key)] = Tokens(u, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const Json& block : Dyn::Or(Dyn::Get(message, "content"), Dyn::EmptyList())) {
|
||||
if (!block.is_object() || Dyn::Get(block, "type") != "tool_use") continue;
|
||||
const Json& name = Dyn::Get(block, "name");
|
||||
const std::string key = JsonKey(name);
|
||||
toolCalls[key] = Dyn::Add(Dyn::Has(toolCalls, key) ? toolCalls[key] : Json(0), Json(1));
|
||||
names[KeyOf(Dyn::Get(block, "id"))] = name;
|
||||
const Json& input = Dyn::Get(block, "input");
|
||||
const Json command = input.is_object() ? Dyn::Get(Dyn::Or(input, Dyn::EmptyDict()), "command") : Json();
|
||||
if (name == "Bash" && command.is_string() && IsSqlCall(command.get_ref<const std::string&>())) sqlIds.insert(KeyOf(Dyn::Get(block, "id")));
|
||||
}
|
||||
}
|
||||
if (type == "user") {
|
||||
for (const Json& block : Dyn::Or(Dyn::Get(message, "content"), Dyn::EmptyList())) {
|
||||
if (!block.is_object() || Dyn::Get(block, "type") != "tool_result") continue;
|
||||
const bool isError = Dyn::Truthy(Dyn::Get(block, "is_error"));
|
||||
if (isError) ++toolErrors;
|
||||
const std::string id = KeyOf(Dyn::Get(block, "tool_use_id"));
|
||||
const auto named = names.find(id);
|
||||
const std::string name = named == names.end() ? std::string() : Dyn::Str(named->second);
|
||||
if (name.starts_with("mcp__playwright__")) {
|
||||
++browserCalls;
|
||||
if (isError) ++browserErrors;
|
||||
}
|
||||
if (sqlIds.contains(id)) {
|
||||
++dbCalls;
|
||||
if (HasAnsweredLine(ContentText(Dyn::Get(block, "content")))) ++dbAnswered;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Json cost;
|
||||
Json denials = Json::array();
|
||||
Json thinking = 0;
|
||||
if (Dyn::Truthy(result) && Dyn::Get(result, "usage").is_object()) {
|
||||
// The result line's totals are authoritative: stream-json repeats a
|
||||
// turn's usage on every assistant content block, and its
|
||||
// per-message output_tokens are placeholders (2026-09-17 smoke:
|
||||
// summed 2.26M cache reads vs 985K real).
|
||||
const Json& u = Dyn::At(result, "usage");
|
||||
usage = {{"input", Tokens(u, "input_tokens")}, {"cacheCreation", Tokens(u, "cache_creation_input_tokens")},
|
||||
{"cacheRead", Tokens(u, "cache_read_input_tokens")}, {"output", Tokens(u, "output_tokens")}};
|
||||
const Json& details = Dyn::Or(Dyn::Get(u, "output_tokens_details"), Dyn::EmptyDict());
|
||||
thinking = Tokens(details, "thinking_tokens");
|
||||
cost = Dyn::Get(result, "total_cost_usd");
|
||||
for (const Json& d : Dyn::Or(Dyn::Get(result, "permission_denials"), Dyn::EmptyList())) {
|
||||
const Json& input = Dyn::Or(Dyn::Get(d, "tool_input"), Dyn::EmptyDict());
|
||||
const Json& command = Dyn::Get(input, "command");
|
||||
const Json& file = Dyn::Get(input, "file_path");
|
||||
denials.push_back(Dyn::Truthy(command) ? command : (Dyn::Truthy(file) ? file : Dyn::Get(d, "tool_name")));
|
||||
}
|
||||
if (Dyn::IsInt(Dyn::Get(result, "num_turns"))) turns = Dyn::At(result, "num_turns");
|
||||
}
|
||||
usage["thinking"] = thinking;
|
||||
return {
|
||||
{"result", result},
|
||||
{"rateLimit", rate},
|
||||
{"sessionId", sessionId},
|
||||
{"init", init},
|
||||
{"usage", usage},
|
||||
{"firstTurn", first},
|
||||
{"turns", turns},
|
||||
{"toolCalls", toolCalls},
|
||||
{"toolErrors", toolErrors},
|
||||
{"costUsd", cost},
|
||||
{"denials", denials},
|
||||
{"browser", {{"calls", browserCalls}, {"errors", browserErrors}}},
|
||||
{"db", {{"calls", dbCalls}, {"answered", dbAnswered}}},
|
||||
};
|
||||
}
|
||||
|
||||
std::optional<Json> StructuredOutput(const Json& result) {
|
||||
if (!Dyn::Truthy(result)) return std::nullopt;
|
||||
Json inner = Dyn::Get(result, "structured_output");
|
||||
const Json& text = Dyn::Get(result, "result");
|
||||
if (inner.is_null() && text.is_string() && Dyn::Truthy(text)) {
|
||||
try {
|
||||
inner = ParseLoose(text.get_ref<const std::string&>());
|
||||
} catch (const std::exception&) {
|
||||
inner = nullptr;
|
||||
}
|
||||
}
|
||||
if (inner.is_string()) {
|
||||
try {
|
||||
inner = ParseLoose(inner.get<std::string>());
|
||||
} catch (const std::exception&) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
if (!inner.is_object() || !Dyn::Get(inner, "findings").is_array()) return std::nullopt;
|
||||
return inner;
|
||||
}
|
||||
|
||||
std::pair<std::string, std::string> Classify(const Json& parsed, std::optional<std::int32_t> exitCode, std::string_view stderrText, bool timedOut) {
|
||||
const Json& result = Dyn::At(parsed, "result");
|
||||
const Json& rate = Dyn::Or(Dyn::At(parsed, "rateLimit"), Dyn::EmptyDict());
|
||||
if (timedOut || exitCode == 124) return {"timeout", "process exceeded its time budget"};
|
||||
// Asked only in its place in the `or`: whether it is asked at all is
|
||||
// part of the classification.
|
||||
auto rejected = [&] { return Dyn::Get(rate, "status") == "rejected"; };
|
||||
if (!Dyn::Truthy(result)) {
|
||||
if (CreditText(stderrText)) return {"credits", "credit balance exhausted (no result line)"};
|
||||
if (rejected() || LimitText(stderrText)) return {"limit", "rate limit (no result line)"};
|
||||
if (AuthText(stderrText)) return {"auth", "authentication failure (no result line)"};
|
||||
return {"error", "no result line - the process did not finish"};
|
||||
}
|
||||
if (Dyn::Truthy(Dyn::Get(result, "is_error"))) {
|
||||
std::vector<std::string> errors;
|
||||
for (const Json& e : Dyn::Or(Dyn::Get(result, "errors"), Dyn::EmptyList())) errors.push_back(Dyn::Str(e));
|
||||
const std::string message = std::format("{} {}", Dyn::StrOr(Dyn::Get(result, "result"), ""), Dyn::Join(errors, " "));
|
||||
const std::string head = Dyn::Slice(message, 0, 200);
|
||||
const Json& status = Dyn::Get(result, "api_error_status");
|
||||
if (CreditText(message)) return {"credits", head};
|
||||
if (Dyn::Equal(status, Json(401)) || Dyn::Equal(status, Json(403)) || AuthText(message)) return {"auth", head};
|
||||
if (Dyn::Equal(status, Json(429)) || LimitText(message) || rejected()) return {"limit", head};
|
||||
const Json& subtype = Dyn::Get(result, "subtype");
|
||||
if (!Dyn::Truthy(subtype)) return {"error", std::format("error: {}", head)};
|
||||
if (!subtype.is_string()) throw Dyn::Error("TypeError", std::format("unsupported operand type(s) for +: '{}' and 'str'", Dyn::TypeName(subtype)));
|
||||
return {"error", std::format("{}: {}", subtype.get<std::string>(), head)};
|
||||
}
|
||||
if (!StructuredOutput(result)) return {"error", "no structured_output in the result envelope"};
|
||||
return {"ok", ""};
|
||||
}
|
||||
|
||||
Json Windows(const Json& rate) {
|
||||
Json out = Json::object();
|
||||
const Json& windows = Dyn::Or(Dyn::Get(Dyn::Or(rate, Dyn::EmptyDict()), "unifiedWindows"), Dyn::EmptyDict());
|
||||
for (auto it = windows.begin(); it != windows.end(); ++it) {
|
||||
const Json& w = it.value();
|
||||
if (!w.is_object()) continue;
|
||||
const Json& u = Dyn::Get(w, "utilization");
|
||||
out[it.key()] = {{"utilization", u.is_null() ? Json() : Json(Dyn::Round(ToFloat(u) * 100.0, 1))}, {"resetsAt", Dyn::Get(w, "resetsAt")}};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
|
@ -205,6 +205,46 @@ namespace Crafter::Review::Dyn {
|
|||
return Encode(std::u32string_view(decoded).substr(static_cast<std::size_t>(from), static_cast<std::size_t>(to - from)));
|
||||
}
|
||||
|
||||
std::pair<char32_t, std::size_t> CodePointAt(std::string_view text, std::size_t at) {
|
||||
return Next(text, at);
|
||||
}
|
||||
|
||||
std::pair<std::size_t, std::size_t> SliceBounds(std::size_t size, std::int64_t start, std::optional<std::int64_t> end) {
|
||||
const auto count = static_cast<std::int64_t>(size);
|
||||
auto clip = [count](std::int64_t index) {
|
||||
if (index < 0) index += count;
|
||||
return std::clamp<std::int64_t>(index, 0, count);
|
||||
};
|
||||
const std::int64_t from = clip(start);
|
||||
const std::int64_t to = end ? clip(*end) : count;
|
||||
return {static_cast<std::size_t>(from), static_cast<std::size_t>(std::max(from, to))};
|
||||
}
|
||||
|
||||
namespace {
|
||||
template <typename Predicate>
|
||||
std::size_t RunOf(std::string_view text, std::size_t at, Predicate matches) {
|
||||
std::size_t end = at;
|
||||
while (end < text.size()) {
|
||||
const auto [c, used] = Next(text, end);
|
||||
if (!matches(c)) break;
|
||||
end += used;
|
||||
}
|
||||
return end - at;
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t DecimalRun(std::string_view text, std::size_t at) {
|
||||
return RunOf(text, at, [](char32_t c) { return IsDecimal(c); });
|
||||
}
|
||||
|
||||
std::size_t SpaceRun(std::string_view text, std::size_t at) {
|
||||
return RunOf(text, at, [](char32_t c) { return IsSpace(c); });
|
||||
}
|
||||
|
||||
std::size_t NonSpaceRun(std::string_view text, std::size_t at) {
|
||||
return RunOf(text, at, [](char32_t c) { return !IsSpace(c); });
|
||||
}
|
||||
|
||||
bool IsSpace(char32_t c) {
|
||||
return std::ranges::binary_search(Unicode::Space, c);
|
||||
}
|
||||
|
|
@ -230,6 +270,10 @@ namespace Crafter::Review::Dyn {
|
|||
return InRuns(Unicode::Printable, c);
|
||||
}
|
||||
|
||||
bool IsWord(char32_t c) {
|
||||
return InRuns(Unicode::Word, c);
|
||||
}
|
||||
|
||||
char IgnoreCaseLetter(char32_t c) {
|
||||
if (c >= 'a' && c <= 'z') return static_cast<char>(c);
|
||||
if (c >= 'A' && c <= 'Z') return static_cast<char>(c - 'A' + 'a');
|
||||
|
|
@ -413,21 +457,305 @@ namespace Crafter::Review::Dyn {
|
|||
return out;
|
||||
}
|
||||
|
||||
std::string LJust(std::string_view text, std::size_t width) {
|
||||
const std::size_t length = Length(text);
|
||||
std::string out(text);
|
||||
if (length < width) out.append(width - length, ' ');
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string ReadText(const std::filesystem::path& path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) throw std::runtime_error(std::format("[Errno 2] No such file or directory: {}", Repr(std::string_view(path.string()))));
|
||||
const std::string bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
for (std::size_t at = 0; at < bytes.size();) {
|
||||
const auto [c, used] = Next(bytes, at);
|
||||
if (c == 0xFFFD && (used != 3 || std::string_view(bytes).substr(at, 3) != "\xEF\xBF\xBD")) {
|
||||
throw std::runtime_error(std::format("'utf-8' codec can't decode byte 0x{:02x} in position {}: invalid utf-8 ({})",
|
||||
static_cast<std::uint8_t>(bytes[at]), at, path.string()));
|
||||
}
|
||||
at += used;
|
||||
if (!in || std::filesystem::is_directory(path)) {
|
||||
throw Error("FileNotFoundError", std::format("[Errno 2] No such file or directory: {}", Repr(std::string_view(path.string()))));
|
||||
}
|
||||
const std::string bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
if (const std::optional<std::string> error = Utf8DecodeError(bytes)) throw Error("UnicodeDecodeError", *error);
|
||||
return UniversalNewlines(bytes);
|
||||
}
|
||||
|
||||
std::optional<std::string> Utf8DecodeError(std::string_view bytes) {
|
||||
// The decoder's reasons: a bad lead byte is "invalid start byte"; the
|
||||
// k-th continuation byte out of range is "invalid continuation byte"
|
||||
// over k bytes; the input ending inside a sequence is "unexpected end
|
||||
// of data" over the rest of it.
|
||||
for (std::size_t at = 0; at < bytes.size();) {
|
||||
const auto lead = static_cast<std::uint8_t>(bytes[at]);
|
||||
std::size_t need = 0;
|
||||
std::uint8_t low = 0x80;
|
||||
std::uint8_t high = 0xBF;
|
||||
if (lead < 0x80) {
|
||||
++at;
|
||||
continue;
|
||||
}
|
||||
if (lead >= 0xC2 && lead <= 0xDF) {
|
||||
need = 1;
|
||||
} else if (lead >= 0xE0 && lead <= 0xEF) {
|
||||
need = 2;
|
||||
if (lead == 0xE0) low = 0xA0;
|
||||
if (lead == 0xED) high = 0x9F;
|
||||
} else if (lead >= 0xF0 && lead <= 0xF4) {
|
||||
need = 3;
|
||||
if (lead == 0xF0) low = 0x90;
|
||||
if (lead == 0xF4) high = 0x8F;
|
||||
}
|
||||
std::string_view reason;
|
||||
std::size_t length = 1;
|
||||
if (need == 0) {
|
||||
reason = "invalid start byte";
|
||||
} else {
|
||||
for (std::size_t k = 1; k <= need; ++k) {
|
||||
if (at + k >= bytes.size()) {
|
||||
reason = "unexpected end of data";
|
||||
length = bytes.size() - at;
|
||||
break;
|
||||
}
|
||||
const auto next = static_cast<std::uint8_t>(bytes[at + k]);
|
||||
if (next < low || next > high) {
|
||||
reason = "invalid continuation byte";
|
||||
length = k;
|
||||
break;
|
||||
}
|
||||
low = 0x80;
|
||||
high = 0xBF;
|
||||
}
|
||||
}
|
||||
if (reason.empty()) {
|
||||
at += need + 1;
|
||||
continue;
|
||||
}
|
||||
if (length == 1) return std::format("'utf-8' codec can't decode byte 0x{:02x} in position {}: {}", lead, at, reason);
|
||||
return std::format("'utf-8' codec can't decode bytes in position {}-{}: {}", at, at + length - 1, reason);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct JsonFailure {
|
||||
std::string_view message;
|
||||
std::size_t position;
|
||||
};
|
||||
|
||||
bool IsJsonSpace(char32_t c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
||||
}
|
||||
|
||||
bool IsJsonDigit(char32_t c) {
|
||||
return c >= '0' && c <= '9';
|
||||
}
|
||||
|
||||
std::int32_t HexDigit(char32_t c) {
|
||||
if (c >= '0' && c <= '9') return static_cast<std::int32_t>(c - '0');
|
||||
if (c >= 'a' && c <= 'f') return static_cast<std::int32_t>(c - 'a' + 10);
|
||||
if (c >= 'A' && c <= 'F') return static_cast<std::int32_t>(c - 'A' + 10);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// A JSON scanner that fails where and why the recorded messages say,
|
||||
// step for step: a string, an object, an array, a number, then any
|
||||
// value. "Expecting value" is a position where no value starts.
|
||||
class JsonScanner {
|
||||
public:
|
||||
explicit JsonScanner(std::u32string_view text) : s(text) {}
|
||||
|
||||
std::optional<JsonFailure> Value(std::size_t idx, std::size_t& next) {
|
||||
if (idx >= s.size()) return JsonFailure{"Expecting value", idx};
|
||||
switch (s[idx]) {
|
||||
case '"':
|
||||
return String(idx + 1, next);
|
||||
case '{':
|
||||
return Object(idx + 1, next);
|
||||
case '[':
|
||||
return Array(idx + 1, next);
|
||||
case 'n':
|
||||
if (Word(idx, U"null", next)) return std::nullopt;
|
||||
break;
|
||||
case 't':
|
||||
if (Word(idx, U"true", next)) return std::nullopt;
|
||||
break;
|
||||
case 'f':
|
||||
if (Word(idx, U"false", next)) return std::nullopt;
|
||||
break;
|
||||
case 'N':
|
||||
if (Word(idx, U"NaN", next)) return std::nullopt;
|
||||
break;
|
||||
case 'I':
|
||||
if (Word(idx, U"Infinity", next)) return std::nullopt;
|
||||
break;
|
||||
case '-':
|
||||
if (Word(idx, U"-Infinity", next)) return std::nullopt;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return Number(idx, next);
|
||||
}
|
||||
|
||||
private:
|
||||
std::u32string_view s;
|
||||
|
||||
bool Word(std::size_t idx, std::u32string_view word, std::size_t& next) const {
|
||||
if (s.substr(idx, word.size()) != word) return false;
|
||||
next = idx + word.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::size_t SkipSpace(std::size_t idx) const {
|
||||
while (idx < s.size() && IsJsonSpace(s[idx])) ++idx;
|
||||
return idx;
|
||||
}
|
||||
|
||||
std::optional<JsonFailure> String(std::size_t end, std::size_t& next) const {
|
||||
const std::size_t begin = end - 1;
|
||||
const std::size_t len = s.size();
|
||||
while (true) {
|
||||
std::size_t at = end;
|
||||
char32_t c = 0;
|
||||
for (; at < len; ++at) {
|
||||
c = s[at];
|
||||
if (c == '"' || c == '\\') break;
|
||||
if (c <= 0x1F) return JsonFailure{"Invalid control character at", at};
|
||||
}
|
||||
if (at >= len) return JsonFailure{"Unterminated string starting at", begin};
|
||||
++at;
|
||||
if (c == '"') {
|
||||
next = at;
|
||||
return std::nullopt;
|
||||
}
|
||||
if (at == len) return JsonFailure{"Unterminated string starting at", begin};
|
||||
c = s[at];
|
||||
if (c != 'u') {
|
||||
end = at + 1;
|
||||
if (!std::u32string_view(U"\"\\/bfnrt").contains(c)) return JsonFailure{"Invalid \\escape", end - 2};
|
||||
continue;
|
||||
}
|
||||
++at;
|
||||
end = at + 4;
|
||||
if (end >= len) return JsonFailure{"Invalid \\uXXXX escape", at - 1};
|
||||
std::uint32_t unit = 0;
|
||||
for (; at < end; ++at) {
|
||||
const std::int32_t digit = HexDigit(s[at]);
|
||||
if (digit < 0) return JsonFailure{"Invalid \\uXXXX escape", end - 5};
|
||||
unit = (unit << 4) | static_cast<std::uint32_t>(digit);
|
||||
}
|
||||
if (unit >= 0xD800 && unit <= 0xDBFF && end + 6 < len && s[at] == '\\' && s[at + 1] == 'u') {
|
||||
at += 2;
|
||||
end += 6;
|
||||
std::uint32_t second = 0;
|
||||
for (; at < end; ++at) {
|
||||
const std::int32_t digit = HexDigit(s[at]);
|
||||
if (digit < 0) return JsonFailure{"Invalid \\uXXXX escape", end - 5};
|
||||
second = (second << 4) | static_cast<std::uint32_t>(digit);
|
||||
}
|
||||
if (second < 0xDC00 || second > 0xDFFF) end -= 6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<JsonFailure> Object(std::size_t idx, std::size_t& next) {
|
||||
idx = SkipSpace(idx);
|
||||
if (idx >= s.size() || s[idx] != '}') {
|
||||
while (true) {
|
||||
if (idx >= s.size() || s[idx] != '"') return JsonFailure{"Expecting property name enclosed in double quotes", idx};
|
||||
std::size_t after = 0;
|
||||
if (std::optional<JsonFailure> failure = String(idx + 1, after)) return failure;
|
||||
idx = SkipSpace(after);
|
||||
if (idx >= s.size() || s[idx] != ':') return JsonFailure{"Expecting ':' delimiter", idx};
|
||||
idx = SkipSpace(idx + 1);
|
||||
if (std::optional<JsonFailure> failure = Value(idx, after)) return failure;
|
||||
idx = SkipSpace(after);
|
||||
if (idx < s.size() && s[idx] == '}') break;
|
||||
if (idx >= s.size() || s[idx] != ',') return JsonFailure{"Expecting ',' delimiter", idx};
|
||||
const std::size_t comma = idx;
|
||||
idx = SkipSpace(idx + 1);
|
||||
if (idx < s.size() && s[idx] == '}') return JsonFailure{"Illegal trailing comma before end of object", comma};
|
||||
}
|
||||
}
|
||||
next = idx + 1;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<JsonFailure> Array(std::size_t idx, std::size_t& next) {
|
||||
idx = SkipSpace(idx);
|
||||
if (idx >= s.size() || s[idx] != ']') {
|
||||
while (true) {
|
||||
std::size_t after = 0;
|
||||
if (std::optional<JsonFailure> failure = Value(idx, after)) return failure;
|
||||
idx = SkipSpace(after);
|
||||
if (idx < s.size() && s[idx] == ']') break;
|
||||
if (idx >= s.size() || s[idx] != ',') return JsonFailure{"Expecting ',' delimiter", idx};
|
||||
const std::size_t comma = idx;
|
||||
idx = SkipSpace(idx + 1);
|
||||
if (idx < s.size() && s[idx] == ']') return JsonFailure{"Illegal trailing comma before end of array", comma};
|
||||
}
|
||||
}
|
||||
next = idx + 1;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<JsonFailure> Number(std::size_t start, std::size_t& next) const {
|
||||
const std::size_t len = s.size();
|
||||
std::size_t idx = start;
|
||||
if (s[idx] == '-') {
|
||||
++idx;
|
||||
if (idx >= len) return JsonFailure{"Expecting value", start};
|
||||
}
|
||||
if (s[idx] >= '1' && s[idx] <= '9') {
|
||||
++idx;
|
||||
while (idx < len && IsJsonDigit(s[idx])) ++idx;
|
||||
} else if (s[idx] == '0') {
|
||||
++idx;
|
||||
} else {
|
||||
return JsonFailure{"Expecting value", start};
|
||||
}
|
||||
if (idx + 1 < len && s[idx] == '.' && IsJsonDigit(s[idx + 1])) {
|
||||
idx += 2;
|
||||
while (idx < len && IsJsonDigit(s[idx])) ++idx;
|
||||
}
|
||||
if (idx + 1 < len && (s[idx] == 'e' || s[idx] == 'E')) {
|
||||
const std::size_t exponent = idx;
|
||||
++idx;
|
||||
if (idx + 1 < len && (s[idx] == '-' || s[idx] == '+')) ++idx;
|
||||
while (idx < len && IsJsonDigit(s[idx])) ++idx;
|
||||
if (!IsJsonDigit(s[idx - 1])) idx = exponent;
|
||||
}
|
||||
next = idx;
|
||||
return std::nullopt;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
std::optional<std::string> JsonDecodeError(std::string_view text) {
|
||||
const std::u32string s = Decode(text);
|
||||
JsonScanner scanner(s);
|
||||
std::size_t start = 0;
|
||||
while (start < s.size() && IsJsonSpace(s[start])) ++start;
|
||||
std::size_t end = 0;
|
||||
std::optional<JsonFailure> failure;
|
||||
if (s.starts_with(U'\uFEFF')) {
|
||||
failure = JsonFailure{"Unexpected UTF-8 BOM (decode using utf-8-sig)", 0};
|
||||
} else {
|
||||
failure = scanner.Value(start, end);
|
||||
}
|
||||
if (!failure) {
|
||||
while (end < s.size() && IsJsonSpace(s[end])) ++end;
|
||||
if (end != s.size()) failure = JsonFailure{"Extra data", end};
|
||||
}
|
||||
if (!failure) return std::nullopt;
|
||||
const std::size_t pos = failure->position;
|
||||
const std::u32string_view before = std::u32string_view(s).substr(0, pos);
|
||||
const auto line = std::ranges::count(before, U'\n') + 1;
|
||||
const std::size_t lastNewline = before.rfind(U'\n');
|
||||
const std::size_t column = lastNewline == std::u32string_view::npos ? pos + 1 : pos - lastNewline;
|
||||
return std::format("{}: line {} column {} (char {})", failure->message, line, column, pos);
|
||||
}
|
||||
|
||||
Json LoadJsonFile(const std::filesystem::path& path) {
|
||||
const std::string text = ReadText(path);
|
||||
if (const std::optional<std::string> error = JsonDecodeError(text)) throw Error("JSONDecodeError", *error);
|
||||
return ParseLoose(text);
|
||||
}
|
||||
|
||||
std::string UniversalNewlines(std::string_view text) {
|
||||
if (!text.contains('\r')) return std::string(text);
|
||||
std::string out;
|
||||
|
|
@ -933,6 +1261,29 @@ namespace Crafter::Review::Dyn {
|
|||
return parts;
|
||||
}
|
||||
|
||||
std::string UrlUnsplit(std::string_view scheme, std::string_view netloc, std::string_view path, std::string_view query, std::string_view fragment) {
|
||||
// The schemes that get `//` before an empty netloc.
|
||||
constexpr std::array<std::string_view, 27> UsesNetloc = {
|
||||
"", "ftp", "http", "gopher", "nntp", "telnet", "imap", "wais", "file", "mms", "https", "shttp", "snews", "prospero",
|
||||
"rtsp", "rtsps", "rtspu", "rsync", "svn", "svn+ssh", "sftp", "nfs", "git", "git+ssh", "ws", "wss", "itms-services",
|
||||
};
|
||||
bool hasNetloc = !netloc.empty();
|
||||
if (!hasNetloc && !scheme.empty() && std::ranges::contains(UsesNetloc, scheme) && (path.empty() || path.starts_with('/'))) {
|
||||
hasNetloc = true;
|
||||
}
|
||||
std::string url(path);
|
||||
if (hasNetloc) {
|
||||
if (!url.empty() && !url.starts_with('/')) url.insert(0, "/");
|
||||
url = std::format("//{}{}", netloc, url);
|
||||
} else if (url.starts_with("//")) {
|
||||
url.insert(0, "//");
|
||||
}
|
||||
if (!scheme.empty()) url = std::format("{}:{}", scheme, url);
|
||||
if (!query.empty()) url = std::format("{}?{}", url, query);
|
||||
if (!fragment.empty()) url = std::format("{}#{}", url, fragment);
|
||||
return url;
|
||||
}
|
||||
|
||||
std::string Unquote(std::string_view text) {
|
||||
if (!text.contains('%')) return std::string(text);
|
||||
// Percent sequences become bytes; runs of bytes are decoded as UTF-8
|
||||
|
|
@ -1058,4 +1409,203 @@ namespace Crafter::Review::Dyn {
|
|||
std::string ErrnoText(std::int32_t code) {
|
||||
return std::format("[Errno {}] {}", code, std::strerror(code));
|
||||
}
|
||||
|
||||
Error::Error(std::string_view kind, const std::string& message) : std::runtime_error(message), kind(kind) {}
|
||||
|
||||
const std::string& Error::Kind() const {
|
||||
return kind;
|
||||
}
|
||||
|
||||
bool Truthy(const Json& value) {
|
||||
switch (value.type()) {
|
||||
case Json::value_t::null:
|
||||
return false;
|
||||
case Json::value_t::boolean:
|
||||
return value.get<bool>();
|
||||
case Json::value_t::number_integer:
|
||||
return value.get<std::int64_t>() != 0;
|
||||
case Json::value_t::number_unsigned:
|
||||
return value.get<std::uint64_t>() != 0;
|
||||
case Json::value_t::number_float:
|
||||
// NaN is true: it is not 0.0.
|
||||
return value.get<double>() != 0.0;
|
||||
case Json::value_t::string:
|
||||
return !value.get_ref<const std::string&>().empty();
|
||||
case Json::value_t::array:
|
||||
case Json::value_t::object:
|
||||
return !value.empty();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const Json& Or(const Json& value, const Json& fallback) {
|
||||
return Truthy(value) ? value : fallback;
|
||||
}
|
||||
|
||||
const Json& EmptyDict() {
|
||||
static const Json empty = Json::object();
|
||||
return empty;
|
||||
}
|
||||
|
||||
const Json& EmptyList() {
|
||||
static const Json empty = Json::array();
|
||||
return empty;
|
||||
}
|
||||
|
||||
bool Has(const Json& dict, std::string_view key) {
|
||||
return dict.is_object() && dict.find(key) != dict.end();
|
||||
}
|
||||
|
||||
const Json& Get(const Json& dict, std::string_view key) {
|
||||
static const Json none;
|
||||
if (!dict.is_object()) throw Error("AttributeError", std::format("'{}' object has no attribute 'get'", TypeName(dict)));
|
||||
const auto found = dict.find(key);
|
||||
return found == dict.end() ? none : *found;
|
||||
}
|
||||
|
||||
const Json& At(const Json& dict, std::string_view key) {
|
||||
if (dict.is_object()) {
|
||||
const auto found = dict.find(key);
|
||||
if (found == dict.end()) throw Error("KeyError", Repr(key));
|
||||
return *found;
|
||||
}
|
||||
if (dict.is_array()) throw Error("TypeError", "list indices must be integers or slices, not str");
|
||||
if (dict.is_string()) throw Error("TypeError", "string indices must be integers, not 'str'");
|
||||
throw Error("TypeError", std::format("'{}' object is not subscriptable", TypeName(dict)));
|
||||
}
|
||||
|
||||
std::string_view TypeName(const Json& value) {
|
||||
switch (value.type()) {
|
||||
case Json::value_t::null:
|
||||
return "NoneType";
|
||||
case Json::value_t::boolean:
|
||||
return "bool";
|
||||
case Json::value_t::number_integer:
|
||||
case Json::value_t::number_unsigned:
|
||||
return "int";
|
||||
case Json::value_t::number_float:
|
||||
return "float";
|
||||
case Json::value_t::string:
|
||||
return "str";
|
||||
case Json::value_t::array:
|
||||
return "list";
|
||||
case Json::value_t::object:
|
||||
return "dict";
|
||||
default:
|
||||
return "bytes";
|
||||
}
|
||||
}
|
||||
|
||||
bool IsInt(const Json& value) {
|
||||
return value.is_boolean() || value.is_number_integer();
|
||||
}
|
||||
|
||||
bool IsNumber(const Json& value) {
|
||||
return value.is_boolean() || value.is_number();
|
||||
}
|
||||
|
||||
std::string Str(const Json& value) {
|
||||
if (value.is_string()) return value.get<std::string>();
|
||||
return ReprValue(value);
|
||||
}
|
||||
|
||||
std::string StrOr(const Json& value, std::string_view fallback) {
|
||||
return Truthy(value) ? Str(value) : std::string(fallback);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// An int of a JSON value: a bool is 0 or 1. Unsigned values beyond
|
||||
// the signed range keep their own type in Equal().
|
||||
std::int64_t IntOf(const Json& value) {
|
||||
if (value.is_boolean()) return value.get<bool>() ? 1 : 0;
|
||||
if (value.is_number_unsigned()) return static_cast<std::int64_t>(value.get<std::uint64_t>());
|
||||
return value.get<std::int64_t>();
|
||||
}
|
||||
|
||||
// An integer equals a float exactly, never by rounding the integer to
|
||||
// a double.
|
||||
bool IntEqualsFloat(const Json& integer, double f) {
|
||||
if (!std::isfinite(f) || f != std::floor(f)) return false;
|
||||
if (integer.is_number_unsigned() && integer.get<std::uint64_t>() > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
|
||||
if (f < 0.0 || f >= 18446744073709551616.0) return false;
|
||||
return static_cast<std::uint64_t>(f) == integer.get<std::uint64_t>();
|
||||
}
|
||||
if (f < -9223372036854775808.0 || f >= 9223372036854775808.0) return false;
|
||||
return static_cast<std::int64_t>(f) == IntOf(integer);
|
||||
}
|
||||
}
|
||||
|
||||
bool Equal(const Json& a, const Json& b) {
|
||||
if (IsNumber(a) && IsNumber(b)) {
|
||||
if (a.is_number_float() && b.is_number_float()) return a.get<double>() == b.get<double>();
|
||||
if (a.is_number_float()) return IntEqualsFloat(b, a.get<double>());
|
||||
if (b.is_number_float()) return IntEqualsFloat(a, b.get<double>());
|
||||
const bool bigA = a.is_number_unsigned() && a.get<std::uint64_t>() > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max());
|
||||
const bool bigB = b.is_number_unsigned() && b.get<std::uint64_t>() > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max());
|
||||
if (bigA || bigB) return bigA && bigB && a.get<std::uint64_t>() == b.get<std::uint64_t>();
|
||||
return IntOf(a) == IntOf(b);
|
||||
}
|
||||
if (a.type() != b.type()) return false;
|
||||
if (a.is_array()) {
|
||||
if (a.size() != b.size()) return false;
|
||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
||||
if (!Equal(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (a.is_object()) {
|
||||
if (a.size() != b.size()) return false;
|
||||
for (auto it = a.begin(); it != a.end(); ++it) {
|
||||
const auto other = b.find(it.key());
|
||||
if (other == b.end() || !Equal(it.value(), *other)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
bool In(const Json& item, const Json& container) {
|
||||
if (container.is_array()) {
|
||||
return std::ranges::any_of(container, [&](const Json& element) { return Equal(item, element); });
|
||||
}
|
||||
if (container.is_object()) {
|
||||
if (item.is_array() || item.is_object()) throw Error("TypeError", std::format("unhashable type: '{}'", TypeName(item)));
|
||||
return item.is_string() && container.find(item.get_ref<const std::string&>()) != container.end();
|
||||
}
|
||||
if (container.is_string()) {
|
||||
if (!item.is_string()) throw Error("TypeError", std::format("'in <string>' requires string as left operand, not {}", TypeName(item)));
|
||||
return container.get_ref<const std::string&>().contains(item.get_ref<const std::string&>());
|
||||
}
|
||||
throw Error("TypeError", std::format("argument of type '{}' is not a container or iterable", TypeName(container)));
|
||||
}
|
||||
|
||||
Json Add(const Json& a, const Json& b) {
|
||||
if (!IsNumber(a) || !IsNumber(b)) {
|
||||
throw Error("TypeError", std::format("unsupported operand type(s) for +: '{}' and '{}'", TypeName(a), TypeName(b)));
|
||||
}
|
||||
if (a.is_number_float() || b.is_number_float()) {
|
||||
auto asDouble = [](const Json& value) { return value.is_number_float() ? value.get<double>() : static_cast<double>(IntOf(value)); };
|
||||
return Json(asDouble(a) + asDouble(b));
|
||||
}
|
||||
return Json(IntOf(a) + IntOf(b));
|
||||
}
|
||||
|
||||
double Sum(std::span<const double> values) {
|
||||
if (values.empty()) return 0.0;
|
||||
// sum() starts from the int 0; 0 + x is x (and 0.0 for -0.0).
|
||||
double total = 0.0 + values.front();
|
||||
double compensation = 0.0;
|
||||
for (const double x : values.subspan(1)) {
|
||||
const double t = total + x;
|
||||
if (std::fabs(total) >= std::fabs(x)) {
|
||||
compensation += (total - t) + x;
|
||||
} else {
|
||||
compensation += (x - t) + total;
|
||||
}
|
||||
total = t;
|
||||
}
|
||||
if (compensation != 0.0 && std::isfinite(compensation)) total += compensation;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
implementations/Crafter.Review-Tools.cpp
Normal file
71
implementations/Crafter.Review-Tools.cpp
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Review:Tools_impl;
|
||||
import :Tools;
|
||||
import std;
|
||||
|
||||
namespace Crafter::Review::Tools {
|
||||
std::vector<std::string> McpToolNames() {
|
||||
std::vector<std::string> out;
|
||||
for (const std::string_view tool : McpTools) out.push_back(std::format("mcp__{}__{}", McpServer, tool));
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string> McpDeniedToolNames() {
|
||||
std::vector<std::string> out;
|
||||
for (const std::string_view tool : McpDenied) out.push_back(std::format("mcp__{}__{}", McpServer, tool));
|
||||
return out;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& Deny() {
|
||||
static const std::vector<std::string> deny = [] {
|
||||
std::vector<std::string> out = {
|
||||
"Edit", "Write", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Task", "Agent", "TodoWrite",
|
||||
"Bash(ssh:*)", "Bash(adb:*)", "Bash(rm:*)", "Bash(mv:*)", "Bash(cp:*)", "Bash(git push:*)", "Bash(git commit:*)",
|
||||
"Bash(git checkout:*)", "Bash(git reset:*)", "Bash(git am:*)", "Bash(make:*)", "Bash(npm:*)", "Bash(npx:*)", "Bash(node:*)",
|
||||
"Bash(supabase:*)", "Bash(docker:*)", "Bash(podman:*)", "Bash(psql:*)",
|
||||
};
|
||||
std::ranges::move(McpDeniedToolNames(), std::back_inserter(out));
|
||||
return out;
|
||||
}();
|
||||
return deny;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& BaseAllow() {
|
||||
static const std::vector<std::string> allow = [] {
|
||||
std::vector<std::string> out = {
|
||||
"Read", "Glob", "Grep", "Bash(cat:*)", "Bash(rg:*)", "Bash(grep:*)", "Bash(wc:*)", "Bash(head:*)", "Bash(tail:*)", "Bash(sed -n:*)",
|
||||
"Bash(ls:*)", "Bash(find:*)", "Bash(diff:*)", "Bash(sha256sum:*)", "Bash(sort:*)", "Bash(uniq:*)", "Bash(cut:*)", "Bash(awk:*)",
|
||||
"Bash(echo:*)", "Bash(printf:*)", "Bash(cd:*)", "Bash(python3 -:*)",
|
||||
};
|
||||
for (const std::string_view verb : GitReadOnly) out.push_back(std::format("Bash(git {}:*)", verb));
|
||||
return out;
|
||||
}();
|
||||
return allow;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// re.match(r'^Bash\((.+):\*\)$', tool).group(1): the prefix of a
|
||||
// Bash(<prefix>:*) entry. `.` stops at a newline and `$` also matches
|
||||
// before a final one, so a prefix never holds a newline.
|
||||
std::optional<std::string_view> BashPrefix(std::string_view tool) {
|
||||
std::string_view body = tool;
|
||||
if (body.ends_with('\n')) body.remove_suffix(1);
|
||||
if (body.size() < 9 || !body.starts_with("Bash(") || !body.ends_with(":*)")) return std::nullopt;
|
||||
const std::string_view prefix = body.substr(5, body.size() - 8);
|
||||
if (prefix.contains('\n')) return std::nullopt;
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
|
||||
bool Denied(std::string_view tool) {
|
||||
if (std::ranges::contains(Deny(), tool)) return true;
|
||||
const std::optional<std::string_view> prefix = BashPrefix(tool);
|
||||
if (!prefix) return false;
|
||||
return std::ranges::any_of(Deny(), [&](const std::string& entry) {
|
||||
const std::optional<std::string_view> denied = BashPrefix(entry);
|
||||
return denied && (*prefix == *denied || prefix->starts_with(std::format("{} ", *denied)));
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue