From 28caf8396171797679b1e7e4fea76f8ac3b21c72 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 31 Jul 2026 13:26:42 -0400 Subject: [PATCH 1/4] feat(compile): zero-config auto-compile + binding faithfulness marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves perry toward "it just works" for dependents (Socket Firewall is the driving case) — no hand-maintained perry.compilePackages list and no node_modules offloading needed. Auto-compile default: a project that does not pin perry.compilePackages now gets its whole reachable node_modules graph compiled automatically (host_config injects the universal "*" and auto-satisfies the allow.compilePackages two-key check). Perry is a compiler, not a supply-chain gate — "this package is not on a list" is no longer a hard error; a dependency that genuinely cannot compile surfaces as an ordinary compile error. Native-shimmed packages still resolve to their bundled bindings (the #3527 wildcard expansion skips them). Opt out with an explicit array, or compilePackages: false / [] to compile nothing and re-arm the V8-free gate. perry.compilePackages: "auto" (and allow.compilePackages: true) are the explicit spelling of the default. Faithfulness marker: well-known bindings gain compat = "full" | "partial" (absent => partial, conservative) in well_known_bindings.toml, parsed into BindingCompat with an is_faithful() helper. When perry auto-prefers a partial binding over an installed node_modules copy it prints a one-line transparency note; PERRY_REQUIRE_FAITHFUL_BINDINGS=1 turns that into a refusal (default off, so existing builds are byte-identical). Faithfulness audit (conservative — default to partial unless clearly complete): dotenv / nanoid / slugify / uuid => full (small, complete pure ports); undici (dispatcher-only), node-forge (PKI-only), lru-cache => explicit partial with rationale; every other binding inherits the partial default. Design note + firewall-cleanup guidance: docs/src/native-libraries/zero-config-and-faithfulness.md --- .../zero-config-binding-faithfulness.md | 52 ++++++ .../src/commands/compile/collect_modules.rs | 65 +++++++ .../perry/src/commands/compile/host_config.rs | 103 +++++++++-- .../src/commands/compile/run_pipeline.rs | 18 ++ crates/perry/src/commands/compile/types.rs | 10 + .../perry/src/commands/compile/well_known.rs | 124 +++++++++++++ crates/perry/well_known_bindings.toml | 25 +++ .../zero-config-and-faithfulness.md | 173 ++++++++++++++++++ 8 files changed, 556 insertions(+), 14 deletions(-) create mode 100644 changelog.d/zero-config-binding-faithfulness.md create mode 100644 docs/src/native-libraries/zero-config-and-faithfulness.md diff --git a/changelog.d/zero-config-binding-faithfulness.md b/changelog.d/zero-config-binding-faithfulness.md new file mode 100644 index 0000000000..35e6fac6ad --- /dev/null +++ b/changelog.d/zero-config-binding-faithfulness.md @@ -0,0 +1,52 @@ +### Added + +- **Well-known bindings now carry a `compat` faithfulness marker (#466 + follow-up).** Each `[bindings.X]` in `well_known_bindings.toml` may declare + `compat = "full"` (an audited complete drop-in for the npm package's public + API) or `compat = "partial"` (ports only a subset, or not yet audited). + **Absent ⇒ `partial`**, the conservative default — a binding is never treated + as faithful by accident. Audited this release: `dotenv`, `nanoid`, `slugify`, + `uuid` opt in to `full`; the documented subsets `undici` (dispatcher-only), + `node-forge` (PKI-only), and `lru-cache` (numeric store) are marked `partial` + explicitly; every other binding inherits the `partial` default. + +- **Transparency note when perry auto-prefers a partial binding over your + installed copy.** When a bare `import 'X'` is served by a bundled + `perry-ext-X` wrapper that is `partial` **and** you have a `node_modules/X` + copy on disk, perry now prints a one-line note per package pointing at the + `perry.compilePackages` escape hatch. The build still succeeds — this is the + zero-config path — the note just makes the (previously silent) choice visible. + +- **`PERRY_REQUIRE_FAITHFUL_BINDINGS=1`** — opt-in strict mode that turns that + note into a hard error: perry refuses to auto-prefer a `partial` binding over + an installed `node_modules` copy, telling you to either add the package to + `perry.compilePackages` (to AOT-compile the real JavaScript) or accept the + partial binding by unsetting the variable. `full` bindings are unaffected. + Default off ⇒ existing behavior is byte-identical. + +### Changed + +- **Auto-compile is now the default — no `perry.compilePackages` allowlist + required.** A project that does not pin a `perry.compilePackages` value gets + its whole reachable `node_modules` dependency graph compiled automatically. + Perry is a compiler, not a supply-chain gate: faithfully compiling code that + is already installed is not perry's decision to block, and "this package is + not on a list" is no longer a hard error. A dependency that genuinely cannot + be compiled surfaces as an ordinary **compile error** with a diagnostic, not + a policy refusal. Native-shimmed packages still resolve to their bundled + bindings (the wildcard expansion skips them), so bindings keep winning. + + Supply-chain hygiene (pinning, lockfiles, review, dedicated tooling) is the + user's responsibility. Opt out to the old "listed packages only" behavior + with an explicit `perry.compilePackages` array, or `compilePackages: false` / + `[]` to compile nothing and re-arm the V8-free gate. + +### Added + +- **`perry.compilePackages: "auto"` (and `perry.allow.compilePackages: true`).** + Explicit spelling of the new default — sugar for the universal `["*"]` + wildcard (#3527), useful to document intent or to re-enable auto-compile + inside a config that would otherwise constrain it. `"auto"` / `"all"` / + `true` are accepted; an explicit array is unchanged. A universal-trust value + auto-satisfies `perry.allow.compilePackages`, and native-shimmed packages + stay on their bindings exactly as with a literal `"*"`. diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 5b1d2b4d23..cf79affe98 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -58,6 +58,29 @@ use wasm_asset::{is_wasm_asset, synthesize_wasm_stub_module}; const MAX_CROSS_MODULE_INLINE_PRIOR_MODULES: usize = 128; +/// Is there a `node_modules/` directory on disk reachable from the +/// build? Used to tell "perry served this bare import from a bundled +/// binding because there was no local copy" apart from "perry +/// auto-preferred the binding over the user's installed copy" — only the +/// latter warrants the partial-binding note. Walks the ancestor +/// `node_modules` chains of both the entry file and the importing module +/// (mirroring the resolver's own search roots), so pnpm/nested 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); + } + for start in starts { + for node_modules in super::resolve::ancestor_node_modules_dirs(start) { + if node_modules.join(pkg_name).is_dir() { + return true; + } + } + } + false +} + /// Next.js wall 54 (part 2): recursively gather every `*.js` file under `dir` /// (page/route loaders + turbopack chunks). Symlinks are not followed; errors /// reading a subdirectory are skipped silently (best-effort discovery). @@ -1180,6 +1203,48 @@ fn collect_module_one( if import.is_native { import.module_kind = ModuleKind::NativeRust; + + // "Just works" safety marker (#466 follow-up): perry is about to + // serve this bare import from a bundled `perry-ext-*` binding. + // When the binding is a well-known *partial* drop-in AND the user + // actually has a `node_modules/` copy on disk, perry is + // silently auto-preferring an incomplete wrapper over their real + // dependency. Surface that: record it for a post-collect note, and + // — under the strict opt-in — refuse rather than auto-prefer. + let is_bare = !import.source.starts_with('.') && !import.source.starts_with('/'); + if is_bare { + let (pkg_name, _) = parse_package_specifier(&import.source); + if let Some(binding) = super::well_known::lookup_well_known(&pkg_name) { + // Node builtins (net/http/zlib/events/…) are always native + // and have no meaningful npm copy to shadow — skip them. + if !binding.node_builtin + && !binding.is_faithful() + && node_modules_copy_on_disk(&pkg_name, entry_path, &canonical) + { + if std::env::var_os("PERRY_REQUIRE_FAITHFUL_BINDINGS").is_some() { + 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` 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 = pkg_name, + krate = binding.krate, + importer = canonical.display(), + ); + } + ctx.partial_binding_autoprefers.insert(pkg_name); + } + } + } + if import.source == "perry/ui" { ctx.needs_ui = true; } diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index 959e487c88..b34d9e2e15 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -24,6 +24,23 @@ 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, + } +} + pub(super) fn apply_pkg_and_toml_config( args: &CompileArgs, project_root: &Path, @@ -34,6 +51,13 @@ 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; // #2309: tree-shaking opt-in via env var (checked unconditionally, even // with no host package.json). `perry.experiments.treeShake: true` in the @@ -100,32 +124,58 @@ 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") { + // 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()); } } } @@ -629,6 +679,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 trust ⇒ universal allow. Whenever routing includes the `"*"` + // wildcard (from this auto default, the `"auto"` scalar, or a literal + // `["*"]`), the host is trusting its whole graph, so the #497 two-key + // allowlist below must not then block it. Supply-chain review is the + // user's responsibility (lockfiles, pinning, external tooling), not a + // hand-maintained perry allowlist. + if ctx.compile_packages.iter().any(|p| p == "*") + && !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. // 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..432c3b3561 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,21 @@ 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 { + // Aliases inherit the faithfulness of their target (resolved by + // the caller when needed); the row's own `compat` still applies + // as a conservative floor. + matches!(self.compat, BindingCompat::Full) + } } /// Provenance pin for a binding's upstream npm package — the same record @@ -223,6 +278,9 @@ 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 +332,7 @@ fn parse_well_known_toml(raw: &str) -> Result upstream, node_builtin, alias_of, + compat, }, ); } @@ -321,6 +380,71 @@ 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` (never auto-preferred silently), and the small + /// audited pure-ports opt in to `Full`. + #[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_audited_bindings_are_full() { + for name in ["dotenv", "nanoid", "slugify", "uuid"] { + let b = lookup_well_known(name).unwrap_or_else(|| panic!("{name} registered")); + assert_eq!( + b.compat, + BindingCompat::Full, + "{name} is audited as a full drop-in (compat=full)" + ); + } + } + #[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..91beb39fb5 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -40,6 +40,12 @@ 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's surface is the small, +# fully-ported `config()`/`parse()` pair. +compat = "full" [bindings.dotenv.upstream] version = "17.4.2" @@ -52,6 +58,9 @@ date = "2026-07-30" crate = "perry-ext-nanoid" lib = "perry_ext_nanoid" tracking = "#466" +# Full drop-in: the complete `nanoid()` / `customAlphabet` / `urlAlphabet` +# surface is ported. +compat = "full" [bindings.nanoid.upstream] version = "6.0.0" @@ -64,6 +73,8 @@ date = "2026-07-30" crate = "perry-ext-uuid" lib = "perry_ext_uuid" tracking = "#466" +# Full drop-in: v1/v3/v4/v5/v7 generators + parse/stringify/validate/version. +compat = "full" [bindings.uuid.upstream] version = "14.0.1" @@ -76,6 +87,9 @@ date = "2026-07-30" crate = "perry-ext-slugify" lib = "perry_ext_slugify" tracking = "#466" +# Full drop-in: the single `slugify(str, opts)` entry point with the +# documented option bag (replacement/remove/lower/strict/locale/trim). +compat = "full" [bindings.slugify.upstream] version = "1.6.9" @@ -135,6 +149,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 +580,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 +604,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/native-libraries/zero-config-and-faithfulness.md b/docs/src/native-libraries/zero-config-and-faithfulness.md new file mode 100644 index 0000000000..a468658147 --- /dev/null +++ b/docs/src/native-libraries/zero-config-and-faithfulness.md @@ -0,0 +1,173 @@ +# Zero-config bindings & the faithfulness marker + +Status: **design note + landed increments.** This note records (a) the +current resolution behavior for bare npm imports that have a bundled +`perry-ext-*` binding, (b) the faithfulness (`compat`) marker landed +alongside it, and (c) the two policy decisions that are deliberately left +to a maintainer rather than flipped unilaterally. + +## Background: the friction we set out to remove + +Socket Firewall compiled its CLIs with two hand-maintained workarounds: + +1. it kept a `perry.compilePackages` list of every AOT-compiled dependency, and +2. its `scripts/build-perry.ts` physically moved + `node_modules/{undici,node-forge,iovalkey,dotenv}` aside for the duration + of each `perry compile`, because a bare `node_modules/` copy used to + shadow perry's bundled binding and trip the V8-free "JavaScript runtime" + gate. + +## Current behavior (map) + +Resolution precedence for a bare `import 'X'` is documented at the top of +`crates/perry/well_known_bindings.toml` and implemented across: + +- **The native short-circuit** — `perry_hir::is_native_module` ( + `crates/perry-hir/src/ir/constants.rs:439`) consults + `perry_api_manifest::NATIVE_MODULES` (`crates/perry-api-manifest/src/entries.rs:30`). + When `X` is in that list and **not** in `perry.compilePackages`, the import is + marked native (`crates/perry/src/commands/compile/collect_modules.rs`, the + `if import.is_native { … }` branch) and routed to the bundled binding — + perry never walks `node_modules/X`. +- **File resolution** — `resolve::resolve_import` + (`crates/perry/src/commands/compile/resolve.rs:1288`) returns `None` for a + native module (line 1303), so a `node_modules/X` copy is only consulted when + `X` is **not** native (or has been opted into `compilePackages`). +- **The V8-free gate** — `enforce_js_runtime_gate` + (`crates/perry/src/commands/compile/bootstrap.rs:266`) hard-errors when the + build reaches a `node_modules` JS module that is neither native nor in + `compilePackages`. This is the intentional supply-chain boundary: + AOT-compiling arbitrary dependency JS requires an explicit trust opt-in. + +**Key finding:** `undici` (#7032), `node-forge` (#7033), `iovalkey`, and +`dotenv` were all added to `NATIVE_MODULES` in the last few days, so on current +`main` **all four already resolve to their bundled bindings even when a +`node_modules/` copy is present** — verified empirically (`Found N +module(s): N native, 0 JavaScript`, no error, no `node_modules` move). The +"binding gets shadowed" problem the firewall offload worked around is already +gone. Firewall's offload dance and its omission of these packages from +`compilePackages` are now **stale** (see the firewall cleanup section). + +That reframes the original ask. The literal "convert today's hard error into a +working binding build" is already the behavior for anything in `NATIVE_MODULES` +(which is kept in lock-step with the well-known table). What was missing is the +**safety and transparency** around it: perry auto-prefers *every* binding — +including the documented **subset** wrappers (`undici`, `node-forge`, +`lru-cache`) — silently, with no signal that the wrapper is not a full drop-in. + +## Landed: the `compat` faithfulness marker + +`well_known_bindings.toml` entries may now declare: + +```toml +[bindings.dotenv] +crate = "perry-ext-dotenv" +lib = "perry_ext_dotenv" +compat = "full" # audited complete drop-in — safe to auto-prefer silently +``` + +- `compat = "full"` — audited complete drop-in for the package's public API. +- `compat = "partial"` — ports a subset, or not yet audited. +- **absent ⇒ `partial`** (conservative; a binding never becomes faithful by + accident). + +Parsed into `BindingCompat` on `WellKnownBinding` +(`crates/perry/src/commands/compile/well_known.rs`) with an `is_faithful()` +helper. Audited this pass: `dotenv`, `nanoid`, `slugify`, `uuid` → `full`; +`undici`, `node-forge`, `lru-cache` → explicit `partial` with rationale +comments; everything else left at the `partial` default. + +Two consumers make the marker load-bearing, **both additive** (default build +byte-identical): + +1. **Transparency note.** When a bare import is served by a `partial` binding + *and* a `node_modules/` copy is on disk, perry prints one note per + package after collection + (`crates/perry/src/commands/compile/run_pipeline.rs`), pointing at the + `compilePackages` escape hatch. `full` bindings and imports with no local + copy stay silent. +2. **Strict opt-in.** `PERRY_REQUIRE_FAITHFUL_BINDINGS=1` turns that note into a + hard error — perry refuses to auto-prefer a `partial` binding over an + installed copy. This is the provable "an unfaithful binding does *not* + silently win" behavior; it is **off by default** so nothing currently + relying on a partial binding (firewall included) breaks. + +## Landed: auto-compile is the default + +A project that does not pin a `perry.compilePackages` value now gets its whole +reachable `node_modules` graph compiled automatically — the host_config loader +injects the universal `"*"` (and auto-satisfies the `allow.compilePackages` +two-key check) when no explicit value is present. The existing #3527 wildcard +expansion then materializes `"*"` into concrete installed package names, +skipping natively-shimmed packages so bindings keep winning. + +`perry.compilePackages: "auto"` (also `"all"` / `true`) and +`perry.allow.compilePackages: true` are the explicit spelling of that default — +useful to document intent or to re-enable auto-compile inside a config that +would otherwise constrain it. + +Opt out to the old "listed packages only" posture with an explicit +`compilePackages` array, or `compilePackages: false` / `[]` to compile nothing +and re-arm the V8-free gate. + +## Policy decisions left to the maintainer + +These are **not** flipped here — they change the security/behavior contract and +want a human sign-off. + +### 1. Should auto-preference of *partial* bindings require opt-in? + +Today perry auto-prefers a partial binding (`undici`, `node-forge`, …) over a +user's real `node_modules` copy silently. The conservative alternative is to +make `PERRY_REQUIRE_FAITHFUL_BINDINGS` behavior the **default** — a partial +binding would error unless the user opts into either the binding or +`compilePackages`. That is *safer* (no silent subset substitution) but **would +break Socket Firewall today**, because firewall depends on the `undici` and +`node-forge` bindings being auto-preferred and those wrappers are, by design, +subsets. So the two cannot both be true at once: + +> "the four firewall packages just work with zero config" **and** "a partial +> binding refuses to auto-prefer" + +are mutually exclusive while `undici`/`node-forge` remain `partial`. The honest +paths forward are: (a) keep auto-prefer-with-note as the default (current +choice) and let strict mode be opt-in; (b) complete the `undici`/`node-forge` +wrappers to `full` and *then* make strict the default; or (c) make strict the +default but ship a curated allowlist of "trusted partial" bindings. Recommend +(a) now, (b) as the target. + +### 2. Should auto-compile be the default? — RESOLVED: yes. + +Resolved by the owner: **auto-compile is the default.** Perry is a compiler, +not a supply-chain gate — faithfully compiling code that is already in +`node_modules` is not perry's call to block, and "this package is not on a +list" is not an error. Supply-chain hygiene (pinning, lockfiles, review, +dedicated tooling) is the user's responsibility, not a hand-maintained perry +allowlist. The V8-free gate is retained only as an opt-in constraint +(`compilePackages: []` / an explicit array) and for genuinely unsupported +situations, not for "unlisted". + +Possible future layer (not built): perry could integrate `socket-sdk-js` to +**warn** about known-malicious packages during compile — a telemetry/warning +layer, never a build-blocking gate. + +## Firewall cleanup this unblocks + +Once firewall builds against a perry with these changes, its perry config +collapses to **nothing**: + +- delete the `node_modules` offload/restore dance in `scripts/build-perry.ts` + (the `bindingServed` array, `offload()`, `restore()`) — the four packages + resolve to bindings on their own (they are in `NATIVE_MODULES`), so no + relocation is needed; call `perry compile` directly; +- delete the entire `perry` block from `package.json` — both + `perry.compilePackages` (`git-up`, `git-url-parse`, `is-ssh`, `lodash`, + `lru-cache`, `node-machine-id`, `parse-path`, `parse-url`, `protocols`, + `zod`) and the mirrored `perry.allow.compilePackages`. Under auto-compile + those reachable deps compile with no list; +- expect the informational per-package note for `undici` / `node-forge` / + `iovalkey` (all `partial` bindings). It is not an error. Note that + `lru-cache` is marked `partial` on this base (its faithful port, #7136, is + not yet in `main`); once #7136 lands its covered option surface can move to + `full`. If firewall would rather compile the real `lru-cache` JS than use the + binding, list just `lru-cache` in `compilePackages`. From d20156e46286de89333e7f67600dd9f971cb385f Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 31 Jul 2026 23:05:41 -0400 Subject: [PATCH 2/4] docs(native-libraries): describe the dependent project generically --- .../zero-config-and-faithfulness.md | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/docs/src/native-libraries/zero-config-and-faithfulness.md b/docs/src/native-libraries/zero-config-and-faithfulness.md index a468658147..810e87333b 100644 --- a/docs/src/native-libraries/zero-config-and-faithfulness.md +++ b/docs/src/native-libraries/zero-config-and-faithfulness.md @@ -8,10 +8,11 @@ to a maintainer rather than flipped unilaterally. ## Background: the friction we set out to remove -Socket Firewall compiled its CLIs with two hand-maintained workarounds: +A downstream command-line application compiled its CLIs with two +hand-maintained workarounds: 1. it kept a `perry.compilePackages` list of every AOT-compiled dependency, and -2. its `scripts/build-perry.ts` physically moved +2. its build script physically moved `node_modules/{undici,node-forge,iovalkey,dotenv}` aside for the duration of each `perry compile`, because a bare `node_modules/` copy used to shadow perry's bundled binding and trip the V8-free "JavaScript runtime" @@ -44,9 +45,9 @@ Resolution precedence for a bare `import 'X'` is documented at the top of `main` **all four already resolve to their bundled bindings even when a `node_modules/` copy is present** — verified empirically (`Found N module(s): N native, 0 JavaScript`, no error, no `node_modules` move). The -"binding gets shadowed" problem the firewall offload worked around is already -gone. Firewall's offload dance and its omission of these packages from -`compilePackages` are now **stale** (see the firewall cleanup section). +"binding gets shadowed" problem that offload worked around is already gone. +That offload dance, and omitting these packages from `compilePackages`, are +now **stale** (see the cleanup section below). That reframes the original ask. The literal "convert today's hard error into a working binding build" is already the behavior for anything in `NATIVE_MODULES` @@ -90,7 +91,7 @@ byte-identical): hard error — perry refuses to auto-prefer a `partial` binding over an installed copy. This is the provable "an unfaithful binding does *not* silently win" behavior; it is **off by default** so nothing currently - relying on a partial binding (firewall included) breaks. + relying on a partial binding breaks. ## Landed: auto-compile is the default @@ -122,12 +123,12 @@ user's real `node_modules` copy silently. The conservative alternative is to make `PERRY_REQUIRE_FAITHFUL_BINDINGS` behavior the **default** — a partial binding would error unless the user opts into either the binding or `compilePackages`. That is *safer* (no silent subset substitution) but **would -break Socket Firewall today**, because firewall depends on the `undici` and -`node-forge` bindings being auto-preferred and those wrappers are, by design, -subsets. So the two cannot both be true at once: +break downstream consumers today**, because a dependent application can rely on +the `undici` and `node-forge` bindings being auto-preferred, and those wrappers +are, by design, subsets. So the two cannot both be true at once: -> "the four firewall packages just work with zero config" **and** "a partial -> binding refuses to auto-prefer" +> "the four natively-shimmed packages just work with zero config" **and** "a +> partial binding refuses to auto-prefer" are mutually exclusive while `undici`/`node-forge` remain `partial`. The honest paths forward are: (a) keep auto-prefer-with-note as the default (current @@ -147,27 +148,25 @@ allowlist. The V8-free gate is retained only as an opt-in constraint (`compilePackages: []` / an explicit array) and for genuinely unsupported situations, not for "unlisted". -Possible future layer (not built): perry could integrate `socket-sdk-js` to -**warn** about known-malicious packages during compile — a telemetry/warning -layer, never a build-blocking gate. +Possible future layer (not built): perry could integrate a package-advisory +data source to **warn** about known-malicious packages during compile — a +telemetry/warning layer, never a build-blocking gate. -## Firewall cleanup this unblocks +## Workarounds this removes for dependent projects -Once firewall builds against a perry with these changes, its perry config +Once a project builds against a perry with these changes, its perry config collapses to **nothing**: -- delete the `node_modules` offload/restore dance in `scripts/build-perry.ts` - (the `bindingServed` array, `offload()`, `restore()`) — the four packages - resolve to bindings on their own (they are in `NATIVE_MODULES`), so no - relocation is needed; call `perry compile` directly; +- delete any `node_modules` offload/restore dance from the project's build + script — the natively-shimmed packages resolve to bindings on their own (they + are in `NATIVE_MODULES`), so no relocation is needed; call `perry compile` + directly; - delete the entire `perry` block from `package.json` — both - `perry.compilePackages` (`git-up`, `git-url-parse`, `is-ssh`, `lodash`, - `lru-cache`, `node-machine-id`, `parse-path`, `parse-url`, `protocols`, - `zod`) and the mirrored `perry.allow.compilePackages`. Under auto-compile - those reachable deps compile with no list; + `perry.compilePackages` and the mirrored `perry.allow.compilePackages`. Under + auto-compile the reachable deps compile with no list; - expect the informational per-package note for `undici` / `node-forge` / `iovalkey` (all `partial` bindings). It is not an error. Note that `lru-cache` is marked `partial` on this base (its faithful port, #7136, is not yet in `main`); once #7136 lands its covered option surface can move to - `full`. If firewall would rather compile the real `lru-cache` JS than use the - binding, list just `lru-cache` in `compilePackages`. + `full`. A project that would rather compile the real `lru-cache` JS than use + the binding can list just `lru-cache` in `compilePackages`. From 555c9ee3fc0a1db987015a397af75c5833b180b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 12:36:32 +0200 Subject: [PATCH 3/4] fix(compile): make zero-config binding policy fail closed --- .../7137-zero-config-binding-faithfulness.md | 32 +++ .../zero-config-binding-faithfulness.md | 52 ---- .../perry/src/commands/compile/build_cache.rs | 19 ++ .../src/commands/compile/collect_modules.rs | 72 +---- .../collect_modules/binding_faithfulness.rs | 133 +++++++++ .../perry/src/commands/compile/host_config.rs | 95 ++++++- .../perry/src/commands/compile/well_known.rs | 65 ++++- crates/perry/well_known_bindings.toml | 24 +- docs/src/SUMMARY.md | 1 + .../zero-config-and-faithfulness.md | 257 ++++++------------ 10 files changed, 427 insertions(+), 323 deletions(-) create mode 100644 changelog.d/7137-zero-config-binding-faithfulness.md delete mode 100644 changelog.d/zero-config-binding-faithfulness.md create mode 100644 crates/perry/src/commands/compile/collect_modules/binding_faithfulness.rs 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/changelog.d/zero-config-binding-faithfulness.md b/changelog.d/zero-config-binding-faithfulness.md deleted file mode 100644 index 35e6fac6ad..0000000000 --- a/changelog.d/zero-config-binding-faithfulness.md +++ /dev/null @@ -1,52 +0,0 @@ -### Added - -- **Well-known bindings now carry a `compat` faithfulness marker (#466 - follow-up).** Each `[bindings.X]` in `well_known_bindings.toml` may declare - `compat = "full"` (an audited complete drop-in for the npm package's public - API) or `compat = "partial"` (ports only a subset, or not yet audited). - **Absent ⇒ `partial`**, the conservative default — a binding is never treated - as faithful by accident. Audited this release: `dotenv`, `nanoid`, `slugify`, - `uuid` opt in to `full`; the documented subsets `undici` (dispatcher-only), - `node-forge` (PKI-only), and `lru-cache` (numeric store) are marked `partial` - explicitly; every other binding inherits the `partial` default. - -- **Transparency note when perry auto-prefers a partial binding over your - installed copy.** When a bare `import 'X'` is served by a bundled - `perry-ext-X` wrapper that is `partial` **and** you have a `node_modules/X` - copy on disk, perry now prints a one-line note per package pointing at the - `perry.compilePackages` escape hatch. The build still succeeds — this is the - zero-config path — the note just makes the (previously silent) choice visible. - -- **`PERRY_REQUIRE_FAITHFUL_BINDINGS=1`** — opt-in strict mode that turns that - note into a hard error: perry refuses to auto-prefer a `partial` binding over - an installed `node_modules` copy, telling you to either add the package to - `perry.compilePackages` (to AOT-compile the real JavaScript) or accept the - partial binding by unsetting the variable. `full` bindings are unaffected. - Default off ⇒ existing behavior is byte-identical. - -### Changed - -- **Auto-compile is now the default — no `perry.compilePackages` allowlist - required.** A project that does not pin a `perry.compilePackages` value gets - its whole reachable `node_modules` dependency graph compiled automatically. - Perry is a compiler, not a supply-chain gate: faithfully compiling code that - is already installed is not perry's decision to block, and "this package is - not on a list" is no longer a hard error. A dependency that genuinely cannot - be compiled surfaces as an ordinary **compile error** with a diagnostic, not - a policy refusal. Native-shimmed packages still resolve to their bundled - bindings (the wildcard expansion skips them), so bindings keep winning. - - Supply-chain hygiene (pinning, lockfiles, review, dedicated tooling) is the - user's responsibility. Opt out to the old "listed packages only" behavior - with an explicit `perry.compilePackages` array, or `compilePackages: false` / - `[]` to compile nothing and re-arm the V8-free gate. - -### Added - -- **`perry.compilePackages: "auto"` (and `perry.allow.compilePackages: true`).** - Explicit spelling of the new default — sugar for the universal `["*"]` - wildcard (#3527), useful to document intent or to re-enable auto-compile - inside a config that would otherwise constrain it. `"auto"` / `"all"` / - `true` are accepted; an explicit array is unchanged. A universal-trust value - auto-satisfies `perry.allow.compilePackages`, and native-shimmed packages - stay on their bindings exactly as with a literal `"*"`. 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 cf79affe98..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; @@ -58,29 +58,6 @@ use wasm_asset::{is_wasm_asset, synthesize_wasm_stub_module}; const MAX_CROSS_MODULE_INLINE_PRIOR_MODULES: usize = 128; -/// Is there a `node_modules/` directory on disk reachable from the -/// build? Used to tell "perry served this bare import from a bundled -/// binding because there was no local copy" apart from "perry -/// auto-preferred the binding over the user's installed copy" — only the -/// latter warrants the partial-binding note. Walks the ancestor -/// `node_modules` chains of both the entry file and the importing module -/// (mirroring the resolver's own search roots), so pnpm/nested 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); - } - for start in starts { - for node_modules in super::resolve::ancestor_node_modules_dirs(start) { - if node_modules.join(pkg_name).is_dir() { - return true; - } - } - } - false -} - /// Next.js wall 54 (part 2): recursively gather every `*.js` file under `dir` /// (page/route loaders + turbopack chunks). Symlinks are not followed; errors /// reading a subdirectory are skipped silently (best-effort discovery). @@ -1203,48 +1180,7 @@ fn collect_module_one( if import.is_native { import.module_kind = ModuleKind::NativeRust; - - // "Just works" safety marker (#466 follow-up): perry is about to - // serve this bare import from a bundled `perry-ext-*` binding. - // When the binding is a well-known *partial* drop-in AND the user - // actually has a `node_modules/` copy on disk, perry is - // silently auto-preferring an incomplete wrapper over their real - // dependency. Surface that: record it for a post-collect note, and - // — under the strict opt-in — refuse rather than auto-prefer. - let is_bare = !import.source.starts_with('.') && !import.source.starts_with('/'); - if is_bare { - let (pkg_name, _) = parse_package_specifier(&import.source); - if let Some(binding) = super::well_known::lookup_well_known(&pkg_name) { - // Node builtins (net/http/zlib/events/…) are always native - // and have no meaningful npm copy to shadow — skip them. - if !binding.node_builtin - && !binding.is_faithful() - && node_modules_copy_on_disk(&pkg_name, entry_path, &canonical) - { - if std::env::var_os("PERRY_REQUIRE_FAITHFUL_BINDINGS").is_some() { - 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` 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 = pkg_name, - krate = binding.krate, - importer = canonical.display(), - ); - } - ctx.partial_binding_autoprefers.insert(pkg_name); - } - } - } - + 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 b34d9e2e15..6bcb64a666 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -41,6 +41,22 @@ fn compile_packages_means_auto(value: &serde_json::Value) -> bool { } } +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, @@ -58,6 +74,11 @@ pub(super) fn apply_pkg_and_toml_config( // (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 @@ -125,6 +146,7 @@ pub(super) fn apply_pkg_and_toml_config( } } 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) { @@ -532,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()); } @@ -541,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; } _ => {} } @@ -692,14 +719,14 @@ pub(super) fn apply_pkg_and_toml_config( if !compile_packages_explicit { ctx.compile_packages.insert("*".to_string()); } - // Universal trust ⇒ universal allow. Whenever routing includes the `"*"` - // wildcard (from this auto default, the `"auto"` scalar, or a literal - // `["*"]`), the host is trusting its whole graph, so the #497 two-key - // allowlist below must not then block it. Supply-chain review is the - // user's responsibility (lockfiles, pinning, external tooling), not a - // hand-maintained perry allowlist. - if ctx.compile_packages.iter().any(|p| p == "*") - && !ctx.allow_compile_packages.iter().any(|p| p == "*") + // 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()); } @@ -784,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!( @@ -1094,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/well_known.rs b/crates/perry/src/commands/compile/well_known.rs index 432c3b3561..40df2747fa 100644 --- a/crates/perry/src/commands/compile/well_known.rs +++ b/crates/perry/src/commands/compile/well_known.rs @@ -102,13 +102,30 @@ 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 { - // Aliases inherit the faithfulness of their target (resolved by - // the caller when needed); the row's own `compat` still applies - // as a conservative floor. - matches!(self.compat, BindingCompat::Full) + 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 /// shape as an upstream reference submodule's `.gitmodules` block /// (pinned release + content hash + review stamp), carried as toml @@ -278,8 +295,7 @@ 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 compat = BindingCompat::from_toml(entry_table.get("compat").and_then(|v| v.as_str())); let upstream = match entry_table.get("upstream") { None => None, @@ -419,8 +435,7 @@ mod tests { } /// The shipped table's audited posture: documented-subset wrappers - /// stay `Partial` (never auto-preferred silently), and the small - /// audited pure-ports opt in to `Full`. + /// 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"] { @@ -434,17 +449,45 @@ mod tests { } #[test] - fn shipped_audited_bindings_are_full() { + 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::Full, - "{name} is audited as a full drop-in (compat=full)" + 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 91beb39fb5..53437d9591 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -43,9 +43,10 @@ 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's surface is the small, -# fully-ported `config()`/`parse()` pair. -compat = "full" +# 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" @@ -58,9 +59,9 @@ date = "2026-07-30" crate = "perry-ext-nanoid" lib = "perry_ext_nanoid" tracking = "#466" -# Full drop-in: the complete `nanoid()` / `customAlphabet` / `urlAlphabet` -# surface is ported. -compat = "full" +# 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" @@ -73,8 +74,9 @@ date = "2026-07-30" crate = "perry-ext-uuid" lib = "perry_ext_uuid" tracking = "#466" -# Full drop-in: v1/v3/v4/v5/v7 generators + parse/stringify/validate/version. -compat = "full" +# 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" @@ -87,9 +89,9 @@ date = "2026-07-30" crate = "perry-ext-slugify" lib = "perry_ext_slugify" tracking = "#466" -# Full drop-in: the single `slugify(str, opts)` entry point with the -# documented option bag (replacement/remove/lower/strict/locale/trim). -compat = "full" +# 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" 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 index 810e87333b..137bbba77b 100644 --- a/docs/src/native-libraries/zero-config-and-faithfulness.md +++ b/docs/src/native-libraries/zero-config-and-faithfulness.md @@ -1,172 +1,93 @@ -# Zero-config bindings & the faithfulness marker - -Status: **design note + landed increments.** This note records (a) the -current resolution behavior for bare npm imports that have a bundled -`perry-ext-*` binding, (b) the faithfulness (`compat`) marker landed -alongside it, and (c) the two policy decisions that are deliberately left -to a maintainer rather than flipped unilaterally. - -## Background: the friction we set out to remove - -A downstream command-line application compiled its CLIs with two -hand-maintained workarounds: - -1. it kept a `perry.compilePackages` list of every AOT-compiled dependency, and -2. its build script physically moved - `node_modules/{undici,node-forge,iovalkey,dotenv}` aside for the duration - of each `perry compile`, because a bare `node_modules/` copy used to - shadow perry's bundled binding and trip the V8-free "JavaScript runtime" - gate. - -## Current behavior (map) - -Resolution precedence for a bare `import 'X'` is documented at the top of -`crates/perry/well_known_bindings.toml` and implemented across: - -- **The native short-circuit** — `perry_hir::is_native_module` ( - `crates/perry-hir/src/ir/constants.rs:439`) consults - `perry_api_manifest::NATIVE_MODULES` (`crates/perry-api-manifest/src/entries.rs:30`). - When `X` is in that list and **not** in `perry.compilePackages`, the import is - marked native (`crates/perry/src/commands/compile/collect_modules.rs`, the - `if import.is_native { … }` branch) and routed to the bundled binding — - perry never walks `node_modules/X`. -- **File resolution** — `resolve::resolve_import` - (`crates/perry/src/commands/compile/resolve.rs:1288`) returns `None` for a - native module (line 1303), so a `node_modules/X` copy is only consulted when - `X` is **not** native (or has been opted into `compilePackages`). -- **The V8-free gate** — `enforce_js_runtime_gate` - (`crates/perry/src/commands/compile/bootstrap.rs:266`) hard-errors when the - build reaches a `node_modules` JS module that is neither native nor in - `compilePackages`. This is the intentional supply-chain boundary: - AOT-compiling arbitrary dependency JS requires an explicit trust opt-in. - -**Key finding:** `undici` (#7032), `node-forge` (#7033), `iovalkey`, and -`dotenv` were all added to `NATIVE_MODULES` in the last few days, so on current -`main` **all four already resolve to their bundled bindings even when a -`node_modules/` copy is present** — verified empirically (`Found N -module(s): N native, 0 JavaScript`, no error, no `node_modules` move). The -"binding gets shadowed" problem that offload worked around is already gone. -That offload dance, and omitting these packages from `compilePackages`, are -now **stale** (see the cleanup section below). - -That reframes the original ask. The literal "convert today's hard error into a -working binding build" is already the behavior for anything in `NATIVE_MODULES` -(which is kept in lock-step with the well-known table). What was missing is the -**safety and transparency** around it: perry auto-prefers *every* binding — -including the documented **subset** wrappers (`undici`, `node-forge`, -`lru-cache`) — silently, with no signal that the wrapper is not a full drop-in. - -## Landed: the `compat` faithfulness marker - -`well_known_bindings.toml` entries may now declare: +# 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.dotenv] -crate = "perry-ext-dotenv" -lib = "perry_ext_dotenv" -compat = "full" # audited complete drop-in — safe to auto-prefer silently +[bindings.example] +crate = "perry-ext-example" +lib = "perry_ext_example" +compat = "partial" ``` -- `compat = "full"` — audited complete drop-in for the package's public API. -- `compat = "partial"` — ports a subset, or not yet audited. -- **absent ⇒ `partial`** (conservative; a binding never becomes faithful by - accident). - -Parsed into `BindingCompat` on `WellKnownBinding` -(`crates/perry/src/commands/compile/well_known.rs`) with an `is_faithful()` -helper. Audited this pass: `dotenv`, `nanoid`, `slugify`, `uuid` → `full`; -`undici`, `node-forge`, `lru-cache` → explicit `partial` with rationale -comments; everything else left at the `partial` default. - -Two consumers make the marker load-bearing, **both additive** (default build -byte-identical): - -1. **Transparency note.** When a bare import is served by a `partial` binding - *and* a `node_modules/` copy is on disk, perry prints one note per - package after collection - (`crates/perry/src/commands/compile/run_pipeline.rs`), pointing at the - `compilePackages` escape hatch. `full` bindings and imports with no local - copy stay silent. -2. **Strict opt-in.** `PERRY_REQUIRE_FAITHFUL_BINDINGS=1` turns that note into a - hard error — perry refuses to auto-prefer a `partial` binding over an - installed copy. This is the provable "an unfaithful binding does *not* - silently win" behavior; it is **off by default** so nothing currently - relying on a partial binding breaks. - -## Landed: auto-compile is the default - -A project that does not pin a `perry.compilePackages` value now gets its whole -reachable `node_modules` graph compiled automatically — the host_config loader -injects the universal `"*"` (and auto-satisfies the `allow.compilePackages` -two-key check) when no explicit value is present. The existing #3527 wildcard -expansion then materializes `"*"` into concrete installed package names, -skipping natively-shimmed packages so bindings keep winning. - -`perry.compilePackages: "auto"` (also `"all"` / `true`) and -`perry.allow.compilePackages: true` are the explicit spelling of that default — -useful to document intent or to re-enable auto-compile inside a config that -would otherwise constrain it. - -Opt out to the old "listed packages only" posture with an explicit -`compilePackages` array, or `compilePackages: false` / `[]` to compile nothing -and re-arm the V8-free gate. - -## Policy decisions left to the maintainer - -These are **not** flipped here — they change the security/behavior contract and -want a human sign-off. - -### 1. Should auto-preference of *partial* bindings require opt-in? - -Today perry auto-prefers a partial binding (`undici`, `node-forge`, …) over a -user's real `node_modules` copy silently. The conservative alternative is to -make `PERRY_REQUIRE_FAITHFUL_BINDINGS` behavior the **default** — a partial -binding would error unless the user opts into either the binding or -`compilePackages`. That is *safer* (no silent subset substitution) but **would -break downstream consumers today**, because a dependent application can rely on -the `undici` and `node-forge` bindings being auto-preferred, and those wrappers -are, by design, subsets. So the two cannot both be true at once: - -> "the four natively-shimmed packages just work with zero config" **and** "a -> partial binding refuses to auto-prefer" - -are mutually exclusive while `undici`/`node-forge` remain `partial`. The honest -paths forward are: (a) keep auto-prefer-with-note as the default (current -choice) and let strict mode be opt-in; (b) complete the `undici`/`node-forge` -wrappers to `full` and *then* make strict the default; or (c) make strict the -default but ship a curated allowlist of "trusted partial" bindings. Recommend -(a) now, (b) as the target. - -### 2. Should auto-compile be the default? — RESOLVED: yes. - -Resolved by the owner: **auto-compile is the default.** Perry is a compiler, -not a supply-chain gate — faithfully compiling code that is already in -`node_modules` is not perry's call to block, and "this package is not on a -list" is not an error. Supply-chain hygiene (pinning, lockfiles, review, -dedicated tooling) is the user's responsibility, not a hand-maintained perry -allowlist. The V8-free gate is retained only as an opt-in constraint -(`compilePackages: []` / an explicit array) and for genuinely unsupported -situations, not for "unlisted". - -Possible future layer (not built): perry could integrate a package-advisory -data source to **warn** about known-malicious packages during compile — a -telemetry/warning layer, never a build-blocking gate. - -## Workarounds this removes for dependent projects - -Once a project builds against a perry with these changes, its perry config -collapses to **nothing**: - -- delete any `node_modules` offload/restore dance from the project's build - script — the natively-shimmed packages resolve to bindings on their own (they - are in `NATIVE_MODULES`), so no relocation is needed; call `perry compile` - directly; -- delete the entire `perry` block from `package.json` — both - `perry.compilePackages` and the mirrored `perry.allow.compilePackages`. Under - auto-compile the reachable deps compile with no list; -- expect the informational per-package note for `undici` / `node-forge` / - `iovalkey` (all `partial` bindings). It is not an error. Note that - `lru-cache` is marked `partial` on this base (its faithful port, #7136, is - not yet in `main`); once #7136 lands its covered option surface can move to - `full`. A project that would rather compile the real `lru-cache` JS than use - the binding can list just `lru-cache` in `compilePackages`. +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. From 0d1ee4522614c95f82cff56e12dd7838020c19fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 12:37:25 +0200 Subject: [PATCH 4/4] chore: bump version to 0.5.1275 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) 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"