Add semantic hash table tools, lock scaffolding, and H2 dual-hash migration scaffold
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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
105
editor/src/SemanticHash.h
Normal 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;
|
||||
}
|
||||
183
editor/src/SemanticHashTable.h
Normal file
183
editor/src/SemanticHashTable.h
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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}});
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user