From f4f3633b11e64c3eebb6f883ef213fb7af584563 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 24 Feb 2026 11:13:31 -0700 Subject: [PATCH] Add semantic hash table tools, lock scaffolding, and H2 dual-hash migration scaffold --- editor/src/ASTMutationAPI.h | 30 +++- editor/src/AgentPermissionPolicy.h | 5 + editor/src/CompactAST.h | 6 + editor/src/SemanticHash.h | 105 ++++++++++++++ editor/src/SemanticHashTable.h | 183 ++++++++++++++++++++++++ editor/src/SidecarPersistence.h | 11 ++ editor/src/ast/ASTNode.h | 7 + editor/src/ast/Serialization.h | 16 +++ editor/src/headless_rpc/DispatchPart1.h | 38 +++++ editor/src/headless_rpc/DispatchPart4.h | 116 ++++++++++++++- editor/src/mcp/RegisterSidecarTools.h | 71 +++++++++ editor/tests/step295_test.cpp | 83 ++++++++++- 12 files changed, 665 insertions(+), 6 deletions(-) create mode 100644 editor/src/SemanticHash.h create mode 100644 editor/src/SemanticHashTable.h diff --git a/editor/src/ASTMutationAPI.h b/editor/src/ASTMutationAPI.h index d584748..29f5ddd 100644 --- a/editor/src/ASTMutationAPI.h +++ b/editor/src/ASTMutationAPI.h @@ -150,6 +150,27 @@ public: std::vector getJournalEntries() const { return journal_; } void clearJournal() { journal_.clear(); } + static const ASTNode* findSemanticHashLock(const ASTNode* node) { + const ASTNode* cur = node; + while (cur) { + if (cur->semanticHashLockState == "locked") { + return cur; + } + cur = cur->parent; + } + return nullptr; + } + + static std::string semanticHashLockWarning(const ASTNode* node) { + const ASTNode* locked = findSemanticHashLock(node); + if (!locked) return ""; + std::string reason = locked->semanticHashLockReason.empty() + ? "no reason provided" + : locked->semanticHashLockReason; + std::string hash = locked->semanticHash.empty() ? "" : locked->semanticHash; + return "Node is under SemanticHashLock (" + hash + ", reason: " + reason + ")"; + } + private: // Recursive node lookup ASTNode* findById(ASTNode* node, const std::string& nodeId) const { @@ -165,18 +186,21 @@ private: // Walk up the ancestor chain looking for an OptimizationLock annotation. // Returns a warning string if locked, empty string otherwise. std::string checkLock(const ASTNode* node) const { + std::string hashLockWarning = semanticHashLockWarning(node); const ASTNode* cur = node; while (cur) { for (const auto* anno : cur->getChildren("annotations")) { if (anno->conceptType == "OptimizationLock") { auto* lock = static_cast(anno); - return "Node is under OptimizationLock (locked by " + - lock->lockedBy + ": " + lock->lockReason + ")"; + std::string optWarn = "Node is under OptimizationLock (locked by " + + lock->lockedBy + ": " + lock->lockReason + ")"; + if (!hashLockWarning.empty()) return hashLockWarning + "; " + optWarn; + return optWarn; } } cur = cur->parent; } - return ""; + return hashLockWarning; } // Apply a string property to a node. Returns false if the property diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h index e4972e3..a8063a5 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -59,6 +59,9 @@ struct AgentPermissionPolicy { method == "searchProject" || method == "loadAnnotatedAST" || method == "listAnnotatedFiles" || + method == "getSemanticHashTable" || + method == "listSemanticHashTables" || + method == "getSemanticHashLock" || method == "getSemanticAnnotations" || method == "getUnannotatedNodes" || method == "getEnvironment" || @@ -100,6 +103,8 @@ struct AgentPermissionPolicy { method == "undo" || method == "redo" || method == "saveAnnotatedAST" || + method == "saveSemanticHashTable" || + method == "setSemanticHashLock" || method == "setSemanticAnnotation" || method == "removeSemanticAnnotation" || method == "setEnvironment" || diff --git a/editor/src/CompactAST.h b/editor/src/CompactAST.h index 75d8176..e696ca1 100644 --- a/editor/src/CompactAST.h +++ b/editor/src/CompactAST.h @@ -469,6 +469,8 @@ inline json toJsonCompact(const ASTNode* node) { json j; j["id"] = node->id; j["type"] = node->conceptType; + if (!node->semanticHash.empty()) j["semanticHash"] = node->semanticHash; + if (node->semanticHashLockState == "locked") j["semanticHashLockState"] = "locked"; std::string name = getNodeName(node); if (!name.empty()) j["name"] = name; if (node->hasSpan()) j["line"] = node->spanStartLine; @@ -498,6 +500,8 @@ inline json toJsonCompactSummary(const ASTNode* root) { json rootJ; rootJ["id"] = root->id; rootJ["type"] = root->conceptType; + if (!root->semanticHash.empty()) rootJ["semanticHash"] = root->semanticHash; + if (root->semanticHashLockState == "locked") rootJ["semanticHashLockState"] = "locked"; std::string rname = getNodeName(root); if (!rname.empty()) rootJ["name"] = rname; if (root->hasSpan()) rootJ["line"] = root->spanStartLine; @@ -509,6 +513,8 @@ inline json toJsonCompactSummary(const ASTNode* root) { json cj; cj["id"] = child->id; cj["type"] = child->conceptType; + if (!child->semanticHash.empty()) cj["semanticHash"] = child->semanticHash; + if (child->semanticHashLockState == "locked") cj["semanticHashLockState"] = "locked"; std::string cname = getNodeName(child); if (!cname.empty()) cj["name"] = cname; if (child->hasSpan()) cj["line"] = child->spanStartLine; diff --git a/editor/src/SemanticHash.h b/editor/src/SemanticHash.h new file mode 100644 index 0000000..689387c --- /dev/null +++ b/editor/src/SemanticHash.h @@ -0,0 +1,105 @@ +#pragma once +// Step 1546: Semantic hash primitives and deterministic hash computation. + +#include "ast/ASTNode.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +inline std::string semanticIrNodeKindForConcept(const std::string& conceptType) { + if (conceptType == "Module") return "Module"; + if (conceptType == "Function" || conceptType == "MethodDeclaration" || + conceptType == "AsyncFunction") return "Function"; + if (conceptType == "ClassDeclaration" || conceptType == "InterfaceDeclaration" || + conceptType == "TypeAlias" || conceptType == "GenericType" || + conceptType == "TypeParameter" || conceptType == "PrimitiveType" || + conceptType == "CustomType") return "Type"; + if (conceptType == "HostCall" || conceptType == "ScheduleTask" || + conceptType == "ModuleLoad") return "Effect"; + return "Node"; +} + +inline void collectSemanticPrimitivePaths(const json& value, + const std::string& prefix, + std::set& out) { + if (value.is_object()) { + for (auto it = value.begin(); it != value.end(); ++it) { + const std::string key = prefix.empty() ? it.key() : (prefix + "." + it.key()); + collectSemanticPrimitivePaths(it.value(), key, out); + } + return; + } + if (value.is_array()) { + if (!prefix.empty()) out.insert(prefix + "[]"); + return; + } + if (!prefix.empty()) out.insert(prefix); +} + +inline std::vector semanticPrimitivePaths(const json& semanticSummary) { + std::set unique; + collectSemanticPrimitivePaths(semanticSummary, "", unique); + return std::vector(unique.begin(), unique.end()); +} + +inline uint64_t fnv1a64(const std::string& input) { + constexpr uint64_t kOffset = 14695981039346656037ull; + constexpr uint64_t kPrime = 1099511628211ull; + uint64_t hash = kOffset; + for (unsigned char c : input) { + hash ^= static_cast(c); + hash *= kPrime; + } + return hash; +} + +inline std::string formatSemanticHashV1(uint64_t value) { + std::ostringstream oss; + oss << "H1:" << std::uppercase << std::hex << std::setw(16) << std::setfill('0') << value; + return oss.str(); +} + +inline json semanticHashPreimage(const ASTNode* node, const json& semanticSummary) { + json preimage = json::object(); + preimage["concept"] = node ? node->conceptType : "Unknown"; + preimage["irNodeKind"] = node ? semanticIrNodeKindForConcept(node->conceptType) : "Node"; + preimage["semantic"] = semanticSummary.is_object() ? semanticSummary : json::object(); + preimage["primitives"] = semanticPrimitivePaths(preimage["semantic"]); + return preimage; +} + +inline std::string computeSemanticHashV1(const ASTNode* node, const json& semanticSummary) { + const json preimage = semanticHashPreimage(node, semanticSummary); + return formatSemanticHashV1(fnv1a64(preimage.dump())); +} + +inline json semanticHashPreimageV2(const ASTNode* node, const json& semanticSummary) { + json preimage = semanticHashPreimage(node, semanticSummary); + preimage["hashSchemaVersion"] = 2; + preimage["hashProfile"] = "semantic-contract-v2"; + return preimage; +} + +inline std::string computeSemanticHashV2(const ASTNode* node, const json& semanticSummary) { + const json preimage = semanticHashPreimageV2(node, semanticSummary); + return "H2:" + formatSemanticHashV1(fnv1a64(preimage.dump())).substr(3); +} + +struct SemanticHashDigests { + std::string h1; + std::string h2; +}; + +inline SemanticHashDigests computeSemanticHashDigests(const ASTNode* node, const json& semanticSummary) { + SemanticHashDigests out; + out.h1 = computeSemanticHashV1(node, semanticSummary); + out.h2 = computeSemanticHashV2(node, semanticSummary); + return out; +} diff --git a/editor/src/SemanticHashTable.h b/editor/src/SemanticHashTable.h new file mode 100644 index 0000000..f865afa --- /dev/null +++ b/editor/src/SemanticHashTable.h @@ -0,0 +1,183 @@ +#pragma once +// Step 1546: Semantic hash sidecar table persistence. + +#include "ast/ASTNode.h" +#include "CompactAST.h" +#include "SemanticHash.h" +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +struct SemanticHashTableSaveResult { + bool success = false; + std::string path; + int entryCount = 0; + std::string error; +}; + +struct SemanticHashTableLoadResult { + bool success = false; + std::string path; + json table = json::object(); + std::string error; +}; + +inline std::string semanticHashTablePath(const std::string& workspaceRoot, + const std::string& filePath) { + namespace fs = std::filesystem; + fs::path base = fs::path(workspaceRoot) / ".whetstone"; + return (base / (filePath + ".semantic_hash.json")).string(); +} + +inline bool shouldPersistSemanticHashEntry(const ASTNode* node, const json& semanticSummary) { + if (!node) return false; + if (!semanticSummary.empty()) return true; + return node->conceptType == "Module" || + node->conceptType == "Function" || + node->conceptType == "MethodDeclaration" || + node->conceptType == "AsyncFunction" || + node->conceptType == "ClassDeclaration" || + node->conceptType == "InterfaceDeclaration" || + node->conceptType == "Variable" || + node->conceptType == "Parameter"; +} + +inline void collectSemanticHashEntries(ASTNode* node, + json& entries, + std::set& primitiveCatalog, + int& v2MismatchCount) { + if (!node) return; + + const json sem = extractSemanticSummary(node); + const SemanticHashDigests digests = computeSemanticHashDigests(node, sem); + node->semanticHash = digests.h1; // Active hash remains H1 during migration scaffold. + + if (shouldPersistSemanticHashEntry(node, sem)) { + const std::vector primitives = semanticPrimitivePaths(sem); + for (const auto& p : primitives) primitiveCatalog.insert(p); + if (digests.h1 != digests.h2) ++v2MismatchCount; + entries.push_back({ + {"nodeId", node->id}, + {"concept", node->conceptType}, + {"irNodeKind", semanticIrNodeKindForConcept(node->conceptType)}, + {"name", getNodeName(node)}, + {"semanticHash", digests.h1}, + {"semanticHashH1", digests.h1}, + {"semanticHashH2", digests.h2}, + {"primitives", primitives}, + {"semantic", sem.is_object() ? sem : json::object()} + }); + } + + for (auto* child : node->allChildren()) { + collectSemanticHashEntries(child, entries, primitiveCatalog, v2MismatchCount); + } +} + +inline SemanticHashTableSaveResult saveSemanticHashTable(const std::string& workspaceRoot, + const std::string& filePath, + Module* ast) { + SemanticHashTableSaveResult result; + if (!ast) { + result.error = "No AST to save"; + return result; + } + + result.path = semanticHashTablePath(workspaceRoot, filePath); + namespace fs = std::filesystem; + fs::create_directories(fs::path(result.path).parent_path()); + + json entries = json::array(); + std::set primitiveCatalog; + int v2MismatchCount = 0; + collectSemanticHashEntries(ast, entries, primitiveCatalog, v2MismatchCount); + result.entryCount = static_cast(entries.size()); + + json table = { + {"version", "H1"}, + {"activeVersion", "H1"}, + {"dualEmitVersions", {"H1", "H2"}}, + {"hashEncoding", { + {"algorithm", "fnv1a64"}, + {"digestByteOrder", "big-endian"}, + {"digestFormat", "hex16"}, + {"inputEncoding", "utf-8"} + }}, + {"primitiveCatalogVersion", 1}, + {"file", filePath}, + {"migration", { + {"h2ScaffoldEnabled", true}, + {"entriesCompared", static_cast(entries.size())}, + {"h1h2MismatchCount", v2MismatchCount} + }}, + {"primitiveCatalog", std::vector(primitiveCatalog.begin(), primitiveCatalog.end())}, + {"entries", entries} + }; + + std::ofstream out(result.path); + if (!out.is_open()) { + result.error = "Cannot write to " + result.path; + return result; + } + out << table.dump(2); + out.close(); + + result.success = true; + return result; +} + +inline SemanticHashTableLoadResult loadSemanticHashTable(const std::string& workspaceRoot, + const std::string& filePath) { + SemanticHashTableLoadResult result; + result.path = semanticHashTablePath(workspaceRoot, filePath); + + std::ifstream in(result.path); + if (!in.is_open()) { + result.error = "No semantic hash table: " + result.path; + return result; + } + + try { + in >> result.table; + } catch (const std::exception& e) { + result.error = std::string("Failed to parse semantic hash table: ") + e.what(); + return result; + } + + result.success = true; + return result; +} + +inline std::vector listSemanticHashTables(const std::string& workspaceRoot) { + namespace fs = std::filesystem; + std::vector files; + fs::path dir = fs::path(workspaceRoot) / ".whetstone"; + if (!fs::exists(dir)) return files; + + const std::string suffix = ".semantic_hash.json"; + for (const auto& entry : fs::recursive_directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + std::string full = entry.path().string(); + if (full.size() <= suffix.size()) continue; + if (full.substr(full.size() - suffix.size()) != suffix) continue; + + std::error_code ec; + fs::path rel = fs::relative(entry.path(), dir, ec); + if (ec) { + files.push_back(entry.path().filename().string()); + } else { + std::string relStr = rel.generic_string(); + files.push_back(relStr.substr(0, relStr.size() - suffix.size())); + } + } + + std::sort(files.begin(), files.end()); + files.erase(std::unique(files.begin(), files.end()), files.end()); + return files; +} diff --git a/editor/src/SidecarPersistence.h b/editor/src/SidecarPersistence.h index 4cabbe0..b3ab474 100644 --- a/editor/src/SidecarPersistence.h +++ b/editor/src/SidecarPersistence.h @@ -10,6 +10,7 @@ #include "ASTUtils.h" #include "CompactAST.h" #include "SemannoSidecar.h" +#include "SemanticHashTable.h" #include #include #include @@ -124,7 +125,9 @@ inline int countSemanticAnnotations(const ASTNode* node) { struct SidecarSaveResult { bool success = false; std::string sidecarPath; + std::string semanticHashTablePath; int annotationCount = 0; + int semanticHashCount = 0; std::string error; }; @@ -150,6 +153,14 @@ inline SidecarSaveResult saveSidecarAST(const std::string& workspaceRoot, out << astJson.dump(2); out.close(); + auto hashSave = saveSemanticHashTable(workspaceRoot, filePath, ast); + if (!hashSave.success) { + result.error = hashSave.error; + return result; + } + + result.semanticHashTablePath = hashSave.path; + result.semanticHashCount = hashSave.entryCount; result.annotationCount = countSemanticAnnotations(ast); result.success = true; return result; diff --git a/editor/src/ast/ASTNode.h b/editor/src/ast/ASTNode.h index 99a4bef..0896434 100644 --- a/editor/src/ast/ASTNode.h +++ b/editor/src/ast/ASTNode.h @@ -8,6 +8,9 @@ class ASTNode { public: std::string id; std::string conceptType; + std::string semanticHash; // Optional semantic identity hash: H: + std::string semanticHashLockState; // "locked" or "unlocked" (empty == unlocked) + std::string semanticHashLockReason; // Optional rationale for lock transitions ASTNode* parent = nullptr; int spanStartLine = -1; int spanStartCol = -1; @@ -27,6 +30,10 @@ public: return spanStartLine >= 0 && spanStartCol >= 0 && spanEndLine >= 0 && spanEndCol >= 0; } + bool isSemanticHashLocked() const { + return semanticHashLockState == "locked"; + } + // Multi-valued child: append to role void addChild(const std::string& role, ASTNode* child) { child->parent = this; diff --git a/editor/src/ast/Serialization.h b/editor/src/ast/Serialization.h index 2de4d69..d275b0d 100644 --- a/editor/src/ast/Serialization.h +++ b/editor/src/ast/Serialization.h @@ -602,6 +602,13 @@ inline json toJson(const ASTNode* node) { json j; j["id"] = node->id; j["concept"] = node->conceptType; + if (!node->semanticHash.empty()) j["semanticHash"] = node->semanticHash; + if (node->semanticHashLockState == "locked") { + j["semanticHashLockState"] = "locked"; + if (!node->semanticHashLockReason.empty()) { + j["semanticHashLockReason"] = node->semanticHashLockReason; + } + } j["properties"] = propertiesToJson(node); if (node->hasSpan()) { j["span"] = { @@ -1390,6 +1397,15 @@ inline ASTNode* fromJson(const json& j) { node->id = j.contains("id") ? j["id"].get() : generateNodeId(); + if (j.contains("semanticHash") && j["semanticHash"].is_string()) { + node->semanticHash = j["semanticHash"].get(); + } + if (j.contains("semanticHashLockState") && j["semanticHashLockState"].is_string()) { + node->semanticHashLockState = j["semanticHashLockState"].get(); + } + if (j.contains("semanticHashLockReason") && j["semanticHashLockReason"].is_string()) { + node->semanticHashLockReason = j["semanticHashLockReason"].get(); + } if (j.contains("span")) { const auto& span = j["span"]; if (span.contains("start") && span.contains("end")) { diff --git a/editor/src/headless_rpc/DispatchPart1.h b/editor/src/headless_rpc/DispatchPart1.h index d51aad7..ec80060 100644 --- a/editor/src/headless_rpc/DispatchPart1.h +++ b/editor/src/headless_rpc/DispatchPart1.h @@ -200,6 +200,7 @@ std::string type = params.value("type", ""); bool preferImports = params.value("preferImports", false); bool strictMode = params.value("strictMode", false); + std::string hashLockMode = params.value("hashLockMode", "warn"); // warn|error Module* ast = state.mutationAST(); if (!ast) return headlessRpcError(id, -32001, "AST unavailable"); ASTMutationAPI mut; @@ -207,9 +208,22 @@ ASTMutationAPI::MutationResult res; LibraryPolicyResult policy; std::vector affectedIds; + std::string hashLockWarning; + auto handleHashLock = [&](ASTNode* n, const std::string& context) -> json { + if (!n) return json(); + std::string w = ASTMutationAPI::semanticHashLockWarning(n); + if (w.empty()) return json(); + hashLockWarning = w; + if (hashLockMode == "error") { + return headlessRpcError(id, -32012, "Semantic hash lock violation (" + context + "): " + w); + } + return json(); + }; if (type == "setProperty") { std::string nodeId = params.value("nodeId", ""); if (!nodeId.empty()) affectedIds.push_back(nodeId); + json lockErr = handleHashLock(findNodeById(ast, nodeId), "setProperty"); + if (!lockErr.is_null()) return lockErr; res = mut.setProperty(params.value("nodeId", ""), params.value("property", ""), params.value("value", "")); @@ -221,10 +235,14 @@ } std::string nodeId = params.value("nodeId", ""); if (!nodeId.empty()) affectedIds.push_back(nodeId); + json lockErr = handleHashLock(findNodeById(ast, nodeId), "updateNode"); + if (!lockErr.is_null()) return lockErr; res = mut.updateNode(params.value("nodeId", ""), props); } else if (type == "deleteNode") { std::string nodeId = params.value("nodeId", ""); if (!nodeId.empty()) affectedIds.push_back(nodeId); + json lockErr = handleHashLock(findNodeById(ast, nodeId), "deleteNode"); + if (!lockErr.is_null()) return lockErr; res = mut.deleteNode(params.value("nodeId", "")); } else if (type == "insertNode") { ASTNode* node = nullptr; @@ -241,6 +259,11 @@ return headlessRpcError(id, -32011, policy.error); } } + json lockErr = handleHashLock(findNodeById(ast, params.value("parentId", "")), "insertNode"); + if (!lockErr.is_null()) { + if (node) deleteTree(node); + return lockErr; + } res = mut.insertNode(params.value("parentId", ""), params.value("role", ""), node); if (!res.success && node) { @@ -267,6 +290,7 @@ } return headlessRpcResult(id, { {"success", true}, {"warning", res.warning}, + {"hashLockWarning", hashLockWarning}, {"libraryWarning", policy.warning}, {"unknownFunctions", policy.unknownFunctions}, {"version", state.active() @@ -283,6 +307,7 @@ Module* ast = state.mutationAST(); auto params = request.contains("params") ? request["params"] : json::object(); + std::string hashLockMode = params.value("hashLockMode", "warn"); // warn|error if (!params.contains("mutations") || !params["mutations"].is_array()) return headlessRpcError(id, -32602, @@ -291,6 +316,7 @@ batch.setRoot(ast); std::vector mutations; std::vector ownedNodes; + std::vector hashLockWarnings; for (const auto& m : params["mutations"]) { BatchMutationAPI::Mutation mut; mut.type = m.value("type", ""); @@ -303,8 +329,19 @@ mut.newNode = fromJson(m["node"]); if (mut.newNode) ownedNodes.push_back(mut.newNode); } + ASTNode* guard = nullptr; + if (mut.type == "insertNode") guard = findNodeById(ast, mut.parentId); + else guard = findNodeById(ast, mut.nodeId); + std::string w = ASTMutationAPI::semanticHashLockWarning(guard); + if (!w.empty()) hashLockWarnings.push_back(w); mutations.push_back(mut); } + if (!hashLockWarnings.empty() && hashLockMode == "error") { + for (auto* n : ownedNodes) { + if (n->parent == nullptr) deleteTree(n); + } + return headlessRpcError(id, -32012, "Semantic hash lock violation in batch: " + hashLockWarnings.front()); + } auto batchRes = batch.applySequence(mutations); if (!batchRes.success) { for (auto* n : ownedNodes) { @@ -329,6 +366,7 @@ } return headlessRpcResult(id, {{"success", true}, {"appliedCount", batchRes.appliedCount}, + {"hashLockWarnings", hashLockWarnings}, {"version", state.active() ? state.active()->versionTracker.version : 0}}); } diff --git a/editor/src/headless_rpc/DispatchPart4.h b/editor/src/headless_rpc/DispatchPart4.h index a5d1ada..2c3aef1 100644 --- a/editor/src/headless_rpc/DispatchPart4.h +++ b/editor/src/headless_rpc/DispatchPart4.h @@ -96,7 +96,9 @@ return headlessRpcResult(id, {{"success", true}, {"sidecarPath", res.sidecarPath}, - {"annotationCount", res.annotationCount}}); + {"annotationCount", res.annotationCount}, + {"semanticHashTablePath", res.semanticHashTablePath}, + {"semanticHashCount", res.semanticHashCount}}); } // --- loadAnnotatedAST --- @@ -130,6 +132,107 @@ {{"files", files}, {"count", (int)files.size()}}); } + // --- saveSemanticHashTable --- + if (method == "saveSemanticHashTable") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] : json::object(); + std::string path = params.value("path", ""); + if (path.empty() && state.activeBuffer) path = state.activeBuffer->path; + auto it = state.bufferStates.find(path); + if (it == state.bufferStates.end()) + return headlessRpcError(id, -32602, "No buffer: " + path); + Module* ast = it->second->sync.getAST(); + auto res = saveSemanticHashTable(state.workspaceRoot, path, ast); + if (!res.success) return headlessRpcError(id, -32010, res.error); + return headlessRpcResult(id, { + {"success", true}, + {"semanticHashTablePath", res.path}, + {"semanticHashCount", res.entryCount} + }); + } + + // --- getSemanticHashTable --- + if (method == "getSemanticHashTable") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] : json::object(); + std::string path = params.value("path", ""); + if (path.empty() && state.activeBuffer) path = state.activeBuffer->path; + bool refresh = params.value("refresh", false); + auto it = state.bufferStates.find(path); + if (it == state.bufferStates.end()) + return headlessRpcError(id, -32602, "No buffer: " + path); + if (refresh) { + Module* ast = it->second->sync.getAST(); + auto save = saveSemanticHashTable(state.workspaceRoot, path, ast); + if (!save.success) return headlessRpcError(id, -32010, save.error); + } + auto res = loadSemanticHashTable(state.workspaceRoot, path); + if (!res.success) return headlessRpcError(id, -32010, res.error); + return headlessRpcResult(id, { + {"success", true}, + {"semanticHashTablePath", res.path}, + {"table", res.table} + }); + } + + // --- listSemanticHashTables --- + if (method == "listSemanticHashTables") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto files = listSemanticHashTables(state.workspaceRoot); + return headlessRpcResult(id, { + {"files", files}, + {"count", (int)files.size()} + }); + } + + // --- setSemanticHashLock --- + if (method == "setSemanticHashLock") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] : json::object(); + std::string nodeId = params.value("nodeId", ""); + if (nodeId.empty()) + return headlessRpcError(id, -32602, "nodeId required"); + bool locked = params.value("locked", true); + std::string reason = params.value("reason", ""); + Module* ast = state.activeAST(); + if (!ast) return headlessRpcError(id, -32602, "No AST available"); + ASTNode* node = findNodeById(ast, nodeId); + if (!node) return headlessRpcError(id, -32602, "Node not found: " + nodeId); + node->semanticHashLockState = locked ? "locked" : "unlocked"; + node->semanticHashLockReason = reason; + return headlessRpcResult(id, { + {"success", true}, + {"nodeId", node->id}, + {"semanticHash", node->semanticHash}, + {"semanticHashLockState", node->semanticHashLockState}, + {"semanticHashLockReason", node->semanticHashLockReason} + }); + } + + // --- getSemanticHashLock --- + if (method == "getSemanticHashLock") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] : json::object(); + std::string nodeId = params.value("nodeId", ""); + if (nodeId.empty()) + return headlessRpcError(id, -32602, "nodeId required"); + Module* ast = state.activeAST(); + if (!ast) return headlessRpcError(id, -32602, "No AST available"); + ASTNode* node = findNodeById(ast, nodeId); + if (!node) return headlessRpcError(id, -32602, "Node not found: " + nodeId); + return headlessRpcResult(id, { + {"nodeId", node->id}, + {"semanticHash", node->semanticHash}, + {"semanticHashLockState", node->semanticHashLockState.empty() ? "unlocked" : node->semanticHashLockState}, + {"semanticHashLockReason", node->semanticHashLockReason} + }); + } + // --- setSemanticAnnotation --- if (method == "setSemanticAnnotation") { if (!AgentPermissionPolicy::canInvoke(role, method)) @@ -138,6 +241,7 @@ : json::object(); std::string nodeId = params.value("nodeId", ""); std::string type = params.value("type", ""); + std::string hashLockMode = params.value("hashLockMode", "warn"); // warn|error json fields = params.contains("fields") ? params["fields"] : json::object(); if (nodeId.empty() || type.empty()) @@ -150,6 +254,10 @@ ASTNode* target = findNodeById(ast, nodeId); if (!target) return headlessRpcError(id, -32602, "Node not found: " + nodeId); + std::string hashLockWarning = ASTMutationAPI::semanticHashLockWarning(target); + if (!hashLockWarning.empty() && hashLockMode == "error") { + return headlessRpcError(id, -32012, "Semantic hash lock violation: " + hashLockWarning); + } // Map type string to conceptType std::string conceptType; @@ -246,7 +354,11 @@ return headlessRpcError(id, -32010, "Failed to create annotation"); target->addChild("annotations", newAnno); - return headlessRpcResult(id, {{"success", true}, {"type", type}}); + return headlessRpcResult(id, { + {"success", true}, + {"type", type}, + {"hashLockWarning", hashLockWarning} + }); } // --- getSemanticAnnotations --- diff --git a/editor/src/mcp/RegisterSidecarTools.h b/editor/src/mcp/RegisterSidecarTools.h index 33f9a4c..8d4bbe8 100644 --- a/editor/src/mcp/RegisterSidecarTools.h +++ b/editor/src/mcp/RegisterSidecarTools.h @@ -39,6 +39,77 @@ [this](const json& args) { return callWhetstone("listAnnotatedFiles", args); }; + + // whetstone_save_semantic_hash_table + tools_.push_back({"whetstone_save_semantic_hash_table", + "Compute and persist semantic hash table sidecar for the current " + "buffer. Uses SemAnno/Whetstone semantic primitives as hash inputs.", + {{"type", "object"}, {"properties", { + {"path", {{"type", "string"}, + {"description", "Buffer path to save semantic hash table for"}}} + }}} + }); + toolHandlers_["whetstone_save_semantic_hash_table"] = + [this](const json& args) { + return callWhetstone("saveSemanticHashTable", args); + }; + + // whetstone_get_semantic_hash_table + tools_.push_back({"whetstone_get_semantic_hash_table", + "Read semantic hash table sidecar for a file. Set refresh=true " + "to recompute from live AST before reading.", + {{"type", "object"}, {"properties", { + {"path", {{"type", "string"}, + {"description", "Buffer path to read semantic hash table for"}}}, + {"refresh", {{"type", "boolean"}, + {"description", "Recompute hash table from AST before read"}}} + }}} + }); + toolHandlers_["whetstone_get_semantic_hash_table"] = + [this](const json& args) { + return callWhetstone("getSemanticHashTable", args); + }; + + // whetstone_list_semantic_hash_tables + tools_.push_back({"whetstone_list_semantic_hash_tables", + "List all semantic hash table sidecar files in the workspace.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_list_semantic_hash_tables"] = + [this](const json& args) { + return callWhetstone("listSemanticHashTables", args); + }; + + // whetstone_set_semantic_hash_lock + tools_.push_back({"whetstone_set_semantic_hash_lock", + "Set hash lock state on an AST node (locked/unlocked) with " + "optional reason. This is scaffolding metadata for lock policy.", + {{"type", "object"}, {"properties", { + {"nodeId", {{"type", "string"}, + {"description", "AST node ID"}}}, + {"locked", {{"type", "boolean"}, + {"description", "true=locked, false=unlocked"}}}, + {"reason", {{"type", "string"}, + {"description", "Optional lock rationale"}}} + }}, {"required", {"nodeId", "locked"}}} + }); + toolHandlers_["whetstone_set_semantic_hash_lock"] = + [this](const json& args) { + return callWhetstone("setSemanticHashLock", args); + }; + + // whetstone_get_semantic_hash_lock + tools_.push_back({"whetstone_get_semantic_hash_lock", + "Get semantic hash lock metadata for an AST node.", + {{"type", "object"}, {"properties", { + {"nodeId", {{"type", "string"}, + {"description", "AST node ID"}}} + }}, {"required", {"nodeId"}}} + }); + toolHandlers_["whetstone_get_semantic_hash_lock"] = + [this](const json& args) { + return callWhetstone("getSemanticHashLock", args); + }; } // --------------------------------------------------------------- diff --git a/editor/tests/step295_test.cpp b/editor/tests/step295_test.cpp index 7f13437..18a60db 100644 --- a/editor/tests/step295_test.cpp +++ b/editor/tests/step295_test.cpp @@ -1,13 +1,16 @@ // Step 295: Semanno Sidecar Integration (12 tests) #include "SemannoSidecar.h" #include "SidecarPersistence.h" +#include "SemanticHashTable.h" #include "ast/Module.h" #include "ast/Function.h" #include "ast/Annotation.h" +#include "ast/Serialization.h" #include "EnvironmentSpec.h" #include #include #include +#include static int passed = 0, failed = 0; #define TEST(name) { std::cout << " " << #name << "... "; } @@ -170,11 +173,15 @@ void test_alongside_ast_sidecar() { auto astPath = sidecarPath(tmpDir(), "test.py"); namespace fs = std::filesystem; fs::create_directories(fs::path(astPath).parent_path()); - saveSidecarAST(tmpDir(), "test.py", mod.get()); + auto astSave = saveSidecarAST(tmpDir(), "test.py", mod.get()); + CHECK(astSave.success, "ast sidecar save failed"); // Both files should exist in same .whetstone dir CHECK(fs::exists(semannoResult.path), "semanno file missing"); CHECK(fs::exists(astPath), "ast file missing"); + CHECK(!astSave.semanticHashTablePath.empty(), "hash table path missing"); + CHECK(fs::exists(astSave.semanticHashTablePath), "hash table file missing"); + CHECK(astSave.semanticHashCount > 0, "hash table should have entries"); // Both under .whetstone/ CHECK(semannoResult.path.find(".whetstone") != std::string::npos, "wrong dir"); CHECK(astPath.find(".whetstone") != std::string::npos, "wrong dir"); @@ -285,6 +292,77 @@ void test_empty_module() { PASS(); } +// 13. semanticHash field roundtrips via AST JSON +void test_semantic_hash_roundtrip() { + TEST(semantic_hash_roundtrip); + Function fn; + fn.id = "fn_1"; + fn.name = "f"; + fn.semanticHash = "H1:00000000000000AA"; + + auto j = toJson(&fn); + CHECK(j.contains("semanticHash"), "semanticHash missing in JSON"); + CHECK(j["semanticHash"] == "H1:00000000000000AA", "semanticHash changed in JSON"); + + ASTNode* restored = fromJson(j); + CHECK(restored != nullptr, "roundtrip restore failed"); + CHECK(restored->semanticHash == "H1:00000000000000AA", "semanticHash missing after roundtrip"); + delete restored; + PASS(); +} + +// 14. semantic hash table save/load/list +void test_semantic_hash_table_roundtrip() { + TEST(semantic_hash_table_roundtrip); + cleanup(); + auto mod = makeAnnotatedModule(); + + auto saved = saveSemanticHashTable(tmpDir(), "hashes.py", mod.get()); + CHECK(saved.success, "save semantic hash table failed: " + saved.error); + CHECK(saved.entryCount > 0, "expected at least one hash entry"); + CHECK(std::filesystem::exists(saved.path), "semantic hash table file missing"); + + auto loaded = loadSemanticHashTable(tmpDir(), "hashes.py"); + CHECK(loaded.success, "load semantic hash table failed: " + loaded.error); + CHECK(loaded.table.contains("entries"), "entries missing"); + CHECK(loaded.table["entries"].is_array(), "entries should be array"); + CHECK(!loaded.table["entries"].empty(), "entries should not be empty"); + CHECK(loaded.table["entries"][0].contains("semanticHash"), "semanticHash missing from entry"); + CHECK(loaded.table["entries"][0].contains("semanticHashH1"), "semanticHashH1 missing from entry"); + CHECK(loaded.table["entries"][0].contains("semanticHashH2"), "semanticHashH2 missing from entry"); + CHECK(loaded.table.contains("dualEmitVersions"), "dualEmitVersions missing"); + CHECK(loaded.table["dualEmitVersions"].is_array(), "dualEmitVersions should be array"); + CHECK(loaded.table.contains("migration"), "migration metadata missing"); + + auto listed = listSemanticHashTables(tmpDir()); + CHECK(!listed.empty(), "listSemanticHashTables should not be empty"); + CHECK(std::find(listed.begin(), listed.end(), "hashes.py") != listed.end(), + "hashes.py not found in semantic hash table list"); + PASS(); +} + +// 15. hash lock state roundtrips via AST JSON +void test_semantic_hash_lock_roundtrip() { + TEST(semantic_hash_lock_roundtrip); + Function fn; + fn.id = "fn_lock"; + fn.name = "f"; + fn.semanticHash = "H1:1234567890ABCDEF"; + fn.semanticHashLockState = "locked"; + fn.semanticHashLockReason = "API stability"; + + auto j = toJson(&fn); + CHECK(j.value("semanticHashLockState", "") == "locked", "lock state missing"); + CHECK(j.value("semanticHashLockReason", "") == "API stability", "lock reason missing"); + + ASTNode* restored = fromJson(j); + CHECK(restored != nullptr, "roundtrip restore failed"); + CHECK(restored->semanticHashLockState == "locked", "lock state not restored"); + CHECK(restored->semanticHashLockReason == "API stability", "lock reason not restored"); + delete restored; + PASS(); +} + int main() { std::cout << "=== Step 295: Semanno Sidecar Integration Tests ===\n"; test_sidecar_path(); @@ -299,6 +377,9 @@ int main() { test_overwrite(); test_properties_preserved(); test_empty_module(); + test_semantic_hash_roundtrip(); + test_semantic_hash_table_roundtrip(); + test_semantic_hash_lock_roundtrip(); cleanup(); std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n"; return failed > 0 ? 1 : 0;