How to write a host advisory finding

Copy Markdown View Source

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 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:

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 (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:

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.

SurfaceWhat you see
The drawer's Findings tabone row, stamped data-anchor="config:<block id>:<key>", data-source="lint", data-severity="warning"
The inspector's Findings tabthe same row, for the selected block, on the same anchor
The inspector's config formunder the control, when the key names a form field
The Findings tab's countincludes it
StatifierBlocks.Editor.findings_count/3includes 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 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.) 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.