Guard the rest of the family: controls that render but do nothing

d154f71ed43e · AtlantisPleb · · parent 747f1596ed6c

Guard the rest of the family: controls that render but do nothing

The submit-button defect was one instance of a class, so this covers the
neighbours. Five rules, read from the templates:

  * a `.link` with no navigate, patch, or href
  * a `popovertarget` naming an id that is not in its template
  * an `aria-controls`, `aria-labelledby`, or `aria-describedby` naming one
  * a `phx-click` on a div, td, or span that neither takes focus nor holds a
    real control
  * the same literal id declared twice in one template

Each was verified by injecting the defect it describes and watching it fail. A
guard that has never fired is not known to work, and two of these did not: the
tag scanner required a letter after `<`, so every `.link` and `UI.button` in
the codebase was invisible to it, and the "holds a real control" allowance
read four thousand characters *after* the tag rather than the element's body,
so it found some control nearly always and excused everything. Both are fixed;
the scanner walks tags rather than matching them, because a regex stops at the
first `>` inside a `{...}` expression and silently truncates the attributes.

A `phx-hook` without an id belongs to this family and is deliberately absent:
LiveView's compiler already raises on it, and a rule that repeats the compiler
adds noise without adding protection.

The table loses `row_click`, which nothing used. A `phx-click` on a `td` is
not reachable by keyboard and announces nothing, so a table built that way is
usable only with a mouse -- keeping the attribute meant keeping a trap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/components/ui.ex
  • added test/openagents_web/ui_contracts_test.exs

Diff

2 files changed, +255 -6

lib/openagents_web/components/ui.ex modified +4 -6

@@ -336,7 +336,6 @@ defmodule OpenAgentsWeb.UI do

336 336
  attr :id, :string, required: true
337 337
  attr :rows, :list, required: true
338 338
  attr :row_id, :any, default: nil
339
  attr :row_click, :any, default: nil
340 339
  attr :row_item, :any, default: &Function.identity/1
341 340
342 341
  slot :col, required: true do

@@ -364,11 +363,10 @@ defmodule OpenAgentsWeb.UI do

364 363
        </thead>
365 364
        <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
366 365
          <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="hover:bg-muted/40">
367
            <td
368
              :for={column <- @col}
369
              phx-click={@row_click && @row_click.(row)}
370
              class={["px-3 py-2", @row_click && "hover:cursor-pointer"]}
371
            >
366
            <%!-- No row-level click. A `phx-click` on a `td` is not reachable
367
            by keyboard and announces nothing, so a table built that way is
368
            usable only with a mouse. Put a real control in a cell instead. --%>
369
            <td :for={column <- @col} class="px-3 py-2">
372 370
              {render_slot(column, @row_item.(row))}
373 371
            </td>
374 372
            <td :if={@action != []} class="w-0 px-3 py-2 font-semibold">
test/openagents_web/ui_contracts_test.exs added +251

@@ -0,0 +1,251 @@

1
defmodule OpenAgentsWeb.UIContractsTest do
2
  @moduledoc """
3
  Contracts that hold across every template, checked by reading the source.
4
5
  These catch a specific class of defect: a control that renders, looks
6
  finished, and does nothing. A behavioural test cannot see it. `render_submit`
7
  submits the form without touching the button; `render_click` pushes the event
8
  the element declares whether or not a browser could ever reach it; a hook that
9
  never mounts leaves the markup identical. In every case the server path is
10
  exercised and correct while the thing a person clicks is dead.
11
12
  Seven such defects shipped before the first of these existed (see
13
  `OpenAgentsWeb.FormSubmitButtonsTest`). The rules below are the ones that
14
  would have caught them, plus the neighbouring cases in the same family.
15
16
  A `phx-hook` without an id belongs to this family and is deliberately not
17
  checked here: LiveView's own compiler raises on it, and a rule that repeats
18
  the compiler adds noise without adding protection.
19
20
  Each rule is deliberately narrow. A rule that fires on correct markup gets
21
  suppressed rather than fixed, and then it protects nothing.
22
  """
23
24
  use ExUnit.Case, async: true
25
26
  @interactive ~w(button a input select textarea summary)
27
28
  describe "controls that would be inert" do
29
    test "every .link has somewhere to go" do
30
      # A link that spreads a global is excluded: its destination arrives in
