diff --git a/Cargo.lock b/Cargo.lock index 0648556b21..ebcce9bbe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6146,13 +6146,6 @@ dependencies = [ "perry-ffi", ] -[[package]] -name = "perry-ext-slugify" -version = "0.5.1519" -dependencies = [ - "perry-ffi", -] - [[package]] name = "perry-ext-streams" version = "0.5.1519" diff --git a/Cargo.toml b/Cargo.toml index dbb5acc71a..2623e8a948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ members = [ "crates/perry-ext-dotenv", "crates/perry-ext-nanoid", "crates/perry-ext-uuid", - "crates/perry-ext-slugify", "crates/perry-ext-bcrypt", "crates/perry-ext-argon2", "crates/perry-ext-jsonwebtoken", @@ -460,7 +459,6 @@ perry-ffi = { path = "crates/perry-ffi", version = "0.5.1011" } perry-ext-dotenv = { path = "crates/perry-ext-dotenv" } perry-ext-nanoid = { path = "crates/perry-ext-nanoid" } perry-ext-uuid = { path = "crates/perry-ext-uuid" } -perry-ext-slugify = { path = "crates/perry-ext-slugify" } perry-ext-bcrypt = { path = "crates/perry-ext-bcrypt" } perry-ext-argon2 = { path = "crates/perry-ext-argon2" } perry-ext-jsonwebtoken = { path = "crates/perry-ext-jsonwebtoken" } diff --git a/changelog.d/5716-native-binding-governance.md b/changelog.d/5716-native-binding-governance.md index 446b99b2bf..e8af6d1b22 100644 --- a/changelog.d/5716-native-binding-governance.md +++ b/changelog.d/5716-native-binding-governance.md @@ -2,5 +2,7 @@ Defined an enforceable policy for bundled native bindings: ordinary JavaScript and TypeScript packages now have recorded upstream-source migration targets, native/domain integrations have external-package targets, and shared runtime APIs have explicit retain/consolidate decisions. Release builds consume -the governed inventory, while existing compatibility shims remain bundled until -their documented migration gates pass. +the governed inventory. The first completed migration removes both native +`slugify` implementations and compiles installed `slugify@1.6.9` source through +default package routing, with a Node-parity E2E covering its full option and +extension behavior. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index a6f47efd3a..20b4a2f5a0 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -48,7 +48,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "dotenv/config", // dotenv's auto-load-on-import subpath "jsonwebtoken", // JWT sign/verify "nanoid", // compact URL-safe ID generation - "slugify", // string → URL slug "validator", // string validators/sanitizers "ethers", // Ethereum library (utils/wallet/ABI) "mongodb", // MongoDB driver diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index eff44bac89..bdcc7b413a 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1291,39 +1291,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ }], TypeSpec::String, ), - // Second arg is npm slugify's replacement-or-options overload - // (string | { replacement, lower, strict, trim }) — Any, matching - // the NA_JSV dispatch slot. - method_sig( - "slugify", - "default", - false, - None, - &[ - p_str("p0"), - ParamSpec::Named { - name: "p1", - ty: TypeSpec::Any, - optional: true, - }, - ], - TypeSpec::String, - ), - method_sig( - "slugify", - "slugify", - false, - None, - &[ - p_str("p0"), - ParamSpec::Named { - name: "p1", - ty: TypeSpec::Any, - optional: true, - }, - ], - TypeSpec::String, - ), method_sig( "validator", "isEmail", diff --git a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs index 433b300921..18a86f86a3 100644 --- a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs +++ b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs @@ -157,34 +157,6 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_STR, }, - // ========== slugify ========== - // Second arg is npm slugify's replacement-or-options overload: a - // plain string ('_') OR an options object ({ replacement, lower, - // strict, trim }). It must cross as raw NaN-box bits (NA_JSV) so - // the runtime can distinguish the two — the old NA_STR coercion - // JSON-stringified the object and its first char '{' became the - // separator ("hello{world"). Missing arg pads to TAG_UNDEFINED → - // runtime defaults ("-" separator, no lower/strict, trim). - // "default" for `import slugify from 'slugify'; slugify(s)` (HIR emits method:"default"). - // "slugify" for `import { slugify } from 'slugify'; slugify(s)` (named import). - NativeModSig { - module: "slugify", - has_receiver: false, - method: "default", - class_filter: None, - runtime: "js_slugify_with_options", - args: &[NA_STR, NA_JSV], - ret: NR_STR, - }, - NativeModSig { - module: "slugify", - has_receiver: false, - method: "slugify", - class_filter: None, - runtime: "js_slugify_with_options", - args: &[NA_STR, NA_JSV], - ret: NR_STR, - }, // ========== validator ========== NativeModSig { module: "validator", diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs index 074d402b81..c600e17d87 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs @@ -50,7 +50,7 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) { declare_streams_events(module); // Date, String, Object, Math, Atomics, Number, JSON, Map/Set, Error, // Promise, text encoding, closures, NaN-boxing, GC, console, fetch, net, - // performance, async-step, slugify, class registration, runtime init/ + // performance, async-step, class registration, runtime init/ // module-loader, well-known Symbol hooks, Object.groupBy, JSX adapter. declare_core(module); } diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs index 0288014f8c..0647ceee3f 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs @@ -1,7 +1,7 @@ //! Core language / runtime FFI declarations (extracted from stdlib_ffi.rs): //! Date, String, Object, Math, Atomics, Number, JSON, Map/Set, Error, Promise, //! text encoding, closures, NaN-boxing, GC, console, fetch, net, performance, -//! async-step, slugify, class registration, runtime init/module-loader, +//! async-step, class registration, runtime init/module-loader, //! well-known Symbol hooks, Object.groupBy, JSX runtime adapter. use crate::module::LlModule; @@ -309,10 +309,6 @@ pub(crate) fn declare_core(module: &mut LlModule) { &[DOUBLE, DOUBLE, DOUBLE], ); - // ========== Slugify ========== - module.declare_function("js_slugify", I64, &[I64]); - module.declare_function("js_slugify_strict", I64, &[I64]); - // ========== Class registration ========== module.declare_function("js_register_class_getter", VOID, &[I64, I64, I64, I64]); // Refs #486: per-class setter dispatch — see object.rs::js_register_class_setter. diff --git a/crates/perry-ext-slugify/Cargo.toml b/crates/perry-ext-slugify/Cargo.toml deleted file mode 100644 index b77e8a0cdc..0000000000 --- a/crates/perry-ext-slugify/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "perry-ext-slugify" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for the npm `slugify` package — uses only `perry-ffi`. Fourth port under #466 Phase 5." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-slugify/src/lib.rs b/crates/perry-ext-slugify/src/lib.rs deleted file mode 100644 index 12e7e3a0d8..0000000000 --- a/crates/perry-ext-slugify/src/lib.rs +++ /dev/null @@ -1,395 +0,0 @@ -//! Native bindings for the npm `slugify` package. -//! -//! Functionally identical to `crates/perry-stdlib/src/slugify.rs` — -//! both follow simov/slugify's actual algorithm: -//! -//! 1. per-char charMap substitution (case-preserving: 'É' → 'E'); -//! 2. a mapped char equal to `options.replacement` becomes a space; -//! 3. chars outside the default keep-set `[\w\s$*_+~.()'"!\-:@]` are -//! removed (the `remove` regex option is not supported); -//! 4. `strict` strips everything but `[A-Za-z0-9\s]`; -//! 5. `trim` (default true) trims whitespace; -//! 6. whitespace runs collapse to the (full, possibly multi-char) -//! replacement string; -//! 7. `lower` lowercases the final slug. -//! -//! The second argument mirrors npm slugify's overloads: a plain string -//! (the replacement) or an options object `{ replacement, lower, -//! strict, trim }`. It crosses the FFI as raw NaN-box bits (i64) so -//! this wrapper can distinguish string / object / undefined — the old -//! coerce-to-string ABI is what garbled `slugify(s, { lower: true })` -//! into `hello{world` (the JSON-stringified object's first char `{` -//! became the separator). -//! -//! Depends only on [`perry_ffi`] plus three C-ABI runtime symbols -//! (declared below, resolved at final link — the perry-ext-events -//! pattern for by-name object field reads). - -use perry_ffi::{alloc_string, read_string, JsString, JsValue, ObjectHeader, StringHeader}; - -extern "C" { - /// perry-runtime: read an object field by string key, returning the - /// raw NaN-boxed JSValue bits as f64 (undefined tag when absent). - fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64; - /// perry-runtime: JS truthiness probe for a NaN-boxed value. - fn js_is_truthy(value: f64) -> i32; - /// perry-runtime: extract the StringHeader pointer from any - /// string-tagged NaN-boxed value. - fn js_get_string_pointer_unified(value: f64) -> i64; -} - -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - -/// Subset of npm slugify's charMap. Case-preserving, may expand to -/// multiple chars ('ß' → "ss", '&' → "and") — exactly like the npm map. -fn char_map(c: char) -> Option<&'static str> { - Some(match c { - 'À' => "A", - 'Á' => "A", - 'Â' => "A", - 'Ã' => "A", - 'Ä' => "A", - 'Å' => "A", - 'Æ' => "AE", - 'Ç' => "C", - 'È' => "E", - 'É' => "E", - 'Ê' => "E", - 'Ë' => "E", - 'Ì' => "I", - 'Í' => "I", - 'Î' => "I", - 'Ï' => "I", - 'Ð' => "D", - 'Ñ' => "N", - 'Ò' => "O", - 'Ó' => "O", - 'Ô' => "O", - 'Õ' => "O", - 'Ö' => "O", - 'Ø' => "O", - 'Ù' => "U", - 'Ú' => "U", - 'Û' => "U", - 'Ü' => "U", - 'Ý' => "Y", - 'Þ' => "TH", - 'ß' => "ss", - 'à' => "a", - 'á' => "a", - 'â' => "a", - 'ã' => "a", - 'ä' => "a", - 'å' => "a", - 'æ' => "ae", - 'ç' => "c", - 'è' => "e", - 'é' => "e", - 'ê' => "e", - 'ë' => "e", - 'ì' => "i", - 'í' => "i", - 'î' => "i", - 'ï' => "i", - 'ð' => "d", - 'ñ' => "n", - 'ò' => "o", - 'ó' => "o", - 'ô' => "o", - 'õ' => "o", - 'ö' => "o", - 'ø' => "o", - 'ù' => "u", - 'ú' => "u", - 'û' => "u", - 'ü' => "u", - 'ý' => "y", - 'þ' => "th", - 'ÿ' => "y", - 'Ÿ' => "Y", - 'Œ' => "OE", - 'œ' => "oe", - '&' => "and", - '|' => "or", - '<' => "less", - '>' => "greater", - '©' => "(c)", - '®' => "(r)", - '™' => "tm", - _ => return None, - }) -} - -/// JS `\s` (non-unicode regex flag): ASCII whitespace + the Unicode -/// space separators the npm regexes match. -fn js_space_char(c: char) -> bool { - matches!( - c, - ' ' | '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | '\u{a0}' | '\u{1680}' | '\u{2000}' - ..='\u{200a}' - | '\u{2028}' - | '\u{2029}' - | '\u{202f}' - | '\u{205f}' - | '\u{3000}' - | '\u{feff}' - ) -} - -fn default_keep(c: char) -> bool { - // JS `[\w\s$*_+~.()'"!\-:@]` with the default (ASCII) `\w`. - c.is_ascii_alphanumeric() - || c == '_' - || js_space_char(c) - || matches!( - c, - '$' | '*' | '+' | '~' | '.' | '(' | ')' | '\'' | '"' | '!' | '-' | ':' | '@' - ) -} - -/// Parsed slugify options (npm surface; `remove` / `locale` unsupported). -struct SlugifyOptions { - /// The replacement string for whitespace runs. npm defaults it to - /// "-" BEFORE the per-char `appendChar === replacement` check, so a - /// literal '-' in the input collapses with adjacent whitespace even - /// when no replacement was supplied (slugify('a - b') === 'a-b'). - replacement: String, - lower: bool, - strict: bool, - trim: bool, -} - -impl Default for SlugifyOptions { - fn default() -> Self { - SlugifyOptions { - replacement: "-".to_string(), - lower: false, - strict: false, - trim: true, - } - } -} - -/// Core algorithm — mirrors npm slugify's reduce + post-passes ordering. -fn slugify_npm(input: &str, opts: &SlugifyOptions) -> String { - let mut slug = String::with_capacity(input.len()); - let mut buf = [0u8; 4]; - for ch in input.chars() { - let mapped: &str = match char_map(ch) { - Some(m) => m, - None => ch.encode_utf8(&mut buf), - }; - // `if (appendChar === replacement) appendChar = ' '` — with the - // already-defaulted replacement. - let effective: &str = if mapped == opts.replacement { - " " - } else { - mapped - }; - for c in effective.chars() { - if default_keep(c) { - slug.push(c); - } - } - } - - if opts.strict { - slug.retain(|c| c.is_ascii_alphanumeric() || js_space_char(c)); - } - if opts.trim { - slug = slug.trim_matches(js_space_char).to_string(); - } - - // replace(/\s+/g, replacement) - let mut out = String::with_capacity(slug.len()); - let mut in_ws = false; - for c in slug.chars() { - if js_space_char(c) { - if !in_ws { - out.push_str(&opts.replacement); - in_ws = true; - } - } else { - out.push(c); - in_ws = false; - } - } - - if opts.lower { - out.to_lowercase() - } else { - out - } -} - -unsafe fn string_from_bits(bits: u64) -> Option { - let ptr = js_get_string_pointer_unified(f64::from_bits(bits)) as *mut StringHeader; - if ptr.is_null() { - return None; - } - read_string(JsString::from_raw(ptr)).map(String::from) -} - -/// Decode the second slugify argument from raw NaN-box bits. -unsafe fn options_from_bits(options_bits: i64) -> SlugifyOptions { - let mut opts = SlugifyOptions::default(); - let bits = options_bits as u64; - let jv = JsValue::from_bits(bits); - - if jv.is_any_string() { - if let Some(s) = string_from_bits(bits) { - opts.replacement = s; - } - return opts; - } - - if !jv.is_pointer() { - return opts; - } - let obj = jv.as_pointer::(); - if obj.is_null() || (obj as usize) < 0x100000 { - return opts; - } - - let field = |name: &str| -> f64 { - let key = alloc_string(name); - js_object_get_field_by_name_f64(obj, key.as_raw()) - }; - - let replacement = field("replacement"); - if JsValue::from_bits(replacement.to_bits()).is_any_string() { - if let Some(s) = string_from_bits(replacement.to_bits()) { - opts.replacement = s; - } - } - opts.lower = js_is_truthy(field("lower")) != 0; - opts.strict = js_is_truthy(field("strict")) != 0; - // npm: `if (options.trim !== false) slug = slug.trim()` — absent - // (undefined) means trim. - let trim = field("trim"); - opts.trim = JsValue::from_bits(trim.to_bits()).is_undefined() || js_is_truthy(trim) != 0; - opts -} - -/// `slugify(string)` — default slug with `-` separator (case-preserved, -/// matching npm). -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_slugify(input_ptr: *const StringHeader) -> *mut StringHeader { - js_slugify_with_options(input_ptr, TAG_UNDEFINED as i64) -} - -/// `slugify(string, replacementOrOptions)` — `options_bits` carries the -/// second JS argument as raw NaN-box bits: string → replacement, object -/// → `{ replacement, lower, strict, trim }`, undefined → defaults. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`; -/// `options_bits` must be valid NaN-box bits. -#[no_mangle] -pub unsafe extern "C" fn js_slugify_with_options( - input_ptr: *const StringHeader, - options_bits: i64, -) -> *mut StringHeader { - let input_handle = JsString::from_raw(input_ptr as *mut StringHeader); - let Some(input) = read_string(input_handle) else { - return std::ptr::null_mut(); - }; - let opts = options_from_bits(options_bits); - alloc_string(&slugify_npm(input, &opts)).as_raw() -} - -/// `slugify(string, { strict: true })` — legacy strict entry point. -/// -/// # Safety -/// -/// `input_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_slugify_strict(input_ptr: *const StringHeader) -> *mut StringHeader { - let handle = JsString::from_raw(input_ptr as *mut StringHeader); - let Some(input) = read_string(handle) else { - return std::ptr::null_mut(); - }; - let opts = SlugifyOptions { - strict: true, - ..SlugifyOptions::default() - }; - alloc_string(&slugify_npm(input, &opts)).as_raw() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_preserves_case_like_npm() { - let opts = SlugifyOptions::default(); - assert_eq!(slugify_npm("Hello World", &opts), "Hello-World"); - } - - #[test] - fn lower_option_lowercases() { - let opts = SlugifyOptions { - lower: true, - ..SlugifyOptions::default() - }; - assert_eq!(slugify_npm("Hello World", &opts), "hello-world"); - } - - #[test] - fn string_replacement_is_full_string() { - let opts = SlugifyOptions { - replacement: "__".into(), - ..SlugifyOptions::default() - }; - assert_eq!(slugify_npm("foo bar", &opts), "foo__bar"); - } - - #[test] - fn strict_removes_non_alnum() { - let opts = SlugifyOptions { - strict: true, - lower: true, - ..SlugifyOptions::default() - }; - assert_eq!( - slugify_npm("Hello, World! (2024)", &opts), - "hello-world-2024" - ); - } - - #[test] - fn accents_fold_case_preserving() { - let opts = SlugifyOptions::default(); - assert_eq!(slugify_npm("Crème Brûlée", &opts), "Creme-Brulee"); - } - - #[test] - fn ampersand_maps_to_and() { - let opts = SlugifyOptions { - lower: true, - ..SlugifyOptions::default() - }; - assert_eq!(slugify_npm("Foo & Bar", &opts), "foo-and-bar"); - } - - #[test] - fn default_dash_collapses_with_whitespace() { - let opts = SlugifyOptions::default(); - assert_eq!(slugify_npm("a - b", &opts), "a-b"); - assert_eq!(slugify_npm("a-b", &opts), "a-b"); - } - - #[test] - fn trim_false_keeps_edges_as_separators() { - let opts = SlugifyOptions { - trim: false, - ..SlugifyOptions::default() - }; - assert_eq!(slugify_npm(" foo bar ", &opts), "-foo-bar-"); - } -} diff --git a/crates/perry-ffi/README.md b/crates/perry-ffi/README.md index 20a22571ac..d29e096005 100644 --- a/crates/perry-ffi/README.md +++ b/crates/perry-ffi/README.md @@ -41,4 +41,4 @@ The crate ships its own semver, currently tracking Perry's minor version (`0.5.x ## Surface -See [`lib.rs`](src/lib.rs) for the full list. Today's surface covers what the smallest stdlib wrappers (`dotenv`, `nanoid`, `uuid`, `slugify`) need: allocate a JS string, read a JS string back, register a GC root scanner, hand objects/arrays/closures in and out of FFI calls. +See [`lib.rs`](src/lib.rs) for the full list. Today's surface covers what the smallest stdlib wrappers (`dotenv`, `nanoid`, `uuid`) need: allocate a JS string, read a JS string back, register a GC root scanner, hand objects/arrays/closures in and out of FFI calls. diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index bf3edbc0ed..533c81fb8e 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -32,7 +32,7 @@ //! # Today's surface (v0.5.x) //! //! Just enough to port the smallest stdlib wrappers (`dotenv`, -//! `nanoid`, `uuid`, `slugify`) — read a string, allocate a string. +//! `nanoid`, `uuid`) — read a string, allocate a string. //! The minimal set is intentional: every helper added is a forever //! commitment, and we'd rather grow it as real wrappers demand than //! over-design up front. diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 1f88198217..d02f02730a 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -20,7 +20,7 @@ crate-type = ["rlib"] default = ["full"] # Full stdlib - everything included -full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "validation", "net", "tls", "bundled-dotenv", "bundled-slugify", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-commander", "bundled-streams"] +full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "validation", "net", "tls", "bundled-dotenv", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-commander", "bundled-streams"] # Minimal core - just what's needed for basic programs core = [] @@ -33,12 +33,6 @@ core = [] # don't import dotenv pay nothing for this either way. bundled-dotenv = [] -# In-tree implementation of `slugify` — same gating contract as -# `bundled-dotenv` (#466 Phase 4 step 2). Default-on through -# `default = ["full"]`; the well-known flip strips this when -# `import 'slugify'` resolves to perry-ext-slugify instead. -bundled-slugify = [] - # In-tree implementation of `lru-cache`. Default-on through # `default = ["full"]`; flipped to perry-ext-lru-cache by the # well-known table (#466 Phase 4). Pulls the `lru` crate dep so diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 7af014eef6..d51b98f9fb 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -70,11 +70,6 @@ pub mod moment; #[cfg(feature = "bundled-lru-cache")] pub mod lru_cache; pub mod readline; -// slugify is feature-gated as of v0.5.536 so the well-known bindings -// flip can route `import 'slugify'` to perry-ext-slugify cleanly. -// Default-on through `default = ["full"]`. -#[cfg(feature = "bundled-slugify")] -pub mod slugify; // string_decoder — issue #848. Native StringDecoder with real `write` / // `end` methods + `lastNeed` / `lastTotal` / `lastChar` getters wired // through HANDLE_METHOD_DISPATCH / HANDLE_PROPERTY_DISPATCH. @@ -111,8 +106,6 @@ pub use lru_cache::*; pub use moment::*; pub use querystring::*; pub use readline::*; -#[cfg(feature = "bundled-slugify")] -pub use slugify::*; pub use string_decoder::*; pub use vm::*; pub use worker_threads::*; diff --git a/crates/perry-stdlib/src/slugify.rs b/crates/perry-stdlib/src/slugify.rs deleted file mode 100644 index 3ae8d850d5..0000000000 --- a/crates/perry-stdlib/src/slugify.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! Slugify module (slugify compatible) -//! -//! Native implementation of the 'slugify' npm package (simov/slugify), -//! following the package's actual algorithm: -//! -//! 1. per-char charMap substitution (case-preserving: 'É' → 'E'); -//! 2. a mapped char equal to `options.replacement` becomes a space; -//! 3. chars outside the default keep-set `[\w\s$*_+~.()'"!\-:@]` are -//! removed (the `remove` regex option is not supported); -//! 4. `strict` strips everything but `[A-Za-z0-9\s]`; -//! 5. `trim` (default true) trims whitespace; -//! 6. whitespace runs collapse to the (full, possibly multi-char) -//! replacement string; -//! 7. `lower` lowercases the final slug. -//! -//! The second argument mirrors npm slugify's overloads: a plain string -//! (the replacement) or an options object `{ replacement, lower, -//! strict, trim }`. It crosses the FFI as raw NaN-box bits (i64) so the -//! runtime can distinguish string / object / undefined — passing it as -//! a coerced string is what garbled `slugify(s, { lower: true })` into -//! `hello{world` (the JSON-stringified object's first char `{` became -//! the separator). - -use perry_runtime::{js_string_from_bytes, JSValue, ObjectHeader, StringHeader}; - -use crate::common::string_from_header; - -/// Helper to extract string from StringHeader pointer -/// Subset of npm slugify's charMap. Case-preserving, may expand to -/// multiple chars ('ß' → "ss", '&' → "and") — exactly like the npm map. -pub(crate) fn char_map(c: char) -> Option<&'static str> { - Some(match c { - 'À' => "A", - 'Á' => "A", - 'Â' => "A", - 'Ã' => "A", - 'Ä' => "A", - 'Å' => "A", - 'Æ' => "AE", - 'Ç' => "C", - 'È' => "E", - 'É' => "E", - 'Ê' => "E", - 'Ë' => "E", - 'Ì' => "I", - 'Í' => "I", - 'Î' => "I", - 'Ï' => "I", - 'Ð' => "D", - 'Ñ' => "N", - 'Ò' => "O", - 'Ó' => "O", - 'Ô' => "O", - 'Õ' => "O", - 'Ö' => "O", - 'Ø' => "O", - 'Ù' => "U", - 'Ú' => "U", - 'Û' => "U", - 'Ü' => "U", - 'Ý' => "Y", - 'Þ' => "TH", - 'ß' => "ss", - 'à' => "a", - 'á' => "a", - 'â' => "a", - 'ã' => "a", - 'ä' => "a", - 'å' => "a", - 'æ' => "ae", - 'ç' => "c", - 'è' => "e", - 'é' => "e", - 'ê' => "e", - 'ë' => "e", - 'ì' => "i", - 'í' => "i", - 'î' => "i", - 'ï' => "i", - 'ð' => "d", - 'ñ' => "n", - 'ò' => "o", - 'ó' => "o", - 'ô' => "o", - 'õ' => "o", - 'ö' => "o", - 'ø' => "o", - 'ù' => "u", - 'ú' => "u", - 'û' => "u", - 'ü' => "u", - 'ý' => "y", - 'þ' => "th", - 'ÿ' => "y", - 'Ÿ' => "Y", - 'Œ' => "OE", - 'œ' => "oe", - '&' => "and", - '|' => "or", - '<' => "less", - '>' => "greater", - '©' => "(c)", - '®' => "(r)", - '™' => "tm", - _ => return None, - }) -} - -/// JS `\s` (non-unicode regex flag): ASCII whitespace + the Unicode -/// space separators the npm regexes match. -pub(crate) fn js_space_char(c: char) -> bool { - matches!( - c, - ' ' | '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | '\u{a0}' | '\u{1680}' | '\u{2000}' - ..='\u{200a}' - | '\u{2028}' - | '\u{2029}' - | '\u{202f}' - | '\u{205f}' - | '\u{3000}' - | '\u{feff}' - ) -} - -fn default_keep(c: char) -> bool { - // JS `[\w\s$*_+~.()'"!\-:@]` with the default (ASCII) `\w`. - c.is_ascii_alphanumeric() - || c == '_' - || js_space_char(c) - || matches!( - c, - '$' | '*' | '+' | '~' | '.' | '(' | ')' | '\'' | '"' | '!' | '-' | ':' | '@' - ) -} - -/// Parsed slugify options (mirrors npm's option surface; `remove` and -/// `locale` are not supported). -pub(crate) struct SlugifyOptions { - /// The replacement string for whitespace runs. npm defaults it to - /// "-" BEFORE the per-char `appendChar === replacement` check, so a - /// literal '-' in the input collapses with adjacent whitespace even - /// when no replacement was supplied (slugify('a - b') === 'a-b'). - pub replacement: String, - pub lower: bool, - pub strict: bool, - pub trim: bool, -} - -impl Default for SlugifyOptions { - fn default() -> Self { - SlugifyOptions { - replacement: "-".to_string(), - lower: false, - strict: false, - trim: true, - } - } -} - -/// Core algorithm shared by every entry point. Mirrors npm slugify's -/// reduce + post-passes ordering exactly. -pub(crate) fn slugify_npm(input: &str, opts: &SlugifyOptions) -> String { - let mut slug = String::with_capacity(input.len()); - let mut buf = [0u8; 4]; - for ch in input.chars() { - let mapped: &str = match char_map(ch) { - Some(m) => m, - None => ch.encode_utf8(&mut buf), - }; - // `if (appendChar === replacement) appendChar = ' '` — with the - // already-defaulted replacement. - let effective: &str = if mapped == opts.replacement { - " " - } else { - mapped - }; - for c in effective.chars() { - if default_keep(c) { - slug.push(c); - } - } - } - - if opts.strict { - slug.retain(|c| c.is_ascii_alphanumeric() || js_space_char(c)); - } - if opts.trim { - slug = slug.trim_matches(js_space_char).to_string(); - } - - // replace(/\s+/g, replacement) - let mut out = String::with_capacity(slug.len()); - let mut in_ws = false; - for c in slug.chars() { - if js_space_char(c) { - if !in_ws { - out.push_str(&opts.replacement); - in_ws = true; - } - } else { - out.push(c); - in_ws = false; - } - } - - if opts.lower { - out.to_lowercase() - } else { - out - } -} - -/// Decode the second slugify argument from raw NaN-box bits: -/// string → `{ replacement }`, object → `{ replacement, lower, strict, -/// trim }`, anything else → defaults. -unsafe fn options_from_bits(options_bits: i64) -> SlugifyOptions { - let mut opts = SlugifyOptions::default(); - let value = f64::from_bits(options_bits as u64); - let jv = JSValue::from_bits(options_bits as u64); - - if jv.is_any_string() { - let ptr = perry_runtime::js_get_string_pointer_unified(value) as *const StringHeader; - if let Some(s) = string_from_header(ptr) { - opts.replacement = s; - } - return opts; - } - - if !jv.is_pointer() { - return opts; - } - let obj = jv.as_pointer::(); - if obj.is_null() || perry_runtime::value::addr_class::is_handle_band(obj as usize) { - return opts; - } - - let field = |name: &[u8]| -> f64 { - let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); - perry_runtime::object::js_object_get_field_by_name_f64(obj, key) - }; - - let replacement = field(b"replacement"); - if JSValue::from_bits(replacement.to_bits()).is_any_string() { - let ptr = perry_runtime::js_get_string_pointer_unified(replacement) as *const StringHeader; - if let Some(s) = string_from_header(ptr) { - opts.replacement = s; - } - } - opts.lower = perry_runtime::value::js_is_truthy(field(b"lower")) != 0; - opts.strict = perry_runtime::value::js_is_truthy(field(b"strict")) != 0; - // npm: `if (options.trim !== false) slug = slug.trim()` — absent - // (undefined) means trim. - let trim = field(b"trim"); - let trim_jv = JSValue::from_bits(trim.to_bits()); - opts.trim = trim_jv.is_undefined() || perry_runtime::value::js_is_truthy(trim) != 0; - opts -} - -/// Convert a string to a URL-friendly slug -/// slugify(string) -> string -#[no_mangle] -pub unsafe extern "C" fn js_slugify(input_ptr: *const StringHeader) -> *mut StringHeader { - js_slugify_with_options(input_ptr, perry_runtime::JSValue::undefined().bits() as i64) -} - -/// Convert a string to a URL-friendly slug with options. -/// `options_bits` carries the second JS argument as raw NaN-box bits: -/// `slugify(s, '_')` (string replacement) and -/// `slugify(s, { replacement, lower, strict, trim })` both route here. -#[no_mangle] -pub unsafe extern "C" fn js_slugify_with_options( - input_ptr: *const StringHeader, - options_bits: i64, -) -> *mut StringHeader { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return std::ptr::null_mut(), - }; - let opts = options_from_bits(options_bits); - let result = slugify_npm(&input, &opts); - js_string_from_bytes(result.as_ptr(), result.len() as u32) -} - -/// Slugify with strict mode (only alphanumeric) -/// slugify(string, { strict: true }) -> string -#[no_mangle] -pub unsafe extern "C" fn js_slugify_strict(input_ptr: *const StringHeader) -> *mut StringHeader { - let input = match string_from_header(input_ptr) { - Some(s) => s, - None => return std::ptr::null_mut(), - }; - let opts = SlugifyOptions { - strict: true, - ..SlugifyOptions::default() - }; - let result = slugify_npm(&input, &opts); - js_string_from_bytes(result.as_ptr(), result.len() as u32) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_preserves_case_like_npm() { - let opts = SlugifyOptions::default(); - assert_eq!(slugify_npm("Hello World", &opts), "Hello-World"); - } - - #[test] - fn lower_option_lowercases() { - let opts = SlugifyOptions { - lower: true, - ..SlugifyOptions::default() - }; - assert_eq!(slugify_npm("Hello World", &opts), "hello-world"); - } - - #[test] - fn string_replacement_is_full_string() { - let opts = SlugifyOptions { - replacement: "_".into(), - ..SlugifyOptions::default() - }; - assert_eq!(slugify_npm("foo bar baz", &opts), "foo_bar_baz"); - } - - #[test] - fn strict_removes_non_alnum() { - let opts = SlugifyOptions { - strict: true, - lower: true, - ..SlugifyOptions::default() - }; - assert_eq!( - slugify_npm("Hello, World! (2024)", &opts), - "hello-world-2024" - ); - } - - #[test] - fn accents_fold_case_preserving() { - let opts = SlugifyOptions::default(); - assert_eq!(slugify_npm("Crème Brûlée", &opts), "Creme-Brulee"); - } - - #[test] - fn ampersand_maps_to_and() { - let opts = SlugifyOptions { - lower: true, - ..SlugifyOptions::default() - }; - assert_eq!(slugify_npm("Foo & Bar", &opts), "foo-and-bar"); - } - - #[test] - fn default_dash_collapses_with_whitespace() { - // npm defaults the replacement to '-' BEFORE the per-char - // compare, so a literal '-' merges with adjacent whitespace. - let opts = SlugifyOptions::default(); - assert_eq!(slugify_npm("a - b", &opts), "a-b"); - assert_eq!(slugify_npm("a-b", &opts), "a-b"); - assert_eq!(slugify_npm("foo_bar-baz", &opts), "foo_bar-baz"); - } - - #[test] - fn trim_false_keeps_edges_as_separators() { - let opts = SlugifyOptions { - trim: false, - ..SlugifyOptions::default() - }; - assert_eq!(slugify_npm(" foo bar ", &opts), "-foo-bar-"); - } -} diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 63f05e80b1..6e061442a9 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -1427,14 +1427,6 @@ pub extern "C" fn js_set_property() -> i64 { // stubs in perry-runtime/src/closure.rs (those three FFIs were never wired in // either perry-stdlib or perry-ext-sharp). #[no_mangle] -pub extern "C" fn js_slugify() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_slugify_strict() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_sqlite_close() -> i64 { 0 } diff --git a/crates/perry/src/commands/compile/well_known.rs b/crates/perry/src/commands/compile/well_known.rs index 7f64697861..95ced11120 100644 --- a/crates/perry/src/commands/compile/well_known.rs +++ b/crates/perry/src/commands/compile/well_known.rs @@ -485,7 +485,7 @@ mod tests { #[test] fn shipped_unproven_bindings_are_partial() { - for name in ["dotenv", "nanoid", "slugify", "uuid"] { + for name in ["dotenv", "nanoid", "uuid"] { let b = lookup_well_known(name).unwrap_or_else(|| panic!("{name} registered")); assert_eq!( b.compat, diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index f79759d6bb..bc51e18251 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -190,11 +190,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { &["container"] } - // Slugify gained the `bundled-slugify` feature in v0.5.536 so - // the well-known flip can swap it out for perry-ext-slugify. - // Default-on via `default = ["full"]` keeps existing - // `import 'slugify'` calls byte-identical. - "slugify" => &["bundled-slugify"], // lru-cache: feature-gated v0.5.539; well-known flip // routes to perry-ext-lru-cache. "lru-cache" => &["bundled-lru-cache"], diff --git a/crates/perry/tests/issue_5716_slugify_source_e2e.rs b/crates/perry/tests/issue_5716_slugify_source_e2e.rs new file mode 100644 index 0000000000..82465a9e50 --- /dev/null +++ b/crates/perry/tests/issue_5716_slugify_source_e2e.rs @@ -0,0 +1,186 @@ +//! #5716: ordinary npm packages should compile from their installed source, +//! not route through an in-tree Rust rewrite. +//! +//! This test installs the last version covered by Perry's former slugify shim, +//! compiles the real CommonJS package through the default `compilePackages: +//! auto` path, and compares its observable output with Node. Set +//! `PERRY_REQUIRE_NPM_E2E=1` to make an unavailable npm/network a hard failure. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn target_debug_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("debug") +} + +fn ensure_runtime_archives() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let build = Command::new(cargo) + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime-static") + .arg("-p") + .arg("perry-stdlib-static") + .output() + .expect("build Perry runtime archives"); + assert_success("runtime archive build", &build); + }); +} + +fn assert_success(label: &str, output: &Output) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn npm_install(root: &Path) -> bool { + let output = Command::new("npm") + .current_dir(root) + .arg("install") + .arg("--no-audit") + .arg("--no-fund") + .output(); + let required = std::env::var("PERRY_REQUIRE_NPM_E2E").ok().as_deref() == Some("1"); + match output { + Ok(output) if output.status.success() => true, + Ok(output) if required => { + assert_success("npm install slugify@1.6.9", &output); + false + } + Ok(output) => { + eprintln!( + "SKIP: npm install slugify@1.6.9 failed (offline?)\n{}", + String::from_utf8_lossy(&output.stderr) + ); + false + } + Err(error) if required => panic!("npm is required for slugify source E2E: {error}"), + Err(error) => { + eprintln!("SKIP: npm is unavailable: {error}"); + false + } + } +} + +#[test] +fn installed_slugify_source_matches_node() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write( + root.join("package.json"), + r#"{ + "name": "perry-issue-5716-slugify", + "private": true, + "type": "module", + "dependencies": { "slugify": "1.6.9" } +}"#, + ) + .expect("write package.json"); + if !npm_install(root) { + return; + } + + std::fs::write( + root.join("main.ts"), + r#"import slugify from "slugify"; + +const outputs = [ + slugify("some string"), + slugify("Déjà Vu!"), + slugify("foo_bar baz", { + replacement: "_", + lower: true, + strict: true, + trim: true, + }), + slugify("Äpfel & Öl", { lower: true, locale: "de" }), + slugify("a*b+c", { remove: /[*+]/g }), + typeof slugify.extend, +]; + +slugify.extend({ "☢": "radioactive", "♥": "love" }); +outputs.push(slugify("☢ ♥", { lower: true })); + +for (const output of outputs) console.log(output); +"#, + ) + .expect("write source probe"); + + let node = Command::new("node") + .current_dir(root) + .arg("--experimental-strip-types") + .arg("main.ts") + .output() + .expect("run source probe with Node"); + assert_success("Node slugify probe", &node); + + ensure_runtime_archives(); + let binary = root.join("perry-out"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg("main.ts") + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .arg("--verbose") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", target_debug_dir()) + .output() + .expect("compile installed slugify source"); + assert_success("Perry slugify source compile", &compile); + + let compile_log = format!( + "{}\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + assert!( + !compile_log.contains("failed to compile") && !compile_log.contains("empty stubs"), + "source migration must not hide failed package modules:\n{compile_log}" + ); + assert!( + compile_log.contains("Compile package wildcard: expanded to 1 installed package(s)"), + "default auto routing must select the installed package:\n{compile_log}" + ); + assert!( + !compile_log.contains("perry_ext_slugify"), + "removed native archive must not appear on the link line:\n{compile_log}" + ); + + let perry = Command::new(&binary) + .current_dir(root) + .output() + .expect("run Perry slugify probe"); + assert_success("Perry slugify probe", &perry); + assert_eq!( + perry.stdout, node.stdout, + "installed slugify source must match Node byte-for-byte" + ); + assert!( + perry.stderr.is_empty(), + "Perry slugify probe wrote stderr:\n{}", + String::from_utf8_lossy(&perry.stderr) + ); +} diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 0088bea726..b32b4ffa3c 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -91,21 +91,6 @@ repo = "https://github.com/uuidjs/uuid" ref = "70177807e9229dfacde2038dc1e722f1828f358a" ported-at = "14.0.1" date = "2026-07-30" -[bindings.slugify] -crate = "perry-ext-slugify" -lib = "perry_ext_slugify" -tracking = "#466" -# Partial: `remove`, `locale`, the complete char map, and `slugify.extend` -# are not implemented by this wrapper. -compat = "partial" - -[bindings.slugify.upstream] -version = "1.6.9" -sha256 = "19ab247286f687aa8803b3615eab0779f517904ec96298e5005ff4de77ff9948" -repo = "https://github.com/simov/slugify" -ref = "78c6ee658d61d81a3a33bc1f558b50013be9ccf6" -ported-at = "1.6.9" -date = "2026-07-30" [bindings.bcrypt] crate = "perry-ext-bcrypt" lib = "perry_ext_bcrypt" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 97524f03f0..977a98312c 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2053 entries across 134 modules +// Coverage: 2051 entries across 133 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -3663,13 +3663,6 @@ declare module "sharp" { export function sharp(p0: string): any; } -declare module "slugify" { - /** stdlib */ - export default function (p0: string, p1?: any): string; - /** stdlib */ - export function slugify(p0: string, p1?: any): string; -} - declare module "sqlite" { /** stdlib */ export class DatabaseSync { [key: string]: any; } diff --git a/docs/examples/stdlib/utilities/snippets.ts b/docs/examples/stdlib/utilities/snippets.ts index b7a96ea875..c255fa1b1a 100644 --- a/docs/examples/stdlib/utilities/snippets.ts +++ b/docs/examples/stdlib/utilities/snippets.ts @@ -9,7 +9,7 @@ // if any snippet drifts from the real native binding, CI fails. // // Only the packages that have a wired NativeModSig dispatch (uuid, nanoid, -// slugify, validator) are anchored here. lodash / dayjs / moment have +// validator) are anchored here. lodash / dayjs / moment have // runtime declarations but no dispatch path from user-visible imports yet, // so the markdown page keeps those snippets as `,no-test` with a clear // status note above each fence. @@ -28,13 +28,6 @@ const nid = nanoid() // Default 21 chars console.log(nid) // ANCHOR_END: nanoid -// ANCHOR: slugify -import slugify from "slugify" - -const slug = slugify("Hello World!") -console.log(slug) // "hello-world" -// ANCHOR_END: slugify - // ANCHOR: validator import validator from "validator" diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 9c0077b1d8..22de32cc51 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2990 entries across 136 modules. +Total: 2988 entries across 135 modules. ## Modules @@ -113,7 +113,6 @@ Total: 2990 entries across 136 modules. - [`repl`](#repl) - [`sea`](#sea) - [`sharp`](#sharp) -- [`slugify`](#slugify) - [`sqlite`](#sqlite) - [`stream`](#stream) - [`stream/consumers`](#streamconsumers) @@ -3265,13 +3264,6 @@ Total: 2990 entries across 136 modules. - `webp` — instance - `width` — instance -## `slugify` - -### Methods - -- `default` — module -- `slugify` — module - ## `sqlite` ### Classes diff --git a/docs/src/native-libraries/abi.md b/docs/src/native-libraries/abi.md index 6d93d40ba0..a3a12ed3be 100644 --- a/docs/src/native-libraries/abi.md +++ b/docs/src/native-libraries/abi.md @@ -68,7 +68,7 @@ incompatible changes to anything in this document bump perry-ffi's ## Surface (v0.5.x) The current surface is *deliberately minimal* — just enough to port -the simplest stdlib wrappers (`dotenv`, `nanoid`, `uuid`, `slugify`). +the simplest remaining stdlib wrappers (`dotenv`, `nanoid`, `uuid`). It will grow as real wrappers demand it; we'd rather under-design and add than commit to a helper we later regret. @@ -166,9 +166,9 @@ moves out of `perry-stdlib` over the course of #466 Phase 5. 'dotenv'` resolves to `perry-ext-dotenv` automatically — until it lands, `import 'dotenv'` continues to bind to the `perry-stdlib` copy. -- [#466 Phase 5] ports the rest of the wrappers in size order - (`uuid`, `nanoid`, `slugify`, `bcrypt`, `argon2`, then `ws`, then - the database batch). +- [#466 Phase 5] ports the rest of the wrappers in size order. Ordinary + source packages can subsequently retire those wrappers; `slugify` did so + under #5716 once its installed upstream package passed source parity. [#466 Phase 1]: https://github.com/PerryTS/perry/issues/466 [#466 Phase 2]: https://github.com/PerryTS/perry/issues/466 diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index 9c859fc020..c047c79845 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -5,6 +5,12 @@ packages. A package-specific Rust rewrite can be useful as a temporary bridge, but it is not the default compatibility strategy and does not demonstrate that Perry can compile the package it replaces. +Applications are expected to install their declared dependencies with npm, +Bun, pnpm, or another package manager. Source-package migrations therefore do +not preserve a zero-install fallback: after a bundled rewrite is removed, the +import resolves from the application's installed dependency graph like any +other npm package. + ## Policy Use this order when adding package compatibility: @@ -59,6 +65,14 @@ crates. Distribution builds use the inventory below as their explicit shipping set; changing that set therefore requires changing the recorded decision and passing the governance check. +## Completed source migrations + +- `slugify@1.6.9` compiles from its installed CommonJS source through the + default automatic package-routing path. The #5716 E2E test compares Perry + with Node across transliteration, replacement, strict/trim options, locale, + regular-expression removal, and `slugify.extend`. Its former + `perry-ext-slugify` and `perry-stdlib` implementations have been removed. + ## Current inventory `workspace-architecture.json` is the source of truth for the crate decision and @@ -102,7 +116,6 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-pg` | `pg` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-ratelimit` | `rate-limiter-flexible` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-sharp` | `sharp` | External integration | Move to an external native package | Bundled; migration pending | -| `perry-ext-slugify` | `slugify` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-streams` | `streams` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-typescript` | `typescript` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-undici` | `undici` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/docs/src/native-libraries/zero-config-and-faithfulness.md b/docs/src/native-libraries/zero-config-and-faithfulness.md index a5c7d57585..a051b9e37e 100644 --- a/docs/src/native-libraries/zero-config-and-faithfulness.md +++ b/docs/src/native-libraries/zero-config-and-faithfulness.md @@ -63,12 +63,16 @@ 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 +for example, lacks exports including `parse` and `stringify`; 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. +Ordinary packages that have completed their source migration are not listed in +the native manifest or this table. They must be installed in the application, +and Perry compiles their package source through the normal automatic routing +path. `slugify` is the first completed migration under this policy. + ## Diagnostics and strict mode When a partial binding wins while a copy of its root package exists in diff --git a/docs/src/stdlib/overview.md b/docs/src/stdlib/overview.md index 0d1e2f3586..d9af669917 100644 --- a/docs/src/stdlib/overview.md +++ b/docs/src/stdlib/overview.md @@ -51,7 +51,6 @@ for compatibility guarantees. - **dayjs** / **moment** — Date manipulation - **uuid** — UUID generation - **nanoid** — ID generation -- **slugify** — String slugification - **validator** — String validation ### CLI & Data diff --git a/docs/src/stdlib/utilities.md b/docs/src/stdlib/utilities.md index d0ad5e9f9d..b34df8a613 100644 --- a/docs/src/stdlib/utilities.md +++ b/docs/src/stdlib/utilities.md @@ -71,16 +71,6 @@ yet — track at issue #200. {{#include ../../examples/stdlib/utilities/snippets.ts:nanoid}} ``` -## slugify - -The single-arg form is wired. The options-object form -`slugify("Hello World!", { lower: true })` has a runtime function -(`js_slugify_with_options`) but no dispatch yet — track at issue #200. - -```typescript -{{#include ../../examples/stdlib/utilities/snippets.ts:slugify}} -``` - ## validator ```typescript diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index 59dc50a577..55b95c8d47 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -164,7 +164,6 @@ crates/perry-runtime/src/array/collection_tag_tests.rs | * | unit tests for the crates/perry-ext-events/src/lib.rs | const EVENT_EMITTER_HANDLE_ID_END | #7272: the crate's own handle-id range end, a registry bound rather than a heap-address band test crates/perry-ext-net/src/jsvalue.rs | * | #7272: socket/server handle-vs-pointer discrimination before dereference; re-types the handle band instead of calling addr_class::is_handle_band, which perry-runtime does not export crates/perry-ext-ratelimit/src/lib.rs | (obj as usize) >= 0x100000 | #7272: same handle-vs-pointer guard before an ObjectHeader read -crates/perry-ext-slugify/src/lib.rs | (obj as usize) < 0x100000 | #7272: same handle-vs-pointer guard before an ObjectHeader read crates/perry-runtime/src/arena/page_meta.rs | let header = addr as *const GcHeader; | promoted-page-run expansion: `addr` starts at a `first_header` recorded by arena/promote.rs's linear block iteration (its grandfathered sibling entry above) and advances by `GcHeader::size` from there, so every address is a block-interior header, never a NaN-box payload; the parse stops at the first implausible size exactly as the arena walkers do crates/perry-runtime/src/arena/tests_promoted_runs.rs | * | arena promoted-run tests: header addresses are offsets into a buffer the test itself allocated and initialised, never NaN-box payloads -- same discipline as the arena/tests.rs entry above crates/perry-runtime/src/box/release_tests.rs | * | async-box release tests: the closure whose GcHeader is read is allocated by the test itself, and the test needs a MUTABLE header to toggle GC_FLAG_MARKED and restore it -- try_read_gc_header yields a shared ref, so it cannot express this. Same discipline as the arena/tests_promoted_runs.rs entry above. diff --git a/test-files/test_ffi_surface_stdlib_core.ts b/test-files/test_ffi_surface_stdlib_core.ts index ab03b11b0c..ded25dca0c 100644 --- a/test-files/test_ffi_surface_stdlib_core.ts +++ b/test-files/test_ffi_surface_stdlib_core.ts @@ -6,7 +6,7 @@ // inventory into behavioral tests as each area gets deeper compatibility // coverage. // -// Inventory entries: 84 unique FFI names, 85 declarations. +// Inventory entries: 81 unique FFI names, 82 declarations. const testFfiSurfaceStdlibCoreVersion = 1; if (testFfiSurfaceStdlibCoreVersion !== 1) { @@ -92,10 +92,6 @@ crates/perry-stdlib/src/ratelimit.rs: - js_ratelimit_remaining - js_ratelimit_reset - js_ratelimit_reward -crates/perry-stdlib/src/slugify.rs: - - js_slugify - - js_slugify_strict - - js_slugify_with_options crates/perry-stdlib/src/sqlite.rs: - js_sqlite_begin_transaction - js_sqlite_close diff --git a/test-files/test_gap_slugify_options.ts b/test-files/test_gap_slugify_options.ts deleted file mode 100644 index 9d552cee76..0000000000 --- a/test-files/test_gap_slugify_options.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Gap test: slugify's second argument is a replacement string OR an -// options object ({ replacement, lower, strict, trim }). The options -// object used to be coerced through the string path, garbling -// slugify("Hello World", { lower: true }) into "hello{world". -// All inputs below stay inside the accent/charMap subset the native -// binding supports, and outputs are fully deterministic. - -import slugify from "slugify"; - -// Positional-string form (kept working). -console.log(slugify("Hello World")); -console.log(slugify("Hello World", "_")); - -// Options-object forms. -console.log(slugify("Hello World", { lower: true })); -console.log(slugify("Hello World", { replacement: "_" })); -console.log(slugify("Hello World", { replacement: "__", lower: true })); - -// npm semantics: default keeps case; '!' is in the keep-set; ',' is not. -console.log(slugify("Crème Brûlée!", { lower: true })); -console.log(slugify("Hello, World! (2024)", { lower: true, strict: true })); - -// charMap expansion: '&' -> 'and'. -console.log(slugify("Foo & Bar", { lower: true })); - -// trim (default true) + custom replacement. -console.log(slugify(" padded input ", { replacement: "_" })); - -// '-' and '_' are kept literally; only whitespace runs collapse. -console.log(slugify("a - b")); -console.log(slugify("foo_bar-baz")); -console.log(slugify("UPPER Case Kept")); diff --git a/workspace-architecture.json b/workspace-architecture.json index d30bde7881..eddd42c1b0 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 80, + "workspace_members": 79, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -66,7 +66,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 33, + "externalize": 32, "keep": 42, "merge": 1, "remove": 1, @@ -303,11 +303,6 @@ "decision": "externalize", "migration": "external-package" }, - "perry-ext-slugify": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-streams": { "category": "binding", "decision": "keep",