PhoenixKit.Conformance.ComponentAssigns (phoenix_kit v2.7.0)

Copy Markdown View Source

Static check: a HEEx function component must not read an assign it neither declares nor assigns itself.

Inside a function component, @x reads that component's OWN assigns. An assign that is neither declared as an attr/slot nor put there in the body raises KeyError when — and only when — the branch reading it renders. The compiler says nothing: Phoenix's declarative-assigns validation checks the CALL SITE (unknown attrs passed, required attrs missing) and never inspects the callee body, deliberately, because bodies may compute assigns dynamically. Verified empirically: the body-read shape produces zero diagnostics while the call-site shape warns.

That silence let phoenix_kit_open_graph's assignment modal ship a KeyError :preview_loading behind an :if={...} guard for four releases (BeamLabEU/phoenix_kit_open_graph#7). This module is the tree-wide guard for the class; one repo's render test covers one branch of one component, while every module in this ecosystem compiles the same blind spot.

Policy — deliberately conservative

A guard that cries wolf gets deleted, so v1 only reports what it can defend:

  • Only components that opt into declarative assigns — at least one attr/slot immediately above the definition. A component with no declarations legitimately receives whatever its callers pass; judging it requires call-site analysis this check does not do.
  • render/1 is skipped — LiveView/extracted-template modules read the socket's assigns, which are not declared per-component.
  • Self-assigned counts as declared — only via a rebind: the template reads whatever the assigns VAR holds when ~H runs, and keys enter it only through assigns = .... So keys are collected exclusively from the right-hand side of such rebinds (assign/2,3, assign_new/3, literal-key Map.put/3, literal-map Map.merge/2, rooted at assigns, straight or piped). A DISCARDED assign(assigns, :x, v) feeds nothing and donates nothing, and Map.put(assigns.user, :src, ...) puts a key into a sub-map, not into assigns.
  • Reserved assigns (@inner_block, @myself, @rest, @flash, @socket, and LiveView's internals) always pass, as do declared slot names.
  • Only code is scanned, ever: balanced {...} expressions outside <style>, plus <%= ... %> blocks everywhere (those interpolate even inside <style>, where {...} and #{...} stay literal character data). Prose, emails, CSS at-rules and showcase snippets are never looked at, rather than stripped-and-hopefully-not-missed. Within a code segment, string/sigil literals are removed and their #{...} interpolations recursed into — nested ones included.
  • Escape hatch: allow: %{"file_suffix.ex" => [:assign]} for a component doing something the analysis cannot follow (assigns_to_attributes/2, dynamic merge, macro-generated bodies). Allowlisting is a documented decision, not a silent skip.

Usage

violations =
  PhoenixKit.Conformance.ComponentAssigns.violations(
    Path.wildcard("lib/**/*.ex")
  )

assert violations == []

Each violation is %{file:, line:, component:, assign:}. The paired test in this repo runs it over core's own lib/; sibling modules can do the same once their core pin ships this module, or be swept from a workspace script in the meantime.

Summary

Functions

Scans paths (a list of .ex files) and returns every undeclared read.

Types

violation()

@type violation() :: %{
  file: String.t(),
  line: pos_integer(),
  component: atom(),
  assign: atom()
}

Functions

violations(paths, opts \\ [])

@spec violations(
  [Path.t()],
  keyword()
) :: [violation()]

Scans paths (a list of .ex files) and returns every undeclared read.

Options:

  • :allow — map of file suffix => list of assign atoms to tolerate there.