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
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 literalsSQLInjection—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 handlingSilentErrorCase—case matching {:ok, _} without {:error, _} branchSwallowingException—try/rescue without logging or re-raisingNPlusOneQuery—Database calls inside collection operations (N+1)MissingPreload—Collection ops over DB results without eager loadingUnmanagedTask—Task.async without Task.SupervisorSyncOverAsync—Blocking calls in GenServer/LiveView callbacksMissingHandleAsync—Blocking in handle_event without async delegationDirectStructUpdate—Struct updates bypassing changesetsCallbackHell—Deeply nested conditionals exceeding thresholdBlockingInPlug—Blocking I/O in Plug call/init middlewareMissingThrottle—Expensive operations without rate limitingInefficientFilter—Repo.all then Enum.filter (filter in memory)ImperativeStatusHandling—Imperative if/else chains on status codesUnusedOperation—Function call result discarded (not assigned or returned)UnsafeExec—System.cmd/exec with user-controlled arguments (CWE-78)BoolOperationOnSameValues—x && x,x || x(always same result)OperationOnSameValues—x - x(always 0),x / x(always 1)OperationWithConstantResult—x * 0(always 0)LazyLogging—Logger with string interpolation instead of anonymous functionDebugLeftover—IO.inspect, console.log, dbg() left in production codeRaiseInsideRescue—raise/throw inside rescue without re-raise semantics
Readability [R]—13 checks
MagicNumber—Numeric literals in expressions without named constantsDeepNesting—Functions with nesting depth exceeding thresholdLongFunction—Functions with too many statementsComplexConditional—Deeply nested boolean operationsLongParameterList—Functions with too many parametersFunctionNames—Function names not in snake_caseModuleNames—Module/container names not in PascalCaseVariableNames—Variable names not in snake_caseModuleDoc—Modules without documentationSinglePipe—Single-step pipe chains (unnecessary|>)NestedFunctionCalls—Deeply nested calls likefoo(bar(baz(x)))Specs—Public functions without type specificationsLargeNumbers—Large integers without underscore separators
Refactor [F]—10 checks
SimplifyConditional—if x do true else false endpatternsDeadCode—Unreachable code after early returnsCodeDuplication—Duplicate function bodies (same AST structure)NegatedConditionWithElse—if !x do...else(swap branches)DoubleBooleanNegation—!!xpattern (simplify to boolean cast)AppendSingleItem—list ++ [item](use[item | list]orList.insert_at)PipeChainStart—Pipe chains starting with a literal valueFilterCount—Enum.filter |> Enum.count(useEnum.count/2)UnlessWithElse—unless...else(useifinstead)VariableRebinding—Same variable assigned multiple times in a block
Design [D]—5 checks
HighComplexity—Functions with cyclomatic complexity exceeding thresholdLowCohesion—Modules where functions share no common dataHighCoupling—Modules with too many external dependenciesTagTODO—TODO comments that should be addressed (configurable viatags)TagFIXME—FIXME comments indicating known bugs (configurable viatags)
Observability [O]—5 checks
MissingTelemetryInObanWorker—Oban worker perform/1 without telemetryMissingTelemetryInLiveviewMount—LiveView mount/3 without telemetryMissingTelemetryInAuthPlug—Auth plug call/2 without telemetryMissingTelemetryForExternalHttp—HTTP client calls without telemetry wrapperTelemetryInRecursiveFunction—Telemetry inside recursive functions (anti-pattern)
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:
- Local
.metacredo.exsin the project root config/.metacredo.exs- Global user configuration (
~/.config/metacredo/.metacredo.exs) - 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:
- They are project-specific and only make sense with explicit opt-in (e.g. web-only security checks in a CLI/library codebase).
- 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
- Credo operates on Elixir's native AST (
Macromodule). 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.
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:
- Plugin system for third-party checks (mirrors Credo plugins).
- LSP integration for in-editor diagnostics.
- Auto-fix / code modification via MetaAST transformations.
- 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