Crafter.Review/implementations/Crafter.Review-Refs.cpp

325 lines
15 KiB
C++
Raw Normal View History

feat(modes): the kernel modes - a patch series and a mailing-list reply Beside the push gates, crafter-review runs the pre-send review of a kernel contributor's workbench: --mode series over a format-patch export with its cover letter, --mode reply over a mail draft. The inventory partitions a cover letter, a plain mail and the camera review's shape; ten kernel lenses join the table with their gates (structure, code, artifact truth) and the persona gate - one maintainer-persona instance per --reviewers entry, its whole pass gating its blocks and pruning the units that are not theirs. A series' code units are keyed on the code like the push gates'; every text unit on its text, the references it cites and the anchor when it is dated. The references an artifact cites are prefetched through utilities/lore.py into the gate-state, with a digest in the run layer, and a persona's calibration map is its lore listing. The kernel schema carries the persona vocabulary, the kernel note kinds, the predicted review and the not-mine list; the matrix the persona holes, the thin and the modelled units; gates clean without a persona pass is GATES CLEAN. A reply's gate-state sits beside the draft and is read once; a kernel mode needs --subsystem, defaults the effort to max and the trees to work/linux,work/milos-linux, and excuses no unit from the holes. The kernel lenses and the common text ship verbatim; the table lists them after the push gates' lenses and the config template holds them off, so a repository that never turns them on changes nothing. Prompts, keys and plans were checked byte for byte against the pipeline these modes replace, on its own artifacts: two replies and a series, every system prompt, user prompt, job and reuse key identical.
2026-10-31 22:56:12 +01:00
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Review:Refs_impl;
import :Refs;
import :Json;
import :Text;
import :Platform;
import :Process;
import std;
namespace fs = std::filesystem;
namespace Crafter::Review::Refs {
namespace {
constexpr std::string_view LoreHosts[] = {"https://lore.kernel.org/", "http://lore.kernel.org/"};
bool IsListChar(char c) {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
}
// LORE_RE at `at`: https?://lore\.kernel\.org/(?:r|all|[a-z0-9-]+)/([^/\s>]+)/?
// -> (the end of the match, the message id), or nullopt.
std::optional<std::pair<std::size_t, std::string>> LoreAt(std::string_view text, std::size_t at) {
std::size_t after = 0;
for (const std::string_view host : LoreHosts) {
if (text.substr(at).starts_with(host)) {
after = at + host.size();
break;
}
}
if (after == 0) return std::nullopt;
std::size_t list = after;
while (list < text.size() && IsListChar(text[list])) ++list;
if (list == after || list >= text.size() || text[list] != '/') return std::nullopt;
std::size_t id = list + 1;
std::size_t end = id;
while (end < text.size()) {
const auto [c, used] = Dyn::CodePointAt(text, end);
if (c == '/' || c == '>' || Dyn::IsSpace(c)) break;
end += used;
}
if (end == id) return std::nullopt;
const std::string msgid(text.substr(id, end - id));
if (end < text.size() && text[end] == '/') ++end;
return std::pair(end, msgid);
}
Fetcher& TheFetcher() {
static Fetcher fetcher;
return fetcher;
}
Searcher& TheSearcher() {
static Searcher searcher;
return searcher;
}
std::string Tail(std::string_view text, std::size_t n) {
return Dyn::Slice(text, -static_cast<std::int64_t>(n));
}
}
Table ParseRefs(std::string_view text) {
Table refs;
auto set = [&](const std::string& key, const std::string& url) {
for (auto& [k, u] : refs) {
if (k == key) {
u = url;
return;
}
}
refs.emplace_back(key, url);
};
// ^\[(\d+)\] (\S+), multiline.
for (const std::string& line : Dyn::Split(text, "\n")) {
if (!line.starts_with('[')) continue;
const std::size_t digits = Dyn::DecimalRun(line, 1);
if (digits == 0 || !std::string_view(line).substr(1 + digits).starts_with("] ")) continue;
const std::size_t urlAt = 1 + digits + 2;
const std::size_t urlLen = Dyn::NonSpaceRun(line, urlAt);
if (urlLen == 0) continue;
const std::optional<std::int64_t> n = Dyn::ParseInt(line.substr(1, digits));
if (!n) continue;
set(std::to_string(*n), line.substr(urlAt, urlLen));
}
if (!refs.empty()) return refs;
std::vector<std::string> seen;
for (std::size_t at = 0; at < text.size();) {
const std::optional<std::pair<std::size_t, std::string>> found = LoreAt(text, at);
if (!found) {
++at;
continue;
}
const std::string whole(text.substr(at, found->first - at));
if (!std::ranges::contains(seen, whole)) seen.push_back(whole);
at = found->first;
}
for (std::size_t k = 0; k < seen.size(); ++k) refs.emplace_back(std::format("L{}", k + 1), seen[k]);
return refs;
}
std::optional<std::string> UrlOf(const Table& refs, std::int64_t n) {
const std::string key = std::to_string(n);
for (const auto& [k, url] : refs) {
if (k == key) return url;
}
return std::nullopt;
}
std::optional<std::string> MsgidOf(std::string_view url) {
for (std::size_t at = 0; at < url.size(); ++at) {
if (const std::optional<std::pair<std::size_t, std::string>> found = LoreAt(url, at)) return found->second;
}
return std::nullopt;
}
std::string SafeName(std::string_view msgid) {
std::u32string out;
for (const char32_t c : Dyn::Decode(msgid)) {
const bool kept = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '@' || c == '-';
out.push_back(kept ? c : U'_');
}
if (out.size() > 200) out.resize(200);
return Dyn::Encode(out);
}
std::map<std::string, std::string> Headers(std::string_view raw) {
std::map<std::string, std::string> out;
const std::string head = Dyn::Split(raw, "\n\n", 1).front();
for (const std::string& line : Dyn::SplitLines(head)) {
for (const std::string_view key : {"From", "Date", "Subject"}) {
const std::string prefix = std::format("{}:", key);
if (line.starts_with(prefix) && !out.contains(std::string(key))) out[std::string(key)] = Dyn::Strip(std::string_view(line).substr(prefix.size()));
}
}
return out;
}
void SetFetcher(Fetcher fetcher) {
TheFetcher() = std::move(fetcher);
}
std::pair<std::string, std::string> Fetch(const fs::path& repo, const std::string& url, const fs::path& path) {
if (TheFetcher()) return TheFetcher()(repo, url, path);
std::string last;
for (std::int32_t attempt = 0; attempt < 3; ++attempt) {
Process::Captured p;
try {
p = Process::Run({"python3", "utilities/lore.py", "fetch", std::format("{}/raw", Dyn::RStrip(url, "/"))}, repo, std::chrono::milliseconds(180000));
} catch (const std::exception& e) {
last = Dyn::Slice(std::format("{}: {}", "RuntimeError", e.what()), 0, 200);
std::this_thread::sleep_for(std::chrono::seconds(2));
continue;
}
if (p.timedOut) {
last = Dyn::Slice(std::format("TimeoutExpired: Command '{}' timed out after 180 seconds", url), 0, 200);
std::this_thread::sleep_for(std::chrono::seconds(2));
continue;
}
if (p.returnCode == 0 && !Dyn::Strip(p.out).empty()) {
Platform::WriteAtomic(path, p.out);
return {"fetched", ""};
}
last = Tail(Dyn::DecodeReplace(p.err.empty() ? p.out : p.err), 200);
std::this_thread::sleep_for(std::chrono::seconds(2));
}
return {"failed", last};
}
Json Prefetch(const fs::path& repo, const Table& refs, const fs::path& refsDir, const fs::path& indexPath, const Log& log, std::int32_t workers) {
fs::create_directories(refsDir);
Json index = Json::object();
struct Todo {
std::string key;
std::string url;
fs::path path;
};
std::vector<Todo> todo;
for (const auto& [n, url] : refs) {
const std::optional<std::string> mid = MsgidOf(url);
if (!mid) {
index[n] = {{"url", url}, {"status", "not-prefetched (not a lore URL)"}};
continue;
}
const fs::path path = refsDir / std::format("{}.raw", SafeName(*mid));
index[n] = {{"url", url}, {"msgid", *mid}, {"path", Dyn::RelPath(path.string(), repo.string())}};
std::error_code error;
if (fs::exists(path, error) && fs::file_size(path, error) > 0) {
index[n]["status"] = "cached";
} else {
todo.push_back({n, url, path});
}
}
std::vector<std::pair<std::string, std::string>> results(todo.size());
{
std::atomic<std::size_t> next = 0;
std::vector<std::jthread> pool;
const std::size_t width = std::min<std::size_t>(todo.size(), static_cast<std::size_t>(std::max(1, workers)));
for (std::size_t w = 0; w < width; ++w) {
pool.emplace_back([&] {
for (std::size_t i = next.fetch_add(1); i < todo.size(); i = next.fetch_add(1)) results[i] = Fetch(repo, todo[i].url, todo[i].path);
});
}
}
for (std::size_t i = 0; i < todo.size(); ++i) {
index[todo[i].key]["status"] = results[i].first;
if (!results[i].second.empty()) index[todo[i].key]["error"] = results[i].second;
}
for (auto it = index.begin(); it != index.end(); ++it) {
Json& e = it.value();
const Json& status = Dyn::Get(e, "status");
if (!Dyn::Truthy(Dyn::Get(e, "path")) || !(status == "cached" || status == "fetched")) continue;
const std::optional<std::string> bytes = Platform::ReadFile(repo / Dyn::Str(e.at("path")));
if (!bytes) {
e["status"] = "failed";
e["error"] = std::format("[Errno 2] No such file or directory: {}", Dyn::Repr((repo / Dyn::Str(e.at("path"))).string()));
continue;
}
const std::string raw = Dyn::DecodeReplace(*bytes);
for (const auto& [k, v] : Headers(raw)) e[k] = v;
e["bytes"] = Dyn::Length(raw);
}
Platform::WriteAtomic(indexPath, DumpFile(index));
std::int64_t ok = 0;
std::int64_t fetched = 0;
for (auto it = index.begin(); it != index.end(); ++it) {
const Json& status = Dyn::Get(it.value(), "status");
if (status == "cached" || status == "fetched") ++ok;
if (status == "fetched") ++fetched;
}
log(std::format("refs: {}/{} lore references available locally ({} fetched now)", ok, index.size(), fetched));
return index;
}
std::string Digest(const Json& index) {
std::vector<std::string> lines = {
"REFERENCES prefetched (read the raw file instead of fetching; a FETCH FAILED line means the reference must be fetched by you or reported as unverifiable):"};
std::vector<std::string> keys;
for (auto it = index.begin(); it != index.end(); ++it) keys.push_back(it.key());
auto numeric = [](const std::string& k) { return !k.empty() && Dyn::DecimalRun(k, 0) == k.size(); };
std::ranges::sort(keys, [&](const std::string& a, const std::string& b) {
const bool na = numeric(a);
const bool nb = numeric(b);
if (na != nb) return na;
if (na) return *Dyn::ParseInt(a) < *Dyn::ParseInt(b);
return a < b;
});
const Json unknown = "?";
for (const std::string& n : keys) {
const Json& e = index.at(n);
const Json& status = Dyn::Get(e, "status");
if (status == "cached" || status == "fetched") {
const Json& subject = Dyn::Has(e, "Subject") ? e.at("Subject") : unknown;
lines.push_back(std::format("[{}] {} | From: {} | Date: {} | Subject: {} | {} ({} B)", n, Dyn::Str(Dyn::Get(e, "msgid")), Dyn::Str(Dyn::Has(e, "From") ? e.at("From") : unknown),
Dyn::Str(Dyn::Has(e, "Date") ? e.at("Date") : unknown), Dyn::Slice(Dyn::Str(subject), 0, 100), Dyn::Str(e.at("path")),
Dyn::Str(Dyn::Has(e, "bytes") ? e.at("bytes") : Json(0))));
} else if (status == "failed") {
lines.push_back(std::format("[{}] {} | FETCH FAILED | {}", n, Dyn::Str(Dyn::Get(e, "msgid")), Dyn::Str(e.at("url"))));
} else {
lines.push_back(std::format("[{}] {} | {}", n, Dyn::Str(e.at("url")), Dyn::Str(status)));
}
}
return Dyn::Join(lines, "\n");
}
void SetSearcher(Searcher searcher) {
TheSearcher() = std::move(searcher);
}
std::pair<std::optional<fs::path>, std::string> PersonaCorpus(const fs::path& repo, std::string_view name, const fs::path& refsDir, std::string_view since, const Log& log) {
// re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
std::u32string slug;
bool dash = false;
for (const char32_t c : Dyn::Decode(Dyn::Lower(name))) {
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
slug.push_back(c);
dash = false;
} else if (!dash) {
slug.push_back(U'-');
dash = true;
}
}
const std::string slugText = Dyn::Strip(Dyn::Encode(slug), "-");
const fs::path path = refsDir / std::format("persona-{}.txt", slugText);
std::error_code error;
if (fs::exists(path, error) && fs::file_size(path, error) > 0) return {path, Dyn::ReadText(path)};
const std::string q = std::format("f:\"{}\" AND d:{}..", name, since);
std::string out;
std::string err;
if (TheSearcher()) {
std::tie(out, err) = TheSearcher()(repo, q);
} else {
try {
const Process::Captured p = Process::Run({"python3", "utilities/lore.py", "search", "-n", "100", q}, repo, std::chrono::milliseconds(180000));
out = Dyn::DecodeReplace(p.out);
err = Dyn::DecodeReplace(p.err);
} catch (const std::exception& e) {
err = e.what();
}
}
const std::string text = Dyn::Strip(out);
if (text.empty() || Dyn::Lower(text).starts_with("no results")) {
log(std::format("persona corpus: {} -> NO RESULTS for {} (stderr: {}) - the whole pass calibrates by its own searches", name, Dyn::Repr(q), Dyn::Slice(Dyn::Strip(err), 0, 120)));
return {std::nullopt, ""};
}
fs::create_directories(refsDir);
Platform::WriteAtomic(path, std::format("lore.py search {} ({})\n{}\n", Dyn::Repr(q), Platform::LocalTime("%Y-%m-%d"), text));
log(std::format("persona corpus: {} -> {} ({} chars)", name, Dyn::RelPath(path.string(), repo.string()), Dyn::Length(text)));
return {path, Dyn::ReadText(path)};
}
std::string SinceOf(std::string_view isoDate) {
const std::vector<std::string> parts = Dyn::Split(isoDate, "-");
if (parts.size() != 3) throw Dyn::Error("ValueError", std::format("Invalid isoformat string: {}", Dyn::Repr(isoDate)));
const std::optional<std::int64_t> y = Dyn::ParseInt(parts[0]);
const std::optional<std::int64_t> m = Dyn::ParseInt(parts[1]);
const std::optional<std::int64_t> d = Dyn::ParseInt(parts[2]);
if (!y || !m || !d) throw Dyn::Error("ValueError", std::format("Invalid isoformat string: {}", Dyn::Repr(isoDate)));
const std::chrono::year_month_day date{std::chrono::year(static_cast<int>(*y)), std::chrono::month(static_cast<unsigned>(*m)), std::chrono::day(static_cast<unsigned>(*d))};
if (!date.ok()) throw Dyn::Error("ValueError", std::format("Invalid isoformat string: {}", Dyn::Repr(isoDate)));
const std::chrono::year_month_day since{std::chrono::sys_days(date) - std::chrono::days(365)};
return std::format("{:04}{:02}{:02}", static_cast<std::int32_t>(since.year()), static_cast<std::uint32_t>(since.month()), static_cast<std::uint32_t>(since.day()));
}
}