diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index bc21f4c..161ce2a 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1651,4 +1651,15 @@ target_link_libraries(step268_test PRIVATE tree_sitter_java tree_sitter_rust tree_sitter_go tree_sitter_org) +# Step 271: Phase 10b integration tests +add_executable(step271_test tests/step271_test.cpp) +target_include_directories(step271_test PRIVATE src) +target_link_libraries(step271_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go + tree_sitter_org) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index 40505a2..96797f1 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -1866,10 +1866,17 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, hints["bodyStatements"] = (int)body.size(); // Side effect heuristic: check for io/network calls bool hasSideEffects = false; + // Check body statements and their descendants + std::vector toCheck; for (auto* stmt : body) { - if (stmt->conceptType == "FunctionCall" || - stmt->conceptType == "MethodCall") { - std::string callee = getNodeName(stmt); + toCheck.push_back(stmt); + for (auto* desc : stmt->allChildren()) + toCheck.push_back(desc); + } + for (auto* node : toCheck) { + if (node->conceptType == "FunctionCall" || + node->conceptType == "MethodCall") { + std::string callee = getNodeName(node); if (callee.find("print") != std::string::npos || callee.find("write") != std::string::npos || callee.find("send") != std::string::npos || diff --git a/editor/tests/step269_test.cpp b/editor/tests/step269_test.cpp new file mode 100644 index 0000000..00347d6 --- /dev/null +++ b/editor/tests/step269_test.cpp @@ -0,0 +1,530 @@ +// Step 269 TDD Test: Annotation RPC Methods +// +// High-level RPC methods for agents to set/get/remove semantic annotations +// without needing low-level applyMutation(insertNode) calls. +#include "HeadlessEditorState.h" +#include "MCPServer.h" + +#include +#include + +static void expect(bool cond, const std::string& name, + int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) + << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) + << " FAIL: " << name << "\n"; + ++failed; + } +} + +static json rpc(HeadlessEditorState& state, const std::string& session, + const std::string& method, + json params = json::object()) { + json request = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", method}, {"params", params}}; + return state.processAgentRequest(request, session); +} + +int main() { + int passed = 0; + int failed = 0; + + std::string src = + "def greet(name):\n" + " return \"hello \" + name\n" + "\n" + "def farewell(name):\n" + " return \"goodbye \" + name\n"; + + // --------------------------------------------------------------- + // Test 1: setSemanticAnnotation adds intent annotation + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + fnId = c->id; + break; + } + } + + json resp = rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "greets user"}, + {"category", "io"}}}}); + + bool success = resp.contains("result") && + resp["result"].value("success", false); + + // Verify annotation exists + bool found = false; + ASTNode* fn = findNodeById(state.activeAST(), fnId); + if (fn) { + for (auto* a : fn->getChildren("annotations")) { + if (a->conceptType == "IntentAnnotation") { + auto* ia = static_cast(a); + if (ia->summary == "greets user") found = true; + } + } + } + + expect(success && found, + "setSemanticAnnotation adds IntentAnnotation", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: setSemanticAnnotation updates existing (no duplicates) + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + fnId = c->id; + break; + } + } + + // Set intent twice with different values + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "greets v1"}, {"category", "io"}}}}); + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "greets v2"}, {"category", "io"}}}}); + + // Should have exactly 1 IntentAnnotation with "greets v2" + int count = 0; + std::string lastSummary; + ASTNode* fn = findNodeById(state.activeAST(), fnId); + if (fn) { + for (auto* a : fn->getChildren("annotations")) { + if (a->conceptType == "IntentAnnotation") { + ++count; + lastSummary = static_cast(a)->summary; + } + } + } + + expect(count == 1 && lastSummary == "greets v2", + "setSemanticAnnotation updates existing, no duplicates", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: setSemanticAnnotation for all 5 types + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + fnId = c->id; + break; + } + } + + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "greets"}, {"category", "io"}}}}); + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "complexity"}, + {"fields", {{"timeComplexity", "O(1)"}, + {"cognitiveComplexity", 1}, + {"linesOfLogic", 2}}}}); + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "risk"}, + {"fields", {{"level", "low"}, {"reason", "simple"}, + {"dependentCount", 2}}}}); + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "contract"}, + {"fields", {{"preconditions", "name is str"}, + {"postconditions", "returns greeting"}, + {"returnShape", "str"}, + {"sideEffects", "none"}}}}); + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "tags"}, + {"fields", {{"tags", json::array({"@io", "@greeting"})}}}}); + + int count = 0; + ASTNode* fn = findNodeById(state.activeAST(), fnId); + if (fn) { + for (auto* a : fn->getChildren("annotations")) { + if (isSemanticAnnotation(a->conceptType)) + ++count; + } + } + + expect(count == 5, + "setSemanticAnnotation works for all 5 types (" + + std::to_string(count) + "/5)", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: getSemanticAnnotations on a specific node + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + fnId = c->id; + break; + } + } + + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "greets user"}, {"category", "io"}}}}); + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "risk"}, + {"fields", {{"level", "low"}, {"reason", "trivial"}, + {"dependentCount", 0}}}}); + + json resp = rpc(state, "s1", "getSemanticAnnotations", + {{"nodeId", fnId}}); + bool hasResult = resp.contains("result"); + int annoCount = 0; + bool hasIntent = false, hasRisk = false; + if (hasResult && resp["result"].contains("annotations")) { + auto annos = resp["result"]["annotations"]; + annoCount = (int)annos.size(); + for (const auto& a : annos) { + if (a.value("type", "") == "intent") hasIntent = true; + if (a.value("type", "") == "risk") hasRisk = true; + } + } + + expect(hasResult && annoCount == 2 && hasIntent && hasRisk, + "getSemanticAnnotations returns 2 annotations on specific node", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: getSemanticAnnotations with no nodeId returns all + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string greetId, farewellId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function") { + if (getNodeName(c) == "greet") greetId = c->id; + if (getNodeName(c) == "farewell") farewellId = c->id; + } + } + + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", greetId}, {"type", "intent"}, + {"fields", {{"summary", "greets"}, {"category", "io"}}}}); + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", farewellId}, {"type", "intent"}, + {"fields", {{"summary", "says goodbye"}, {"category", "io"}}}}); + + json resp = rpc(state, "s1", "getSemanticAnnotations"); + bool hasResult = resp.contains("result"); + int nodeCount = 0; + if (hasResult && resp["result"].contains("nodes")) + nodeCount = (int)resp["result"]["nodes"].size(); + + expect(hasResult && nodeCount >= 2, + "getSemanticAnnotations (no nodeId) returns " + + std::to_string(nodeCount) + " annotated nodes", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: removeSemanticAnnotation removes specific type + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + fnId = c->id; + break; + } + } + + // Add two annotations + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "greets"}, {"category", "io"}}}}); + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "risk"}, + {"fields", {{"level", "low"}, {"reason", "simple"}, + {"dependentCount", 0}}}}); + + // Remove intent + json resp = rpc(state, "s1", "removeSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}}); + bool success = resp.contains("result") && + resp["result"].value("removed", false); + + // Check only risk remains + int intentCount = 0, riskCount = 0; + ASTNode* fn = findNodeById(state.activeAST(), fnId); + if (fn) { + for (auto* a : fn->getChildren("annotations")) { + if (a->conceptType == "IntentAnnotation") ++intentCount; + if (a->conceptType == "RiskAnnotation") ++riskCount; + } + } + + expect(success && intentCount == 0 && riskCount == 1, + "removeSemanticAnnotation removes intent, keeps risk", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: getUnannotatedNodes returns functions without annotations + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + // Before annotations: both should be unannotated + json resp = rpc(state, "s1", "getUnannotatedNodes"); + int countBefore = 0; + if (resp.contains("result") && resp["result"].contains("nodes")) + countBefore = (int)resp["result"]["nodes"].size(); + + // Annotate greet + std::string greetId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + greetId = c->id; + break; + } + } + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", greetId}, {"type", "intent"}, + {"fields", {{"summary", "greets"}, {"category", "io"}}}}); + + // After: only farewell should be unannotated + json resp2 = rpc(state, "s1", "getUnannotatedNodes"); + int countAfter = 0; + if (resp2.contains("result") && resp2["result"].contains("nodes")) + countAfter = (int)resp2["result"]["nodes"].size(); + + expect(countBefore >= 2 && countAfter == countBefore - 1, + "getUnannotatedNodes: " + std::to_string(countBefore) + + " before, " + std::to_string(countAfter) + " after annotating greet", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: getUnannotatedNodes with type filter + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string greetId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + greetId = c->id; + break; + } + } + + // Add only intent to greet + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", greetId}, {"type", "intent"}, + {"fields", {{"summary", "greets"}, {"category", "io"}}}}); + + // getUnannotatedNodes for "risk" should include greet + json resp = rpc(state, "s1", "getUnannotatedNodes", + {{"type", "risk"}}); + int count = 0; + bool greetIncluded = false; + if (resp.contains("result") && resp["result"].contains("nodes")) { + count = (int)resp["result"]["nodes"].size(); + for (const auto& n : resp["result"]["nodes"]) { + if (n.value("name", "") == "greet") greetIncluded = true; + } + } + + expect(count >= 2 && greetIncluded, + "getUnannotatedNodes(type=risk) includes greet (has intent but not risk)", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 9: Linter cannot set/remove annotations + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("admin", AgentRole::Refactor); + state.setAgentRole("linter", AgentRole::Linter); + + rpc(state, "admin", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + fnId = c->id; + break; + } + } + + // Linter cannot set + json resp1 = rpc(state, "linter", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "test"}, {"category", "io"}}}}); + bool setDenied = resp1.contains("error"); + + // Linter CAN get + json resp2 = rpc(state, "linter", "getSemanticAnnotations", + {{"nodeId", fnId}}); + bool getOk = resp2.contains("result"); + + // Linter CAN get unannotated + json resp3 = rpc(state, "linter", "getUnannotatedNodes"); + bool getUnOk = resp3.contains("result"); + + expect(setDenied && getOk && getUnOk, + "Linter denied set, allowed get/getUnannotated", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 10: 4 MCP tools registered for annotation operations + // --------------------------------------------------------------- + { + MCPServer mcp; + bool foundSet = false, foundGet = false, + foundRemove = false, foundUnannotated = false; + for (const auto& t : mcp.getTools()) { + if (t.name == "whetstone_set_semantic_annotation") foundSet = true; + if (t.name == "whetstone_get_semantic_annotations") foundGet = true; + if (t.name == "whetstone_remove_semantic_annotation") foundRemove = true; + if (t.name == "whetstone_get_unannotated_nodes") foundUnannotated = true; + } + expect(foundSet && foundGet && foundRemove && foundUnannotated, + "4 annotation MCP tools registered", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 11: setSemanticAnnotation on invalid node returns error + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + json resp = rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", "nonexistent_node"}, {"type", "intent"}, + {"fields", {{"summary", "test"}, {"category", "io"}}}}); + bool hasError = resp.contains("error"); + + expect(hasError, + "setSemanticAnnotation on invalid nodeId returns error", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 12: getSemanticAnnotations response format + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "greet") { + fnId = c->id; + break; + } + } + + rpc(state, "s1", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "greets user"}, {"category", "io"}}}}); + + json resp = rpc(state, "s1", "getSemanticAnnotations", + {{"nodeId", fnId}}); + bool hasAnnotations = resp.contains("result") && + resp["result"].contains("annotations"); + bool hasCorrectFields = false; + if (hasAnnotations) { + for (const auto& a : resp["result"]["annotations"]) { + if (a.value("type", "") == "intent") { + hasCorrectFields = + a.contains("summary") && + a.value("summary", "") == "greets user" && + a.contains("category") && + a.value("category", "") == "io"; + } + } + } + + expect(hasAnnotations && hasCorrectFields, + "getSemanticAnnotations returns structured annotation fields", + passed, failed); + } + + std::cout << "\n=== Step 269 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/editor/tests/step270_test.cpp b/editor/tests/step270_test.cpp new file mode 100644 index 0000000..d36fbe7 --- /dev/null +++ b/editor/tests/step270_test.cpp @@ -0,0 +1,283 @@ +// Step 270 TDD Test: Annotation Prompt Templates +// +// MCP prompt templates that guide a frontier model through annotating +// a codebase. Tests prompt registration, static analysis hints in +// getUnannotatedNodes, and prompt message content. +#include "HeadlessEditorState.h" +#include "MCPServer.h" + +#include +#include + +static void expect(bool cond, const std::string& name, + int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) + << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) + << " FAIL: " << name << "\n"; + ++failed; + } +} + +static json rpc(HeadlessEditorState& state, const std::string& session, + const std::string& method, + json params = json::object()) { + json request = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", method}, {"params", params}}; + return state.processAgentRequest(request, session); +} + +int main() { + int passed = 0; + int failed = 0; + + // --------------------------------------------------------------- + // Test 1: annotate_intent prompt registered + // --------------------------------------------------------------- + { + MCPServer mcp; + bool found = false; + for (const auto& p : mcp.getPrompts()) { + if (p.name == "annotate_intent") found = true; + } + expect(found, "annotate_intent prompt registered", passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: annotate_complexity prompt registered + // --------------------------------------------------------------- + { + MCPServer mcp; + bool found = false; + for (const auto& p : mcp.getPrompts()) { + if (p.name == "annotate_complexity") found = true; + } + expect(found, "annotate_complexity prompt registered", passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: annotate_risk prompt registered + // --------------------------------------------------------------- + { + MCPServer mcp; + bool found = false; + for (const auto& p : mcp.getPrompts()) { + if (p.name == "annotate_risk") found = true; + } + expect(found, "annotate_risk prompt registered", passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: annotate_contracts prompt registered + // --------------------------------------------------------------- + { + MCPServer mcp; + bool found = false; + for (const auto& p : mcp.getPrompts()) { + if (p.name == "annotate_contracts") found = true; + } + expect(found, "annotate_contracts prompt registered", passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: annotate_full prompt registered + // --------------------------------------------------------------- + { + MCPServer mcp; + bool found = false; + for (const auto& p : mcp.getPrompts()) { + if (p.name == "annotate_full") found = true; + } + expect(found, "annotate_full prompt registered", passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: annotate_intent prompt returns message with instructions + // --------------------------------------------------------------- + { + MCPServer mcp; + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "prompts/get"}, + {"params", {{"name", "annotate_intent"}, + {"arguments", json::object()}}}}; + json resp = mcp.handleRequest(req); + bool hasMessages = resp.contains("result") && + resp["result"].contains("messages"); + bool mentionsSetAnnotation = false; + bool mentionsGetUnannotated = false; + if (hasMessages) { + std::string text = resp["result"]["messages"][0] + ["content"].value("text", ""); + mentionsSetAnnotation = + text.find("setSemanticAnnotation") != std::string::npos || + text.find("set_semantic_annotation") != std::string::npos; + mentionsGetUnannotated = + text.find("getUnannotatedNodes") != std::string::npos || + text.find("get_unannotated") != std::string::npos; + } + expect(hasMessages && mentionsSetAnnotation && mentionsGetUnannotated, + "annotate_intent prompt mentions setAnnotation and getUnannotated", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: annotate_full prompt mentions save + // --------------------------------------------------------------- + { + MCPServer mcp; + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "prompts/get"}, + {"params", {{"name", "annotate_full"}, + {"arguments", json::object()}}}}; + json resp = mcp.handleRequest(req); + bool mentionsSave = false; + if (resp.contains("result") && + resp["result"].contains("messages")) { + std::string text = resp["result"]["messages"][0] + ["content"].value("text", ""); + mentionsSave = + text.find("save") != std::string::npos || + text.find("Save") != std::string::npos; + } + expect(mentionsSave, + "annotate_full prompt mentions saving annotated AST", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: getUnannotatedNodes includes static analysis hints + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + std::string src = + "def greet(name):\n" + " return \"hello \" + name\n" + "\n" + "def process(data):\n" + " x = data + 1\n" + " y = x * 2\n" + " return y\n"; + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + json resp = rpc(state, "s1", "getUnannotatedNodes", + {{"includeHints", true}}); + bool hasHints = false; + if (resp.contains("result") && resp["result"].contains("nodes")) { + for (const auto& n : resp["result"]["nodes"]) { + if (n.contains("hints")) { + hasHints = true; + break; + } + } + } + + expect(hasHints, + "getUnannotatedNodes with includeHints returns static hints", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 9: Hints include bodyStatements count + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + std::string src = + "def process(data):\n" + " x = data + 1\n" + " y = x * 2\n" + " return y\n"; + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + json resp = rpc(state, "s1", "getUnannotatedNodes", + {{"includeHints", true}}); + int bodyStmts = -1; + if (resp.contains("result") && resp["result"].contains("nodes")) { + for (const auto& n : resp["result"]["nodes"]) { + if (n.value("name", "") == "process" && + n.contains("hints")) { + bodyStmts = n["hints"].value("bodyStatements", -1); + } + } + } + + expect(bodyStmts >= 2, + "Hints include bodyStatements=" + std::to_string(bodyStmts) + + " for process()", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 10: All 5 prompts available via prompts/list + // --------------------------------------------------------------- + { + MCPServer mcp; + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "prompts/list"}}; + json resp = mcp.handleRequest(req); + int annotatePrompts = 0; + if (resp.contains("result") && resp["result"].contains("prompts")) { + for (const auto& p : resp["result"]["prompts"]) { + std::string name = p.value("name", ""); + if (name.find("annotate_") == 0) + ++annotatePrompts; + } + } + + expect(annotatePrompts >= 5, + std::to_string(annotatePrompts) + + " annotate_* prompts in prompts/list", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 11: annotate_risk prompt mentions callers/dependents + // --------------------------------------------------------------- + { + MCPServer mcp; + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "prompts/get"}, + {"params", {{"name", "annotate_risk"}, + {"arguments", json::object()}}}}; + json resp = mcp.handleRequest(req); + bool mentionsCallers = false; + if (resp.contains("result") && resp["result"].contains("messages")) { + std::string text = resp["result"]["messages"][0] + ["content"].value("text", ""); + mentionsCallers = + text.find("caller") != std::string::npos || + text.find("dependent") != std::string::npos || + text.find("Caller") != std::string::npos; + } + expect(mentionsCallers, + "annotate_risk prompt mentions callers/dependents", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 12: Total prompt count after Sprint 10 + // --------------------------------------------------------------- + { + MCPServer mcp; + int total = (int)mcp.getPrompts().size(); + expect(total >= 9, + "Total prompts: " + std::to_string(total) + " (>= 9 expected)", + passed, failed); + } + + std::cout << "\n=== Step 270 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/editor/tests/step271_test.cpp b/editor/tests/step271_test.cpp new file mode 100644 index 0000000..f07c5c0 --- /dev/null +++ b/editor/tests/step271_test.cpp @@ -0,0 +1,492 @@ +// Step 271 TDD Test: Phase 10b Integration Tests +// +// Full agent annotation session: discover unannotated → set annotations → +// query → remove → re-query → save sidecar. Tests multi-function workflow, +// prompt template content, permission enforcement, and hint accuracy. +#include "HeadlessEditorState.h" +#include "MCPServer.h" + +#include +#include +#include + +static void expect(bool cond, const std::string& name, + int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) + << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) + << " FAIL: " << name << "\n"; + ++failed; + } +} + +static json rpc(HeadlessEditorState& state, const std::string& session, + const std::string& method, + json params = json::object()) { + json request = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", method}, {"params", params}}; + return state.processAgentRequest(request, session); +} + +int main() { + int passed = 0; + int failed = 0; + + auto tmpDir = std::filesystem::temp_directory_path() / + "whetstone_step271_test"; + std::filesystem::remove_all(tmpDir); + std::filesystem::create_directories(tmpDir); + + std::string src = + "def validate(data):\n" + " if not data:\n" + " return False\n" + " return True\n" + "\n" + "def transform(items):\n" + " result = []\n" + " for item in items:\n" + " result.append(item * 2)\n" + " return result\n" + "\n" + "def notify(user):\n" + " print(\"hello \" + user)\n"; + + // --------------------------------------------------------------- + // Test 1: Full annotation workflow — discover → annotate → query + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("agent", AgentRole::Refactor); + + rpc(state, "agent", "openFile", + {{"path", "app.py"}, {"content", src}}); + + // Step 1: Discover unannotated functions + json unannotated = rpc(state, "agent", "getUnannotatedNodes", + {{"includeHints", true}}); + int initialCount = unannotated["result"].value("count", 0); + + // Step 2: Get function IDs + std::string validateId, transformId, notifyId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function") { + std::string n = getNodeName(c); + if (n == "validate") validateId = c->id; + if (n == "transform") transformId = c->id; + if (n == "notify") notifyId = c->id; + } + } + + // Step 3: Annotate all 3 functions with intent + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", validateId}, {"type", "intent"}, + {"fields", {{"summary", "validates input data"}, + {"category", "validation"}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", transformId}, {"type", "intent"}, + {"fields", {{"summary", "doubles each item"}, + {"category", "transformation"}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", notifyId}, {"type", "intent"}, + {"fields", {{"summary", "prints greeting"}, + {"category", "io"}}}}); + + // Step 4: Query — all should be annotated now + json afterAnnotate = rpc(state, "agent", "getUnannotatedNodes"); + int afterCount = afterAnnotate["result"].value("count", 0); + + // Step 5: Query all annotations project-wide + json allAnnotations = rpc(state, "agent", "getSemanticAnnotations"); + int nodeCount = (int)allAnnotations["result"]["nodes"].size(); + + expect(initialCount == 3 && afterCount == 0 && nodeCount == 3, + "Full workflow: discover " + std::to_string(initialCount) + + " → annotate → " + std::to_string(afterCount) + + " unannotated, " + std::to_string(nodeCount) + " annotated nodes", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: Remove and re-query workflow + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("agent", AgentRole::Refactor); + + rpc(state, "agent", "openFile", + {{"path", "app.py"}, {"content", src}}); + + std::string validateId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && getNodeName(c) == "validate") { + validateId = c->id; + break; + } + } + + // Add intent + risk + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", validateId}, {"type", "intent"}, + {"fields", {{"summary", "validates"}, {"category", "validation"}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", validateId}, {"type", "risk"}, + {"fields", {{"level", "medium"}, {"reason", "multiple callers"}, + {"dependentCount", 5}}}}); + + // Verify 2 annotations + json before = rpc(state, "agent", "getSemanticAnnotations", + {{"nodeId", validateId}}); + int beforeCount = (int)before["result"]["annotations"].size(); + + // Remove risk + rpc(state, "agent", "removeSemanticAnnotation", + {{"nodeId", validateId}, {"type", "risk"}}); + + // Re-query: should have 1 + json after = rpc(state, "agent", "getSemanticAnnotations", + {{"nodeId", validateId}}); + int afterCount = (int)after["result"]["annotations"].size(); + + // getUnannotatedNodes for risk should include validate now + json unanRisk = rpc(state, "agent", "getUnannotatedNodes", + {{"type", "risk"}}); + bool validateMissingRisk = false; + for (const auto& n : unanRisk["result"]["nodes"]) { + if (n.value("name", "") == "validate") + validateMissingRisk = true; + } + + expect(beforeCount == 2 && afterCount == 1 && validateMissingRisk, + "Remove risk → re-query shows 2→1, validate needs risk again", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: Annotation session with sidecar save/load roundtrip + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("agent", AgentRole::Refactor); + + rpc(state, "agent", "openFile", + {{"path", "roundtrip.py"}, {"content", src}}); + + // Annotate via RPC + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType != "Function") continue; + std::string name = getNodeName(c); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", c->id}, {"type", "intent"}, + {"fields", {{"summary", name + " function"}, + {"category", "general"}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", c->id}, {"type", "complexity"}, + {"fields", {{"timeComplexity", "O(n)"}, + {"cognitiveComplexity", 3}, + {"linesOfLogic", 4}}}}); + } + + // Save sidecar + json saveResp = rpc(state, "agent", "saveAnnotatedAST", + {{"path", "roundtrip.py"}}); + int savedCount = saveResp["result"].value("annotationCount", 0); + + // Load into fresh state + HeadlessEditorState state2; + state2.defaultLanguage = "python"; + state2.workspaceRoot = tmpDir.string(); + state2.setAgentRole("agent", AgentRole::Refactor); + rpc(state2, "agent", "openFile", + {{"path", "roundtrip.py"}, {"content", src}}); + rpc(state2, "agent", "loadAnnotatedAST", {{"path", "roundtrip.py"}}); + + // Query via RPC in new state + json allAnnotations = rpc(state2, "agent", "getSemanticAnnotations"); + int restoredNodes = (int)allAnnotations["result"]["nodes"].size(); + + expect(savedCount >= 6 && restoredNodes == 3, + "Sidecar roundtrip: saved " + std::to_string(savedCount) + + " annotations, restored " + std::to_string(restoredNodes) + + " annotated nodes", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: Permission enforcement across the pipeline + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("admin", AgentRole::Refactor); + state.setAgentRole("reader", AgentRole::Linter); + + rpc(state, "admin", "openFile", + {{"path", "perms.py"}, {"content", src}}); + + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function") { + fnId = c->id; + break; + } + } + + // Admin can set annotation + json setResp = rpc(state, "admin", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}, + {"fields", {{"summary", "test"}, {"category", "io"}}}}); + bool adminSetOk = setResp.contains("result"); + + // Reader cannot set + json readerSet = rpc(state, "reader", "setSemanticAnnotation", + {{"nodeId", fnId}, {"type", "risk"}, + {"fields", {{"level", "low"}, {"reason", "test"}, + {"dependentCount", 0}}}}); + bool readerSetDenied = readerSet.contains("error"); + + // Reader cannot remove + json readerRemove = rpc(state, "reader", "removeSemanticAnnotation", + {{"nodeId", fnId}, {"type", "intent"}}); + bool readerRemoveDenied = readerRemove.contains("error"); + + // Reader CAN query + json readerGet = rpc(state, "reader", "getSemanticAnnotations", + {{"nodeId", fnId}}); + bool readerGetOk = readerGet.contains("result"); + + // Reader CAN get unannotated + json readerUnan = rpc(state, "reader", "getUnannotatedNodes"); + bool readerUnanOk = readerUnan.contains("result"); + + expect(adminSetOk && readerSetDenied && readerRemoveDenied && + readerGetOk && readerUnanOk, + "Linter: denied set/remove, allowed get/getUnannotated", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: Hint accuracy — sideEffectHint for io function + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("agent", AgentRole::Refactor); + + rpc(state, "agent", "openFile", + {{"path", "hints.py"}, {"content", src}}); + + json resp = rpc(state, "agent", "getUnannotatedNodes", + {{"includeHints", true}}); + + // notify() calls print(), so sideEffectHint should be "possible" + std::string notifyHint; + // validate() is pure, so sideEffectHint should be "none" + std::string validateHint; + for (const auto& n : resp["result"]["nodes"]) { + if (n.value("name", "") == "notify" && n.contains("hints")) + notifyHint = n["hints"].value("sideEffectHint", ""); + if (n.value("name", "") == "validate" && n.contains("hints")) + validateHint = n["hints"].value("sideEffectHint", ""); + } + + expect(notifyHint == "possible" && validateHint == "none", + "Hint accuracy: notify='" + notifyHint + + "' validate='" + validateHint + "'", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: Prompt template content validates workflow steps + // --------------------------------------------------------------- + { + MCPServer mcp; + + // Each annotation prompt should have non-empty messages + std::vector promptNames = { + "annotate_intent", "annotate_complexity", + "annotate_risk", "annotate_contracts", "annotate_full" + }; + + int validPrompts = 0; + for (const auto& pname : promptNames) { + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "prompts/get"}, + {"params", {{"name", pname}, + {"arguments", json::object()}}}}; + json resp = mcp.handleRequest(req); + if (resp.contains("result") && + resp["result"].contains("messages") && + !resp["result"]["messages"].empty()) { + std::string text = resp["result"]["messages"][0] + ["content"].value("text", ""); + if (text.size() > 50) ++validPrompts; + } + } + + expect(validPrompts == 5, + "All 5 prompt templates return substantive instructions (" + + std::to_string(validPrompts) + "/5)", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: Multi-type annotation then compact AST verification + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("agent", AgentRole::Refactor); + + rpc(state, "agent", "openFile", + {{"path", "compact.py"}, {"content", src}}); + + std::string transformId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "transform") { + transformId = c->id; + break; + } + } + + // Set 3 annotation types via RPC + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", transformId}, {"type", "intent"}, + {"fields", {{"summary", "doubles items"}, + {"category", "transformation"}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", transformId}, {"type", "complexity"}, + {"fields", {{"timeComplexity", "O(n)"}, + {"cognitiveComplexity", 2}, + {"linesOfLogic", 4}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", transformId}, {"type", "tags"}, + {"fields", {{"tags", json::array({"@collection", "@pure"})}}}}); + + // Verify compact AST shows all 3 in semantic field + json resp = rpc(state, "agent", "getAST", {{"compact", true}}); + json nodes = resp["result"]["nodes"]; + + bool allPresent = false; + for (const auto& n : nodes) { + if (n.value("name", "") == "transform" && + n.contains("semantic")) { + auto sem = n["semantic"]; + allPresent = sem.contains("intent") && + sem.contains("complexity") && + sem.contains("tags"); + } + } + + expect(allPresent, + "Compact AST reflects 3 annotation types set via RPC", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: End-to-end: RPC annotate → save → load → RPC query + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("agent", AgentRole::Refactor); + + rpc(state, "agent", "openFile", + {{"path", "e2e.py"}, {"content", src}}); + + // Annotate validate with all 5 types via RPC + std::string validateId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "validate") { + validateId = c->id; + break; + } + } + + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", validateId}, {"type", "intent"}, + {"fields", {{"summary", "validates input"}, + {"category", "validation"}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", validateId}, {"type", "complexity"}, + {"fields", {{"timeComplexity", "O(1)"}, + {"cognitiveComplexity", 2}, + {"linesOfLogic", 3}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", validateId}, {"type", "risk"}, + {"fields", {{"level", "high"}, {"reason", "12 callers"}, + {"dependentCount", 12}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", validateId}, {"type", "contract"}, + {"fields", {{"preconditions", "data not null"}, + {"postconditions", "returns bool"}, + {"returnShape", "bool"}, + {"sideEffects", "none"}}}}); + rpc(state, "agent", "setSemanticAnnotation", + {{"nodeId", validateId}, {"type", "tags"}, + {"fields", {{"tags", json::array({"@validation", "@guard"})}}}}); + + // Save sidecar + rpc(state, "agent", "saveAnnotatedAST", {{"path", "e2e.py"}}); + + // Load into fresh state and query via RPC + HeadlessEditorState state2; + state2.defaultLanguage = "python"; + state2.workspaceRoot = tmpDir.string(); + state2.setAgentRole("agent", AgentRole::Refactor); + rpc(state2, "agent", "openFile", + {{"path", "e2e.py"}, {"content", src}}); + rpc(state2, "agent", "loadAnnotatedAST", {{"path", "e2e.py"}}); + + // Find validate in new state + std::string newValidateId; + for (auto* c : state2.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "validate") { + newValidateId = c->id; + break; + } + } + + json resp = rpc(state2, "agent", "getSemanticAnnotations", + {{"nodeId", newValidateId}}); + int annoCount = (int)resp["result"]["annotations"].size(); + + // Check all 5 types present + bool hasI = false, hasCx = false, hasR = false, + hasCt = false, hasT = false; + for (const auto& a : resp["result"]["annotations"]) { + std::string t = a.value("type", ""); + if (t == "intent") hasI = true; + if (t == "complexity") hasCx = true; + if (t == "risk") hasR = true; + if (t == "contract") hasCt = true; + if (t == "tags") hasT = true; + } + + expect(annoCount == 5 && hasI && hasCx && hasR && hasCt && hasT, + "E2E: RPC annotate 5 types → save → load → RPC query (" + + std::to_string(annoCount) + "/5)", + passed, failed); + } + + // Cleanup + std::filesystem::remove_all(tmpDir); + + std::cout << "\n=== Step 271 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +}