Skip to main content

README.md

# 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

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

## Usage

**Analyze** — detect issues without modifying code:

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

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

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

### Example

```elixir
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:

```elixir
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:

```elixir
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)

```elixir
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)

```elixir
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)

```elixir
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 |
|------|-------------|
| `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