diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 128d924..53eff46 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1554,4 +1554,15 @@ target_link_libraries(step261_test PRIVATE tree_sitter_java tree_sitter_rust tree_sitter_go tree_sitter_org) +# Step 262: Phase 9d multi-file project integration tests +add_executable(step262_test tests/step262_test.cpp) +target_include_directories(step262_test PRIVATE src) +target_link_libraries(step262_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/tests/step262_test.cpp b/editor/tests/step262_test.cpp new file mode 100644 index 0000000..daf4c6c --- /dev/null +++ b/editor/tests/step262_test.cpp @@ -0,0 +1,402 @@ +// Step 262 TDD Test: Phase 9d Multi-File Project Integration Tests +// +// End-to-end integration tests exercising the full multi-file project +// workflow: open files, switch buffers, cross-file symbols, import graph, +// project-wide diagnostics, project-wide search, cross-file rename, +// and batch queries across multiple buffers. +// +// Follows the Phase closer pattern from Steps 249, 253, 257. +#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; + + // Shared test sources — a 3-file Python project + std::string srcUtils = + "def clamp(value, lo, hi):\n" + " if value < lo:\n" + " return lo\n" + " if value > hi:\n" + " return hi\n" + " return value\n\n" + "def double(x):\n" + " return x * 2\n"; + + std::string srcMath = + "import utils\n\n" + "def transform(data):\n" + " return clamp(double(data), 0, 100)\n"; + + std::string srcMain = + "import utils\n" + "import math_ops\n\n" + "def run(input):\n" + " return transform(clamp(input, 1, 50))\n"; + + // --------------------------------------------------------------- + // Test 1: Open 3 files, listBuffers shows all with correct state + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "utils.py"}, {"content", srcUtils}}); + rpc(state, "s1", "openFile", + {{"path", "math_ops.py"}, {"content", srcMath}}); + rpc(state, "s1", "openFile", + {{"path", "main.py"}, {"content", srcMain}}); + + json resp = rpc(state, "s1", "listBuffers"); + int count = resp["result"].value("count", 0); + bool allPresent = false; + if (count == 3) { + std::set paths; + for (const auto& b : resp["result"]["buffers"]) + paths.insert(b.value("path", "")); + allPresent = paths.count("utils.py") && + paths.count("math_ops.py") && + paths.count("main.py"); + } + expect(count == 3 && allPresent, + "3 files open, all present in listBuffers", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: setActiveBuffer switches context, getAST returns + // correct module for each buffer + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "utils.py"}, {"content", srcUtils}}); + rpc(state, "s1", "openFile", + {{"path", "math_ops.py"}, {"content", srcMath}}); + + rpc(state, "s1", "setActiveBuffer", {{"path", "utils.py"}}); + json astU = rpc(state, "s1", "getAST", {{"compact", true}}); + // utils.py has clamp and double + bool hasClamp = false; + for (const auto& n : astU["result"]["nodes"]) { + if (n.value("name", "") == "clamp") hasClamp = true; + } + + rpc(state, "s1", "setActiveBuffer", {{"path", "math_ops.py"}}); + json astM = rpc(state, "s1", "getAST", {{"compact", true}}); + bool hasTransform = false; + for (const auto& n : astM["result"]["nodes"]) { + if (n.value("name", "") == "transform") hasTransform = true; + } + + expect(hasClamp && hasTransform, + "Switching buffers returns correct AST per file", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: Cross-file symbol resolution — crossFile=true includes + // exports from other open buffers + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "utils.py"}, {"content", srcUtils}}); + rpc(state, "s1", "openFile", + {{"path", "math_ops.py"}, {"content", srcMath}}); + rpc(state, "s1", "setActiveBuffer", {{"path", "math_ops.py"}}); + + // Get the module node ID for math_ops + std::string moduleId; + Module* ast = state.activeAST(); + if (ast) moduleId = ast->id; + + // Local scope only + json localResp = rpc(state, "s1", "getInScopeSymbols", + {{"nodeId", moduleId}}); + int localCount = localResp["result"].value("count", 0); + + // Cross-file scope + json crossResp = rpc(state, "s1", "getInScopeSymbols", + {{"nodeId", moduleId}, + {"crossFile", true}}); + int crossCount = crossResp["result"].value("count", 0); + bool crossHasFile = false; + for (const auto& s : crossResp["result"]["symbols"]) { + if (s.contains("file") && + s.value("file", "") == "utils.py") { + crossHasFile = true; + break; + } + } + + expect(crossCount > localCount && crossHasFile, + "Cross-file scope includes utils.py exports (" + + std::to_string(crossCount) + " > " + + std::to_string(localCount) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: Import graph tracks which files import which + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "utils.py"}, {"content", srcUtils}}); + rpc(state, "s1", "openFile", + {{"path", "math_ops.py"}, {"content", srcMath}}); + rpc(state, "s1", "openFile", + {{"path", "main.py"}, {"content", srcMain}}); + + // Check full import graph + json graphResp = rpc(state, "s1", "getImportGraph"); + int fileCount = graphResp["result"].value("fileCount", 0); + + // Check per-file imports for main.py + json mainGraph = rpc(state, "s1", "getImportGraph", + {{"file", "main.py"}}); + bool importsUtils = false; + bool importsMath = false; + for (const auto& m : mainGraph["result"]["imports"]) { + if (m == "utils") importsUtils = true; + if (m == "math_ops") importsMath = true; + } + + expect(fileCount >= 2 && importsUtils && importsMath, + "Import graph: main.py imports utils + math_ops, " + + std::to_string(fileCount) + " files tracked", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: Project-wide diagnostics — cross-file E0400 for + // undefined import, no E0400 for known imports + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + // Open utils and math_ops but NOT main + // So math_ops imports "utils" which IS open — no error + rpc(state, "s1", "openFile", + {{"path", "utils.py"}, {"content", srcUtils}}); + rpc(state, "s1", "openFile", + {{"path", "math_ops.py"}, {"content", srcMath}}); + + json resp = rpc(state, "s1", "getProjectDiagnostics"); + int totalDiags = resp["result"].value("totalDiagnostics", 0); + + // Now open a file that imports a module NOT open + std::string srcBad = + "import nonexistent_module\n\n" + "def broken():\n" + " return 1\n"; + rpc(state, "s1", "openFile", + {{"path", "bad.py"}, {"content", srcBad}}); + + json resp2 = rpc(state, "s1", "getProjectDiagnostics"); + int totalDiags2 = resp2["result"].value("totalDiagnostics", 0); + + // bad.py should have at least one cross-file diagnostic + bool badHasDiag = resp2["result"]["files"].contains("bad.py"); + + expect(totalDiags2 > totalDiags && badHasDiag, + "Undefined import in bad.py raises cross-file diagnostic (" + + std::to_string(totalDiags2) + " > " + + std::to_string(totalDiags) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: searchProject finds symbol across all 3 files + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "utils.py"}, {"content", srcUtils}}); + rpc(state, "s1", "openFile", + {{"path", "math_ops.py"}, {"content", srcMath}}); + rpc(state, "s1", "openFile", + {{"path", "main.py"}, {"content", srcMain}}); + + json resp = rpc(state, "s1", "searchProject", + {{"name", "clamp"}}); + int count = resp["result"].value("count", 0); + int fileCount = resp["result"].value("fileCount", 0); + + // clamp: defined in utils.py, called in math_ops.py and main.py + std::set files; + bool hasDef = false; + bool hasCall = false; + for (const auto& ref : resp["result"]["references"]) { + files.insert(ref.value("file", "")); + if (ref.value("kind", "") == "definition") hasDef = true; + if (ref.value("kind", "") == "call") hasCall = true; + } + + expect(count >= 3 && fileCount >= 2 && hasDef && hasCall, + "searchProject 'clamp': " + std::to_string(count) + + " refs across " + std::to_string(fileCount) + + " files, has definition + call", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: renameSymbol across files — preview then apply + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "utils.py"}, {"content", srcUtils}}); + rpc(state, "s1", "openFile", + {{"path", "math_ops.py"}, {"content", srcMath}}); + rpc(state, "s1", "openFile", + {{"path", "main.py"}, {"content", srcMain}}); + + // Preview first + json preview = rpc(state, "s1", "renameSymbol", + {{"oldName", "clamp"}, + {"newName", "constrain"}, + {"preview", true}}); + int previewCount = preview["result"].value("changeCount", 0); + bool isPreview = preview["result"].value("preview", false); + + // Apply the rename + json apply = rpc(state, "s1", "renameSymbol", + {{"oldName", "clamp"}, + {"newName", "constrain"}}); + int applied = apply["result"].value("applied", 0); + int fileCount = apply["result"].value("fileCount", 0); + + // Verify: search for new name finds references + json search = rpc(state, "s1", "searchProject", + {{"name", "constrain"}}); + int newCount = search["result"].value("count", 0); + + // Verify: search for old name finds nothing + json oldSearch = rpc(state, "s1", "searchProject", + {{"name", "clamp"}}); + int oldCount = oldSearch["result"].value("count", 0); + + expect(isPreview && previewCount >= 3 && + applied >= 3 && fileCount >= 2 && + newCount >= 3 && oldCount == 0, + "Rename clamp→constrain: preview=" + + std::to_string(previewCount) + ", applied=" + + std::to_string(applied) + " across " + + std::to_string(fileCount) + " files, verified", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: Full agent workflow — open, diagnose, search, rename, + // re-diagnose, verify via batch query + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + // Step 1: Open project files + rpc(state, "s1", "openFile", + {{"path", "utils.py"}, {"content", srcUtils}}); + rpc(state, "s1", "openFile", + {{"path", "math_ops.py"}, {"content", srcMath}}); + + // Step 2: Batch query — diagnostics + search + buffers in one call + json batchResp = rpc(state, "s1", "batchQuery", + {{"queries", json::array({ + {{"method", "getProjectDiagnostics"}}, + {{"method", "searchProject"}, + {"params", {{"name", "double"}}}}, + {{"method", "listBuffers"}} + })}}); + + int batchCount = batchResp["result"].value("count", 0); + bool allSucceeded = true; + for (const auto& r : batchResp["result"]["results"]) { + if (r.contains("error")) allSucceeded = false; + } + + // Step 3: Rename "double" across project + rpc(state, "s1", "renameSymbol", + {{"oldName", "double"}, {"newName", "multiply_by_two"}}); + + // Step 4: Verify via batch — new name found, old name gone + json verifyBatch = rpc(state, "s1", "batchQuery", + {{"queries", json::array({ + {{"method", "searchProject"}, + {"params", {{"name", "multiply_by_two"}}}}, + {{"method", "searchProject"}, + {"params", {{"name", "double"}}}}, + {{"method", "getProjectDiagnostics"}} + })}}); + + int newRefs = 0; + int oldRefs = 0; + if (verifyBatch["result"]["results"].size() >= 2) { + auto& r0 = verifyBatch["result"]["results"][0]; + auto& r1 = verifyBatch["result"]["results"][1]; + if (r0.contains("result")) + newRefs = r0["result"].value("count", 0); + if (r1.contains("result")) + oldRefs = r1["result"].value("count", 0); + } + + expect(batchCount == 3 && allSucceeded && + newRefs >= 2 && oldRefs == 0, + "Full workflow: batch(3)→rename→verify: new=" + + std::to_string(newRefs) + " old=" + + std::to_string(oldRefs), + passed, failed); + } + + std::cout << "\n=== Step 262 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index 6513450..da3ddc4 100644 --- a/progress.md +++ b/progress.md @@ -560,3 +560,30 @@ Variable declarations, VariableReferences, and Parameters. - Buffers marked as modified after rename for save-tracking - Search returns kind field (definition/call/reference/parameter) to categorize - 30 MCP tools total + +### Step 262: Phase 9d Multi-File Project Integration Tests +**Status:** PASS (8/8 tests) + +End-to-end integration tests exercising the full multi-file project workflow +across 3-file Python projects. Simulates a realistic agent session from +file opening through cross-file analysis, search, rename, and batch queries. + +**Files created:** +- `editor/tests/step262_test.cpp` — 8 integration test cases: + 1. Open 3 files, listBuffers shows all with correct state + 2. setActiveBuffer switches context, getAST returns correct module + 3. Cross-file symbol resolution (crossFile=true includes other buffers' exports) + 4. Import graph tracks which files import which (main.py→utils+math_ops) + 5. Project-wide diagnostics: undefined import raises cross-file E0400 + 6. searchProject finds symbol across 3 files (definition + call kinds) + 7. renameSymbol: preview then apply across 3 files, verified both directions + 8. Full workflow: open→batch(diagnostics+search+buffers)→rename→batch verify + +**Files modified:** +- `editor/CMakeLists.txt` — step262_test target + +**Key results:** +- Phase 9d complete: all 5 steps pass (56/56 tests across steps 258–262) +- Full multi-file project workflow validated end-to-end +- 30 MCP tools, cross-file symbols, import graph, project diagnostics, search, rename +- Sprint 9 complete: all 4 phases pass (212/212 tests across steps 245–262)