Credence

A tool that reads Elixir code written by an AI and fixes the clumsy bits.

Three kinds of checker look at Elixir code. The compiler checks that the code is spelled right. Credo checks that it looks tidy. Credence checks what the code actually does — it finds code that runs fine and passes its tests but is written in a roundabout way, is slower than it needs to be, or was copied over from Python or JavaScript habits that don't fit Elixir. Then it rewrites it the normal Elixir way.

How it works: three rounds

Credence runs your code through three rounds, one after another. Each round fixes a different kind of problem:

Credence.Syntax → can the parser even read it? (fixes the raw text)
Credence.Semantic → does the compiler accept it? (fixes compiler warnings)
Credence.Pattern → is it written the Elixir way? (deeper idiomatic/performance rules)

Round 1 — Syntax fixes code that won't even parse — for example n * (n + 1) div 2 (someone translated Python's // straight across) becomes div(n * (n + 1), 2).

Round 2 — Semantic collects the warnings the compiler would print and fixes them: an unused variable gets an _ in front of it, a call to a function that doesn't exist gets corrected when we can tell what was meant.

Round 3 — Pattern is the big one: ~117 rules that spot clumsy-but-working code and rewrite it. Enum.sort |> Enum.reverse becomes Enum.sort(:desc), counting things by hand becomes Enum.frequencies/1, acc ++ [x] becomes [x | acc].

Each round has its own kind of rule. Credence finds all the rules by itself and runs them in a set order.

One firm promise: every Pattern rule fixes what it finds. There is no "just warn me" mode. If a problem can only be pointed at but not safely fixed — because the fix would have to rearrange code in several places, because there's more than one reasonable fix, or because the fix would change the type of value the code returns — we leave that rule out of the program entirely.

Installation

def deps do
[
{:credence, "~> 0.7.1", only: [:dev, :test], runtime: false}
]
end

Usage

Analyze — point out problems without touching the code:

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

Fix — repair what can be repaired, and 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 also run just some of the rules:

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

See which rules would run for a set of options, without running them:

# every rule across all three rounds, tagged by round, in execution order
Credence.rule_status(assumptions: :strict)
#=> [%{round: :pattern, name: "NoSortThenReverse", enabled: true, ...}, ...]
# just the on-names
Credence.enabled_rules(assumptions: :strict)

Only the Pattern round is filtered by options; Syntax and Semantic rules always report enabled: true (whether they fire at runtime depends on the code — a parse failure or a compiler warning — not the options).

Safety switches

A few cleanups are identical to your original code for almost every input, and differ only on rare Unicode — a decomposed accent (an "e" plus a separate accent mark), a joined emoji, a flag. Credence keeps those cleanups behind a safety switch: a promise about the text your program handles while it runs — names, messages, file contents — not the characters in your .ex source files.

The first switch, single_codepoint_graphemes, is on by default: Credence assumes the text your code processes is all plain, single-piece characters, which is true for almost every app. If your code processes arbitrary Unicode and you want the iron-clad "identical for every possible input" guarantee, turn the promises off:

# play it safe everywhere — only always-correct rules run
Credence.fix(code, assumptions: :strict)
# or set it project-wide
config :credence, assumptions: :strict

Credence.Pattern.rule_status/1 shows which rules are on and which promises they need (or Credence.rule_status/1 for the same view across all three rounds). Full reference: the Credence.Assumptions moduledoc.

Writing your own rules

Start by scaffolding the rule and its tests:

mix credence.gen.rule MyRule # a Pattern rule (default)
mix credence.gen.rule MyRule --type syntax # or syntax / semantic

This writes a correctly-named rule plus its test files — heredoc fixtures, the right module names, already passing every structural meta gate. The generated tests start red (they carry real assertions against an empty stub), so mix test shows you exactly what to fill in: check/fix (or analyze/match?) and the example fixtures.

Each round has its own kind of rule.

Pattern rules

A Pattern rule works on the AST — the tree shape a parser turns your code into, instead of plain text. A Pattern rule has two parts: check/2 finds the problems, and the fix is written in one of two ways — pick whichever is simpler for your change.

Way A — patches (preferred). Walk the tree, find the spots you want to change with Sourceror.get_range/1, and hand back %{range, change} patches. Only the bytes you point at move; everything around them stays exactly as the person wrote it.

defmodule Credence.Pattern.MyRule do
use Credence.Pattern.Rule
@impl true
def priority, do: 500 # default; lower numbers run first
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, issues ->
# match the node you care about; add a %Credence.Issue{} when it matches
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
{_ast, patches} =
Macro.prewalk(ast, [], fn node, acc ->
# for each matched node, hand back:
# %{range: Sourceror.get_range(node), change: replacement_source}
{node, acc}
end)
Enum.reverse(patches)
end
end

Way B — rewrite the whole text. When your change is really about the text (a search-and-replace, an edit by line), write fix/2 instead. The built-in fix_patches/2 you get from use Credence.Pattern.Rule wraps your text change as one big patch.

defmodule Credence.Pattern.MyRule do
use Credence.Pattern.Rule
@impl true
def check(ast, _opts), do: [...]
@impl true
def fix(source, _opts) do
# hand back the changed source string; keeping the layout tidy is on you
source
end
end

