From 4ec5a3cd8a1b8222783a76ecb527843cafb660f0 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:46:38 +0300 Subject: [PATCH] Expose structured gameplay build diagnostics --- cmake/BuildService.cmake | 6 +- include/faset/editor/build_diagnostics.hpp | 15 +++ include/faset/editor/build_service.hpp | 1 + src/editor/build_diagnostics.cpp | 147 +++++++++++++++++++++ src/editor/build_service.cpp | 56 +++++++- tests/build_diagnostics_tests.cpp | 75 +++++++++++ tests/build_schema_tests.cpp | 19 +++ tests/build_schema_tool.cpp | 12 +- 8 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 include/faset/editor/build_diagnostics.hpp create mode 100644 src/editor/build_diagnostics.cpp create mode 100644 tests/build_diagnostics_tests.cpp diff --git a/cmake/BuildService.cmake b/cmake/BuildService.cmake index 9f8f889..51d25ab 100644 --- a/cmake/BuildService.cmake +++ b/cmake/BuildService.cmake @@ -1,10 +1,14 @@ add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp - ${PROJECT_SOURCE_DIR}/src/editor/build_cache.cpp) + ${PROJECT_SOURCE_DIR}/src/editor/build_cache.cpp + ${PROJECT_SOURCE_DIR}/src/editor/build_diagnostics.cpp) target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include) target_compile_features(faset_build_service PUBLIC cxx_std_20) target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads) if(BUILD_TESTING) + add_executable(faset_build_diagnostics_tests ${PROJECT_SOURCE_DIR}/tests/build_diagnostics_tests.cpp) + target_link_libraries(faset_build_diagnostics_tests PRIVATE faset_build_service) + add_test(NAME build_diagnostics COMMAND faset_build_diagnostics_tests) add_executable(faset_build_cache_tests ${PROJECT_SOURCE_DIR}/tests/build_cache_tests.cpp) target_link_libraries(faset_build_cache_tests PRIVATE faset_build_service) target_compile_definitions(faset_build_cache_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") diff --git a/include/faset/editor/build_diagnostics.hpp b/include/faset/editor/build_diagnostics.hpp new file mode 100644 index 0000000..e711b6c --- /dev/null +++ b/include/faset/editor/build_diagnostics.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +namespace faset::editor { + +// Parse compiler, MSVC/clang-cl and Lua output into bounded Editor-facing rows. +// Only sources under the current project's Scripts directory receive a file +// key, so clients cannot offer navigation to unrelated filesystem paths. +Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase, + const std::filesystem::path& project_root); + +} // namespace faset::editor diff --git a/include/faset/editor/build_service.hpp b/include/faset/editor/build_service.hpp index 0a12cc3..5ea32b2 100644 --- a/include/faset/editor/build_service.hpp +++ b/include/faset/editor/build_service.hpp @@ -22,6 +22,7 @@ struct JobStatus { std::string id, kind, state{"queued"}, stage{"queued"}; double progress{}; std::string log, error; + Json diagnostics = Json::array(); Json result = Json::object(); bool finished() const { return state == "succeeded" || state == "failed" || state == "cancelled"; diff --git a/src/editor/build_diagnostics.cpp b/src/editor/build_diagnostics.cpp new file mode 100644 index 0000000..8c23a41 --- /dev/null +++ b/src/editor/build_diagnostics.cpp @@ -0,0 +1,147 @@ +#include + +#include +#include +#include +#include +#include + +namespace faset::editor { +namespace { +constexpr std::size_t max_rows = 200; +constexpr std::size_t max_line = 8192; +constexpr std::size_t max_message = 2048; + +std::string strip_ansi(std::string_view text) { + std::string clean; + clean.reserve(text.size()); + for (std::size_t index = 0; index < text.size(); ++index) { + if (text[index] == '\x1b' && index + 1 < text.size() && text[index + 1] == '[') { + index += 2; + while (index < text.size() && !(text[index] >= '@' && text[index] <= '~')) + ++index; + continue; + } + clean += text[index]; + } + return clean; +} +std::string normalize_path(std::string path) { + std::replace(path.begin(), path.end(), '\\', '/'); + path = std::filesystem::path(path).lexically_normal().generic_string(); + while (path.size() > 1 && path.back() == '/') + path.pop_back(); + return path; +} +bool has_windows_drive(std::string_view path) { + return path.size() >= 2 && std::isalpha(static_cast(path[0])) && + path[1] == ':'; +} +std::string lower_ascii(std::string value) { + for (auto& character : value) + character = static_cast(std::tolower(static_cast(character))); + return value; +} +std::string project_source(std::string raw, const std::filesystem::path& project_root) { + if (raw.starts_with("lua: ")) + raw.erase(0, 5); + std::replace(raw.begin(), raw.end(), '\\', '/'); + for (const auto& component : std::filesystem::path(raw)) + if (component == "..") + return {}; + const auto path = normalize_path(std::move(raw)); + const auto root = normalize_path(path_to_utf8(project_root)); + std::string relative; + if (!path.empty() && path[0] != '/' && !has_windows_drive(path)) + relative = path; + else { + const auto windows = has_windows_drive(path) && has_windows_drive(root); + const auto comparable_path = windows ? lower_ascii(path) : path; + const auto comparable_root = windows ? lower_ascii(root) : root; + if (comparable_path.size() <= comparable_root.size() || + !comparable_path.starts_with(comparable_root) || + comparable_path[comparable_root.size()] != '/') + return {}; + relative = path.substr(root.size() + 1); + } + if (!relative.starts_with("Scripts/") || relative.size() <= 8) + return {}; + if (relative.find("/../") != std::string::npos || relative.ends_with("/..")) + return {}; + return relative; +} +int positive_number(const std::string& text) { + try { + const auto value = std::stoll(text); + return value > 0 && value <= 100000000 ? static_cast(value) : 0; + } catch (const std::exception&) { + return 0; + } +} +} // namespace + +Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase, + const std::filesystem::path& project_root) { + static const std::regex msvc( + R"(^(.+)\(([0-9]+)(?:,([0-9]+))?\)\s*:\s*(fatal error|error|warning|note)\s*([A-Za-z]+[0-9]+)?\s*:\s*(.*)$)"); + static const std::regex clang_column( + R"(^(.+):([0-9]+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)"); + static const std::regex clang_line( + R"(^(.+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)"); + static const std::regex lua(R"(^(.+\.lua):([0-9]+):\s*(.*)$)"); + Json rows = Json::array(); + std::istringstream stream{std::string(raw_log)}; + std::string raw; + while (rows.size() < max_rows && std::getline(stream, raw)) { + if (raw.size() > max_line) + raw.resize(max_line); + auto line = strip_ansi(raw); + if (!line.empty() && line.back() == '\r') + line.pop_back(); + std::smatch match; + std::string file, message, severity, code; + int row = 0, column = 0; + bool has_column = false; + if (std::regex_match(line, match, msvc) || + std::regex_match(line, match, clang_column)) { + file = match[1].str(); + row = positive_number(match[2].str()); + has_column = !match[3].str().empty(); + column = positive_number(match[3].str()); + severity = match[4].str(); + code = match[5].str(); + message = match[6].str(); + } else if (std::regex_match(line, match, clang_line)) { + file = match[1].str(); + row = positive_number(match[2].str()); + severity = match[3].str(); + code = match[4].str(); + message = match[5].str(); + } else if (std::regex_match(line, match, lua)) { + file = match[1].str(); + row = positive_number(match[2].str()); + severity = "error"; + message = match[3].str(); + } else + continue; + if (row == 0 || (has_column && column == 0)) + continue; + if (severity == "fatal error") + severity = "error"; + if (message.size() > max_message) + message.resize(max_message); + Json diagnostic{{"severity", severity}, + {"phase", std::string(phase)}, + {"message", message}, + {"line", row}}; + if (column > 0) + diagnostic["column"] = column; + if (!code.empty()) + diagnostic["code"] = code; + if (auto relative = project_source(file, project_root); !relative.empty()) + diagnostic["file"] = std::move(relative); + rows.push_back(std::move(diagnostic)); + } + return rows; +} +} // namespace faset::editor diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index ce3763e..3d7bd2e 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -94,7 +95,7 @@ fs::path build_executable(const fs::path& build, const std::string& configuratio Json JobStatus::json() const { return {{"id", id}, {"kind", kind}, {"state", state}, {"stage", stage}, {"progress", progress}, {"log", log}, - {"error", error}, {"result", result}}; + {"error", error}, {"diagnostics", diagnostics}, {"result", result}}; } void write_cooked_scene(const fs::path& path, const Json& scene) { validate_scene(scene); @@ -199,6 +200,13 @@ struct BuildService::Impl { std::string run(Job& job, std::vector arguments, const fs::path& cwd) { if (job.cancelled) throw Cancelled{}; + std::string phase; + std::size_t diagnostics_before; + { + std::lock_guard lock(job.mutex); + phase = job.status.stage; + diagnostics_before = job.status.diagnostics.size(); + } std::string description = "$"; for (const auto& argument : arguments) description += " " + Json(argument).dump(); @@ -206,6 +214,35 @@ struct BuildService::Impl { log(job, description); Process process({std::move(arguments), cwd, {}}); std::string output; + std::string pending; + const auto collect_diagnostics = [&](std::string_view chunk, bool final) { + pending.append(chunk); + std::size_t newline; + while ((newline = pending.find('\n')) != std::string::npos) { + const auto line = pending.substr(0, newline + 1); + pending.erase(0, newline + 1); + const auto found = parse_build_diagnostics(line, phase, config.project_root); + if (found.empty()) + continue; + std::lock_guard lock(job.mutex); + for (const auto& row : found) { + if (job.status.diagnostics.size() >= 200) + break; + job.status.diagnostics.push_back(row); + } + } + if (final && !pending.empty()) { + const auto found = parse_build_diagnostics(pending, phase, config.project_root); + std::lock_guard lock(job.mutex); + for (const auto& row : found) { + if (job.status.diagnostics.size() >= 200) + break; + job.status.diagnostics.push_back(row); + } + pending.clear(); + } else if (pending.size() > 8192) + pending.erase(0, pending.size() - 8192); + }; while (true) { if (job.cancelled) { process.cancel(); @@ -215,14 +252,29 @@ struct BuildService::Impl { } auto poll = process.poll(); log(job, poll.output); + collect_diagnostics(poll.output, !poll.running); output += poll.output; if (output.size() > max_log_bytes) output.erase(0, output.size() - max_log_bytes); if (!poll.running) { - if (poll.exit_code.value_or(1) != 0) + if (poll.exit_code.value_or(1) != 0) { + std::lock_guard lock(job.mutex); + bool has_error = false; + for (std::size_t index = diagnostics_before; + index < job.status.diagnostics.size(); ++index) + if (job.status.diagnostics[index].value("severity", "") == "error") + has_error = true; + if (!has_error && job.status.diagnostics.size() < 200) + job.status.diagnostics.push_back( + {{"severity", "error"}, + {"phase", phase}, + {"message", "Process exited with code " + + std::to_string(poll.exit_code.value_or(1)) + + "; see job log"}}); throw std::runtime_error("Process exited with code " + std::to_string(poll.exit_code.value_or(1)) + "; see job log"); + } return output; } std::this_thread::sleep_for(std::chrono::milliseconds(15)); diff --git a/tests/build_diagnostics_tests.cpp b/tests/build_diagnostics_tests.cpp new file mode 100644 index 0000000..3856dae --- /dev/null +++ b/tests/build_diagnostics_tests.cpp @@ -0,0 +1,75 @@ +#include +#include +#include + +using namespace faset; +namespace fs = std::filesystem; + +namespace { +void check(bool condition, std::string_view message) { + if (!condition) + throw std::runtime_error(std::string(message)); +} +void contracts() { + const auto project = fs::path("/project"); + auto rows = editor::parse_build_diagnostics( + "/project/Scripts/Game.cpp:17:4: error: bad field\n", "compile", project); + check(rows.size() == 1 && rows[0].at("file") == "Scripts/Game.cpp" && + rows[0].at("line") == 17 && rows[0].at("column") == 4 && + rows[0].at("severity") == "error" && rows[0].at("phase") == "compile", + "Clang error has a project-relative source location"); + rows = editor::parse_build_diagnostics( + "\x1b[31mScripts/player.lua:6: unexpected symbol near '='\x1b[0m\n", + "schema", project); + check(rows.size() == 1 && rows[0].at("file") == "Scripts/player.lua" && + rows[0].at("line") == 6 && rows[0].at("severity") == "error" && + rows[0].at("message") == "unexpected symbol near '='", + "Lua syntax error and ANSI stripping work"); + const auto windows = fs::path(R"(C:\Café)"); + rows = editor::parse_build_diagnostics( + R"(C:\Café\Scripts\Game.cpp(17,4): error C2143: syntax error)" "\n" + R"(C:\Café\Scripts\Game.cpp:19:2: warning: suspicious conversion)" "\n", + "compile", windows); + check(rows.size() == 2 && rows[0].at("file") == "Scripts/Game.cpp" && + rows[0].at("code") == "C2143" && rows[0].at("line") == 17 && + rows[1].at("severity") == "warning" && rows[1].at("line") == 19, + "Windows drive colon and Unicode directory remain intact"); + rows = editor::parse_build_diagnostics( + "Scripts/Game.cpp:9:3: error: missing value\n" + " broken call\n" + " ^~~~~~\n" + "Scripts/Game.cpp:4:1: note: declared here\n", + "compile", project); + check(rows.size() == 2 && rows[0].at("severity") == "error" && + rows[1].at("severity") == "note" && rows[1].at("file") == "Scripts/Game.cpp", + "Caret and source excerpts do not become duplicate diagnostics"); + rows = editor::parse_build_diagnostics( + "/outside/Scripts/Game.cpp:4:2: error: external\n" + "../Scripts/Escape.cpp:5:2: error: escaped\n", + "compile", project); + check(rows.size() == 2 && !rows[0].contains("file") && !rows[1].contains("file"), + "External and traversing sources are never navigable"); + rows = editor::parse_build_diagnostics( + "Scripts/../Scripts/Game.cpp:4:2: error: disguised traversal\n" + "Scripts/Game.cpp:4:0: error: invalid column\n", + "compile", project); + check(rows.size() == 1 && !rows[0].contains("file"), + "Traversal stays non-navigable and zero columns are rejected"); + std::string noisy; + for (int index = 0; index < 300; ++index) + noisy += "Scripts/Game.cpp:2:1: warning: repeated\n"; + check(editor::parse_build_diagnostics(noisy, "compile", project).size() <= 200, + "Structured diagnostic count is bounded"); +} +} // namespace + +int main() { + try { + contracts(); + std::cout << "Build diagnostic parsing contracts passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/build_schema_tests.cpp b/tests/build_schema_tests.cpp index 273ef8b..00ac62e 100644 --- a/tests/build_schema_tests.cpp +++ b/tests/build_schema_tests.cpp @@ -182,6 +182,25 @@ int test_main(int argc, char** argv) { const auto previous_player = sha256_file(player); const auto previous_schema = read_text(schema); const auto previous_manifest = read_text(directory / "manifest.json"); + atomic_write(config.project_root / "emit-clang-error-and-fail-build", "fixture\n"); + const auto compile_failed = builds.wait(builds.start_build()); + check(compile_failed.state == "failed" && + read_text(last_build) == previous_pointer && + compile_failed.json().at("diagnostics").size() == 1 && + compile_failed.json().at("diagnostics")[0].at("file") == + "Scripts/Gameplay.cpp" && + compile_failed.log.find("fixture compile failure") != std::string::npos, + "Failed compiler output retains raw log and structured source location"); + fs::remove(config.project_root / "emit-clang-error-and-fail-build"); + atomic_write(config.project_root / "fail-unparseable-build", "fixture\n"); + const auto generic_failed = builds.wait(builds.start_build()); + check(generic_failed.state == "failed" && + generic_failed.json().at("diagnostics").size() == 1 && + generic_failed.json().at("diagnostics")[0].at("severity") == "error" && + !generic_failed.json().at("diagnostics")[0].contains("file") && + read_text(last_build) == previous_pointer, + "Unparseable process failure has a non-navigable diagnostic"); + fs::remove(config.project_root / "fail-unparseable-build"); atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp", "#define BUILD_ONLY 3\n"); atomic_write(config.project_root / "mutate-cpp-header-during-build", "fixture\n"); diff --git a/tests/build_schema_tool.cpp b/tests/build_schema_tool.cpp index 9427ddd..89285bb 100644 --- a/tests/build_schema_tool.cpp +++ b/tests/build_schema_tool.cpp @@ -39,8 +39,18 @@ int tool_main(int argc, char** argv) { std::cout << "Native packaging fixture validated\n"; return 0; } - if (argc > 2 && std::string_view(argv[1]) == "--build") + if (argc > 2 && std::string_view(argv[1]) == "--build") { + if (fs::exists("emit-clang-error-and-fail-build")) { + std::cerr << path_to_utf8(fs::current_path() / "Scripts/Gameplay.cpp") + << ":7:3: error: fixture compile failure\n"; + return 1; + } + if (fs::exists("fail-unparseable-build")) { + std::cerr << "Synthetic native build failed without a source location\n"; + return 1; + } return 0; + } fs::path build; for (int i = 1; i + 1 < argc; ++i) if (std::string_view(argv[i]) == "-B")