MetaCredo

MetaCredo

Cross-language static code analysis tool built on MetaAST.

Write a check once, run it across Elixir, Erlang, Ruby, Python, Haskell, and all other languages supported by Metastatic.

Credits

MetaCredo stands on the shoulders of Credo by Rene Foehring—an exceptional static analysis tool that has shaped how the entire Elixir community thinks about code quality, consistency, and teaching through tooling. Credo's design—its check behaviour, category system, configuration format, and the philosophy that a linter should teach rather than merely scold—served as the direct architectural inspiration for MetaCredo. We are grateful for the years of thoughtful work that went into Credo and the high bar it set for developer experience in static analysis.

MetaCredo extends that vision across language boundaries: every check operates on Metastatic's unified MetaAST, so the same insight that helps an Elixir developer can help a Python, Ruby, or Haskell developer just as well.

Installation

Add metacredo to your list of dependencies in mix.exs:

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

Usage

# Run all checks
$ mix metacredo

# Strict mode (only normal+ priority issues)
$ mix metacredo --strict

# Filter by category
$ mix metacredo --only security,warning

# JSON output
$ mix metacredo --format json

# Explain a specific check
$ mix metacredo explain MetaCredo.Check.Security.HardcodedValue

# Generate default configuration
$ mix metacredo.gen.config

How It Works

MetaCredo operates on the MetaAST representation provided by Metastatic. Source files are parsed into a language-agnostic AST using Metastatic's adapters (Elixir, Python, Ruby, Haskell, Erlang), and then checks pattern-match against the uniform {type, keyword_meta, children} node structure. This means every check is cross-language by default.

Check Categories (72 checks)

Consistency [C]—2 checks

  • ExceptionNames—Exception/error classes not ending in "Error" or "Exception"
  • ParameterPatternMatching—Destructuring in body instead of params

Security [S]—15 checks

  • HardcodedValue—Hardcoded URLs, IPs, and sensitive values in string literals
  • SQLInjection—SQL string concatenation/interpolation with variables (CWE-89)
  • XSSVulnerability—raw(), html_safe, innerHTML, dangerouslySetInnerHTML (CWE-79)
  • PathTraversal—File operations with user-controlled paths (CWE-22)
  • SSRFVulnerability—HTTP requests with user-controlled URLs (CWE-918)
  • SensitiveDataExposure—Logging/inspecting passwords, tokens, PII (CWE-200)
  • MissingCSRFProtection—State-changing actions without CSRF validation (CWE-352)
  • InsecureDirectObjectReference—Direct DB lookups from user params (CWE-639)
  • UnrestrictedFileUpload—File uploads without type/size validation (CWE-434)
  • TOCTOU—File.exists? followed by file operations (CWE-367)
  • MissingAuthentication—Controllers/handlers without auth middleware (CWE-306)
  • MissingAuthorization—Sensitive operations without authorization (CWE-862)
  • IncorrectAuthorization—Auth-after-action bugs, negation patterns (CWE-863)
  • ImproperInputValidation—User input to sensitive ops without validation (CWE-20)
  • InlineJavascript—Inline script tags, onclick handlers, javascript: URIs

Warning [W]—22 checks

  • MissingErrorHandling{:ok, _} = call() without error handling
  • SilentErrorCase—case matching {:ok, } without {:error, } branch
  • SwallowingException—try/rescue without logging or re-raising
  • NPlusOneQuery—Database calls inside collection operations (N+1)
  • MissingPreload—Collection ops over DB results without eager loading
  • UnmanagedTask—Task.async without Task.Supervisor
  • SyncOverAsync—Blocking calls in GenServer/LiveView callbacks
  • MissingHandleAsync—Blocking in handle_event without async delegation
  • DirectStructUpdate—Struct updates bypassing changesets
  • CallbackHell—Deeply nested conditionals exceeding threshold
  • BlockingInPlug—Blocking I/O in Plug call/init middleware
  • MissingThrottle—Expensive operations without rate limiting
  • InefficientFilter—Repo.all then Enum.filter (filter in memory)
  • ImperativeStatusHandling—Imperative if/else chains on status codes
  • UnusedOperation—Function call result discarded (not assigned or returned)
  • UnsafeExec—System.cmd/exec with user-controlled arguments (CWE-78)
  • BoolOperationOnSameValuesx && x, x || x (always same result)
  • OperationOnSameValuesx - x (always 0), x / x (always 1)
  • OperationWithConstantResultx * 0 (always 0)
  • LazyLogging—Logger with string interpolation instead of anonymous function
  • DebugLeftover—IO.inspect, console.log, dbg() left in production code
  • RaiseInsideRescue—raise/throw inside rescue without re-raise semantics

