Credence

A semantic linter for LLM-generated Elixir code.

Elixir's compiler checks syntax. Credo checks style. Credence checks semantics — it catches patterns that compile and pass tests but are non-idiomatic, inefficient, or ported from Python/JavaScript conventions that don't belong in Elixir.

Three-phase pipeline

Credence runs code through three escalating phases:

Credence.Syntax    → can the parser read it?     (string-level fixes)
Credence.Semantic  → does the compiler accept it? (compiler warning fixes)
Credence.Pattern   → is it idiomatic Elixir?      (80+ AST-level rules)

Syntax repairs code that won't parse — e.g. n * (n + 1) div 2 (Python's // translated as infix) becomes div(n * (n + 1), 2).

Semantic captures compiler warnings via Code.with_diagnostics/1 and fixes them — unused variables get _ prefixed, undefined function calls get corrected.

Pattern detects and auto-fixes 80+ anti-patterns using AST analysis — Enum.sort |> Enum.reverse becomes Enum.sort(:desc), manual frequency counting becomes Enum.frequencies/1, acc ++ [x] becomes [x | acc].

Each phase has its own Rule behaviour. Rules are discovered automatically and run in priority order.

Installation

def deps do
  [{:credence, github: "Cinderella-Man/credence", only: [:dev, :test], runtime: false}]
end

Usage

Analyze — detect issues without modifying code:

%{valid: true, issues: []} = Credence.analyze(code)

Fix — auto-fix what's fixable, report the rest:

%{code: fixed, issues: remaining} = Credence.fix(code)

Example

code = ~S"""
defmodule StudentAnalyzer do
  @doc "Analyzes scores.\nReturns statistics.\n"

  def analyze(scores) do
    if length(scores) == 0 do
      %{error: "no scores"}
    else
      total = Enum.map(scores, fn s -> s end) |> Enum.sum()
      avg = total / Enum.count(scores) * 1.0
      freq = Enum.reduce(scores, %{}, fn s, acc ->
        Map.update(acc, s, 1, &(&1 + 1))
      end)
      ranked = Enum.sort(scores) |> Enum.reverse()
      top_3 = Enum.sort(scores) |> Enum.take(-3)
      unique = scores |> Enum.uniq_by(fn s -> s end)
      csv = Enum.map(unique, fn s -> Integer.to_string(s) end) |> Enum.join(",")

      %{average: avg, frequencies: freq, top_3: top_3,
        csv: csv, passing: is_passing(avg)}
    end
  end

  def is_passing(avg), do: avg |> Kernel.>=(60.0)
end
"""

%{code: fixed, issues: remaining} = Credence.fix(code)

15 transformations in one call:

