pub mod resolve;
pub mod stale;
use std::collections::{BTreeMap, HashMap, VecDeque};
use crate::diagnostic::DiagnosticCollector;
use crate::lock::ConfigEntryRecord;
use crate::sync::AppliedState;
use crate::types::{MarsContext, SourceName};
pub(crate) fn preflight_hooks(
ctx: &MarsContext,
resolved: &crate::sync::ResolvedState,
diag: &mut DiagnosticCollector,
) -> Result<(), crate::error::MarsError> {
use crate::compiler::hooks::discover_hook_items;
use crate::error::{ConfigError, MarsError};
use crate::target::TargetRegistry;
let mut hooks = discover_hook_items(&ctx.project_root, "_self", 0, 0)?;
for (decl_order, source_name) in resolved.graph.order.iter().enumerate() {
let Some(node) = resolved.graph.nodes.get(source_name) else {
continue;
};
hooks.extend(discover_hook_items(
&node.rooted_ref.package_root,
source_name.as_str(),
1,
decl_order,
)?);
}
let registry = TargetRegistry::new();
let mut errors = Vec::new();
for item in hooks {
for (target_name, target) in &item.def.targets {
let known = registry
.get(target_name)
.and_then(|adapter| adapter.known_hook_events());
let Some(known) = known else {
let detail = if target_name == ".opencode" {
"extensibility is TypeScript plugins"
} else if target_name == ".pi" {
"extensibility is TypeScript extensions"
} else {
"no declarative command-hook mechanism is available"
};
errors.push(format!(
"hook `{}`: target `{target_name}` has no command-hook mechanism ({detail})",
item.def.name
));
continue;
};
for event in &target.events {
if known.contains(&event.as_str()) {
continue;
}
if target.unchecked {
diag.warn(
"hook-event-unchecked",
format!(
"hook `{}` passes unknown event `{event}` through verbatim to \
`{target_name}` because `unchecked = true`",
item.def.name
),
);
} else {
errors.push(format!(
"hook `{}`: unknown event `{event}` for target `{target_name}`; valid \
events: {}; use `unchecked = true` to pass a newer native event \
through verbatim",
item.def.name,
known.join(", ")
));
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(MarsError::Config(ConfigError::Invalid {
message: errors.join("\n"),
}))
}
}
pub(crate) fn compile_config_entries(
ctx: &MarsContext,
applied: &AppliedState,
dry_run: bool,
diag: &mut DiagnosticCollector,
) -> BTreeMap<String, BTreeMap<String, ConfigEntryRecord>> {
use crate::compiler::config_entries::resolve::{
resolve_hook_collisions_for_target, resolve_mcp_collisions_for_target,
};
use crate::compiler::hooks::discover_hook_items;
use crate::compiler::mcp::{TargetMcpEntry, check_env_refs, discover_mcp_items};
use crate::target::{ConfigEntry, HookEntry, McpServerEntry, TargetRegistry};
let graph = &applied.planned.targeted.resolved.graph;
let effective = &applied.planned.targeted.resolved.loaded.effective;
let target_roots: Vec<String> = effective.settings.managed_targets();
let depths = compute_depths(graph);
let decl_orders = compute_decl_orders(graph, &effective.dependencies);
let mut all_mcp: Vec<crate::compiler::mcp::ParsedMcpItem> = Vec::new();
let mut all_hooks: Vec<crate::compiler::hooks::ParsedHookItem> = Vec::new();
let local_mcp = match discover_mcp_items(&ctx.project_root, "_self", 0) {
Ok(items) => items,
Err(e) => {
diag.warn(
"mcp-discover",
format!("failed to scan local MCP items: {e}"),
);
Vec::new()
}
};
all_mcp.extend(local_mcp);
let local_hooks = match discover_hook_items(&ctx.project_root, "_self", 0, 0) {
Ok(items) => items,
Err(e) => {
diag.error(
"hook-discover",
format!("failed to scan local hook items: {e}"),
);
Vec::new()
}
};
all_hooks.extend(local_hooks);
for source_name in &graph.order {
let Some(node) = graph.nodes.get(source_name) else {
continue;
};
let package_root = &node.rooted_ref.package_root;
let decl_order = decl_orders
.get(source_name)
.copied()
.unwrap_or(effective.dependencies.len() + graph.order.len() + 1);
match discover_mcp_items(package_root, source_name.as_str(), decl_order) {
Ok(items) => all_mcp.extend(items),
Err(e) => {
diag.warn(
"mcp-discover",
format!("failed to scan MCP items in `{source_name}`: {e}"),
);
}
}
let depth = depths.get(source_name).copied().unwrap_or(1);
match discover_hook_items(package_root, source_name.as_str(), depth, decl_order) {
Ok(items) => all_hooks.extend(items),
Err(e) => {
diag.error(
"hook-discover",
format!("failed to scan hook items in `{source_name}`: {e}"),
);
}
}
}
{
use crate::compiler::visibility::{can_cross_package_boundary, resolve_visibility};
use crate::lock::ItemKind;
all_mcp.retain(|item| {
if item.source_name == "_self" {
return true;
}
let explicit = match item.def.visibility.as_str() {
"exported" => Some(true),
"local" => Some(false),
_ => None, };
let vis = resolve_visibility(ItemKind::McpServer, &item.name, explicit);
if !can_cross_package_boundary(&vis) {
return false;
}
true
});
all_hooks.retain(|item| {
if item.source_name == "_self" {
return true;
}
let explicit = match item.def.visibility.as_str() {
"exported" => Some(true),
"local" => Some(false),
_ => None,
};
let vis = resolve_visibility(ItemKind::Hook, &item.def.name, explicit);
can_cross_package_boundary(&vis)
});
}
if let Err(e) = check_env_refs(&all_mcp, false, diag) {
diag.warn("mcp-env", format!("MCP env check failed: {e}"));
}
let registry = TargetRegistry::new();
let mut desired_records: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>> =
BTreeMap::new();
let mut pending_writes: BTreeMap<String, Vec<ConfigEntry>> = BTreeMap::new();
for target_root in &target_roots {
let mut entries_with_source: Vec<(ConfigEntry, String)> = Vec::new();
for parsed in resolve_mcp_collisions_for_target(&all_mcp, target_root, diag) {
let source = parsed.source_name.clone();
let e = TargetMcpEntry::from_parsed(parsed);
entries_with_source.push((
ConfigEntry::McpServer(McpServerEntry {
name: e.name,
command: e.command,
args: e.args,
env: e.env.into_iter().collect(),
}),
source,
));
}
let mut target_hooks = resolve_hook_collisions_for_target(&all_hooks, target_root, diag);
target_hooks.sort_by_key(|hook| {
(
hook.item.package_depth,
hook.item.decl_order,
hook.item.def.order,
hook.item.def.name.as_str(),
hook.event,
)
});
let Some(adapter) = registry.get(target_root) else {
continue;
};
let hook_entries: Vec<(ConfigEntry, String)> = target_hooks
.into_iter()
.filter_map(|hook| {
let target = &hook.item.def.targets[target_root];
let source = hook.item.source_name.clone();
let script_path = match &hook.item.def.action {
crate::compiler::hooks::HookAction::Script { path } => {
let resolved = hook
.item
.package_root
.join("hooks")
.join(&hook.item.def.name)
.join(path);
if resolved.strip_prefix(&hook.item.package_root).is_err() {
diag.warn(
"hook-path-escape",
format!(
"hook `{}`: script path `{path}` escapes package root — skipped",
hook.item.def.name
),
);
return None;
}
resolved.to_string_lossy().to_string()
}
};
Some((
ConfigEntry::Hook(HookEntry {
name: hook.item.def.name.clone(),
native_event: hook.event.to_string(),
matcher: target.matcher.clone(),
script_path,
order: hook.item.def.order,
}),
source,
))
})
.collect();
entries_with_source.extend(hook_entries);
if entries_with_source.is_empty() {
continue;
}
let entries: Vec<ConfigEntry> = entries_with_source
.iter()
.map(|(entry, _)| entry.clone())
.collect();
let mut target_records = BTreeMap::new();
for (entry, source) in &entries_with_source {
target_records.insert(
entry.key(),
ConfigEntryRecord {
source: source.clone(),
},
);
}
adapter.emit_pre_write_diagnostics(&entries, diag);
desired_records.insert(target_root.clone(), target_records);
pending_writes.insert(target_root.clone(), entries);
}
let previous_records = &applied
.planned
.targeted
.resolved
.loaded
.old_lock
.config_entries;
let stale_entries = stale::find_stale_entries(previous_records, &desired_records);
let mut retained_stale_records = BTreeMap::new();
for (target_root, keys) in stale_entries {
if dry_run {
diag.warn(
"stale-config-entry",
format!(
"target `{target_root}` has stale config entries: {}",
keys.join(", ")
),
);
continue;
}
let Some(adapter) = registry.get(&target_root) else {
continue;
};
let target_dir = ctx.project_root.join(&target_root);
if let Err(e) = adapter.remove_config_entries(&keys, &target_dir) {
diag.warn(
"config-entry-remove",
format!("failed to remove stale config entries from `{target_root}`: {e}"),
);
if let Some(previous_target_records) = previous_records.get(&target_root) {
let target_records = retained_stale_records
.entry(target_root.clone())
.or_insert_with(BTreeMap::new);
for key in &keys {
if let Some(record) = previous_target_records.get(key) {
target_records.insert(key.clone(), record.clone());
}
}
}
} else {
diag.info(
"stale-config-entry",
format!(
"removed stale config entries from `{target_root}`: {}",
keys.join(", ")
),
);
}
}
if dry_run {
return desired_records;
}
let mut current_records = retained_stale_records;
for (target_root, entries) in pending_writes {
let Some(adapter) = registry.get(&target_root) else {
continue;
};
let target_dir = ctx.project_root.join(&target_root);
match adapter.write_config_entries(&entries, &target_dir) {
Ok(_) => {
if let Some(records) = desired_records.remove(&target_root) {
current_records
.entry(target_root)
.or_default()
.extend(records);
}
}
Err(e) => {
diag.warn(
"config-entry-write",
format!("failed to write config entries to `{target_root}`: {e}"),
);
}
}
}
current_records
}
fn compute_depths(graph: &crate::resolve::ResolvedGraph) -> HashMap<SourceName, usize> {
let mut in_degree: HashMap<SourceName, usize> = HashMap::new();
for name in graph.nodes.keys() {
in_degree.insert(name.clone(), 0);
}
for node in graph.nodes.values() {
for dep in &node.deps {
if graph.nodes.contains_key(dep) {
*in_degree.entry(dep.clone()).or_insert(0) += 1;
}
}
}
let mut depths: HashMap<SourceName, usize> = HashMap::new();
let mut queue: VecDeque<SourceName> = VecDeque::new();
for (name, degree) in &in_degree {
if *degree == 0 {
depths.insert(name.clone(), 1);
queue.push_back(name.clone());
}
}
while let Some(current) = queue.pop_front() {
let current_depth = depths[¤t];
if let Some(node) = graph.nodes.get(¤t) {
for dep in &node.deps {
if graph.nodes.contains_key(dep) {
depths
.entry(dep.clone())
.and_modify(|d| *d = (*d).max(current_depth + 1))
.or_insert_with(|| {
queue.push_back(dep.clone());
current_depth + 1
});
}
}
}
}
depths
}
fn compute_decl_orders(
graph: &crate::resolve::ResolvedGraph,
dependencies: &indexmap::IndexMap<SourceName, crate::config::EffectiveDependency>,
) -> HashMap<SourceName, usize> {
let mut orders: HashMap<SourceName, usize> = HashMap::new();
let mut queue: VecDeque<SourceName> = VecDeque::new();
for (idx, source_name) in dependencies.keys().enumerate() {
if graph.nodes.contains_key(source_name) {
orders.insert(source_name.clone(), idx + 1);
queue.push_back(source_name.clone());
}
}
while let Some(current) = queue.pop_front() {
let current_order = orders[¤t];
let Some(node) = graph.nodes.get(¤t) else {
continue;
};
for dep in &node.deps {
if !graph.nodes.contains_key(dep) {
continue;
}
match orders.get_mut(dep) {
Some(existing) if current_order < *existing => {
*existing = current_order;
queue.push_back(dep.clone());
}
Some(_) => {}
None => {
orders.insert(dep.clone(), current_order);
queue.push_back(dep.clone());
}
}
}
}
orders
}