From 4d47e894c1ce20e0195d13f8632d5de6098db177 Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 1 Mar 2026 20:28:31 -0700 Subject: [PATCH] Sprint 278: LSP Proxy Hardening (steps 1918-1922) - LSPMessageValidator: JSON-RPC 2.0 shape validation (method, id type, params type) - LSPCapabilityNegotiator: per-source capability tracking; shouldRoute gates on declared caps - LSPRequestThrottler: per-URI/method debounce; blocks rapid didChange floods - LSPErrorRecovery: per-source health tracking; stale-cache fallback when crashed - Sprint278IntegrationSummary: poly-parse (C++ + Haskell) end-to-end integration 26/26 tests passing (5+5+5+5+6). All new headers well under 600 lines. LoRA recorded: sprint278-2026-03-01 (9 calls, 6437 tokens). Co-Authored-By: Claude Sonnet 4.6 --- editor/CMakeLists.txt | 17 +++ editor/src/LSPCapabilityNegotiator.h | 58 ++++++++++ editor/src/LSPErrorRecovery.h | 64 +++++++++++ editor/src/LSPMessageValidator.h | 52 +++++++++ editor/src/LSPRequestThrottler.h | 45 ++++++++ editor/src/Sprint278IntegrationSummary.h | 13 +++ editor/tests/step1918_test.cpp | 97 ++++++++++++++++ editor/tests/step1919_test.cpp | 92 +++++++++++++++ editor/tests/step1920_test.cpp | 80 ++++++++++++++ editor/tests/step1921_test.cpp | 101 +++++++++++++++++ editor/tests/step1922_test.cpp | 135 +++++++++++++++++++++++ 11 files changed, 754 insertions(+) create mode 100644 editor/src/LSPCapabilityNegotiator.h create mode 100644 editor/src/LSPErrorRecovery.h create mode 100644 editor/src/LSPMessageValidator.h create mode 100644 editor/src/LSPRequestThrottler.h create mode 100644 editor/src/Sprint278IntegrationSummary.h create mode 100644 editor/tests/step1918_test.cpp create mode 100644 editor/tests/step1919_test.cpp create mode 100644 editor/tests/step1920_test.cpp create mode 100644 editor/tests/step1921_test.cpp create mode 100644 editor/tests/step1922_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index c74c848..129c65d 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -10957,3 +10957,20 @@ target_link_libraries(step1916_test PRIVATE nlohmann_json::nlohmann_json) add_executable(step1917_test tests/step1917_test.cpp) target_include_directories(step1917_test PRIVATE src) target_link_libraries(step1917_test PRIVATE nlohmann_json::nlohmann_json) + +add_executable(step1918_test tests/step1918_test.cpp) +target_include_directories(step1918_test PRIVATE src) +target_link_libraries(step1918_test PRIVATE nlohmann_json::nlohmann_json) + +add_executable(step1919_test tests/step1919_test.cpp) +target_include_directories(step1919_test PRIVATE src) + +add_executable(step1920_test tests/step1920_test.cpp) +target_include_directories(step1920_test PRIVATE src) + +add_executable(step1921_test tests/step1921_test.cpp) +target_include_directories(step1921_test PRIVATE src) + +add_executable(step1922_test tests/step1922_test.cpp) +target_include_directories(step1922_test PRIVATE src) +target_link_libraries(step1922_test PRIVATE nlohmann_json::nlohmann_json) diff --git a/editor/src/LSPCapabilityNegotiator.h b/editor/src/LSPCapabilityNegotiator.h new file mode 100644 index 0000000..553e08c --- /dev/null +++ b/editor/src/LSPCapabilityNegotiator.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include + +namespace whetstone { + +class LSPCapabilityNegotiator { +public: + void registerSource(const std::string& source, + const std::vector& caps) { + caps_[source] = {caps.begin(), caps.end()}; + } + + bool supports(const std::string& source, + const std::string& capability) const { + auto it = caps_.find(source); + if (it == caps_.end()) return false; + return it->second.count(capability) > 0; + } + + // Returns true if the source supports the capability required by this method. + // Unknown methods pass through (return true). + bool shouldRoute(const std::string& source, + const std::string& method) const { + auto it = methodToCap_.find(method); + if (it == methodToCap_.end()) return true; // unknown method: pass through + return supports(source, it->second); + } + + std::vector capabilitiesFor(const std::string& source) const { + auto it = caps_.find(source); + if (it == caps_.end()) return {}; + return {it->second.begin(), it->second.end()}; + } + + std::vector sourcesSupporting(const std::string& cap) const { + std::vector result; + for (const auto& [src, capSet] : caps_) + if (capSet.count(cap)) result.push_back(src); + return result; + } + +private: + std::map> caps_; + + const std::map methodToCap_ = { + {"textDocument/hover", "hoverProvider"}, + {"textDocument/definition", "definitionProvider"}, + {"textDocument/references", "referencesProvider"}, + {"textDocument/completion", "completionProvider"}, + {"textDocument/rename", "renameProvider"}, + {"textDocument/formatting", "documentFormattingProvider"}, + }; +}; + +} // namespace whetstone diff --git a/editor/src/LSPErrorRecovery.h b/editor/src/LSPErrorRecovery.h new file mode 100644 index 0000000..68facfc --- /dev/null +++ b/editor/src/LSPErrorRecovery.h @@ -0,0 +1,64 @@ +#pragma once +#include "LSPDiagnosticsAggregator.h" +#include +#include +#include +#include +#include + +namespace whetstone { + +class LSPErrorRecovery { +public: + void markCrashed(const std::string& source) { + crashed_.insert(source); + } + + void markRecovered(const std::string& source) { + crashed_.erase(source); + } + + // Unknown sources are healthy (optimistic default). + bool isHealthy(const std::string& source) const { + return crashed_.count(source) == 0; + } + + std::vector healthySources( + const std::vector& all) const { + std::vector result; + for (const auto& s : all) + if (isHealthy(s)) result.push_back(s); + return result; + } + + void cacheDiagnostics(const std::string& source, const std::string& uri, + const std::vector& diags) { + cache_[{source, uri}] = diags; + } + + // Returns cached diags if source is crashed, else empty. + std::vector fallbackDiagnostics(const std::string& source, + const std::string& uri) const { + if (isHealthy(source)) return {}; + auto it = cache_.find({source, uri}); + if (it == cache_.end()) return {}; + return it->second; + } + + void clearCache(const std::string& source) { + for (auto it = cache_.begin(); it != cache_.end(); ) { + if (it->first.first == source) it = cache_.erase(it); + else ++it; + } + } + + int crashedCount() const { + return static_cast(crashed_.size()); + } + +private: + std::set crashed_; + std::map, std::vector> cache_; +}; + +} // namespace whetstone diff --git a/editor/src/LSPMessageValidator.h b/editor/src/LSPMessageValidator.h new file mode 100644 index 0000000..500c706 --- /dev/null +++ b/editor/src/LSPMessageValidator.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include + +namespace whetstone { + +struct ValidationResult { + bool valid = true; + int errorCode = 0; + std::string errorMessage; +}; + +class LSPMessageValidator { +public: + ValidationResult validate(const nlohmann::json& msg) const { + // Must have "jsonrpc": "2.0" + if (!msg.contains("jsonrpc") || msg["jsonrpc"] != "2.0") + return {false, -32600, "Invalid Request: missing or wrong jsonrpc version"}; + + // Must have "method" as a string + if (!msg.contains("method")) + return {false, -32600, "Invalid Request: missing method"}; + if (!msg["method"].is_string()) + return {false, -32600, "Invalid Request: method must be a string"}; + + // If "id" present: must be string, number, or null + if (msg.contains("id")) { + const auto& id = msg["id"]; + if (!id.is_string() && !id.is_number() && !id.is_null()) + return {false, -32600, "Invalid Request: id must be string, number, or null"}; + } + + // If "params" present: must be object or array + if (msg.contains("params")) { + const auto& p = msg["params"]; + if (!p.is_object() && !p.is_array()) + return {false, -32602, "Invalid params: must be object or array"}; + } + + return {true, 0, ""}; + } + + static bool isNotification(const nlohmann::json& msg) { + return !msg.contains("id"); + } + + static bool isRequest(const nlohmann::json& msg) { + return msg.contains("id"); + } +}; + +} // namespace whetstone diff --git a/editor/src/LSPRequestThrottler.h b/editor/src/LSPRequestThrottler.h new file mode 100644 index 0000000..ef67f3a --- /dev/null +++ b/editor/src/LSPRequestThrottler.h @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include +#include + +namespace whetstone { + +class LSPRequestThrottler { +public: + void record(const std::string& uri, const std::string& method, + int64_t timestamp_ms) { + last_[{uri, method}] = timestamp_ms; + } + + bool shouldAllow(const std::string& uri, const std::string& method, + int64_t timestamp_ms, int64_t debounce_ms) const { + auto it = last_.find({uri, method}); + if (it == last_.end()) return true; + return (timestamp_ms - it->second) >= debounce_ms; + } + + void reset(const std::string& uri) { + for (auto it = last_.begin(); it != last_.end(); ) { + if (it->first.first == uri) it = last_.erase(it); + else ++it; + } + } + + void resetAll() { + last_.clear(); + } + + int64_t lastTimestamp(const std::string& uri, + const std::string& method) const { + auto it = last_.find({uri, method}); + if (it == last_.end()) return -1; + return it->second; + } + +private: + std::map, int64_t> last_; +}; + +} // namespace whetstone diff --git a/editor/src/Sprint278IntegrationSummary.h b/editor/src/Sprint278IntegrationSummary.h new file mode 100644 index 0000000..0378358 --- /dev/null +++ b/editor/src/Sprint278IntegrationSummary.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace whetstone { + +struct Sprint278IntegrationSummary { + int stepsCompleted = 5; + bool success = true; + + std::string sprintName() const { return "Sprint 278: LSP Proxy Hardening"; } +}; + +} // namespace whetstone diff --git a/editor/tests/step1918_test.cpp b/editor/tests/step1918_test.cpp new file mode 100644 index 0000000..a3b478d --- /dev/null +++ b/editor/tests/step1918_test.cpp @@ -0,0 +1,97 @@ +// Step 1918: LSPMessageValidator +// Validates JSON-RPC 2.0 message shape before routing. +// +// t1: valid request passes +// t2: missing jsonrpc field rejected +// t3: wrong jsonrpc version rejected +// t4: non-string method rejected; missing method rejected +// t5: bad id type rejected; bad params type rejected; notification/request detection + +#include "LSPMessageValidator.h" +#include +#include + +using json = nlohmann::json; +namespace ws = whetstone; + +static int p=0,f=0; +#define T(n) { std::cout<<" "<<#n<<"... "; } +#define P() { std::cout<<"PASS\n"; ++p; } +#define F(m) { std::cout<<"FAIL: "< 0 ? 1 : 0; +} diff --git a/editor/tests/step1919_test.cpp b/editor/tests/step1919_test.cpp new file mode 100644 index 0000000..cb1d422 --- /dev/null +++ b/editor/tests/step1919_test.cpp @@ -0,0 +1,92 @@ +// Step 1919: LSPCapabilityNegotiator +// Tracks per-source capability sets; gates routing on capability support. +// +// t1: registerSource and supports +// t2: shouldRoute blocks unsupported method; passes unknown methods +// t3: capabilitiesFor returns sorted capability list +// t4: sourcesSupporting finds all sources with a given capability +// t5: unregistered source supports nothing; shouldRoute passes unknown sources + +#include "LSPCapabilityNegotiator.h" +#include + +namespace ws = whetstone; + +static int p=0,f=0; +#define T(n) { std::cout<<" "<<#n<<"... "; } +#define P() { std::cout<<"PASS\n"; ++p; } +#define F(m) { std::cout<<"FAIL: "< 0 ? 1 : 0; +} diff --git a/editor/tests/step1920_test.cpp b/editor/tests/step1920_test.cpp new file mode 100644 index 0000000..c962712 --- /dev/null +++ b/editor/tests/step1920_test.cpp @@ -0,0 +1,80 @@ +// Step 1920: LSPRequestThrottler +// Debounces per-URI requests to prevent flooding language servers. +// +// t1: no prior record → shouldAllow true +// t2: within debounce window → shouldAllow false +// t3: after debounce window expires → shouldAllow true +// t4: reset clears uri; resetAll clears everything +// t5: lastTimestamp returns -1 for missing, correct value after record + +#include "LSPRequestThrottler.h" +#include + +namespace ws = whetstone; + +static int p=0,f=0; +#define T(n) { std::cout<<" "<<#n<<"... "; } +#define P() { std::cout<<"PASS\n"; ++p; } +#define F(m) { std::cout<<"FAIL: "<=) + C( t.shouldAllow("file:///a.go","textDocument/didChange",1500,500), "at boundary allowed"); + // past boundary + C( t.shouldAllow("file:///a.go","textDocument/didChange",1600,500), "past boundary allowed"); + P(); +} + +void t4(){ + T(reset_and_resetAll); + ws::LSPRequestThrottler t; + t.record("file:///a.go","textDocument/didChange",1000); + t.record("file:///b.go","textDocument/didChange",1000); + t.reset("file:///a.go"); + C( t.shouldAllow("file:///a.go","textDocument/didChange",1100,500), "a.go reset → allowed"); + C(!t.shouldAllow("file:///b.go","textDocument/didChange",1100,500), "b.go still throttled"); + t.resetAll(); + C( t.shouldAllow("file:///b.go","textDocument/didChange",1100,500), "b.go allowed after resetAll"); + P(); +} + +void t5(){ + T(lastTimestamp); + ws::LSPRequestThrottler t; + C(t.lastTimestamp("file:///a.go","textDocument/didChange") == -1, "no record returns -1"); + t.record("file:///a.go","textDocument/didChange",9999); + C(t.lastTimestamp("file:///a.go","textDocument/didChange") == 9999, "correct timestamp"); + C(t.lastTimestamp("file:///a.go","textDocument/hover") == -1, "different method -1"); + P(); +} + +int main(){ + std::cout << "Step 1920: LSPRequestThrottler\n"; + t1(); t2(); t3(); t4(); t5(); + std::cout << "\n" << p << "/" << (p+f) << " passed\n"; + return f > 0 ? 1 : 0; +} diff --git a/editor/tests/step1921_test.cpp b/editor/tests/step1921_test.cpp new file mode 100644 index 0000000..ec9d809 --- /dev/null +++ b/editor/tests/step1921_test.cpp @@ -0,0 +1,101 @@ +// Step 1921: LSPErrorRecovery +// Tracks per-source health; returns stale cached diagnostics when crashed. +// +// t1: unknown sources are healthy by default +// t2: markCrashed/markRecovered toggle health +// t3: healthySources filters crashed sources +// t4: fallbackDiagnostics returns cache only when crashed +// t5: clearCache removes entries; crashedCount + +#include "LSPErrorRecovery.h" +#include + +namespace ws = whetstone; + +static int p=0,f=0; +#define T(n) { std::cout<<" "<<#n<<"... "; } +#define P() { std::cout<<"PASS\n"; ++p; } +#define F(m) { std::cout<<"FAIL: "< all = {"gopls","haskell-lsp","tsserver"}; + auto healthy = r.healthySources(all); + C(healthy.size() == 2, "two healthy"); + C(healthy[0] == "gopls", "gopls healthy"); + C(healthy[1] == "tsserver", "tsserver healthy"); + P(); +} + +void t4(){ + T(fallbackDiagnostics_only_when_crashed); + ws::LSPErrorRecovery r; + auto d = diag("file:///Parse.hs",1,"type error","haskell-lsp"); + r.cacheDiagnostics("haskell-lsp","file:///Parse.hs",{d}); + // healthy: fallback is empty + C(r.fallbackDiagnostics("haskell-lsp","file:///Parse.hs").empty(), + "healthy: no fallback"); + // crash it: fallback returns cache + r.markCrashed("haskell-lsp"); + auto fb = r.fallbackDiagnostics("haskell-lsp","file:///Parse.hs"); + C(fb.size() == 1, "one fallback diag"); + C(fb[0].message == "type error", "correct message"); + // different uri: empty + C(r.fallbackDiagnostics("haskell-lsp","file:///Other.hs").empty(), + "missing uri empty"); + P(); +} + +void t5(){ + T(clearCache_and_crashedCount); + ws::LSPErrorRecovery r; + r.cacheDiagnostics("haskell-lsp","file:///A.hs", + {diag("file:///A.hs",1,"E","haskell-lsp")}); + r.cacheDiagnostics("haskell-lsp","file:///B.hs", + {diag("file:///B.hs",2,"W","haskell-lsp")}); + r.markCrashed("haskell-lsp"); + C(!r.fallbackDiagnostics("haskell-lsp","file:///A.hs").empty(), "cache present"); + r.clearCache("haskell-lsp"); + C(r.fallbackDiagnostics("haskell-lsp","file:///A.hs").empty(), "cache cleared"); + // crashedCount tracks multiple + r.markCrashed("gopls"); + C(r.crashedCount() == 2, "two crashed"); + P(); +} + +int main(){ + std::cout << "Step 1921: LSPErrorRecovery\n"; + t1(); t2(); t3(); t4(); t5(); + std::cout << "\n" << p << "/" << (p+f) << " passed\n"; + return f > 0 ? 1 : 0; +} diff --git a/editor/tests/step1922_test.cpp b/editor/tests/step1922_test.cpp new file mode 100644 index 0000000..1511ae5 --- /dev/null +++ b/editor/tests/step1922_test.cpp @@ -0,0 +1,135 @@ +// Step 1922: Sprint 278 Integration +// poly-parse project: C++ lexer component + Haskell AST transform. +// All four hardening components work together: +// - Validator rejects malformed messages +// - Negotiator blocks methods haskell-lsp doesn't support +// - Throttler debounces rapid didChange from the C++ lexer +// - ErrorRecovery serves stale diags when haskell-lsp crashes +// +// t1: validator rejects bad message before routing +// t2: negotiator blocks referencesProvider (haskell-lsp doesn't declare it) +// t3: throttler debounces rapid didChange on lexer.cpp +// t4: error recovery serves stale haskell diags after crash +// t5: healthy gopls still routes normally; haskell-lsp in recovery +// t6: Sprint278IntegrationSummary reports complete + +#include "LSPMessageValidator.h" +#include "LSPCapabilityNegotiator.h" +#include "LSPRequestThrottler.h" +#include "LSPErrorRecovery.h" +#include "Sprint278IntegrationSummary.h" +#include +#include + +using json = nlohmann::json; +namespace ws = whetstone; + +static int p=0,f=0; +#define T(n) { std::cout<<" "<<#n<<"... "; } +#define P() { std::cout<<"PASS\n"; ++p; } +#define F(m) { std::cout<<"FAIL: "< 0 ? 1 : 0; +}