Credence

A semantic linter for LLM-generated Elixir code.

Elixir's compiler checks syntax. Credo checks style. Credence checks semantics — it mainly 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(if possible).

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, "~> 0.4.3", 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)

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

License

MIT