Skip to content

Commit 6738f47

Browse files
compusophyclaude
andcommitted
feat(cli): compile --host-calls dumps a cartridge's host-call surface (telemetry #52)
#52 asked for a way to debug a cartridge + its platform surface locally before spending $LH to publish. The dry-run half already existed (`compile` compile-checks with no chain write); this adds `--host-calls` (alias `--schemas`) which parses the compiled wasm import section and prints every `host::<module>::<func>` the cartridge binds — ground-truth from the bytes, no rustlite change, zero new deps. Dumps for ANY cartridge that compiled, including the entry-less / over-cap cases an author most wants to introspect. Headless execution stays out of scope (no native wasm host); output points at the in-browser run_cartridge path. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent b4e9f29 commit 6738f47

4 files changed

Lines changed: 224 additions & 8 deletions

File tree

src/bin/localharness/main.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
//! the public face so a live URL exists immediately
1919
//! face <name> <directory|app|html>
2020
//! set the subdomain's public face (visitor view)
21-
//! compile <src.rl> compile-check a rustlite cartridge locally (no write)
21+
//! compile <src.rl> [--host-calls]
22+
//! compile-check a rustlite cartridge locally (no write;
23+
//! --host-calls dumps its host:: platform-call surface)
2224
//! publish <name> <src.rl> compile a rustlite cartridge + publish it as
2325
//! <name>'s public face on-chain (served to every
2426
//! visitor 24/7, no browser tab required); CLAIMS the
@@ -293,7 +295,9 @@ IDENTITY & PROFILE
293295
app store (each name + its live URL)
294296
295297
CARTRIDGES & PUBLISHING
296-
localharness compile <src.rl> compile-check a cartridge locally (no write)
298+
localharness compile <src.rl> [--host-calls]
299+
compile-check a cartridge locally (no write);
300+
--host-calls dumps its host:: platform surface
297301
localharness sh <script.bl> [--as <name>] [--confirm]
298302
localharness sh -c '<inline script>' [--as <name>] [--confirm]
299303
run a bashlite script (file or -c inline):
@@ -770,9 +774,15 @@ async fn run(args: &[String]) -> i32 {
770774
eprintln!("usage: localharness face <name> <directory|app|html>");
771775
2
772776
}
773-
Some("compile") if args.len() >= 2 => compile_check(&args[1], args.get(2).map(String::as_str)),
777+
Some("compile") if args.len() >= 2 => match parse_compile_args(&args[1..]) {
778+
Ok((src, out, host_calls)) => compile_check(&src, out.as_deref(), host_calls),
779+
Err(u) => {
780+
eprintln!("{u}");
781+
2
782+
}
783+
},
774784
Some("compile") => {
775-
eprintln!("usage: localharness compile <source.rl> [out.wasm]");
785+
eprintln!("usage: localharness compile <source.rl> [out.wasm] [--host-calls]");
776786
2
777787
}
778788
Some("price") if args.len() >= 3 => set_price(&args[1], &args[2]).await,

src/bin/localharness/publish.rs

Lines changed: 208 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,10 +472,124 @@ pub(crate) fn compile_big_stack(src: &str) -> Result<Vec<u8>, localharness::rust
472472
.expect("rustlite compile thread panicked")
473473
}
474474

