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`, ``/`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/ 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