Credence.DslGuard (credence v0.8.1)

Copy Markdown

Keeps Pattern rules out of macro blocks that reinterpret plain Elixir AST.

The problem this solves

Credence rules read raw Elixir AST and assume plain-Elixir semantics. Some macros re-read that same AST with different meaning, so a rewrite that is valid in plain Elixir becomes wrong — and still compiles, so it fails only at runtime. The reported case (Ash):

# plain Elixir: !x ≡ not x. Inside Ash.Expr.expr/1 they are NOT the same.
expr(if cond do false else x end)        # not-negation, translates to SQL
expr(if !cond do x else false end)        # %Ash.Query.Call{name: :!} — no SQL translation

The same hazard exists for Ecto.Query macros (!/&&/|| are compile errors; x == nil is a compile error; is_nil is required), Nx.Defn (==/and/if are element-wise tensor ops), and any other AST-reinterpreting DSL. See the divergence catalogue in the issue tracker.

This module finds the source ranges of those blocks. Both Pattern gates derive from them — and from the same patches — so they cannot disagree:

  • Credence.RuleHelpers.apply_rule_fix/3 drops any single patch whose range intersects an unsafe_in_dsl/0 block (a fix on plain code elsewhere in the file still lands), and
  • Credence.Pattern.analyze/2 suppresses a finding exactly when its line falls inside one of those dropped patch ranges (RuleHelpers.dsl_dropped_ranges/3). A finding is therefore reported iff its fix is applied — the two gates share one computation, not two views of it.

Detection is mostly by SHAPE, not by import

A parse-only tool cannot expand macros, so it cannot know that use MyAppWeb, :live_view brings import Ecto.Query into scope, nor that use MyApp.Resource re-exports use Ash.Resource. Keying on a literal top-level import/use therefore fails for the dominant real-world setups. Wherever a distinctive call shape exists we match that instead, since it survives wrappers, scoped imports and aliases:

  • expr(...), defn/defnp ... — distinctive names, always treated as DSL.
  • from(x in Y, ...) — first argument is an x in Source expression (this also covers the keyword query syntax from(p in P, where: ..., select: ...), since the whole from block range encloses the keywords).
  • Ecto pipe macros (where, select, dynamic, join, ...) — only when a binding list argument is present (where([p], p.x == ^v)). The binding list is Ecto's signature and is virtually unseen in plain code, so a plain select(opts) / def dynamic(d) is never mistaken for a query.
  • Qualified/aliased calls (Ecto.Query.from, alias Ash.Expr; Expr.expr) — resolved through the file's alias table.

The one place shape is insufficient is Ash's filter/calculate/aggregate, which reinterpret their argument but are spelled like ordinary functions and carry no binding-list-style signature. A bare call to one of these is treated as :ash_expr only in a file that directly imports/uses an Ash.* module — a same-file signal strong enough to distinguish the Ash macro from a plain filter/2 helper. This is the sole import-dependent path, so it does not cover a wrapper that hides the Ash use (config :credence, dsl_macros: handles those); the qualified Ash.Query.filter form is always covered above.

Posture: conservative

When in doubt we treat code as DSL. Over-detection only costs a missed fix (we skip a safe rewrite); under-detection would ship a wrong one. The known cost on the 500-package corpus is a handful of plain-Elixir antipatterns that merely share a line with a DSL call going unfixed.

Limits (best-effort, not a guarantee)

The macro set is a denylist. An AST-reinterpreting DSL that is neither built in nor configured is not protected. Extend coverage with:

config :credence, dsl_macros: [:my_query_macro, ...]

Names added there are matched as "always DSL" (like expr).

Summary

Types

A detected block: its family, source range, and AST node.

A DSL family — which library reinterprets the block.

A Sourceror source range: %{start: [line:, column:], end: [line:, column:]}.

Functions

Returns every reinterpreting-DSL block in ast, each tagged with its family.

The DSL families the guard knows about, in declaration order.

True when line falls within a block of one of families (used by the analyze gate with the firing rule's unsafe_in_dsl/0). :all matches every family; with the 2-arity form, any family.

True when line falls inside any of ranges (plain Sourceror ranges). Used by the analyze-side gate against the dropped patch ranges from Credence.RuleHelpers.dsl_dropped_ranges/3, so finding-suppression tracks the fix-side patch drops exactly.

True when patch must be dropped because it touches a reinterpreting block of one of families (used by the fix gate with the firing rule's unsafe_in_dsl/0).

Types

block()

@type block() :: %{family: family(), range: range(), node: Macro.t()}

A detected block: its family, source range, and AST node.

family()

@type family() :: :ash_expr | :ecto_query | :nx_defn

A DSL family — which library reinterprets the block.

range()

@type range() :: %{start: keyword(), end: keyword()}

A Sourceror source range: %{start: [line:, column:], end: [line:, column:]}.

Functions

block_ranges(ast, opts \\ [])

@spec block_ranges(
  Macro.t(),
  keyword()
) :: [block()]

Returns every reinterpreting-DSL block in ast, each tagged with its family.

This is the single predicate behind both the analyze-side and fix-side gates, so the two can never disagree about what counts as DSL.

families()

@spec families() :: [family()]

The DSL families the guard knows about, in declaration order.

inside_block?(line, blocks)

@spec inside_block?(integer() | nil, [block()]) :: boolean()

True when line falls within a block of one of families (used by the analyze gate with the firing rule's unsafe_in_dsl/0). :all matches every family; with the 2-arity form, any family.

inside_block?(line, blocks, families)

@spec inside_block?(integer() | nil, [block()], [family()] | :all) :: boolean()

line_in_ranges?(line, ranges)

@spec line_in_ranges?(integer() | nil, [range()]) :: boolean()

True when line falls inside any of ranges (plain Sourceror ranges). Used by the analyze-side gate against the dropped patch ranges from Credence.RuleHelpers.dsl_dropped_ranges/3, so finding-suppression tracks the fix-side patch drops exactly.

patch_blocked?(patch, blocks)

@spec patch_blocked?(range() | map(), [block()]) :: boolean()

True when patch must be dropped because it touches a reinterpreting block of one of families (used by the fix gate with the firing rule's unsafe_in_dsl/0).

patch may be a full patch (%{range:, change:}) or a bare range. A block is a hazard for the patch unless the patch encloses the whole block AND leaves that block byte-for-byte unchanged — i.e. the patch only re-renders an outer node and moves the block verbatim, with the actual rewrite landing on plain code outside it (the relating_to_actor shape: an if whose branch merely contains an expr). Every other overlap is a hazard: a patch inside a block (the reported !/not flip), a patch that reshapes the block, or a partial overlap. With only a range (no change to inspect) any intersection is a hazard, so this degrades to the original intersection test.

patch_blocked?(patch, blocks, families)

@spec patch_blocked?(range() | map(), [block()], [family()] | :all) :: boolean()