Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions .github/workflows/hypatia-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ jobs:
elixir-version: '1.19.4'
otp-version: '28.3'

- name: Clone Hypatia
- name: Clone Hypatia (or use checkout when scanning hypatia itself)
run: |
if [ ! -d "$HOME/hypatia" ]; then
# When scanning hypatia from inside hypatia, point $HOME/hypatia
# at the PR/branch checkout instead of cloning main — otherwise
# CLI changes can never pass their own gate (the scanner binary
# would always come from main and ignore new flags).
if [ "${{ github.repository }}" = "hyperpolymath/hypatia" ]; then
ln -sfn "${GITHUB_WORKSPACE}" "$HOME/hypatia"
elif [ ! -d "$HOME/hypatia" ]; then
git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia"
fi

Expand All @@ -47,11 +53,19 @@ jobs:

- name: Run Hypatia scan
id: scan
env:
# Suppress the "Warning: Dependabot alerts unavailable: GITHUB_TOKEN
# not set" line so the run is silent-warning-free. The token is
# read-only by default and only used to query Dependabot alerts.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Scanning repository: ${{ github.repository }}"

# Run scanner
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . > hypatia-findings.json
# Run scanner with --exit-zero so a findings-found exit-1 does
# NOT short-circuit the rest of this step under `set -e`. The
# downstream "Check for critical or high-severity issues" step
# is the explicit gate. See hyperpolymath/hypatia#213.
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json

# Count findings
FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0)
Expand All @@ -73,7 +87,7 @@ jobs:
echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY

- name: Upload findings artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: hypatia-findings
path: hypatia-findings.json
Expand Down
93 changes: 86 additions & 7 deletions lib/hypatia/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,18 @@ defmodule Hypatia.CLI do

@doc """
Main entry point invoked by the escript runtime.

Parses argv, dispatches to the appropriate command, and halts with
an appropriate exit code (0 = success, 1 = findings found, 2 = error).
an appropriate exit code:

* `0` -- success (no findings at/above the severity threshold), or
`--exit-zero` / `HYPATIA_EXIT_ZERO` was set and the scan ran cleanly.
* `1` -- findings exist at/above the threshold (default behaviour).
* `2` -- error (bad arguments, scan failed, etc.).

Findings are always written to stdout in the requested format. A
one-line `[hypatia] scan complete: ...` summary is always written to
stderr so CI logs are never silent on exit.
"""
def main(argv) do
{opts, args, _invalid} =
Expand All @@ -78,7 +88,8 @@ defmodule Hypatia.CLI do
severity: :string,
path: :string,
help: :boolean,
version: :boolean
version: :boolean,
exit_zero: :boolean
],
aliases: [
r: :rules,
Expand All @@ -93,12 +104,14 @@ defmodule Hypatia.CLI do
# Environment variable overrides
format = opts[:format] || System.get_env("HYPATIA_FORMAT") || "json"
severity = opts[:severity] || System.get_env("HYPATIA_SEVERITY") || "medium"
exit_zero = opts[:exit_zero] || env_flag?("HYPATIA_EXIT_ZERO")

config = %{
format: format,
severity: severity,
rules: parse_rules(opts[:rules]),
path: opts[:path]
path: opts[:path],
exit_zero: exit_zero
}

case args do
Expand Down Expand Up @@ -154,9 +167,12 @@ defmodule Hypatia.CLI do
end)

output(filtered, config.format)
emit_finding_summary(filtered, config)

if length(filtered) > 0 do
System.halt(1)
cond do
length(filtered) == 0 -> :ok
config.exit_zero -> :ok
true -> System.halt(1)
end
end

Expand All @@ -183,9 +199,60 @@ defmodule Hypatia.CLI do

# Report always uses text format with extra detail
output_report(filtered, abs_path)
emit_finding_summary(filtered, config)

cond do
length(filtered) == 0 -> :ok
config.exit_zero -> :ok
true -> System.halt(1)
end
end

