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}]
endUsage
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) == 0 | scores == [] |
Enum.map(fn s -> s end) |> Enum.sum() | Enum.sum(scores) |
Enum.count(scores) * 1.0 | length(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_passing | passing? |
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
endYou 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
endSyntax 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
endSemantic 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
endRules
Syntax rules
| Rule | Description |
|---|---|
FixDivRem | expr 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/1 → List.last/1 |
Pattern rules
| Rule | Description | Fix |
|---|---|---|
AvoidGraphemesEnumCount | Enum.count(String.graphemes(s)) → String.length(s) | ✅ |
AvoidGraphemesLength | length(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 | ✅ |
NoEagerWithIndexInReduce | Enum.with_index into Enum.reduce — use Stream.with_index | ✅ |
NoEnumAtBinarySearch | Enum.at/2 inside recursive binary search | ❌ |
NoEnumAtInLoop | Enum.at/2 inside looping constructs — O(n) per iteration | ❌ |
NoEnumAtLoopAccess | Enum.at/2 inside loops (heuristic) | ❌ |
NoEnumAtMidpointAccess | Enum.at/2 with midpoint index in divide-and-conquer | ✅ |
NoEnumAtNegativeIndex | Enum.at(list, -n) → reverse + pattern match or List.last | ✅ |
NoEnumCountForLength | Enum.count/1 without predicate on list → length/1 | ✅ |
NoEnumDropNegative | Enum.drop(list, -n) → Enum.take/2 | ✅ |
NoEnumTakeNegative | Enum.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 | ✅ |
NoIdentityFunctionInEnum | Enum._by(fn x -> x end) → non-_by variant | ✅ |
NoIntegerToStringDigits | Integer.to_string |> String.graphemes → Integer.digits | ✅ |
NoIsPrefixForNonGuard | is_ prefix on non-guard functions → ? suffix | ✅ |
NoKernelOpInPipeline | Kernel.op/2 in pipeline → infix operator | ✅ |
NoKernelShadowing |
Variables that shadow Kernel functions | ❌ |
NoLengthComparisonForEmpty | length(list) == 0 → list == [] | ✅ |
NoLengthGuardToPattern | length/1 in guard → pattern match up to 5 elements | ✅ |
NoLengthInGuard | length/1 in guard clauses — nest logic instead | ❌ |
NoListAppendInLoop | ++ inside non-fixable loops — O(n²) | ❌ |
NoListAppendInRecursion | ++ inside recursion — O(n²) | ✅ |
NoListAppendInReduce | ++ inside reduce — O(n²) | ✅ |
NoListDeleteAtInLoop | List.delete_at/2 inside loops | ❌ |
NoListFold | List.foldl/3 / List.foldr/3 → Enum.reduce/3 | ✅ |
NoListLast | List.last/1 — use pattern matching or restructure | ❌ |
NoListToTupleForAccess | List.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 | ✅ |
NoManualMax | if reimplementing Kernel.max/2 | ✅ |
NoManualMin | if reimplementing Kernel.min/2 | ✅ |
NoManualStringReverse |
Manual string reversal → String.reverse/1 | ✅ |
NoMapAsSet | Map with boolean values → MapSet | ❌ |
NoMapKeysEnumLookup | Map.keys |> Enum.member? → Map.has_key?/2 | ✅ |
NoMapKeysOrValuesForIteration | Map.values/keys into Enum → iterate map directly | ✅ |
NoMapKeysOrValuesForRawIteration | Map.values/keys into Enum (unfixable) | ❌ |
NoMapThenAggregate | Enum.map |> Enum.sum/min/max → fused variant | ✅ |
NoMapUpdateThenFetch | Map.update then Map.fetch on same key | ✅ |
NoMultipleEnumAt |
Multiple Enum.at on same list → convert to tuple | ✅ |
NoMultiplyByOnePointZero | expr * 1.0 → remove no-op | ✅ |
NoNestedEnumOnSameEnumerable | Enum.member? nested in Enum.* on same enumerable | ✅ |
NoNestedEnumOnSameEnumerableUnfixable |
Nested Enum.* on same enumerable (unfixable) | ❌ |
NoParamRebinding | Rebinding parameter names inside function body | ✅ |
NoRedundantEnumJoinSeparator | Enum.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) | ❌ |
NoSortThenAt | Enum.sort |> Enum.at(0/-1) → Enum.min/max | ✅ |
NoSortThenAtUnfixable | Enum.sort |> Enum.at via intermediate variable | ❌ |
NoSortThenReverse | Enum.sort |> Enum.reverse → Enum.sort(:desc) | ✅ |
NoSortThenReverseUnfixable | Sort then reverse via intermediate variable | ❌ |
NoSplitToCount | length(String.split(str, sep)) - 1 — Python str.count() | ❌ |
NoStringConcatInLoop | <> in loops → iodata | ✅ |
NoStringConcatInLoopUnfixable | <> in complex loops (unfixable) | ❌ |
NoStringLengthForCharCheck | String.length(x) == 1 → pattern match | ✅ |
NoTakeWhileLengthCheck | Enum.take_while |> length → Enum.count/2 | ✅ |
NoTrailingNewlineInDoc |
Trailing \n in @doc/@moduledoc | ✅ |
NoUnderscoreFunctionName |
Leading _ in function names → defp | ✅ |
NoUnnecessaryCatchAllRaise | Catch-all clause that just raises | ✅ |
PreferDescSortOverNegativeTake | Enum.sort |> Enum.take(-n) → Enum.sort(:desc) |> Enum.take(n) | ✅ |
PreferEnumReverseTwo | Enum.reverse(list) ++ other → Enum.reverse(list, other) | ✅ |
PreferEnumSlice | Enum.drop |> Enum.take → Enum.slice/3 | ✅ |
PreferHeredocForMultiLineDoc |
Multi-line @doc with \n escapes → heredoc """ | ✅ |
PreferMapFetchOverHasKey | Map.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) | ❌ |
UseMapJoin | Enum.map |> Enum.join → Enum.map_join/3 | ✅ |
License
MIT