From 4e72d1e71ebeb7633cadcf4adb143ee2a5d87f66 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 9 Aug 2026 10:44:39 +0800 Subject: [PATCH] feat(vm): freeze builtin call IDs --- build.rs | 512 ++++++++++--------- docs/callable-runtime.md | 19 +- pd-vm-nostd/README.md | 2 +- pd-vm-nostd/src/generated_builtin_ids.rs | 222 ++++++++ pd-vm-nostd/src/lib.rs | 1 + pd-vm-nostd/src/vm.rs | 58 ++- pd-vm-nostd/src/vmbc.rs | 4 +- pd-vm-nostd/tests/embedded_vmbc.rs | 4 +- src/builtins/catalog.rs | 142 +++++ src/builtins/runtime/core.rs | 21 +- src/bytecode.rs | 5 +- src/debugger/recording.rs | 10 +- src/lib.rs | 24 +- src/vm/aot/artifact.rs | 18 +- src/vm/native/mod.rs | 2 +- src/vmbc.rs | 6 +- tests/wire/catalog_build_validation_tests.rs | 228 +++++++++ tests/wire/catalog_contract_tests.rs | 372 ++++++++++++++ tests/wire/wire_tests.rs | 19 +- tests/wire_tests.rs | 6 + 20 files changed, 1358 insertions(+), 317 deletions(-) create mode 100644 pd-vm-nostd/src/generated_builtin_ids.rs create mode 100644 src/builtins/catalog.rs create mode 100644 tests/wire/catalog_build_validation_tests.rs create mode 100644 tests/wire/catalog_contract_tests.rs diff --git a/build.rs b/build.rs index 00b550e9..cdd376a0 100644 --- a/build.rs +++ b/build.rs @@ -60,6 +60,43 @@ impl HostBindingKind { } } +/// Documented call-index blocks shared by builtins and host imports. +/// +/// Must match the block table in `src/builtins/catalog.rs`. +pub(crate) const ORDINARY_BLOCK_START: u16 = 0xFFA2; +pub(crate) const SPECIAL_CALL_BLOCK_START: u16 = 0xFF90; +pub(crate) const SPECIAL_CALL_BLOCK_END: u16 = 0xFFA1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CatalogClass { + Ordinary, + Internal, + Special, +} + +impl CatalogClass { + fn parse(value: &str) -> Self { + match value { + "Ordinary" => Self::Ordinary, + "Internal" => Self::Internal, + "Special" => Self::Special, + other => panic!( + "unknown builtin catalog class {other:?} (expected Ordinary, Internal, or Special)" + ), + } + } +} + +/// One explicit static builtin ID from `src/builtins/catalog.rs`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CatalogEntry { + pub(crate) id: u16, + pub(crate) source_name: String, + pub(crate) variant: String, + pub(crate) class: CatalogClass, + pub(crate) feature_gate: String, +} + #[derive(Clone, Debug)] struct CallableDecl { rust_ident: String, @@ -101,6 +138,10 @@ fn main() { println!("cargo:rerun-if-changed={}", namespace_manifest.display()); let namespaces = parse_namespace_manifest(&namespace_manifest); + let catalog_path = manifest_dir.join("src").join("builtins").join("catalog.rs"); + println!("cargo:rerun-if-changed={}", catalog_path.display()); + let catalog = parse_catalog(&catalog_path); + let host_sources = [SourceSpec { path: "src/builtins/runtime/host.rs".to_string(), module: "host".to_string(), @@ -131,6 +172,7 @@ fn main() { &host_callables, &builtin_callables, &metadata_callables, + &catalog, ), ); write_generated_file( @@ -367,6 +409,183 @@ fn parse_source_file(path: &Path, spec: &SourceSpec, _order_offset: usize) -> Ve out } +/// Parse the authoritative static builtin ID catalog (`src/builtins/catalog.rs`). +/// +/// Each entry is one line: +/// +/// ```text +/// builtin_id!(0xXXXX, "source_name", RustVariant, Class, feature_gate); +/// ``` +/// +/// Fails on malformed lines, duplicate IDs, duplicate source names, or +/// duplicate Rust variants. Class and gate values are validated here; block +/// membership and callable coverage are validated by +/// [`validate_catalog_contract`]. +fn parse_catalog(path: &Path) -> Vec { + let source = fs::read_to_string(path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + parse_catalog_source(&source, &path.display().to_string()) +} + +pub(crate) fn parse_catalog_source(source: &str, display: &str) -> Vec { + let mut entries = Vec::new(); + let mut seen_ids = HashSet::new(); + let mut seen_names = HashSet::new(); + let mut seen_variants = HashSet::new(); + for (line_index, raw_line) in source.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with("//") { + continue; + } + let line_number = line_index + 1; + let Some(rest) = line.strip_prefix("builtin_id!(") else { + panic!("{display}:{line_number}: unexpected line in builtin catalog: {line:?}"); + }; + let rest = rest.strip_suffix(");").unwrap_or_else(|| { + panic!("{display}:{line_number}: catalog entry must end with ');': {line:?}") + }); + let parts = rest.split(',').map(str::trim).collect::>(); + if parts.len() != 5 { + panic!( + "{display}:{line_number}: catalog entry needs 5 fields \ + (id, source_name, RustVariant, Class, feature_gate): {line:?}" + ); + } + let id = u16::from_str_radix(parts[0].trim_start_matches("0x"), 16).unwrap_or_else(|err| { + panic!("{display}:{line_number}: invalid builtin id {parts:?}: {err}") + }); + let source_name = strip_quoted(parts[1]).unwrap_or_else(|| { + panic!("{display}:{line_number}: expected quoted source name: {line:?}") + }); + let variant = parts[2].to_string(); + let class = CatalogClass::parse(parts[3]); + let feature_gate = parts[4].to_string(); + if feature_gate != "none" { + panic!( + "{display}:{line_number}: unsupported feature gate {feature_gate:?}; \ + only 'none' is defined today" + ); + } + if !seen_ids.insert(id) { + panic!("{display}:{line_number}: duplicate builtin id 0x{id:04X}"); + } + if !seen_names.insert(source_name.clone()) { + panic!("{display}:{line_number}: duplicate builtin source name {source_name:?}"); + } + if !seen_variants.insert(variant.clone()) { + panic!("{display}:{line_number}: duplicate builtin variant {variant}"); + } + entries.push(CatalogEntry { + id, + source_name, + variant, + class, + feature_gate, + }); + } + entries +} + +fn strip_quoted(value: &str) -> Option { + value + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .map(str::to_string) +} + +/// Validate the static builtin ID contract against the discovered callables. +/// +/// Fails the build when: +/// - a runtime callable has no explicit catalog ID, or a catalog entry has no +/// runtime callable (typos and missing IDs); +/// - a catalog variant does not match the derived variant for its source name; +/// - a class disagrees with the dispatch classification (ordinary vs +/// special-call) or with the `__` internal-name prefix; +/// - an ID falls outside its documented block. +pub(crate) fn validate_catalog_contract( + entries: &[CatalogEntry], + discovered_names: &HashSet, + special_variants: &HashSet, +) { + let catalog_names = entries + .iter() + .map(|entry| entry.source_name.as_str()) + .collect::>(); + let discovered = discovered_names + .iter() + .map(String::as_str) + .collect::>(); + if catalog_names != discovered { + let mut missing = discovered.difference(&catalog_names).collect::>(); + missing.sort_unstable(); + let mut extra = catalog_names.difference(&discovered).collect::>(); + extra.sort_unstable(); + panic!( + "builtin catalog does not match discovered callables: \ + missing explicit IDs for {missing:?}, catalog entries without callables {extra:?}" + ); + } + for entry in entries { + let expected_variant = builtin_variant_name(&entry.source_name); + if expected_variant != entry.variant { + panic!( + "catalog variant mismatch for '{}': derived {expected_variant}, catalog {}", + entry.source_name, entry.variant + ); + } + let is_special_call = special_variants.contains(&entry.variant); + match entry.class { + CatalogClass::Ordinary => { + if is_special_call { + panic!( + "catalog entry '{}' is class Ordinary but dispatches as a special-call builtin", + entry.source_name + ); + } + if entry.id < ORDINARY_BLOCK_START { + panic!( + "ordinary builtin '{}' id 0x{:04X} falls outside the ordinary block \ + 0x{ORDINARY_BLOCK_START:04X}..=0xFFFF", + entry.source_name, entry.id + ); + } + } + CatalogClass::Internal | CatalogClass::Special => { + if !is_special_call { + panic!( + "catalog entry '{}' is class {:?} but is not a special-call builtin", + entry.source_name, entry.class + ); + } + if !(SPECIAL_CALL_BLOCK_START..=SPECIAL_CALL_BLOCK_END).contains(&entry.id) { + panic!( + "special-call builtin '{}' id 0x{:04X} falls outside the special-call block \ + 0x{SPECIAL_CALL_BLOCK_START:04X}..=0x{SPECIAL_CALL_BLOCK_END:04X}", + entry.source_name, entry.id + ); + } + } + } + let is_internal_name = entry.source_name.starts_with("__"); + match entry.class { + CatalogClass::Internal if !is_internal_name => { + panic!( + "catalog entry '{}' is class Internal but its source name has no '__' prefix", + entry.source_name + ); + } + CatalogClass::Special if is_internal_name => { + panic!( + "catalog entry '{}' has an internal '__' source name but is class Special; \ + use Internal", + entry.source_name + ); + } + _ => {} + } + } +} + fn parse_namespace_manifest(path: &Path) -> Vec { let source = fs::read_to_string(path) .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); @@ -467,6 +686,7 @@ fn render_builtin_catalog( host_callables: &[CallableDecl], builtin_callables: &[CallableDecl], metadata_callables: &[CallableDecl], + catalog: &[CatalogEntry], ) -> String { let language_group_input = metadata_callables .iter() @@ -482,21 +702,21 @@ fn render_builtin_catalog( let host_groups = stable_groups(&host_group_input, |callable| callable.name.clone()); let (builtin_variant_order, actual_builtin_by_variant) = ordered_actual_builtin_variants(namespaces, builtin_callables, metadata_callables); - let builtin_call_count = u16::try_from( - builtin_variant_order - .len() - .checked_sub(appended_builtin_order().len()) - .expect("appended builtin count should fit catalog"), - ) - .expect("builtin function count should fit in u16"); - let builtin_call_base = u16::MAX - .checked_sub(builtin_call_count) - .and_then(|value| value.checked_add(1)) - .expect("builtin call base should fit in u16"); - assert!( - builtin_call_base >= 15, - "builtin call base must leave room for reserved special builtins" - ); + let discovered_names = builtin_callables + .iter() + .chain(metadata_callables.iter()) + .map(|callable| callable.name.clone()) + .collect::>(); + let special_variants = special_builtin_order() + .iter() + .chain(appended_builtin_order()) + .map(|name| builtin_variant_name(name)) + .collect::>(); + validate_catalog_contract(catalog, &discovered_names, &special_variants); + let catalog_id_by_variant = catalog + .iter() + .map(|entry| (entry.variant.clone(), entry.id)) + .collect::>(); let namespace_member_group_input = builtin_callables .iter() @@ -532,27 +752,15 @@ fn render_builtin_catalog( .unwrap(); writeln!(&mut out, "#[repr(u16)]").unwrap(); writeln!(&mut out, "pub enum BuiltinFunction {{").unwrap(); - for (index, variant) in builtin_variant_order.iter().enumerate() { - if index == 0 { - writeln!(&mut out, " {variant} = 0,").unwrap(); - } else { - writeln!(&mut out, " {variant},").unwrap(); - } + for variant in &builtin_variant_order { + let id = catalog_id_by_variant + .get(variant) + .unwrap_or_else(|| panic!("missing static id for builtin variant '{variant}'")); + writeln!(&mut out, " {variant} = 0x{id:04X},").unwrap(); } writeln!(&mut out, "}}").unwrap(); writeln!(&mut out).unwrap(); - writeln!( - &mut out, - "const MAIN_RANGE_BUILTINS: &[BuiltinFunction] = &[" - ) - .unwrap(); - for variant in main_range_builtin_variants(&builtin_variant_order) { - writeln!(&mut out, " BuiltinFunction::{variant},").unwrap(); - } - writeln!(&mut out, "];").unwrap(); - writeln!(&mut out).unwrap(); - for group in &language_groups { render_signature_group_const( &mut out, @@ -588,95 +796,17 @@ fn render_builtin_catalog( writeln!( &mut out, - "pub(crate) const BUILTIN_CALL_BASE: u16 = 0x{builtin_call_base:04X};" - ) - .unwrap(); - writeln!( - &mut out, - "pub(crate) const BUILTIN_CALL_COUNT: u16 = MAIN_RANGE_BUILTINS.len() as u16;" - ) - .unwrap(); - writeln!(&mut out).unwrap(); - writeln!( - &mut out, - "const SPECIAL_CALL_BUILTINS: &[(u16, BuiltinFunction)] = &[" - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 4, BuiltinFunction::FormatTemplate)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 3, BuiltinFunction::ToString)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 2, BuiltinFunction::TypeOf)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 1, BuiltinFunction::Assert)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 7, BuiltinFunction::StringContains)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 6, BuiltinFunction::StringReplaceLiteral)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 5, BuiltinFunction::StringLowerAscii)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 8, BuiltinFunction::StringSplitLiteral)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 9, BuiltinFunction::MapIterInit)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 10, BuiltinFunction::MapIterNext)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 11, BuiltinFunction::MapIterTakeKey)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 12, BuiltinFunction::MapIterTakeValue)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 13, BuiltinFunction::MapIterClose)," - ) - .unwrap(); - writeln!( - &mut out, - " (BUILTIN_CALL_BASE - 14, BuiltinFunction::BindCallable)," + "/// Every VM-visible builtin in catalog order; the enum discriminants are the static IDs." ) .unwrap(); writeln!( &mut out, - " (BUILTIN_CALL_BASE - 15, BuiltinFunction::DetachLocal)," + "pub const BUILTIN_CATALOG: &[BuiltinFunction] = &[" ) .unwrap(); + for variant in &builtin_variant_order { + writeln!(&mut out, " BuiltinFunction::{variant},").unwrap(); + } writeln!(&mut out, "];").unwrap(); writeln!(&mut out).unwrap(); @@ -803,118 +933,47 @@ fn render_builtin_catalog( writeln!(&mut out, " resolve_namespaced_builtin(name)").unwrap(); writeln!(&mut out, " }}").unwrap(); writeln!(&mut out).unwrap(); - writeln!(&mut out, " pub fn call_index(self) -> u16 {{").unwrap(); - writeln!(&mut out, " match self {{").unwrap(); - writeln!( - &mut out, - " BuiltinFunction::FormatTemplate => BUILTIN_CALL_BASE - 4," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::ToString => BUILTIN_CALL_BASE - 3," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::TypeOf => BUILTIN_CALL_BASE - 2," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::Assert => BUILTIN_CALL_BASE - 1," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::StringContains => BUILTIN_CALL_BASE - 7," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::StringReplaceLiteral => BUILTIN_CALL_BASE - 6," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::StringLowerAscii => BUILTIN_CALL_BASE - 5," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::StringSplitLiteral => BUILTIN_CALL_BASE - 8," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::MapIterInit => BUILTIN_CALL_BASE - 9," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::MapIterNext => BUILTIN_CALL_BASE - 10," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::MapIterTakeKey => BUILTIN_CALL_BASE - 11," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::MapIterTakeValue => BUILTIN_CALL_BASE - 12," - ) - .unwrap(); writeln!( &mut out, - " BuiltinFunction::MapIterClose => BUILTIN_CALL_BASE - 13," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::BindCallable => BUILTIN_CALL_BASE - 14," - ) - .unwrap(); - writeln!( - &mut out, - " BuiltinFunction::DetachLocal => BUILTIN_CALL_BASE - 15," - ) - .unwrap(); - writeln!( - &mut out, - " _ => BUILTIN_CALL_BASE + self as u16," + " pub fn from_source_name(name: &str) -> Option {{" ) .unwrap(); + writeln!(&mut out, " match name {{").unwrap(); + for entry in catalog { + writeln!( + &mut out, + " {:?} => Some(Self::{}),", + entry.source_name, entry.variant + ) + .unwrap(); + } + writeln!(&mut out, " _ => None,").unwrap(); writeln!(&mut out, " }}").unwrap(); writeln!(&mut out, " }}").unwrap(); writeln!(&mut out).unwrap(); + writeln!(&mut out, " pub fn call_index(self) -> u16 {{").unwrap(); writeln!( &mut out, - " pub(crate) fn from_call_index(index: u16) -> Option {{" + " // The enum is #[repr(u16)] with explicit static ID discriminants." ) .unwrap(); + writeln!(&mut out, " self as u16").unwrap(); + writeln!(&mut out, " }}").unwrap(); + writeln!(&mut out).unwrap(); writeln!( &mut out, - " if let Some((_, builtin)) = SPECIAL_CALL_BUILTINS.iter().find(|(call_index, _)| *call_index == index) {{" + " pub(crate) fn from_call_index(index: u16) -> Option {{" ) .unwrap(); - writeln!(&mut out, " return Some(*builtin);").unwrap(); + writeln!(&mut out, " match index {{").unwrap(); + for variant in &builtin_variant_order { + let id = catalog_id_by_variant + .get(variant) + .unwrap_or_else(|| panic!("missing static id for builtin variant '{variant}'")); + writeln!(&mut out, " 0x{id:04X} => Some(Self::{variant}),").unwrap(); + } + writeln!(&mut out, " _ => None,").unwrap(); writeln!(&mut out, " }}").unwrap(); - writeln!( - &mut out, - " let offset = index.checked_sub(BUILTIN_CALL_BASE)?;" - ) - .unwrap(); - writeln!( - &mut out, - " if offset >= BUILTIN_CALL_COUNT {{ return None; }}" - ) - .unwrap(); - writeln!( - &mut out, - " MAIN_RANGE_BUILTINS.get(offset as usize).copied()" - ) - .unwrap(); writeln!(&mut out, " }}").unwrap(); writeln!(&mut out, "}}").unwrap(); @@ -1309,7 +1368,7 @@ fn render_builtin_name_method( builtin_variant_order: &[String], actual_builtin_by_variant: &HashMap>, ) { - writeln!(out, " pub(crate) fn name(self) -> &'static str {{").unwrap(); + writeln!(out, " pub fn name(self) -> &'static str {{").unwrap(); writeln!(out, " match self {{").unwrap(); for variant in builtin_variant_order { let internal_name = builtin_internal_name(variant, actual_builtin_by_variant); @@ -1626,7 +1685,7 @@ fn namespace_root(name: &str) -> Option<&str> { name.split_once("::").map(|(root, _)| root) } -fn builtin_variant_name(name: &str) -> String { +pub(crate) fn builtin_variant_name(name: &str) -> String { match name { "type" => "TypeOf".to_string(), "__to_string" => "ToString".to_string(), @@ -1670,33 +1729,6 @@ fn variant_segment(segment: &str) -> String { } } -fn main_range_builtin_variants(builtin_variant_order: &[String]) -> Vec { - builtin_variant_order - .iter() - .filter(|variant| { - !matches!( - variant.as_str(), - "FormatTemplate" - | "ToString" - | "TypeOf" - | "Assert" - | "StringContains" - | "StringReplaceLiteral" - | "StringLowerAscii" - | "StringSplitLiteral" - | "MapIterInit" - | "MapIterNext" - | "MapIterTakeKey" - | "MapIterTakeValue" - | "MapIterClose" - | "BindCallable" - | "DetachLocal" - ) - }) - .cloned() - .collect() -} - fn namespace_member_target_variant(name: &str) -> String { builtin_variant_name(name) } diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index 6b518131..ed0cfd4b 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -1,15 +1,24 @@ # Script call frames and callable values -RustScript bytecode format version 9 introduces runtime script call frames and first-class callable values. +RustScript bytecode format version 11 (VMBC v11) introduces runtime script call frames, first-class callable values, and the static builtin ID catalog. ## Bytecode contract -- `call ` remains the direct host/builtin operation. +- `call ` remains the direct host/builtin operation; the `u16` operand is an explicit static builtin call index from the catalog (or a host-import slot) — never a count-derived offset. - `callvalue ` consumes a stack segment in `callee, arg0, ..., argN` order. - callable environments are bound through the internal builtin call path; callable creation adds no bytecode opcode. - `ret` completes the active script frame. A nested frame leaves exactly one result at the caller segment base, using `null` when the body produced no value. Root `ret` keeps the historical program-result stack behavior. -VMBC v9 is a hard format boundary. Decoders reject older versions. The stream includes script-function entry ranges, callable prototypes, function regions, and root callable bindings. PDRC v4 recordings and AOT artifacts use their corresponding bumped format/ABI versions and include callable metadata in cache identity. +VMBC v11 is a hard format boundary. Decoders reject all earlier versions (v10 and below) with a deterministic unsupported-version error; there is no compatibility decoder and no old-ID alias. The stream includes script-function entry ranges, callable prototypes, function regions, root callable bindings, and call indices drawn from the static builtin catalog. PDRC v6 recordings and AOT artifacts (format 7, ABI 6) use their corresponding bumped versions and include callable metadata in cache identity. + +## Static builtin IDs + +Every VM-visible builtin (ordinary, internal, and special-call) has one explicit, immutable `u16` call index assigned in `src/builtins/catalog.rs`. `build.rs` parses that catalog and generates the `BuiltinFunction` enum discriminants, `call_index`/`from_call_index`, the `builtin_call_index` reverse lookup, and the `BUILTIN_CATALOG` iteration from the explicit IDs; no ID is derived from catalog length or source order. + +- **Immutable explicit IDs.** IDs never change once assigned. Adding or reordering catalog entries never renumbers existing entries; new builtins take the next free ID in their documented block (extension `0x0000..=0xFF8F` for future builtins and host imports, special-call `0xFF90..=0xFFA1`, ordinary `0xFFA2..=0xFFFF`). The reserved sentinel gap `0xFF90..=0xFF92` stays unassigned. +- **Build-time validation.** The build fails on duplicate IDs, duplicate source names, duplicate Rust variants, out-of-block IDs, class/gate inconsistencies, a discovered runtime callable without an explicit ID, or a catalog entry without a runtime callable. +- **Shared std/no-std IDs.** `pd-vm-nostd` dispatches on the same static indices through the checked-in generated mirror `pd-vm-nostd/src/generated_builtin_ids.rs`; the workspace test `static_builtin_ids_are_frozen` fails when the mirror drifts from the catalog. +- **One-time format break.** The static ID migration bumped VMBC to v11 (and the internal bytecode ABI to 11). Older VMBC versions are rejected, never decoded. ## Runtime model @@ -36,8 +45,8 @@ PDRC recordings preserve full execution-frame metadata. Callable environments us ## Optimized backends -Whole-program AOT and Trace JIT use the same builtin call path for environment binding and native frame dispatch for `callvalue`. Script-frame entry and return preserve frame-relative locals and typed continuations. +Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding and native frame dispatch for `callvalue`. Script-frame entry and return preserve frame-relative locals and typed continuations. ## Embedded runtime -`pd-vm-nostd` decodes the same VMBC v9 callable metadata and executes callable binding, `callvalue`, recursive frames, captures, and direct host targets using `core` plus `alloc`. +`pd-vm-nostd` decodes the same VMBC v11 callable metadata and executes callable binding, `callvalue`, recursive frames, captures, and direct host targets using `core` plus `alloc`, dispatching on the identical static builtin IDs via its checked-in generated mirror. diff --git a/pd-vm-nostd/README.md b/pd-vm-nostd/README.md index 73554ffa..59a30900 100644 --- a/pd-vm-nostd/README.md +++ b/pd-vm-nostd/README.md @@ -6,7 +6,7 @@ compiler, parser, CLI, debugger, JIT/AOT backends, filesystem support, and opera ## Runtime surface -- VMBC v9 decoding with script-call and callable metadata +- VMBC v11 decoding with script-call and callable metadata - stack, local, and recursive script-frame execution for direct bytecode opcodes - instruction fuel with pause/resume support - synchronous named host bindings and dynamic host dispatch diff --git a/pd-vm-nostd/src/generated_builtin_ids.rs b/pd-vm-nostd/src/generated_builtin_ids.rs new file mode 100644 index 00000000..bc078a5a --- /dev/null +++ b/pd-vm-nostd/src/generated_builtin_ids.rs @@ -0,0 +1,222 @@ +// @generated mirror of src/builtins/catalog.rs (static builtin IDs). +// +// Every VM-visible builtin has one explicit u16 call index assigned in the +// authoritative catalog at the workspace root. This file mirrors those IDs so +// pd-vm-nostd dispatches on the same static indices without a build script. +// The workspace test `static_builtin_ids_are_frozen` fails when this file +// drifts from the catalog; do not edit by hand. + +#![allow(dead_code)] + +pub const DETACH_LOCAL_CALL_INDEX: u16 = 0xFF93; +pub const BIND_CALLABLE_CALL_INDEX: u16 = 0xFF94; +pub const MAP_ITER_CLOSE_CALL_INDEX: u16 = 0xFF95; +pub const MAP_ITER_TAKE_VALUE_CALL_INDEX: u16 = 0xFF96; +pub const MAP_ITER_TAKE_KEY_CALL_INDEX: u16 = 0xFF97; +pub const MAP_ITER_NEXT_CALL_INDEX: u16 = 0xFF98; +pub const MAP_ITER_INIT_CALL_INDEX: u16 = 0xFF99; +pub const STRING_SPLIT_LITERAL_CALL_INDEX: u16 = 0xFF9A; +pub const STRING_CONTAINS_CALL_INDEX: u16 = 0xFF9B; +pub const STRING_REPLACE_LITERAL_CALL_INDEX: u16 = 0xFF9C; +pub const STRING_LOWER_ASCII_CALL_INDEX: u16 = 0xFF9D; +pub const FORMAT_TEMPLATE_CALL_INDEX: u16 = 0xFF9E; +pub const TO_STRING_CALL_INDEX: u16 = 0xFF9F; +pub const TYPE_CALL_INDEX: u16 = 0xFFA0; +pub const ASSERT_CALL_INDEX: u16 = 0xFFA1; +pub const LEN_CALL_INDEX: u16 = 0xFFA2; +pub const SLICE_CALL_INDEX: u16 = 0xFFA3; +pub const CONCAT_CALL_INDEX: u16 = 0xFFA4; +pub const ARRAY_NEW_CALL_INDEX: u16 = 0xFFA5; +pub const ARRAY_PUSH_CALL_INDEX: u16 = 0xFFA6; +pub const MAP_NEW_CALL_INDEX: u16 = 0xFFA7; +pub const GET_CALL_INDEX: u16 = 0xFFA8; +pub const HAS_CALL_INDEX: u16 = 0xFFA9; +pub const SET_CALL_INDEX: u16 = 0xFFAA; +pub const KEYS_CALL_INDEX: u16 = 0xFFAB; +pub const BYTES_FROM_UTF8_CALL_INDEX: u16 = 0xFFAC; +pub const BYTES_TO_UTF8_CALL_INDEX: u16 = 0xFFAD; +pub const BYTES_TO_UTF8_LOSSY_CALL_INDEX: u16 = 0xFFAE; +pub const BYTES_FROM_HEX_CALL_INDEX: u16 = 0xFFAF; +pub const BYTES_TO_HEX_CALL_INDEX: u16 = 0xFFB0; +pub const BYTES_FROM_BASE64_CALL_INDEX: u16 = 0xFFB1; +pub const BYTES_TO_BASE64_CALL_INDEX: u16 = 0xFFB2; +pub const BYTES_FROM_ARRAY_U8_CALL_INDEX: u16 = 0xFFB3; +pub const BYTES_TO_ARRAY_U8_CALL_INDEX: u16 = 0xFFB4; +pub const IO_OPEN_CALL_INDEX: u16 = 0xFFB5; +pub const IO_POPEN_CALL_INDEX: u16 = 0xFFB6; +pub const IO_READ_ALL_CALL_INDEX: u16 = 0xFFB7; +pub const IO_READ_LINE_CALL_INDEX: u16 = 0xFFB8; +pub const IO_WRITE_CALL_INDEX: u16 = 0xFFB9; +pub const IO_FLUSH_CALL_INDEX: u16 = 0xFFBA; +pub const IO_CLOSE_CALL_INDEX: u16 = 0xFFBB; +pub const IO_EXISTS_CALL_INDEX: u16 = 0xFFBC; +pub const RE_MATCH_CALL_INDEX: u16 = 0xFFBD; +pub const RE_FIND_CALL_INDEX: u16 = 0xFFBE; +pub const RE_REPLACE_CALL_INDEX: u16 = 0xFFBF; +pub const RE_SPLIT_CALL_INDEX: u16 = 0xFFC0; +pub const RE_CAPTURES_CALL_INDEX: u16 = 0xFFC1; +pub const JSON_ENCODE_CALL_INDEX: u16 = 0xFFC2; +pub const JSON_DECODE_CALL_INDEX: u16 = 0xFFC4; +pub const JIT_SET_CONFIG_CALL_INDEX: u16 = 0xFFC5; +pub const JIT_GET_CONFIG_CALL_INDEX: u16 = 0xFFC6; +pub const JIT_SET_ENABLED_CALL_INDEX: u16 = 0xFFC7; +pub const JIT_GET_ENABLED_CALL_INDEX: u16 = 0xFFC8; +pub const JIT_SET_HOT_LOOP_THRESHOLD_CALL_INDEX: u16 = 0xFFC9; +pub const JIT_GET_HOT_LOOP_THRESHOLD_CALL_INDEX: u16 = 0xFFCA; +pub const JIT_SET_MAX_TRACE_LEN_CALL_INDEX: u16 = 0xFFCB; +pub const JIT_GET_MAX_TRACE_LEN_CALL_INDEX: u16 = 0xFFCC; +pub const MATH_PI_CALL_INDEX: u16 = 0xFFCD; +pub const MATH_TAU_CALL_INDEX: u16 = 0xFFCE; +pub const MATH_E_CALL_INDEX: u16 = 0xFFCF; +pub const MATH_EPSILON_CALL_INDEX: u16 = 0xFFD0; +pub const MATH_INF_CALL_INDEX: u16 = 0xFFD1; +pub const MATH_NEG_INF_CALL_INDEX: u16 = 0xFFD2; +pub const MATH_NAN_CALL_INDEX: u16 = 0xFFD3; +pub const MATH_ABS_CALL_INDEX: u16 = 0xFFD4; +pub const MATH_SQRT_CALL_INDEX: u16 = 0xFFD5; +pub const MATH_CBRT_CALL_INDEX: u16 = 0xFFD6; +pub const MATH_EXP_CALL_INDEX: u16 = 0xFFD7; +pub const MATH_EXP2_CALL_INDEX: u16 = 0xFFD8; +pub const MATH_LN_CALL_INDEX: u16 = 0xFFD9; +pub const MATH_LN_1P_CALL_INDEX: u16 = 0xFFDA; +pub const MATH_LOG2_CALL_INDEX: u16 = 0xFFDB; +pub const MATH_LOG10_CALL_INDEX: u16 = 0xFFDC; +pub const MATH_SIN_CALL_INDEX: u16 = 0xFFDD; +pub const MATH_COS_CALL_INDEX: u16 = 0xFFDE; +pub const MATH_TAN_CALL_INDEX: u16 = 0xFFDF; +pub const MATH_ASIN_CALL_INDEX: u16 = 0xFFE0; +pub const MATH_ACOS_CALL_INDEX: u16 = 0xFFE1; +pub const MATH_ATAN_CALL_INDEX: u16 = 0xFFE2; +pub const MATH_SINH_CALL_INDEX: u16 = 0xFFE3; +pub const MATH_COSH_CALL_INDEX: u16 = 0xFFE4; +pub const MATH_TANH_CALL_INDEX: u16 = 0xFFE5; +pub const MATH_FLOOR_CALL_INDEX: u16 = 0xFFE6; +pub const MATH_CEIL_CALL_INDEX: u16 = 0xFFE7; +pub const MATH_ROUND_CALL_INDEX: u16 = 0xFFE8; +pub const MATH_TRUNC_CALL_INDEX: u16 = 0xFFE9; +pub const MATH_FRACT_CALL_INDEX: u16 = 0xFFEA; +pub const MATH_SIGNUM_CALL_INDEX: u16 = 0xFFEB; +pub const MATH_TO_DEGREES_CALL_INDEX: u16 = 0xFFEC; +pub const MATH_TO_RADIANS_CALL_INDEX: u16 = 0xFFED; +pub const MATH_IS_NAN_CALL_INDEX: u16 = 0xFFEE; +pub const MATH_IS_INFINITE_CALL_INDEX: u16 = 0xFFEF; +pub const MATH_IS_FINITE_CALL_INDEX: u16 = 0xFFF0; +pub const MATH_ATAN2_CALL_INDEX: u16 = 0xFFF1; +pub const MATH_POWF_CALL_INDEX: u16 = 0xFFF2; +pub const MATH_POWI_CALL_INDEX: u16 = 0xFFF3; +pub const MATH_HYPOT_CALL_INDEX: u16 = 0xFFF4; +pub const MATH_LOG_CALL_INDEX: u16 = 0xFFF5; +pub const MATH_MIN_CALL_INDEX: u16 = 0xFFF6; +pub const MATH_MAX_CALL_INDEX: u16 = 0xFFF7; +pub const MATH_COPYSIGN_CALL_INDEX: u16 = 0xFFF8; +pub const MATH_CLAMP_CALL_INDEX: u16 = 0xFFF9; +pub const MATH_MUL_ADD_CALL_INDEX: u16 = 0xFFFA; +pub const COUNT_CALL_INDEX: u16 = 0xFFFB; + +/// Every static builtin call index, ascending. +pub const ALL_CALL_INDICES: &[u16] = &[ + DETACH_LOCAL_CALL_INDEX, + BIND_CALLABLE_CALL_INDEX, + MAP_ITER_CLOSE_CALL_INDEX, + MAP_ITER_TAKE_VALUE_CALL_INDEX, + MAP_ITER_TAKE_KEY_CALL_INDEX, + MAP_ITER_NEXT_CALL_INDEX, + MAP_ITER_INIT_CALL_INDEX, + STRING_SPLIT_LITERAL_CALL_INDEX, + STRING_CONTAINS_CALL_INDEX, + STRING_REPLACE_LITERAL_CALL_INDEX, + STRING_LOWER_ASCII_CALL_INDEX, + FORMAT_TEMPLATE_CALL_INDEX, + TO_STRING_CALL_INDEX, + TYPE_CALL_INDEX, + ASSERT_CALL_INDEX, + LEN_CALL_INDEX, + SLICE_CALL_INDEX, + CONCAT_CALL_INDEX, + ARRAY_NEW_CALL_INDEX, + ARRAY_PUSH_CALL_INDEX, + MAP_NEW_CALL_INDEX, + GET_CALL_INDEX, + HAS_CALL_INDEX, + SET_CALL_INDEX, + KEYS_CALL_INDEX, + BYTES_FROM_UTF8_CALL_INDEX, + BYTES_TO_UTF8_CALL_INDEX, + BYTES_TO_UTF8_LOSSY_CALL_INDEX, + BYTES_FROM_HEX_CALL_INDEX, + BYTES_TO_HEX_CALL_INDEX, + BYTES_FROM_BASE64_CALL_INDEX, + BYTES_TO_BASE64_CALL_INDEX, + BYTES_FROM_ARRAY_U8_CALL_INDEX, + BYTES_TO_ARRAY_U8_CALL_INDEX, + IO_OPEN_CALL_INDEX, + IO_POPEN_CALL_INDEX, + IO_READ_ALL_CALL_INDEX, + IO_READ_LINE_CALL_INDEX, + IO_WRITE_CALL_INDEX, + IO_FLUSH_CALL_INDEX, + IO_CLOSE_CALL_INDEX, + IO_EXISTS_CALL_INDEX, + RE_MATCH_CALL_INDEX, + RE_FIND_CALL_INDEX, + RE_REPLACE_CALL_INDEX, + RE_SPLIT_CALL_INDEX, + RE_CAPTURES_CALL_INDEX, + JSON_ENCODE_CALL_INDEX, + JSON_DECODE_CALL_INDEX, + JIT_SET_CONFIG_CALL_INDEX, + JIT_GET_CONFIG_CALL_INDEX, + JIT_SET_ENABLED_CALL_INDEX, + JIT_GET_ENABLED_CALL_INDEX, + JIT_SET_HOT_LOOP_THRESHOLD_CALL_INDEX, + JIT_GET_HOT_LOOP_THRESHOLD_CALL_INDEX, + JIT_SET_MAX_TRACE_LEN_CALL_INDEX, + JIT_GET_MAX_TRACE_LEN_CALL_INDEX, + MATH_PI_CALL_INDEX, + MATH_TAU_CALL_INDEX, + MATH_E_CALL_INDEX, + MATH_EPSILON_CALL_INDEX, + MATH_INF_CALL_INDEX, + MATH_NEG_INF_CALL_INDEX, + MATH_NAN_CALL_INDEX, + MATH_ABS_CALL_INDEX, + MATH_SQRT_CALL_INDEX, + MATH_CBRT_CALL_INDEX, + MATH_EXP_CALL_INDEX, + MATH_EXP2_CALL_INDEX, + MATH_LN_CALL_INDEX, + MATH_LN_1P_CALL_INDEX, + MATH_LOG2_CALL_INDEX, + MATH_LOG10_CALL_INDEX, + MATH_SIN_CALL_INDEX, + MATH_COS_CALL_INDEX, + MATH_TAN_CALL_INDEX, + MATH_ASIN_CALL_INDEX, + MATH_ACOS_CALL_INDEX, + MATH_ATAN_CALL_INDEX, + MATH_SINH_CALL_INDEX, + MATH_COSH_CALL_INDEX, + MATH_TANH_CALL_INDEX, + MATH_FLOOR_CALL_INDEX, + MATH_CEIL_CALL_INDEX, + MATH_ROUND_CALL_INDEX, + MATH_TRUNC_CALL_INDEX, + MATH_FRACT_CALL_INDEX, + MATH_SIGNUM_CALL_INDEX, + MATH_TO_DEGREES_CALL_INDEX, + MATH_TO_RADIANS_CALL_INDEX, + MATH_IS_NAN_CALL_INDEX, + MATH_IS_INFINITE_CALL_INDEX, + MATH_IS_FINITE_CALL_INDEX, + MATH_ATAN2_CALL_INDEX, + MATH_POWF_CALL_INDEX, + MATH_POWI_CALL_INDEX, + MATH_HYPOT_CALL_INDEX, + MATH_LOG_CALL_INDEX, + MATH_MIN_CALL_INDEX, + MATH_MAX_CALL_INDEX, + MATH_COPYSIGN_CALL_INDEX, + MATH_CLAMP_CALL_INDEX, + MATH_MUL_ADD_CALL_INDEX, + COUNT_CALL_INDEX, +]; diff --git a/pd-vm-nostd/src/lib.rs b/pd-vm-nostd/src/lib.rs index 6d86dafa..e827be13 100644 --- a/pd-vm-nostd/src/lib.rs +++ b/pd-vm-nostd/src/lib.rs @@ -8,6 +8,7 @@ extern crate alloc; mod error; +mod generated_builtin_ids; mod host; mod program; mod value; diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index ef0cca48..0f7f73a4 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -610,23 +610,55 @@ impl Vm { } fn call_core_builtin(&mut self, index: u16, arity: u8) -> Option> { - const BUILTIN_BASE: u16 = 0xFFA3; - const ARRAY_NEW: u16 = BUILTIN_BASE + 3; - const ARRAY_PUSH: u16 = BUILTIN_BASE + 4; - const MAP_NEW: u16 = BUILTIN_BASE + 5; - const SET: u16 = BUILTIN_BASE + 8; - const BIND_CALLABLE: u16 = BUILTIN_BASE - 14; - const DETACH_LOCAL: u16 = BUILTIN_BASE - 15; + use super::generated_builtin_ids::{ + ARRAY_NEW_CALL_INDEX, ARRAY_PUSH_CALL_INDEX, BIND_CALLABLE_CALL_INDEX, + CONCAT_CALL_INDEX, DETACH_LOCAL_CALL_INDEX, MAP_NEW_CALL_INDEX, SET_CALL_INDEX, + }; Some(match index { - ARRAY_NEW => { + CONCAT_CALL_INDEX => { + if let Err(error) = self.require_builtin_arity("concat", arity, 2) { + return Some(Err(error)); + } + let start = match self.stack.len().checked_sub(2) { + Some(start) => start, + None => return Some(Err(VmError::StackUnderflow)), + }; + let lhs = self.stack[start].clone(); + let rhs = self.stack[start + 1].clone(); + let result = match (lhs, rhs) { + (Value::String(lhs), Value::String(rhs)) => { + let mut value = String::with_capacity(lhs.len() + rhs.len()); + value.push_str(&lhs); + value.push_str(&rhs); + Value::string(value) + } + (Value::Bytes(lhs), Value::Bytes(rhs)) => { + let mut value = Vec::with_capacity(lhs.len() + rhs.len()); + value.extend_from_slice(&lhs); + value.extend_from_slice(&rhs); + Value::bytes(value) + } + (Value::Array(lhs), Value::Array(rhs)) => { + let mut value = Vec::with_capacity(lhs.len() + rhs.len()); + value.extend_from_slice(&lhs); + value.extend_from_slice(&rhs); + Value::array(value) + } + _ => return Some(Err(VmError::TypeMismatch("concat operands"))), + }; + self.stack.truncate(start); + self.stack.push(result); + Ok(()) + } + ARRAY_NEW_CALL_INDEX => { if let Err(error) = self.require_builtin_arity("array_new", arity, 0) { return Some(Err(error)); } self.stack.push(Value::array(Vec::new())); Ok(()) } - ARRAY_PUSH => { + ARRAY_PUSH_CALL_INDEX => { if let Err(error) = self.require_builtin_arity("array_push", arity, 2) { return Some(Err(error)); } @@ -643,14 +675,14 @@ impl Vm { self.stack.push(Value::Array(values)); Ok(()) } - MAP_NEW => { + MAP_NEW_CALL_INDEX => { if let Err(error) = self.require_builtin_arity("map_new", arity, 0) { return Some(Err(error)); } self.stack.push(Value::map(Vec::new())); Ok(()) } - SET => { + SET_CALL_INDEX => { if let Err(error) = self.require_builtin_arity("set", arity, 3) { return Some(Err(error)); } @@ -674,7 +706,7 @@ impl Vm { self.stack.push(Value::Map(entries)); Ok(()) } - DETACH_LOCAL => { + DETACH_LOCAL_CALL_INDEX => { if let Err(error) = self.require_builtin_arity("__detach_local", arity, 1) { return Some(Err(error)); } @@ -698,7 +730,7 @@ impl Vm { self.stack.truncate(start); Ok(()) } - BIND_CALLABLE => { + BIND_CALLABLE_CALL_INDEX => { if let Err(error) = self.require_builtin_arity("__bind_callable", arity, 2) { return Some(Err(error)); } diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 6de3153b..77d8b9b2 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -8,7 +8,7 @@ use super::{ }; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V10: u16 = 10; +const VERSION_V11: u16 = 11; const FLAGS: u16 = 0; const MAX_SCHEMA_DEPTH: usize = 64; const MAX_CONSTANT_DEPTH: usize = 64; @@ -57,7 +57,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V10 { + if version != VERSION_V11 { return Err(WireError::UnsupportedVersion(version)); } let flags = cursor.read_u16()?; diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index d61c6242..8bd2b5eb 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -29,9 +29,9 @@ fn encoded_scalar_program() -> Vec { } #[test] -fn embedded_decoder_reads_host_generated_v10() { +fn embedded_decoder_reads_host_generated_v11() { let bytes = encoded_scalar_program(); - let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v10"); + let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v11"); assert_eq!( program.code(), diff --git a/src/builtins/catalog.rs b/src/builtins/catalog.rs new file mode 100644 index 00000000..93bf4c2a --- /dev/null +++ b/src/builtins/catalog.rs @@ -0,0 +1,142 @@ +// Authoritative static builtin ID catalog. +// +// Every VM-visible builtin (ordinary, internal, and special-call) receives one +// explicit, immutable u16 call index assigned in this file. build.rs parses +// this file and generates the `BuiltinFunction` enum discriminants, +// `call_index`, `from_call_index`, and catalog iteration from these IDs. +// `pd-vm-nostd` consumes a checked-in generated mirror +// (pd-vm-nostd/src/generated_builtin_ids.rs), which the workspace test +// `static_builtin_ids_are_frozen` keeps in sync. +// +// # ID blocks (shared u16 call-index space) +// +// | Block | Range | Purpose | +// |---|---|---| +// | extension | 0x0000 ..= 0xFF8F | reserved for future builtins and host imports | +// | special-call | 0xFF90 ..= 0xFFA1 | special-call builtins (incl. internal lowering builtins) | +// | ordinary | 0xFFA2 ..= 0xFFFF | ordinary builtins (language + namespaced) | +// +// # Rules +// +// - IDs are immutable once assigned. Appending or reordering entries must not +// renumber existing entries. +// - build.rs fails the build on duplicate IDs, duplicate source names, +// duplicate Rust variants, out-of-block IDs, a discovered runtime callable +// without an explicit ID, or a catalog entry without a runtime callable. +// - Class is one of Ordinary | Internal | Special. Internal entries are the +// `__`-prefixed lowering builtins; Special entries are the remaining +// special-call builtins; both live in the special-call block. +// - The feature gate column names the cargo feature gating the runtime +// implementation, or `none`. +// +// Entry syntax (parsed textually by build.rs): +// +// builtin_id!(0xXXXX, "source_name", RustVariant, Class, feature_gate); +// +// source_name must equal the `#[pd_host_function(name = ...)]` value of the +// runtime callable, and RustVariant must equal the derived variant name. + +builtin_id!(0xFFA2, "len", Len, Ordinary, none); +builtin_id!(0xFFA3, "slice", Slice, Ordinary, none); +builtin_id!(0xFFA4, "concat", Concat, Ordinary, none); +builtin_id!(0xFFA5, "array_new", ArrayNew, Ordinary, none); +builtin_id!(0xFFA6, "array_push", ArrayPush, Ordinary, none); +builtin_id!(0xFFA7, "map_new", MapNew, Ordinary, none); +builtin_id!(0xFFA8, "get", Get, Ordinary, none); +builtin_id!(0xFFA9, "has", Has, Ordinary, none); +builtin_id!(0xFFAA, "set", Set, Ordinary, none); +builtin_id!(0xFFAB, "keys", Keys, Ordinary, none); +builtin_id!(0xFFAC, "bytes::from_utf8", BytesFromUtf8, Ordinary, none); +builtin_id!(0xFFAD, "bytes::to_utf8", BytesToUtf8, Ordinary, none); +builtin_id!(0xFFAE, "bytes::to_utf8_lossy", BytesToUtf8Lossy, Ordinary, none); +builtin_id!(0xFFAF, "bytes::from_hex", BytesFromHex, Ordinary, none); +builtin_id!(0xFFB0, "bytes::to_hex", BytesToHex, Ordinary, none); +builtin_id!(0xFFB1, "bytes::from_base64", BytesFromBase64, Ordinary, none); +builtin_id!(0xFFB2, "bytes::to_base64", BytesToBase64, Ordinary, none); +builtin_id!(0xFFB3, "bytes::from_array_u8", BytesFromArrayU8, Ordinary, none); +builtin_id!(0xFFB4, "bytes::to_array_u8", BytesToArrayU8, Ordinary, none); +builtin_id!(0xFFB5, "io::open", IoOpen, Ordinary, none); +builtin_id!(0xFFB6, "io::popen", IoPopen, Ordinary, none); +builtin_id!(0xFFB7, "io::read_all", IoReadAll, Ordinary, none); +builtin_id!(0xFFB8, "io::read_line", IoReadLine, Ordinary, none); +builtin_id!(0xFFB9, "io::write", IoWrite, Ordinary, none); +builtin_id!(0xFFBA, "io::flush", IoFlush, Ordinary, none); +builtin_id!(0xFFBB, "io::close", IoClose, Ordinary, none); +builtin_id!(0xFFBC, "io::exists", IoExists, Ordinary, none); +builtin_id!(0xFFBD, "re::match", ReMatch, Ordinary, none); +builtin_id!(0xFFBE, "re::find", ReFind, Ordinary, none); +builtin_id!(0xFFBF, "re::replace", ReReplace, Ordinary, none); +builtin_id!(0xFFC0, "re::split", ReSplit, Ordinary, none); +builtin_id!(0xFFC1, "re::captures", ReCaptures, Ordinary, none); +builtin_id!(0xFFC2, "json::encode", JsonEncode, Ordinary, none); +builtin_id!(0xFFC4, "json::decode", JsonDecode, Ordinary, none); +builtin_id!(0xFFC5, "jit::set_config", JitSetConfig, Ordinary, none); +builtin_id!(0xFFC6, "jit::get_config", JitGetConfig, Ordinary, none); +builtin_id!(0xFFC7, "jit::set_enabled", JitSetEnabled, Ordinary, none); +builtin_id!(0xFFC8, "jit::get_enabled", JitGetEnabled, Ordinary, none); +builtin_id!(0xFFC9, "jit::set_hot_loop_threshold", JitSetHotLoopThreshold, Ordinary, none); +builtin_id!(0xFFCA, "jit::get_hot_loop_threshold", JitGetHotLoopThreshold, Ordinary, none); +builtin_id!(0xFFCB, "jit::set_max_trace_len", JitSetMaxTraceLen, Ordinary, none); +builtin_id!(0xFFCC, "jit::get_max_trace_len", JitGetMaxTraceLen, Ordinary, none); +builtin_id!(0xFFCD, "math::pi", MathPi, Ordinary, none); +builtin_id!(0xFFCE, "math::tau", MathTau, Ordinary, none); +builtin_id!(0xFFCF, "math::e", MathE, Ordinary, none); +builtin_id!(0xFFD0, "math::epsilon", MathEpsilon, Ordinary, none); +builtin_id!(0xFFD1, "math::inf", MathInf, Ordinary, none); +builtin_id!(0xFFD2, "math::neg_inf", MathNegInf, Ordinary, none); +builtin_id!(0xFFD3, "math::nan", MathNaN, Ordinary, none); +builtin_id!(0xFFD4, "math::abs", MathAbs, Ordinary, none); +builtin_id!(0xFFD5, "math::sqrt", MathSqrt, Ordinary, none); +builtin_id!(0xFFD6, "math::cbrt", MathCbrt, Ordinary, none); +builtin_id!(0xFFD7, "math::exp", MathExp, Ordinary, none); +builtin_id!(0xFFD8, "math::exp2", MathExp2, Ordinary, none); +builtin_id!(0xFFD9, "math::ln", MathLn, Ordinary, none); +builtin_id!(0xFFDA, "math::ln_1p", MathLn1p, Ordinary, none); +builtin_id!(0xFFDB, "math::log2", MathLog2, Ordinary, none); +builtin_id!(0xFFDC, "math::log10", MathLog10, Ordinary, none); +builtin_id!(0xFFDD, "math::sin", MathSin, Ordinary, none); +builtin_id!(0xFFDE, "math::cos", MathCos, Ordinary, none); +builtin_id!(0xFFDF, "math::tan", MathTan, Ordinary, none); +builtin_id!(0xFFE0, "math::asin", MathAsin, Ordinary, none); +builtin_id!(0xFFE1, "math::acos", MathAcos, Ordinary, none); +builtin_id!(0xFFE2, "math::atan", MathAtan, Ordinary, none); +builtin_id!(0xFFE3, "math::sinh", MathSinh, Ordinary, none); +builtin_id!(0xFFE4, "math::cosh", MathCosh, Ordinary, none); +builtin_id!(0xFFE5, "math::tanh", MathTanh, Ordinary, none); +builtin_id!(0xFFE6, "math::floor", MathFloor, Ordinary, none); +builtin_id!(0xFFE7, "math::ceil", MathCeil, Ordinary, none); +builtin_id!(0xFFE8, "math::round", MathRound, Ordinary, none); +builtin_id!(0xFFE9, "math::trunc", MathTrunc, Ordinary, none); +builtin_id!(0xFFEA, "math::fract", MathFract, Ordinary, none); +builtin_id!(0xFFEB, "math::signum", MathSignum, Ordinary, none); +builtin_id!(0xFFEC, "math::to_degrees", MathToDegrees, Ordinary, none); +builtin_id!(0xFFED, "math::to_radians", MathToRadians, Ordinary, none); +builtin_id!(0xFFEE, "math::is_nan", MathIsNaN, Ordinary, none); +builtin_id!(0xFFEF, "math::is_infinite", MathIsInfinite, Ordinary, none); +builtin_id!(0xFFF0, "math::is_finite", MathIsFinite, Ordinary, none); +builtin_id!(0xFFF1, "math::atan2", MathAtan2, Ordinary, none); +builtin_id!(0xFFF2, "math::powf", MathPowF, Ordinary, none); +builtin_id!(0xFFF3, "math::powi", MathPowI, Ordinary, none); +builtin_id!(0xFFF4, "math::hypot", MathHypot, Ordinary, none); +builtin_id!(0xFFF5, "math::log", MathLog, Ordinary, none); +builtin_id!(0xFFF6, "math::min", MathMin, Ordinary, none); +builtin_id!(0xFFF7, "math::max", MathMax, Ordinary, none); +builtin_id!(0xFFF8, "math::copysign", MathCopySign, Ordinary, none); +builtin_id!(0xFFF9, "math::clamp", MathClamp, Ordinary, none); +builtin_id!(0xFFFA, "math::mul_add", MathMulAdd, Ordinary, none); +builtin_id!(0xFFFB, "count", Count, Ordinary, none); +builtin_id!(0xFF9E, "__format_template", FormatTemplate, Internal, none); +builtin_id!(0xFF9F, "__to_string", ToString, Internal, none); +builtin_id!(0xFFA0, "type", TypeOf, Special, none); +builtin_id!(0xFFA1, "assert", Assert, Special, none); +builtin_id!(0xFF9B, "string_contains", StringContains, Special, none); +builtin_id!(0xFF9C, "string_replace_literal", StringReplaceLiteral, Special, none); +builtin_id!(0xFF9D, "string_lower_ascii", StringLowerAscii, Special, none); +builtin_id!(0xFF9A, "string_split_literal", StringSplitLiteral, Special, none); +builtin_id!(0xFF99, "__map_iter_init", MapIterInit, Internal, none); +builtin_id!(0xFF98, "__map_iter_next", MapIterNext, Internal, none); +builtin_id!(0xFF97, "__map_iter_take_key", MapIterTakeKey, Internal, none); +builtin_id!(0xFF96, "__map_iter_take_value", MapIterTakeValue, Internal, none); +builtin_id!(0xFF95, "__map_iter_close", MapIterClose, Internal, none); +builtin_id!(0xFF94, "__bind_callable", BindCallable, Internal, none); +builtin_id!(0xFF93, "__detach_local", DetachLocal, Internal, none); diff --git a/src/builtins/runtime/core.rs b/src/builtins/runtime/core.rs index 6a47e996..799307c2 100644 --- a/src/builtins/runtime/core.rs +++ b/src/builtins/runtime/core.rs @@ -891,25 +891,24 @@ fn builtin_map_iter_close_metadata(map: VmMapRef<'_>, _slot: i64) -> VmMapHandle #[cfg(test)] mod tests { use super::*; - use crate::builtins::{BUILTIN_CALL_BASE, BUILTIN_CALL_COUNT, BuiltinFunction}; + use crate::builtins::BuiltinFunction; use std::sync::Arc; #[test] fn internal_builtins_have_unique_reserved_call_indices() { let reserved = [ - (BuiltinFunction::MapIterInit, BUILTIN_CALL_BASE - 9), - (BuiltinFunction::MapIterNext, BUILTIN_CALL_BASE - 10), - (BuiltinFunction::MapIterTakeKey, BUILTIN_CALL_BASE - 11), - (BuiltinFunction::MapIterTakeValue, BUILTIN_CALL_BASE - 12), - (BuiltinFunction::MapIterClose, BUILTIN_CALL_BASE - 13), - (BuiltinFunction::BindCallable, BUILTIN_CALL_BASE - 14), - (BuiltinFunction::DetachLocal, BUILTIN_CALL_BASE - 15), + (BuiltinFunction::MapIterInit, 0xFF99), + (BuiltinFunction::MapIterNext, 0xFF98), + (BuiltinFunction::MapIterTakeKey, 0xFF97), + (BuiltinFunction::MapIterTakeValue, 0xFF96), + (BuiltinFunction::MapIterClose, 0xFF95), + (BuiltinFunction::BindCallable, 0xFF94), + (BuiltinFunction::DetachLocal, 0xFF93), ]; for (builtin, index) in reserved { assert_eq!(builtin.call_index(), index); assert_eq!(BuiltinFunction::from_call_index(index), Some(builtin)); } - assert_eq!(BUILTIN_CALL_COUNT, 89); for name in ["__bind_callable", "__detach_local"] { assert!( !crate::builtins::language_builtin_specs() @@ -918,8 +917,8 @@ mod tests { "internal callable metadata operation must not be language-visible: {name}" ); } - for alias in BUILTIN_CALL_BASE + 89..=BUILTIN_CALL_BASE + 92 { - assert_eq!(BuiltinFunction::from_call_index(alias), None); + for reserved_index in 0xFF90..=0xFF92 { + assert_eq!(BuiltinFunction::from_call_index(reserved_index), None); } } diff --git a/src/bytecode.rs b/src/bytecode.rs index 84e993e7..23d12a71 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -5,7 +5,10 @@ use std::sync::{Arc, OnceLock}; use crate::compiler::TypeSchema; -pub const BYTECODE_ABI_VERSION: u16 = 10; +/// Bytecode ABI version used for VM-internal cache identity (JIT trace cache, +/// program cache keys). The VMBC wire format version lives in `src/vmbc.rs` +/// (`VERSION_V11`); both were bumped together for the static builtin ID break. +pub const BYTECODE_ABI_VERSION: u16 = 11; pub type SharedString = Arc; pub type SharedBytes = Arc>; diff --git a/src/debugger/recording.rs b/src/debugger/recording.rs index 516c6c21..bb8a8a90 100644 --- a/src/debugger/recording.rs +++ b/src/debugger/recording.rs @@ -102,7 +102,7 @@ impl VmRecording { pub fn encode(&self) -> Result, VmRecordingError> { const MAGIC: [u8; 4] = *b"PDRC"; - const VERSION: u16 = 5; + const VERSION: u16 = 6; let mut out = Vec::new(); out.extend_from_slice(&MAGIC); @@ -167,7 +167,7 @@ impl VmRecording { pub fn decode(bytes: &[u8]) -> Result { const MAGIC: [u8; 4] = *b"PDRC"; - const VERSION: u16 = 5; + const VERSION: u16 = 6; let mut cursor = RecordingCursor::new(bytes); @@ -594,16 +594,16 @@ mod tests { } #[test] - fn recording_v5_rejects_legacy_versions() { + fn recording_v6_rejects_legacy_versions() { let recording = VmRecording { program: Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]), frames: Vec::new(), terminal_status: Some(VmStatus::Halted), }; let bytes = recording.encode().expect("recording should encode"); - assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 5); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 6); - for legacy in [1u16, 2u16, 3u16, 4u16] { + for legacy in [1u16, 2u16, 3u16, 4u16, 5u16] { let mut legacy_bytes = bytes.clone(); legacy_bytes[4..6].copy_from_slice(&legacy.to_le_bytes()); assert!(matches!( diff --git a/src/lib.rs b/src/lib.rs index fbdfcff6..27267771 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,10 +24,11 @@ pub use assembler::{AsmParseError, Assembler, AssemblerError, BytecodeBuilder, a #[cfg(feature = "runtime")] pub use builtins::runtime::print::{PrintHostFunction, PrintlnHostFunction, format_value}; pub use builtins::{ - BuiltinFunction, BuiltinNamespaceMemberSpec, BuiltinNamespaceSpec, CallableDef, CallableParam, - CallableParamType, CallableSignature, LanguageBuiltinSpec, builtin_namespace_specs, - callable_signatures_for_builtin_namespace_member, default_host_callables, is_builtin_namespace, - language_builtin_specs, resolve_builtin_namespace_call, + BUILTIN_CATALOG, BuiltinFunction, BuiltinNamespaceMemberSpec, BuiltinNamespaceSpec, + CallableDef, CallableParam, CallableParamType, CallableSignature, LanguageBuiltinSpec, + builtin_namespace_specs, callable_signatures_for_builtin_namespace_member, + default_host_callables, is_builtin_namespace, language_builtin_specs, + resolve_builtin_namespace_call, }; pub use bytecode::{ CallableEnvironment, CallableKind, CallablePrototype, CallableTarget, CallableValue, @@ -37,20 +38,7 @@ pub use bytecode::{ pub fn builtin_call_index(name: &str) -> Option { use builtins::BuiltinFunction; - match name { - "len" => Some(BuiltinFunction::Len.call_index()), - "slice" => Some(BuiltinFunction::Slice.call_index()), - "concat" => Some(BuiltinFunction::Concat.call_index()), - "get" => Some(BuiltinFunction::Get.call_index()), - "has" => Some(BuiltinFunction::Has.call_index()), - "set" => Some(BuiltinFunction::Set.call_index()), - "keys" => Some(BuiltinFunction::Keys.call_index()), - "string_contains" => Some(BuiltinFunction::StringContains.call_index()), - "string_replace_literal" => Some(BuiltinFunction::StringReplaceLiteral.call_index()), - "string_lower_ascii" => Some(BuiltinFunction::StringLowerAscii.call_index()), - "string_split_literal" => Some(BuiltinFunction::StringSplitLiteral.call_index()), - _ => BuiltinFunction::from_namespaced_name(name).map(|builtin| builtin.call_index()), - } + BuiltinFunction::from_source_name(name).map(|builtin| builtin.call_index()) } pub use compiler::diagnostics::{render_compile_error, render_source_error}; pub use compiler::source_map::{LineSpanMapping, LoweredSource, SourceId, SourceMap, Span}; diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index fbed9d48..37e31f83 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -11,8 +11,8 @@ use super::super::jit::JitConfig; use super::compile::CompiledProgram; const MAGIC: [u8; 4] = *b"PAT\0"; -const VERSION: u16 = 6; -const ABI_VERSION: u16 = 5; +const VERSION: u16 = 7; +const ABI_VERSION: u16 = 6; const FLAG_INTERPRETER_BOUNDARY_ONLY: u16 = 1; const SUPPORTED_FLAGS: u16 = FLAG_INTERPRETER_BOUNDARY_ONLY; @@ -571,7 +571,7 @@ mod tests { } #[test] - fn aot_artifact_v6_roundtrips_callable_metadata_and_rejects_old_revisions() { + fn aot_artifact_v7_roundtrips_callable_metadata_and_rejects_old_revisions() { let compiled = crate::compile_source_for_repl("pub fn add_one(value: int) -> int { value + 1 }") .expect("callable program should compile"); @@ -580,20 +580,20 @@ mod tests { let encoded = vm .encode_aot_artifact() .expect("artifact encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 6); - assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 5); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 7); + assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 6); let mut old_format = encoded.clone(); - old_format[4..6].copy_from_slice(&5u16.to_le_bytes()); + old_format[4..6].copy_from_slice(&6u16.to_le_bytes()); assert!(matches!( Vm::new_from_aot_artifact_with_jit_config(&old_format, JitConfig::default()), - Err(AotArtifactError::UnsupportedVersion(5)) + Err(AotArtifactError::UnsupportedVersion(6)) )); let mut old_abi = encoded.clone(); - old_abi[6..8].copy_from_slice(&4u16.to_le_bytes()); + old_abi[6..8].copy_from_slice(&5u16.to_le_bytes()); assert!(matches!( Vm::new_from_aot_artifact_with_jit_config(&old_abi, JitConfig::default()), - Err(AotArtifactError::UnsupportedAbiVersion(4)) + Err(AotArtifactError::UnsupportedAbiVersion(5)) )); let mut standalone = diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index bbc415e7..86b536cb 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -54,7 +54,7 @@ pub(crate) use layout::{ #[cfg(feature = "cranelift-jit")] pub(crate) use offsets::{HeapIntrinsicAddrs, HeapIntrinsicRefs, ResolvedOffsets, resolve_offsets}; -pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 4; +pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 5; pub(crate) const MAX_INHERITED_ENTRY_VALUES: usize = 256; pub(crate) const INHERITED_STATE_ACTIVE_OFFSET: i32 = 0; pub(crate) const INHERITED_STATE_FRAME_KEY_OFFSET: i32 = 8; diff --git a/src/vmbc.rs b/src/vmbc.rs index 77c23eb0..1ac65b68 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -11,7 +11,7 @@ use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; use crate::vm::{HostImport, OpCode, Program, Value}; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V10: u16 = 10; +const VERSION_V11: u16 = 11; const FLAGS: u16 = 0; #[derive(Debug, Clone, PartialEq, Eq)] @@ -241,7 +241,7 @@ fn read_constant(cursor: &mut Cursor<'_>, depth: usize) -> Result Result, WireError> { let mut out = Vec::new(); out.extend_from_slice(&MAGIC); - out.extend_from_slice(&VERSION_V10.to_le_bytes()); + out.extend_from_slice(&VERSION_V11.to_le_bytes()); out.extend_from_slice(&FLAGS.to_le_bytes()); write_u32_count("constants", program.constants.len(), &mut out)?; @@ -275,7 +275,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V10 { + if version != VERSION_V11 { return Err(WireError::UnsupportedVersion(version)); } diff --git a/tests/wire/catalog_build_validation_tests.rs b/tests/wire/catalog_build_validation_tests.rs new file mode 100644 index 00000000..c56f9185 --- /dev/null +++ b/tests/wire/catalog_build_validation_tests.rs @@ -0,0 +1,228 @@ +//! Build-time catalog validation tests. +//! +//! `build.rs` is the generator that fails the build when the catalog violates +//! the static builtin ID contract. This suite includes the real build script +//! as a module (the same pattern as `tests/host_binding_generation_tests.rs`) +//! and exercises its catalog parser and contract validator directly: +//! duplicate IDs/names/variants, malformed entries, unsupported feature +//! gates, unknown classes, out-of-block IDs, class/name inconsistencies, and +//! missing/extra runtime callables all must fail validation. + +#[allow(dead_code)] +#[path = "../../build.rs"] +mod build_script; + +use std::collections::HashSet; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use build_script::{ + CatalogClass, CatalogEntry, ORDINARY_BLOCK_START, SPECIAL_CALL_BLOCK_END, + SPECIAL_CALL_BLOCK_START, builtin_variant_name, parse_catalog_source, + validate_catalog_contract, +}; + +fn assert_panics(f: F) +where + F: FnOnce() -> R, +{ + let result = catch_unwind(AssertUnwindSafe(f)); + assert!(result.is_err(), "expected a build.rs validation panic"); +} + +fn catalog_line(id: u16, name: &str, variant: &str, class: &str, gate: &str) -> String { + format!("builtin_id!(0x{id:04X}, {name:?}, {variant}, {class}, {gate});") +} + +fn entry(id: u16, source_name: &str, class: CatalogClass) -> CatalogEntry { + CatalogEntry { + id, + source_name: source_name.to_string(), + variant: builtin_variant_name(source_name), + class, + feature_gate: "none".to_string(), + } +} + +fn names(values: &[&str]) -> HashSet { + values.iter().map(|value| (*value).to_string()).collect() +} + +#[test] +fn parse_catalog_source_accepts_the_checked_in_catalog() { + let source = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/catalog.rs" + )) + .expect("read authoritative catalog"); + let entries = parse_catalog_source(&source, "catalog.rs"); + assert!(!entries.is_empty()); + assert_eq!(entries.len(), vm::BUILTIN_CATALOG.len()); +} + +#[test] +fn parse_catalog_source_rejects_duplicate_ids() { + let source = format!( + "{}\n{}", + catalog_line(0xFFA2, "len", "Len", "Ordinary", "none"), + catalog_line(0xFFA2, "get", "Get", "Ordinary", "none"), + ); + assert_panics(|| parse_catalog_source(&source, "test")); +} + +#[test] +fn parse_catalog_source_rejects_duplicate_source_names() { + let source = format!( + "{}\n{}", + catalog_line(0xFFA2, "len", "Len", "Ordinary", "none"), + catalog_line(0xFFA3, "len", "LenAlias", "Ordinary", "none"), + ); + assert_panics(|| parse_catalog_source(&source, "test")); +} + +#[test] +fn parse_catalog_source_rejects_duplicate_variants() { + let source = format!( + "{}\n{}", + catalog_line(0xFFA2, "len", "Len", "Ordinary", "none"), + catalog_line(0xFFA3, "len_alias", "Len", "Ordinary", "none"), + ); + assert_panics(|| parse_catalog_source(&source, "test")); +} + +#[test] +fn parse_catalog_source_rejects_unsupported_feature_gates() { + let source = catalog_line(0xFFA2, "len", "Len", "Ordinary", "sqlite"); + assert_panics(|| parse_catalog_source(&source, "test")); +} + +#[test] +fn parse_catalog_source_rejects_unknown_classes() { + let source = catalog_line(0xFFA2, "len", "Len", "Host", "none"); + assert_panics(|| parse_catalog_source(&source, "test")); +} + +#[test] +fn parse_catalog_source_rejects_malformed_entries() { + // Wrong field count. + assert_panics(|| parse_catalog_source("builtin_id!(0xFFA2, \"len\", Len, Ordinary);", "test")); + // Non-hex id. + assert_panics(|| { + parse_catalog_source("builtin_id!(0xZZZZ, \"len\", Len, Ordinary, none);", "test") + }); + // Unquoted source name. + assert_panics(|| { + parse_catalog_source("builtin_id!(0xFFA2, len, Len, Ordinary, none);", "test") + }); + // Missing terminator. + assert_panics(|| { + parse_catalog_source("builtin_id!(0xFFA2, \"len\", Len, Ordinary, none)", "test") + }); + // A line that is neither a comment nor an entry. + assert_panics(|| parse_catalog_source("fn main() {}", "test")); +} + +#[test] +fn validate_catalog_contract_accepts_a_valid_catalog() { + let entries = vec![ + entry(0xFFA2, "len", CatalogClass::Ordinary), + entry(0xFF93, "__detach_local", CatalogClass::Internal), + entry(0xFFA0, "type", CatalogClass::Special), + ]; + let discovered = names(&["len", "__detach_local", "type"]); + let special = names(&["DetachLocal", "TypeOf"]); + validate_catalog_contract(&entries, &discovered, &special); +} + +#[test] +fn validate_catalog_contract_rejects_out_of_block_ordinary_ids() { + for bad_id in [ + SPECIAL_CALL_BLOCK_START - 1, // extension block + SPECIAL_CALL_BLOCK_END, // special-call block + ] { + let entries = vec![entry(bad_id, "len", CatalogClass::Ordinary)]; + let discovered = names(&["len"]); + let special = HashSet::new(); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); + } +} + +#[test] +fn validate_catalog_contract_rejects_out_of_block_special_ids() { + let entries = vec![entry(ORDINARY_BLOCK_START, "type", CatalogClass::Special)]; + let discovered = names(&["type"]); + let special = names(&["TypeOf"]); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); +} + +#[test] +fn validate_catalog_contract_rejects_internal_class_without_internal_name() { + let entries = vec![entry( + SPECIAL_CALL_BLOCK_START, + "type", + CatalogClass::Internal, + )]; + let discovered = names(&["type"]); + let special = names(&["TypeOf"]); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); +} + +#[test] +fn validate_catalog_contract_rejects_special_class_with_internal_name() { + let entries = vec![entry( + SPECIAL_CALL_BLOCK_START, + "__detach_local", + CatalogClass::Special, + )]; + let discovered = names(&["__detach_local"]); + let special = names(&["DetachLocal"]); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); +} + +#[test] +fn validate_catalog_contract_rejects_variant_mismatches() { + let mut entries = vec![entry(0xFFA2, "len", CatalogClass::Ordinary)]; + entries[0].variant = "WrongVariant".to_string(); + let discovered = names(&["len"]); + let special = HashSet::new(); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); +} + +#[test] +fn validate_catalog_contract_rejects_missing_callables() { + // A runtime callable without an explicit catalog ID. + let entries = vec![entry(0xFFA2, "len", CatalogClass::Ordinary)]; + let discovered = names(&["len", "unlisted_builtin"]); + let special = HashSet::new(); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); +} + +#[test] +fn validate_catalog_contract_rejects_entries_without_callables() { + // A catalog entry with no runtime callable (typo). + let entries = vec![entry(0xFFA2, "len", CatalogClass::Ordinary)]; + let discovered = names(&[]); + let special = HashSet::new(); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); +} + +#[test] +fn validate_catalog_contract_rejects_ordinary_class_for_special_dispatch() { + let entries = vec![entry(0xFFA2, "len", CatalogClass::Ordinary)]; + let discovered = names(&["len"]); + let special = names(&["Len"]); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); +} + +#[test] +fn validate_catalog_contract_rejects_special_class_for_ordinary_dispatch() { + let entries = vec![entry( + SPECIAL_CALL_BLOCK_START, + "type", + CatalogClass::Special, + )]; + let discovered = names(&["type"]); + // "type" is a language builtin, so it dispatches through the special path; + // force the opposite (not special) to prove the class/dispatch check. + let special = names(&["Unrelated"]); + assert_panics(|| validate_catalog_contract(&entries, &discovered, &special)); +} diff --git a/tests/wire/catalog_contract_tests.rs b/tests/wire/catalog_contract_tests.rs new file mode 100644 index 00000000..f35d3911 --- /dev/null +++ b/tests/wire/catalog_contract_tests.rs @@ -0,0 +1,372 @@ +//! Static builtin ID catalog contract tests. +//! +//! These tests re-parse the authoritative catalog +//! (`src/builtins/catalog.rs`) and the checked-in no-std mirror +//! (`pd-vm-nostd/src/generated_builtin_ids.rs`) as plain text and pin the +//! contract that `build.rs` enforces at build time: +//! +//! - every VM-visible builtin has exactly one explicit `u16` ID; +//! - IDs, source names, and Rust variants are unique across the catalog; +//! - IDs live in their documented blocks, reserved sentinel ranges stay +//! empty, and no ID overlaps the bytecode opcode space; +//! - the generated std enum and the public reverse lookup agree with the +//! catalog one-to-one; +//! - the checked-in no-std mirror is in sync with the std catalog (frozen); +//! - appending or reordering catalog entries cannot renumber existing IDs +//! (IDs are explicit and immutable once assigned). +#![cfg(feature = "runtime")] + +use std::collections::HashMap; + +use vm::{BUILTIN_CATALOG, builtin_call_index}; + +/// Documented call-index blocks; must match `src/builtins/catalog.rs`. +/// Extension block: 0x0000..=0xFF8F (reserved for future builtins and host +/// imports). +const EXTENSION_BLOCK_END: u16 = 0xFF8F; +const SPECIAL_CALL_BLOCK_START: u16 = 0xFF90; +const SPECIAL_CALL_BLOCK_END: u16 = 0xFFA1; +const ORDINARY_BLOCK_START: u16 = 0xFFA2; + +/// Reserved sentinel gap inside the special-call block (see the catalog docs +/// and `core.rs::internal_builtins_have_unique_reserved_call_indices`). +const RESERVED_GAP_START: u16 = 0xFF90; +const RESERVED_GAP_END: u16 = 0xFF92; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CatalogEntry { + id: u16, + source_name: String, + variant: String, + class: String, + feature_gate: String, +} + +fn catalog_source() -> String { + std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/catalog.rs" + )) + .expect("read authoritative catalog") +} + +fn parse_catalog(source: &str) -> Vec { + let mut entries = Vec::new(); + for (line_index, raw_line) in source.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with("//") { + continue; + } + let line_number = line_index + 1; + let rest = line + .strip_prefix("builtin_id!(") + .unwrap_or_else(|| panic!("{line_number}: unexpected catalog line: {line:?}")); + let rest = rest + .strip_suffix(");") + .unwrap_or_else(|| panic!("{line_number}: catalog entry must end with ');': {line:?}")); + let parts: Vec<&str> = rest.split(',').map(str::trim).collect(); + assert_eq!( + parts.len(), + 5, + "{line_number}: catalog entry needs 5 fields" + ); + let id = u16::from_str_radix(parts[0].trim_start_matches("0x"), 16) + .unwrap_or_else(|err| panic!("{line_number}: invalid id: {err}")); + entries.push(CatalogEntry { + id, + source_name: parts[1].trim_matches('"').to_string(), + variant: parts[2].to_string(), + class: parts[3].to_string(), + feature_gate: parts[4].to_string(), + }); + } + entries +} + +fn assert_unique(values: &[String], what: &str) { + let mut seen = std::collections::HashSet::new(); + for value in values { + assert!(seen.insert(value.clone()), "duplicate {what}: {value}"); + } +} + +/// The frozen static ID map: the checked-in catalog, the generated std enum, +/// the public reverse lookup, and the no-std mirror all agree. +#[test] +fn static_builtin_ids_are_frozen() { + let entries = parse_catalog(&catalog_source()); + assert!(!entries.is_empty(), "catalog must not be empty"); + + // Full-catalog uniqueness: IDs, source names, and Rust variants. + assert_unique( + &entries + .iter() + .map(|entry| entry.id.to_string()) + .collect::>(), + "builtin id", + ); + assert_unique( + &entries + .iter() + .map(|entry| entry.source_name.clone()) + .collect::>(), + "builtin source name", + ); + assert_unique( + &entries + .iter() + .map(|entry| entry.variant.clone()) + .collect::>(), + "builtin variant", + ); + + // Block membership, class rules, and feature gates. + for entry in &entries { + assert_eq!( + entry.feature_gate, "none", + "feature gate for '{}' must be 'none' (no gated builtins exist yet)", + entry.source_name + ); + match entry.class.as_str() { + "Ordinary" => { + assert!( + (ORDINARY_BLOCK_START..=u16::MAX).contains(&entry.id), + "ordinary builtin '{}' id 0x{:04X} outside the ordinary block", + entry.source_name, + entry.id + ); + assert!( + !entry.source_name.starts_with("__"), + "ordinary builtin '{}' must not use the internal '__' prefix", + entry.source_name + ); + } + "Internal" | "Special" => { + assert!( + (SPECIAL_CALL_BLOCK_START..=SPECIAL_CALL_BLOCK_END).contains(&entry.id), + "special-call builtin '{}' id 0x{:04X} outside the special-call block", + entry.source_name, + entry.id + ); + } + other => panic!("unknown catalog class {other:?}"), + } + let internal_named = entry.source_name.starts_with("__"); + assert_eq!( + entry.class == "Internal", + internal_named, + "class Internal must match the '__' source-name prefix for '{}'", + entry.source_name + ); + } + + // Reserved sentinel ranges stay empty: the extension block (future + // builtins and host imports) and the 0xFF90..=0xFF92 gap. + for entry in &entries { + assert!( + entry.id > EXTENSION_BLOCK_END, + "id 0x{:04X} of '{}' must not fall in the reserved extension block", + entry.id, + entry.source_name + ); + assert!( + !(RESERVED_GAP_START..=RESERVED_GAP_END).contains(&entry.id), + "id 0x{:04X} of '{}' falls in the reserved sentinel gap", + entry.id, + entry.source_name + ); + } + + // No static builtin ID may overlap the bytecode opcode space (u8). + assert!( + entries.iter().all(|entry| entry.id > u8::MAX as u16), + "static builtin IDs must not overlap opcodes" + ); + + // Generated enum parity: BUILTIN_CATALOG == catalog one-to-one by ID. + // `name()` is the internal/runtime name: namespace separators become `_`, + // and the language `type` builtin is exposed as `type_of` (mirrors + // `builtin_internal_name` in build.rs). + let by_id: HashMap = + entries.iter().map(|entry| (entry.id, entry)).collect(); + assert_eq!(BUILTIN_CATALOG.len(), entries.len()); + for builtin in BUILTIN_CATALOG { + let entry = by_id.get(&builtin.call_index()).unwrap_or_else(|| { + panic!( + "generated id 0x{:04X} is missing from the catalog", + builtin.call_index() + ) + }); + let expected_name = match entry.source_name.as_str() { + "type" => "type_of".to_string(), + other => other.replace("::", "_"), + }; + assert_eq!(builtin.name(), expected_name); + } + + // The public reverse lookup resolves every catalog source name to its + // explicit static ID. + for entry in &entries { + assert_eq!( + builtin_call_index(&entry.source_name), + Some(entry.id), + "reverse lookup for '{}'", + entry.source_name + ); + } +} + +/// The checked-in no-std mirror (`pd-vm-nostd/src/generated_builtin_ids.rs`) +/// dispatches on exactly the same static IDs as the std catalog. This is the +/// sync guard referenced by both files; drift fails here. +#[test] +fn checked_in_nostd_mirror_matches_std_catalog() { + let entries = parse_catalog(&catalog_source()); + let mirror = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/pd-vm-nostd/src/generated_builtin_ids.rs" + )) + .expect("read no-std mirror"); + + // Parse `pub const _CALL_INDEX: u16 = 0xXXXX;` declarations. + let mut mirror_ids = Vec::new(); + let mut mirror_by_const = HashMap::new(); + for raw_line in mirror.lines() { + let line = raw_line.trim(); + let Some(rest) = line.strip_prefix("pub const ") else { + continue; + }; + let Some((const_name, value)) = rest.split_once(": u16 = ") else { + continue; + }; + let Some(hex) = value.strip_suffix(';') else { + continue; + }; + let id = u16::from_str_radix(hex.trim().trim_start_matches("0x"), 16) + .unwrap_or_else(|err| panic!("mirror const {const_name} has invalid id: {err}")); + mirror_ids.push(id); + mirror_by_const.insert(const_name.to_string(), id); + } + assert!( + !mirror_ids.is_empty(), + "no generated consts found in the no-std mirror" + ); + assert_eq!( + mirror_ids.len(), + entries.len(), + "mirror/catalog entry count mismatch" + ); + + // Mirror const names are the SCREAMING_SNAKE form of the catalog source + // names with a `_CALL_INDEX` suffix; every catalog entry must have one + // mirror const with the identical ID. + for entry in &entries { + let expected_const = mirror_const_name(&entry.source_name); + let mirror_id = mirror_by_const.get(&expected_const).unwrap_or_else(|| { + panic!( + "no mirror const {expected_const} for '{}'", + entry.source_name + ) + }); + assert_eq!( + mirror_id, &entry.id, + "mirror const {expected_const} disagrees with the catalog id of '{}'", + entry.source_name + ); + } + + // The mirror's ALL_CALL_INDICES array is the same ascending, unique ID set. + let array_start = mirror + .find("pub const ALL_CALL_INDICES") + .expect("mirror must export ALL_CALL_INDICES"); + let array_ids: Vec = mirror[array_start..] + .lines() + .filter_map(|raw_line| { + let name = raw_line.trim().strip_suffix(',')?.trim(); + name.ends_with("_CALL_INDEX") + .then(|| mirror_by_const.get(name).copied()) + .flatten() + }) + .collect(); + assert_eq!( + array_ids.len(), + entries.len(), + "ALL_CALL_INDICES length mismatch" + ); + assert!( + array_ids.windows(2).all(|pair| pair[0] < pair[1]), + "ALL_CALL_INDICES must be strictly ascending" + ); + let mut catalog_ids: Vec = entries.iter().map(|entry| entry.id).collect(); + catalog_ids.sort_unstable(); + assert_eq!(array_ids, catalog_ids); +} + +/// Derive the no-std mirror const name from the catalog source name: +/// uppercase the `::`/`_` segments and append `_CALL_INDEX`. +fn mirror_const_name(source_name: &str) -> String { + let mut parts = Vec::new(); + for segment in source_name.split([':', '_']) { + if !segment.is_empty() { + parts.push(segment.to_ascii_uppercase()); + } + } + format!("{}_CALL_INDEX", parts.join("_")) +} + +/// IDs are explicit and immutable once assigned: reordering the declarations +/// or appending a new entry must not renumber any existing entry. +#[test] +fn appending_or_reordering_catalog_entries_does_not_renumber_existing_ids() { + let source = catalog_source(); + let entries = parse_catalog(&source); + let name_to_id: HashMap<&str, u16> = entries + .iter() + .map(|entry| (entry.source_name.as_str(), entry.id)) + .collect(); + + // Reordering declarations must not renumber: IDs come from the file. + let mut lines: Vec<&str> = source + .lines() + .map(str::trim) + .filter(|line| line.starts_with("builtin_id!(")) + .collect(); + lines.reverse(); + let reordered = parse_catalog(&lines.join("\n")); + assert_eq!(reordered.len(), entries.len()); + for entry in &reordered { + assert_eq!( + name_to_id.get(entry.source_name.as_str()), + Some(&entry.id), + "reordering renumbered '{}'", + entry.source_name + ); + } + + // Appending a new entry at the next free ordinary ID (append-only + // allocation) must not renumber any existing entry. + let mut used: Vec = entries.iter().map(|entry| entry.id).collect(); + used.sort_unstable(); + let next_free = (ORDINARY_BLOCK_START..=u16::MAX) + .find(|candidate| used.binary_search(candidate).is_err()) + .expect("ordinary block is exhausted"); + let appended = format!( + "{source}\nbuiltin_id!(0x{next_free:04X}, \"synthetic_contract_probe\", \ + SyntheticContractProbe, Ordinary, none);\n" + ); + let reparsed = parse_catalog(&appended); + assert_eq!(reparsed.len(), entries.len() + 1); + for entry in &reparsed { + if entry.source_name == "synthetic_contract_probe" { + assert_eq!(entry.id, next_free); + assert_eq!(entry.class, "Ordinary"); + } else { + assert_eq!( + name_to_id.get(entry.source_name.as_str()), + Some(&entry.id), + "append renumbered '{}'", + entry.source_name + ); + } + } +} diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index ddc3ecfc..9cd2a0f7 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -55,7 +55,7 @@ fn wire_roundtrip_preserves_constants_and_code() { }); let encoded = encode_program(&program).expect("encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 10); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 11); let decoded = decode_program(&encoded).expect("decode should succeed"); assert_eq!(decoded.constants, program.constants); @@ -110,6 +110,13 @@ fn decode_rejects_invalid_magic_version_and_truncation() { Err(WireError::UnsupportedVersion(9)) )); + let mut previous_version = encoded.clone(); + previous_version[4..6].copy_from_slice(&10u16.to_le_bytes()); + assert!(matches!( + decode_program(&previous_version), + Err(WireError::UnsupportedVersion(10)) + )); + let truncated = &encoded[..encoded.len() - 1]; assert!(matches!( decode_program(truncated), @@ -165,7 +172,7 @@ fn validate_accepts_known_good_program() { } #[test] -fn callable_metadata_roundtrips_vmbc_v10() { +fn callable_metadata_roundtrips_vmbc_v11() { let compiled = vm::compile_source_for_repl( r#" fn add_one(value: int) -> int { value + 1 } @@ -453,10 +460,10 @@ fn assembler_deduplicates_equal_scalar_constants() { #[test] fn literal_string_builtin_indices_are_appended_and_publicly_resolved() { assert_eq!(BuiltinFunction::Count.call_index(), 65_531); - assert_eq!(BuiltinFunction::FormatTemplate.call_index(), 65_439); - assert_eq!(BuiltinFunction::ToString.call_index(), 65_440); - assert_eq!(BuiltinFunction::TypeOf.call_index(), 65_441); - assert_eq!(BuiltinFunction::Assert.call_index(), 65_442); + assert_eq!(BuiltinFunction::FormatTemplate.call_index(), 65_438); + assert_eq!(BuiltinFunction::ToString.call_index(), 65_439); + assert_eq!(BuiltinFunction::TypeOf.call_index(), 65_440); + assert_eq!(BuiltinFunction::Assert.call_index(), 65_441); let first = BuiltinFunction::FormatTemplate.call_index() - 3; assert_eq!(builtin_call_index("string_contains"), Some(first)); diff --git a/tests/wire_tests.rs b/tests/wire_tests.rs index f292226f..2b89ae9b 100644 --- a/tests/wire_tests.rs +++ b/tests/wire_tests.rs @@ -3,5 +3,11 @@ #[path = "wire/assembler_vmbc_edge_tests.rs"] mod assembler_vmbc_edge_tests; +#[path = "wire/catalog_build_validation_tests.rs"] +mod catalog_build_validation_tests; + +#[path = "wire/catalog_contract_tests.rs"] +mod catalog_contract_tests; + #[path = "wire/wire_tests.rs"] mod wire_tests;