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.4", 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

# Umbrella switches to turn off DB or user input checks
$ mix metacredo --no-db
$ mix metacredo --no-user

# JSON output
$ mix metacredo --format json

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

# Generate default configuration (local or global)
$ mix metacredo.gen.config
$ mix metacredo.gen.config --global

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, Cure, March, JavaScript, TypeScript), 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

Security [S]—15 checks

Warning [W]—22 checks

Readability [R]—13 checks

Refactor [F]—10 checks

Design [D]—5 checks

Observability [O]—5 checks

Generate a default .metacredo.exs file in your project directory by running:

$ mix metacredo.gen.config

Or generate a user-wide global configuration in ~/.config/metacredo/.metacredo.exs (or $XDG_CONFIG_HOME/metacredo/.metacredo.exs):

$ mix metacredo.gen.config --global

MetaCredo resolves configuration files in order:

  1. Local .metacredo.exs in the project root
  2. config/.metacredo.exs
  3. Global user configuration (~/.config/metacredo/.metacredo.exs)
  4. Internal defaults

Typical .metacredo.exs Example

Here is an example of a typical .metacredo.exs configuration file:

# Generated by `mix metacredo.gen.config`.
# Move any check to the `disabled` list or set its second element to `false`
# to turn it off. Add `{ModuleName, [param: value]}` to customize parameters.
%{
  configs: [
    %{
      name: "default",
      no_db: false,
      no_user: false,
      files: %{
        included: ["lib/", "src/", "web/"],
        excluded: [
          ~r"(^|/)_build/",
          ~r"(^|/)deps/",
          ~r"(^|/)node_modules/",
          ~r"(^|/)\.git/",
          ~r"(^|/)test/"
        ]
      },
      checks: %{
        enabled: [
          # -- Consistency --
          {MetaCredo.Check.Consistency.ExceptionNames, []},
          {MetaCredo.Check.Consistency.ParameterPatternMatching, []},

          # -- Security --
          {MetaCredo.Check.Security.HardcodedValue, [exclude_localhost: true]},
          {MetaCredo.Check.Security.SQLInjection, []},
          {MetaCredo.Check.Security.XSSVulnerability, []},
          {MetaCredo.Check.Security.PathTraversal, []},
          {MetaCredo.Check.Security.SSRFVulnerability, []},
          {MetaCredo.Check.Security.SensitiveDataExposure, []},
          {MetaCredo.Check.Security.MissingCSRFProtection, []},

          # -- Warning --
          {MetaCredo.Check.Warning.MissingErrorHandling, []},
          {MetaCredo.Check.Warning.SilentErrorCase, []},
          {MetaCredo.Check.Warning.SwallowingException, []},
          {MetaCredo.Check.Warning.NPlusOneQuery, []},
          {MetaCredo.Check.Warning.UnmanagedTask, []},

          # -- Readability --
          {MetaCredo.Check.Readability.MagicNumber, [ignored_numbers: [0, 1, -1, 2]]},
          {MetaCredo.Check.Readability.DeepNesting, [max_nesting: 3]},
          {MetaCredo.Check.Readability.LongFunction, [max_length: 50]},
          {MetaCredo.Check.Readability.FunctionNames, []},
          {MetaCredo.Check.Readability.SinglePipe, []},

          # -- Refactor --
          {MetaCredo.Check.Refactor.SimplifyConditional, []},
          {MetaCredo.Check.Refactor.DeadCode, []},
          {MetaCredo.Check.Refactor.CodeDuplication, []},

          # -- Design --
          {MetaCredo.Check.Design.HighComplexity, [max_complexity: 10]},
          {MetaCredo.Check.Design.TagTodo, []},
          {MetaCredo.Check.Design.TagFixme, []},

          # -- Observability --
          {MetaCredo.Check.Observability.MissingTelemetryInObanWorker, []},
          {MetaCredo.Check.Observability.MissingTelemetryInLiveviewMount, []}
        ],
        disabled: [
          {MetaCredo.Check.Readability.ModuleDoc, []}
        ]
      }
    }
  ]
}

Alternatively, set enabled: :all to run all available checks by default and explicitly list disabled checks in disabled: [...].

Configuring TODO / FIXME tags

The Design.TagTodo and Design.TagFixme checks accept a tags param: a list of tag patterns to search for inside comments. Each entry may be a literal string (matched exactly, case-insensitively) or a regex:

# -- Design --
# Report TODO plus the custom XXX tag, and FIXME plus HACK:
{MetaCredo.Check.Design.TagTodo, [tags: ["TODO", ~r/XXX/]]},
{MetaCredo.Check.Design.TagFixme, [tags: ["FIXME", ~r/HACK/]]},

