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
73 changes: 73 additions & 0 deletions lib/rules/baseline_health.ex
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,79 @@ defmodule Hypatia.Rules.BaselineHealth do
end
end

# ─── BH008: Inherited main-branch debt ────────────────────────────────
#
# Pattern hit repeatedly during the 2026-06-02 sweeps: a single broken
# check on `main` cascades to EVERY open PR in the same repo. Each PR
# shows the same failing check, none of the PR diffs are causal, and
# the right answer is one root-fix PR on `main` rather than N retries.
#
# Detection: across the open PRs in `{owner, repo}`, find any check
# name that fails on `:threshold` (default 3) or more PRs. Emit one
# `:warn` finding per inherited check name with the affected PR list
# and a recommended action to file a root-fix PR on main.
#
# Crucially NOT auto-fixable: the root cause may be a workflow change,
# a dep bump, a baseline regression — each needs owner direction.
# See feedback in MEMORY.md: "C. NEW rule: inherited-main-branch-debt".

@default_inherited_debt_threshold 3

@doc """
BH008: Find check names that fail across ≥ `threshold` open PRs in
`{owner, repo}`. The shared failure is almost certainly inherited from
a degraded `main` baseline rather than caused by any single PR's diff.

Inputs (caller supplies; this rule is data-driven so the gh API
fan-out lives in the caller):

pr_check_data :: [
%{number: integer,
failed_checks: [String.t()]}
]

Returns a list of findings, one per check name meeting the threshold.

Opts:
* `:threshold` — minimum PR count to flag (default 3)
"""
def bh008_inherited_main_debt(owner, repo, pr_check_data, opts \\ [])
when is_list(pr_check_data) do
threshold = Keyword.get(opts, :threshold, @default_inherited_debt_threshold)

pr_check_data
|> Enum.flat_map(fn pr ->
Enum.map(pr.failed_checks, fn check -> {check, pr.number} end)
end)
|> Enum.group_by(fn {check, _} -> check end, fn {_, num} -> num end)
|> Enum.filter(fn {_check, prs} -> length(Enum.uniq(prs)) >= threshold end)
|> Enum.map(fn {check, prs} ->
pr_numbers = prs |> Enum.uniq() |> Enum.sort()

%{
rule: "BH008",
file: "#{owner}/#{repo}",
severity: :warn,
auto_fixable: false,
type: :inherited_main_debt,
check_name: check,
pr_count: length(pr_numbers),
affected_prs: pr_numbers,
reason:
"Check `#{check}` is failing on #{length(pr_numbers)} open PRs " <>
"(##{Enum.join(pr_numbers, ", #")}). When ≥#{threshold} PRs share " <>
"the same failing check the cause is almost always inherited from " <>
"a degraded main baseline rather than any single PR diff. " <>
"Recommended action: file a single root-fix PR targeting main.",
action: :file_root_fix_pr_on_main,
detail: %{
recommendation: "file_root_fix_pr_on_main",
threshold: threshold
}
}
end)
end

# ─── scan/2 facade ────────────────────────────────────────────────────

@doc """
Expand Down
234 changes: 234 additions & 0 deletions lib/rules/workflow_audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1576,6 +1576,113 @@ defmodule Hypatia.Rules.WorkflowAudit do
end)
end

# ─── WF025: CodeQL cron-canonical conformance ─────────────────────────
#
# standards#286 fixed the canonical CodeQL schedule at `0 6 1 * *` (monthly,
# 06:00 UTC on the 1st of each month) — the estate-wide cron drift sweep
# that triggered this rule is tracked at standards#288 / standards#323 / #324.
#
# Two failure shapes (a sub-category drives gitbot-fleet routing):
#
# :weekly_to_monthly — `0 6 * * 1` style (any dow != *). Maps to the
# #288-class follow-up (weekly → monthly).
#
# :non_canonical_monthly — monthly but at a different (HH:MM) / day-of-
# month. Maps to the #324-class follow-up
# (non-canonical monthly → canonical monthly).
#
# Both are auto-fixable via the same recipe `codeql-cron-monthly` —
# rewrite to `0 6 1 * *`. Severity `:warn` (drift, not breakage).

@codeql_canonical_cron "0 6 1 * *"

@doc """
WF025: Detect `.github/workflows/codeql.yml` cron entries that are NOT the
canonical `0 6 1 * *` monthly schedule.

