From ac5035c1985442ad68662e4a98694c02a963fe53 Mon Sep 17 00:00:00 2001 From: GGOBP Date: Mon, 7 Sep 2026 15:09:49 +0900 Subject: [PATCH 1/2] refactor(js)!: split js/object reflection into glendix/js/reflect `glendix/js/object` bundled two concerns behind one FFI file: plain data-object construction and arbitrary JavaScript reflection. This separates them so the data boundary no longer depends on interop. - `glendix/js/object` is now data-only: `JsValue`/`JsObject`/`JsBoolean`, the scalar coercions, `from_entries`, and `empty`. `object_ffi.mjs` is trimmed to `create_object`/`empty_object`/`identity`. - New `glendix/js/reflect` (+ `reflect_ffi.mjs`) owns the minimized, explicitly named reflection surface: `get`/`set`/`delete`/`has`, `call_method`, `call_method_without_arguments`, `new_instance`, and the `JsConstructor` type. Function names, labels, and behavior are preserved; only the module changes. Data construction keeps its bespoke `Object.fromEntries` FFI on purpose: no ecosystem package builds a live, prototype-pollution-safe plain object (`gleam/javascript` only covers arrays/promises/symbols, and a `gleam/json` round-trip is indirect and lossy for live handles). The `__proto__`-as-own-data guarantee is retained and documented. Tests cover creation (including a `__proto__` key), property get/set/delete/has, method calls with and without arguments, and construction. Module docs and the README document the split, the migration, and the unsafe/dynamic reflection boundary. The `glendix -> mendraw` dependency source form is unchanged. Closes #19 --- README.md | 18 ++++++ src/glendix/js/object.gleam | 99 +++++-------------------------- src/glendix/js/object_ffi.mjs | 23 -------- src/glendix/js/reflect.gleam | 105 +++++++++++++++++++++++++++++++++ src/glendix/js/reflect_ffi.mjs | 23 ++++++++ test/glendix_test.gleam | 81 +++++++++++++++++++++++++ test/glendix_test_ffi.mjs | 29 +++++++++ 7 files changed, 271 insertions(+), 107 deletions(-) create mode 100644 src/glendix/js/reflect.gleam create mode 100644 src/glendix/js/reflect_ffi.mjs diff --git a/README.md b/README.md index 30d20af..4dae31c 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,13 @@ preserving entry order and keeping the last value for a duplicate key. The object passes to an external React component as one prop through a Redraw attribute, without any application-local React FFI: +`glendix/js/object` is deliberately data-only. Dynamic interop — reading, +writing, or deleting arbitrary properties, calling methods, and invoking +constructors — lives in the separate `glendix/js/reflect` module. Reflection is +the unsafe/dynamic boundary where the caller, not the type system, guarantees a +property or method exists, so reach for it only when a plain data object is not +enough. + ```gleam import glendix/binding import glendix/js/environment @@ -332,6 +339,17 @@ common `list |> array.from_list |> array.to_list` usage is unchanged. The former opaque `glendix/js/array.JsArray(element)` type is removed; annotate values with `gleam/javascript/array.Array(element)` instead. +`glendix/js/object` is now data-only. Its reflection operations moved unchanged +to the new `glendix/js/reflect` module: `get`, `set`, `delete`, `has`, +`call_method`, `call_method_without_arguments`, and `new_instance`, together +with the `JsConstructor` type. Their names, labels, and behavior are preserved, +so migrate a pre-split call such as `object.get(from: handle, key: "x")` to +`reflect.get(from: handle, key: "x")` (add `import glendix/js/reflect`). Data +construction (`from_entries`, `empty`, `string`, `int`, `float`, `bool`, +`from_object`) stays in `glendix/js/object`, and `from_entries` keeps its +prototype-pollution-safe `Object.fromEntries` behavior for keys such as +`__proto__`. + ## Development ```sh diff --git a/src/glendix/js/object.gleam b/src/glendix/js/object.gleam index db8eaa8..21366fb 100644 --- a/src/glendix/js/object.gleam +++ b/src/glendix/js/object.gleam @@ -1,4 +1,18 @@ -//// Creates and manipulates typed JavaScript object handles. +//// Builds plain, prototype-safe JavaScript data objects from typed entries. +//// +//// This module owns *data-only* object construction. It converts typed Gleam +//// scalars into opaque JavaScript values and assembles them into plain objects +//// whose keys are always ordinary own data properties. Dynamic interop such as +//// property reads and writes, method calls, and constructor invocation lives in +//// the separate `glendix/js/reflect` module, so this data boundary never +//// depends on arbitrary reflection. +//// +//// Construction keeps a small bespoke FFI (`Object.fromEntries`) on purpose: no +//// ecosystem package builds a live, prototype-pollution-safe plain object. +//// `gleam/javascript` only covers arrays, promises, and symbols, and a +//// `gleam/json` round-trip would be indirect and lossy for live handles. The +//// retained FFI guarantees that even a `__proto__` entry is stored as ordinary +//// own data instead of invoking the legacy prototype setter. //// /// Represents a JavaScript value whose runtime shape is intentionally opaque. @@ -7,9 +21,6 @@ pub type JsValue /// Represents a JavaScript object handle. pub type JsObject -/// Represents a JavaScript constructor handle. -pub type JsConstructor - /// Represents a JavaScript boolean value. pub type JsBoolean { /// JavaScript `true`. @@ -61,55 +72,6 @@ pub fn empty() -> JsObject { empty_object_raw() } -/// Reads an object property. -pub fn get(from object: JsObject, key key: String) -> JsValue { - get_property_raw(object, key) -} - -/// Mutates an object property and returns the same object handle. -pub fn set( - on object: JsObject, - key key: String, - to value: JsValue, -) -> JsObject { - set_property_raw(object, key, value) -} - -/// Deletes an object property and returns the same object handle. -pub fn delete(from object: JsObject, key key: String) -> JsObject { - delete_property_raw(object, key) -} - -/// Reports whether an object has the given property. -pub fn has(in object: JsObject, key key: String) -> Bool { - has_property_raw(object, key) -} - -/// Calls an object method with a list of arguments. -pub fn call_method( - on object: JsObject, - named method: String, - with arguments: List(JsValue), -) -> JsValue { - call_method_raw(object, method, arguments) -} - -/// Calls an object method without arguments. -pub fn call_method_without_arguments( - on object: JsObject, - named method: String, -) -> JsValue { - call_method_without_arguments_raw(object, method) -} - -/// Creates an object with JavaScript's `new` operator. -pub fn new_instance( - using constructor: JsConstructor, - with arguments: List(JsValue), -) -> JsObject { - new_instance_raw(constructor, arguments) -} - // -- FFI -- @external(javascript, "./object_ffi.mjs", "identity") fn string_raw(value: String) -> JsValue @@ -131,34 +93,3 @@ fn create_object_raw(entries: List(#(String, JsValue))) -> JsObject @external(javascript, "./object_ffi.mjs", "empty_object") fn empty_object_raw() -> JsObject - -@external(javascript, "./object_ffi.mjs", "get_property") -fn get_property_raw(object: JsObject, key: String) -> JsValue - -@external(javascript, "./object_ffi.mjs", "set_property") -fn set_property_raw(object: JsObject, key: String, value: JsValue) -> JsObject - -@external(javascript, "./object_ffi.mjs", "delete_property") -fn delete_property_raw(object: JsObject, key: String) -> JsObject - -@external(javascript, "./object_ffi.mjs", "has_property") -fn has_property_raw(object: JsObject, key: String) -> Bool - -@external(javascript, "./object_ffi.mjs", "call_method") -fn call_method_raw( - object: JsObject, - method: String, - arguments: List(JsValue), -) -> JsValue - -@external(javascript, "./object_ffi.mjs", "call_method_0") -fn call_method_without_arguments_raw( - object: JsObject, - method: String, -) -> JsValue - -@external(javascript, "./object_ffi.mjs", "new_instance") -fn new_instance_raw( - constructor: JsConstructor, - arguments: List(JsValue), -) -> JsObject diff --git a/src/glendix/js/object_ffi.mjs b/src/glendix/js/object_ffi.mjs index be01d67..c4f60c9 100644 --- a/src/glendix/js/object_ffi.mjs +++ b/src/glendix/js/object_ffi.mjs @@ -7,29 +7,6 @@ export function create_object(entries) { export function empty_object() { return {}; } -export function get_property(obj, key) { - return obj[key]; -} -export function set_property(obj, key, value) { - obj[key] = value; - return obj; -} -export function delete_property(obj, key) { - delete obj[key]; - return obj; -} -export function has_property(obj, key) { - return key in obj; -} -export function call_method(obj, method, args) { - return obj[method](...args.toArray()); -} -export function call_method_0(obj, method) { - return obj[method](); -} -export function new_instance(constructor, args) { - return new constructor(...args.toArray()); -} export function identity(value) { return value; } diff --git a/src/glendix/js/reflect.gleam b/src/glendix/js/reflect.gleam new file mode 100644 index 0000000..64f88ad --- /dev/null +++ b/src/glendix/js/reflect.gleam @@ -0,0 +1,105 @@ +//// Performs dynamic JavaScript reflection against object handles. +//// +//// These operations are the interop boundary: they read and write arbitrary +//// properties, invoke methods, and call constructors by name at runtime. They +//// are inherently dynamic and unsafe in the sense that the caller, not the +//// type system, guarantees a property exists, a method is callable, or a value +//// is a constructor. Keep this surface minimal and prefer the data-only +//// `glendix/js/object` module whenever a plain data object is enough. +//// +//// Object handles and values flow through `glendix/js/object`, so both modules +//// share one typed representation of JavaScript objects and values. +//// + +import glendix/js/object + +/// Represents a JavaScript constructor handle. +pub type JsConstructor + +/// Reads an object property. +pub fn get(from handle: object.JsObject, key key: String) -> object.JsValue { + get_property_raw(handle, key) +} + +/// Mutates an object property and returns the same object handle. +pub fn set( + on handle: object.JsObject, + key key: String, + to value: object.JsValue, +) -> object.JsObject { + set_property_raw(handle, key, value) +} + +/// Deletes an object property and returns the same object handle. +pub fn delete( + from handle: object.JsObject, + key key: String, +) -> object.JsObject { + delete_property_raw(handle, key) +} + +/// Reports whether an object has the given property. +pub fn has(in handle: object.JsObject, key key: String) -> Bool { + has_property_raw(handle, key) +} + +/// Calls an object method with a list of arguments. +pub fn call_method( + on handle: object.JsObject, + named method: String, + with arguments: List(object.JsValue), +) -> object.JsValue { + call_method_raw(handle, method, arguments) +} + +/// Calls an object method without arguments. +pub fn call_method_without_arguments( + on handle: object.JsObject, + named method: String, +) -> object.JsValue { + call_method_without_arguments_raw(handle, method) +} + +/// Creates an object with JavaScript's `new` operator. +pub fn new_instance( + using constructor: JsConstructor, + with arguments: List(object.JsValue), +) -> object.JsObject { + new_instance_raw(constructor, arguments) +} + +// -- FFI -- +@external(javascript, "./reflect_ffi.mjs", "get_property") +fn get_property_raw(handle: object.JsObject, key: String) -> object.JsValue + +@external(javascript, "./reflect_ffi.mjs", "set_property") +fn set_property_raw( + handle: object.JsObject, + key: String, + value: object.JsValue, +) -> object.JsObject + +@external(javascript, "./reflect_ffi.mjs", "delete_property") +fn delete_property_raw(handle: object.JsObject, key: String) -> object.JsObject + +@external(javascript, "./reflect_ffi.mjs", "has_property") +fn has_property_raw(handle: object.JsObject, key: String) -> Bool + +@external(javascript, "./reflect_ffi.mjs", "call_method") +fn call_method_raw( + handle: object.JsObject, + method: String, + arguments: List(object.JsValue), +) -> object.JsValue + +@external(javascript, "./reflect_ffi.mjs", "call_method_0") +fn call_method_without_arguments_raw( + handle: object.JsObject, + method: String, +) -> object.JsValue + +@external(javascript, "./reflect_ffi.mjs", "new_instance") +fn new_instance_raw( + constructor: JsConstructor, + arguments: List(object.JsValue), +) -> object.JsObject diff --git a/src/glendix/js/reflect_ffi.mjs b/src/glendix/js/reflect_ffi.mjs new file mode 100644 index 0000000..e4191af --- /dev/null +++ b/src/glendix/js/reflect_ffi.mjs @@ -0,0 +1,23 @@ +export function get_property(object, key) { + return object[key]; +} +export function set_property(object, key, value) { + object[key] = value; + return object; +} +export function delete_property(object, key) { + delete object[key]; + return object; +} +export function has_property(object, key) { + return key in object; +} +export function call_method(object, method, args) { + return object[method](...args.toArray()); +} +export function call_method_0(object, method) { + return object[method](); +} +export function new_instance(constructor, args) { + return new constructor(...args.toArray()); +} diff --git a/test/glendix_test.gleam b/test/glendix_test.gleam index 78a70f4..6dee5b2 100644 --- a/test/glendix_test.gleam +++ b/test/glendix_test.gleam @@ -16,6 +16,7 @@ import glendix/js/array import glendix/js/environment import glendix/js/object import glendix/js/promise as glendix_promise +import glendix/js/reflect import glendix/lustre import lustre/attribute import lustre/element @@ -354,6 +355,71 @@ pub fn binding_object_prop_preserves_object_test() -> Nil { |> should.equal("{\"theme\":\"dark\",\"locale\":\"en\"}") } +/// Verifies reflection reads an existing own property from a data object. +pub fn reflect_get_reads_existing_property_test() -> Nil { + object.from_entries([#("theme", object.string("dark"))]) + |> reflect.get(key: "theme") + |> reflect_value_to_string + |> should.equal("dark") +} + +/// Verifies a set mutates in place and returns the same object handle. +pub fn reflect_set_overwrites_and_returns_same_handle_test() -> Nil { + let handle = object.empty() + let updated = reflect.set(on: handle, key: "count", to: object.int(7)) + reflect_same_object(handle, updated) + |> should.be_true + updated + |> reflect.get(key: "count") + |> reflect_value_to_string + |> should.equal("7") +} + +/// Verifies a delete removes the property and returns the same handle. +pub fn reflect_delete_removes_property_and_returns_same_handle_test() -> Nil { + let handle = object.from_entries([#("theme", object.string("dark"))]) + let cleared = reflect.delete(from: handle, key: "theme") + reflect_same_object(handle, cleared) + |> should.be_true + cleared + |> reflect.has(key: "theme") + |> should.be_false +} + +/// Verifies presence checks report both present and absent properties. +pub fn reflect_has_reports_property_presence_test() -> Nil { + let handle = object.from_entries([#("theme", object.string("dark"))]) + reflect.has(in: handle, key: "theme") + |> should.be_true + reflect.has(in: handle, key: "missing") + |> should.be_false +} + +/// Verifies method calls forward ordered arguments to the receiver. +pub fn reflect_call_method_passes_arguments_test() -> Nil { + reflect_method_object() + |> reflect.call_method(named: "add", with: [object.int(2), object.int(5)]) + |> reflect_value_to_string + |> should.equal("7") +} + +/// Verifies argument-free method calls bind the receiver as `this`. +pub fn reflect_call_method_without_arguments_reads_receiver_test() -> Nil { + reflect_method_object() + |> reflect.call_method_without_arguments(named: "describe") + |> reflect_value_to_string + |> should.equal("total:3") +} + +/// Verifies construction invokes `new` and returns the built object. +pub fn reflect_new_instance_constructs_object_test() -> Nil { + reflect_point_constructor() + |> reflect.new_instance(with: [object.int(3), object.int(4)]) + |> reflect.get(key: "x") + |> reflect_value_to_string + |> should.equal("3") +} + /// Verifies resolve fulfills a Promise with the supplied value. pub fn promise_resolve_yields_value_test() -> promise.Promise(Nil) { glendix_promise.resolve("glendix") @@ -512,6 +578,21 @@ fn element_prop_is( expected expected: object.JsObject, ) -> Bool +@external(javascript, "./glendix_test_ffi.mjs", "reflect_value_to_string") +fn reflect_value_to_string(value value: object.JsValue) -> String + +@external(javascript, "./glendix_test_ffi.mjs", "reflect_method_object") +fn reflect_method_object() -> object.JsObject + +@external(javascript, "./glendix_test_ffi.mjs", "reflect_point_constructor") +fn reflect_point_constructor() -> reflect.JsConstructor + +@external(javascript, "./glendix_test_ffi.mjs", "reflect_same_object") +fn reflect_same_object( + left left: object.JsObject, + right right: object.JsObject, +) -> Bool + @external(javascript, "./glendix_test_ffi.mjs", "new_promise_callback_counter") fn new_promise_callback_counter() -> PromiseCallbackCounter diff --git a/test/glendix_test_ffi.mjs b/test/glendix_test_ffi.mjs index 47186cc..0b6dd2c 100644 --- a/test/glendix_test_ffi.mjs +++ b/test/glendix_test_ffi.mjs @@ -238,6 +238,35 @@ export function element_prop_is(element, key, expected) { return element.props[key] === expected; } +export function reflect_value_to_string(value) { + return String(value); +} + +export function reflect_method_object() { + return { + total: 3, + describe() { + return "total:" + this.total; + }, + add(first, second) { + return first + second; + }, + }; +} + +export function reflect_point_constructor() { + return class ReflectPoint { + constructor(x, y) { + this.x = x; + this.y = y; + } + }; +} + +export function reflect_same_object(left, right) { + return left === right; +} + export function new_promise_callback_counter() { return { count: 0 }; } From b9ae99ffc7fdb4420e2768f72ea43c302a792498 Mon Sep 17 00:00:00 2001 From: GGOBP Date: Mon, 7 Sep 2026 15:15:26 +0900 Subject: [PATCH 2/2] test(js): cover object and reflection boundaries Move the object and reflection contracts into source-matched test modules, cover all value coercions, empty construction, missing and inherited properties, handle identity, receiver binding, and constructor arguments, and assert that __proto__ remains own data without changing the prototype. Clarify the reflection setter risk and has-property semantics, document the ecosystem retention rationale, and keep the English, Korean, and Japanese migration guidance synchronized. --- README.ja.md | 20 +++++ README.ko.md | 17 ++++ README.md | 15 ++-- src/glendix/js/object.gleam | 9 +- src/glendix/js/reflect.gleam | 7 +- test/glendix/js/object_test.gleam | 82 ++++++++++++++++++ test/glendix/js/object_test_ffi.mjs | 19 +++++ test/glendix/js/reflect_test.gleam | 105 +++++++++++++++++++++++ test/glendix/js/reflect_test_ffi.mjs | 36 ++++++++ test/glendix_test.gleam | 119 --------------------------- test/glendix_test_ffi.mjs | 33 -------- 11 files changed, 298 insertions(+), 164 deletions(-) create mode 100644 test/glendix/js/object_test.gleam create mode 100644 test/glendix/js/object_test_ffi.mjs create mode 100644 test/glendix/js/reflect_test.gleam create mode 100644 test/glendix/js/reflect_test_ffi.mjs diff --git a/README.ja.md b/README.ja.md index 608b47c..e719278 100644 --- a/README.ja.md +++ b/README.ja.md @@ -207,6 +207,15 @@ pub fn themed_component( } ``` +`glendix/js/object` は意図的にデータ構築だけを担当します。任意のプロパティの +読み取り・書き込み・削除、メソッド呼び出し、コンストラクター実行などの動的な +interop は、別の `glendix/js/reflect` モジュールにあります。Reflection は、 +プロパティやメソッドの存在を型システムではなく呼び出し側が保証する +unsafe/dynamic な境界です。`__proto__` を通常のデータとして保持する保証は +`object.from_entries` にだけ適用されます。Reflection による代入は通常の +JavaScript setter の意味を維持するため、信頼できないプロパティ名を +`reflect.set` に渡してはいけません。 + ## WebAssembly 依存関係 Glendix は、ブラウザ toolchain が使用する次の標準的な静的 URL 形式の @@ -268,6 +277,17 @@ JavaScript 設定、最終 MPK ビルドを担当します。 opaque 型 `glendix/js/array.JsArray(element)` は削除されたので、値の型は `gleam/javascript/array.Array(element)` で注釈してください。 +`glendix/js/object` はデータ構築専用になりました。従来の reflection 操作である +`get`、`set`、`delete`、`has`、`call_method`、 +`call_method_without_arguments`、`new_instance` と `JsConstructor` 型は、新しい +`glendix/js/reflect` モジュールへ移動しました。関数名・ラベル・挙動は維持されて +います。従来の `object.get(from: handle, key: "x")` は +`import glendix/js/reflect` を追加し、 +`reflect.get(from: handle, key: "x")` へ変更してください。データ構築関数 +(`from_entries`、`empty`、`string`、`int`、`float`、`bool`、`from_object`) は +`glendix/js/object` に残り、`from_entries` は `__proto__` のようなキーでも +prototype pollution を起こさない `Object.fromEntries` の挙動を維持します。 + ## 開発 ```sh diff --git a/README.ko.md b/README.ko.md index 98bb572..aef2775 100644 --- a/README.ko.md +++ b/README.ko.md @@ -201,6 +201,13 @@ pub fn themed_component( } ``` +`glendix/js/object`는 의도적으로 데이터 생성만 담당한다. 임의 속성의 읽기·쓰기·삭제, +메서드 호출, 생성자 실행 같은 동적 interop은 별도의 `glendix/js/reflect` 모듈에 +있다. Reflection은 속성이나 메서드가 실제로 존재함을 타입 시스템이 아니라 호출자가 +보장하는 unsafe/dynamic 경계다. `__proto__`를 일반 데이터로 보존하는 보장은 +`object.from_entries`에만 적용된다. Reflection 대입은 일반 JavaScript setter 의미를 +유지하므로 신뢰할 수 없는 속성 이름을 `reflect.set`에 전달하면 안 된다. + ## WebAssembly 의존성 Glendix는 브라우저 도구가 사용하는 다음 표준 정적 URL 형식의 WebAssembly @@ -262,6 +269,16 @@ JavaScript 어댑터를 더 이상 포함하지 않는다. `from_list`와 `to_li 타입 `glendix/js/array.JsArray(element)`는 제거되었으므로 값의 타입은 `gleam/javascript/array.Array(element)`로 표기한다. +`glendix/js/object`는 이제 데이터 생성만 담당한다. 기존 reflection 연산인 `get`, +`set`, `delete`, `has`, `call_method`, `call_method_without_arguments`, +`new_instance`와 `JsConstructor` 타입은 새 `glendix/js/reflect` 모듈로 이동했다. +함수 이름·label·동작은 유지된다. 기존 +`object.get(from: handle, key: "x")` 호출은 `import glendix/js/reflect`를 추가하고 +`reflect.get(from: handle, key: "x")`로 변경한다. 데이터 생성 함수 +(`from_entries`, `empty`, `string`, `int`, `float`, `bool`, `from_object`)는 +`glendix/js/object`에 남아 있으며, `from_entries`는 `__proto__` 같은 키에도 +prototype pollution을 일으키지 않는 `Object.fromEntries` 동작을 유지한다. + ## 개발 ```sh diff --git a/README.md b/README.md index 4dae31c..8855dbf 100644 --- a/README.md +++ b/README.md @@ -238,13 +238,6 @@ preserving entry order and keeping the last value for a duplicate key. The object passes to an external React component as one prop through a Redraw attribute, without any application-local React FFI: -`glendix/js/object` is deliberately data-only. Dynamic interop — reading, -writing, or deleting arbitrary properties, calling methods, and invoking -constructors — lives in the separate `glendix/js/reflect` module. Reflection is -the unsafe/dynamic boundary where the caller, not the type system, guarantees a -property or method exists, so reach for it only when a plain data object is not -enough. - ```gleam import glendix/binding import glendix/js/environment @@ -270,6 +263,14 @@ pub fn themed_component( } ``` +`glendix/js/object` is deliberately data-only. Dynamic interop — reading, +writing, or deleting arbitrary properties, calling methods, and invoking +constructors — lives in the separate `glendix/js/reflect` module. Reflection is +the unsafe/dynamic boundary where the caller, not the type system, guarantees a +property or method exists. The `__proto__`-as-data guarantee applies to +`object.from_entries`; reflective assignment retains ordinary JavaScript setter +semantics, so never pass untrusted property names to `reflect.set`. + ## WebAssembly dependencies Glendix automatically packages browser WebAssembly modules referenced with the diff --git a/src/glendix/js/object.gleam b/src/glendix/js/object.gleam index 21366fb..615b09f 100644 --- a/src/glendix/js/object.gleam +++ b/src/glendix/js/object.gleam @@ -9,10 +9,11 @@ //// //// Construction keeps a small bespoke FFI (`Object.fromEntries`) on purpose: no //// ecosystem package builds a live, prototype-pollution-safe plain object. -//// `gleam/javascript` only covers arrays, promises, and symbols, and a -//// `gleam/json` round-trip would be indirect and lossy for live handles. The -//// retained FFI guarantees that even a `__proto__` entry is stored as ordinary -//// own data instead of invoking the legacy prototype setter. +//// The public `gleam/javascript` API only covers arrays, promises, and symbols, +//// while Plinth has no general plain-object builder. A `gleam/json` round-trip +//// would be indirect and lossy for live handles. The retained FFI guarantees +//// that even a `__proto__` entry is stored as ordinary own data instead of +//// invoking the legacy prototype setter. //// /// Represents a JavaScript value whose runtime shape is intentionally opaque. diff --git a/src/glendix/js/reflect.gleam b/src/glendix/js/reflect.gleam index 64f88ad..0659ad9 100644 --- a/src/glendix/js/reflect.gleam +++ b/src/glendix/js/reflect.gleam @@ -7,6 +7,11 @@ //// is a constructor. Keep this surface minimal and prefer the data-only //// `glendix/js/object` module whenever a plain data object is enough. //// +//// The `__proto__`-as-data guarantee belongs specifically to +//// `object.from_entries`. `set` deliberately preserves ordinary JavaScript +//// assignment semantics, so a key such as `__proto__` can invoke an inherited +//// setter. Do not pass untrusted property names to reflection operations. +//// //// Object handles and values flow through `glendix/js/object`, so both modules //// share one typed representation of JavaScript objects and values. //// @@ -38,7 +43,7 @@ pub fn delete( delete_property_raw(handle, key) } -/// Reports whether an object has the given property. +/// Reports whether an object or its prototype chain has the given property. pub fn has(in handle: object.JsObject, key key: String) -> Bool { has_property_raw(handle, key) } diff --git a/test/glendix/js/object_test.gleam b/test/glendix/js/object_test.gleam new file mode 100644 index 0000000..013691c --- /dev/null +++ b/test/glendix/js/object_test.gleam @@ -0,0 +1,82 @@ +//// Tests plain JavaScript data-object construction and value coercion. +//// + +import gleeunit/should +import glendix/js/object + +/// Verifies object construction preserves the order of ordinary string keys. +pub fn from_entries_preserves_entry_order_test() -> Nil { + object.from_entries([ + #("theme", object.string("dark")), + #("locale", object.string("en")), + #("density", object.int(2)), + ]) + |> object_json + |> should.equal("{\"theme\":\"dark\",\"locale\":\"en\",\"density\":2}") +} + +/// Verifies an empty entry list safely builds an empty object. +pub fn from_entries_without_entries_is_empty_object_test() -> Nil { + object.from_entries([]) + |> object_json + |> should.equal("{}") +} + +/// Verifies the dedicated empty constructor returns a normal plain object. +pub fn empty_builds_empty_plain_object_test() -> Nil { + let handle = object.empty() + handle + |> object_json + |> should.equal("{}") + handle + |> has_default_prototype + |> should.be_true +} + +/// Verifies a duplicate key keeps the last supplied value. +pub fn from_entries_duplicate_key_keeps_last_value_test() -> Nil { + object.from_entries([ + #("theme", object.string("light")), + #("theme", object.string("dark")), + ]) + |> object_json + |> should.equal("{\"theme\":\"dark\"}") +} + +/// Verifies every typed value coercion preserves its JavaScript representation. +pub fn from_entries_preserves_supported_value_representations_test() -> Nil { + let nested = object.from_entries([#("name", object.string("nested"))]) + object.from_entries([ + #("string", object.string("glendix")), + #("int", object.int(42)), + #("float", object.float(3.5)), + #("true", object.bool(object.TrueValue)), + #("false", object.bool(object.FalseValue)), + #("object", object.from_object(nested)), + ]) + |> object_json + |> should.equal( + "{\"string\":\"glendix\",\"int\":42,\"float\":3.5,\"true\":true,\"false\":false,\"object\":{\"name\":\"nested\"}}", + ) +} + +/// Verifies `__proto__` remains own data without changing the object prototype. +pub fn from_entries_proto_key_is_safe_data_test() -> Nil { + let handle = object.from_entries([#("__proto__", object.string("safe"))]) + handle + |> object_json + |> should.equal("{\"__proto__\":\"safe\"}") + handle + |> proto_key_is_safe_data + |> should.be_true +} + +// -- FFI -- +@external(javascript, "./object_test_ffi.mjs", "object_json") +fn object_json(handle: object.JsObject) -> String + +@external(javascript, "./object_test_ffi.mjs", "proto_key_is_safe_data") +fn proto_key_is_safe_data(handle: object.JsObject) -> Bool + +@external(javascript, "./object_test_ffi.mjs", "has_default_prototype") +fn has_default_prototype(handle: object.JsObject) -> Bool diff --git a/test/glendix/js/object_test_ffi.mjs b/test/glendix/js/object_test_ffi.mjs new file mode 100644 index 0000000..0ae5f1c --- /dev/null +++ b/test/glendix/js/object_test_ffi.mjs @@ -0,0 +1,19 @@ +export function object_json(object) { + return JSON.stringify(object); +} + +export function proto_key_is_safe_data(object) { + const property = Object.getOwnPropertyDescriptor(object, "__proto__"); + return ( + Object.getPrototypeOf(object) === Object.prototype && + property !== undefined && + property.value === "safe" && + property.enumerable === true && + property.writable === true && + property.configurable === true + ); +} + +export function has_default_prototype(object) { + return Object.getPrototypeOf(object) === Object.prototype; +} diff --git a/test/glendix/js/reflect_test.gleam b/test/glendix/js/reflect_test.gleam new file mode 100644 index 0000000..f868eb7 --- /dev/null +++ b/test/glendix/js/reflect_test.gleam @@ -0,0 +1,105 @@ +//// Tests dynamic JavaScript property, method, and constructor reflection. +//// + +import gleeunit/should +import glendix/js/object +import glendix/js/reflect + +/// Verifies reflection reads existing values and preserves missing `undefined`. +pub fn get_reads_existing_and_missing_properties_test() -> Nil { + let handle = object.from_entries([#("theme", object.string("dark"))]) + handle + |> reflect.get(key: "theme") + |> value_to_string + |> should.equal("dark") + handle + |> reflect.get(key: "missing") + |> value_is_undefined + |> should.be_true +} + +/// Verifies a set overwrites in place and returns the same object handle. +pub fn set_overwrites_and_returns_same_handle_test() -> Nil { + let handle = object.from_entries([#("count", object.int(1))]) + let updated = reflect.set(on: handle, key: "count", to: object.int(7)) + same_object(handle, updated) + |> should.be_true + updated + |> reflect.get(key: "count") + |> value_to_string + |> should.equal("7") + updated + |> reflect.set(key: "added", to: object.bool(object.TrueValue)) + |> reflect.get(key: "added") + |> value_to_string + |> should.equal("true") +} + +/// Verifies deleting present or missing properties returns the same handle. +pub fn delete_removes_property_and_returns_same_handle_test() -> Nil { + let handle = object.from_entries([#("theme", object.string("dark"))]) + let cleared = reflect.delete(from: handle, key: "theme") + same_object(handle, cleared) + |> should.be_true + cleared + |> reflect.has(key: "theme") + |> should.be_false + cleared + |> reflect.delete(key: "missing") + |> same_object(cleared) + |> should.be_true +} + +/// Verifies presence checks retain JavaScript prototype-chain semantics. +pub fn has_reports_own_missing_and_inherited_properties_test() -> Nil { + let handle = object.from_entries([#("theme", object.string("dark"))]) + reflect.has(in: handle, key: "theme") + |> should.be_true + reflect.has(in: handle, key: "missing") + |> should.be_false + reflect.has(in: handle, key: "toString") + |> should.be_true +} + +/// Verifies method calls forward ordered arguments to the receiver. +pub fn call_method_passes_arguments_test() -> Nil { + method_object() + |> reflect.call_method(named: "add", with: [object.int(2), object.int(5)]) + |> value_to_string + |> should.equal("10") +} + +/// Verifies argument-free method calls bind the receiver as `this`. +pub fn call_method_without_arguments_reads_receiver_test() -> Nil { + method_object() + |> reflect.call_method_without_arguments(named: "describe") + |> value_to_string + |> should.equal("total:3") +} + +/// Verifies construction forwards every argument and returns the built object. +pub fn new_instance_constructs_object_test() -> Nil { + point_constructor() + |> reflect.new_instance(with: [object.int(3), object.int(4)]) + |> point_summary + |> should.equal("3,4") +} + +// -- FFI -- +@external(javascript, "./reflect_test_ffi.mjs", "value_to_string") +fn value_to_string(value: object.JsValue) -> String + +@external(javascript, "./reflect_test_ffi.mjs", "value_is_undefined") +fn value_is_undefined(value: object.JsValue) -> Bool + +@external(javascript, "./reflect_test_ffi.mjs", "method_object") +fn method_object() -> object.JsObject + +@external(javascript, "./reflect_test_ffi.mjs", "point_constructor") +fn point_constructor() -> reflect.JsConstructor + +@external(javascript, "./reflect_test_ffi.mjs", "same_object") +fn same_object(left: object.JsObject, right: object.JsObject) -> Bool + +@external(javascript, "./reflect_test_ffi.mjs", "point_summary") +fn point_summary(handle: object.JsObject) -> String diff --git a/test/glendix/js/reflect_test_ffi.mjs b/test/glendix/js/reflect_test_ffi.mjs new file mode 100644 index 0000000..cbbb86d --- /dev/null +++ b/test/glendix/js/reflect_test_ffi.mjs @@ -0,0 +1,36 @@ +export function value_to_string(value) { + return String(value); +} + +export function value_is_undefined(value) { + return value === undefined; +} + +export function method_object() { + return { + total: 3, + describe() { + return "total:" + this.total; + }, + add(first, second) { + return this.total + first + second; + }, + }; +} + +export function point_constructor() { + return class ReflectPoint { + constructor(x, y) { + this.x = x; + this.y = y; + } + }; +} + +export function same_object(left, right) { + return left === right; +} + +export function point_summary(point) { + return `${point.x},${point.y}`; +} diff --git a/test/glendix_test.gleam b/test/glendix_test.gleam index 6dee5b2..77c23bd 100644 --- a/test/glendix_test.gleam +++ b/test/glendix_test.gleam @@ -16,7 +16,6 @@ import glendix/js/array import glendix/js/environment import glendix/js/object import glendix/js/promise as glendix_promise -import glendix/js/reflect import glendix/lustre import lustre/attribute import lustre/element @@ -299,41 +298,6 @@ pub fn environment_unavailable_match_media_is_unresolved_test() -> Nil { |> should.equal(environment.ResolutionUnavailable) } -/// Verifies object construction preserves entry order. -pub fn object_from_entries_preserves_entry_order_test() -> Nil { - object.from_entries([ - #("theme", object.string("dark")), - #("locale", object.string("en")), - #("density", object.int(2)), - ]) - |> object_json - |> should.equal("{\"theme\":\"dark\",\"locale\":\"en\",\"density\":2}") -} - -/// Verifies an empty entry list safely builds an empty object. -pub fn object_from_entries_without_entries_is_empty_object_test() -> Nil { - object.from_entries([]) - |> object_json - |> should.equal("{}") -} - -/// Verifies a duplicate key keeps the last supplied value. -pub fn object_from_entries_duplicate_key_keeps_last_value_test() -> Nil { - object.from_entries([ - #("theme", object.string("light")), - #("theme", object.string("dark")), - ]) - |> object_json - |> should.equal("{\"theme\":\"dark\"}") -} - -/// Verifies a prototype-looking key remains ordinary own object data. -pub fn object_from_entries_proto_key_is_safe_data_test() -> Nil { - object.from_entries([#("__proto__", object.string("safe"))]) - |> object_json - |> should.equal("{\"__proto__\":\"safe\"}") -} - /// Verifies an object passes through Glendix bindings as one intact prop. pub fn binding_object_prop_preserves_object_test() -> Nil { let configuration = @@ -355,71 +319,6 @@ pub fn binding_object_prop_preserves_object_test() -> Nil { |> should.equal("{\"theme\":\"dark\",\"locale\":\"en\"}") } -/// Verifies reflection reads an existing own property from a data object. -pub fn reflect_get_reads_existing_property_test() -> Nil { - object.from_entries([#("theme", object.string("dark"))]) - |> reflect.get(key: "theme") - |> reflect_value_to_string - |> should.equal("dark") -} - -/// Verifies a set mutates in place and returns the same object handle. -pub fn reflect_set_overwrites_and_returns_same_handle_test() -> Nil { - let handle = object.empty() - let updated = reflect.set(on: handle, key: "count", to: object.int(7)) - reflect_same_object(handle, updated) - |> should.be_true - updated - |> reflect.get(key: "count") - |> reflect_value_to_string - |> should.equal("7") -} - -/// Verifies a delete removes the property and returns the same handle. -pub fn reflect_delete_removes_property_and_returns_same_handle_test() -> Nil { - let handle = object.from_entries([#("theme", object.string("dark"))]) - let cleared = reflect.delete(from: handle, key: "theme") - reflect_same_object(handle, cleared) - |> should.be_true - cleared - |> reflect.has(key: "theme") - |> should.be_false -} - -/// Verifies presence checks report both present and absent properties. -pub fn reflect_has_reports_property_presence_test() -> Nil { - let handle = object.from_entries([#("theme", object.string("dark"))]) - reflect.has(in: handle, key: "theme") - |> should.be_true - reflect.has(in: handle, key: "missing") - |> should.be_false -} - -/// Verifies method calls forward ordered arguments to the receiver. -pub fn reflect_call_method_passes_arguments_test() -> Nil { - reflect_method_object() - |> reflect.call_method(named: "add", with: [object.int(2), object.int(5)]) - |> reflect_value_to_string - |> should.equal("7") -} - -/// Verifies argument-free method calls bind the receiver as `this`. -pub fn reflect_call_method_without_arguments_reads_receiver_test() -> Nil { - reflect_method_object() - |> reflect.call_method_without_arguments(named: "describe") - |> reflect_value_to_string - |> should.equal("total:3") -} - -/// Verifies construction invokes `new` and returns the built object. -pub fn reflect_new_instance_constructs_object_test() -> Nil { - reflect_point_constructor() - |> reflect.new_instance(with: [object.int(3), object.int(4)]) - |> reflect.get(key: "x") - |> reflect_value_to_string - |> should.equal("3") -} - /// Verifies resolve fulfills a Promise with the supplied value. pub fn promise_resolve_yields_value_test() -> promise.Promise(Nil) { glendix_promise.resolve("glendix") @@ -565,9 +464,6 @@ fn stub_prefers_none() -> Nil @external(javascript, "./glendix_test_ffi.mjs", "clear_match_media") fn clear_match_media() -> Nil -@external(javascript, "./glendix_test_ffi.mjs", "object_json") -fn object_json(handle handle: object.JsObject) -> String - @external(javascript, "./glendix_test_ffi.mjs", "element_prop_json") fn element_prop_json(element element: redraw.Element, key key: String) -> String @@ -578,21 +474,6 @@ fn element_prop_is( expected expected: object.JsObject, ) -> Bool -@external(javascript, "./glendix_test_ffi.mjs", "reflect_value_to_string") -fn reflect_value_to_string(value value: object.JsValue) -> String - -@external(javascript, "./glendix_test_ffi.mjs", "reflect_method_object") -fn reflect_method_object() -> object.JsObject - -@external(javascript, "./glendix_test_ffi.mjs", "reflect_point_constructor") -fn reflect_point_constructor() -> reflect.JsConstructor - -@external(javascript, "./glendix_test_ffi.mjs", "reflect_same_object") -fn reflect_same_object( - left left: object.JsObject, - right right: object.JsObject, -) -> Bool - @external(javascript, "./glendix_test_ffi.mjs", "new_promise_callback_counter") fn new_promise_callback_counter() -> PromiseCallbackCounter diff --git a/test/glendix_test_ffi.mjs b/test/glendix_test_ffi.mjs index 0b6dd2c..592e54f 100644 --- a/test/glendix_test_ffi.mjs +++ b/test/glendix_test_ffi.mjs @@ -226,10 +226,6 @@ export function clear_match_media() { delete globalThis.matchMedia; } -export function object_json(object) { - return JSON.stringify(object); -} - export function element_prop_json(element, key) { return JSON.stringify(element.props[key]); } @@ -238,35 +234,6 @@ export function element_prop_is(element, key, expected) { return element.props[key] === expected; } -export function reflect_value_to_string(value) { - return String(value); -} - -export function reflect_method_object() { - return { - total: 3, - describe() { - return "total:" + this.total; - }, - add(first, second) { - return first + second; - }, - }; -} - -export function reflect_point_constructor() { - return class ReflectPoint { - constructor(x, y) { - this.x = x; - this.y = y; - } - }; -} - -export function reflect_same_object(left, right) { - return left === right; -} - export function new_promise_callback_counter() { return { count: 0 }; }