Packages

A real-browser external-app verifier for patch-safe Phoenix LiveView interactions, with secondary unstyled reference components.

Current section

Files

Jump to
live_interaction_contracts lib mix tasks live_interaction_contracts.audit.ex
Raw

lib/mix/tasks/live_interaction_contracts.audit.ex

defmodule Mix.Tasks.LiveInteractionContracts.Audit do
@shortdoc "Static audit of a LiveView app for patch-safety / dual-write risks"
@moduledoc """
Scans a target LiveView app's source for the KERNEL invariants that predict
interaction bugs under DOM patching, and reports candidate risk sites.
mix live_interaction_contracts.audit --path /path/to/app
mix live_interaction_contracts.audit --path . --out interaction-audit.md
This is **advisory** — it produces a candidate list (file:line), not a verdict.
High-confidence rules are syntactic; the low-confidence ones (marked) need human
review because they depend on server-side dataflow the scan cannot see.
Rules (each maps to a frozen contract):
* R1 reflected-attribute write without protection (KERNEL rule 1.3 / CURSOR §focus,
REFERENCE_POPOVER): a JS hook `setAttribute("aria-*"|"data-active"|"data-state")`
whose element likely has no `JS.ignore_attributes` — a patch touching it strips
the attribute. **High confidence when the repo has zero ignore_attributes.**
* R2 `phx-update="ignore"` scope (KERNEL Delegation): flags each site to check it
does not wrap server-rendered dynamic content (interpolation / comprehension),
which `ignore` would then freeze.
* R3 roving tabindex in a patchable list (CURSOR_CONTRACT): `tabindex` on a
non-form element, esp. near `role="option|menuitem|treeitem"` — not patch-safe;
prefer aria-activedescendant.
* R4 inventory: every `phx-hook`, `<dialog>`/`showModal`/modal wrapper, and
template `aria-expanded`/`aria-activedescendant` — the interaction surface map.
* R5 (low confidence) client event names that look like open/close/toggle reaching
the server via `pushEvent` — candidates for "server derives canonical open state
from a lossy signal" (Observation contract). Human review required.
"""
use Mix.Task
@src_exts ~w(.ex .heex)
@js_exts ~w(.js .ts)
@skip ~w(node_modules vendor deps _build .git priv/static assets/vendor)
@impl true
def run(argv) do
{opts, _, _} = OptionParser.parse(argv, strict: [path: :string, out: :string])
root = Path.expand(opts[:path] || File.cwd!())
unless File.dir?(root), do: Mix.raise("--path #{root} is not a directory")
files = collect_files(root)
src = Enum.filter(files, &(Path.extname(&1) in @src_exts))
js = Enum.filter(files, &(Path.extname(&1) in @js_exts))
ignore_attrs_count =
src |> Enum.map(&count_in_file(&1, ~r/ignore_attributes/)) |> Enum.sum()
findings =
r1(js, ignore_attrs_count) ++
r2(src) ++ r3(src) ++ r4(src) ++ r5(js)
report = render(root, files, src, js, ignore_attrs_count, findings)
out = opts[:out] || Path.join(File.cwd!(), "interaction-audit.md")
File.write!(out, report)
Mix.shell().info(report)
Mix.shell().info("\n== audit written to #{out}")
end
# ---- file collection ----
defp collect_files(root) do
root
|> Path.join("**/*")
|> Path.wildcard(match_dot: false)
|> Enum.reject(&File.dir?/1)
|> Enum.reject(fn f -> Enum.any?(@skip, &String.contains?(f, "/" <> &1 <> "/") || String.ends_with?(f, "/" <> &1)) end)
|> Enum.filter(&(Path.extname(&1) in @src_exts ++ @js_exts))
end
defp scan(files, regex) do
for f <- files, {line, n} <- Enum.with_index(File.stream!(f), 1), Regex.match?(regex, line) do
{f, n, String.trim(line)}
end
end
defp count_in_file(f, regex), do: length(scan([f], regex))
# ---- rules ----
# R1 is a real dual-write risk ONLY when the hook decorates a SERVER-rendered
# element. A hook that BUILDS its own subtree (createElement/innerHTML) is a
# client-owned island LiveView never patches — its attribute writes are safe.
# Distinguish per file so the tool does not cry wolf on rendering islands.
defp r1(js, ignore_count) do
Enum.flat_map(js, fn f ->
builder? = Regex.match?(~r/createElement(NS)?\(|\.innerHTML\s*=/, File.read!(f))
writes = scan([f], ~r/setAttribute\(\s*['"](aria-[a-z]+|data-active|data-state|data-open|data-expanded)/)
Enum.map(writes, fn {file, n, s} ->
if builder? do
{"R1-info reflected-attr write inside a CLIENT-BUILT subtree — likely safe island (verify the container is not server-patched / is phx-update=ignore)", file, n, snippet(s)}
else
conf = if ignore_count == 0, do: "HIGH (0 ignore_attributes in repo)", else: "REVIEW"
{"R1 reflected-attr write on SERVER DOM without ignore_attributes [#{conf}]", file, n, snippet(s)}
end
end)
end)
end
defp r2(src) do
scan(src, ~r/phx-update=\{?["']?ignore/)
|> Enum.map(fn {f, n, s} -> {"R2 phx-update=ignore — verify it does not freeze server-rendered content", f, n, snippet(s)} end)
end
# Roving tabindex is a risk only when it cycles focus across LIST ITEMS. A
# conditional tabindex on a single button/row (`tabindex={@loading && "-1"}`) is a
# focus toggle, not roving. Fire only when the file also carries a listbox/menu/
# tree/grid item role — where roving would actually live.
defp r3(src) do
roving_files =
src
|> Enum.filter(fn f -> Regex.match?(~r/role=["']?\{?["']?(option|menuitem|treeitem|gridcell|row)["']?/, File.read!(f)) end)
|> MapSet.new()
scan(src, ~r/tabindex/)
|> Enum.filter(fn {f, _, _} -> MapSet.member?(roving_files, f) end)
|> Enum.reject(fn {_, _, s} -> Regex.match?(~r/<(input|select|textarea|button|a)\b/, s) end)
|> Enum.map(fn {f, n, s} -> {"R3 tabindex in a file with list-item roles — check it is not roving over a patchable list (prefer aria-activedescendant)", f, n, snippet(s)} end)
end
defp r4(src) do
inv = fn regex, label -> scan(src, regex) |> Enum.map(fn {f, n, s} -> {"R4 inventory: #{label}", f, n, snippet(s)} end) end
inv.(~r/phx-hook=/, "phx-hook callsite") ++
inv.(~r/<dialog\b|showModal|def modal|<\.modal\b/, "dialog/modal") ++
inv.(~r/aria-expanded|aria-activedescendant/, "template-managed reflected ARIA")
end
defp r5(js) do
scan(js, ~r/pushEvent\(\s*['"][a-z_]*(open|close|toggle|expand|collapse|select|dismiss)/i)
|> Enum.map(fn {f, n, s} -> {"R5 (low conf) open/close-ish client event → server — check server does not treat it as canonical state", f, n, snippet(s)} end)
end
defp snippet(s), do: s |> String.slice(0, 120)
# ---- report ----
defp render(root, files, src, js, ignore_count, findings) do
by_rule = Enum.group_by(findings, fn {rule, _, _, _} -> rule end)
rel = fn f -> Path.relative_to(f, root) end
header = """
# Interaction Audit — #{Path.basename(root)}
Static, advisory scan for patch-safety / dual-write risk (KERNEL invariants).
**Candidate list, not a verdict** — each finding needs human confirmation.
Scanned: #{length(files)} files (#{length(src)} .ex/.heex, #{length(js)} .js/.ts) under `#{root}`.
Repo-wide `JS.ignore_attributes` count: **#{ignore_count}**.
## Summary
| Rule | Count |
|---|---|
"""
summary =
by_rule
|> Enum.map(fn {rule, fs} -> "| #{rule} | #{length(fs)} |" end)
|> Enum.sort()
|> Enum.join("\n")
body =
by_rule
|> Enum.sort_by(fn {rule, _} -> rule end)
|> Enum.map(fn {rule, fs} ->
lines =
fs
|> Enum.sort()
|> Enum.map(fn {_, f, n, s} -> "- `#{rel.(f)}:#{n}` — `#{s}`" end)
|> Enum.join("\n")
"\n### #{rule}\n\n#{lines}\n"
end)
|> Enum.join("")
header <> summary <> "\n" <> body
end
end