Sub-category drives downstream routing:
`:weekly_to_monthly` — #288-class follow-up
`:non_canonical_monthly` — #324-class follow-up

See standards#286 (canonical fix), standards#288 (estate cron drift sweep),
standards#323 (recurring drift-detection), standards#324 (non-canonical
one-off fan-out).
"""
def check_codeql_cron_drift(workflow_contents) do
Enum.flat_map(workflow_contents, fn {filename, content} ->
if codeql_workflow_file?(filename) do
stripped = strip_comments(content)
crons = extract_cron_expressions(stripped)

crons
|> Enum.reject(&(&1 == @codeql_canonical_cron))
|> Enum.map(fn raw ->
subcat = cron_drift_subcategory(raw)

%{
rule: "WF025",
rule_id: "WF-025",
type: :codeql_cron_drift,
sub_category: subcat,
file: filename,
severity: :warn,
auto_fixable: true,
recipe_id: "codeql-cron-monthly",
canonical: @codeql_canonical_cron,
observed: raw,
reason:
"CodeQL workflow uses non-canonical cron `#{raw}`. The estate-wide " <>
"canonical (per standards#286) is `#{@codeql_canonical_cron}` " <>
"(monthly, 06:00 UTC on the 1st). Sub-category " <>
"`#{subcat}` — routes through #{routing_label(subcat)}.",
action: :rewrite_cron_to_canonical
}
end)
else
[]
end
end)
end

@doc """
Public for testability: classify a cron expression against the canonical
`0 6 1 * *` shape.

`:canonical` — exact match
`:weekly_to_monthly` — DOW field is not `*` (fires N times per week)
`:non_canonical_monthly` — DOW is `*` but HH/MM/DOM differ
`:malformed` — not a valid 5-field cron expression
"""
def cron_drift_subcategory(@codeql_canonical_cron), do: :canonical

def cron_drift_subcategory(raw) when is_binary(raw) do
case String.split(String.trim(raw), ~r/\s+/, trim: true) do
[_min, _hour, _dom, _mon, dow] when dow != "*" -> :weekly_to_monthly
[_min, _hour, _dom, _mon, _dow] -> :non_canonical_monthly
_ -> :malformed
end
end

defp routing_label(:weekly_to_monthly), do: "standards#288 (weekly→monthly fan-out)"

defp routing_label(:non_canonical_monthly),
do: "standards#324 (non-canonical→canonical fan-out)"

defp routing_label(:malformed), do: "manual triage (malformed cron)"
defp routing_label(_), do: "manual triage"

defp codeql_workflow_file?(filename) do
base = Path.basename(filename)
base == "codeql.yml" or base == "codeql.yaml"
end

defp extract_cron_expressions(stripped) do
~r/^\s*-\s*cron:\s*["']?([^"'\n#]+?)["']?\s*$/m
|> Regex.scan(stripped, capture: :all_but_first)
|> List.flatten()
|> Enum.map(&String.trim/1)
end

# ─── WF024: Chapel ≥2.8.0 ABI / runs-on mismatch ──────────────────────

# Chapel debian packages (`chapel-*.deb` from chapel-lang/chapel releases,
Expand Down Expand Up @@ -1640,6 +1747,8 @@ defmodule Hypatia.Rules.WorkflowAudit do
file: filename,
severity: :high,
runs_on: bad,
auto_fixable: true,
recipe_id: "chapel-runs-on-pin-22-04",
reason:
"Workflow installs a Chapel ≥2.8.0 .deb (CHAPEL_DEB_URL or " <>
"chapel-*.deb), but `runs-on:` is `#{Enum.join(bad, ", ")}`. " <>
Expand All @@ -1657,4 +1766,129 @@ defmodule Hypatia.Rules.WorkflowAudit do
end
end)
end

# ─── WF024 sub-patterns: 4 prior Chapel-2.8.0 sharp edges ─────────────
#
# Surfaced by panic-attack#99 (Chapel Wave 2 — 6 cold-cache CI iterations).
# Each sub-pattern has its own fix recipe so future sessions can route
# them through the gitbot-fleet auto-remediation pipeline without rebuilding
# the diagnostic from scratch.
#
# A. `MANPATH` is unset on minimal runners — Chapel install scripts
# that do `MANPATH=...:$MANPATH` produce `unbound variable` under
# `set -u`. Recipe: guard with `${MANPATH:-}`.
#
# B. `CHPL_LLVM` is unset — chpl needs `CHPL_LLVM=bundled` (or `system`)
# to find its backend. Pre-2.8.0 builds defaulted; 2.8.0 errors.
# Recipe: export `CHPL_LLVM=bundled` (or `system`) before running.
#
# C. `chpl --about` dropped in 2.8.0 — diagnostics that scrape `--about`
# output now fail. Recipe: switch to `chpl --version`.
#
# D. `printchplenv --simple` output reformatted — old globs that match
# `comm-gasnet*` against the simple output now miss. Recipe: switch
# to `find <CHPL_HOME> -name comm-gasnet` (path-walk, not output-grep).

@doc """
WF024 sub-patterns: detect the 4 additional Chapel-2.8.0 sharp edges
alongside the runs-on ABI mismatch. Each emits its own finding with a
distinct `recipe_id` so the gitbot-fleet auto-remediation pipeline can
apply the right targeted fix without re-deriving the diagnostic.

