1use std::collections::{BTreeMap, BTreeSet};
11
12use harn_builtin_meta::CapabilityId;
13
14use super::{
15 all_builtin_manifest, builtin_manifest_entry, capability_method_manifest_entry,
16 harness_method_for_builtin, stdlib_probe_vm,
17};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum HarnessBuiltinArgumentMigration {
22 Forward,
24 RequestRecord(&'static [&'static str]),
26 CallThenProperty(&'static str),
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct HarnessBuiltinMigration {
35 pub capability: harn_builtin_meta::CapabilityId,
36 pub method: &'static str,
37 pub arguments: HarnessBuiltinArgumentMigration,
38}
39
40pub fn harness_migration_for_builtin(name: &str) -> Option<HarnessBuiltinMigration> {
48 if let Some((capability, method)) = harness_method_for_builtin(name) {
49 return Some(HarnessBuiltinMigration {
50 capability,
51 method,
52 arguments: HarnessBuiltinArgumentMigration::Forward,
53 });
54 }
55 use harn_builtin_meta::CapabilityId;
56 use HarnessBuiltinArgumentMigration::{CallThenProperty, Forward, RequestRecord};
57 let request_record = |method, fields| HarnessBuiltinMigration {
58 capability: CapabilityId::Project,
59 method,
60 arguments: RequestRecord(fields),
61 };
62 let projection = |capability, method, property| HarnessBuiltinMigration {
63 capability,
64 method,
65 arguments: CallThenProperty(property),
66 };
67 let forward = |capability, method| HarnessBuiltinMigration {
68 capability,
69 method,
70 arguments: Forward,
71 };
72 let (method, fields): (&'static str, &'static [&'static str]) = match name {
73 "metadata_get" | "metadata_resolve" => ("metadata_get", &["dir", "namespace"]),
74 "metadata_set" => ("metadata_set", &["dir", "namespace", "data"]),
75 "metadata_entries" => ("metadata_entries", &["namespace"]),
76 "metadata_save" => ("metadata_save", &[]),
77 "metadata_stale" => ("metadata_stale", &["dir"]),
78 "metadata_refresh_hashes" => ("metadata_refresh_hashes", &[]),
79 "metadata_status" => ("metadata_status", &["namespace"]),
80 "path_metadata_get" => ("path_metadata_get", &["path", "namespace", "options"]),
81 "path_metadata_set" => (
82 "path_metadata_set",
83 &["path", "namespace", "data", "options"],
84 ),
85 "path_metadata_entries" => ("path_metadata_entries", &["namespace", "options"]),
86 "platform" => return Some(projection(CapabilityId::System, "platform", "os")),
87 "arch" => return Some(projection(CapabilityId::System, "platform", "arch")),
88 "username" => return Some(projection(CapabilityId::System, "identity", "username")),
89 "hostname" => return Some(projection(CapabilityId::System, "identity", "hostname")),
90 "pid" => return Some(projection(CapabilityId::System, "identity", "pid")),
91 "execution_root" => {
92 return Some(projection(
93 CapabilityId::Fs,
94 "runtime_paths",
95 "execution_root",
96 ));
97 }
98 "asset_root" => {
99 return Some(projection(CapabilityId::Fs, "runtime_paths", "asset_root"));
100 }
101 "home_dir" => return Some(forward(CapabilityId::Fs, "home_dir")),
102 "runtime_paths" => return Some(forward(CapabilityId::Fs, "runtime_paths")),
103 "source_dir" => return Some(forward(CapabilityId::Fs, "source_dir")),
104 "project_root" => return Some(forward(CapabilityId::Fs, "project_root")),
105 "date_iso" => return Some(forward(CapabilityId::Clock, "date_iso")),
106 "term_width" => return Some(forward(CapabilityId::Term, "width")),
107 "term_height" => return Some(forward(CapabilityId::Term, "height")),
108 "security_policy" => {
109 return Some(forward(CapabilityId::System, "security_policy"));
110 }
111 "security_stamp_directive" => {
112 return Some(forward(CapabilityId::System, "security_stamp_directive"));
113 }
114 "security_verify_directive" => {
115 return Some(forward(CapabilityId::System, "security_verify_directive"));
116 }
117 "llm_catalog" => return Some(forward(CapabilityId::Llm, "catalog")),
118 "llm_catalog_refresh" => {
119 return Some(forward(CapabilityId::Llm, "catalog_refresh"));
120 }
121 "llm_provider_status" => return Some(forward(CapabilityId::Llm, "providers")),
122 "llm_session_cost" => return Some(forward(CapabilityId::Llm, "session_cost")),
123 "llm_budget" => return Some(forward(CapabilityId::Llm, "budget")),
124 "llm_budget_remaining" => {
125 return Some(forward(CapabilityId::Llm, "budget_remaining"));
126 }
127 "http_mock" => return Some(forward(CapabilityId::Testing, "http_mock")),
128 "http_mock_clear" => return Some(forward(CapabilityId::Testing, "http_mock_clear")),
129 "http_mock_calls" => return Some(forward(CapabilityId::Testing, "http_mock_calls")),
130 "egress_policy" => return Some(forward(CapabilityId::Net, "egress_policy")),
134 "transport_mock_clear" => {
135 return Some(forward(CapabilityId::Testing, "transport_mock_clear"));
136 }
137 "transport_mock_calls" => {
138 return Some(forward(CapabilityId::Testing, "transport_mock_calls"));
139 }
140 "sse_mock" => return Some(forward(CapabilityId::Testing, "sse_mock")),
141 "sse_server_mock_receive" => {
142 return Some(forward(CapabilityId::Testing, "sse_server_mock_receive"));
143 }
144 "sse_server_mock_disconnect" => {
145 return Some(forward(CapabilityId::Testing, "sse_server_mock_disconnect"));
146 }
147 "websocket_mock" => return Some(forward(CapabilityId::Testing, "websocket_mock")),
148 "secret_get" => return Some(forward(CapabilityId::Secrets, "read")),
152 "mock_time" => return Some(forward(CapabilityId::Testing, "clock_set")),
156 "unmock_time" => return Some(forward(CapabilityId::Testing, "clock_reset")),
157 "advance_time" => return Some(forward(CapabilityId::Testing, "clock_advance")),
158 "mock_stdin" => return Some(forward(CapabilityId::Testing, "stdin_set")),
159 "unmock_stdin" => return Some(forward(CapabilityId::Testing, "stdin_reset")),
160 "mock_tty" => return Some(forward(CapabilityId::Testing, "tty_set")),
161 "unmock_tty" => return Some(forward(CapabilityId::Testing, "tty_reset")),
162 "host_mock_push_scope" => return Some(forward(CapabilityId::Testing, "push_scope")),
163 "host_mock_pop_scope" => return Some(forward(CapabilityId::Testing, "pop_scope")),
164 "host_mock_calls" => return Some(forward(CapabilityId::Testing, "calls")),
165 "llm_mock" => return Some(forward(CapabilityId::Llm, "mock_enqueue")),
166 "render_string" => return Some(forward(CapabilityId::Fs, "render_template")),
167 "render_with_provenance" => {
168 return Some(forward(CapabilityId::Fs, "render_prompt_with_provenance"));
169 }
170 "crypto_random_bytes" => return Some(forward(CapabilityId::Random, "bytes")),
171 "emit_channel" => return Some(forward(CapabilityId::Channels, "append")),
172 "flush_trigger_aggregations" => {
173 return Some(forward(CapabilityId::Channels, "flush_aggregations"));
174 }
175 "channel_ack" => return Some(forward(CapabilityId::Channels, "ack")),
177 "channel_events" => return Some(forward(CapabilityId::Channels, "events")),
178 "channel_subscribe" => return Some(forward(CapabilityId::Channels, "subscribe")),
179 "channel_consumer_cursor" => {
180 return Some(forward(CapabilityId::Channels, "consumer_cursor"));
181 }
182 "pg_connect" => return Some(forward(CapabilityId::Postgres, "connect")),
183 "pg_pool" => return Some(forward(CapabilityId::Postgres, "pool")),
184 _ => {
185 return derived_capability_owner(name)
186 .map(|(capability, method)| forward(capability, method));
187 }
188 };
189 Some(request_record(method, fields))
190}
191
192struct CapabilityMethodIndex {
203 bridged_owner: BTreeMap<&'static str, Option<CapabilityId>>,
207 methods_by_capability: BTreeMap<CapabilityId, BTreeSet<&'static str>>,
208}
209
210fn capability_method_index() -> &'static CapabilityMethodIndex {
211 static INDEX: std::sync::OnceLock<CapabilityMethodIndex> = std::sync::OnceLock::new();
212 INDEX.get_or_init(|| {
213 let mut index = CapabilityMethodIndex {
214 bridged_owner: BTreeMap::new(),
215 methods_by_capability: BTreeMap::new(),
216 };
217 for entry in all_builtin_manifest() {
218 if let harn_builtin_meta::BuiltinExposure::HarnessMethod { capability, method } =
219 entry.contract.exposure
220 {
221 index
222 .methods_by_capability
223 .entry(capability)
224 .or_default()
225 .insert(method);
226 }
227 }
228 for (capability, method) in stdlib_probe_vm().capability_method_names() {
229 let method: &'static str = Box::leak(method.into_boxed_str());
230 index
231 .bridged_owner
232 .entry(method)
233 .and_modify(|owner| {
234 if *owner != Some(capability) {
235 *owner = None;
236 }
237 })
238 .or_insert(Some(capability));
239 index
240 .methods_by_capability
241 .entry(capability)
242 .or_default()
243 .insert(method);
244 }
245 index
246 })
247}
248
249fn derived_capability_owner(name: &str) -> Option<(CapabilityId, &'static str)> {
269 if is_source_visible_global(name) {
270 return None;
271 }
272 let index = capability_method_index();
273 if let Some((method, Some(owner))) = index.bridged_owner.get_key_value(name) {
274 return Some((*owner, method));
275 }
276
277 let mut candidates: Vec<(CapabilityId, &'static str)> = Vec::new();
278 let unprefixed = name.strip_prefix("hostlib_").unwrap_or(name);
279 for (capability, methods) in &index.methods_by_capability {
280 if let Some(method) = methods.get(name) {
281 candidates.push((*capability, method));
282 }
283 for prefix in [
284 capability.field_name().to_string(),
285 snake_case(capability.variant_name()),
286 ] {
287 let Some(rest) = unprefixed
288 .strip_prefix(&prefix)
289 .and_then(|rest| rest.strip_prefix('_'))
290 else {
291 continue;
292 };
293 for method in [Some(rest), rest.strip_prefix("session_")]
296 .into_iter()
297 .flatten()
298 .filter_map(|method| methods.get(method))
299 {
300 candidates.push((*capability, method));
301 }
302 }
303 }
304 candidates.sort_unstable();
305 candidates.dedup();
306 if candidates.len() > 1 {
307 candidates
308 .retain(|(capability, method)| takes_the_same_parameters(name, *capability, method));
309 }
310 match candidates.as_slice() {
311 [only] => Some(*only),
312 _ => None,
313 }
314}
315
316fn takes_the_same_parameters(removed_global: &str, capability: CapabilityId, method: &str) -> bool {
321 let Some(before) = builtin_manifest_entry(removed_global) else {
322 return false;
323 };
324 let Some(after) = capability_method_manifest_entry(capability, method) else {
325 return false;
326 };
327 let names = |entry: &'static harn_builtin_registry::BuiltinManifestEntry| {
328 entry
329 .signature
330 .params
331 .iter()
332 .map(|param| param.name)
333 .collect::<Vec<_>>()
334 };
335 names(before) == names(after)
336}
337
338fn snake_case(camel: &str) -> String {
339 let mut out = String::with_capacity(camel.len() + 2);
340 for (index, ch) in camel.char_indices() {
341 if ch.is_ascii_uppercase() && index > 0 {
342 out.push('_');
343 }
344 out.push(ch.to_ascii_lowercase());
345 }
346 out
347}
348
349fn is_source_visible_global(name: &str) -> bool {
351 builtin_manifest_entry(name).is_some_and(|entry| {
352 matches!(
353 entry.contract.exposure,
354 harn_builtin_meta::BuiltinExposure::PureGlobal
355 | harn_builtin_meta::BuiltinExposure::CapabilityFunction { .. }
356 )
357 })
358}
359#[cfg(test)]
360mod registered_capability_migration_tests {
361 use harn_builtin_meta::CapabilityId;
362
363 use super::{
364 all_builtin_manifest, harness_migration_for_builtin, HarnessBuiltinArgumentMigration,
365 HarnessBuiltinMigration,
366 };
367 use crate::stdlib::stdlib_probe_vm;
368
369 #[test]
370 fn migration_recipes_follow_names_that_moved_onto_a_handle() {
371 let forward = |capability, method| {
372 Some(HarnessBuiltinMigration {
373 capability,
374 method,
375 arguments: HarnessBuiltinArgumentMigration::Forward,
376 })
377 };
378 assert_eq!(
380 harness_migration_for_builtin("exit"),
381 forward(CapabilityId::Runtime, "exit")
382 );
383 assert_eq!(
385 harness_migration_for_builtin("hostlib_code_index_rebuild"),
386 forward(CapabilityId::CodeIndex, "rebuild")
387 );
388 assert_eq!(
390 harness_migration_for_builtin("agent_session_open"),
391 forward(CapabilityId::Agent, "open")
392 );
393 assert_eq!(harness_migration_for_builtin("len"), None);
395 }
396
397 #[test]
403 fn the_whole_mock_family_says_where_it_went() {
404 let forward = |capability, method| {
405 Some(HarnessBuiltinMigration {
406 capability,
407 method,
408 arguments: HarnessBuiltinArgumentMigration::Forward,
409 })
410 };
411 for name in ["http_mock", "http_mock_clear", "http_mock_calls"] {
412 assert_eq!(
413 harness_migration_for_builtin(name),
414 forward(CapabilityId::Testing, name),
415 "{name} left no way back to its handle"
416 );
417 }
418 for name in ["transport_mock_clear", "transport_mock_calls"] {
419 assert_eq!(
420 harness_migration_for_builtin(name),
421 forward(CapabilityId::Testing, name),
422 "{name} left no way back to its handle"
423 );
424 }
425 assert_eq!(
426 harness_migration_for_builtin("egress_policy"),
427 forward(CapabilityId::Net, "egress_policy")
428 );
429 }
430
431 fn has_a_repair(name: &str) -> bool {
437 use harn_parser::diagnostic::{
438 harness_clock_replacement, harness_env_replacement, harness_fs_replacement,
439 harness_net_replacement, harness_random_replacement, harness_stdio_replacement,
440 };
441 harness_migration_for_builtin(name).is_some()
442 || harness_clock_replacement(name).is_some()
443 || harness_stdio_replacement(name).is_some()
444 || harness_fs_replacement(name).is_some()
445 || harness_env_replacement(name).is_some()
446 || harness_random_replacement(name).is_some()
447 || harness_net_replacement(name).is_some()
448 }
449
450 const RUNTIME_PLUMBING: &[&str] = &[
457 "exec_at_opts",
458 "exec_opts",
459 "host_tool_call",
460 "host_tool_list",
461 "invalidate_facts",
462 "llm_mock_known_scopes",
463 "llm_mock_load_jsonl",
464 "llm_mock_receipts",
465 "render",
466 ];
467
468 #[test]
473 fn every_runtime_internal_builtin_is_migrated_or_named_as_plumbing() {
474 use harn_builtin_meta::BuiltinExposure;
475
476 let offenders = all_builtin_manifest()
477 .iter()
478 .filter(|entry| entry.is_canonical())
479 .filter(|entry| matches!(entry.contract.exposure, BuiltinExposure::RuntimeInternal))
480 .filter(|entry| !entry.name.starts_with("__"))
481 .filter(|entry| !RUNTIME_PLUMBING.contains(&entry.name))
482 .filter(|entry| !has_a_repair(entry.name))
483 .map(|entry| entry.name)
484 .collect::<Vec<_>>();
485
486 assert!(
487 offenders.is_empty(),
488 "these globals moved onto a handle but report no repair: {offenders:?}"
489 );
490 }
491
492 #[test]
496 fn every_uniquely_owned_capability_method_has_a_migration() {
497 let vm = stdlib_probe_vm();
498 let declared: std::collections::BTreeSet<String> = super::all_builtin_manifest()
499 .iter()
500 .map(|entry| entry.name.to_string())
501 .collect();
502 let mut owners: std::collections::BTreeMap<String, std::collections::BTreeSet<_>> =
503 std::collections::BTreeMap::new();
504 for (capability, method) in vm.capability_method_names() {
505 owners.entry(method).or_default().insert(capability);
506 }
507
508 let missing: Vec<_> = owners
509 .iter()
510 .filter(|(method, capabilities)| {
511 capabilities.len() == 1
512 && !declared.contains(*method)
513 && harness_migration_for_builtin(method).is_none()
514 })
515 .map(|(method, _)| method.clone())
516 .collect();
517 assert!(
518 missing.is_empty(),
519 "capability methods without a migration recipe: {missing:?}"
520 );
521 }
522
523 #[test]
524 fn runtime_registered_store_methods_migrate_to_their_owning_capability() {
525 for method in ["store_get", "store_set", "store_delete", "store_list"] {
526 let migration =
527 harness_migration_for_builtin(method).expect("store method has a migration");
528 assert_eq!(
529 migration.capability,
530 harn_builtin_meta::CapabilityId::Runtime
531 );
532 assert_eq!(migration.method, method);
533 assert_eq!(
534 migration.arguments,
535 HarnessBuiltinArgumentMigration::Forward
536 );
537 }
538 }
539
540 #[test]
543 fn ambiguously_owned_methods_have_no_migration() {
544 let vm = stdlib_probe_vm();
545 let mut owners: std::collections::BTreeMap<String, std::collections::BTreeSet<_>> =
546 std::collections::BTreeMap::new();
547 for (capability, method) in vm.capability_method_names() {
548 owners.entry(method).or_default().insert(capability);
549 }
550 let Some((method, _)) = owners
551 .iter()
552 .find(|(method, capabilities)| {
553 capabilities.len() > 1 && super::harness_method_for_builtin(method).is_none()
554 })
555 .map(|(method, capabilities)| (method.clone(), capabilities.clone()))
556 else {
557 return;
558 };
559 assert!(
560 super::derived_capability_owner(&method).is_none(),
561 "`{method}` is owned by several capabilities and must not resolve to one"
562 );
563 }
564}