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        "transport_mock_clear" => {
128            return Some(forward(CapabilityId::Testing, "transport_mock_clear"));
129        }
130        "transport_mock_calls" => {
131            return Some(forward(CapabilityId::Testing, "transport_mock_calls"));
132        }
133        "sse_mock" => return Some(forward(CapabilityId::Testing, "sse_mock")),
134        "sse_server_mock_receive" => {
135            return Some(forward(CapabilityId::Testing, "sse_server_mock_receive"));
136        }
137        "sse_server_mock_disconnect" => {
138            return Some(forward(CapabilityId::Testing, "sse_server_mock_disconnect"));
139        }
140        "websocket_mock" => return Some(forward(CapabilityId::Testing, "websocket_mock")),
141        // The connector runtime used to inject `secret_get` for the duration
142        // of an export call. Connector exports now receive a root Harness, so
143        // the same read is a plain capability method.
144        "secret_get" => return Some(forward(CapabilityId::Secrets, "read")),
145        // These globals were renamed on the way onto their handle, so the
146        // name match below has nothing to follow. Everything that kept its
147        // name resolves without an entry here.
148        "mock_time" => return Some(forward(CapabilityId::Testing, "clock_set")),
149        "unmock_time" => return Some(forward(CapabilityId::Testing, "clock_reset")),
150        "advance_time" => return Some(forward(CapabilityId::Testing, "clock_advance")),
151        "mock_stdin" => return Some(forward(CapabilityId::Testing, "stdin_set")),
152        "unmock_stdin" => return Some(forward(CapabilityId::Testing, "stdin_reset")),
153        "mock_tty" => return Some(forward(CapabilityId::Testing, "tty_set")),
154        "unmock_tty" => return Some(forward(CapabilityId::Testing, "tty_reset")),
155        "host_mock_push_scope" => return Some(forward(CapabilityId::Testing, "push_scope")),
156        "host_mock_pop_scope" => return Some(forward(CapabilityId::Testing, "pop_scope")),
157        "host_mock_calls" => return Some(forward(CapabilityId::Testing, "calls")),
158        "llm_mock" => return Some(forward(CapabilityId::Llm, "mock_enqueue")),
159        "render_string" => return Some(forward(CapabilityId::Fs, "render_template")),
160        "render_with_provenance" => {
161            return Some(forward(CapabilityId::Fs, "render_prompt_with_provenance"));
162        }
163        "crypto_random_bytes" => return Some(forward(CapabilityId::Random, "bytes")),
164        "emit_channel" => return Some(forward(CapabilityId::Channels, "append")),
165        "flush_trigger_aggregations" => {
166            return Some(forward(CapabilityId::Channels, "flush_aggregations"));
167        }
168        // The handle is `harness.channels`; the globals were singular.
169        "channel_ack" => return Some(forward(CapabilityId::Channels, "ack")),
170        "channel_events" => return Some(forward(CapabilityId::Channels, "events")),
171        "channel_subscribe" => return Some(forward(CapabilityId::Channels, "subscribe")),
172        "channel_consumer_cursor" => {
173            return Some(forward(CapabilityId::Channels, "consumer_cursor"));
174        }
175        "pg_connect" => return Some(forward(CapabilityId::Postgres, "connect")),
176        "pg_pool" => return Some(forward(CapabilityId::Postgres, "pool")),
177        _ => {
178            return derived_capability_owner(name)
179                .map(|(capability, method)| forward(capability, method));
180        }
181    };
182    Some(request_record(method, fields))
183}
184
185/// Every typed Harness method, indexed by method name and by owning
186/// capability.
187///
188/// Two registration paths reach the same dispatch table. `#[harn_builtin]`
189/// declares most methods through `HarnessMethod` exposure; store, checkpoint,
190/// metadata, and host-injected methods are installed on the VM at startup and
191/// never reach the builtin manifest at all. Projecting both keeps the
192/// migration recipe — and with it the linter and `harn fix` — aware of the
193/// whole surface instead of a hand-maintained list that drifts every time a
194/// host adds a capability.
195struct CapabilityMethodIndex {
196    /// Owner of a method installed by `register_capability_method`, or `None`
197    /// when several capabilities install it. This is the surface the legacy
198    /// ambient bridge restores, so it decides what a bare global used to mean.
199    bridged_owner: BTreeMap<&'static str, Option<CapabilityId>>,
200    methods_by_capability: BTreeMap<CapabilityId, BTreeSet<&'static str>>,
201}
202
203fn capability_method_index() -> &'static CapabilityMethodIndex {
204    static INDEX: std::sync::OnceLock<CapabilityMethodIndex> = std::sync::OnceLock::new();
205    INDEX.get_or_init(|| {
206        let mut index = CapabilityMethodIndex {
207            bridged_owner: BTreeMap::new(),
208            methods_by_capability: BTreeMap::new(),
209        };
210        for entry in all_builtin_manifest() {
211            if let harn_builtin_meta::BuiltinExposure::HarnessMethod { capability, method } =
212                entry.contract.exposure
213            {
214                index
215                    .methods_by_capability
216                    .entry(capability)
217                    .or_default()
218                    .insert(method);
219            }
220        }
221        for (capability, method) in stdlib_probe_vm().capability_method_names() {
222            let method: &'static str = Box::leak(method.into_boxed_str());
223            index
224                .bridged_owner
225                .entry(method)
226                .and_modify(|owner| {
227                    if *owner != Some(capability) {
228                        *owner = None;
229                    }
230                })
231                .or_insert(Some(capability));
232            index
233                .methods_by_capability
234                .entry(capability)
235                .or_default()
236                .insert(method);
237        }
238        index
239    })
240}
241
242/// Where a pre-cutover global went, derived from the capability surface
243/// itself rather than from a parallel list.
244///
245/// A global that moved onto a handle nearly always kept its own name, so the
246/// method index doubles as the migration table. Three spellings show up:
247///
248///   * the name survived verbatim — `exit` became `harness.runtime.exit`;
249///   * the name already carried its capability — `hostlib_code_index_rebuild`
250///     became `harness.code_index.rebuild`;
251///   * the name carried a family prefix the handle now supplies —
252///     `agent_session_open` became `harness.agent.open`.
253///
254/// Two rules keep a guess from becoming a wrong rewrite. A name that is still
255/// a callable global resolves to nothing, because that call works as written
256/// and has not moved. A name several methods answer to is settled by parameter
257/// list — `agent_session_open(id?, opts?)` matches `harness.agent.open` and not
258/// `harness.agent.session_open` — and if that still leaves a tie, the name
259/// resolves to nothing rather than sending a bare `read` to `harness.fs.read`
260/// when the caller meant `harness.secrets.read`.
261fn derived_capability_owner(name: &str) -> Option<(CapabilityId, &'static str)> {
262    if is_source_visible_global(name) {
263        return None;
264    }
265    let index = capability_method_index();
266    if let Some((method, Some(owner))) = index.bridged_owner.get_key_value(name) {
267        return Some((*owner, method));
268    }
269
270    let mut candidates: Vec<(CapabilityId, &'static str)> = Vec::new();
271    let unprefixed = name.strip_prefix("hostlib_").unwrap_or(name);
272    for (capability, methods) in &index.methods_by_capability {
273        if let Some(method) = methods.get(name) {
274            candidates.push((*capability, method));
275        }
276        for prefix in [
277            capability.field_name().to_string(),
278            snake_case(capability.variant_name()),
279        ] {
280            let Some(rest) = unprefixed
281                .strip_prefix(&prefix)
282                .and_then(|rest| rest.strip_prefix('_'))
283            else {
284                continue;
285            };
286            // `agent_session_open` and `agent_open` both mean an agent method:
287            // the handle already says which session.
288            for method in [Some(rest), rest.strip_prefix("session_")]
289                .into_iter()
290                .flatten()
291                .filter_map(|method| methods.get(method))
292            {
293                candidates.push((*capability, method));
294            }
295        }
296    }
297    candidates.sort_unstable();
298    candidates.dedup();
299    if candidates.len() > 1 {
300        candidates
301            .retain(|(capability, method)| takes_the_same_parameters(name, *capability, method));
302    }
303    match candidates.as_slice() {
304        [only] => Some(*only),
305        _ => None,
306    }
307}
308
309/// Whether a capability method accepts what the removed global accepted.
310///
311/// Capability methods repeat the global's parameter list verbatim, so this
312/// separates a genuine successor from a same-named neighbour.
313fn takes_the_same_parameters(removed_global: &str, capability: CapabilityId, method: &str) -> bool {
314    let Some(before) = builtin_manifest_entry(removed_global) else {
315        return false;
316    };
317    let Some(after) = capability_method_manifest_entry(capability, method) else {
318        return false;
319    };
320    let names = |entry: &'static harn_builtin_registry::BuiltinManifestEntry| {
321        entry
322            .signature
323            .params
324            .iter()
325            .map(|param| param.name)
326            .collect::<Vec<_>>()
327    };
328    names(before) == names(after)
329}
330
331fn snake_case(camel: &str) -> String {
332    let mut out = String::with_capacity(camel.len() + 2);
333    for (index, ch) in camel.char_indices() {
334        if ch.is_ascii_uppercase() && index > 0 {
335            out.push('_');
336        }
337        out.push(ch.to_ascii_lowercase());
338    }
339    out
340}
341
342/// Whether Harn source can still call `name` as a plain global.
343fn is_source_visible_global(name: &str) -> bool {
344    builtin_manifest_entry(name).is_some_and(|entry| {
345        matches!(
346            entry.contract.exposure,
347            harn_builtin_meta::BuiltinExposure::PureGlobal
348                | harn_builtin_meta::BuiltinExposure::CapabilityFunction { .. }
349        )
350    })
351}
352#[cfg(test)]
353mod registered_capability_migration_tests {
354    use harn_builtin_meta::CapabilityId;
355
356    use super::{
357        all_builtin_manifest, harness_migration_for_builtin, HarnessBuiltinArgumentMigration,
358        HarnessBuiltinMigration,
359    };
360    use crate::stdlib::stdlib_probe_vm;
361
362    #[test]
363    fn migration_recipes_follow_names_that_moved_onto_a_handle() {
364        let forward = |capability, method| {
365            Some(HarnessBuiltinMigration {
366                capability,
367                method,
368                arguments: HarnessBuiltinArgumentMigration::Forward,
369            })
370        };
371        // The name survived verbatim.
372        assert_eq!(
373            harness_migration_for_builtin("exit"),
374            forward(CapabilityId::Runtime, "exit")
375        );
376        // The name already carried its capability.
377        assert_eq!(
378            harness_migration_for_builtin("hostlib_code_index_rebuild"),
379            forward(CapabilityId::CodeIndex, "rebuild")
380        );
381        // The name carried a family prefix the handle now supplies.
382        assert_eq!(
383            harness_migration_for_builtin("agent_session_open"),
384            forward(CapabilityId::Agent, "open")
385        );
386        // A global that still resolves has not moved anywhere.
387        assert_eq!(harness_migration_for_builtin("len"), None);
388    }
389
390    /// Whether the linter can tell a caller where this global went.
391    ///
392    /// The clock, stdio, fs, env, random, and net families predate the recipe
393    /// table and keep their own replacement maps in `harn-parser`, which the
394    /// linter consults first.
395    fn has_a_repair(name: &str) -> bool {
396        use harn_parser::diagnostic::{
397            harness_clock_replacement, harness_env_replacement, harness_fs_replacement,
398            harness_net_replacement, harness_random_replacement, harness_stdio_replacement,
399        };
400        harness_migration_for_builtin(name).is_some()
401            || harness_clock_replacement(name).is_some()
402            || harness_stdio_replacement(name).is_some()
403            || harness_fs_replacement(name).is_some()
404            || harness_env_replacement(name).is_some()
405            || harness_random_replacement(name).is_some()
406            || harness_net_replacement(name).is_some()
407    }
408
409    /// Runtime plumbing that never had a script-facing name to migrate.
410    ///
411    /// `exec_opts` and `exec_at_opts` parse option records for the process
412    /// builtins, `render` backs the template engine, `host_tool_*` is the
413    /// host tool wire, and the rest are internals of the LLM mock and the
414    /// fact cache. None of them is a global that moved onto a handle.
415    const RUNTIME_PLUMBING: &[&str] = &[
416        "exec_at_opts",
417        "exec_opts",
418        "host_tool_call",
419        "host_tool_list",
420        "invalidate_facts",
421        "llm_mock_known_scopes",
422        "llm_mock_load_jsonl",
423        "llm_mock_receipts",
424        "render",
425    ];
426
427    /// A builtin Harn source cannot name is either a global that moved onto a
428    /// handle or runtime plumbing. The first kind needs a repair, or the
429    /// cutover leaves callers with a bare "not defined" and no way forward;
430    /// the second kind belongs on the list above, where a reviewer sees it.
431    #[test]
432    fn every_runtime_internal_builtin_is_migrated_or_named_as_plumbing() {
433        use harn_builtin_meta::BuiltinExposure;
434
435        let offenders = all_builtin_manifest()
436            .iter()
437            .filter(|entry| entry.is_canonical())
438            .filter(|entry| matches!(entry.contract.exposure, BuiltinExposure::RuntimeInternal))
439            .filter(|entry| !entry.name.starts_with("__"))
440            .filter(|entry| !RUNTIME_PLUMBING.contains(&entry.name))
441            .filter(|entry| !has_a_repair(entry.name))
442            .map(|entry| entry.name)
443            .collect::<Vec<_>>();
444
445        assert!(
446            offenders.is_empty(),
447            "these globals moved onto a handle but report no repair: {offenders:?}"
448        );
449    }
450
451    /// Every capability method the legacy ambient bridge can restore as a
452    /// global needs a migration recipe, or a script running under the bridge
453    /// gets an error with nowhere to go.
454    #[test]
455    fn every_uniquely_owned_capability_method_has_a_migration() {
456        let vm = stdlib_probe_vm();
457        let declared: std::collections::BTreeSet<String> = super::all_builtin_manifest()
458            .iter()
459            .map(|entry| entry.name.to_string())
460            .collect();
461        let mut owners: std::collections::BTreeMap<String, std::collections::BTreeSet<_>> =
462            std::collections::BTreeMap::new();
463        for (capability, method) in vm.capability_method_names() {
464            owners.entry(method).or_default().insert(capability);
465        }
466
467        let missing: Vec<_> = owners
468            .iter()
469            .filter(|(method, capabilities)| {
470                capabilities.len() == 1
471                    && !declared.contains(*method)
472                    && harness_migration_for_builtin(method).is_none()
473            })
474            .map(|(method, _)| method.clone())
475            .collect();
476        assert!(
477            missing.is_empty(),
478            "capability methods without a migration recipe: {missing:?}"
479        );
480    }
481
482    #[test]
483    fn runtime_registered_store_methods_migrate_to_their_owning_capability() {
484        for method in ["store_get", "store_set", "store_delete", "store_list"] {
485            let migration =
486                harness_migration_for_builtin(method).expect("store method has a migration");
487            assert_eq!(
488                migration.capability,
489                harn_builtin_meta::CapabilityId::Runtime
490            );
491            assert_eq!(migration.method, method);
492            assert_eq!(
493                migration.arguments,
494                HarnessBuiltinArgumentMigration::Forward
495            );
496        }
497    }
498
499    /// A name two capabilities both answer to has no single rewrite target,
500    /// so it must stay uncovered rather than pick an owner arbitrarily.
501    #[test]
502    fn ambiguously_owned_methods_have_no_migration() {
503        let vm = stdlib_probe_vm();
504        let mut owners: std::collections::BTreeMap<String, std::collections::BTreeSet<_>> =
505            std::collections::BTreeMap::new();
506        for (capability, method) in vm.capability_method_names() {
507            owners.entry(method).or_default().insert(capability);
508        }
509        let Some((method, _)) = owners
510            .iter()
511            .find(|(method, capabilities)| {
512                capabilities.len() > 1 && super::harness_method_for_builtin(method).is_none()
513            })
514            .map(|(method, capabilities)| (method.clone(), capabilities.clone()))
515        else {
516            return;
517        };
518        assert!(
519            super::derived_capability_owner(&method).is_none(),
520            "`{method}` is owned by several capabilities and must not resolve to one"
521        );
522    }
523}