Returned shapes share `rule: "WF024"`, `auto_fixable: true`, and
`severity: :medium` (the sharp edges are CI-fatal but each has a
one-line workaround once recognised — promoted from `:high` reserved
for the ABI mismatch itself).
"""
def check_chapel_sharp_edges(workflow_contents) do
Enum.flat_map(workflow_contents, fn {filename, content} ->
stripped = strip_comments(content)

chapel? =
Enum.any?(@chapel_marker_patterns, &Regex.match?(&1, stripped)) or
Regex.match?(~r/\bchpl\b/, stripped)

if chapel? do
[]
|> append_if(
# A. MANPATH unset under set -u
Regex.match?(~r/\bMANPATH=[^\n]*\$MANPATH\b/, stripped) and
not Regex.match?(~r/\$\{MANPATH:-\}/, stripped),
%{
rule: "WF024",
type: :chapel_manpath_unset,
file: filename,
severity: :medium,
auto_fixable: true,
recipe_id: "chapel-manpath-guard",
reason:
"Workflow extends `MANPATH` without guarding for the unset case " <>
"(`$MANPATH` on a minimal runner triggers `unbound variable` under " <>
"`set -u`). Replace with `${MANPATH:-}`. See panic-attack#99.",
action: :guard_manpath_default
}
)
|> append_if(
# B. CHPL_LLVM unset (no export anywhere in workflow)
not Regex.match?(~r/\bCHPL_LLVM\s*[:=]/, stripped),
%{
rule: "WF024",
type: :chapel_chpl_llvm_unset,
file: filename,
severity: :medium,
auto_fixable: true,
recipe_id: "chapel-chpl-llvm-export",
reason:
"Workflow invokes `chpl` without exporting `CHPL_LLVM`. Chapel 2.8.0 " <>
"no longer auto-detects the backend; add `export CHPL_LLVM=bundled` " <>
"(or `system`) before any chpl invocation. See panic-attack#99.",
action: :export_chpl_llvm_bundled
}
)
|> append_if(
# C. `chpl --about` was dropped in 2.8.0
Regex.match?(~r/\bchpl\s+--about\b/, stripped),
%{
rule: "WF024",
type: :chapel_chpl_about_dropped,
file: filename,
severity: :medium,
auto_fixable: true,
recipe_id: "chapel-replace-chpl-about-with-version",
reason:
"`chpl --about` was removed in Chapel 2.8.0. Diagnostics that scrape " <>
"`--about` output now fail. Replace with `chpl --version`. " <>
"See panic-attack#99.",
action: :replace_chpl_about_with_version
}
)
|> append_if(
# D. `printchplenv --simple | grep comm-gasnet*` (brittle glob)
Regex.match?(
~r/printchplenv\s+--simple[^|\n]*\|[^|\n]*\bcomm-gasnet/,
stripped
),
%{
rule: "WF024",
type: :chapel_printchplenv_glob_brittle,
file: filename,
severity: :medium,
auto_fixable: true,
recipe_id: "chapel-find-comm-gasnet",
reason:
"`printchplenv --simple` output reformatted in Chapel 2.8.0; " <>
"globs that grep its output for `comm-gasnet*` now miss. Switch to " <>
"`find \"$CHPL_HOME\" -name comm-gasnet` (path-walk, not output-grep). " <>
"See panic-attack#99.",
action: :replace_printchplenv_grep_with_find
}
)
else
[]
end
end)
end

defp append_if(list, true, finding), do: list ++ [finding]
defp append_if(list, false, _finding), do: list
end
Loading
Loading