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/slotimmediately 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/1is 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
assignsVAR holds when~Hruns, and keys enter it only throughassigns = .... So keys are collected exclusively from the right-hand side of such rebinds (assign/2,3,assign_new/3, literal-keyMap.put/3, literal-mapMap.merge/2, rooted atassigns, straight or piped). A DISCARDEDassign(assigns, :x, v)feeds nothing and donates nothing, andMap.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
@type violation() :: %{ file: String.t(), line: pos_integer(), component: atom(), assign: atom() }