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 translationThe 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/3drops any single patch whose range intersects anunsafe_in_dsl/0block (a fix on plain code elsewhere in the file still lands), andCredence.Pattern.analyze/2suppresses 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 anx in Sourceexpression (this also covers the keyword query syntaxfrom(p in P, where: ..., select: ...), since the wholefromblock 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 plainselect(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'saliastable.
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
A detected block: its family, source range, and AST node.
@type family() :: :ash_expr | :ecto_query | :nx_defn
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.
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.
@spec families() :: [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).
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.