# How to write a host advisory finding

This guide shows you how to put one of your own rules on the author's screen
as a warning, without teaching this package anything about it and without
stopping the document from compiling.

The channel is `StatifierBlocks.DocumentValidator`: a module of yours, listed
on the palette under `:validators`, handed the whole document as authored and
answering with whatever it objects to.
[`docs/adr/0005-liveview-editor.md`](https://github.com/riddler/statifier_blocks/blob/main/docs/adr/0005-liveview-editor.md) clauses `11p`
to `11t` are the record; everything below is the moves.

## Before you start

You need a palette you build yourself - `StatifierBlocks.Palette.new/2` or
`from_modules/2` - because `:validators` is an option on that call. A host
that renders `StatifierBlocks.Editor` already builds one.

Your rule has to be answerable from the document alone. The callback is pure
and it is handed one value; a rule that needs the datamodel, a fixture or a
network call is yours to evaluate before you build the palette, and what you
learned rides into the module as data (below, `@dropped` is exactly that).

## The rule

Take a patron registration document. Your datamodel document redefined the
`patron` record and dropped `age_band` from it, so every config value in every
open draft that still reads that member now reads a path the datamodel no
longer covers. The compiler will not say so - the member's absence is a fact about
your datamodel, not about this document - but an author looking at the branch
should see it.

Which members went away is the half you work out yourself, before the palette
exists. The rule is the half that finds them in the document:

```elixir
defmodule MyApp.Rules.RecordCoverage do
  @moduledoc """
  Warns where a config value still reads a member the `patron` record lost.
  """

  @behaviour StatifierBlocks.DocumentValidator

  alias StatifierBlocks.Document

  # What redefining `patron` dropped. Derived from your own datamodel diff and
  # baked in here as data, because the callback itself is pure.
  @dropped ~w(age_band)

  @impl true
  def validate_document(%Document{} = document) do
    document |> Document.blocks() |> Enum.flat_map(&block_findings/1)
  end

  defp block_findings(block) do
    for {key, value} <- block.config, member <- @dropped, reads?(value, member) do
      {{:config, block.id, key},
       "`patron.#{member}` is no longer a member of the `patron` record, so this " <>
         "reads a path the datamodel no longer covers", severity: :warning}
    end
  end

  defp reads?(value, member) when is_binary(value), do: String.contains?(value, member)
  defp reads?(value, member) when is_list(value), do: Enum.any?(value, &reads?(&1, member))

  defp reads?(value, member) when is_map(value),
    do: value |> Map.values() |> Enum.any?(&reads?(&1, member))

  defp reads?(_value, _member), do: false
end
```

Two things about the return value are worth naming, because they are what make
the finding land where an author can act on it:

- **The anchor is `{:config, block_id, key}`**, not `{:block, block_id}`. Both
  are legal (`t:StatifierBlocks.Finding.anchor/0` also has `{:slot, id, name}`),
  but a config anchor names the field somebody typed the value into, and that
  is what carries the message into the inspector for that block instead of
  leaving it only on the card - under the control itself when the key names a
  form field, and in the block's Findings panel either way.
- **`severity: :warning`** is the default, so you may leave it off. It is
  written out here because a rule that means `:info` or `:error` says so in
  the same place, and because the severity is the one thing about the finding
  a host actually chooses. The *source* is not: this package stamps `:lint` on
  everything a validator returns, and nothing you put in the keyword list
  changes that.

## Registering it

`:validators` is a list, and every module in it runs, in list order:

```elixir
palette =
  StatifierBlocks.Palette.new(my_types,
    recipes: my_recipes,
    validators: [MyApp.Rules.RecordCoverage]
  )
