Basics
This check is disabled by default.
Learn how to enable it via .credo.exs.
This check has a base priority of high and works with any version of Elixir.
Explanation
External-model producers belong only in declared boundary modules.
Why
A port is the boundary where an external model (a subprocess's bytes, an
HTTP response) is (de)serialized into the application's internal model.
The invariant is that an external model never reaches the core
un-deserialized. The way to guarantee that by construction is to fence
the producer calls that emit external models (System.cmd, Req.post,
and friends) to a small set of declared boundary modules. If every
producer lives behind a boundary, no external model can exist in the core.
This check is the generalization of "no raw Repo outside the context" to
every I/O faucet. Detection is module/path membership, never first
argument inspection: executables are routinely invoked through bound
variables (gh_path = System.find_executable("gh") then
System.cmd(gh_path, args)), so matching on the literal command would let
real ports through and flag innocent tooling. The check instead asks a
single question per producer call: does the file it lives in match a
declared boundary glob for that producer's family?
Producer families are keyed separately, and a call in the wrong family's
boundary still fails (a Req call inside a subprocess-only boundary is an
issue):
subprocess:System.cmd/2,3,System.shell/1,:os.cmd/1,Port.open/2,:erlang.open_port/2http:Req.{get,post,put,patch,delete,request}and their!variants.Req.new/1only builds a request struct and performs no I/O, so it is not flagged.
Out of scope
The following are deliberately not flagged, because including any of
them makes the check false-positive-noisy: File.*, :ets/:dets,
Repo/Ecto, and Jason.decode. They are not external-model producers in
the ports-and-adapters sense this check enforces.
Known limits
Detection is syntactic: the check reads the raw AST and does not resolve
aliases. A few constructs therefore bypass it by design rather than being
caught: an aliased module (alias Req, as: HTTP; HTTP.post(...)), an
imported call (import System; cmd(...)), a dynamic dispatch
(apply(System, :cmd, args)), and a producer reached through a
user-defined module whose final segment collides with System/Req.
Closing them would require whole-program semantic analysis; they are
documented here so the boundary the check leaves open is explicit.
Bad
# in lib/forge/core/runner.ex — not a declared boundary
System.cmd("gh", ["pr", "view"])Good
# in lib/forge/ports/gh.ex — matches a subprocess_boundaries glob
System.cmd("gh", ["pr", "view"])Configuration
subprocess_boundaries and http_boundaries are lists of patterns
(regexes or substrings) matched against each file's path; a producer of
that family is allowed only in a file whose path matches. excluded_paths
(default [~r"^test/"]) lists paths skipped entirely.
{ForgeCredoChecks.PortProducerBoundary,
subprocess_boundaries: [~r"^lib/forge/ports/"],
http_boundaries: [~r"^lib/forge/adapters/"]}This check's category is :warning, which in Credo still contributes a
non-zero exit status. A plain registration therefore hard-fails the build
the first time any producer is flagged. For a warning-first rollout you
MUST set exit_status: 0 at registration, and drop it (letting the
:warning exit status apply) only once the boundary modules exist and the
scatter is contained:
# warning phase: reports issues but does not fail CI
{ForgeCredoChecks.PortProducerBoundary,
exit_status: 0,
subprocess_boundaries: [~r"/ports/"]}
# hard gate: drop exit_status to let the :warning status apply
{ForgeCredoChecks.PortProducerBoundary,
subprocess_boundaries: [~r"/ports/"]}Credo's # credo:disable-for-next-line escape hatch is honored
automatically; when rolling this out as a real boundary, pair it with a CI
check that rejects net-new disables of this rule.
Check-Specific Parameters
Use the following parameters to configure this check:
:subprocess_boundaries
Paths (regexes or substrings) allowed to host subprocess producers.
This parameter defaults to [].
:http_boundaries
Paths (regexes or substrings) allowed to host HTTP producers.
This parameter defaults to [].
:excluded_paths
Paths skipped entirely (regexes or substrings).
This parameter defaults to [~r/^test\//].
General Parameters
Like with all checks, general params can be applied.
Parameters can be configured via the .credo.exs config file.