31
      # `@rest`, so the call site decides whether there is one. Both such links
32
      # in the component library also guard themselves with an `:if` on exactly
33
      # those keys, which is the honest way to write it.
34
      assert_no_offenders(
35
        fn name, attrs, _body ->
36
          name == ".link" and
37
            not Enum.any?(~w(navigate patch href), &has?(attrs, &1)) and
38
            not String.contains?(attrs, "{@rest}")
39
        end,
40
        "are links with no navigate, patch, or href"
41
      )
42
    end
43
44
    test "no popover trigger names a target that does not exist in its template" do
45
      assert_no_dangling("popovertarget")
46
    end
47
  end
48
49
  describe "controls that would be unreachable" do
50
    test "a phx-click on a non-interactive element is reachable another way" do
51
      # A div or td that handles a click is not focusable and announces
52
      # nothing, so it is mouse-only. It is fine when the element declares a
53
      # role and takes focus, or when it holds a real control that does the
54
      # same thing -- clicking the surrounding region is then a convenience.
55
      assert_no_offenders(
56
        fn name, attrs, body ->
57
          name in ~w(div span li td tr section article p dl dd) and
58
            has?(attrs, "phx-click") and
59
            not (has?(attrs, "role") and has?(attrs, "tabindex")) and
60
            not contains_control?(body)
61
        end,
62
        "handle a click but cannot be reached by keyboard"
63
      )
64
    end
65
66
    test "no aria reference points at an id that is not in the same template" do
67
      for attribute <- ~w(aria-controls aria-labelledby aria-describedby) do
68
        assert_no_dangling(attribute)
69
      end
70
    end
71
  end
72
73
  describe "identity" do
74
    test "no template declares the same literal id twice" do
75
      offenders =
76
        for path <- templates(),
77
            source = File.read!(path),
78
            {id, count} <-
79
              Enum.frequencies(
80
                Regex.scan(~r/\bid="([^"{}]+)"/, source, capture: :all_but_first)
81
                |> List.flatten()
82
              ),
83
            count > 1,
84
            do: {path, id}
85
86
      assert offenders == [], """
87
      These templates declare the same id more than once. Duplicate ids break
88
      LiveView's DOM patching and every aria reference that names them:
89
90
      #{format(offenders)}
91
      """
92
    end
93
  end
94
95
  # ── scanning ──────────────────────────────────────────────────────────────
96
97
  defp assert_no_offenders(predicate, description) do
98
    offenders =
99
      for path <- templates(),
100
          {name, attrs, body, line} <- tags(File.read!(path)),
101
          predicate.(name, attrs, body),
102
          do: {path, line, name}
103
104
    assert offenders == [], """
105
    These elements #{description}:
106
107
    #{format(offenders)}
108
    """
109
  end
110
111
  defp assert_no_dangling(attribute) do
112
    offenders =
113
      for path <- templates(),
114
          source = File.read!(path),
115
          ids = literal_ids(source),
116
          {_name, attrs, _body, line} <- tags(source),
117
          reference <- references(attrs, attribute),
118
          reference not in ids,
119
          do: {path, line, "#{attribute}=#{reference}"}
120
121
    assert offenders == [], """
122
    These `#{attribute}` values name an id that does not appear in the same
123
    template, so the relationship they declare does not exist:
124
125
    #{format(offenders)}
126
    """
127
  end
128
129
  defp templates do
130
    Path.wildcard("lib/openagents_web/**/*.ex") ++ Path.wildcard("lib/openagents_web/**/*.heex")
131
  end
132
133
  defp literal_ids(source) do
134
    ~r/\bid="([^"{}]+)"/
135
    |> Regex.scan(source, capture: :all_but_first)
136
    |> List.flatten()
137
    |> MapSet.new()
138
  end
139
140
  # Only literal references are checked. An interpolated one is computed, and
141
  # guessing at what it computes to would produce noise rather than findings.
142
  defp references(attrs, attribute) do