Before After
@doc "...\n...\n"@doc """ heredoc
length(scores) == 0scores == []
Enum.map(fn s -> s end) |> Enum.sum()Enum.sum(scores)
Enum.count(scores) * 1.0length(scores)
Enum.reduce(... Map.update ...)Enum.frequencies(scores)
Enum.sort() |> Enum.reverse()Enum.sort(:desc)
Enum.sort() |> Enum.take(-3)Enum.sort(:desc) |> Enum.take(3)
Enum.uniq_by(fn s -> s end)Enum.uniq()
Enum.map() |> Enum.join()Enum.map_join()
is_passingpassing?
Kernel.>=(60.0)avg >= 60.0
acc ++ [x][x | acc]
@doc false on defp removed

LLM pipeline integration

Credence fits as a validation step after mix compile, mix format, and mix test. Feed violations back to the LLM as retry context:

defmodule Pipeline.SemanticCheck do
  def validate(code) do
    case Credence.analyze(code) do
      %{valid: true} -> :ok
      %{issues: issues} ->
        feedback = Enum.map_join(issues, "\n", fn issue ->
          "Line #{issue.meta.line}: #{issue.message}"
        end)
        {:error, feedback}
    end
  end
end

You can run a subset of rules:

Credence.analyze(code, rules: [
  Credence.Pattern.NoListAppendInRecursion,
  Credence.Pattern.NoSortForTopK,
  Credence.Pattern.NoListFold
])

Writing custom rules

Each phase has its own Rule behaviour:

Pattern rules (AST-level)

defmodule Credence.Pattern.MyRule do
  use Credence.Pattern.Rule

  @impl true
  def priority, do: 500  # default; lower runs first

  @impl true
  def check(ast, _opts) do
    {_ast, issues} =
      Macro.prewalk(ast, [], fn node, issues ->
        # pattern match on node
        {node, issues}
      end)
    Enum.reverse(issues)
  end

  @impl true
  def fixable?, do: true

  @impl true
  def fix(source, _opts) do
    # return modified source string
    source
  end
end

Syntax rules (string-level, for unparseable code)

defmodule Credence.Syntax.MyFix do
  use Credence.Syntax.Rule

  @impl true
  def analyze(source), do: []  # return [%Issue{}] for detected problems

  @impl true
  def fix(source), do: source  # return repaired source string
end

Semantic rules (compiler warning fixes)

defmodule Credence.Semantic.MyFix do
  use Credence.Semantic.Rule

  @impl true
  def match?(%{severity: :warning, message: msg}), do: false

  @impl true
  def to_issue(diagnostic), do: %Credence.Issue{rule: :my_fix, message: diagnostic.message, meta: %{}}

  @impl true
  def fix(source, diagnostic), do: source
end

Rules

Syntax rules

Rule Description
FixDivRemexpr div expr / expr rem expr infix syntax → div(expr, expr) function call

Semantic rules

Rule Description
UnusedVariable Prefixes unused variables with _ to satisfy --warnings-as-errors
UndefinedFunction Known corrections — e.g. Enum.last/1List.last/1

Pattern rules

Rule Description Fix
AvoidGraphemesEnumCountEnum.count(String.graphemes(s))String.length(s)
AvoidGraphemesLengthlength(String.graphemes(s))String.length(s)
InconsistentParamNames Same positional parameter uses different names across clauses
NoAnonFnApplicationInPipe Anonymous functions applied with .() inside a pipe chain
NoDestructureReconstruct List destructured into variables only to reconstruct the same list
NoDocFalseOnPrivate@doc false on defp — redundant
NoDoubleSortSameList Same list sorted twice — use Enum.sort/2 once
NoEagerWithIndexInReduceEnum.with_index into Enum.reduce — use Stream.with_index
NoEnumAtBinarySearchEnum.at/2 inside recursive binary search
NoEnumAtInLoopEnum.at/2 inside looping constructs — O(n) per iteration
NoEnumAtLoopAccessEnum.at/2 inside loops (heuristic)
NoEnumAtMidpointAccessEnum.at/2 with midpoint index in divide-and-conquer
NoEnumAtNegativeIndexEnum.at(list, -n) → reverse + pattern match or List.last
NoEnumCountForLengthEnum.count/1 without predicate on list → length/1
NoEnumDropNegativeEnum.drop(list, -n)Enum.take/2
NoEnumTakeNegativeEnum.take(list, -n)Enum.drop/2 and reverse
NoExplicitMaxReduce Manual max-reduce → Enum.max/1
NoExplicitMinReduce Manual min-reduce → Enum.min/1
NoExplicitSumReduce Manual sum-reduce → Enum.sum/1
NoGraphemePalindromeCheck Grapheme palindrome check → String.reverse/1
NoGuardEqualityForPatternMatch Guard equality → pattern match clause
NoIdentityFunctionInEnumEnum._by(fn x -> x end) → non-_by variant
NoIntegerToStringDigitsInteger.to_string |> String.graphemesInteger.digits
NoIsPrefixForNonGuardis_ prefix on non-guard functions → ? suffix
NoKernelOpInPipelineKernel.op/2 in pipeline → infix operator
NoKernelShadowing Variables that shadow Kernel functions
NoLengthComparisonForEmptylength(list) == 0list == []
NoLengthGuardToPatternlength/1 in guard → pattern match up to 5 elements
NoLengthInGuardlength/1 in guard clauses — nest logic instead
NoListAppendInLoop++ inside non-fixable loops — O(n²)
NoListAppendInRecursion++ inside recursion — O(n²)
NoListAppendInReduce++ inside reduce — O(n²)
NoListDeleteAtInLoopList.delete_at/2 inside loops
NoListFoldList.foldl/3 / List.foldr/3Enum.reduce/3
NoListLastList.last/1 — use pattern matching or restructure
NoListToTupleForAccessList.to_tuple for index access → Enum.at/2
NoManualEnumUniq Manual uniqueness filtering → Enum.uniq/1
NoManualFrequencies Manual frequency counting → Enum.frequencies/1
NoManualListLast Hand-rolled List.last/1 reimplementation
NoManualMaxif reimplementing Kernel.max/2
NoManualMinif reimplementing Kernel.min/2
NoManualStringReverse Manual string reversal → String.reverse/1
NoMapAsSetMap with boolean values → MapSet
NoMapKeysEnumLookupMap.keys |> Enum.member?Map.has_key?/2
NoMapKeysOrValuesForIterationMap.values/keys into Enum → iterate map directly
NoMapKeysOrValuesForRawIterationMap.values/keys into Enum (unfixable)
NoMapThenAggregateEnum.map |> Enum.sum/min/max → fused variant
NoMapUpdateThenFetchMap.update then Map.fetch on same key
NoMultipleEnumAt Multiple Enum.at on same list → convert to tuple
NoMultiplyByOnePointZeroexpr * 1.0 → remove no-op
NoNestedEnumOnSameEnumerableEnum.member? nested in Enum.* on same enumerable
NoNestedEnumOnSameEnumerableUnfixable Nested Enum.* on same enumerable (unfixable)
NoParamRebinding Rebinding parameter names inside function body
NoRedundantEnumJoinSeparatorEnum.join(list, "")Enum.join(list)
NoRedundantNegatedGuard Redundant guard clause already handled by preceding clause
NoRepeatedEnumTraversal Same variable traversed multiple times in Enum calls
NoSortForTopK Full sort for top-k → Enum.min/max
NoSortForTopKReduce Full sort for top-k in reduce (unfixable)
NoSortThenAtEnum.sort |> Enum.at(0/-1)Enum.min/max
NoSortThenAtUnfixableEnum.sort |> Enum.at via intermediate variable
NoSortThenReverseEnum.sort |> Enum.reverseEnum.sort(:desc)
NoSortThenReverseUnfixable Sort then reverse via intermediate variable
NoSplitToCountlength(String.split(str, sep)) - 1 — Python str.count()
NoStringConcatInLoop<> in loops → iodata
NoStringConcatInLoopUnfixable<> in complex loops (unfixable)
NoStringLengthForCharCheckString.length(x) == 1 → pattern match
NoTakeWhileLengthCheckEnum.take_while |> lengthEnum.count/2
NoTrailingNewlineInDoc Trailing \n in @doc/@moduledoc
NoUnderscoreFunctionName Leading _ in function names → defp
NoUnnecessaryCatchAllRaise Catch-all clause that just raises
PreferDescSortOverNegativeTakeEnum.sort |> Enum.take(-n)Enum.sort(:desc) |> Enum.take(n)
PreferEnumReverseTwoEnum.reverse(list) ++ otherEnum.reverse(list, other)
PreferEnumSliceEnum.drop |> Enum.takeEnum.slice/3
PreferHeredocForMultiLineDoc Multi-line @doc with \n escapes → heredoc """
PreferMapFetchOverHasKeyMap.has_key? in conditions → Map.fetch/2
RedundantListGuard Redundant is_list/1 guard on pattern-matched list
UnnecessaryGraphemeChunking N-gram pipeline via unnecessary grapheme conversion
UnnecessaryGraphemeChunkingUnfixable Grapheme-based string transformation (unfixable)
UseMapJoinEnum.map |> Enum.joinEnum.map_join/3

License

MIT