From 095cbde492172aa0fa95083e2a9f4a37df7703de Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:47:30 +0100 Subject: [PATCH 1/3] fix(ci): detect unreachable reusable workflow pins --- lib/rules/baseline_health.ex | 127 +++++++++++++++++++++++++++++----- test/baseline_health_test.exs | 33 +++++++++ 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/lib/rules/baseline_health.ex b/lib/rules/baseline_health.ex index af3e0687..1e0c4dfe 100644 --- a/lib/rules/baseline_health.ex +++ b/lib/rules/baseline_health.ex @@ -27,8 +27,10 @@ defmodule Hypatia.Rules.BaselineHealth do flake. - **BH004** — A workflow `uses:` line references an action by full SHA - that does not exist on the upstream repository. Every workflow run - using the pin fails immediately on action resolution. Discovered + that does not exist on the upstream repository, or references a reusable + workflow at a commit that exists but is not reachable from the upstream + default branch. Every workflow run using the pin fails immediately on + action/workflow resolution. Discovered 2026-05-26 in `hyperpolymath/rsr-template-repo` and 9 other estate repos — a single dead SHA pin (`actions/upload-artifact@65c79d7f…`) was propagated by template scaffolding and broke main on each. @@ -104,7 +106,12 @@ defmodule Hypatia.Rules.BaselineHealth do # `uses: /@<40-hex-sha>` (with or without a trailing # comment) for every action reference. Composite-action callouts # (`uses: ./...`) and docker-image refs (`docker://...`) are skipped. - @uses_sha_pattern ~r/^\s*-?\s*uses:\s*([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@([a-fA-F0-9]{40})\b/m + # Capture the repository, optional in-repository path, and SHA separately. + # The previous form required `@` immediately after owner/repo, so it silently + # ignored every cross-repository reusable workflow (`owner/repo/.github/ + # workflows/x.yml@sha`). That blind spot is how an extant but non-mainline + # standards commit reached 251 active workflow files without BH004 noticing. + @uses_sha_pattern ~r/^\s*-?\s*uses:\s*([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(\/[a-zA-Z0-9_.\/-]+)?@([a-fA-F0-9]{40})\b/m # BH005/BH006 — distinguish workflows that ran on the latest PR vs # only on the main-branch push. A required check whose name appears @@ -284,8 +291,12 @@ defmodule Hypatia.Rules.BaselineHealth do # ─── BH004: Dead action SHA pin in workflow YAML ────────────────────── @doc """ - BH004: For each `uses: /@` reference in workflow YAML, - verify the SHA resolves to a real commit on the upstream action repo. + BH004: For each `uses: /[/path]@` reference in workflow + YAML, verify the SHA resolves to a real commit on the upstream repository. + For cross-repository reusable workflows, also verify that commit is reachable + from the repository's default branch. GitHub's contents API can retrieve an + orphaned/diverged commit, but the Actions workflow resolver rejects it as + `workflow was not found` before creating any jobs. Discovery 2026-05-26: a single dead pin (`actions/upload-artifact@65c79d7f54e76e4e3c7a8f34db0f4ac8b515c478`) @@ -310,20 +321,48 @@ defmodule Hypatia.Rules.BaselineHealth do content = File.read!(path) rel = Path.relative_to(path, repo_path) - Regex.scan(@uses_sha_pattern, content, return: :index) - |> Enum.flat_map(fn [{full_start, _}, {repo_start, repo_len}, {sha_start, sha_len}] -> - # byte offsets from return: :index — String.slice counts graphemes, - # so any earlier multi-byte char shifted these and sent garbage - # owner/repo + sha pairs to the GitHub API (false BH004 criticals). - action_repo = binary_part(content, repo_start, repo_len) - sha = binary_part(content, sha_start, sha_len) |> String.downcase() - line_no = line_number_for_offset(content, full_start) - check_action_sha_alive(action_repo, sha, rel, line_no) + content + |> uses_sha_references() + |> Enum.flat_map(fn ref -> + check_action_sha_alive(ref.repository, ref.path, ref.sha, rel, ref.line) end) end) end - defp check_action_sha_alive(action_repo, sha, file, line_no) do + @doc false + def uses_sha_references(content) when is_binary(content) do + Regex.scan(@uses_sha_pattern, content, return: :index) + |> Enum.map(fn [ + {full_start, _}, + {repo_start, repo_len}, + {path_start, path_len}, + {sha_start, sha_len} + ] -> + # byte offsets from return: :index — String.slice counts graphemes, + # so any earlier multi-byte char shifted these and sent garbage + # owner/repo + sha pairs to the GitHub API (false BH004 criticals). + path = + if path_start < 0, + do: "", + else: binary_part(content, path_start, path_len) |> String.trim_leading("/") + + %{ + repository: binary_part(content, repo_start, repo_len), + path: path, + sha: binary_part(content, sha_start, sha_len) |> String.downcase(), + line: line_number_for_offset(content, full_start) + } + end) + end + + @doc false + def reusable_reachability("ahead"), do: :reachable + def reusable_reachability("identical"), do: :reachable + def reusable_reachability("behind"), do: :unreachable + def reusable_reachability("diverged"), do: :unreachable + def reusable_reachability(_), do: :unknown + + defp check_action_sha_alive(action_repo, upstream_path, sha, file, line_no) do case curl_github("repos/#{action_repo}/commits/#{sha}") do {:ok, %{"message" => "No commit found for SHA: " <> _}} -> [ @@ -338,6 +377,7 @@ defmodule Hypatia.Rules.BaselineHealth do detail: %{ line: line_no, action_repo: action_repo, + upstream_path: upstream_path, dead_sha: sha, fix: "Bump to the current tag head: " <> @@ -347,8 +387,14 @@ defmodule Hypatia.Rules.BaselineHealth do ] {:ok, %{"sha" => _real_sha}} -> - # SHA resolves cleanly — no finding. - [] + if reusable_workflow_path?(upstream_path) do + check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) + else + # Ordinary actions may intentionally live on a release/tag branch; + # existence is sufficient for those. Default-branch reachability is + # a GitHub constraint specifically for cross-repo reusable workflows. + [] + end {:error, :no_token} -> # We can't verify without a token. Don't emit — false positives @@ -361,6 +407,53 @@ defmodule Hypatia.Rules.BaselineHealth do end end + defp reusable_workflow_path?(path) do + String.starts_with?(path, ".github/workflows/") and + (String.ends_with?(path, ".yml") or String.ends_with?(path, ".yaml")) + end + + defp check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) do + with {:ok, %{"default_branch" => branch}} when is_binary(branch) <- + curl_github("repos/#{action_repo}"), + {:ok, %{"status" => status}} <- + curl_github("repos/#{action_repo}/compare/#{sha}...#{branch}") do + case reusable_reachability(status) do + :reachable -> + [] + + :unreachable -> + [ + %{ + rule: "BH004", + file: file, + severity: :critical, + reason: + "workflow #{file}:#{line_no} pins #{action_repo}/#{upstream_path}@#{String.slice(sha, 0, 8)}…; " <> + "the commit exists but is not reachable from `#{branch}`, so GitHub rejects the reusable workflow as `workflow was not found`", + action: :open_followup_pr, + detail: %{ + line: line_no, + action_repo: action_repo, + upstream_path: upstream_path, + unreachable_sha: sha, + default_branch: branch, + compare_status: status, + fix: + "Pin a commit reachable from `#{branch}`: " <> + "`gh api repos/#{action_repo}/commits/#{branch} --jq .sha`" + } + } + ] + + :unknown -> + [] + end + else + # Network/permission ambiguity is not evidence that a pin is broken. + _ -> [] + end + end + # ─── BH005: Push-only required check ────────────────────────────────── @doc """ diff --git a/test/baseline_health_test.exs b/test/baseline_health_test.exs index c842db4a..e8d547a6 100644 --- a/test/baseline_health_test.exs +++ b/test/baseline_health_test.exs @@ -176,6 +176,39 @@ defmodule Hypatia.Rules.BaselineHealthTest do # ─── BH004: dead action SHA pin ───────────────────────────────────── describe "bh004_dead_action_sha_pin/1" do + test "parses both ordinary actions and reusable workflows with exact line numbers" do + action_sha = "ea165f8d65b6e75b540449e92b4886f43607fa02" + workflow_sha = "7fdc2705df74b4e352d2a1cde3e87a5923fdf329" + + refs = + BaselineHealth.uses_sha_references(""" + jobs: + ordinary: + steps: + - uses: actions/checkout@#{action_sha} + reusable: + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@#{workflow_sha} + """) + + assert refs == [ + %{repository: "actions/checkout", path: "", sha: action_sha, line: 4}, + %{ + repository: "hyperpolymath/standards", + path: ".github/workflows/hypatia-scan-reusable.yml", + sha: workflow_sha, + line: 6 + } + ] + end + + test "default-branch compare polarity matches GitHub reusable resolution" do + assert BaselineHealth.reusable_reachability("identical") == :reachable + assert BaselineHealth.reusable_reachability("ahead") == :reachable + assert BaselineHealth.reusable_reachability("diverged") == :unreachable + assert BaselineHealth.reusable_reachability("behind") == :unreachable + assert BaselineHealth.reusable_reachability("unexpected") == :unknown + end + test "returns [] when no workflow files exist", %{repo: repo} do # No .github/workflows/ directory at all. assert BaselineHealth.bh004_dead_action_sha_pin(repo) == [] From ae5d8f4da1eb274730d9a86b33bae52af165a4ce Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:21:01 +0000 Subject: [PATCH 2/3] Fix CodeRabbit issues in PR #755 --- lib/rules/baseline_health.ex | 12 +++++++++--- test/baseline_health_test.exs | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/rules/baseline_health.ex b/lib/rules/baseline_health.ex index 1e0c4dfe..904e2370 100644 --- a/lib/rules/baseline_health.ex +++ b/lib/rules/baseline_health.ex @@ -356,6 +356,12 @@ defmodule Hypatia.Rules.BaselineHealth do end @doc false + def reusable_reachability(%{"status" => status}) when is_binary(status), + do: reusable_reachability(status) + + def reusable_reachability(%{"message" => "No common ancestor between " <> _}), + do: :unreachable + def reusable_reachability("ahead"), do: :reachable def reusable_reachability("identical"), do: :reachable def reusable_reachability("behind"), do: :unreachable @@ -415,9 +421,9 @@ defmodule Hypatia.Rules.BaselineHealth do defp check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) do with {:ok, %{"default_branch" => branch}} when is_binary(branch) <- curl_github("repos/#{action_repo}"), - {:ok, %{"status" => status}} <- + {:ok, comparison} <- curl_github("repos/#{action_repo}/compare/#{sha}...#{branch}") do - case reusable_reachability(status) do + case reusable_reachability(comparison) do :reachable -> [] @@ -437,7 +443,7 @@ defmodule Hypatia.Rules.BaselineHealth do upstream_path: upstream_path, unreachable_sha: sha, default_branch: branch, - compare_status: status, + compare_status: Map.get(comparison, "status") || Map.get(comparison, "message"), fix: "Pin a commit reachable from `#{branch}`: " <> "`gh api repos/#{action_repo}/commits/#{branch} --jq .sha`" diff --git a/test/baseline_health_test.exs b/test/baseline_health_test.exs index e8d547a6..25da8fb4 100644 --- a/test/baseline_health_test.exs +++ b/test/baseline_health_test.exs @@ -209,6 +209,12 @@ defmodule Hypatia.Rules.BaselineHealthTest do assert BaselineHealth.reusable_reachability("unexpected") == :unknown end + test "recognizes GitHub's no-common-ancestor compare response as unreachable" do + assert BaselineHealth.reusable_reachability(%{ + "message" => "No common ancestor between deadbeef and main." + }) == :unreachable + end + test "returns [] when no workflow files exist", %{repo: repo} do # No .github/workflows/ directory at all. assert BaselineHealth.bh004_dead_action_sha_pin(repo) == [] From c97cebdcff22d7406f4702e69854444c12de05d3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:26:13 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=A7=20CodeRabbit=20CI=20Fix:=20Fix?= =?UTF-8?q?=2015=20failing=20CI=20checks=20across=20build,=20test,=20secur?= =?UTF-8?q?ity,=20and=20docs=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/fleet_dispatcher.ex | 150 +++++++++++++------------- lib/hypatia/diagnostics/monitor.ex | 4 +- lib/hypatia/web/api_router.ex | 78 ++++++-------- lib/hypatia/web/router.ex | 60 +++++------ lib/merge_orchestration/strategist.ex | 49 ++++++--- lib/neural/prover_recommender.ex | 4 +- lib/rules/admin_merge_eligibility.ex | 6 +- lib/rules/rules.ex | 1 - lib/vcl/client.ex | 16 +-- 9 files changed, 182 insertions(+), 186 deletions(-) diff --git a/lib/fleet_dispatcher.ex b/lib/fleet_dispatcher.ex index c7cc1f07..363872a6 100644 --- a/lib/fleet_dispatcher.ex +++ b/lib/fleet_dispatcher.ex @@ -45,54 +45,6 @@ defmodule Hypatia.FleetDispatcher do end end - # Standard fleet dispatch path for eliminate tier. - defp dispatch_eliminate_via_fleet(recipe, pattern) do - confidence = Map.get(recipe, "confidence", 0.0) - strategy = TriangleRouter.dispatch_strategy(confidence) - - bot_id = - case strategy do - :auto_execute -> "robot-repo-automaton" - :review -> "rhodibot" - :report_only -> "sustainabot" - end - - action_type = - case strategy do - :auto_execute -> :commit_push - :review -> :pr_create - :report_only -> :advisory - end - - # Gate review -- every action must pass through the Kin Gate - gate_action = %{ - bot_id: bot_id, - repo: get_pattern_repo(pattern), - action_type: action_type, - confidence: confidence, - pattern_id: Map.get(pattern, "id", Map.get(pattern, "description", "")), - scan_timestamp: Map.get(pattern, "scan_timestamp"), - dispatch_tier: strategy - } - - case gate_review(gate_action) do - {:approved, _} -> - do_eliminate_dispatch(strategy, recipe, pattern, confidence) - - {:held, reason} -> - Logger.warning("Gate held eliminate dispatch: #{reason}") - {:ok, :held} - - {:rejected, reason} -> - Logger.warning("Gate rejected eliminate dispatch: #{reason}") - {:error, :gate_rejected, reason} - - {:deferred, wait_ms} -> - Logger.info("Gate deferred eliminate dispatch -- retry in #{div(wait_ms, 1000)}s") - {:ok, :deferred} - end - end - def dispatch_routed_action({:substitute, recipe, pattern}) do proven_module = Map.get(recipe, "proven_module", "unknown") @@ -123,22 +75,20 @@ defmodule Hypatia.FleetDispatcher do }) end - @doc """ - Dispatch a ProofObligation recipe through the Safety Triangle. - - Called by `ProofObligation.obligations_from_patterns/2` and any code - that constructs `{:proof_obligation, recipe, pattern}` tuples. - - Triangle routing for proof obligations: - - `:eliminate` (auto-provable, confidence >= 0.90) → - robot-repo-automaton applies tactic inline - - `:eliminate` (confidence < 0.90) → - echidnabot with eliminate-tier hint - - `:substitute` → - echidnabot with VeriSimDB-recommended prover hint - - `:control` → - sustainabot advisory (sorry/Admitted present, human required) - """ + # Dispatch a ProofObligation recipe through the Safety Triangle. + # + # Called by `ProofObligation.obligations_from_patterns/2` and any code + # that constructs `{:proof_obligation, recipe, pattern}` tuples. + # + # Triangle routing for proof obligations: + # - `:eliminate` (auto-provable, confidence >= 0.90) → + # robot-repo-automaton applies tactic inline + # - `:eliminate` (confidence < 0.90) → + # echidnabot with eliminate-tier hint + # - `:substitute` → + # echidnabot with VeriSimDB-recommended prover hint + # - `:control` → + # sustainabot advisory (sorry/Admitted present, human required) def dispatch_routed_action({:proof_obligation, recipe, pattern}) do tier = Map.get(recipe, "triangle_tier", "substitute") claim = Map.get(recipe, "claim", Map.get(pattern, "description", "")) @@ -202,19 +152,17 @@ defmodule Hypatia.FleetDispatcher do end end - @doc """ - Dispatch a DependabotAlerts recipe through the Safety Triangle. - - Called by `DependabotAlerts.fixes_from_alerts/3` and any code that - constructs `{:dependabot_fix, recipe, pattern}` tuples. - - Triangle routing for Dependabot alerts: - - `:eliminate` + confidence >= 0.95 -> robot-repo-automaton auto-bumps - (subject to Kin Gate, rate limiter, exclusion registry) - - `:eliminate` + confidence in [0.85, 0.95) -> rhodibot opens a PR - - `:substitute` -> rhodibot opens a PR (major bump / breaking change) - - `:control` -> sustainabot advisory (no auto-fix path) - """ + # Dispatch a DependabotAlerts recipe through the Safety Triangle. + # + # Called by `DependabotAlerts.fixes_from_alerts/3` and any code that + # constructs `{:dependabot_fix, recipe, pattern}` tuples. + # + # Triangle routing for Dependabot alerts: + # - `:eliminate` + confidence >= 0.95 -> robot-repo-automaton auto-bumps + # (subject to Kin Gate, rate limiter, exclusion registry) + # - `:eliminate` + confidence in [0.85, 0.95) -> rhodibot opens a PR + # - `:substitute` -> rhodibot opens a PR (major bump / breaking change) + # - `:control` -> sustainabot advisory (no auto-fix path) def dispatch_routed_action({:dependabot_fix, recipe, pattern}) do tier = Map.get(recipe, "triangle_tier", "control") confidence = Map.get(recipe, "confidence", 0.5) @@ -265,6 +213,54 @@ defmodule Hypatia.FleetDispatcher do end end + # Standard fleet dispatch path for eliminate tier. + defp dispatch_eliminate_via_fleet(recipe, pattern) do + confidence = Map.get(recipe, "confidence", 0.0) + strategy = TriangleRouter.dispatch_strategy(confidence) + + bot_id = + case strategy do + :auto_execute -> "robot-repo-automaton" + :review -> "rhodibot" + :report_only -> "sustainabot" + end + + action_type = + case strategy do + :auto_execute -> :commit_push + :review -> :pr_create + :report_only -> :advisory + end + + # Gate review -- every action must pass through the Kin Gate + gate_action = %{ + bot_id: bot_id, + repo: get_pattern_repo(pattern), + action_type: action_type, + confidence: confidence, + pattern_id: Map.get(pattern, "id", Map.get(pattern, "description", "")), + scan_timestamp: Map.get(pattern, "scan_timestamp"), + dispatch_tier: strategy + } + + case gate_review(gate_action) do + {:approved, _} -> + do_eliminate_dispatch(strategy, recipe, pattern, confidence) + + {:held, reason} -> + Logger.warning("Gate held eliminate dispatch: #{reason}") + {:ok, :held} + + {:rejected, reason} -> + Logger.warning("Gate rejected eliminate dispatch: #{reason}") + {:error, :gate_rejected, reason} + + {:deferred, wait_ms} -> + Logger.info("Gate deferred eliminate dispatch -- retry in #{div(wait_ms, 1000)}s") + {:ok, :deferred} + end + end + defp maybe_cve(nil), do: "" defp maybe_cve(""), do: "" defp maybe_cve(cve), do: " (#{cve})" diff --git a/lib/hypatia/diagnostics/monitor.ex b/lib/hypatia/diagnostics/monitor.ex index b30667f9..f602ab53 100644 --- a/lib/hypatia/diagnostics/monitor.ex +++ b/lib/hypatia/diagnostics/monitor.ex @@ -158,12 +158,12 @@ defmodule Hypatia.Diagnostics.Monitor do nil -> {:error, :neural_unresponsive} status -> {:ok, {:neural, status}} end + rescue + _ -> {:error, :neural_crashed} catch # Training cycles can take minutes -- a timeout means busy, not crashed :exit, {:timeout, _} -> {:ok, {:neural, :training_in_progress}} :exit, _ -> {:error, :neural_crashed} - rescue - _ -> {:error, :neural_crashed} end end diff --git a/lib/hypatia/web/api_router.ex b/lib/hypatia/web/api_router.ex index f771c91f..1f67528e 100644 --- a/lib/hypatia/web/api_router.ex +++ b/lib/hypatia/web/api_router.ex @@ -64,11 +64,9 @@ defmodule Hypatia.Web.ApiRouter do end end - @doc """ - GET /api/recipes/:id -- single-recipe drill-down. Returns the same - shape as one row from `/api/recipes`, plus the recipe definition - itself when found in the registry. - """ + # GET /api/recipes/:id -- single-recipe drill-down. Returns the same + # shape as one row from `/api/recipes`, plus the recipe definition + # itself when found in the registry. get "/recipes/:id" do health = Hypatia.OutcomeTracker.recipe_health() row = Enum.find(health, &(&1.recipe_id == id)) @@ -81,11 +79,9 @@ defmodule Hypatia.Web.ApiRouter do end end - @doc """ - GET /api/quarantine -- everything currently auto-quarantined: - recipes (verification-rate gate) and bots (consecutive-failure / - FP-rate gate from Hypatia.Safety.Quarantine). - """ + # GET /api/quarantine -- everything currently auto-quarantined: + # recipes (verification-rate gate) and bots (consecutive-failure / + # FP-rate gate from Hypatia.Safety.Quarantine). get "/quarantine" do recipes = Hypatia.OutcomeTracker.recipe_health() @@ -103,11 +99,9 @@ defmodule Hypatia.Web.ApiRouter do }) end - @doc """ - GET /api/alerts -- Recent threshold-rule alerts emitted by - Hypatia.Watcher.Alerts (ring buffer, newest first). Powers the - dashboard alert ribbon and supports manual triage. - """ + # GET /api/alerts -- Recent threshold-rule alerts emitted by + # Hypatia.Watcher.Alerts (ring buffer, newest first). Powers the + # dashboard alert ribbon and supports manual triage. get "/alerts" do rows = case Process.whereis(Hypatia.Watcher.Alerts) do @@ -118,20 +112,18 @@ defmodule Hypatia.Web.ApiRouter do json(conn, 200, %{count: length(rows), rows: rows}) end - @doc """ - POST /api/alerts/ingest -- Federation ingress. Peer hypatia - instances POST their alerts here via the Peer sink. - - Auth: the auth_gate plug enforces a valid bearer token, so this - endpoint is only reachable when HYPATIA_API_BEARER_TOKEN is set - and the request carries it. Federation without shared auth is - refused at the gate, not here. - - Loop prevention: the ingested alert is tagged with - `metadata.federated_from = ` so the - Peer sink can skip it on broadcast and the dashboard can - attribute it. - """ + # POST /api/alerts/ingest -- Federation ingress. Peer hypatia + # instances POST their alerts here via the Peer sink. + # + # Auth: the auth_gate plug enforces a valid bearer token, so this + # endpoint is only reachable when HYPATIA_API_BEARER_TOKEN is set + # and the request carries it. Federation without shared auth is + # refused at the gate, not here. + # + # Loop prevention: the ingested alert is tagged with + # `metadata.federated_from = ` so the + # Peer sink can skip it on broadcast and the dashboard can + # attribute it. post "/alerts/ingest" do {:ok, body, conn} = Plug.Conn.read_body(conn) @@ -174,20 +166,18 @@ defmodule Hypatia.Web.ApiRouter do defp parse_atom(_), do: :unknown - @doc """ - GET /api/events -- Server-Sent Events stream of telemetry as it - fires. Each event arrives as - - event: hypatia.scan.complete - data: {"measurements": {...}, "metadata": {...}, "at": ms} - - Optional `?events=hypatia.scan.complete,hypatia.outcome.recorded` - filter narrows the stream to specific event kinds. - - Heartbeats every 15s as comment lines (`: keepalive`) defeat proxy - idle-timeouts. The handler exits cleanly when the client disconnects - (Bandit closes the chunked response). - """ + # GET /api/events -- Server-Sent Events stream of telemetry as it + # fires. Each event arrives as + # + # event: hypatia.scan.complete + # data: {"measurements": {...}, "metadata": {...}, "at": ms} + # + # Optional `?events=hypatia.scan.complete,hypatia.outcome.recorded` + # filter narrows the stream to specific event kinds. + # + # Heartbeats every 15s as comment lines (`: keepalive`) defeat proxy + # idle-timeouts. The handler exits cleanly when the client disconnects + # (Bandit closes the chunked response). get "/events" do conn = Plug.Conn.fetch_query_params(conn) filter = parse_event_filter(conn.query_params["events"]) @@ -368,7 +358,7 @@ defmodule Hypatia.Web.ApiRouter do a |> :binary.bin_to_list() |> Enum.zip(:binary.bin_to_list(b)) - |> Enum.reduce(0, fn {x, y}, acc -> acc ||| Bitwise.bxor(x, y) end) == 0 + |> Enum.reduce(0, fn {x, y}, acc -> acc ||| bxor(x, y) end) == 0 end end diff --git a/lib/hypatia/web/router.ex b/lib/hypatia/web/router.ex index c9b30eac..e2098bc9 100644 --- a/lib/hypatia/web/router.ex +++ b/lib/hypatia/web/router.ex @@ -28,20 +28,16 @@ defmodule Hypatia.Web.Router do plug(:match) plug(:dispatch) - @doc """ - GET / -- Single-page live operational dashboard. HTML + vanilla JS, - polls /api/status and EventSource-streams /api/events. The dashboard - itself is publicly reachable; the data endpoints it calls are - loopback-only (gated in ApiRouter), so a non-local browser would - render the chrome but get 403 from the XHR/SSE calls. - """ + # GET / -- Single-page live operational dashboard. HTML + vanilla JS, + # polls /api/status and EventSource-streams /api/events. The dashboard + # itself is publicly reachable; the data endpoints it calls are + # loopback-only (gated in ApiRouter), so a non-local browser would + # render the chrome but get 403 from the XHR/SSE calls. get "/" do Hypatia.Web.Dashboard.call(conn, []) end - @doc """ - GET /health -- Basic health check for the HTTP endpoint. - """ + # GET /health -- Basic health check for the HTTP endpoint. get "/health" do health = %{ status: "ok", @@ -54,27 +50,23 @@ defmodule Hypatia.Web.Router do |> send_resp(200, Jason.encode!(health)) end - @doc """ - GET /metrics -- Prometheus text-format exposition. Publicly - reachable (NOT loopback-only) because scrapers routinely run on a - different host; there's no operational data in the metric body - that isn't already implied by the dashboard's existence. - """ + # GET /metrics -- Prometheus text-format exposition. Publicly + # reachable (NOT loopback-only) because scrapers routinely run on a + # different host; there's no operational data in the metric body + # that isn't already implied by the dashboard's existence. get "/metrics" do Hypatia.Web.Metrics.call(conn, []) end - @doc """ - GET /metrics/snapshot -- Compact JSON snapshot of estate-level - counters: repos scanned, weak points, dispatched actions, outcomes, - recipes, average confidence. Consumed by the optional Ada TUI - (`lib/tui/port.ex`) on its 10s tick, and useful as a single-call - status read for external dashboards. - - Reads from the verisim-data flat-file store via VerisimConnector; - any failure returns a degraded snapshot with status="degraded" - rather than 500, so the TUI keeps rendering. - """ + # GET /metrics/snapshot -- Compact JSON snapshot of estate-level + # counters: repos scanned, weak points, dispatched actions, outcomes, + # recipes, average confidence. Consumed by the optional Ada TUI + # (`lib/tui/port.ex`) on its 10s tick, and useful as a single-call + # status read for external dashboards. + # + # Reads from the verisim-data flat-file store via VerisimConnector; + # any failure returns a degraded snapshot with status="degraded" + # rather than 500, so the TUI keeps rendering. get "/metrics/snapshot" do snapshot = Hypatia.Web.MetricsSnapshot.build() @@ -88,14 +80,12 @@ defmodule Hypatia.Web.Router do # reachable for container orchestrators. forward("/api", to: Hypatia.Web.ApiRouter) - @doc """ - POST /graphql -- GraphQL-shaped query endpoint (M14). - - Minimal hand-rolled implementation; no introspection, no schema - federation, no Absinthe dep. See lib/hypatia/web/graphql.ex for - the supported field set and limitations. Loopback-only by sharing - the bearer-auth gate when HYPATIA_API_BEARER_TOKEN is configured. - """ + # POST /graphql -- GraphQL-shaped query endpoint (M14). + # + # Minimal hand-rolled implementation; no introspection, no schema + # federation, no Absinthe dep. See lib/hypatia/web/graphql.ex for + # the supported field set and limitations. Loopback-only by sharing + # the bearer-auth gate when HYPATIA_API_BEARER_TOKEN is configured. post "/graphql" do Hypatia.Web.GraphQL.call(conn, []) end diff --git a/lib/merge_orchestration/strategist.ex b/lib/merge_orchestration/strategist.ex index e0ae384b..00c85d53 100644 --- a/lib/merge_orchestration/strategist.ex +++ b/lib/merge_orchestration/strategist.ex @@ -115,36 +115,55 @@ defmodule Hypatia.MergeOrchestration.Strategist do vetoes = [] # License/SPDX touch veto - if Map.get(ctx, :license_touch, false) do - vetoes = [%{bot: "policy-gate", reason: "license/SPDX -- owner-only"} | vetoes] - end + vetoes = + if Map.get(ctx, :license_touch, false) do + [%{bot: "policy-gate", reason: "license/SPDX -- owner-only"} | vetoes] + else + vetoes + end # DO NOT MERGE / WIP in title (case-insensitive) title = Map.get(ctx, :title, "") title_lower = String.downcase(title) - if String.contains?(title_lower, "do not merge") || String.contains?(title_lower, "wip") do - vetoes = [%{bot: "policy-gate", reason: "title contains DO NOT MERGE or WIP"} | vetoes] - end + + vetoes = + if String.contains?(title_lower, "do not merge") || String.contains?(title_lower, "wip") do + [%{bot: "policy-gate", reason: "title contains DO NOT MERGE or WIP"} | vetoes] + else + vetoes + end # do-not-merge / hold labels (case-insensitive) labels = Map.get(ctx, :labels, []) hold_labels = ["do-not-merge", "hold", "do not merge"] labels_lower = Enum.map(labels, &String.downcase/1) - if Enum.any?(labels_lower, &(&1 in hold_labels)) do - vetoes = [%{bot: "policy-gate", reason: "PR has do-not-merge or hold label"} | vetoes] - end + + vetoes = + if Enum.any?(labels_lower, &(&1 in hold_labels)) do + [%{bot: "policy-gate", reason: "PR has do-not-merge or hold label"} | vetoes] + else + vetoes + end # litmus/ or test/ branch prefix branch = Map.get(ctx, :branch, "") - if String.starts_with?(branch, "litmus/") || String.starts_with?(branch, "test/") do - vetoes = [%{bot: "policy-gate", reason: "branch prefix litmus/ or test/"} | vetoes] - end + + vetoes = + if String.starts_with?(branch, "litmus/") || String.starts_with?(branch, "test/") do + [%{bot: "policy-gate", reason: "branch prefix litmus/ or test/"} | vetoes] + else + vetoes + end # Draft state state = Map.get(ctx, :state, "open") - if state == "draft" do - vetoes = [%{bot: "policy-gate", reason: "PR is in draft state"} | vetoes] - end + + vetoes = + if state == "draft" do + [%{bot: "policy-gate", reason: "PR is in draft state"} | vetoes] + else + vetoes + end vetoes end diff --git a/lib/neural/prover_recommender.ex b/lib/neural/prover_recommender.ex index 8f5a6887..b41b9e70 100644 --- a/lib/neural/prover_recommender.ex +++ b/lib/neural/prover_recommender.ex @@ -120,7 +120,7 @@ defmodule Hypatia.Neural.ProverRecommender do # --- verisim-api bridge --------------------------------------------------- - defp fetch_attempts(limit, base_url \\ nil) do + defp fetch_attempts(limit, base_url) do resolved_url = base_url || @verisim_base_url url = "#{resolved_url}/api/v1/proof_attempts?limit=#{limit}" # verisim-api /proof_attempts GET doesn't exist yet -- fall back to ClickHouse @@ -131,7 +131,7 @@ defmodule Hypatia.Neural.ProverRecommender do end end - defp fetch_attempts_via_clickhouse(limit, base_url \\ nil) do + defp fetch_attempts_via_clickhouse(limit, base_url) do resolved_url = base_url || @verisim_base_url # ClickHouse HTTP: reach it by probing each active class's strategy endpoint # and folding the recommendations back into synthetic attempt rows. diff --git a/lib/rules/admin_merge_eligibility.ex b/lib/rules/admin_merge_eligibility.ex index 5d3a3a7a..cee487b4 100644 --- a/lib/rules/admin_merge_eligibility.ex +++ b/lib/rules/admin_merge_eligibility.ex @@ -253,7 +253,9 @@ defmodule Hypatia.Rules.AdminMergeEligibility do Returns `:stalled` if the heuristic fires, `:ok` otherwise. """ @spec dependabot_stalled?(map(), pos_integer()) :: :stalled | :ok - def dependabot_stalled?(%{author: %{login: "dependabot[bot]"}} = pr, days_threshold \\ 7) do + def dependabot_stalled?(pr, days_threshold \\ 7) + + def dependabot_stalled?(%{author: %{login: "dependabot[bot]"}} = pr, days_threshold) do created = Map.get(pr, :createdAt, "") has_review_requests = Map.get(pr, :reviewRequests, []) != [] @@ -360,7 +362,7 @@ defmodule Hypatia.Rules.AdminMergeEligibility do """ @spec obsolete_supersedes?(map(), (String.t() -> String.t() | nil)) :: {:obsolete, String.t()} | :not_obsolete - def obsolete_supersedes?(%{files: files} = pr, main_lookup) + def obsolete_supersedes?(%{files: files}, main_lookup) when is_function(main_lookup, 1) do # Look at workflow YAML edits that change a `uses: ...@` line. Enum.find_value(files, :not_obsolete, fn file -> diff --git a/lib/rules/rules.ex b/lib/rules/rules.ex index 6f5dfe5f..83d121b8 100644 --- a/lib/rules/rules.ex +++ b/lib/rules/rules.ex @@ -28,7 +28,6 @@ defmodule Hypatia.Rules do alias Hypatia.Rules.WorkflowHardening alias Hypatia.Rules.SupplyChain alias Hypatia.Rules.BranchProtection - alias Hypatia.Rules.AdminMergeEligibility # alias Hypatia.Rules.ResearchExtensions # wired in follow-up after PR #325 merges @doc """ diff --git a/lib/vcl/client.ex b/lib/vcl/client.ex index 9b7487de..7e14ff3a 100644 --- a/lib/vcl/client.ex +++ b/lib/vcl/client.ex @@ -123,14 +123,6 @@ defmodule Hypatia.VCL.Client do end end - # Route a parsed AST to the right executor. Multi-URL remote federation - # goes to RemoteExecutor; every other source stays on FileExecutor. - defp dispatch(%{source: {:federation_remote, urls, _policy}} = ast, opts) do - Hypatia.VCL.RemoteExecutor.execute(urls, ast, opts) - end - - defp dispatch(ast, opts), do: Hypatia.VCL.FileExecutor.execute(ast, opts) - @impl true def handle_call(:stats, _from, state) do {:reply, @@ -141,6 +133,14 @@ defmodule Hypatia.VCL.Client do }, state} end + # Route a parsed AST to the right executor. Multi-URL remote federation + # goes to RemoteExecutor; every other source stays on FileExecutor. + defp dispatch(%{source: {:federation_remote, urls, _policy}} = ast, opts) do + Hypatia.VCL.RemoteExecutor.execute(urls, ast, opts) + end + + defp dispatch(ast, opts), do: Hypatia.VCL.FileExecutor.execute(ast, opts) + # --------------------------------------------------------------------------- # Built-in VCL Parser (derived from VeriSim.Query.VQLBridge) # ---------------------------------------------------------------------------