Add semantic hash table tools, lock scaffolding, and H2 dual-hash migration scaffold

This commit is contained in:
Bill
2026-02-24 11:13:31 -07:00
parent 1a761c62e5
commit f4f3633b11
12 changed files with 665 additions and 6 deletions

View File

@@ -150,6 +150,27 @@ public:
std::vector<std::string> 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() ? "<unset>" : 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<const OptimizationLock*>(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

View File

@@ -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" ||

View File

@@ -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;

105
editor/src/SemanticHash.h Normal file
View File

@@ -0,0 +1,105 @@
#pragma once
// Step 1546: Semantic hash primitives and deterministic hash computation.
#include "ast/ASTNode.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdint>
#include <iomanip>
#include <set>
#include <sstream>
#include <string>
#include <vector>
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<std::string>& 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<std::string> semanticPrimitivePaths(const json& semanticSummary) {
std::set<std::string> unique;
collectSemanticPrimitivePaths(semanticSummary, "", unique);
return std::vector<std::string>(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<uint64_t>(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;
}

View File

@@ -0,0 +1,183 @@
#pragma once
// Step 1546: Semantic hash sidecar table persistence.
#include "ast/ASTNode.h"
#include "CompactAST.h"
#include "SemanticHash.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <set>
#include <string>
#include <vector>
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<std::string>& 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<std::string> 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<std::string> primitiveCatalog;
int v2MismatchCount = 0;
collectSemanticHashEntries(ast, entries, primitiveCatalog, v2MismatchCount);
result.entryCount = static_cast<int>(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<int>(entries.size())},
{"h1h2MismatchCount", v2MismatchCount}
}},
{"primitiveCatalog", std::vector<std::string>(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<std::string> listSemanticHashTables(const std::string& workspaceRoot) {
namespace fs = std::filesystem;
std::vector<std::string> 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;
}

View File

@@ -10,6 +10,7 @@
#include "ASTUtils.h"
#include "CompactAST.h"
#include "SemannoSidecar.h"
#include "SemanticHashTable.h"
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
@@ -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;

View File

@@ -8,6 +8,9 @@ class ASTNode {
public:
std::string id;
std::string conceptType;
std::string semanticHash; // Optional semantic identity hash: H<version>:<hex>
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;

View File

@@ -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<std::string>()
: generateNodeId();
if (j.contains("semanticHash") && j["semanticHash"].is_string()) {
node->semanticHash = j["semanticHash"].get<std::string>();
}
if (j.contains("semanticHashLockState") && j["semanticHashLockState"].is_string()) {
node->semanticHashLockState = j["semanticHashLockState"].get<std::string>();
}
if (j.contains("semanticHashLockReason") && j["semanticHashLockReason"].is_string()) {
node->semanticHashLockReason = j["semanticHashLockReason"].get<std::string>();
}
if (j.contains("span")) {
const auto& span = j["span"];
if (span.contains("start") && span.contains("end")) {

View File

@@ -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<std::string> 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<BatchMutationAPI::Mutation> mutations;
std::vector<ASTNode*> ownedNodes;
std::vector<std::string> 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}});
}

View File

@@ -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 ---

View File

@@ -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);
};
}
// ---------------------------------------------------------------

View File

@@ -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 <iostream>
#include <filesystem>
#include <fstream>
#include <algorithm>
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;