# ─── Diagnostic summary ──────────────────────────────────────────────
#
# Always emit a single-line summary on stderr after a scan/report so CI
# logs never report a silent exit-1. Findings themselves go to stdout
# in the requested format; this line is a separate operator-facing
# signal that includes severity counts and the exit code about to be
# returned. See hypatia#213.

defp emit_finding_summary(filtered, config) do
counts =
Enum.reduce(filtered, %{}, fn f, acc ->
sev = Map.get(f, :severity, "medium") |> to_string()
Map.update(acc, sev, 1, &(&1 + 1))
end)

total = length(filtered)

if length(filtered) > 0 do
System.halt(1)
breakdown =
["critical", "high", "medium", "low", "info"]
|> Enum.map(fn sev -> "#{sev}=#{Map.get(counts, sev, 0)}" end)
|> Enum.join(", ")

exit_code =
cond do
total == 0 -> 0
config.exit_zero -> 0
true -> 1
end

IO.puts(
:stderr,
"[hypatia] scan complete: #{total} findings >= #{config.severity} " <>
"(#{breakdown}); exit #{exit_code}" <>
if(config.exit_zero and total > 0, do: " (--exit-zero suppressed exit 1)", else: "")
)
end

defp env_flag?(name) do
case System.get_env(name) do
nil -> false
"" -> false
"0" -> false
"false" -> false
"FALSE" -> false
_ -> true
end
end

Expand Down Expand Up @@ -847,15 +914,27 @@ defmodule Hypatia.CLI do
--format, -f <fmt> Output format: json (default), text, github
--severity, -s <lvl> Minimum severity: critical, high, medium (default), low
--path, -p <dir> Path to scan (alternative to positional arg)
--exit-zero Always exit 0 after a successful scan, even when
findings exist. Use in CI when a downstream step
gates on severity counts. (See HYPATIA_EXIT_ZERO.)

EXIT CODES:
0 No findings at or above the configured severity threshold,
OR --exit-zero / HYPATIA_EXIT_ZERO was set and the scan ran cleanly.
1 Findings exist at/above the threshold (default behaviour).
2 Error (bad arguments, scan failed, etc.).

ENVIRONMENT:
HYPATIA_FORMAT Override --format
HYPATIA_SEVERITY Override --severity
HYPATIA_EXIT_ZERO If set to a truthy value (1, true, anything
non-empty/non-zero), behaves as --exit-zero.

EXAMPLES:
hypatia scan .
hypatia scan ~/repos/my-project --format text
hypatia scan . --rules root_hygiene,code_safety --severity high
hypatia scan . --exit-zero # CI: emit findings, never fail step
hypatia report . > report.txt
HYPATIA_FORMAT=json hypatia scan .
""")
Expand Down
44 changes: 44 additions & 0 deletions test/cli_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: PMPL-1.0-or-later

defmodule Hypatia.CLITest do
use ExUnit.Case, async: true
import ExUnit.CaptureIO

# Regression coverage for hyperpolymath/hypatia#213 -- the `scan` step
# in consumer-repo workflows was failing under `set -e` because the
# CLI halted with exit 1 whenever findings existed, and there was no
# stderr diagnostic to explain why. The fix adds `--exit-zero` /
# `HYPATIA_EXIT_ZERO` plus an always-emitted summary line; this test
# locks in the flag plumbing and the documented exit-code semantics.

describe "--exit-zero plumbing" do
test "OptionParser accepts --exit-zero as a strict boolean" do
{opts, _args, invalid} =
OptionParser.parse(
["scan", ".", "--exit-zero"],
strict: [
rules: :string,
format: :string,
severity: :string,
path: :string,
help: :boolean,
version: :boolean,
exit_zero: :boolean
]
)

assert opts[:exit_zero] == true
assert invalid == []
end
end

describe "help output" do
test "documents --exit-zero, HYPATIA_EXIT_ZERO, and exit codes" do
help = capture_io(fn -> Hypatia.CLI.main(["help"]) end)

assert help =~ "--exit-zero"
assert help =~ "HYPATIA_EXIT_ZERO"
assert help =~ "EXIT CODES"
end
end
end
Loading