# `PhoenixKit.Conformance.ComponentAssigns`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.13.7/lib/phoenix_kit/conformance/component_assigns.ex#L1)

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.

# `violation`

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

# `violations`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
