diff --git a/CLAUDE.md b/CLAUDE.md index c2e8ed6de3..cf1343b470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1274 +**Current Version:** 0.5.1275 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 572ab0c6df..f5a16168bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5503,7 +5503,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "base64", @@ -5563,14 +5563,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "cc", "libc", @@ -5578,7 +5578,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "log", @@ -5592,7 +5592,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "perry-hir", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "perry-hir", @@ -5608,7 +5608,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "perry-dispatch", @@ -5617,7 +5617,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "perry-hir", @@ -5625,7 +5625,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "base64", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "perry-hir", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "async-trait", @@ -5674,14 +5674,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "serde", "serde_json", @@ -5689,7 +5689,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1274" +version = "0.5.1275" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5700,7 +5700,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "clap", @@ -5715,7 +5715,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "block2", "objc2", @@ -5725,7 +5725,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "argon2", "perry-ffi", @@ -5733,7 +5733,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "reqwest", @@ -5742,7 +5742,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "bcrypt", "perry-ffi", @@ -5750,7 +5750,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "rusqlite", @@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "scraper", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "perry-runtime", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "chrono", "cron", @@ -5784,7 +5784,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "chrono", "perry-ffi", @@ -5792,7 +5792,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "rust_decimal", @@ -5800,7 +5800,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "serde_json", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "perry-runtime", @@ -5824,14 +5824,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "bytes", "http-body-util", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "bytes", "lazy_static", @@ -5862,7 +5862,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "bytes", "h2", @@ -5886,7 +5886,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "lazy_static", "perry-ffi", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "base64", "jsonwebtoken", @@ -5907,7 +5907,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "lru", "perry-ffi", @@ -5916,7 +5916,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "chrono", "perry-ffi", @@ -5924,7 +5924,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "bson", "futures-util", @@ -5936,7 +5936,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "chrono", "perry-ffi", @@ -5946,7 +5946,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "nanoid", "perry-ffi", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "bytes", "perry-ffi", @@ -5968,7 +5968,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -5987,7 +5987,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "lettre", "perry-ffi", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "printpdf", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "sqlx", @@ -6014,7 +6014,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "governor", "perry-ffi", @@ -6022,7 +6022,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "fast_image_resize", "image", @@ -6032,14 +6032,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "lazy_static", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "perry-runtime", @@ -6057,7 +6057,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "uuid", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ffi", "regex", @@ -6075,7 +6075,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "futures-util", "lazy_static", @@ -6088,7 +6088,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "brotli", "flate2", @@ -6098,7 +6098,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "dashmap", "once_cell", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "perry-api-manifest", @@ -6125,7 +6125,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "perry-diagnostics", @@ -6137,7 +6137,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "base64", @@ -6178,14 +6178,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6280,14 +6280,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "perry-hir", @@ -6296,14 +6296,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "base64", "itoa", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "rand 0.10.1", "serde", @@ -6330,7 +6330,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "base64", "block2", @@ -6369,7 +6369,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "base64", "block2", @@ -6384,7 +6384,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1274" +version = "0.5.1275" [[package]] name = "perry-ui-test" @@ -6395,11 +6395,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1274" +version = "0.5.1275" [[package]] name = "perry-ui-tvos" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "base64", "block2", @@ -6415,7 +6415,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "base64", "block2", @@ -6431,7 +6431,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "block2", "libc", @@ -6444,7 +6444,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "base64", "libc", @@ -6461,14 +6461,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "anyhow", "base64", @@ -6484,7 +6484,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1274" +version = "0.5.1275" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 6f16db8b4d..4c0dd894e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -292,7 +292,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1274" +version = "0.5.1275" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7137-zero-config-binding-faithfulness.md b/changelog.d/7137-zero-config-binding-faithfulness.md new file mode 100644 index 0000000000..a05237abc4 --- /dev/null +++ b/changelog.d/7137-zero-config-binding-faithfulness.md @@ -0,0 +1,32 @@ +### Added + +- Well-known native bindings may now declare a conservative compatibility + marker: `compat = "full"` is reserved for an audited complete drop-in, + while `partial` (and an absent or unknown value) means the wrapper is not + known to cover the package's complete public API. Existing third-party + wrappers remain `partial` until an exhaustive audit proves otherwise, and + aliases inherit their target's marker. + +- When Perry auto-prefers a partial bundled binding over an installed + `node_modules` copy, text-mode builds now print one note per package with + the `perry.compilePackages` escape hatch. Setting + `PERRY_REQUIRE_FAITHFUL_BINDINGS=1` turns that case into a hard error; + false/zero values leave strict mode disabled. Binding policy variables now + participate in build-cache fingerprints. + +- `perry.compilePackages: "auto"` (also `"all"` or `true`) explicitly asks + Perry to compile every reachable dependency that is not natively shimmed. + `perry.allow.compilePackages: true` is the corresponding universal allow + spelling. + +### Changed + +- Auto-compile is now the default when `perry.compilePackages` is omitted. + Perry expands the installed dependency graph and compiles reachable package + source while preserving bundled native bindings. An explicit list, `false`, + or `[]` opts out. An omitted allow policy is granted automatically for this + default, but an explicit `perry.allow.compilePackages` policy and + `PERRY_ALLOW_PERRY_FEATURES=0` remain authoritative fail-closed constraints. + +- Compatibility diagnostics resolve registered package subpaths (for example + `mysql2/promise`) before falling back to the root package binding. diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 5d84ff3a1d..d31651a752 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -47,8 +47,27 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_NO_AUTO_OPTIMIZE", "PERRY_DISABLE_WELL_KNOWN", "PERRY_FORCE_WELL_KNOWN", + // Both switches change native-vs-JavaScript module routing and therefore + // the linked artifact, not merely diagnostics. + "PERRY_ALLOW_PERRY_FEATURES", + "PERRY_REQUIRE_FAITHFUL_BINDINGS", ]; +#[cfg(test)] +mod tests { + use super::BUILD_CACHE_ENV_VARS; + + #[test] + fn binding_policy_switches_are_build_cache_inputs() { + for name in [ + "PERRY_ALLOW_PERRY_FEATURES", + "PERRY_REQUIRE_FAITHFUL_BINDINGS", + ] { + assert!(BUILD_CACHE_ENV_VARS.contains(&name), "missing {name}"); + } + } +} + #[derive(Debug, Clone)] pub(super) struct BuildCacheProbe { args_key: String, diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 5b1d2b4d23..dcecd5b564 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -31,6 +31,7 @@ use super::{ ParseCache, }; +mod binding_faithfulness; mod crypto_ns; mod dynamic_glob; mod eval_worker; @@ -43,14 +44,13 @@ mod static_require_transform; mod tests; mod wasm_asset; +use binding_faithfulness::audit_native_binding_choice; use dynamic_glob::expand_dynamic_import_glob; use eval_worker::materialize_eval_worker_source; +pub(super) use import_helpers::known_node_submodule_key; use import_helpers::{ cached_resolve_import_with_lexical_base, collect_js_module_imports, env_defines_for_lowering, }; -// Re-exported at `pub(super)` because `compile.rs` (the parent module) calls -// `collect_modules::known_node_submodule_key` directly. -pub(super) use import_helpers::known_node_submodule_key; use native_addon::{refuse_compile_package_native_addon, refuse_node_addon_binary}; use parse_error::annotate_parse_error; use static_require_transform::transform_static_literal_requires; @@ -1180,6 +1180,7 @@ fn collect_module_one( if import.is_native { import.module_kind = ModuleKind::NativeRust; + audit_native_binding_choice(&import.source, entry_path, &canonical, ctx)?; if import.source == "perry/ui" { ctx.needs_ui = true; } diff --git a/crates/perry/src/commands/compile/collect_modules/binding_faithfulness.rs b/crates/perry/src/commands/compile/collect_modules/binding_faithfulness.rs new file mode 100644 index 0000000000..bc05d3cba6 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/binding_faithfulness.rs @@ -0,0 +1,133 @@ +use std::path::Path; + +use anyhow::Result; + +use super::super::CompilationContext; + +/// Report when a bundled partial binding shadows an installed package, or +/// reject that choice under the strict faithfulness policy. +pub(super) fn audit_native_binding_choice( + source: &str, + entry_path: &Path, + importer: &Path, + ctx: &mut CompilationContext, +) -> Result<()> { + if source.starts_with('.') || source.starts_with('/') { + return Ok(()); + } + + let (package_name, binding) = lookup_well_known_for_import(source); + let Some(binding) = binding else { + return Ok(()); + }; + // Node builtins have no meaningful npm copy to shadow. + if binding.node_builtin + || binding.is_faithful() + || !node_modules_copy_on_disk(&package_name, entry_path, importer) + { + return Ok(()); + } + + if faithful_bindings_required() { + anyhow::bail!( + "`{pkg}` resolves to the bundled native binding `{krate}`, which is \ + a PARTIAL drop-in (not the full npm API), but a `node_modules/{pkg}` \ + copy is installed and `PERRY_REQUIRE_FAITHFUL_BINDINGS=1` is set. \ + Refusing to auto-prefer the partial binding.\n\ + \n\ + Either add `{pkg}` to `perry.compilePackages` (+ \ + `perry.allow.compilePackages`) to AOT-compile the real JavaScript, \ + or unset PERRY_REQUIRE_FAITHFUL_BINDINGS to accept the partial \ + binding.\n\ + \n\ + (imported from {importer})", + pkg = package_name, + krate = binding.krate, + importer = importer.display(), + ); + } + ctx.partial_binding_autoprefers.insert(package_name); + Ok(()) +} + +/// Preserve a registered subpath such as `mysql2/promise` before falling back +/// to the root-package binding. The root name remains the on-disk/config key. +fn lookup_well_known_for_import( + source: &str, +) -> ( + String, + Option<&'static super::super::well_known::WellKnownBinding>, +) { + let (package_name, _) = super::super::resolve::parse_package_specifier(source); + let binding = super::super::well_known::lookup_well_known(source) + .or_else(|| super::super::well_known::lookup_well_known(&package_name)); + (package_name, binding) +} + +/// Probe both resolver ancestor chains so nested and pnpm layouts are covered. +fn node_modules_copy_on_disk(pkg_name: &str, entry_path: &Path, importer: &Path) -> bool { + let mut starts = vec![entry_path]; + if importer != entry_path { + starts.push(importer); + } + starts.into_iter().any(|start| { + super::super::resolve::ancestor_node_modules_dirs(start) + .into_iter() + .any(|node_modules| node_modules.join(pkg_name).is_dir()) + }) +} + +fn faithful_bindings_required_value(value: Option<&str>) -> bool { + value.is_some_and(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true")) +} + +fn faithful_bindings_required() -> bool { + faithful_bindings_required_value( + std::env::var("PERRY_REQUIRE_FAITHFUL_BINDINGS") + .ok() + .as_deref(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strict_mode_requires_an_enabled_value() { + for value in [Some("1"), Some("true"), Some(" TRUE ")] { + assert!(faithful_bindings_required_value(value), "value: {value:?}"); + } + for value in [None, Some(""), Some("0"), Some("false"), Some("no")] { + assert!(!faithful_bindings_required_value(value), "value: {value:?}"); + } + } + + #[test] + fn lookup_preserves_registered_subpaths_before_falling_back() { + let (root, binding) = lookup_well_known_for_import("mysql2/promise"); + assert_eq!(root, "mysql2"); + assert_eq!(binding.expect("subpath binding").package, "mysql2/promise"); + + let (root, binding) = lookup_well_known_for_import("dotenv/config"); + assert_eq!(root, "dotenv"); + assert_eq!(binding.expect("root fallback").package, "dotenv"); + } + + #[test] + fn installed_copy_probe_uses_root_package_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("src/main.ts"); + let importer = dir.path().join("node_modules/consumer/index.js"); + std::fs::create_dir_all(dir.path().join("node_modules/mysql2")).unwrap(); + std::fs::create_dir_all(importer.parent().unwrap()).unwrap(); + std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); + + assert!(node_modules_copy_on_disk("mysql2", &entry, &importer)); + assert!(!node_modules_copy_on_disk( + "mysql2/promise", + &entry, + &importer + )); + } +} diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index 959e487c88..6bcb64a666 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -24,6 +24,39 @@ use crate::OutputFormat; use super::audit_manifest::allowlist_matches; use super::{CompilationContext, CompileArgs}; +/// Whether a `perry.compilePackages` / `perry.allow.compilePackages` value +/// is the "trust everything reachable" single switch — either the boolean +/// `true` or the string `"auto"` / `"all"`. Both are sugar for the +/// universal `["*"]` wildcard (#3527), letting a zero-config project opt in +/// without hand-enumerating its dependency graph. An explicit array is +/// unaffected (returns false and is parsed name-by-name). +fn compile_packages_means_auto(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Bool(b) => *b, + serde_json::Value::String(s) => { + let s = s.trim().to_ascii_lowercase(); + s == "auto" || s == "all" + } + _ => false, + } +} + +fn parse_boolean_switch(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" => Some(true), + "0" | "false" => Some(false), + _ => None, + } +} + +fn should_auto_grant_compile_allow( + has_universal_route: bool, + allow_was_explicit: bool, + env_forces_deny: bool, +) -> bool { + has_universal_route && !allow_was_explicit && !env_forces_deny +} + pub(super) fn apply_pkg_and_toml_config( args: &CompileArgs, project_root: &Path, @@ -34,6 +67,18 @@ pub(super) fn apply_pkg_and_toml_config( BTreeMap>, )> { let mut fp_contract_explicit = false; + // Whether the host pinned a `perry.compilePackages` value at all (an + // array, a scalar switch, or `false`/`[]` to compile nothing). When it + // did NOT, the auto-compile default kicks in further below — perry + // compiles the whole reachable node_modules graph without an allowlist + // (owner policy: perry is a compiler, not a supply-chain gate). An + // explicit value — including the empty forms — opts out of that default. + let mut compile_packages_explicit = false; + // An explicit allow policy remains authoritative when routing defaults to + // auto. In particular, `allow.compilePackages: false` / `[]` is a real + // fail-closed choice and must not be replaced with an implicit `"*"`. + let mut allow_compile_packages_explicit = false; + let mut allow_perry_features_forces_deny = false; // #2309: tree-shaking opt-in via env var (checked unconditionally, even // with no host package.json). `perry.experiments.treeShake: true` in the @@ -100,32 +145,59 @@ pub(super) fn apply_pkg_and_toml_config( } } } - if let Some(arr) = allow.get("compilePackages").and_then(|v| v.as_array()) { - for entry in arr { - if let Some(s) = entry.as_str() { - ctx.allow_compile_packages.push(s.to_string()); + if let Some(allow_compile) = allow.get("compilePackages") { + allow_compile_packages_explicit = true; + // Scalar sugar: `true` / `"auto"` = universal trust + // (`"*"`), the companion to `compilePackages: "auto"`. + if compile_packages_means_auto(allow_compile) { + ctx.allow_compile_packages.push("*".to_string()); + } else if let Some(arr) = allow_compile.as_array() { + for entry in arr { + if let Some(s) = entry.as_str() { + ctx.allow_compile_packages.push(s.to_string()); + } } } } } - if let Some(compile_pkgs) = pkg - .get("perry") - .and_then(|p| p.get("compilePackages")) - .and_then(|a| a.as_array()) + if let Some(compile_pkgs_val) = + pkg.get("perry").and_then(|p| p.get("compilePackages")) { + // The key is present — the host is pinning routing + // explicitly, so suppress the auto-compile default (even + // for `false` / `[]`, which mean "compile nothing"). + compile_packages_explicit = true; // #497: collect compilePackages entries here but // defer the allowlist check until after env-var // overrides apply (otherwise // `PERRY_ALLOW_PERRY_FEATURES=1` couldn't unblock // a build whose host hasn't opted in via // package.json yet). - for pkg_name in compile_pkgs { - if let Some(name) = pkg_name.as_str() { - match format { - OutputFormat::Text => println!(" Compile package: {}", name), - OutputFormat::Json => {} + // + // Single trust-switch sugar: `"compilePackages": "auto"` + // (or `true`) is shorthand for the universal `["*"]` + // wildcard — "trust every reachable node_modules dep" — + // so a zero-config project need not hand-enumerate its + // graph. It expands to concrete installed names further + // below (same path as a literal `"*"`), and is still + // gated by `perry.allow.compilePackages`. + if compile_packages_means_auto(compile_pkgs_val) { + match format { + OutputFormat::Text => { + println!(" Compile package: auto (trust all reachable deps)") + } + OutputFormat::Json => {} + } + ctx.compile_packages.insert("*".to_string()); + } else if let Some(compile_pkgs) = compile_pkgs_val.as_array() { + for pkg_name in compile_pkgs { + if let Some(name) = pkg_name.as_str() { + match format { + OutputFormat::Text => println!(" Compile package: {}", name), + OutputFormat::Json => {} + } + ctx.compile_packages.insert(name.to_string()); } - ctx.compile_packages.insert(name.to_string()); } } } @@ -482,8 +554,12 @@ pub(super) fn apply_pkg_and_toml_config( // where editing `package.json` isn't an option (one-off CI run, // bisect script, etc.). `=0` enforces the refusal even when // `package.json` opted in (fail-closed CI gate). - match std::env::var("PERRY_ALLOW_PERRY_FEATURES") { - Ok(v) if v == "1" || v.eq_ignore_ascii_case("true") => { + match std::env::var("PERRY_ALLOW_PERRY_FEATURES") + .ok() + .as_deref() + .and_then(parse_boolean_switch) + { + Some(true) => { if !ctx.allow_native_library.iter().any(|s| s == "*") { ctx.allow_native_library.push("*".to_string()); } @@ -491,9 +567,10 @@ pub(super) fn apply_pkg_and_toml_config( ctx.allow_compile_packages.push("*".to_string()); } } - Ok(v) if v == "0" || v.eq_ignore_ascii_case("false") => { + Some(false) => { ctx.allow_native_library.clear(); ctx.allow_compile_packages.clear(); + allow_perry_features_forces_deny = true; } _ => {} } @@ -629,6 +706,31 @@ pub(super) fn apply_pkg_and_toml_config( perry_hir::set_eval_strict_mode(ctx.strict_eval); perry_hir::set_unimplemented_strict_mode(ctx.strict_unimplemented); + // Auto-compile default (owner policy): a project that does not pin a + // `perry.compilePackages` value gets its whole reachable node_modules + // graph compiled — perry is a compiler, not a supply-chain gate, so + // "unlisted" is not a hard error, it just compiles (and a dependency that + // genuinely can't compile surfaces as an ordinary compile error). This is + // exactly `compilePackages: "auto"`: inject the universal `"*"`, which the + // expansion just below turns into concrete installed names (native-shimmed + // packages are skipped, so bundled bindings still win). Opt out with an + // explicit list, or `compilePackages: false` / `[]` to compile nothing and + // restore the V8-free gate's "listed only" behavior. + if !compile_packages_explicit { + ctx.compile_packages.insert("*".to_string()); + } + // Universal routing with no explicit allow policy ⇒ universal allow. An + // explicit package.json allow value (including false/[]) or the CI + // fail-closed env override remains authoritative and is validated below. + if should_auto_grant_compile_allow( + ctx.compile_packages.iter().any(|p| p == "*"), + allow_compile_packages_explicit, + allow_perry_features_forces_deny, + ) && !ctx.allow_compile_packages.iter().any(|p| p == "*") + { + ctx.allow_compile_packages.push("*".to_string()); + } + // #3527 (blocker #4): materialize `"*"` / `"@scope/*"` wildcard entries // in `perry.compilePackages` into concrete installed package names. // @@ -709,8 +811,9 @@ pub(super) fn apply_pkg_and_toml_config( // parse loop populated `ctx.compile_packages` above; we validate // after env-var overrides). Every entry that flowed in needs a // match in `ctx.allow_compile_packages` — the two-key opt-in. - // Default-empty allowlist = nothing allowed = matches the - // "greenfield projects: nothing allowed" acceptance bullet. + // With auto routing, an omitted allow policy is granted automatically; + // an explicit allow policy (or `PERRY_ALLOW_PERRY_FEATURES=0`) remains a + // real constraint and is checked here. for name in ctx.compile_packages.iter() { if !allowlist_matches(name, &ctx.allow_compile_packages) { anyhow::bail!( @@ -1019,6 +1122,47 @@ pub(super) fn apply_pkg_and_toml_config( Ok((i18n_config, i18n_translations)) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_switch_accepts_only_documented_values() { + for value in [ + serde_json::json!(true), + serde_json::json!("auto"), + serde_json::json!(" ALL "), + ] { + assert!(compile_packages_means_auto(&value), "value: {value}"); + } + for value in [ + serde_json::json!(false), + serde_json::json!("yes"), + serde_json::json!([]), + ] { + assert!(!compile_packages_means_auto(&value), "value: {value}"); + } + } + + #[test] + fn boolean_switch_is_explicit_and_case_insensitive() { + assert_eq!(parse_boolean_switch("1"), Some(true)); + assert_eq!(parse_boolean_switch(" TRUE "), Some(true)); + assert_eq!(parse_boolean_switch("0"), Some(false)); + assert_eq!(parse_boolean_switch("False"), Some(false)); + assert_eq!(parse_boolean_switch("yes"), None); + assert_eq!(parse_boolean_switch(""), None); + } + + #[test] + fn auto_allow_never_overrides_an_explicit_deny() { + assert!(should_auto_grant_compile_allow(true, false, false)); + assert!(!should_auto_grant_compile_allow(true, true, false)); + assert!(!should_auto_grant_compile_allow(true, false, true)); + assert!(!should_auto_grant_compile_allow(false, false, false)); + } +} + fn parse_fp_contract_mode(value: &str, source: &str) -> Result { FpContractMode::from_str(value.trim()).ok_or_else(|| { anyhow::anyhow!( diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 050d305c5a..e0161e2108 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -361,6 +361,24 @@ pub fn run_with_parse_cache( format, )?; + // "Just works" transparency (#466 follow-up): when perry auto-preferred a + // bundled PARTIAL well-known binding over a `node_modules/` copy the + // user actually installed, say so once per package. The build still + // succeeds (this is the zero-config path); the note points at the escape + // hatch for anyone who needs the full npm surface. + if matches!(format, OutputFormat::Text) && !ctx.partial_binding_autoprefers.is_empty() { + for pkg in &ctx.partial_binding_autoprefers { + let krate = self::well_known::lookup_well_known(pkg) + .map(|b| b.krate.clone()) + .unwrap_or_else(|| format!("perry-ext-{pkg}")); + eprintln!( + " note: serving `{pkg}` from the bundled native binding `{krate}` \ + (a partial drop-in), ignoring your installed `node_modules/{pkg}`. \ + Add `{pkg}` to `perry.compilePackages` to AOT-compile the real JS instead." + ); + } + } + run_post_collect_preflight(&args, &mut ctx, format)?; // #2309: tree-shake the final module graph — prune unreachable diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 578452c447..94b3ced08e 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -882,6 +882,15 @@ pub struct CompilationContext { /// name (for `node_modules//...` files) is derived at /// diagnostic-emission time. Empty until `collect_modules` runs. pub js_runtime_importers: Vec, + /// Bare npm package names that were routed to a bundled well-known + /// binding whose `compat` is `Partial` (see `well_known.rs`), even + /// though a `node_modules/` copy is present on disk. Perry + /// auto-prefers the binding (this is the "just works" path), but the + /// wrapper is not an audited full drop-in — so the choice is surfaced + /// with a one-line note after collection (and, under + /// `PERRY_REQUIRE_FAITHFUL_BINDINGS=1`, refused). Insertion-ordered + /// dedup so the summary is stable. Empty until `collect_modules` runs. + pub partial_binding_autoprefers: std::collections::BTreeSet, /// #501: host-controlled per-package capability policy. Map of /// `` (or `"*"` for the default) → allowed /// capability token list (e.g. `["fs:read", "net:fetch"]`). @@ -1133,6 +1142,7 @@ impl CompilationContext { strict_unimplemented: false, allow_dynamic_stdlib_packages: HashSet::new(), js_runtime_importers: Vec::new(), + partial_binding_autoprefers: std::collections::BTreeSet::new(), permissions: std::collections::BTreeMap::new(), host_package_name: None, allow_unsandboxed_build: Vec::new(), diff --git a/crates/perry/src/commands/compile/well_known.rs b/crates/perry/src/commands/compile/well_known.rs index f76404dea7..40df2747fa 100644 --- a/crates/perry/src/commands/compile/well_known.rs +++ b/crates/perry/src/commands/compile/well_known.rs @@ -11,6 +11,46 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::OnceLock; +/// How faithful a bundled binding is to the npm package's public API. +/// +/// This is the safety marker behind "just works" auto-preference (#466 +/// follow-up): perry routes a bare `import 'X'` to the bundled +/// `perry-ext-X` wrapper even when a `node_modules/X` copy is on disk +/// (see `is_native_module` + the resolver short-circuit). That is only +/// safe-by-construction when the wrapper is a genuine drop-in. A wrapper +/// that ports a *subset* of the surface (undici's dispatcher-only client, +/// node-forge's PKI-only slice, lru-cache's numeric-only store) can +/// silently diverge from the real package, so it is marked `Partial` and +/// perry surfaces a diagnostic (and, under +/// `PERRY_REQUIRE_FAITHFUL_BINDINGS=1`, refuses to auto-prefer it). +/// +/// Conservative default: a binding with no `compat` field is treated as +/// `Partial`. A wrapper opts IN to `Full` only once it is audited as a +/// complete drop-in for the package's public API. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BindingCompat { + /// Audited complete drop-in for the npm package's public API. + Full, + /// Ports a subset of the surface (or not yet audited). Not + /// auto-preferred under a strict-faithful build; a note is emitted + /// otherwise. + #[default] + Partial, +} + +impl BindingCompat { + /// Parse the toml `compat = "..."` value. Unknown / absent → the + /// conservative `Partial` default. + fn from_toml(raw: Option<&str>) -> Self { + match raw { + Some("full") => BindingCompat::Full, + // Any other value (including "partial", or a typo) stays + // conservative — a binding is never faithful by accident. + _ => BindingCompat::Partial, + } + } +} + /// One row of the well-known bindings table — what perry's bundled /// wrappers expose to programs that import the bare npm name. #[derive(Debug, Clone)] @@ -52,6 +92,38 @@ pub struct WellKnownBinding { /// instead of carrying its own pin. #[allow(dead_code)] pub alias_of: Option, + /// How faithful this wrapper is to the npm package's public API. + /// Governs whether auto-preference over an on-disk `node_modules` + /// copy is safe-by-construction. Absent in the toml → `Partial`. + pub compat: BindingCompat, +} + +impl WellKnownBinding { + /// Whether this binding is an audited complete drop-in — safe to + /// auto-prefer over a user's `node_modules` copy without a caveat. + pub fn is_faithful(&self) -> bool { + binding_is_faithful(registry(), self) + } +} + +fn binding_is_faithful( + table: &BTreeMap, + binding: &WellKnownBinding, +) -> bool { + let mut current = binding; + // At most one visit per row: exceeding the table length means aliases + // contain a cycle. Missing/cyclic targets are never granted faithfulness. + for _ in 0..=table.len() { + if let Some(target) = current.alias_of.as_deref() { + let Some(target_binding) = table.get(target) else { + return false; + }; + current = target_binding; + } else { + return matches!(current.compat, BindingCompat::Full); + } + } + false } /// Provenance pin for a binding's upstream npm package — the same record @@ -223,6 +295,8 @@ fn parse_well_known_toml(raw: &str) -> Result .and_then(|v| v.as_str()) .map(String::from); + let compat = BindingCompat::from_toml(entry_table.get("compat").and_then(|v| v.as_str())); + let upstream = match entry_table.get("upstream") { None => None, Some(value) => { @@ -274,6 +348,7 @@ fn parse_well_known_toml(raw: &str) -> Result upstream, node_builtin, alias_of, + compat, }, ); } @@ -321,6 +396,98 @@ mod tests { assert!(lookup_well_known("definitely-not-a-real-package").is_none()); } + #[test] + fn compat_defaults_to_partial_when_absent() { + let raw = r#" + [bindings.foo] + crate = "perry-ext-foo" + lib = "perry_ext_foo" + "#; + let parsed = parse_well_known_toml(raw).expect("entry parses"); + assert_eq!(parsed["foo"].compat, BindingCompat::Partial); + assert!(!parsed["foo"].is_faithful()); + } + + #[test] + fn compat_full_marks_binding_faithful() { + let raw = r#" + [bindings.foo] + crate = "perry-ext-foo" + lib = "perry_ext_foo" + compat = "full" + "#; + let parsed = parse_well_known_toml(raw).expect("entry parses"); + assert_eq!(parsed["foo"].compat, BindingCompat::Full); + assert!(parsed["foo"].is_faithful()); + } + + #[test] + fn compat_unknown_value_stays_conservative() { + let raw = r#" + [bindings.foo] + crate = "perry-ext-foo" + lib = "perry_ext_foo" + compat = "mostly" + "#; + let parsed = parse_well_known_toml(raw).expect("entry parses"); + // A typo / unrecognized level never grants faithfulness. + assert_eq!(parsed["foo"].compat, BindingCompat::Partial); + } + + /// The shipped table's audited posture: documented-subset wrappers + /// stay `Partial` and are never silently treated as complete drop-ins. + #[test] + fn shipped_subset_bindings_are_partial() { + for name in ["undici", "node-forge", "lru-cache"] { + let b = lookup_well_known(name).unwrap_or_else(|| panic!("{name} registered")); + assert_eq!( + b.compat, + BindingCompat::Partial, + "{name} is a documented subset and must stay compat=partial" + ); + } + } + + #[test] + fn shipped_unproven_bindings_are_partial() { + for name in ["dotenv", "nanoid", "slugify", "uuid"] { + let b = lookup_well_known(name).unwrap_or_else(|| panic!("{name} registered")); + assert_eq!( + b.compat, + BindingCompat::Partial, + "{name} omits upstream API/behavior and must stay partial" + ); + } + } + + #[test] + fn aliases_inherit_target_compat_and_cycles_fail_closed() { + let raw = r#" + [bindings.full] + crate = "perry-ext-full" + lib = "perry_ext_full" + compat = "full" + + [bindings.alias] + crate = "perry-ext-full" + lib = "perry_ext_full" + alias-of = "full" + + [bindings.a] + crate = "perry-ext-a" + lib = "perry_ext_a" + alias-of = "b" + + [bindings.b] + crate = "perry-ext-b" + lib = "perry_ext_b" + alias-of = "a" + "#; + let parsed = parse_well_known_toml(raw).expect("entries parse"); + assert!(binding_is_faithful(&parsed, &parsed["alias"])); + assert!(!binding_is_faithful(&parsed, &parsed["a"])); + } + #[test] fn parser_rejects_missing_crate_field() { let raw = r#" diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index f88242d8c2..53437d9591 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -40,6 +40,13 @@ lib = "perry_ext_dotenv" # Tracking issue for the migration; surfaced in error messages # when the bundled .a is missing at link time. tracking = "#466" +# `compat` — how faithful this wrapper is to the npm package's public +# API (see `BindingCompat` in well_known.rs). `full` = audited complete +# drop-in, safe to auto-prefer over an on-disk node_modules copy. +# ABSENT ⇒ conservative `partial` default. dotenv remains partial: this +# wrapper has `config()` / `parse()`, but not the upstream decrypt/populate/ +# configDotenv surface and option/error semantics. +compat = "partial" [bindings.dotenv.upstream] version = "17.4.2" @@ -52,6 +59,9 @@ date = "2026-07-30" crate = "perry-ext-nanoid" lib = "perry_ext_nanoid" tracking = "#466" +# Partial: the wrapper covers nanoid + custom alphabets, but not the complete +# upstream export set and flattens the curried customAlphabet contract. +compat = "partial" [bindings.nanoid.upstream] version = "6.0.0" @@ -64,6 +74,9 @@ date = "2026-07-30" crate = "perry-ext-uuid" lib = "perry_ext_uuid" tracking = "#466" +# Partial: several upstream exports and input forms are absent (including +# parse/stringify and newer conversion/constants APIs). +compat = "partial" [bindings.uuid.upstream] version = "14.0.1" @@ -76,6 +89,9 @@ date = "2026-07-30" crate = "perry-ext-slugify" lib = "perry_ext_slugify" tracking = "#466" +# Partial: `remove`, `locale`, the complete char map, and `slugify.extend` +# are not implemented by this wrapper. +compat = "partial" [bindings.slugify.upstream] version = "1.6.9" @@ -135,6 +151,10 @@ date = "2026-07-30" crate = "perry-ext-lru-cache" lib = "perry_ext_lru_cache" tracking = "#466" +# PARTIAL (explicit): the wrapper's store is numeric-value-oriented and +# does not faithfully reproduce lru-cache's full generic/option surface. +# Kept off the auto-prefer faithful path until the port is completed. +compat = "partial" [bindings.lru-cache.upstream] version = "11.5.2" @@ -562,6 +582,9 @@ tracking = "#867" crate = "perry-ext-node-forge" lib = "perry_ext_node_forge" tracking = "#466" +# PARTIAL (explicit): only the PKI subset (RSA keygen, X.509 build/sign, +# PEM round-trips) is ported — forge's cipher/md/util/asn1 surface is not. +compat = "partial" # Upstream provenance pin (see PR #7031 / docs upstream-pins.md). [bindings.node-forge.upstream] @@ -583,6 +606,10 @@ date = "2026-07-30" crate = "perry-ext-undici" lib = "perry_ext_undici" tracking = "#466" +# PARTIAL (explicit): ProxyAgent / Agent / setGlobalDispatcher / +# getGlobalDispatcher + native `fetch` are real; `request` and the +# pool/mock/stream surface are not implemented. +compat = "partial" # Upstream provenance pin (see PR #7031 / docs upstream-pins.md). undici's # stable dispatcher API (ProxyAgent/Agent/setGlobalDispatcher) is unchanged diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 8706376d86..6c1d7678ac 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -26,6 +26,7 @@ - [`perry-ffi` ABI Reference](native-libraries/abi.md) - [Manifest Schema (spec v1)](native-libraries/manifest-v1.md) - [Upstream Provenance Pins](native-libraries/upstream-pins.md) + - [Zero-config Bindings and Faithfulness](native-libraries/zero-config-and-faithfulness.md) # Multi-Threading diff --git a/docs/src/native-libraries/zero-config-and-faithfulness.md b/docs/src/native-libraries/zero-config-and-faithfulness.md new file mode 100644 index 0000000000..137bbba77b --- /dev/null +++ b/docs/src/native-libraries/zero-config-and-faithfulness.md @@ -0,0 +1,93 @@ +# Zero-config bindings and faithfulness + +This note describes Perry's default routing for installed npm dependencies and +the compatibility marker used by bundled `perry-ext-*` bindings. + +## Resolution behavior + +For a bare import, Perry first checks whether the module is in its native +module manifest. Unless the root package was explicitly selected through +`perry.compilePackages`, a native module is served by its bundled binding and +file resolution does not walk into the installed package source. + +Other reachable packages are AOT-compiled by default. When the host +`package.json` omits `perry.compilePackages`, Perry enumerates installed +packages, skips natively shimmed packages, and routes the remaining package +source through the compiler. This is equivalent to: + +```json +{ + "perry": { + "compilePackages": "auto" + } +} +``` + +`"all"`, `true`, and a literal `"*"` array entry have the same universal +routing meaning. An explicit package list constrains routing; `false` or `[]` +opts out entirely and restores the listed-only V8-free gate. + +When no allow policy is present, universal auto routing also receives the +universal compile allow. Explicit trust policy is never discarded: + +- `perry.allow.compilePackages` can constrain the packages admitted by auto + routing; +- `perry.allow.compilePackages: false` or `[]` fails closed; +- `PERRY_ALLOW_PERRY_FEATURES=0` clears the allowlist and fails closed; +- `PERRY_ALLOW_PERRY_FEATURES=1` remains the one-off universal override. + +## The compatibility marker + +Each row in `crates/perry/well_known_bindings.toml` may declare: + +```toml +[bindings.example] +crate = "perry-ext-example" +lib = "perry_ext_example" +compat = "partial" +``` + +The two values are: + +- `full`: an exhaustively audited drop-in for the pinned npm package's public + API and observable behavior; +- `partial`: a subset or a wrapper that has not yet passed that audit. + +An absent or unknown value is `partial`. Aliases inherit the target binding's +effective marker; missing targets and alias cycles fail closed as partial. + +The current third-party wrappers remain partial. Several superficially small +wrappers still differ materially from their pinned packages: the UUID wrapper, +for example, lacks exports including `parse` and `stringify`; slugify lacks +`remove`, `locale`, the complete character map, and `extend`; nanoid flattens a +curried API and omits exports; and dotenv implements only part of its current +surface. A `full` marker should be added only after the implementation and +conformance tests cover the complete pinned surface. + +## Diagnostics and strict mode + +When a partial binding wins while a copy of its root package exists in +`node_modules`, text-mode builds emit one informational note per package. The +note points to `perry.compilePackages`, which makes the installed JavaScript +source win instead. + +For CI that must never substitute a partial wrapper, set: + +```sh +PERRY_REQUIRE_FAITHFUL_BINDINGS=1 perry compile src/main.ts +``` + +Only enabled values (`1` or `true`, case-insensitive) activate strict mode. +Under strict mode Perry refuses the partial auto-preference and identifies the +binding and importing module. Add the root package to both +`perry.compilePackages` and the applicable allow policy to compile the real +source, or disable strict mode to accept the bundled subset. + +Registered subpaths are classified before root fallback. Thus an import such +as `mysql2/promise` uses that alias row and inherits `mysql2`'s compatibility, +while the installed-copy probe and `compilePackages` suggestion correctly use +the root package name `mysql2`. + +Both `PERRY_REQUIRE_FAITHFUL_BINDINGS` and `PERRY_ALLOW_PERRY_FEATURES` are +included in the build-cache environment fingerprint, so changing either policy +cannot reuse an artifact produced under different routing rules.