defmodule Credence.Pattern.NoKernelOpInPipeline do @moduledoc """ Detects qualified `Kernel.op/2` calls used as steps in a pipeline. LLMs produce code like `list |> Enum.sort() |> Kernel.==(list)` because they try to keep everything in one pipeline. In Elixir, comparison and boolean operators are used in infix position, not as qualified calls. ## Bad list |> Enum.uniq() |> Enum.sort() |> Kernel.==(list) score |> calculate() |> Kernel.>=(threshold) ## Good (list |> Enum.uniq() |> Enum.sort()) == list calculate(score) >= threshold ## Auto-fix Extracts the operator from the pipeline and restructures: - **0 remaining steps**: `x |> Kernel.op(y)` → `x op y` - **1 remaining step**: `x |> f() |> Kernel.op(y)` → `f(x) op y` - **2+ remaining steps**: `x |> f() |> g() |> Kernel.op(y)` → `(x |> f() |> g()) op y` Arithmetic operators (`+`, `-`, `*`, `/`) are not flagged since `Kernel.+(n)` in a pipe has no clearly better alternative. """ use Credence.Pattern.Rule alias Credence.Issue alias Credence.RuleHelpers @flagged_ops ~w(== != === !== < > <= >= and or)a @impl true def check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn {:|>, _, [_lhs, {{:., _, [{:__aliases__, _, [:Kernel]}, op]}, meta, [_arg]}]} = node, acc when op in @flagged_ops -> {node, [build_issue(meta, op) | acc]} node, acc -> {node, acc} end) Enum.reverse(issues) end @impl true def fix_patches(ast, _opts) do RuleHelpers.patches_from_postwalk(ast, fn {:|>, _meta, [lhs, {{:., _, [{:__aliases__, _, [:Kernel]}, op]}, _, [arg]}]} when op in @flagged_ops -> transform_kernel_pipe(lhs, op, arg) node -> node end) end defp transform_kernel_pipe(lhs, op, arg) do case count_pipes(lhs) do 0 -> # x |> Kernel.op(y) → x op y {op, [], [lhs, arg]} 1 -> # x |> f() |> Kernel.op(y) → f(x) op y {:|>, _, [initial, step]} = lhs inlined = inline_pipe_step(initial, step) {op, [], [inlined, arg]} _ -> # x |> f() |> g() |> Kernel.op(y) → (x |> f() |> g()) op y # Parens are implicit: |> has higher precedence than all flagged ops {op, [], [lhs, arg]} end end defp count_pipes({:|>, _, [lhs, _]}), do: 1 + count_pipes(lhs) defp count_pipes(_), do: 0 # Inline: x |> func(args) → func(x, args) defp inline_pipe_step(input, {func_name, meta, args}) when is_atom(func_name) do {func_name, meta, [input | args || []]} end defp inline_pipe_step(input, {{:., dot_meta, qualified}, call_meta, args}) do {{:., dot_meta, qualified}, call_meta, [input | args || []]} end # Fallback: shouldn't happen, but keep as pipe to be safe defp inline_pipe_step(input, step) do {:|>, [], [input, step]} end defp build_issue(meta, op) do %Issue{ rule: :no_kernel_op_in_pipeline, message: """ Piping into `Kernel.#{op}/2` is non-idiomatic Elixir. Operators should \ be used in infix position. Extract the comparison from the pipeline: result = pipeline result #{op} value """, meta: %{line: Keyword.get(meta, :line)} } end end