Skip to main content

harn_vm/stdlib/
harness_migration.rs

1//! Where a global that moved onto a `Harness` handle went.
2//!
3//! One question with one answer: given the name of a global that Harn source
4//! can no longer call, which capability method replaced it and how do its
5//! arguments map over? `harn lint` turns that into `HARN-LNT-071` and
6//! `harn fix --apply --safety surface-changing` turns it into an edit, so
7//! anything missing here shows up downstream as a bare "not defined" with no
8//! way forward.
9
10use 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/// How an ambient builtin's arguments map onto its typed Harness replacement.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum HarnessBuiltinArgumentMigration {
22    /// Preserve the original positional argument list.
23    Forward,
24    /// Wrap positional arguments in a named request record.
25    RequestRecord(&'static [&'static str]),
26    /// Replace a zero-argument legacy projection with a property of a typed
27    /// Harness snapshot, for example `platform()` with
28    /// `harness.system.platform().os`.
29    CallThenProperty(&'static str),
30}
31
32/// Complete migration recipe for a removed ambient builtin.
33#[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
40/// Resolve an ambient builtin to its canonical typed Harness call shape.
41///
42/// Most recipes project mechanically from `HarnessMethod` exposure. Legacy
43/// metadata globals and process/environment projections predate that manifest
44/// contract, so their explicit recipes preserve named request records and the
45/// structured System/Fs/Term snapshots instead of adding compatibility
46/// overloads to the typed handles.
47pub 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        // Declaring an egress allowlist is how a sandboxed script goes from
131        // fail-closed to a named set of hosts, so the recipe has to survive the
132        // move onto the handle even though the method is not a plain builtin.
133        "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        // The connector runtime used to inject `secret_get` for the duration
149        // of an export call. Connector exports now receive a root Harness, so
150        // the same read is a plain capability method.
151        "secret_get" => return Some(forward(CapabilityId::Secrets, "read")),
152        // These globals were renamed on the way onto their handle, so the
153        // name match below has nothing to follow. Everything that kept its
154        // name resolves without an entry here.
155        "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        // The handle is `harness.channels`; the globals were singular.
176        "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
192/// Every typed Harness method, indexed by method name and by owning
193/// capability.
194///
195/// Two registration paths reach the same dispatch table. `#[harn_builtin]`
196/// declares most methods through `HarnessMethod` exposure; store, checkpoint,
197/// metadata, and host-injected methods are installed on the VM at startup and
198/// never reach the builtin manifest at all. Projecting both keeps the
199/// migration recipe — and with it the linter and `harn fix` — aware of the
200/// whole surface instead of a hand-maintained list that drifts every time a
201/// host adds a capability.
202struct CapabilityMethodIndex {
203    /// Owner of a method installed by `register_capability_method`, or `None`
204    /// when several capabilities install it. This is the surface the legacy
205    /// ambient bridge restores, so it decides what a bare global used to mean.
206    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
249/// Where a pre-cutover global went, derived from the capability surface
250/// itself rather than from a parallel list.
251///
252/// A global that moved onto a handle nearly always kept its own name, so the
253/// method index doubles as the migration table. Three spellings show up:
254///
255///   * the name survived verbatim — `exit` became `harness.runtime.exit`;
256///   * the name already carried its capability — `hostlib_code_index_rebuild`
257///     became `harness.code_index.rebuild`;
258///   * the name carried a family prefix the handle now supplies —
259///     `agent_session_open` became `harness.agent.open`.
260///
261/// Two rules keep a guess from becoming a wrong rewrite. A name that is still
262/// a callable global resolves to nothing, because that call works as written
263/// and has not moved. A name several methods answer to is settled by parameter
264/// list — `agent_session_open(id?, opts?)` matches `harness.agent.open` and not
265/// `harness.agent.session_open` — and if that still leaves a tie, the name
266/// resolves to nothing rather than sending a bare `read` to `harness.fs.read`
267/// when the caller meant `harness.secrets.read`.
268fn 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            // `agent_session_open` and `agent_open` both mean an agent method:
294            // the handle already says which session.
295            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
316/// Whether a capability method accepts what the removed global accepted.
317///
318/// Capability methods repeat the global's parameter list verbatim, so this
319/// separates a genuine successor from a same-named neighbour.
320fn 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
349/// Whether Harn source can still call `name` as a plain global.
350fn 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        // The name survived verbatim.
379        assert_eq!(
380            harness_migration_for_builtin("exit"),
381            forward(CapabilityId::Runtime, "exit")
382        );
383        // The name already carried its capability.
384        assert_eq!(
385            harness_migration_for_builtin("hostlib_code_index_rebuild"),
386            forward(CapabilityId::CodeIndex, "rebuild")
387        );
388        // The name carried a family prefix the handle now supplies.
389        assert_eq!(
390            harness_migration_for_builtin("agent_session_open"),
391            forward(CapabilityId::Agent, "open")
392        );
393        // A global that still resolves has not moved anywhere.
394        assert_eq!(harness_migration_for_builtin("len"), None);
395    }
396
397    /// Test doubles are the first thing a downstream package hits when it
398    /// upgrades, and they are hand-written natives rather than registered
399    /// builtins, so nothing derives their recipes. Name the whole family here
400    /// so a partially-covered set fails instead of stranding every consumer's
401    /// test suite on `HARN-NAM-002`.
402    #[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    /// Whether the linter can tell a caller where this global went.
432    ///
433    /// The clock, stdio, fs, env, random, and net families predate the recipe
434    /// table and keep their own replacement maps in `harn-parser`, which the
435    /// linter consults first.
436    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    /// Runtime plumbing that never had a script-facing name to migrate.
451    ///
452    /// `exec_opts` and `exec_at_opts` parse option records for the process
453    /// builtins, `render` backs the template engine, `host_tool_*` is the
454    /// host tool wire, and the rest are internals of the LLM mock and the
455    /// fact cache. None of them is a global that moved onto a handle.
456    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    /// A builtin Harn source cannot name is either a global that moved onto a
469    /// handle or runtime plumbing. The first kind needs a repair, or the
470    /// cutover leaves callers with a bare "not defined" and no way forward;
471    /// the second kind belongs on the list above, where a reviewer sees it.
472    #[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    /// Every capability method the legacy ambient bridge can restore as a
493    /// global needs a migration recipe, or a script running under the bridge
494    /// gets an error with nowhere to go.
495    #[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    /// A name two capabilities both answer to has no single rewrite target,
541    /// so it must stay uncovered rather than pick an owner arbitrarily.
542    #[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}