475+
/// Parse the wasm import section (id 2) and return every `host::<module>::<func>`
476+
/// the cartridge binds — its exact platform-call surface (its "tool schemas" in
477+
/// cartridge-author terms) — sorted + deduped. Imports are emitted as
478+
/// `host_<module>` (codegen), so the `host_` prefix is stripped. Conservative:
479+
/// malformed / truncated bytes yield an empty Vec, never a panic.
480+
pub(crate) fn cartridge_host_calls(wasm: &[u8]) -> Vec<String> {
481+
fn leb(b: &[u8], i: &mut usize) -> Option<u64> {
482+
let (mut result, mut shift) = (0u64, 0u32);
483+
loop {
484+
let byte = *b.get(*i)?;
485+
*i += 1;
486+
result |= ((byte & 0x7f) as u64) << shift;
487+
if byte & 0x80 == 0 {
488+
return Some(result);
489+
}
490+
shift += 7;
491+
if shift >= 64 {
492+
return None;
493+
}
494+
}
495+
}
496+
// Skip a wasm `limits` (flag byte, min, optional max) — table/mem imports.
497+
fn skip_limits(b: &[u8], i: &mut usize) -> Option<()> {
498+
let flag = leb(b, i)?;
499+
leb(b, i)?; // min
500+
if flag & 0x01 != 0 {
501+
leb(b, i)?; // max
502+
}
503+
Some(())
504+
}
505+
fn parse(wasm: &[u8]) -> Option<Vec<String>> {
506+
if wasm.len() < 8 || &wasm[0..4] != b"\0asm" {
507+
return None;
508+
}
509+
let mut calls: Vec<String> = Vec::new();
510+
let mut i = 8; // skip magic + version
511+
while i < wasm.len() {
512+
let id = wasm[i];
513+
i += 1;
514+
let size = leb(wasm, &mut i)?;
515+
let section_end = i.checked_add(size as usize)?;
516+
if section_end > wasm.len() {
517+
return None;
518+
}
519+
if id == 2 {
520+
let mut j = i;
521+
let count = leb(wasm, &mut j)?;
522+
for _ in 0..count {
523+
let mod_len = leb(wasm, &mut j)? as usize;
524+
let module = wasm.get(j..)?.get(..mod_len)?;
525+
j += mod_len;
526+
let field_len = leb(wasm, &mut j)? as usize;
527+
let field = wasm.get(j..)?.get(..field_len)?;
528+
j += field_len;
529+
let kind = *wasm.get(j)?;
530+
j += 1;
531+
match kind {
532+
0x00 => {
533+
leb(wasm, &mut j)?; // func: type index
534+
if let Some(m) = module.strip_prefix(b"host_") {
535+
if let (Ok(m), Ok(f)) =
536+
(std::str::from_utf8(m), std::str::from_utf8(field))
537+
{
538+
calls.push(format!("host::{m}::{f}"));
539+
}
540+
}
541+
}
542+
0x01 => {
543+
j += 1; // table: elem type
544+
skip_limits(wasm, &mut j)?;
545+
}
546+
0x02 => skip_limits(wasm, &mut j)?, // mem
547+
0x03 => j += 2, // global: valtype + mut
548+
_ => return None,
549+
}
550+
}
551+
}
552+
i = section_end;
553+
}
554+
calls.sort();
555+
calls.dedup();
556+
Some(calls)
557+
}
558+
parse(wasm).unwrap_or_default()
559+
}
560+
561+
/// Parse `compile <source.rl> [out.wasm] [--out <file>] [--host-calls|--schemas]`.
562+
/// A bare 2nd positional is the out path (back-compat); `--out` is the flag form.
563+
/// `--host-calls` (telemetry #52 alias `--schemas`) dumps the cartridge's
564+
/// host-call surface. Pure/testable. Returns `(source, out, host_calls)`.
565+
pub(crate) fn parse_compile_args(rest: &[String]) -> Result<(String, Option<String>, bool), String> {
566+
const USAGE: &str =
567+
"usage: localharness compile <source.rl> [out.wasm] [--out <file>] [--host-calls]";
568+
let (mut source, mut out, mut host_calls) = (None, None, false);
569+
let mut i = 0;
570+
while i < rest.len() {
571+
match rest[i].as_str() {
572+
"--host-calls" | "--schemas" => host_calls = true,
573+
"--out" => {
574+
i += 1;
575+
// A following flag is a missing value, not the out path.
576+
out = Some(rest.get(i).filter(|s| !s.starts_with("--")).ok_or(USAGE)?.clone());
577+
}
578+
s if s.starts_with("--") => return Err(USAGE.to_string()),
579+
s if source.is_none() => source = Some(s.to_string()),
580+
s if out.is_none() => out = Some(s.to_string()),
581+
_ => return Err(USAGE.to_string()),
582+
}
583+
i += 1;
584+
}
585+
Ok((source.ok_or(USAGE)?, out, host_calls))
586+
}
587+
475588
/// Compile-check a rustlite cartridge locally and report its size — NO on-chain
476589
/// write. Lets an author iterate before spending a sponsored publish. With
477-
/// `out_path`, also writes the compiled `.wasm` (handy for local validation).
478-
pub(crate) fn compile_check(source_path: &str, out_path: Option<&str>) -> i32 {
590+
/// `out_path`, also writes the compiled `.wasm`. With `host_calls`, dumps the
591+
/// `host::<module>::<func>` platform-call surface the cartridge binds.
592+
pub(crate) fn compile_check(source_path: &str, out_path: Option<&str>, host_calls: bool) -> i32 {
479593
let src = match read_file_clean(source_path) {
480594
Ok(s) => s,
481595
Err(e) => {
@@ -493,6 +607,19 @@ pub(crate) fn compile_check(source_path: &str, out_path: Option<&str>) -> i32 {
493607
}
494608
println!(" wrote {out}");
495609
}
610+
// Dump BEFORE the entry/cap gates: an entry-less or over-cap cartridge
611+
// is exactly the broken case an author wants to introspect (telemetry #52).
612+
if host_calls {
613+
let calls = cartridge_host_calls(&wasm);
614+
if calls.is_empty() {
615+
println!(" host-calls: none (binds no host:: platform calls)");
616+
} else {
617+
println!(" host-calls ({}):", calls.len());
618+
for c in &calls {
619+
println!(" {c}");
620+
}
621+
}
622+
}
496623
if !cartridge_has_entry(&wasm) {
497624
eprintln!(
498625
" ✗ no `frame` or `render` export — the loader has no entry to \
@@ -511,6 +638,12 @@ pub(crate) fn compile_check(source_path: &str, out_path: Option<&str>) -> i32 {
511638
" fits the {APPSTORE_PUBLISH_CAP}-byte publish cap ({} bytes to spare)",
512639
APPSTORE_PUBLISH_CAP - wasm.len()
513640
);
641+
// No native wasm host exists — the host_* imports are browser closures.
642+
// Point authors at the real exec surface instead of faking a headless run.
643+
println!(
644+
" run it live via the in-browser run_cartridge tool, or open \
645+
https://<name>.localharness.xyz after publish"
646+
);
514647
0
515648
}
516649
Err(e) => {
@@ -1125,6 +1258,79 @@ mod tests {
11251258
assert!(!cartridge_has_entry(b"\0asm\x01\0\0\0\x07\xff")); // bogus section size
11261259
}
11271260

1261+
#[test]
1262+
fn cartridge_host_calls_lists_bound_platform_calls() {
1263+
// The dump is the cartridge's OWN host-call surface, sorted + deduped.
1264+
let wasm = localharness::rustlite::compile(
1265+
"fn frame(t: i32) { host::display::clear(0); host::display::present(); }",
1266+
)
1267+
.unwrap();
1268+
assert_eq!(
1269+
cartridge_host_calls(&wasm),
1270+
vec![
1271+
"host::display::clear".to_string(),
1272+
"host::display::present".to_string()
1273+
]
1274+
);
1275+
}
1276+
1277+
#[test]
1278+
fn cartridge_host_calls_spans_modules_and_strips_host_prefix() {
1279+
// A second module (net) proves the `host_`-prefix strip is per-import.
1280+
let wasm = localharness::rustlite::compile(
1281+
"fn frame(t: i32) { host::display::present(); let h: i32 = host::net::open(0); }",
1282+
)
1283+
.unwrap();
1284+
let calls = cartridge_host_calls(&wasm);
1285+
assert!(calls.contains(&"host::net::open".to_string()), "got: {calls:?}");
1286+
assert!(calls.contains(&"host::display::present".to_string()), "got: {calls:?}");
1287+
}
1288+
1289+
#[test]
1290+
fn cartridge_host_calls_dedups_repeated_binds() {
1291+
// The same call in two branches yields a single import → one entry.
1292+
let wasm = localharness::rustlite::compile(
1293+
"fn frame(t: i32) { if t > 0 { host::display::present(); } else { host::display::present(); } }",
1294+
)
1295+
.unwrap();
1296+
assert_eq!(cartridge_host_calls(&wasm), vec!["host::display::present".to_string()]);
1297+
}
1298+
1299+
#[test]
1300+
fn cartridge_host_calls_robust_to_garbage() {
1301+
// Malformed / truncated bytes never panic and report no calls.
1302+
assert!(cartridge_host_calls(b"").is_empty());
1303+
assert!(cartridge_host_calls(b"\0asm").is_empty()); // header only
1304+
assert!(cartridge_host_calls(b"\0asm\x01\0\0\0\x02\xff").is_empty()); // bogus section size
1305+
}
1306+
1307+
#[test]
1308+
fn parse_compile_args_flags_and_aliases() {
1309+
// `--host-calls` and its `--schemas` alias resolve to the same bool.
1310+
let hc = parse_compile_args(&args_of(&["app.rl", "--host-calls"])).unwrap();
1311+
let sc = parse_compile_args(&args_of(&["app.rl", "--schemas"])).unwrap();
1312+
assert_eq!(hc, ("app.rl".to_string(), None, true));
1313+
assert_eq!(sc, ("app.rl".to_string(), None, true));
1314+
// `--out <path>` resolves the out path; a bare 2nd positional still works.
1315+
assert_eq!(
1316+
parse_compile_args(&args_of(&["app.rl", "--out", "o.wasm"])).unwrap(),
1317+
("app.rl".to_string(), Some("o.wasm".to_string()), false)
1318+
);
1319+
assert_eq!(
1320+
parse_compile_args(&args_of(&["app.rl", "o.wasm"])).unwrap(),
1321+
("app.rl".to_string(), Some("o.wasm".to_string()), false)
1322+
);
1323+
// Missing source or a dangling `--out` is an error, not a panic.
1324+
assert!(parse_compile_args(&args_of(&["--host-calls"])).is_err());
1325+
assert!(parse_compile_args(&args_of(&["app.rl", "--out"])).is_err());
1326+
// `--out` must not swallow a following flag as its value.
1327+
assert!(parse_compile_args(&args_of(&["app.rl", "--out", "--host-calls"])).is_err());
1328+
}
1329+
1330+
fn args_of(parts: &[&str]) -> Vec<String> {
1331+
parts.iter().map(|s| s.to_string()).collect()
1332+
}
1333+
11281334
#[test]
11291335
fn read_file_clean_maps_not_found_without_leaking_os_error() {
11301336
// Closes on-chain QA finding #1: "os error 2" must not reach the user.

src/docs_manifest.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ pub const AGENT_TOOLS: &[(&str, &[&str])] = &[
178178
pub const CLI_COMMANDS: &[(&str, &str)] = &[
179179
("create", "claim <name>.localharness.xyz (sponsored); scaffolds ./app.rl"),
180180
("onboard", "get a brand-new identity its first $LH via an invite (the terminal onboarding entry)"),
181-
("compile", "compile-check a rustlite cartridge locally (no on-chain write)"),
181+
("compile", "compile-check a rustlite cartridge locally (no on-chain write); --host-calls dumps its host:: platform surface"),
182182
("sh", "run a bashlite script: fs + lh-* commands + `run` composition; value moves (lh-send) need --confirm"),
183183
("publish", "publish a public face (.rl app or .html page; auto-claims if needed)"),
184184
("face", "set the public face: directory | app | html"),

web/skill.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ An agent in the browser (or a scheduled headless run) acts through these tools:
250250
<!-- GEN:cli -->
251251
- `localharness create` — claim <name>.localharness.xyz (sponsored); scaffolds ./app.rl
252252
- `localharness onboard` — get a brand-new identity its first $LH via an invite (the terminal onboarding entry)
253-
- `localharness compile` — compile-check a rustlite cartridge locally (no on-chain write)
253+
- `localharness compile` — compile-check a rustlite cartridge locally (no on-chain write); --host-calls dumps its host:: platform surface
254254
- `localharness sh` — run a bashlite script: fs + lh-* commands + `run` composition; value moves (lh-send) need --confirm
255255
- `localharness publish` — publish a public face (.rl app or .html page; auto-claims if needed)
256256
- `localharness face` — set the public face: directory | app | html

0 commit comments

Comments
 (0)