From cd90026311c5c65ade1c7c01e50b3f885100a095 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 30 Sep 2026 04:55:21 -0600 Subject: [PATCH] Reads a spelled name inside a push expression The assets test's push classifier read a push whose name is an expression carrying a literal (a fallback such as gesture.event || "refresh") or a name held in an object literal (const names = {select: "x"}; pushEvent(names.select)) as the host's expression. Its stamped-name rule is per file, so a draw hook that reads some el.dataset.*Event could push a name of its own that way and still pass as draw-only unless it spelled a command or list event name. push_name/2 now resolves object.key through an object literal the file binds object to, and reads any quoted name inside the argument as the hook's own. A new test holds four probes, two per shape, to pushing their own name, each alone and after a stamped-name binding. Sabotaged: dropping either new clause turns the new test red; a map hook pushing gesture.event || "refresh" now fails the draw-only test as well as the exact-source one. Test-only; no lib change. Gate: full mix quality green on this staged tree (3,952 of 3,952 tests, 95.3% coverage, dialyzer clean, ADR cites green). Refs: sb-wcsm --- test/statifier_blocks/assets_test.exs | 57 +++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/test/statifier_blocks/assets_test.exs b/test/statifier_blocks/assets_test.exs index d3b9c506..9cbad92a 100644 --- a/test/statifier_blocks/assets_test.exs +++ b/test/statifier_blocks/assets_test.exs @@ -152,6 +152,33 @@ defmodule StatifierBlocks.AssetsTest do end end + # A name the hook spells is its own when an expression carries it, as + # a fallback beside a stamped name, and when an object literal holds it, + # even in a file that reads a stamped name elsewhere: the stamped-name + # rule is per file, so only the push itself can tell the two apart. + # Sabotage: dropping the quoted-name-inside-an-expression clause from + # `push_name/2` - the fallback probes read as expressions and pass as + # draw-only - and this goes red. + # Sabotage: dropping the object-literal clause from `push_name/2` - the + # object-held probes read as expressions and pass as draw-only - and + # this goes red. + test "a name inside an expression or an object is the hook's own" do + stamped = ~S|const events = {select: this.el.dataset.selectEvent}; | + + for probe <- [ + ~S[this.pushEvent(gesture.event || "select-row", {})], + ~S|this.pushEventTo(this.el, events.select ?? 'select-row', {})|, + ~S|const names = {select: "select-row"}; this.pushEvent(names.select, {})|, + ~S|let names = {open: 'x', select: `select-row`}; this.pushEventTo(this.el, names.select, {})| + ] do + for text <- [probe, stamped <> probe] do + assert push_names(text) == [{:literal, "select-row"}], text + assert pushes_own_name?(text), text + refute kind(text) == :draw, text + end + end + end + # 7f's rule is that a draw-only hook's pushes come from the names the # host stamped, so a hook whose pushes are all expressions but that # reads no stamped event name is not draw-only. @@ -964,9 +991,10 @@ defmodule StatifierBlocks.AssetsTest do # The event name each push in the JavaScript `text` sends: `{:literal, # name}` for a name it spells - in double quotes, single quotes or a - # template, or through a `const`, `let` or `var` bound to one of those - - # and `{:expression, text}` for one it reads from elsewhere. - # `pushEventTo`'s first argument is the target and is skipped. + # template, through a `const`, `let` or `var` bound to one of those, as a + # key of an object literal bound that way, or anywhere inside the + # argument's expression - and `{:expression, text}` for one it reads from + # elsewhere. `pushEventTo`'s first argument is the target and is skipped. defp push_names(text) do ~r/\b(?:pushEvent\s*\(|pushEventTo\s*\(\s*(?:#{@quoted}|[^,"'`]+?)\s*,)\s*(#{@quoted}|[^,)]+)/ |> Regex.scan(text) @@ -977,6 +1005,8 @@ defmodule StatifierBlocks.AssetsTest do cond do argument =~ ~r/\A(?:#{@quoted})\z/ -> {:literal, unquote_name(argument)} held = held_name(argument, text) -> {:literal, held} + keyed = keyed_name(argument, text) -> {:literal, keyed} + spelled = Regex.run(~r/#{@quoted}/, argument) -> {:literal, unquote_name(hd(spelled))} true -> {:expression, argument} end end @@ -995,6 +1025,27 @@ defmodule StatifierBlocks.AssetsTest do end end + # The name `object.key` holds when `text` binds `object`, in any of its + # bindings, to an object literal whose `key` is a quoted name. + defp keyed_name(argument, text) do + case Regex.run(~r/\A([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\z/, argument) do + [_all, object, key] -> + ~r/\b(?:const|let|var)\s+#{Regex.escape(object)}\s*=\s*\{([^}]*)\}/ + |> Regex.scan(text, capture: :all_but_first) + |> Enum.find_value(fn [body] -> keyed_value(body, key) end) + + nil -> + nil + end + end + + defp keyed_value(body, key) do + case Regex.run(~r/(?:\A|[\s,{])#{Regex.escape(key)}\s*:\s*(#{@quoted})/, body) do + [_all, quoted] -> unquote_name(quoted) + nil -> nil + end + end + defp unquote_name(quoted), do: String.slice(quoted, 1..-2//1) # Whether `text` pushes an event name of its own: a literal other than