Readability [R]—13 checks

  • MagicNumber—Numeric literals in expressions without named constants
  • DeepNesting—Functions with nesting depth exceeding threshold
  • LongFunction—Functions with too many statements
  • ComplexConditional—Deeply nested boolean operations
  • LongParameterList—Functions with too many parameters
  • FunctionNames—Function names not in snake_case
  • ModuleNames—Module/container names not in PascalCase
  • VariableNames—Variable names not in snake_case
  • ModuleDoc—Modules without documentation
  • SinglePipe—Single-step pipe chains (unnecessary |>)
  • NestedFunctionCalls—Deeply nested calls like foo(bar(baz(x)))
  • Specs—Public functions without type specifications
  • LargeNumbers—Large integers without underscore separators

Refactor [F]—10 checks

  • SimplifyConditionalif x do true else false end patterns
  • DeadCode—Unreachable code after early returns
  • CodeDuplication—Duplicate function bodies (same AST structure)
  • NegatedConditionWithElseif !x do...else (swap branches)
  • DoubleBooleanNegation!!x pattern (simplify to boolean cast)
  • AppendSingleItemlist ++ [item] (use [item | list] or List.insert_at)

  • PipeChainStart—Pipe chains starting with a literal value
  • FilterCountEnum.filter |> Enum.count (use Enum.count/2)
  • UnlessWithElseunless...else (use if instead)
  • VariableRebinding—Same variable assigned multiple times in a block

Design [D]—5 checks

  • HighComplexity—Functions with cyclomatic complexity exceeding threshold
  • LowCohesion—Modules where functions share no common data
  • HighCoupling—Modules with too many external dependencies
  • TagTODO—TODO comments that should be addressed
  • TagFIXME—FIXME comments indicating known bugs

Observability [O]—5 checks

  • MissingTelemetryInObanWorker—Oban worker perform/1 without telemetry
  • MissingTelemetryInLiveviewMount—LiveView mount/3 without telemetry
  • MissingTelemetryInAuthPlug—Auth plug call/2 without telemetry
  • MissingTelemetryForExternalHttp—HTTP client calls without telemetry wrapper
  • TelemetryInRecursiveFunction—Telemetry inside recursive functions (anti-pattern)

Configuration

Create a .metacredo.exs file (or run mix metacredo.gen.config):

%{
  configs: [
    %{
      name: "default",
      files: %{
        included: ["lib/", "src/"],
        excluded: [~r"/_build/", ~r"/deps/"]
      },
      checks: %{
        enabled: :all,
        disabled: []
      }
    }
  ]
}

To selectively enable checks with parameters:

checks: %{
  enabled: [
    {MetaCredo.Check.Security.HardcodedValue, [exclude_localhost: true]},
    {MetaCredo.Check.Warning.MissingErrorHandling, []},
    {MetaCredo.Check.Readability.MagicNumber, [ignored_numbers: [0, 1, -1, 2]]}
  ],
  disabled: []
}

Inline Disable Comments

Use source comments to suppress specific checks:

# metacredo:disable-for-next-line MetaCredo.Check.Security.HardcodedValue
@test_url "https://api.example.com"

# metacredo:disable-for-this-file

The comment must be represented as a :comment node in the MetaAST for inline disabling to work. Metastatic's adapters that preserve comments (e.g., the Cure adapter) support this out of the box.

Writing Custom Checks

defmodule MyApp.Check.CustomCheck do
  use MetaCredo.Check,
    category: :warning,
    base_priority: :normal,
    explanations: [
      check: "Detects a custom anti-pattern.",
      params: [threshold: "Maximum allowed occurrences (default: 3)"]
    ],
    param_defaults: [threshold: 3]

  @impl true
  def run(%SourceFile{} = source_file, params) do
    threshold = params_get(params, :threshold)

    source_file
    |> SourceFile.ast()
    |> Metastatic.AST.prewalk([], fn node, acc ->
      # ... detection logic ...
      {node, acc}
    end)
    |> elem(1)
  end
end

Register custom checks in .metacredo.exs:

checks: %{
  enabled: [
    {MyApp.Check.CustomCheck, [threshold: 5]}
  ]
}

Relationship to Credo and OeditusCredo

  • Credo operates on Elixir's native AST (Macro module). MetaCredo operates on the language-agnostic MetaAST.
  • OeditusCredo provides Credo plugin checks for the Elixir community and remains available for Elixir-only projects.
  • MetaCredo covers the same detection patterns as OeditusCredo but works across all languages supported by Metastatic.

Roadmap

The following items are planned for future releases:

  • [ ] Plugin system for third-party checks (mirrors Credo plugins).
  • [ ] LSP integration for in-editor diagnostics.
  • [ ] Auto-fix / code modification via MetaAST transformations.
  • [x] CI/CD integrations (GitHub Actions, GitLab CI, etc.).
  • [ ] Extract analysis modules from metastatic core in the next major release of metastatic, using deprecated re-exports to bridge the transition.

License

MIT