```

It is a list rather than a `name => module` map on purpose: nothing resolves a
validator by name, so there is no key to hold one, and a second entry does not
replace a first. A palette that declares none pays nothing.

## Where the finding shows

Once the palette carries the rule, the finding travels the routes decision 11
already had. Nothing in the editor was taught about validators.

| Surface | What you see |
|---|---|
| The drawer's **Findings** tab | one row, stamped `data-anchor="config:<block id>:<key>"`, `data-source="lint"`, `data-severity="warning"` |
| The inspector's **Findings** tab | the same row, for the selected block, on the same anchor |
| The inspector's config form | under the control, when the key names a form field |
| The Findings tab's **count** | includes it |
| `StatifierBlocks.Editor.findings_count/3` | includes it |

The count is the part worth being explicit about, because
`StatifierBlocks.Editor`'s moduledoc section *the findings number a host may
show* names three producers and a validator is a
fourth. There is only one number: `StatifierBlocks.Shell.findings_count/1`
counts `ViewModel.findings`, the validator findings are appended into that
same list by `StatifierBlocks.ViewModel.build/3`, and `findings_count/3` reads
it through the same code the tab chip does. So a host header that reports the
count moves when your rule fires, and it cannot disagree with the drawer.

The publish check is one more place the row shows, because it runs the same
functions. Your publish step calls `StatifierBlocks.Publish.findings/3` over
the revision it is about to publish - the document as saved - and then
`StatifierBlocks.Compiler.compile/3`: the first composes the editor's own
list, `ViewModel.build/3` and so your validators included, so the finding the
author saw in the drawer is in the answer the publish step reads, with the
severity you gave it. An `:error` from either call refuses the publish; a
`:warning` - the default here - is your call, and a reasonable host publishes
over one and shows it rather than dropping it; an `:info` never refuses. In a
patron registration document, a rule that flags a `core.assign` writing
`patron.card_number` as `:warning` leaves the publish to you, the same rule
returning `:error` refuses it, and in a document that declares no datamodel
roots of its own the undeclared-path advisory this package raises on the
same field stays `:info` whatever your rule says.

A validator finding is about that revision, never about a chart. The chart
is what `compile/3` emits once nothing refuses, identified by its
`chart_identity`, and the `StatifierBlocks.CompilationRecord` on
`compiled.record` is the bridge between the two: it joins the revision's
document id, `revision` and `document_hash` to the chart it compiled to.
Publishing the revision makes that chart; it touches no execution, and an
execution already running stays on the chart it started on. The publish
step, its registries and its revision store are yours; this package ships
the functions. [How to publish a document](guides/publish-a-document.md)
is the whole check.

## It survives the author's next edit

You register the rule once, on the palette. You do not re-register it, and
nothing you do keeps it alive: the finding is re-derived on every build rather
than delivered once at mount.

That is a property of where validators run. `ViewModel.build/3` runs the
palette's validators as part of deriving the findings, the editor rebuilds its
view model on every gesture that changes anything, and the palette it rebuilds
from is the assign you handed in. So an author can select a block, retype a
value, delete a step, and your warning is still on the block it was on -
recomputed each time from the document as it now reads, which also means it
goes away by itself the moment the author fixes what it objected to.

`StatifierBlocks.Editor.HostValidatorRefreshTest` pins that claim as a test.
It drives a rule of the same shape as the one above over a fixture document
from another domain, not over a patron registration document: a mounted
editor, a config edit on an unrelated block, the same row still in the drawer
afterwards, and then the row gone once the block it objected to is deleted.

## What this channel will not do

- **It cannot stop a compile.** A validator finding is advisory; the severity
  the package defaults to is `:warning` for that reason, and even `:error`
  from a host rule is a rendering, not a refusal, to the compile. (At
  publish the severity is read: see [Where the finding shows](#where-the-finding-shows).)
  If your rule is really about whether one block's config is well-formed,
  it belongs in that block type's `validate_config/1`, where a refusal is
  available.
- **It is not validated back at you.** A return that is not a list, and a
  member that is neither `{anchor, message}` nor `{anchor, message, opts}`, is
  read as no finding rather than raised at. A `:severity` outside the enum
  falls back to `:warning`. Unrecognised keys in `opts` are ignored.
- **It does not rescue your bugs.** A raise inside `validate_document/1` is
  not caught. That is deliberate: it is your bug, and the moment it is visible
  is the moment to see it.
- **It sees the document, not the chart or an execution.** The callback is
  handed the document as authored - the one open in the editor, saved or
  not, or the revision your publish step is checking. Anything about a
  compiled chart, an execution on one, a fixture or the datamodel is yours
  to compute outside and carry in.

## Related

- [`docs/typing-a-palette.md`](typing-a-palette.md) - the declarations that
  make the *compiler* refuse a document, which is the other half of this.
- `StatifierBlocks.DocumentValidator` - the callback and its return types.
- `StatifierBlocks.Palette` - `:validators` beside the palette's other seams.