143
    case Regex.run(~r/#{attribute}="([^"{}]+)"/, attrs, capture: :all_but_first) do
144
      [value] -> String.split(value)
145
      nil -> []
146
    end
147
  end
148
149
  defp has?(attrs, attribute), do: Regex.match?(~r/(?<![-\w])#{Regex.escape(attribute)}=/, attrs)
150
151
  defp contains_control?(body) do
152
    Enum.any?(@interactive, &String.contains?(body, "<#{&1}")) or
153
      String.contains?(body, "<.button") or String.contains?(body, "<UI.button") or
154
      String.contains?(body, "<.link") or String.contains?(body, "<UI.")
155
  end
156
157
  # HEEx attributes hold `{...}` expressions that themselves hold braces and
158
  # quotes, so the tag is walked rather than matched: a regex stops at the
159
  # first `>` inside an expression and silently truncates the attributes,
160
  # which reads as an element that is missing whatever came after it.
161
  defp tags(source) do
162
    do_tags(source, 0, [])
163
  end
164
165
  defp do_tags(source, from, acc) do
166
    case :binary.match(source, "<", scope: {from, byte_size(source) - from}) do
167
      :nomatch ->
168
        Enum.reverse(acc)
169
170
      {start, _length} ->
171
        case Regex.run(
172
               ~r/^<([A-Za-z.][\w.:]*)/,
173
               binary_part(source, start, min(64, byte_size(source) - start)),
174
               return: :index
175
             ) do
176
          nil ->
177
            do_tags(source, start + 1, acc)
178
179
          [{_, _}, {name_start, name_length}] ->
180
            name = binary_part(source, start + name_start, name_length)
181
            attrs_start = start + name_start + name_length
182
            attrs_end = close_of(source, attrs_start, 0, nil)
183
            attrs = binary_part(source, attrs_start, attrs_end - attrs_start)
184
            line = count_lines(source, start)
185
            body = body_of(source, name, attrs_end)
186
            do_tags(source, attrs_end + 1, [{name, attrs, body, line} | acc])
187
        end
188
    end
189
  end
190
191
  defp close_of(source, index, depth, quote_char) when index < byte_size(source) do
192
    char = binary_part(source, index, 1)
193
194
    cond do
195
      quote_char && char == quote_char -> close_of(source, index + 1, depth, nil)
196
      quote_char -> close_of(source, index + 1, depth, quote_char)
197
      char in ["\"", "'"] -> close_of(source, index + 1, depth, char)
198
      char == "{" -> close_of(source, index + 1, depth + 1, nil)
199
      char == "}" -> close_of(source, index + 1, depth - 1, nil)
200
      char == ">" and depth == 0 -> index
201
      true -> close_of(source, index + 1, depth, nil)
202
    end
203
  end
204
205
  defp close_of(source, _index, _depth, _quote), do: byte_size(source)
206
207
  # The element's own body, found by counting nested opens of the same name.
208
  # Taking a fixed window after the tag instead means the scan sees whatever
209
  # follows the element in the file, which excuses every element in it.
210
  defp body_of(source, name, from) do
211
    open = "<" <> name
212
    close = "</" <> name <> ">"
213
    scan_body(source, open, close, from, from, 1)
214
  end
215
216
  defp scan_body(source, open, close, body_start, index, depth) do
217
    next_open = :binary.match(source, open, scope: {index, byte_size(source) - index})
218
    next_close = :binary.match(source, close, scope: {index, byte_size(source) - index})
219
220
    case {next_open, next_close} do
221
      {_, :nomatch} ->
222
        ""
223
224
      {:nomatch, {c, _len}} when depth == 1 ->
225
        binary_part(source, body_start, c - body_start)
226
227
      {:nomatch, {c, len}} ->
228
        scan_body(source, open, close, body_start, c + len, depth - 1)
229
230
      {{o, olen}, {c, _clen}} when o < c ->
231
        scan_body(source, open, close, body_start, o + olen, depth + 1)
232
233
      {_, {c, _clen}} when depth == 1 ->
234
        binary_part(source, body_start, c - body_start)
235
236
      {_, {c, clen}} ->
237
        scan_body(source, open, close, body_start, c + clen, depth - 1)
238
    end
239
  end
240
241
  defp count_lines(source, upto) do
242
    source |> binary_part(0, upto) |> :binary.matches("\n") |> length() |> Kernel.+(1)
243
  end
244
245
  defp format(offenders) do
246
    Enum.map_join(offenders, "\n", fn
247
      {path, line, detail} -> "  #{path}:#{line}  #{detail}"
248
      {path, detail} -> "  #{path}  #{detail}"
249
    end)
250
  end
251
end

This page updates live while a promote is in flight · changelog