Way A keeps the surrounding layout intact when several rules change the same file, so it's the better default. Way B is fine for a small, self-contained rewrite.

Syntax rules (for code that won't parse)

defmodule Credence.Syntax.MyFix do
use Credence.Syntax.Rule
@impl true
def analyze(source), do: [] # hand back [%Issue{}] for problems you spot
@impl true
def fix(source), do: source # hand back the repaired source string
end

Semantic rules (for compiler warnings)

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

What happens when you call fix

When you call Credence.fix(code), your code goes through the three rounds in order. Each round handles a different kind of problem, and they run in this order on purpose: the earlier rounds tidy up things that would otherwise confuse the later ones.

The whole journey:

┌──────────┐
source string → │ Syntax │ → can it be parsed?
└────┬─────┘
┌────▼─────┐
│ Semantic │ → does it compile? fix warnings
└────┬─────┘
┌────▼─────┐
│ Pattern │ → is it the Elixir way? (only if it compiles)
└────┬─────┘
fixed source + leftover issues

Round 1: Syntax — fix the raw text

Syntax rules work on the plain text of your code, before Elixir's parser ever sees it. They only run when Code.string_to_quoted/1 fails to read the code — if it already parses, this round is skipped.

This is where we fix things like Python-style // division that the AI translated word-for-word. These rules use text and pattern matching on the string — no tree involved.

If the syntax rules get the code parsing again, we move on. If it still won't parse after every syntax rule has tried, the later rounds do the best they can with what's there.

Round 2: Semantic — fix compiler warnings

This round compiles the code with Code.compile_string/2, wrapped in Code.with_diagnostics/1. That hands us the same warnings and errors you'd see in your terminal — unused variables, functions that don't exist, and so on.

Each warning is checked against the semantic rules. When a rule knows how to fix one, it changes the code. For example, the UnusedVariable rule turns count into _count when nothing ever uses it.

If the code has real compile errors (not just warnings), the semantic rules try to fix those first, then compile again to catch any warnings the errors were hiding. This try-again loop runs up to three times.

Round 3: Pattern — fix clumsy-but-working code

These rules look at the parsed tree for code that compiles fine and passes its tests but isn't written the Elixir way. Think of it as a picky code reviewer who knows the mistakes AIs tend to make.

The compile check first. Before any Pattern rule runs, Credence compiles the code one more time to make sure it actually works. If it doesn't — say there's a variable that was never set and no semantic rule could fix it — the Pattern rules are skipped. This is on purpose. Pattern rules rewrite code based on its tree shape, and rewriting code that's already broken tends to make things worse, not better. Skipping is the safe choice.

When that check passes, each rule gets the tree and walks it looking for a particular shape. For example, NoExplicitSumReduce looks for:

Enum.reduce(list, 0, fn x, acc -> acc + x end)

and replaces it with:

Enum.sum(list)

Rules run in order (lower priority number runs first), and each rule sees the code as the rules before it left it.

The after-the-fix check. After each rule makes its change, Credence compiles the result. If the new code doesn't compile — a buggy rule, or a rule whose change was fine on its own but clashed with an earlier one — Credence undoes that rule's change and keeps going. The undone rule shows up as {Rule, :reverted} in applied_rules, and a [warning] line in the log names it. This keeps one broken rule from wrecking everything after it.

What you see in the logs

Every step of the fix is written to the log at :debug level with a [credence_fix] tag. Turn your Logger up to :debug and you'll see exactly what happened:

[debug] [credence_fix] syntax fix pipeline: source already parses, skipping
[debug] [credence_fix] starting semantic fix pipeline (max 3 passes, 6 rules)
[debug] [credence_fix] semantic pass 1: compilation OK, 1 warning(s)
[debug] [credence_fix] UnusedVariable: matched diagnostic, running fix...
[debug] [credence_fix] UnusedVariable: source CHANGED:
L4 - unused = 1
L4 + _unused = 1
[debug] [credence_fix] semantic done. Applied: [UnusedVariable(1)]
[debug] [credence_fix] starting pattern fix pipeline (117 rules)
[debug] [credence_fix] NoExplicitSumReduce: check found 1 issue(s), running fix...
[debug] [credence_fix] NoExplicitSumReduce: source CHANGED:
L5 - Enum.reduce(list, 0, fn x, acc -> acc + x end)
L5 + Enum.sum(list)
[debug] [credence_fix] done. Applied: [NoExplicitSumReduce(1)]

Every line that changed is shown as a before/after. When something goes wrong, the log tells you which rule ran, what it changed, and where Credence stopped.

What you get back

Credence.fix/2 hands back a map with three keys:

%{
code: "...", # the fixed source string
issues: [...], # problems still found after all the fixing
applied_rules: [...] # {rule_module, issue_count | :reverted} for every rule that ran
}

issues is whatever check/2 still flags after all the fixing is done. Since every Pattern rule fixes what it finds, this list is usually empty — but if a rule's fix produced code that wouldn't compile, the after-the-fix check undoes it, and that original problem stays on the list for you to look at.

applied_rules tells you exactly what each round did. Each entry is {rule_module, issue_count} when a fix worked, or {rule_module, :reverted} when the after-the-fix check had to undo a buggy rule. The list covers all three rounds — syntax, semantic, and pattern — so you can see the full story of what happened to your code.

License

MIT