PhoenixKit.Install.ConfigVerify (phoenix_kit v2.13.18)

Copy Markdown View Source

I103: the config-editing helpers across oban_config.ex, phoenix_kit.update.ex, boot_hook.ex, and runtime_detector.ex all splice text into a host's own .ex/.exs file via regex, then hand the result back without ever checking what they produced. Two independent ways that goes wrong, found the same day:

  • The splice lands on the wrong delimiter (a ] inside a comment, inside a string) and produces text that is not valid Elixir at all — reproduced live as MismatchedDelimiterError from add_scheduled_posts_job_to_crontab/1 given nothing more unusual than an ordinary explanatory comment.
  • The splice lands on the wrong delimiter and STILL produces valid Elixir — the new entry ends up nested inside an unrelated value (an existing entry's own args: %{tags: [...]} list) instead of as a sibling of the list it was meant to join. A parse check alone accepts this silently; it is the exact shape of a green that doesn't back its own claim, same as I082's doctor check before it named its own scope.

This module is the shared fix for both: parse the candidate text, then hand the parsed AST to a caller-supplied predicate that confirms the SPECIFIC change is actually present where it was meant to land — not just that some valid Elixir came out. Either failure returns the ORIGINAL, untouched content plus a reason a caller turns into the same kind of manual-fallback message the safe call sites in this codebase already print. A rollback is never worse than what the file had before; a wrong bracket must never win silently over "tell the operator to do it by hand".

What this deliberately does NOT cover

PhoenixKit.Install.JsIntegration and PhoenixKit.Install.CssIntegration splice regex-built text into a host's assets/js/app.js, root.html.heex, and app.css the same way — but Code.string_to_quoted/1 only understands Elixir, so there is no equivalent parse-then-verify step for JavaScript, HEEX, or CSS available here. That is a real, permanent gap in coverage, not an oversight: those splices stay regex-only, checked (where they are checked at all) by a plain if updated != content before writing. Each site says so in its own comment.

Summary

Types

Whether the candidate came back invalid, or valid but the intended change wasn't found.

Functions

True if the quoted node is a __aliases__ resolving to exactly module.

True if any node in ast satisfies matcher. Stops at the first match — every caller here asks "is this one thing present", not "collect every occurrence".

Looks up key in a quoted keyword list; {:ok, value} or nil.

True if ast contains a {root_key, list} pair (e.g. a crontab:, plugins:, or queues: entry inside some enclosing keyword list) where list satisfies list_check — the shared shape behind "did my new entry land inside the RIGHT list", regardless of how deep that list sits.

The elements of a quoted tuple of any size.

True if module appears as one of the elements of the quoted tuple node — used to confirm a newly-spliced plugin/worker tuple actually landed as its own list entry, not nested deeper inside an unrelated value.

Parses candidate, then runs semantic_check against the resulting AST.

Runs a regex-built candidate through verify/2 and returns either the candidate (accepted) or original (rolled back) — the one-line shape every call site in this codebase wants: try the insertion, keep it only if it's provably correct, otherwise behave exactly as if the insertion had never been attempted.

Types

failure()

@type failure() :: :syntax | :semantic

Whether the candidate came back invalid, or valid but the intended change wasn't found.

Functions

alias_matches?(arg1, module)

@spec alias_matches?(Macro.t(), module()) :: boolean()

True if the quoted node is a __aliases__ resolving to exactly module.

app_config_satisfies?(ast, app_name, module, root_key, list_check)

@spec app_config_satisfies?(
  Macro.t(),
  atom() | String.t(),
  module(),
  atom(),
  ([Macro.t()] -> boolean())
) :: boolean()

Scopes a keyword_list_satisfies?/3-style check to ONE application's config block.

True if ast contains a config(app_name, module, opts) call — the Config.config/3 macro invoked as config :app_name, Module, key: value — whose opts contain a {root_key, list} pair (at any depth, e.g. a crontab: nested inside an Oban.Plugins.Cron tuple inside plugins:) satisfying list_check.

keyword_list_satisfies?/3 alone answers "does a root_key: [...] list like this appear ANYWHERE in the file" — true even when it belongs to a DIFFERENT application's block. Paired with a splice that isn't anchored to the right app either, that isn't a safety net: it is a check that can confirm the WRONG outcome, reporting success while the intended application's config was never touched. See PhoenixKit.Install.ObanConfig's plugins:/crontab: splices, which anchor both the insertion and this check to app_name.

ast_contains?(ast, matcher)

@spec ast_contains?(Macro.t(), (Macro.t() -> boolean())) :: boolean()

True if any node in ast satisfies matcher. Stops at the first match — every caller here asks "is this one thing present", not "collect every occurrence".

keyword_get(kw, key)

@spec keyword_get(Macro.t(), atom()) :: {:ok, Macro.t()} | nil

Looks up key in a quoted keyword list; {:ok, value} or nil.

keyword_list_satisfies?(ast, root_key, list_check)

@spec keyword_list_satisfies?(Macro.t(), atom(), ([Macro.t()] -> boolean())) ::
  boolean()

True if ast contains a {root_key, list} pair (e.g. a crontab:, plugins:, or queues: entry inside some enclosing keyword list) where list satisfies list_check — the shared shape behind "did my new entry land inside the RIGHT list", regardless of how deep that list sits.

tuple_elements(tuple)

@spec tuple_elements(Macro.t()) :: [Macro.t()] | nil

The elements of a quoted tuple of any size.

A literal 2-tuple is its own AST node ({a, b}) — but Elixir represents every OTHER tuple size as {:{}, meta, elements}, the one case a bare is_tuple/1 check on quoted code would miss. Returns nil for anything that isn't a quoted tuple at all.

tuple_names_module?(node, module)

@spec tuple_names_module?(Macro.t(), module()) :: boolean()

True if module appears as one of the elements of the quoted tuple node — used to confirm a newly-spliced plugin/worker tuple actually landed as its own list entry, not nested deeper inside an unrelated value.

verify(candidate, semantic_check)

@spec verify(String.t(), (Macro.t() -> boolean())) ::
  {:ok, String.t()} | {:error, failure()}

Parses candidate, then runs semantic_check against the resulting AST.

Returns {:ok, candidate} only when both the parse succeeds AND semantic_check returns true. Otherwise returns {:error, reason} — the caller is expected to fall back to whatever content it started from (this module never sees or returns original; it isn't needed to make that decision, and threading it through here would only invite a caller to skip the fallback by mistake).

verify_or_rollback(original, candidate, semantic_check)

@spec verify_or_rollback(String.t(), String.t(), (Macro.t() -> boolean())) ::
  {:ok, String.t()} | {:rolled_back, String.t(), failure()}

Runs a regex-built candidate through verify/2 and returns either the candidate (accepted) or original (rolled back) — the one-line shape every call site in this codebase wants: try the insertion, keep it only if it's provably correct, otherwise behave exactly as if the insertion had never been attempted.