Defaults are tags: ["TODO"] and tags: ["FIXME"] respectively. Passing tags: [] disables the check. Only matches that occur inside a comment are reported -- a tag appearing in a string literal, variable/function name, or @moduledoc prose is ignored.

Default Noise Policy

MetaCredo ships with a curated, low-noise default set. The goal is that every finding reported by a default run is actionable without any configuration. Checks are disabled by default for one of two reasons:

  1. They are project-specific and only make sense with explicit opt-in (e.g. web-only security checks in a CLI/library codebase).
  2. They rely on unsound heuristics (typically substring matching on function or variable names) that produce a high false-positive rate on idiomatic code.

The following checks are disabled by default:

Check Why it is off by default
Security.MissingAuthentication Project-specific (web apps only).
Security.MissingCSRFProtection Project-specific (web apps only).
Security.IncorrectAuthorization Project-specific (web apps only).
Security.ImproperInputValidation Project-specific (web apps only).
Security.PathTraversal Name-substring heuristic: format_issue matches rm, source_file matches file.
Security.SSRFVulnerability Name-substring heuristic: params_get matches get.
Security.InsecureDirectObjectReference Name-substring heuristic: any variable containing id/param.
Security.MissingAuthorization Name-substring heuristic: Map.update flagged via id in args.
Security.XSSVulnerability Name-substring heuristic: has_raw_content? matches raw.
Security.UnrestrictedFileUpload Name-substring heuristic on helper functions.
Security.InlineJavascript Flags any string containing <script>/onclick=, including its own doc examples.
Warning.MissingErrorHandling Noisy on idiomatic {:ok, _} = ... discard patterns.
Warning.UnusedOperation Noisy on idiomatic discard patterns.
Warning.NPlusOneQuery database_function?/1 matches any call containing get/find/fetch/load/select, so Map.get in a reduce trips it.
Observability.MissingTelemetryInAuthPlug Name-substring heuristic: check_loc, tokens, validate_* flagged as "auth".
Warning.ImperativeStatusHandling Name-substring heuristic: block, lock, check flagged as state transitions.
Design.LowCohesion Variable-sharing heuristic trips on nearly every module with helpers.
Warning.CallbackHell Redundant with DeepNesting + ComplexConditional + HighComplexity.
Warning.DebugLeftover Flags legitimate output primitives (IO.puts, IO.write).
Readability.Specs Noisy on behaviour callbacks and Mix-task run/1.

Default Exclusions

Beyond the checks above, MetaCredo also excludes whole directory trees from analysis by default via files.excluded:

Pattern Why it is excluded
`~r"(^ /)_build/"`
`~r"(^ /)deps/"`
`~r"(^ /)node_modules/"`
`~r"(^ /).git/"`
`~r"(^ /)test/"`

The test/ exclusion keeps checks that are tuned for production code from firing on test-only patterns (hardcoded credentials, assert_raise, magic numbers, etc.). Paths that merely contain the substring test — such as lib/latest/, lib/contest/, or a root-level test_helper.exs — are not excluded. The test/ exclusion is a default: explicitly targeting a path lifts it, so the tests are analysed. Add the tree back via files.included in .metacredo.exs, or target it explicitly on the command line:

$ mix metacredo --path test/
$ mix metacredo --files-included test/

To enable any of these, add it to the enabled list (or remove it from disabled) in .metacredo.exs. For web projects, a good starting point is:

checks: %{
  enabled: :all,
  disabled: [
    # keep the genuinely noisy ones off
    {MetaCredo.Check.Warning.CallbackHell, []},
    {MetaCredo.Check.Warning.DebugLeftover, []},
    {MetaCredo.Check.Design.LowCohesion, []}
  ]
}

mix metacredo.gen.config writes a configuration file that already reflects this policy: curated checks land in enabled, and the rest in 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

CI / Diff-Based Analysis

MetaCredo can analyze only the files changed in a pull request, making it ideal for CI pipelines:

# Analyze only changed files (default: origin/main...HEAD)
$ mix metacredo --diff --strict

# GitHub Actions with inline PR annotations
$ mix metacredo --diff --format github --strict

# Custom base branch
$ mix metacredo --diff --base origin/develop --format github

--format github emits GitHub Actions workflow commands that produce inline annotations directly on the PR diff:

::error file=lib/repo.ex,line=42::HardcodedValue: Hardcoded URL found
::warning file=lib/worker.ex,line=15::MissingErrorHandling: Missing error handling
metacredo: 2 issue(s) found

See CI.md for the complete CI integration guide, including GitHub Actions workflows, GitLab CI examples, the MetaCredo.Git API, and troubleshooting.

Roadmap

The following items are planned for future releases:

License

MIT