ForgeCredoChecks.PortProducerBoundary (forge_credo_checks v0.7.0)

Copy Markdown View Source

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):

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.