// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "../lib/json.hpp" export module Crafter.Review:Json; import std; export namespace Crafter::Review { // nlohmann/json, vendored unmodified at lib/json.hpp. ordered_json keeps an // object's keys in the order they were read or inserted, and a duplicate // key keeps its first position and its last value. That is what lets a // driver rewrite the files every driver on the machine shares — the seat // board, the ledger, unit-state.json — with every key in place, the ones // it does not know about included. using Json = nlohmann::ordered_json; // Indented by one space per level, one key or item per line, \uXXXX // escapes for everything outside ASCII: every file this pipeline writes. A // string that is not valid UTF-8 has its bad bytes replaced rather than // throwing in the middle of a write. // // A float from 1e15 up to 1e16 prints with an exponent (AGENTS.md). // Readers parse it either way, and none of the files depends on it. inline std::string DumpFile(const Json& value) { return value.dump(1, ' ', true, Json::error_handler_t::replace); } // Keys in code-point order, no whitespace, ASCII only. The // structured-output schema goes to claude in this form and its bytes are // part of the prompt cache, so they must not move by a byte (the goldens // hold them); dedupe keys use it too. inline std::string DumpCompactSorted(const Json& value) { // Converted to nlohmann::json, which keeps objects in a std::map: that // sorts the keys by byte, and byte order is code-point order in UTF-8. const nlohmann::json sorted(value); return sorted.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace); } // What another program wrote — a line of the claude stream, a shared // file — read leniently: an escaped lone surrogate ("\ud800" with no low // half after it), which nlohmann refuses, is read as U+FFFD. inline Json ParseLoose(std::string_view text) { try { return Json::parse(text); } catch (const nlohmann::json::parse_error&) { auto hexAt = [&](std::size_t at) -> std::int32_t { if (at + 4 > text.size()) return -1; std::int32_t value = 0; for (std::size_t i = at; i < at + 4; ++i) { const char c = text[i]; const std::int32_t digit = c >= '0' && c <= '9' ? c - '0' : (c >= 'a' && c <= 'f' ? c - 'a' + 10 : (c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1)); if (digit < 0) return -1; value = value * 16 + digit; } return value; }; std::string fixed; bool changed = false; for (std::size_t at = 0; at < text.size();) { if (text[at] != '\\' || at + 1 >= text.size()) { fixed.push_back(text[at++]); continue; } if (text[at + 1] != 'u') { fixed.append(text.substr(at, 2)); at += 2; continue; } const std::int32_t unit = hexAt(at + 2); const bool high = unit >= 0xD800 && unit <= 0xDBFF; const bool low = unit >= 0xDC00 && unit <= 0xDFFF; if (high && at + 12 <= text.size() && text[at + 6] == '\\' && text[at + 7] == 'u' && hexAt(at + 8) >= 0xDC00 && hexAt(at + 8) <= 0xDFFF) { fixed.append(text.substr(at, 12)); at += 12; } else if (high || low) { fixed += "\\ufffd"; changed = true; at += 6; } else { fixed.append(text.substr(at, 2)); at += 2; } } if (!changed) throw; return Json::parse(fixed); } } // On one line, with ", " and ": " between items: where a reader matches // on the bytes, such as the first line of an opencode output, `{"type": // "review_gates_meta", …`, which salvage looks for. inline std::string DumpSpaced(const Json& value) { if (value.is_object()) { std::string out = "{"; bool first = true; for (const auto& [key, child] : value.items()) { if (!first) out += ", "; first = false; out += Json(key).dump(-1, ' ', true, Json::error_handler_t::replace); out += ": "; out += DumpSpaced(child); } return std::format("{}}}", out); } if (value.is_array()) { std::string out = "["; bool first = true; for (const Json& child : value) { if (!first) out += ", "; first = false; out += DumpSpaced(child); } return std::format("{}]", out); } return value.dump(-1, ' ', true, Json::error_handler_t::replace); } }