use crate::project;
use noxid_agent_planning::{
CatalogKind, ContextLeaseStore, ContextRequest, DescribeQuery, FeatureKind, FeatureSpec,
GoalRequest, MachineContract, MachineTransition, MachineVariant, ManifestProjection,
ResourceContract, ScaffoldPlan, build_context_pack, build_manifest, describe,
plan_feature_scaffold, plan_goal, search_catalog,
};
use noxid_ai_eval::{
CanonicalProjectionOptions, IndexFreshness, LocalTelemetry, SemanticIndex, WorkflowRequest,
canonical_graph_projection, check_intent_drift, compress_diagnostics, evaluate_context_quality,
plan_safe_repairs, plan_safe_repairs_in, select_affected_scenarios, simulate_workflow,
};
use noxid_compiler_core::compile;
use noxid_graph::ApplicationGraph;
use noxid_ir::SemanticId;
use noxid_mcp::{
Backend, ToolResult, argument, argument_json, request_state, semantic_write_accepted,
};
use noxid_semantic_ops::{
ActionSpec, ProjectEditPlan, Provenance, RequirementSpec, ScenarioSpec, SemanticEditPlan,
plan_add_action, plan_add_requirement, plan_add_scenario, plan_add_state, plan_add_transition,
plan_extract_component, plan_modify_view, plan_project_rename, plan_rename_symbol,
};
use noxid_semantic_transactions::{
CapabilitySandbox, DocumentSet, SemanticOperation, SessionId, TransactionEngine,
ValidationReport, source_hash,
};
use noxid_source::{SourceFile, SourceId, json_escape};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;
pub fn run(input: &Path) -> Result<(), String> {
let root = fs::canonicalize(input)
.map_err(|error| format!("cannot resolve {}: {error}", input.display()))?;
noxid_mcp::run_stdio(&CompilerBackend::new(root))
}
pub fn run_http(
input: &Path,
bind: String,
token_env: &str,
allowed_origins: Vec<String>,
stream_responses: bool,
) -> Result<(), String> {
let root = fs::canonicalize(input)
.map_err(|error| format!("cannot resolve {}: {error}", input.display()))?;
let bearer_token = std::env::var(token_env)
.map_err(|_| format!("{token_env} must contain the MCP HTTP bearer token"))?;
let mut config = noxid_mcp::HttpConfig::local(bind, bearer_token);
config.allowed_origins = allowed_origins;
config.stream_responses = stream_responses;
noxid_mcp::run_http(CompilerBackend::new(root), config)
}
pub fn undo_local(input: &Path, transaction_id: &str) -> Result<String, String> {
let root = fs::canonicalize(input)
.map_err(|error| format!("cannot resolve {}: {error}", input.display()))?;
validate_transaction_id(transaction_id)?;
let backend = CompilerBackend::new(root);
let initial = format!(
"{{\"params\":{{\"arguments\":{{\"transaction\":\"{}\"}}}}}}",
json_escape(transaction_id)
);
let state = match backend.undo_transaction(&initial)? {
ToolResult::InputRequired { request_state, .. } => request_state,
ToolResult::Complete(_) => {
return Err("local semantic undo unexpectedly skipped confirmation".into());
}
};
let confirmed = format!(
"{{\"params\":{{\"arguments\":{{\"transaction\":\"{}\"}},\"requestState\":\"{}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}}}}}",
json_escape(transaction_id),
json_escape(&state),
);
match backend.undo_transaction(&confirmed)? {
ToolResult::Complete(result) => Ok(result),
ToolResult::InputRequired { .. } => {
Err("local semantic undo confirmation state changed".into())
}
}
}
struct CompilerBackend {
input: PathBuf,
cache: Mutex<BackendCache>,
leases: Mutex<ContextLeaseStore>,
telemetry: Mutex<LocalTelemetry>,
}
#[derive(Default)]
struct BackendCache {
revision: u64,
snapshot: String,
graph: Option<ApplicationGraph>,
previous_snapshot: String,
previous_graph: Option<ApplicationGraph>,
responses: BTreeMap<String, String>,
validation_snapshot: String,
validation_keys: BTreeSet<String>,
}
impl CompilerBackend {
fn new(input: PathBuf) -> Self {
Self {
input,
cache: Mutex::new(BackendCache::default()),
leases: Mutex::new(ContextLeaseStore::new(16)),
telemetry: Mutex::new(LocalTelemetry::new("local-mcp")),
}
}
fn is_project(&self) -> bool {
project::is_project_input(&self.input)
}
fn settle(&self) -> Result<(), String> {
crate::repair_transaction::recover_before_read(&self.input).map_err(|cause| {
let advice = match &cause {
crate::repair_transaction::RecoveryError::Refused { .. } => {
"This needs a human: the journal stays on disk and every reader refuses \
until it is settled, or discarded with `noxid repair <project> --inspect \
--discard <transaction-id>`."
}
crate::repair_transaction::RecoveryError::ClaimPostdated { .. }
| crate::repair_transaction::RecoveryError::ClaimStalled { .. } => {
"This is transient: retry the request."
}
};
let refusal = format!(
"REPAIR_RECOVERY_BLOCKED: {} was not compiled. cannot recover an interrupted repair transaction: {cause} {advice}",
self.input.display()
);
eprintln!("noxid-mcp: {refusal}");
refusal
})
}
fn compile_source(&self) -> Result<noxid_compiler_core::Compilation, String> {
self.settle()?;
let text = fs::read_to_string(&self.input)
.map_err(|error| format!("cannot read {}: {error}", self.input.display()))?;
Ok(compile(&SourceFile::new(SourceId(0), &self.input, text)))
}
fn programs(&self) -> Result<Vec<noxid_ir::SemanticProgram>, String> {
self.settle()?;
if self.is_project() {
project::query_programs(&self.input)
} else {
Ok(vec![self.compile_source()?.program])
}
}
fn diagnostics(&self) -> Result<Vec<noxid_source::Diagnostic>, String> {
self.settle()?;
if self.is_project() {
project::query_diagnostics(&self.input)
} else {
Ok(self.compile_source()?.diagnostics)
}
}
fn repair_plan(
&self,
diagnostics: &[noxid_source::Diagnostic],
) -> noxid_ai_eval::SafeRepairPlan {
let mut by_path: BTreeMap<Option<String>, Vec<noxid_source::Diagnostic>> = BTreeMap::new();
for diagnostic in diagnostics {
let path = diagnostic
.path
.clone()
.or_else(|| (!self.is_project()).then(|| self.input.display().to_string()));
by_path.entry(path).or_default().push(diagnostic.clone());
}
let mut operations = Vec::new();
let mut unresolved = Vec::new();
for (path, diagnostics) in by_path {
let source = path
.as_deref()
.and_then(|path| fs::read_to_string(path).ok());
let plan = match source {
Some(source) => plan_safe_repairs_in(&source, &diagnostics),
None => plan_safe_repairs(&diagnostics),
};
operations.extend(plan.operations);
unresolved.extend(plan.unresolved);
}
noxid_ai_eval::SafeRepairPlan {
operations,
unresolved,
}
}
fn revision(&self) -> Result<u64, String> {
self.settle()?;
if self.is_project() {
return project::project_revision(&self.input);
}
let bytes = fs::read(&self.input)
.map_err(|error| format!("cannot read {}: {error}", self.input.display()))?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
bytes.hash(&mut hasher);
Ok(hasher.finish())
}
fn refresh(&self) -> Result<(u64, String), String> {
let revision = self.revision()?;
let mut cache = self
.cache
.lock()
.map_err(|_| "MCP cache lock was poisoned")?;
if cache.revision != revision || cache.snapshot.is_empty() {
if !cache.snapshot.is_empty() {
cache.previous_snapshot = cache.snapshot.clone();
cache.previous_graph = cache.graph.clone();
}
cache.revision = revision;
cache.snapshot = format!("noxid:{revision:016x}");
cache.graph = None;
cache.responses.clear();
}
Ok((revision, cache.snapshot.clone()))
}
fn graph(&self) -> Result<(ApplicationGraph, String), String> {
let (_, snapshot) = self.refresh()?;
if let Some(graph) = self
.cache
.lock()
.map_err(|_| "MCP cache lock was poisoned")?
.graph
.clone()
{
return Ok((graph, snapshot));
}
let graph = if self.is_project() {
project::query_graph(&self.input)?
} else {
self.compile_source()?.graph
};
self.cache
.lock()
.map_err(|_| "MCP cache lock was poisoned")?
.graph = Some(graph.clone());
Ok((graph, snapshot))
}
fn cached_response<F>(&self, key: &str, produce: F) -> Result<String, String>
where
F: FnOnce() -> Result<String, String>,
{
self.refresh()?;
if let Some(value) = self
.cache
.lock()
.map_err(|_| "MCP cache lock was poisoned")?
.responses
.get(key)
.cloned()
{
return Ok(value);
}
let value = produce()?;
self.cache
.lock()
.map_err(|_| "MCP cache lock was poisoned")?
.responses
.insert(key.into(), value.clone());
Ok(value)
}
fn invalidate(&self) {
if let Ok(mut cache) = self.cache.lock() {
if !cache.snapshot.is_empty() {
cache.previous_snapshot = cache.snapshot.clone();
cache.previous_graph = cache.graph.clone();
}
cache.revision = 0;
cache.snapshot.clear();
cache.graph = None;
cache.responses.clear();
}
}
fn compact_query(&self, request: &str) -> Result<String, String> {
let started = Instant::now();
let result = self.compact_query_inner(request);
if let Ok(mut telemetry) = self.telemetry.lock() {
telemetry.record_tool_call(
"query_project",
result.as_ref().map_or(0, String::len),
started.elapsed().as_millis() as u64,
result.is_ok(),
);
if result
.as_ref()
.is_err_and(|error| error.contains("unknown compact query operation"))
&& let Some(operation) = argument(request, "operation")
{
telemetry.record_missing_operation(&operation);
}
}
result
}
fn compact_query_inner(&self, request: &str) -> Result<String, String> {
let operation = argument(request, "operation").unwrap_or_else(|| "summary".into());
let kind = argument(request, "kind");
let symbol = argument(request, "symbol");
let since = argument(request, "since");
let requested_limit = argument(request, "limit")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(25)
.clamp(1, 100);
let max_bytes = argument(request, "maxBytes")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(4096)
.clamp(512, 32_768);
let limit = requested_limit.min((max_bytes / 160).max(1));
let (graph, snapshot) = self.graph()?;
match operation.as_str() {
"manifest" => {
let projection = match argument(request, "projection").as_deref() {
Some("agent") => ManifestProjection::Agent,
Some("full") => ManifestProjection::Full,
Some("compact") | None => ManifestProjection::Compact,
Some(other) => return Err(format!("unknown manifest projection `{other}`")),
};
return Ok(build_manifest(&graph, projection).to_json());
}
"context" => {
let task = argument(request, "task").ok_or("context requires `task`")?;
let mut context = ContextRequest::new(task);
context.max_bytes = max_bytes;
context.max_nodes = requested_limit;
return Ok(build_context_pack(&graph, &context).to_json());
}
"lease-create" => {
let task = argument(request, "task").ok_or("lease-create requires `task`")?;
let mut context = ContextRequest::new(task);
context.max_bytes = max_bytes;
context.max_nodes = requested_limit;
let lease = self
.leases
.lock()
.map_err(|_| "context lease lock was poisoned")?
.create(&graph, &context);
return Ok(format!(
"{{\"schemaVersion\":1,\"lease\":\"{}\",\"snapshot\":\"{}\",\"context\":{}}}",
json_escape(&lease.id),
json_escape(&lease.snapshot),
lease.pack.to_json()
));
}
"lease-get" => {
let id = argument(request, "lease").ok_or("lease-get requires `lease`")?;
let current = build_manifest(&graph, ManifestProjection::Compact).snapshot;
let leases = self
.leases
.lock()
.map_err(|_| "context lease lock was poisoned")?;
let lease = leases
.get(&id, ¤t)
.map_err(|error| format!("context lease is unavailable: {error:?}"))?;
return Ok(format!(
"{{\"schemaVersion\":1,\"lease\":\"{}\",\"snapshot\":\"{}\",\"context\":{}}}",
json_escape(&lease.id),
json_escape(&lease.snapshot),
lease.pack.to_json()
));
}
"lease-release" => {
let id = argument(request, "lease").ok_or("lease-release requires `lease`")?;
let released = self
.leases
.lock()
.map_err(|_| "context lease lock was poisoned")?
.release(&id);
return Ok(format!(
"{{\"schemaVersion\":1,\"lease\":\"{}\",\"released\":{released}}}",
json_escape(&id)
));
}
"plan" => {
let goal = argument(request, "goal").ok_or("plan requires `goal`")?;
let mut plan = GoalRequest::new(goal);
plan.preferred_symbols = csv_values(argument(request, "symbols").as_deref());
plan.max_alternatives = limit.min(5);
return Ok(plan_goal(&graph, &plan).to_json());
}
"describe" => {
let name = argument(request, "name").ok_or("describe requires `name`")?;
if let Some(kind) = argument(request, "kind") {
if kind == "guide" {
let text = crate::agent_guide(&name)?;
return Ok(format!(
"{{\"schemaVersion\":1,\"kind\":\"guide\",\"name\":\"{}\",\"text\":\"{}\"}}",
noxid_source::json_escape(&name),
noxid_source::json_escape(&text)
));
}
let kind = catalog_kind(&kind)?;
return describe(&DescribeQuery { kind, name: &name })
.map(|entry| entry.to_json())
.ok_or_else(|| format!("unknown {kind:?} `{name}`"));
}
let matches = search_catalog(&name, limit)
.into_iter()
.map(|entry| entry.to_json())
.collect::<Vec<_>>()
.join(",");
return Ok(format!("{{\"schemaVersion\":1,\"matches\":[{matches}]}}"));
}
"canonical" => {
let roots = semantic_ids(argument(request, "symbols").as_deref())?;
return Ok(canonical_graph_projection(
&graph,
&CanonicalProjectionOptions {
roots,
max_nodes: requested_limit,
include_spans: argument(request, "projection").as_deref() == Some("full"),
},
));
}
"affected-tests" => {
let changed = semantic_ids(argument(request, "symbols").as_deref())?;
if changed.is_empty() {
return Err("affected-tests requires comma-separated `symbols`".into());
}
return Ok(select_affected_scenarios(&graph, &changed).to_json());
}
"run_scenarios" | "run-scenarios" => {
let gate = match argument(request, "gate").as_deref() {
Some("true") => true,
Some("false") | None => false,
Some(other) => {
return Err(format!(
"run_scenarios `gate` must be `true` or `false`, found `{other}`"
));
}
};
let execution = crate::scenario_test::execute_report(
&self.input,
&crate::scenario_test::Options {
gate,
json_only: true,
},
)?;
return Ok(execution.json);
}
"run-affected-tests" => {
let changed = semantic_ids(argument(request, "symbols").as_deref())?;
if changed.is_empty() {
return Err("run-affected-tests requires comma-separated `symbols`".into());
}
let selection = select_affected_scenarios(&graph, &changed);
let selected = selection
.scenarios
.iter()
.map(|scenario| scenario.id.clone())
.collect();
let execution = crate::scenario_test::execute_selected_report(
&self.input,
&crate::scenario_test::Options {
gate: false,
json_only: true,
},
&selected,
)?;
return Ok(crate::scenario_test::affected_report_json(
&selection, &execution,
));
}
"simulate" => {
let component = semantic_id_argument(request, "symbol")?;
let actions = semantic_ids(argument(request, "actions").as_deref())?;
let program = self
.programs()?
.into_iter()
.find(|program| {
program
.components
.iter()
.any(|candidate| candidate.id == component)
})
.ok_or_else(|| format!("unknown component `{component}`"))?;
return Ok(simulate_workflow(
&program,
&graph,
&WorkflowRequest {
component,
actions,
initial_machine_states: BTreeMap::new(),
},
)
.to_json());
}
"repair-plan" => {
return Ok(self.repair_plan(&self.diagnostics()?).to_json());
}
"context-quality" => {
let required = semantic_ids(argument(request, "required").as_deref())?;
let provided = semantic_ids(argument(request, "provided").as_deref())?;
let report = evaluate_context_quality(
&required,
&provided,
argument(request, "responseBytes")
.and_then(|value| value.parse().ok())
.unwrap_or_default(),
max_bytes,
);
if let Ok(mut telemetry) = self.telemetry.lock() {
telemetry.record_context_evaluation(&report);
}
return Ok(report.to_json());
}
"index-search" => {
let query = argument(request, "task")
.or_else(|| argument(request, "name"))
.ok_or("index-search requires `task` or `name`")?;
let path = semantic_index_path(&self.input, self.is_project())?;
let index = match SemanticIndex::read_local(&path) {
Ok(index) if index.freshness(&graph) == IndexFreshness::Current => index,
Ok(_) | Err(_) => {
let index = SemanticIndex::from_graph(&graph);
index
.persist_local(&path)
.map_err(|error| error.to_string())?;
index
}
};
return Ok(index.search(&query, limit).to_json());
}
"telemetry" => {
return self
.telemetry
.lock()
.map(|telemetry| telemetry.to_json())
.map_err(|_| "local telemetry lock was poisoned".into());
}
"recommendations" => {
return self
.telemetry
.lock()
.map(|telemetry| telemetry.developer_recommendations().to_json())
.map_err(|_| "local telemetry lock was poisoned".into());
}
_ => {}
}
if since.as_deref() == Some(&snapshot) {
return Ok(format!(
"{{\"schemaVersion\":1,\"snapshot\":\"{snapshot}\",\"unchanged\":true}}"
));
}
if operation == "delta" {
let cache = self
.cache
.lock()
.map_err(|_| "MCP cache lock was poisoned")?;
if since.as_deref() == Some(cache.previous_snapshot.as_str())
&& let Some(previous) = &cache.previous_graph
{
let mut changed = graph
.nodes
.iter()
.filter(|(id, node)| {
previous.nodes.get(*id).is_none_or(|old| {
old.kind != node.kind || old.name != node.name || old.span != node.span
})
})
.map(|(id, _)| id.to_string())
.chain(
previous
.nodes
.keys()
.filter(|id| !graph.nodes.contains_key(*id))
.map(ToString::to_string),
)
.collect::<Vec<_>>();
changed.sort();
changed.dedup();
let truncated = changed.len() > limit;
changed.truncate(limit);
let values = changed
.iter()
.map(|id| format!("\"{}\"", json_escape(id)))
.collect::<Vec<_>>()
.join(",");
return Ok(format!(
"{{\"schemaVersion\":1,\"snapshot\":\"{snapshot}\",\"from\":\"{}\",\"changed\":[{values}],\"truncated\":{truncated}}}",
json_escape(cache.previous_snapshot.as_str())
));
}
return Ok(format!(
"{{\"schemaVersion\":1,\"snapshot\":\"{snapshot}\",\"resetRequired\":true}}"
));
}
if operation == "summary" {
let mut counts = BTreeMap::<&str, usize>::new();
for node in graph.nodes.values() {
*counts.entry(node.kind.as_str()).or_default() += 1;
}
let counts = counts
.into_iter()
.map(|(kind, count)| format!("\"{}\":{count}", json_escape(kind)))
.collect::<Vec<_>>()
.join(",");
return Ok(format!(
"{{\"schemaVersion\":1,\"snapshot\":\"{snapshot}\",\"nodes\":{},\"edges\":{},\"counts\":{{{counts}}}}}",
graph.nodes.len(),
graph.edges.len()
));
}
let legacy_operation = match operation.as_str() {
"symbol" => "inspect_symbol".to_string(),
"references" => "find_references".to_string(),
"dependencies" => "find_dependencies".to_string(),
"dependents" => "find_dependents".to_string(),
"impact" => "calculate_change_impact".to_string(),
"list" => match kind.as_deref().unwrap_or("components") {
"component" | "components" => "list_components".to_string(),
"resource" | "resources" => "list_resources".to_string(),
"agent" | "agents" => "list_agents".to_string(),
"machine" | "state-machine" | "state-machines" => "list_state_machines".to_string(),
"requirement" | "requirements" => "list_requirements".to_string(),
"scenario" | "scenarios" => "list_scenarios".to_string(),
other => format!("list_{other}"),
},
other => return Err(format!("unknown compact query operation `{other}`")),
};
let result =
source_graph_query_limited(&graph, &legacy_operation, symbol.as_deref(), limit)?;
Ok(format!(
"{{\"schemaVersion\":1,\"snapshot\":\"{snapshot}\",\"limit\":{limit},\"result\":{result}}}"
))
}
fn validate_compact(&self, request: &str) -> Result<String, String> {
let since = argument(request, "since");
let (_, snapshot) = self.refresh()?;
if since.as_deref() == Some(snapshot.as_str()) {
return Ok(format!(
"{{\"schemaVersion\":1,\"ok\":true,\"snapshot\":\"{snapshot}\",\"unchanged\":true}}"
));
}
let diagnostics = self.diagnostics()?;
let keys = diagnostics
.iter()
.map(diagnostic_key)
.collect::<BTreeSet<_>>();
let previous_keys = {
let cache = self
.cache
.lock()
.map_err(|_| "MCP cache lock was poisoned")?;
(since.as_deref() == Some(cache.validation_snapshot.as_str()))
.then(|| cache.validation_keys.clone())
};
let selected = diagnostics
.iter()
.filter(|diagnostic| {
previous_keys
.as_ref()
.is_none_or(|previous| !previous.contains(&diagnostic_key(diagnostic)))
})
.cloned()
.collect::<Vec<_>>();
{
let mut cache = self
.cache
.lock()
.map_err(|_| "MCP cache lock was poisoned")?;
cache.validation_snapshot = snapshot.clone();
cache.validation_keys = keys;
}
if let Ok(mut telemetry) = self.telemetry.lock() {
for diagnostic in &selected {
telemetry.record_diagnostic(diagnostic.code);
}
}
if selected.is_empty() {
return Ok(format!(
"{{\"schemaVersion\":1,\"ok\":true,\"snapshot\":\"{snapshot}\",\"diagnostics\":0,\"changedOnly\":{}}}",
previous_keys.is_some()
));
}
let errors = selected
.iter()
.filter(|diagnostic| diagnostic.severity == noxid_source::Severity::Error)
.count();
let (graph, _) = self.graph()?;
let compressed = compress_diagnostics(&selected, &graph);
let repairs = self.repair_plan(&selected);
Ok(format!(
"{{\"schemaVersion\":1,\"ok\":{},\"snapshot\":\"{snapshot}\",\"diagnosticCount\":{},\"errorCount\":{errors},\"changedOnly\":{},\"rootCauses\":{},\"repairPlan\":{}}}",
errors == 0,
selected.len(),
previous_keys.is_some(),
compressed.to_json(),
repairs.to_json(),
))
}
}
impl Backend for CompilerBackend {
fn read_resource(&self, uri: &str) -> Result<String, String> {
let resource = uri
.strip_prefix("noxid://")
.ok_or_else(|| format!("unsupported resource URI `{uri}`"))?;
if self.is_project() {
return self.cached_response(resource, || {
project::mcp_resource_json(&self.input, resource)
});
}
self.cached_response(resource, || {
let output = self.compile_source()?;
match resource {
"project" => Ok(output.metadata_json()),
"graph" => Ok(output.graph.to_json()),
"semantic" => Ok(output.program.to_json()),
"diagnostics" => Ok(output.diagnostics_json()),
"accessibility" => Ok(output.accessibility.to_json()),
"design-system" => Ok(output.design.to_json()),
"devtools" => Ok(output.devtools.to_json()),
"routes" => Ok("{\"schemaVersion\":1,\"routes\":[]}".into()),
_ => Err(format!("unknown Noxid MCP resource `{resource}`")),
}
})
}
fn call_tool(&self, name: &str, request: &str) -> Result<ToolResult, String> {
if name == "apply_operations" {
return self.apply_operations(request);
}
if name == "semantic_edit" {
let operation =
argument(request, "operation").ok_or("semantic_edit requires `operation`")?;
if !is_semantic_write(&operation)
&& !is_feature_write(&operation)
&& !is_transaction_write(&operation)
{
return Err(format!("unknown semantic edit `{operation}`"));
}
let payload = structured_payload(request, "semantic_edit")?;
if !payload.trim_start().starts_with('{') || !payload.trim_end().ends_with('}') {
return Err("semantic_edit payload must decode to a JSON object".into());
}
let confirmation = if semantic_write_accepted(request) {
let state = request_state(request).unwrap_or_default();
format!(
",\"requestState\":\"{}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}",
json_escape(&state)
)
} else {
String::new()
};
let delegated = format!("{{\"params\":{{\"arguments\":{payload}{confirmation}}}}}");
if is_transaction_write(&operation) {
return self.undo_transaction(&delegated);
}
if is_feature_write(&operation) {
return self.feature_write(&operation, &delegated);
}
return self.semantic_write(&operation, &delegated);
}
if is_semantic_write(name) {
return self.semantic_write(name, request);
}
let result = match name {
"query_project" => self.compact_query(request),
"inspect_project" => self.read_resource("noxid://project"),
"validate_project" => self.validate_compact(request),
"compile_project" => {
if self.is_project() {
let options = project::ProjectBuildOptions {
out_dir: project::default_out_dir(&self.input, false),
title: None,
development: false,
strict_npm: false,
};
let build = project::build_project(&self.input, &options)?;
Ok(format!(
"{{\"compiled\":true,\"routes\":{},\"components\":{},\"assets\":{},\"output\":\"{}\"}}",
build.routes,
build.components,
build.assets,
json_escape(&options.out_dir.display().to_string())
))
} else {
let output = self.compile_source()?;
Ok(format!(
"{{\"compiled\":{},\"diagnostics\":{}}}",
!output.has_errors(),
output.diagnostics_json()
))
}
}
"inspect_symbol"
| "find_references"
| "find_dependencies"
| "find_dependents"
| "calculate_change_impact" => {
let symbol = argument(request, "symbol")
.ok_or_else(|| format!("{name} requires `symbol`"))?;
if self.is_project() {
project::mcp_graph_query_json(&self.input, name, Some(&symbol))
} else {
source_graph_query(&self.compile_source()?.graph, name, Some(&symbol))
}
}
"list_components"
| "list_resources"
| "list_agents"
| "list_state_machines"
| "list_requirements"
| "list_scenarios" => {
if self.is_project() {
project::mcp_graph_query_json(&self.input, name, None)
} else {
source_graph_query(&self.compile_source()?.graph, name, None)
}
}
_ => Err(format!("unknown Noxid MCP tool `{name}`")),
}?;
Ok(ToolResult::Complete(result))
}
}
impl CompilerBackend {
fn apply_operations(&self, request: &str) -> Result<ToolResult, String> {
let raw = argument_json(request, "operations")
.ok_or("apply_operations requires an `operations` array")?;
let operations = json_object_array(&raw)?;
if operations.is_empty() || operations.len() > 32 {
return Err("apply_operations accepts 1 to 32 operations".into());
}
for operation in &operations {
let (name, _) = batch_operation(operation)?;
if !is_semantic_write(&name) && !is_feature_write(&name) {
return Err(format!("unknown semantic edit `{name}`"));
}
}
let mut hasher = DefaultHasher::new();
raw.hash(&mut hasher);
let state = format!("semantic-batch:{:016x}", hasher.finish());
if !semantic_write_accepted(request) || request_state(request).as_deref() != Some(&state) {
return Ok(ToolResult::InputRequired {
message: format!(
"Apply {} compiler-validated semantic operations atomically? Source is restored if any operation fails. No commit or push is performed.",
operations.len()
),
request_state: state,
});
}
let root = if self.is_project() {
if self.input.is_dir() {
self.input.clone()
} else {
self.input
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
} else {
self.input
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
};
let originals = snapshot_edit_files(&root)?;
let mut applied = Vec::new();
for operation in &operations {
let (name, payload) = batch_operation(operation)?;
let initial = format!("{{\"params\":{{\"arguments\":{payload}}}}}");
let plan_state = match self.dispatch_write(&name, &initial) {
Ok(ToolResult::InputRequired { request_state, .. }) => request_state,
Ok(ToolResult::Complete(_)) => {
restore_edit_snapshot(&root, &originals)?;
return Err("semantic operation unexpectedly skipped confirmation".into());
}
Err(error) => {
restore_edit_snapshot(&root, &originals)?;
return Err(format!(
"batch planning failed: {error}; source was restored"
));
}
};
let confirmed = format!(
"{{\"params\":{{\"arguments\":{payload},\"requestState\":\"{}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}}}}}",
json_escape(&plan_state)
);
match self.dispatch_write(&name, &confirmed) {
Ok(ToolResult::Complete(_)) => applied.push(name),
Ok(ToolResult::InputRequired { .. }) => {
restore_edit_snapshot(&root, &originals)?;
return Err("batch confirmation state changed; source was restored".into());
}
Err(error) => {
restore_edit_snapshot(&root, &originals)?;
return Err(format!("batch apply failed: {error}; source was restored"));
}
}
}
self.invalidate();
let applied = applied
.iter()
.map(|name| format!("\"{}\"", json_escape(name)))
.collect::<Vec<_>>()
.join(",");
Ok(ToolResult::Complete(format!(
"{{\"applied\":true,\"atomic\":true,\"operations\":[{applied}]}}"
)))
}
fn dispatch_write(&self, operation: &str, request: &str) -> Result<ToolResult, String> {
if is_feature_write(operation) {
self.feature_write(operation, request)
} else {
self.semantic_write(operation, request)
}
}
fn feature_write(&self, operation: &str, request: &str) -> Result<ToolResult, String> {
if !self.is_project() {
return Err("compiler-owned feature scaffolding requires a Noxid project".into());
}
let spec = feature_spec(operation, request)?;
let plan = plan_feature_scaffold(&spec);
if !plan.safe_to_apply {
return Err(format!(
"feature scaffold is not safe to apply: {}",
plan.to_json()
));
}
let state = format!("semantic-feature:{}", plan.id);
if !semantic_write_accepted(request) || request_state(request).as_deref() != Some(&state) {
return Ok(ToolResult::InputRequired {
message: format!(
"Apply compiler-owned {} scaffold `{}`? The generated files and acceptance contract will be validated atomically. Plan: {}. No commit or push is performed.",
spec.kind.as_str(),
spec.name,
feature_plan_summary(&plan)
),
request_state: state,
});
}
let root = project_root(&self.input);
let mut created = Vec::new();
for file in &plan.files {
let path = safe_project_path(&root, &file.path)?;
if path.exists() && !file.overwrite {
return Err(format!("refusing to overwrite existing {}", path.display()));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
if let Err(error) = fs::write(&path, &file.content) {
remove_created_files(&created)?;
return Err(format!("cannot write {}: {error}", path.display()));
}
created.push(path);
}
if let Err(error) = project::validate_semantic_edit(&self.input) {
remove_created_files(&created)?;
return Err(format!("{error}; generated feature files were removed"));
}
let provenance = root
.join(".nox/provenance")
.join(format!("{}.json", plan.id));
if let Some(parent) = provenance.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
if let Err(error) = fs::write(&provenance, plan.to_json()) {
remove_created_files(&created)?;
return Err(format!(
"cannot write feature provenance {}: {error}; generated files were removed",
provenance.display()
));
}
self.invalidate();
Ok(ToolResult::Complete(format!(
"{{\"applied\":true,\"operation\":\"{}\",\"plan\":{}}}",
json_escape(operation),
feature_plan_summary(&plan)
)))
}
fn semantic_write(&self, operation: &str, request: &str) -> Result<ToolResult, String> {
let target_key = if operation == "rename_symbol" {
"symbol"
} else if operation == "add_transition" {
"machine"
} else {
"component"
};
let target_value = argument(request, target_key)
.ok_or_else(|| format!("{operation} requires `{target_key}`"))?;
let target = SemanticId::parse(&target_value)
.ok_or_else(|| format!("invalid semantic ID `{target_value}`"))?;
if self.is_project() && operation == "rename_symbol" {
let (sources, graph) = project::semantic_project_context(&self.input)?;
return self.project_rename(request, sources, &graph, &target);
}
let (path, graph, definition) = if self.is_project() {
project::semantic_edit_context(&self.input, &target)?
} else {
let output = self.compile_source()?;
let owner = owning_component(&output.graph, &target)?;
let definition = output
.program
.components
.iter()
.find(|component| component.id == owner)
.cloned()
.ok_or_else(|| format!("cannot locate semantic definition for `{owner}`"))?;
(self.input.clone(), output.graph, definition)
};
let text = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let source = SourceFile::new(SourceId(0), &path, text.clone());
let provenance = Provenance {
created_by: argument(request, "createdBy"),
reason: argument(request, "reason"),
task_id: argument(request, "taskId"),
};
let mut plan = build_plan(
operation,
request,
&source,
&graph,
&definition,
&target,
provenance,
)?;
if self.is_project() {
plan.validate_with(|candidate| {
let candidate = SourceFile::new(SourceId(0), &path, candidate);
let parsed = noxid_parser::parse(&candidate);
if parsed.diagnostics.is_empty() {
Ok(())
} else {
Err(format!(
"edit produced {} syntax diagnostic(s)",
parsed.diagnostics.len()
))
}
})?;
} else {
plan.validate_with(|candidate| {
let candidate = SourceFile::new(SourceId(0), &path, candidate);
let output = compile(&candidate);
if output.has_errors() {
Err(format!(
"edit produced {} compiler diagnostic(s)",
output.diagnostics.len()
))
} else {
Ok(())
}
})?;
}
let intent_drift = source_intent_drift(&path, &text, &plan.source);
if intent_drift.has_errors() {
return Err(format!("INTENT_DRIFT_BLOCKED: {}", intent_drift.to_json()));
}
let intent_drift_json = intent_drift.to_json();
let transaction = transaction_preview(&plan, &text, &graph)?;
let state = format!("semantic-write:{}", plan.id);
if !semantic_write_accepted(request) || request_state(request).as_deref() != Some(&state) {
return Ok(ToolResult::InputRequired {
message: format!(
"{} in {}? Validated transaction: {}. Intent drift: {}. This writes source and provenance, but does not commit or push.",
plan.description,
path.display(),
transaction,
intent_drift_json,
),
request_state: state,
});
}
let current = fs::read_to_string(&path)
.map_err(|error| format!("cannot re-read {}: {error}", path.display()))?;
if !plan.verifies_source(¤t) {
return Err("semantic edit plan is stale; request a new plan before writing".into());
}
fs::write(&path, &plan.source)
.map_err(|error| format!("cannot write {}: {error}", path.display()))?;
if self.is_project()
&& let Err(error) = project::validate_semantic_edit(&self.input)
{
fs::write(&path, ¤t).map_err(|restore| {
format!(
"{error}; additionally failed to restore {}: {restore}",
path.display()
)
})?;
return Err(format!("{error}; source was restored"));
}
let provenance_path = match write_provenance(&self.input, self.is_project(), &plan) {
Ok(path) => path,
Err(error) => {
fs::write(&path, ¤t).map_err(|restore| {
format!(
"{error}; additionally failed to restore {}: {restore}",
path.display()
)
})?;
return Err(format!("{error}; source was restored"));
}
};
let transaction_id = match write_transaction_record(
&self.input,
self.is_project(),
&plan,
&transaction,
¤t,
) {
Ok(id) => id,
Err(error) => {
let provenance_cleanup = fs::remove_file(&provenance_path)
.err()
.map(|cleanup| format!("; provenance cleanup failed: {cleanup}"))
.unwrap_or_default();
fs::write(&path, ¤t).map_err(|restore| {
format!(
"{error}{provenance_cleanup}; additionally failed to restore {}: {restore}",
path.display()
)
})?;
return Err(format!("{error}{provenance_cleanup}; source was restored"));
}
};
self.invalidate();
Ok(ToolResult::Complete(format!(
"{{\"applied\":true,\"plan\":{},\"transaction\":{},\"intentDrift\":{},\"undoTransaction\":\"{}\",\"undoOperation\":{{\"operation\":\"undo_transaction\",\"payload\":{{\"transaction\":\"{}\"}}}}}}",
semantic_plan_summary(&plan),
transaction,
intent_drift_json,
json_escape(&transaction_id),
json_escape(&transaction_id),
)))
}
fn undo_transaction(&self, request: &str) -> Result<ToolResult, String> {
let transaction_id =
argument(request, "transaction").ok_or("undo_transaction requires `transaction`")?;
validate_transaction_id(&transaction_id)?;
let root = project_root(&self.input);
let transaction_path = local_transaction_path(&root, &transaction_id)?;
let record = fs::read_to_string(&transaction_path).map_err(|error| {
format!(
"cannot read local semantic transaction {}: {error}",
transaction_path.display()
)
})?;
let wrapped = format!("{{\"params\":{{\"arguments\":{record}}}}}");
let status = argument(&wrapped, "status")
.ok_or("semantic transaction record is missing `status`")?;
if status != "applied" {
return Err(format!(
"semantic transaction `{transaction_id}` cannot be undone from status `{status}`"
));
}
let source_value = argument(&wrapped, "source")
.ok_or("semantic transaction record is missing `source`")?;
let previous_source = argument(&wrapped, "previousSource")
.ok_or("semantic transaction record is missing `previousSource`")?;
let previous_hash = argument(&wrapped, "previousSourceHash")
.ok_or("semantic transaction record is missing `previousSourceHash`")?;
let applied_hash = argument(&wrapped, "appliedSourceHash")
.ok_or("semantic transaction record is missing `appliedSourceHash`")?;
if source_hash(&previous_source) != previous_hash {
return Err("semantic transaction previous source failed its integrity check".into());
}
let source_path = contained_transaction_source(
&root,
&self.input,
self.is_project(),
Path::new(&source_value),
)?;
let current = fs::read_to_string(&source_path)
.map_err(|error| format!("cannot read {}: {error}", source_path.display()))?;
if source_hash(¤t) != applied_hash {
return Err(format!(
"semantic transaction `{transaction_id}` is stale; source changed after it was applied"
));
}
let state = format!(
"semantic-undo:{}:{}",
transaction_id,
applied_hash.replace(':', "-")
);
if !semantic_write_accepted(request) || request_state(request).as_deref() != Some(&state) {
return Ok(ToolResult::InputRequired {
message: format!(
"Undo local semantic transaction `{transaction_id}` in {}? The previous source hash is verified and the restored project will be compiler-validated. No commit or push is performed.",
source_path.display()
),
request_state: state,
});
}
fs::write(&source_path, &previous_source)
.map_err(|error| format!("cannot restore {}: {error}", source_path.display()))?;
let validation = if self.is_project() {
project::validate_semantic_edit(&self.input)
} else {
let output = compile(&SourceFile::new(
SourceId(0),
&source_path,
previous_source.clone(),
));
if output.has_errors() {
Err(format!(
"undo produced {} compiler diagnostic(s)",
output.diagnostics.len()
))
} else {
Ok(())
}
};
if let Err(error) = validation {
fs::write(&source_path, ¤t).map_err(|restore| {
format!(
"{error}; additionally failed to restore {}: {restore}",
source_path.display()
)
})?;
return Err(format!("{error}; applied source was restored"));
}
let provenance_path = match write_undo_provenance(
&root,
&transaction_id,
&source_path,
&applied_hash,
&previous_hash,
) {
Ok(path) => path,
Err(error) => {
fs::write(&source_path, ¤t).map_err(|restore| {
format!(
"{error}; additionally failed to restore {}: {restore}",
source_path.display()
)
})?;
return Err(format!("{error}; applied source was restored"));
}
};
let updated_record = match mark_transaction_undone(
&record,
&provenance_path,
&source_hash(&previous_source),
) {
Ok(record) => record,
Err(error) => {
let cleanup_error = fs::remove_file(&provenance_path)
.err()
.map(|cleanup| format!("; provenance cleanup failed: {cleanup}"))
.unwrap_or_default();
fs::write(&source_path, ¤t).map_err(|restore| {
format!(
"{error}{cleanup_error}; additionally failed to restore {}: {restore}",
source_path.display()
)
})?;
return Err(format!(
"{error}{cleanup_error}; applied source was restored"
));
}
};
if let Err(error) = fs::write(&transaction_path, updated_record) {
let cleanup_error = fs::remove_file(&provenance_path)
.err()
.map(|cleanup| format!("; provenance cleanup failed: {cleanup}"))
.unwrap_or_default();
fs::write(&source_path, ¤t).map_err(|restore| {
format!(
"cannot update transaction record: {error}{cleanup_error}; additionally failed to restore {}: {restore}",
source_path.display()
)
})?;
return Err(format!(
"cannot update transaction record: {error}{cleanup_error}; applied source was restored"
));
}
self.invalidate();
Ok(ToolResult::Complete(format!(
"{{\"undone\":true,\"transaction\":\"{}\",\"source\":\"{}\",\"restoredSourceHash\":\"{}\",\"provenance\":\"{}\"}}",
json_escape(&transaction_id),
json_escape(&source_path.display().to_string()),
json_escape(&previous_hash),
json_escape(&provenance_path.display().to_string()),
)))
}
fn project_rename(
&self,
request: &str,
sources: Vec<SourceFile>,
graph: &ApplicationGraph,
target: &SemanticId,
) -> Result<ToolResult, String> {
let new_name = argument(request, "newName").ok_or("rename_symbol requires `newName`")?;
let provenance = Provenance {
created_by: argument(request, "createdBy"),
reason: argument(request, "reason"),
task_id: argument(request, "taskId"),
};
let mut plan = plan_project_rename(&sources, graph, target, &new_name, provenance)?;
plan.validate_with(|path, candidate| {
let candidate = SourceFile::new(SourceId(0), path, candidate);
let parsed = noxid_parser::parse(&candidate);
if parsed.diagnostics.is_empty() {
Ok(())
} else {
Err(format!(
"project rename produced {} syntax diagnostic(s) in {path}",
parsed.diagnostics.len()
))
}
})?;
let state = format!("semantic-write:{}", plan.id);
if !semantic_write_accepted(request) || request_state(request).as_deref() != Some(&state) {
return Ok(ToolResult::InputRequired {
message: format!(
"{}? Validated multi-file plan: {}. This writes source and provenance, but does not commit or push.",
plan.description,
project_plan_summary(&plan)
),
request_state: state,
});
}
let mut originals = Vec::new();
for file in &plan.files {
let path = PathBuf::from(&file.source_path);
let source = fs::read_to_string(&path)
.map_err(|error| format!("cannot re-read {}: {error}", path.display()))?;
if !plan.verifies_source(&file.source_path, &source) {
return Err(format!(
"semantic edit plan is stale for {}; request a new plan before writing",
path.display()
));
}
let destination = file.destination_path.as_ref().map(PathBuf::from);
if let Some(destination) = &destination
&& destination != &path
&& destination.exists()
{
return Err(format!(
"cannot rename {} because {} already exists",
path.display(),
destination.display()
));
}
originals.push(AppliedProjectFile {
source_path: path,
destination_path: destination,
source,
});
}
for file in &plan.files {
let path = PathBuf::from(&file.source_path);
let destination = file.destination_path.as_ref().map(PathBuf::from);
let write_path = if let Some(destination) = &destination {
if let Err(error) = fs::rename(&path, destination) {
restore_sources(&originals)?;
return Err(format!(
"cannot move {} to {}: {error}; project sources were restored",
path.display(),
destination.display()
));
}
destination
} else {
&path
};
if let Err(error) = fs::write(write_path, &file.source) {
restore_sources(&originals)?;
return Err(format!(
"cannot write {}: {error}; project sources were restored",
write_path.display()
));
}
}
if let Err(error) = project::validate_semantic_edit(&self.input) {
restore_sources(&originals)?;
return Err(format!("{error}; project sources were restored"));
}
if let Err(error) = write_project_provenance(&self.input, &plan) {
restore_sources(&originals)?;
return Err(format!("{error}; project sources were restored"));
}
self.invalidate();
Ok(ToolResult::Complete(format!(
"{{\"applied\":true,\"plan\":{}}}",
project_plan_summary(&plan)
)))
}
}
fn is_semantic_write(name: &str) -> bool {
matches!(
name,
"rename_symbol"
| "add_state"
| "add_action"
| "add_transition"
| "add_requirement"
| "add_scenario"
| "modify_view"
| "extract_component"
)
}
fn is_transaction_write(name: &str) -> bool {
name == "undo_transaction"
}
fn is_feature_write(name: &str) -> bool {
matches!(
name,
"create_component"
| "create_route"
| "create_form"
| "create_list_page"
| "create_resource"
| "create_crud_resource"
| "create_state_machine"
)
}
fn feature_spec(operation: &str, request: &str) -> Result<FeatureSpec, String> {
let kind = match operation {
"create_component" => FeatureKind::Component,
"create_route" => FeatureKind::Route,
"create_form" => FeatureKind::Form,
"create_list_page" => FeatureKind::ListPage,
"create_resource" | "create_crud_resource" => FeatureKind::Resource,
"create_state_machine" => FeatureKind::StateMachine,
_ => return Err(format!("unknown feature operation `{operation}`")),
};
let name = argument(request, "name").ok_or_else(|| format!("{operation} requires `name`"))?;
let mut spec = FeatureSpec::component(name);
spec.kind = kind;
spec.route = argument(request, "route");
spec.fields = typed_fields(argument(request, "fields").as_deref())?;
spec.capabilities = csv_values(argument(request, "capabilities").as_deref());
spec.requirement = argument(request, "requirement");
if kind == FeatureKind::Resource {
spec.resource = Some(ResourceContract {
parameters: typed_fields(argument(request, "parameters").as_deref())?,
output_type: argument(request, "outputType")
.ok_or_else(|| format!("{operation} requires `outputType`"))?,
method: argument(request, "method").unwrap_or_else(|| "GET".into()),
path: argument(request, "path")
.ok_or_else(|| format!("{operation} requires `path`"))?,
cache: argument(request, "cache"),
retry: argument(request, "retry")
.map(|value| {
value
.parse::<u32>()
.map_err(|_| "resource retry must be a non-negative integer")
})
.transpose()?,
});
}
if kind == FeatureKind::StateMachine {
spec.machine = Some(MachineContract {
variants: machine_variants(argument(request, "variants").as_deref())?,
initial: argument(request, "initial")
.ok_or("create_state_machine requires `initial`")?,
initial_payload: argument(request, "initialPayload"),
transitions: machine_transitions(argument(request, "transitions").as_deref())?,
});
}
Ok(spec)
}
fn typed_fields(value: Option<&str>) -> Result<Vec<(String, String)>, String> {
csv_values(value)
.into_iter()
.map(|field| {
field
.split_once(':')
.map(|(name, ty)| (name.trim().into(), ty.trim().into()))
.ok_or_else(|| format!("typed field `{field}` must use name:Type"))
})
.collect()
}
fn machine_variants(value: Option<&str>) -> Result<Vec<MachineVariant>, String> {
csv_values(value)
.into_iter()
.map(|variant| {
let (name, payload_type) = variant
.split_once(':')
.map_or((variant.as_str(), None), |(name, ty)| {
(name, Some(ty.into()))
});
Ok(MachineVariant {
name: name.trim().into(),
payload_type,
})
})
.collect()
}
fn machine_transitions(value: Option<&str>) -> Result<Vec<MachineTransition>, String> {
csv_values(value)
.into_iter()
.map(|transition| {
let (states, event_and_payload) = transition
.split_once(':')
.ok_or_else(|| format!("transition `{transition}` must use From>To:event"))?;
let (from, to) = states
.split_once('>')
.ok_or_else(|| format!("transition `{transition}` must use From>To:event"))?;
let (event, payload) = event_and_payload
.split_once('=')
.map_or((event_and_payload, None), |(event, payload)| {
(event, Some(payload.into()))
});
Ok(MachineTransition {
from: from.trim().into(),
to: to.trim().into(),
event: event.trim().into(),
payload,
})
})
.collect()
}
fn project_root(input: &Path) -> PathBuf {
if input.is_dir() {
input.to_path_buf()
} else {
input
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
}
fn semantic_index_path(input: &Path, is_project: bool) -> Result<PathBuf, String> {
let root = if is_project {
project_root(input)
} else {
input
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
};
let directory = root.join(".nox");
if directory
.symlink_metadata()
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(format!(
"refusing semantic index through symlinked {}",
directory.display()
));
}
Ok(directory.join("semantic-index.json"))
}
fn safe_project_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
let relative = Path::new(relative);
if relative.is_absolute()
|| relative.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
return Err(format!(
"unsafe generated project path `{}`",
relative.display()
));
}
Ok(root.join(relative))
}
fn remove_created_files(paths: &[PathBuf]) -> Result<(), String> {
let mut failures = Vec::new();
for path in paths.iter().rev() {
if path.exists()
&& let Err(error) = fs::remove_file(path)
{
failures.push(format!("{}: {error}", path.display()));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(format!(
"failed to remove generated files: {}",
failures.join(", ")
))
}
}
fn structured_payload(request: &str, tool: &str) -> Result<String, String> {
let raw = argument_json(request, "payload")
.ok_or_else(|| format!("{tool} requires an object `payload`"))?;
let payload = if raw.trim_start().starts_with('"') {
argument(request, "payload")
.ok_or_else(|| format!("{tool} payload is not a valid JSON string"))?
} else {
raw
};
if !payload.trim_start().starts_with('{') || !payload.trim_end().ends_with('}') {
return Err(format!("{tool} payload must be a JSON object"));
}
Ok(payload)
}
fn batch_operation(operation: &str) -> Result<(String, String), String> {
let request = format!("{{\"params\":{{\"arguments\":{operation}}}}}");
let name =
argument(&request, "operation").ok_or("every batch item requires a string `operation`")?;
let payload = if argument_json(&request, "payload").is_some() {
structured_payload(&request, "apply_operations")?
} else {
operation.to_string()
};
Ok((name, payload))
}
fn semantic_plan_summary(plan: &SemanticEditPlan) -> String {
format!(
"{{\"schemaVersion\":1,\"planId\":\"{}\",\"operation\":\"{}\",\"target\":\"{}\",\"description\":\"{}\",\"source\":\"{}\",\"editCount\":{},\"validated\":{}}}",
json_escape(&plan.id),
json_escape(&plan.operation),
plan.target,
json_escape(&plan.description),
json_escape(&plan.source_path),
plan.edits.len(),
plan.is_valid()
)
}
fn project_plan_summary(plan: &ProjectEditPlan) -> String {
let edit_count = plan
.files
.iter()
.map(|file| file.edits.len())
.sum::<usize>();
format!(
"{{\"schemaVersion\":1,\"planId\":\"{}\",\"operation\":\"{}\",\"target\":\"{}\",\"replacementTarget\":\"{}\",\"description\":\"{}\",\"fileCount\":{},\"editCount\":{edit_count}}}",
json_escape(&plan.id),
json_escape(&plan.operation),
plan.target,
plan.replacement_target,
json_escape(&plan.description),
plan.files.len(),
)
}
fn feature_plan_summary(plan: &ScaffoldPlan) -> String {
format!(
"{{\"schemaVersion\":1,\"planId\":\"{}\",\"kind\":\"{}\",\"name\":\"{}\",\"fileCount\":{},\"safeToApply\":{},\"acceptance\":{}}}",
json_escape(&plan.id),
plan.spec.kind.as_str(),
json_escape(&plan.spec.name),
plan.files.len(),
plan.safe_to_apply,
plan.acceptance
.as_ref()
.map(|contract| contract.to_json())
.unwrap_or_else(|| "null".into())
)
}
fn source_intent_drift(path: &Path, before: &str, after: &str) -> noxid_ai_eval::IntentDriftReport {
let before = compile(&SourceFile::new(SourceId(0), path, before.to_string()));
let after = compile(&SourceFile::new(SourceId(0), path, after.to_string()));
check_intent_drift(&before.program, &after.program)
}
fn transaction_preview(
plan: &SemanticEditPlan,
current: &str,
graph: &ApplicationGraph,
) -> Result<String, String> {
let mut documents = DocumentSet::new();
documents.insert(plan.source_path.clone(), current.into());
let mut engine = TransactionEngine::new(documents);
let sandbox = CapabilitySandbox::editor(SessionId::new("local-mcp")?);
let branch = engine.begin_branch(&sandbox)?;
let operation = SemanticOperation::from_edit_plan(engine.committed_snapshot(), plan);
let operation_id = operation.id.clone();
let operation_kind = operation.kind.clone();
let operation_target = operation.target.clone();
engine
.apply_operation(&branch, &sandbox, operation)
.map_err(|conflict| conflict.to_json())?;
engine.validate_branch(
&branch,
&sandbox,
|_| ValidationReport::valid(String::new()),
)?;
let preview = engine.preview(&branch, &sandbox, Some(graph))?;
let commit = engine.commit(&branch, &sandbox)?;
let undo = engine
.undo_records()
.last()
.ok_or("semantic transaction did not produce an undo record")?;
let writes = preview
.semantic_writes
.iter()
.map(|id| format!("\"{}\"", id))
.collect::<Vec<_>>()
.join(",");
let affected_by_kind = preview
.affected_by_kind
.iter()
.map(|(kind, count)| format!("\"{}\":{count}", json_escape(kind)))
.collect::<Vec<_>>()
.join(",");
let authorities = preview
.required_authorities
.iter()
.map(|authority| format!("\"{}\"", json_escape(authority)))
.collect::<Vec<_>>()
.join(",");
Ok(format!(
"{{\"protocol\":\"noxid-semantic-ops/1\",\"operationId\":\"{}\",\"kind\":\"{}\",\"target\":\"{}\",\"writes\":[{writes}],\"affectedCount\":{},\"affectedByKind\":{{{affected_by_kind}}},\"requires\":[{authorities}],\"sourceByteDelta\":{},\"commitId\":\"{}\",\"undoId\":\"{}\",\"undoOperation\":\"available-after-apply\"}}",
json_escape(&operation_id),
json_escape(&operation_kind),
operation_target,
preview.affected.len(),
preview.estimated_source_byte_delta,
json_escape(&commit.id),
json_escape(&undo.id),
))
}
fn write_transaction_record(
input: &Path,
is_project: bool,
plan: &SemanticEditPlan,
transaction: &str,
previous_source: &str,
) -> Result<String, String> {
let root = if is_project {
project_root(input)
} else {
input
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
};
let directory = local_storage_directory(&root, ".nox/transactions")?;
let base_id = transaction_record_id(plan);
for sequence in 1_u32..=10_000 {
let transaction_id = if sequence == 1 {
base_id.clone()
} else {
format!("{base_id}-{sequence}")
};
let path = directory.join(format!("{transaction_id}.json"));
let record = format!(
"{{\"schemaVersion\":1,\"storage\":\"local-only\",\"status\":\"applied\",\"recordId\":\"{}\",\"planId\":\"{}\",\"source\":\"{}\",\"previousSourceHash\":\"{}\",\"appliedSourceHash\":\"{}\",\"previousSource\":\"{}\",\"transaction\":{transaction}}}",
json_escape(&transaction_id),
json_escape(&plan.id),
json_escape(&plan.source_path),
source_hash(previous_source),
source_hash(&plan.source),
json_escape(previous_source)
);
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
file.write_all(record.as_bytes()).map_err(|error| {
let _ = fs::remove_file(&path);
format!(
"cannot write transaction record {}: {error}",
path.display()
)
})?;
return Ok(transaction_id);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"cannot create transaction record {}: {error}",
path.display()
));
}
}
}
Err("local semantic transaction record sequence is exhausted".into())
}
fn transaction_record_id(plan: &SemanticEditPlan) -> String {
plan.id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'-'
}
})
.collect()
}
fn validate_transaction_id(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.len() > 128
|| !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
{
Err("transaction IDs may contain only ASCII letters, digits, `-`, and `_`".into())
} else {
Ok(())
}
}
fn local_transaction_path(root: &Path, transaction_id: &str) -> Result<PathBuf, String> {
let root = fs::canonicalize(root).map_err(|error| {
format!(
"cannot resolve transaction root {}: {error}",
root.display()
)
})?;
let directory = root.join(".nox/transactions");
let directory = fs::canonicalize(&directory).map_err(|error| {
format!(
"cannot resolve local transaction directory {}: {error}",
directory.display()
)
})?;
if !directory.starts_with(&root) {
return Err("local semantic transaction directory escapes the project root".into());
}
let path = directory.join(format!("{transaction_id}.json"));
let resolved = fs::canonicalize(&path).map_err(|error| {
format!(
"cannot resolve local semantic transaction {}: {error}",
path.display()
)
})?;
if !resolved.starts_with(&directory) {
return Err("semantic transaction record escapes the local transaction directory".into());
}
Ok(resolved)
}
fn local_storage_directory(root: &Path, relative: &str) -> Result<PathBuf, String> {
let root = fs::canonicalize(root).map_err(|error| {
format!(
"cannot resolve local storage root {}: {error}",
root.display()
)
})?;
let directory = root.join(relative);
fs::create_dir_all(&directory)
.map_err(|error| format!("cannot create {}: {error}", directory.display()))?;
let directory = fs::canonicalize(&directory)
.map_err(|error| format!("cannot resolve {}: {error}", directory.display()))?;
if !directory.starts_with(&root) {
Err(format!(
"local storage `{relative}` escapes the project root"
))
} else {
Ok(directory)
}
}
fn contained_transaction_source(
root: &Path,
input: &Path,
is_project: bool,
source: &Path,
) -> Result<PathBuf, String> {
let root = fs::canonicalize(root).map_err(|error| {
format!(
"cannot resolve transaction root {}: {error}",
root.display()
)
})?;
let source = if source.is_absolute() {
source.to_path_buf()
} else {
root.join(source)
};
let source = fs::canonicalize(&source).map_err(|error| {
format!(
"cannot resolve transaction source {}: {error}",
source.display()
)
})?;
if !source.starts_with(&root) {
return Err("semantic transaction source escapes the local project root".into());
}
if !is_project {
let input = fs::canonicalize(input)
.map_err(|error| format!("cannot resolve {}: {error}", input.display()))?;
if source != input {
return Err("single-file MCP may undo only its configured Noxid source".into());
}
}
Ok(source)
}
fn write_undo_provenance(
root: &Path,
transaction_id: &str,
source: &Path,
applied_hash: &str,
restored_hash: &str,
) -> Result<PathBuf, String> {
let directory = local_storage_directory(root, ".nox/provenance")?;
let path = directory.join(format!("undo-{transaction_id}.json"));
let record = format!(
"{{\"schemaVersion\":1,\"operation\":\"undo_transaction\",\"storage\":\"local-only\",\"session\":\"local-mcp\",\"transaction\":\"{}\",\"source\":\"{}\",\"appliedSourceHash\":\"{}\",\"restoredSourceHash\":\"{}\"}}",
json_escape(transaction_id),
json_escape(&source.display().to_string()),
json_escape(applied_hash),
json_escape(restored_hash),
);
fs::write(&path, record)
.map_err(|error| format!("cannot write undo provenance {}: {error}", path.display()))?;
Ok(path)
}
fn mark_transaction_undone(
record: &str,
provenance: &Path,
restored_hash: &str,
) -> Result<String, String> {
let updated = record.replacen("\"status\":\"applied\"", "\"status\":\"undone\"", 1);
if updated == record {
return Err("semantic transaction record does not contain an applied status".into());
}
let body = updated
.trim_end()
.strip_suffix('}')
.ok_or("semantic transaction record is not a JSON object")?;
Ok(format!(
"{body},\"undoProvenance\":\"{}\",\"restoredSourceHash\":\"{}\"}}",
json_escape(&provenance.display().to_string()),
json_escape(restored_hash),
))
}
fn build_plan(
operation: &str,
request: &str,
source: &SourceFile,
graph: &ApplicationGraph,
definition: &noxid_ir::ComponentDefinition,
target: &SemanticId,
provenance: Provenance,
) -> Result<SemanticEditPlan, String> {
let required =
|key: &str| argument(request, key).ok_or_else(|| format!("{operation} requires `{key}`"));
match operation {
"rename_symbol" => {
plan_rename_symbol(source, graph, target, &required("newName")?, provenance)
}
"add_state" => plan_add_state(
source,
graph,
target,
&required("name")?,
&required("type")?,
&required("initializer")?,
provenance,
),
"add_action" => {
let name = required("name")?;
let execution = argument(request, "execution").unwrap_or_else(|| "client".into());
let parameters = argument(request, "parameters").unwrap_or_default();
let return_type = argument(request, "returnType");
let capabilities = argument(request, "capabilities").unwrap_or_default();
let body = required("body")?;
plan_add_action(
source,
graph,
target,
ActionSpec {
name: &name,
execution: &execution,
parameters: ¶meters,
return_type: return_type.as_deref(),
capabilities: &capabilities,
body: &body,
},
provenance,
)
}
"add_transition" => plan_add_transition(
source,
graph,
target,
&required("from")?,
&required("to")?,
&required("event")?,
provenance,
),
"add_requirement" => {
let name = required("name")?;
let description = required("description")?;
let verify = argument(request, "verify").unwrap_or_default();
let depends = argument(request, "depends").unwrap_or_default();
plan_add_requirement(
source,
graph,
target,
RequirementSpec {
name: &name,
description: &description,
verify: &verify,
depends: &depends,
},
provenance,
)
}
"add_scenario" => {
let name = required("name")?;
let description = required("description")?;
let given = argument(request, "given").unwrap_or_default();
let when = argument(request, "when").unwrap_or_default();
let expect = argument(request, "expect").unwrap_or_default();
let covers = argument(request, "covers").unwrap_or_default();
let depends = argument(request, "depends").unwrap_or_default();
plan_add_scenario(
source,
graph,
target,
ScenarioSpec {
name: &name,
description: &description,
given: &given,
when: &when,
expect: &expect,
covers: &covers,
depends: &depends,
},
provenance,
)
}
"modify_view" => plan_modify_view(source, graph, target, &required("view")?, provenance),
"extract_component" => plan_extract_component(
source,
graph,
definition,
target,
&required("newName")?,
provenance,
),
_ => Err(format!("unknown semantic write `{operation}`")),
}
}
fn owning_component(graph: &ApplicationGraph, symbol: &SemanticId) -> Result<SemanticId, String> {
let mut current = symbol.clone();
loop {
let node = graph
.nodes
.get(¤t)
.ok_or_else(|| format!("unknown semantic symbol `{symbol}`"))?;
if node.kind == noxid_graph::NodeKind::Component {
return Ok(current);
}
current = graph
.edges
.iter()
.find(|edge| edge.kind == noxid_graph::EdgeKind::Owns && edge.to == current)
.map(|edge| edge.from.clone())
.ok_or_else(|| format!("`{symbol}` is not owned by a component"))?;
}
}
fn write_provenance(
input: &Path,
is_project: bool,
plan: &SemanticEditPlan,
) -> Result<PathBuf, String> {
let root = if is_project {
if input.is_dir() {
input
} else {
input.parent().unwrap_or_else(|| Path::new("."))
}
} else {
input.parent().unwrap_or_else(|| Path::new("."))
};
let directory = local_storage_directory(root, ".nox/provenance")?;
let file_name = format!("{}.json", plan.id.replace(':', "-"));
let path = directory.join(file_name);
fs::write(&path, plan.to_json())
.map_err(|error| format!("cannot write {}: {error}", path.display()))?;
Ok(path)
}
fn write_project_provenance(input: &Path, plan: &ProjectEditPlan) -> Result<(), String> {
let root = if input.is_dir() {
input
} else {
input.parent().unwrap_or_else(|| Path::new("."))
};
let directory = root.join(".nox/provenance");
fs::create_dir_all(&directory)
.map_err(|error| format!("cannot create {}: {error}", directory.display()))?;
let path = directory.join(format!("{}.json", plan.id.replace(':', "-")));
fs::write(&path, plan.to_json())
.map_err(|error| format!("cannot write {}: {error}", path.display()))
}
struct AppliedProjectFile {
source_path: PathBuf,
destination_path: Option<PathBuf>,
source: String,
}
fn restore_sources(originals: &[AppliedProjectFile]) -> Result<(), String> {
let mut failures = Vec::new();
for file in originals.iter().rev() {
if let Some(destination) = &file.destination_path
&& destination.exists()
&& !file.source_path.exists()
&& let Err(error) = fs::rename(destination, &file.source_path)
{
failures.push(format!(
"{} -> {}: {error}",
destination.display(),
file.source_path.display()
));
continue;
}
if let Err(error) = fs::write(&file.source_path, &file.source) {
failures.push(format!("{}: {error}", file.source_path.display()));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(format!(
"failed to restore sources: {}",
failures.join(", ")
))
}
}
fn json_object_array(raw: &str) -> Result<Vec<String>, String> {
let bytes = raw.as_bytes();
if bytes.first() != Some(&b'[') || bytes.last() != Some(&b']') {
return Err("operations must be a JSON array".into());
}
let mut objects = Vec::new();
let mut start = None;
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (index, byte) in bytes.iter().copied().enumerate().skip(1) {
if in_string {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'\"' {
in_string = false;
}
continue;
}
match byte {
b'\"' => {
if depth == 0 {
return Err("operations may contain only JSON objects".into());
}
in_string = true;
}
b'{' => {
if depth == 0 {
start = Some(index);
}
depth += 1;
}
b'}' => {
if depth == 0 {
return Err("unbalanced object in operations".into());
}
depth -= 1;
if depth == 0 {
objects.push(raw[start.expect("object start")..=index].to_string());
start = None;
}
}
value
if depth == 0 && !value.is_ascii_whitespace() && value != b',' && value != b']' =>
{
return Err("operations may contain only JSON objects".into());
}
_ => {}
}
}
if depth != 0 || in_string {
return Err("unterminated JSON in operations".into());
}
Ok(objects)
}
fn snapshot_edit_files(root: &Path) -> Result<BTreeMap<PathBuf, String>, String> {
fn visit(root: &Path, output: &mut BTreeMap<PathBuf, String>) -> Result<(), String> {
if !root.exists() {
return Ok(());
}
for entry in fs::read_dir(root)
.map_err(|error| format!("cannot inspect {}: {error}", root.display()))?
{
let entry = entry.map_err(|error| error.to_string())?;
let path = entry.path();
if path.is_dir() {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
if !matches!(name, "target" | "dist" | "node_modules" | ".git") {
visit(&path, output)?;
}
} else if path.extension().and_then(|value| value.to_str()) == Some("nox")
|| path
.parent()
.is_some_and(|parent| parent.ends_with(".nox/provenance"))
|| path
.parent()
.is_some_and(|parent| parent.ends_with(".nox/transactions"))
{
output.insert(
path.clone(),
fs::read_to_string(&path)
.map_err(|error| format!("cannot snapshot {}: {error}", path.display()))?,
);
}
}
Ok(())
}
let mut output = BTreeMap::new();
visit(root, &mut output)?;
Ok(output)
}
fn restore_edit_snapshot(root: &Path, snapshot: &BTreeMap<PathBuf, String>) -> Result<(), String> {
let current = snapshot_edit_files(root)?;
for path in current.keys().filter(|path| !snapshot.contains_key(*path)) {
fs::remove_file(path)
.map_err(|error| format!("cannot remove batch-created {}: {error}", path.display()))?;
}
for (path, source) in snapshot {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot restore {}: {error}", parent.display()))?;
}
fs::write(path, source)
.map_err(|error| format!("cannot restore {}: {error}", path.display()))?;
}
Ok(())
}
fn source_graph_query(
graph: &ApplicationGraph,
operation: &str,
symbol: Option<&str>,
) -> Result<String, String> {
source_graph_query_limited(graph, operation, symbol, usize::MAX)
}
fn csv_values(value: Option<&str>) -> Vec<String> {
value
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn diagnostic_key(diagnostic: &noxid_source::Diagnostic) -> String {
format!(
"{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
diagnostic.code,
diagnostic.symbol.as_deref().unwrap_or_default(),
diagnostic.span.start,
diagnostic.span.end,
diagnostic.message
)
}
fn semantic_ids(value: Option<&str>) -> Result<Vec<SemanticId>, String> {
csv_values(value)
.into_iter()
.map(|value| {
SemanticId::parse(&value).ok_or_else(|| format!("invalid semantic ID `{value}`"))
})
.collect()
}
fn semantic_id_argument(request: &str, key: &str) -> Result<SemanticId, String> {
let value = argument(request, key).ok_or_else(|| format!("missing `{key}` semantic ID"))?;
SemanticId::parse(&value).ok_or_else(|| format!("invalid semantic ID `{value}`"))
}
fn catalog_kind(value: &str) -> Result<CatalogKind, String> {
match value {
"feature" => Ok(CatalogKind::Feature),
"operation" => Ok(CatalogKind::Operation),
"type" => Ok(CatalogKind::Type),
"diagnostic" => Ok(CatalogKind::Diagnostic),
_ => Err(format!(
"unknown catalog kind `{value}`; expected feature, operation, type, or diagnostic"
)),
}
}
fn source_graph_query_limited(
graph: &ApplicationGraph,
operation: &str,
symbol: Option<&str>,
limit: usize,
) -> Result<String, String> {
if let Some(kind) = operation.strip_prefix("list_") {
let kind = match kind {
"components" => "component",
"resources" => "resource",
"agents" => "agent",
"state_machines" => "state-machine",
"requirements" => "requirement",
"scenarios" => "scenario",
other => other,
};
let matching = graph
.nodes
.values()
.filter(|node| node.kind.as_str() == kind)
.collect::<Vec<_>>();
let truncated = matching.len() > limit;
let nodes = matching
.into_iter()
.take(limit)
.map(|node| {
format!(
"{{\"id\":\"{}\",\"kind\":\"{}\",\"name\":\"{}\"}}",
node.id,
node.kind.as_str(),
json_escape(&node.name)
)
})
.collect::<Vec<_>>()
.join(",");
return Ok(format!(
"{{\"schemaVersion\":1,\"symbols\":[{nodes}],\"truncated\":{truncated}}}"
));
}
let symbol = symbol.ok_or_else(|| format!("{operation} requires a semantic ID"))?;
let id = SemanticId::parse(symbol).ok_or_else(|| format!("invalid semantic ID `{symbol}`"))?;
let node = graph
.nodes
.get(&id)
.ok_or_else(|| format!("unknown semantic symbol `{symbol}`"))?;
if operation == "calculate_change_impact" {
return graph
.impact(&id)
.map(|impact| impact.to_json())
.ok_or_else(|| format!("unknown semantic symbol `{symbol}`"));
}
let edges = match operation {
"find_dependencies" => graph.dependencies_of(&id),
"find_dependents" | "find_references" => graph.dependents_of(&id),
"inspect_symbol" => graph
.edges
.iter()
.filter(|edge| edge.from == id || edge.to == id)
.collect(),
_ => return Err(format!("unknown graph operation `{operation}`")),
};
let truncated = edges.len() > limit;
let relations = edges
.into_iter()
.take(limit)
.map(|edge| {
format!(
"{{\"from\":\"{}\",\"kind\":\"{}\",\"to\":\"{}\"}}",
edge.from,
edge.kind.as_str(),
edge.to
)
})
.collect::<Vec<_>>()
.join(",");
Ok(format!(
"{{\"schemaVersion\":1,\"symbol\":{{\"id\":\"{}\",\"kind\":\"{}\",\"name\":\"{}\"}},\"relationships\":[{relations}],\"truncated\":{truncated}}}",
node.id,
node.kind.as_str(),
json_escape(&node.name)
))
}
#[cfg(test)]
mod tests {
use super::{CompilerBackend, json_object_array, undo_local, validate_transaction_id};
use noxid_mcp::{Backend, ToolResult};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn parses_nested_semantic_operation_batches_without_splitting_strings() {
let operations = json_object_array(
r#"[{"operation":"add_state","initializer":"[1, 2]"},{"operation":"modify_view","view":"<p>{value}</p>"}]"#,
)
.unwrap();
assert_eq!(operations.len(), 2);
assert!(operations[1].contains("<p>{value}</p>"));
assert!(json_object_array(r#"["not-an-object"]"#).is_err());
}
#[test]
fn compact_run_scenarios_returns_the_shipped_runner_json_without_project_writes() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("noxid-mcp-run-scenarios-{nonce}"));
fs::create_dir_all(&root).unwrap();
let file = root.join("Counter.nox");
fs::write(
&file,
r#"component Counter {
state { count: Int = 0 guarded: Int = 0 }
actions { increment() { count = count + 1 } }
invariant GuardedStaysZero { assert: guarded == 0 }
scenario IncrementOnce {
description: "execute the emitted action"
when: increment()
expect: count == 1
}
view { <p>{count}</p> }
}"#,
)
.unwrap();
let original = fs::read_to_string(&file).unwrap();
let original_entries = fs::read_dir(&root).unwrap().count();
let direct = crate::scenario_test::execute_report(
&file,
&crate::scenario_test::Options {
gate: false,
json_only: true,
},
)
.unwrap();
assert!(direct.success, "{}\n{}", direct.json, direct.stderr);
let backend = CompilerBackend::new(file.clone());
let mcp = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"run_scenarios\",\"gate\":\"false\"}}}",
)
.unwrap();
assert_eq!(mcp, direct.json);
assert!(mcp.contains("\"status\":\"pass\""), "{mcp}");
let round_trip = noxid_mcp::handle(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"query_project\",\"arguments\":{\"operation\":\"run_scenarios\",\"gate\":\"false\"}}}",
&backend,
);
assert!(
round_trip.contains("\"structuredContent\":{\"schemaVersion\":1"),
"{round_trip}"
);
assert!(
round_trip.contains(
"\"name\":\"IncrementOnce\",\"component\":\"Counter\",\"status\":\"pass\""
),
"{round_trip}"
);
let affected = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"run-affected-tests\",\"symbols\":\"state:Counter.count\"}}}",
)
.unwrap();
assert!(
affected.contains("\"mode\":\"emitted-artifact\""),
"{affected}"
);
assert!(affected.contains("\"selectedCount\":1"), "{affected}");
assert!(affected.contains("\"status\":\"pass\""), "{affected}");
assert!(
!affected.contains("semantic-contract-and-symbolic-action"),
"{affected}"
);
let invariant_affected = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"run-affected-tests\",\"symbols\":\"state:Counter.guarded\"}}}",
)
.unwrap();
assert!(
invariant_affected.contains("\"selectedCount\":1"),
"{invariant_affected}"
);
assert!(
invariant_affected.contains("\"name\":\"IncrementOnce\""),
"{invariant_affected}"
);
assert!(
invariant_affected.contains("\"status\":\"pass\""),
"{invariant_affected}"
);
assert_eq!(fs::read_to_string(&file).unwrap(), original);
assert_eq!(fs::read_dir(&root).unwrap().count(), original_entries);
assert!(
backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"run_scenarios\",\"gate\":\"yes\"}}}",
)
.unwrap_err()
.contains("must be `true` or `false`")
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn semantic_batches_apply_as_one_confirmed_transaction() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("noxid-semantic-batch-{nonce}"));
fs::create_dir_all(&root).unwrap();
let file = root.join("Batch.nox");
fs::write(
&file,
"component Batch {\n state { count: Int = 0 }\n actions { increment() { count = count + 1 } }\n view { <p>{count}</p> }\n}\n",
)
.unwrap();
let backend = CompilerBackend::new(PathBuf::from(&file));
let operations = r#"[{"operation":"add_state","payload":{"component":"component:Batch","name":"label","type":"String","initializer":"\"ready\""}},{"operation":"add_action","payload":{"component":"component:Batch","name":"clearLabel","body":"label = \"\""}}]"#;
let initial = format!("{{\"params\":{{\"arguments\":{{\"operations\":{operations}}}}}}}");
let state = match backend.apply_operations(&initial).unwrap() {
ToolResult::InputRequired { request_state, .. } => request_state,
ToolResult::Complete(_) => panic!("batch must require confirmation"),
};
let confirmed = format!(
"{{\"params\":{{\"arguments\":{{\"operations\":{operations}}},\"requestState\":\"{state}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}}}}}"
);
match backend.apply_operations(&confirmed).unwrap() {
ToolResult::Complete(result) => assert!(result.contains("\"atomic\":true")),
ToolResult::InputRequired { .. } => panic!("confirmed batch was not applied"),
}
let source = fs::read_to_string(&file).unwrap();
assert!(source.contains("label: String = \"ready\""));
assert!(source.contains("clearLabel()"));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn failed_batches_remove_transaction_and_provenance_records() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("noxid-semantic-batch-rollback-{nonce}"));
fs::create_dir_all(&root).unwrap();
let file = root.join("BatchRollback.nox");
let original = "component BatchRollback {\n state { count: Int = 0 }\n view { <p>{count}</p> }\n}\n";
fs::write(&file, original).unwrap();
let backend = CompilerBackend::new(PathBuf::from(&file));
let operations = r#"[{"operation":"add_state","payload":{"component":"component:BatchRollback","name":"label","type":"String","initializer":"\"ready\""}},{"operation":"add_state","payload":{"component":"component:BatchRollback","name":"label","type":"String","initializer":"\"duplicate\""}}]"#;
let initial = format!("{{\"params\":{{\"arguments\":{{\"operations\":{operations}}}}}}}");
let state = match backend.apply_operations(&initial).unwrap() {
ToolResult::InputRequired { request_state, .. } => request_state,
ToolResult::Complete(_) => panic!("batch must require confirmation"),
};
let confirmed = format!(
"{{\"params\":{{\"arguments\":{{\"operations\":{operations}}},\"requestState\":\"{state}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}}}}}"
);
assert!(backend.apply_operations(&confirmed).is_err());
assert_eq!(fs::read_to_string(&file).unwrap(), original);
for directory in [".nox/transactions", ".nox/provenance"] {
let directory = root.join(directory);
if directory.exists() {
assert_eq!(fs::read_dir(directory).unwrap().count(), 0);
}
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn local_transactions_are_hash_guarded_confirmed_and_provenance_undoable() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("noxid-semantic-undo-{nonce}"));
fs::create_dir_all(&root).unwrap();
let file = root.join("Undoable.nox");
let original =
"component Undoable {\n state { count: Int = 0 }\n view { <p>{count}</p> }\n}\n";
fs::write(&file, original).unwrap();
let backend = CompilerBackend::new(PathBuf::from(&file));
let payload = r#"{"component":"component:Undoable","name":"label","type":"String","initializer":"\"ready\""}"#;
let initial = format!("{{\"params\":{{\"arguments\":{payload}}}}}");
let state = match backend.semantic_write("add_state", &initial).unwrap() {
ToolResult::InputRequired { request_state, .. } => request_state,
ToolResult::Complete(_) => panic!("semantic write must require confirmation"),
};
let confirmed = format!(
"{{\"params\":{{\"arguments\":{payload},\"requestState\":\"{state}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}}}}}"
);
let result = match backend.semantic_write("add_state", &confirmed).unwrap() {
ToolResult::Complete(result) => result,
ToolResult::InputRequired { .. } => panic!("confirmed semantic write was not applied"),
};
assert!(result.contains("\"undoTransaction\""), "{result}");
let applied = fs::read_to_string(&file).unwrap();
assert!(applied.contains("label: String = \"ready\""));
let transaction_path = fs::read_dir(root.join(".nox/transactions"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let transaction_id = transaction_path
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
let record = fs::read_to_string(&transaction_path).unwrap();
assert!(record.contains("\"status\":\"applied\""));
assert!(record.contains("\"previousSourceHash\":\"fnv1a64:"));
assert!(record.contains("\"appliedSourceHash\":\"fnv1a64:"));
fs::write(
&file,
format!("{applied}\n// changed outside transaction\n"),
)
.unwrap();
let undo_payload = format!("{{\"transaction\":\"{transaction_id}\"}}");
let undo_initial = format!(
"{{\"params\":{{\"arguments\":{{\"operation\":\"undo_transaction\",\"payload\":{undo_payload}}}}}}}"
);
let stale = match backend.call_tool("semantic_edit", &undo_initial) {
Err(error) => error,
Ok(_) => panic!("out-of-band source changes must reject semantic undo"),
};
assert!(stale.contains("stale"));
fs::write(&file, &applied).unwrap();
let undo_state = match backend.call_tool("semantic_edit", &undo_initial).unwrap() {
ToolResult::InputRequired { request_state, .. } => request_state,
ToolResult::Complete(_) => panic!("semantic undo must require confirmation"),
};
let undo_confirmed = format!(
"{{\"params\":{{\"arguments\":{{\"operation\":\"undo_transaction\",\"payload\":{undo_payload}}},\"requestState\":\"{undo_state}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}}}}}"
);
match backend.call_tool("semantic_edit", &undo_confirmed).unwrap() {
ToolResult::Complete(result) => assert!(result.contains("\"undone\":true")),
ToolResult::InputRequired { .. } => panic!("confirmed semantic undo did not apply"),
}
assert_eq!(fs::read_to_string(&file).unwrap(), original);
assert!(
fs::read_to_string(&transaction_path)
.unwrap()
.contains("\"status\":\"undone\"")
);
assert!(
root.join(format!(".nox/provenance/undo-{transaction_id}.json"))
.is_file()
);
let replay = match backend.call_tool("semantic_edit", &undo_initial) {
Err(error) => error,
Ok(_) => panic!("an undone transaction must not be replayed"),
};
assert!(replay.contains("status `undone`"));
let second_state = match backend.semantic_write("add_state", &initial).unwrap() {
ToolResult::InputRequired { request_state, .. } => request_state,
ToolResult::Complete(_) => panic!("reapplied write must require confirmation"),
};
let second_confirmed = format!(
"{{\"params\":{{\"arguments\":{payload},\"requestState\":\"{second_state}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}}}}}"
);
match backend
.semantic_write("add_state", &second_confirmed)
.unwrap()
{
ToolResult::Complete(_) => {}
ToolResult::InputRequired { .. } => panic!("reapplied write did not apply"),
}
let transaction_paths = fs::read_dir(root.join(".nox/transactions"))
.unwrap()
.map(|entry| entry.unwrap().path())
.collect::<Vec<_>>();
assert_eq!(transaction_paths.len(), 2);
let second_path = transaction_paths
.iter()
.find(|path| {
fs::read_to_string(path)
.unwrap()
.contains("\"status\":\"applied\"")
})
.unwrap();
let second_id = second_path
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
let cli_result = undo_local(&file, &second_id).unwrap();
assert!(cli_result.contains("\"undone\":true"));
assert_eq!(fs::read_to_string(&file).unwrap(), original);
assert!(validate_transaction_id("../escape").is_err());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn compiler_owned_feature_operations_are_confirmed_and_validated() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("noxid-feature-operation-{nonce}"));
fs::create_dir_all(root.join("src/routes")).unwrap();
fs::create_dir_all(root.join("src/components")).unwrap();
fs::create_dir_all(root.join("src/middleware")).unwrap();
fs::write(
root.join("Noxid.toml"),
"[app]\ntitle = \"Feature Test\"\nroutes = \"src/routes\"\ncomponents = \"src/components\"\nmiddleware = \"src/middleware\"\n",
)
.unwrap();
fs::write(
root.join("src/routes/+page.nox"),
"component HomePage { view { <main><h1>Home</h1></main> } }\n",
)
.unwrap();
let backend = CompilerBackend::new(root.clone());
let payload = r#"{"name":"ReportsPage","route":"/reports"}"#;
let initial = format!("{{\"params\":{{\"arguments\":{payload}}}}}");
let state = match backend.feature_write("create_route", &initial).unwrap() {
ToolResult::InputRequired { request_state, .. } => request_state,
ToolResult::Complete(_) => panic!("feature write must require confirmation"),
};
let confirmed = format!(
"{{\"params\":{{\"arguments\":{payload},\"requestState\":\"{state}\",\"inputResponses\":{{\"confirm_semantic_write\":{{\"action\":\"accept\",\"content\":{{\"confirm\":true}}}}}}}}}}"
);
match backend.feature_write("create_route", &confirmed).unwrap() {
ToolResult::Complete(result) => {
assert!(result.contains("\"applied\":true"), "{result}")
}
ToolResult::InputRequired { .. } => panic!("confirmed feature was not applied"),
}
assert!(root.join("src/routes/reports/+page.nox").is_file());
assert!(
root.join(".nox/provenance")
.read_dir()
.unwrap()
.next()
.is_some()
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn validation_returns_compressed_root_causes_and_repair_operations() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("noxid-compact-diagnostics-{nonce}"));
fs::create_dir_all(&root).unwrap();
let file = root.join("Invalid.nox");
fs::write(
&file,
"component Invalid { state { count: Int = \"wrong\" } view { <p>{count}</p> } }\n",
)
.unwrap();
let backend = CompilerBackend::new(file);
let result = backend
.validate_compact("{\"params\":{\"arguments\":{}}}")
.unwrap();
assert!(result.contains("\"ok\":false"), "{result}");
assert!(result.contains("\"rootCauseCount\":"), "{result}");
assert!(result.contains("\"repairPlan\":"), "{result}");
assert!(!result.contains("\"files\":"), "{result}");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn persistent_queries_return_unchanged_and_semantic_deltas() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("noxid-semantic-delta-{nonce}"));
fs::create_dir_all(&root).unwrap();
let file = root.join("Delta.nox");
let source = "component Delta { state { count: Int = 0 } view { <p>{count}</p> } }\n";
fs::write(&file, source).unwrap();
let backend = CompilerBackend::new(file.clone());
let summary = backend
.compact_query("{\"params\":{\"arguments\":{\"operation\":\"summary\"}}}")
.unwrap();
let snapshot = summary
.split("\"snapshot\":\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap();
let unchanged = backend
.compact_query(&format!(
"{{\"params\":{{\"arguments\":{{\"operation\":\"delta\",\"since\":\"{snapshot}\"}}}}}}"
))
.unwrap();
assert!(unchanged.contains("\"unchanged\":true"));
let bindings = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"list\",\"kind\":\"view-binding\"}}}",
)
.unwrap();
assert!(bindings.contains("view-binding"), "{bindings}");
let manifest = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"manifest\",\"projection\":\"agent\"}}}",
)
.unwrap();
assert!(manifest.contains("state:Delta.count"), "{manifest}");
let context = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"context\",\"task\":\"change count\",\"maxBytes\":\"2048\"}}}",
)
.unwrap();
assert!(context.contains("state:Delta.count"), "{context}");
let lease = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"lease-create\",\"task\":\"change count\"}}}",
)
.unwrap();
assert!(lease.contains("\"lease\":\"lease:"), "{lease}");
let described = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"describe\",\"kind\":\"operation\",\"name\":\"add_state\"}}}",
)
.unwrap();
assert!(described.contains("add_state"), "{described}");
let telemetry = backend
.compact_query("{\"params\":{\"arguments\":{\"operation\":\"telemetry\"}}}")
.unwrap();
assert!(telemetry.contains("local-aggregate-only"), "{telemetry}");
let recommendations = backend
.compact_query("{\"params\":{\"arguments\":{\"operation\":\"recommendations\"}}}")
.unwrap();
assert!(
recommendations.contains("developer-reviewed-recommendations"),
"{recommendations}"
);
let indexed = backend
.compact_query(
"{\"params\":{\"arguments\":{\"operation\":\"index-search\",\"task\":\"count state\"}}}",
)
.unwrap();
assert!(indexed.contains("state:Delta.count"), "{indexed}");
assert!(root.join(".nox/semantic-index.json").is_file());
let validation = backend
.validate_compact("{\"params\":{\"arguments\":{}}}")
.unwrap();
assert!(validation.contains("\"ok\":true"), "{validation}");
assert!(validation.contains("\"diagnostics\":0"), "{validation}");
assert!(!validation.contains("\"files\""), "{validation}");
let unchanged_validation = backend
.validate_compact(&format!(
"{{\"params\":{{\"arguments\":{{\"since\":\"{snapshot}\"}}}}}}"
))
.unwrap();
assert!(
unchanged_validation.contains("\"unchanged\":true"),
"{unchanged_validation}"
);
fs::write(
&file,
"component Delta { state { count: Int = 0 label: String = \"ready\" } view { <p>{count}</p> } }\n",
)
.unwrap();
let delta = backend
.compact_query(&format!(
"{{\"params\":{{\"arguments\":{{\"operation\":\"delta\",\"since\":\"{snapshot}\"}}}}}}"
))
.unwrap();
assert!(delta.contains("state:Delta.label"), "{delta}");
fs::remove_dir_all(root).unwrap();
}
}