Skip to content

Commit 35f33db

Browse files
feat(rules): extend WF024 chapel + add cron-drift + inherited-debt detectors (auto-fix wiring) (#422)
Continues today's hardening from hypatia#414. Three patterns we hit manually that should be auto-handled next time: Chapel 5 sharp edges, CodeQL cron drift, inherited main-branch debt. License category remains auto_fixable:false per #415. ## Commits - **99b7970** WF024 chapel auto-fix wiring + 4 sub-pattern recipes - Primary WF024 finding now sets `auto_fixable: true` + `recipe_id: chapel-runs-on-pin-22-04` - New `check_chapel_sharp_edges/1` emits 4 recipes: `chapel-manpath-guard`, `chapel-chpl-llvm-export`, `chapel-replace-chpl-about-with-version`, `chapel-find-comm-gasnet` (panic-attack#99) - **2285685** WF025 CodeQL cron-drift detector - Canonical `0 6 1 * *` per standards#286 - Sub-categories route to standards#288 (weekly→monthly) vs standards#324 (non-canonical→canonical) - Shared recipe `codeql-cron-monthly` - **df4f545** BH008 inherited-main-branch-debt detector - Threshold ≥3 open PRs sharing same failing check → flag (`:warn`, `auto_fixable: false`) - Recommends `file_root_fix_pr_on_main` instead of N retries ## Test plan - [x] Each rule has ExUnit coverage under `test/rules/` - [x] Chapel detector test covers all 5 recipe_ids + maximal-bad-workflow case - [x] Cron-drift classifier covers canonical / weekly / non-canonical-monthly / malformed - [x] Inherited-debt fires at 3 PRs, NOT at 2; honors custom threshold; per-PR-unique failures suppressed - [ ] CI to verify `mix test` (local mix env was Elixir 1.14 — couldn't fetch deps); individual files compile clean via `elixirc` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5d52d91 commit 35f33db

5 files changed

Lines changed: 703 additions & 0 deletions

File tree

lib/rules/baseline_health.ex

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,79 @@ defmodule Hypatia.Rules.BaselineHealth do
544544
end
545545
end
546546

547+
# ─── BH008: Inherited main-branch debt ────────────────────────────────
548+
#
549+
# Pattern hit repeatedly during the 2026-06-02 sweeps: a single broken
550+
# check on `main` cascades to EVERY open PR in the same repo. Each PR
551+
# shows the same failing check, none of the PR diffs are causal, and
552+
# the right answer is one root-fix PR on `main` rather than N retries.
553+
#
554+
# Detection: across the open PRs in `{owner, repo}`, find any check
555+
# name that fails on `:threshold` (default 3) or more PRs. Emit one
556+
# `:warn` finding per inherited check name with the affected PR list
557+
# and a recommended action to file a root-fix PR on main.
558+
#
559+
# Crucially NOT auto-fixable: the root cause may be a workflow change,
560+
# a dep bump, a baseline regression — each needs owner direction.
561+
# See feedback in MEMORY.md: "C. NEW rule: inherited-main-branch-debt".
562+
563+
@default_inherited_debt_threshold 3
564+
565+
@doc """
566+
BH008: Find check names that fail across ≥ `threshold` open PRs in
567+
`{owner, repo}`. The shared failure is almost certainly inherited from
568+
a degraded `main` baseline rather than caused by any single PR's diff.
569+
570+
Inputs (caller supplies; this rule is data-driven so the gh API
571+
fan-out lives in the caller):
572+
573+
pr_check_data :: [
574+
%{number: integer,
575+
failed_checks: [String.t()]}
576+
]
577+
578+
Returns a list of findings, one per check name meeting the threshold.
579+
580+
Opts:
581+
* `:threshold` — minimum PR count to flag (default 3)
582+
"""
583+
def bh008_inherited_main_debt(owner, repo, pr_check_data, opts \\ [])
584+
when is_list(pr_check_data) do
585+
threshold = Keyword.get(opts, :threshold, @default_inherited_debt_threshold)
586+
587+
pr_check_data
588+
|> Enum.flat_map(fn pr ->
589+
Enum.map(pr.failed_checks, fn check -> {check, pr.number} end)
590+
end)
591+
|> Enum.group_by(fn {check, _} -> check end, fn {_, num} -> num end)
592+
|> Enum.filter(fn {_check, prs} -> length(Enum.uniq(prs)) >= threshold end)
593+
|> Enum.map(fn {check, prs} ->
594+
pr_numbers = prs |> Enum.uniq() |> Enum.sort()
595+
596+
%{
597+
rule: "BH008",
598+
file: "#{owner}/#{repo}",
599+
severity: :warn,
600+
auto_fixable: false,
601+
type: :inherited_main_debt,
602+
check_name: check,
603+
pr_count: length(pr_numbers),
604+
affected_prs: pr_numbers,
605+
reason:
606+
"Check `#{check}` is failing on #{length(pr_numbers)} open PRs " <>
607+
"(##{Enum.join(pr_numbers, ", #")}). When ≥#{threshold} PRs share " <>
608+
"the same failing check the cause is almost always inherited from " <>
609+
"a degraded main baseline rather than any single PR diff. " <>
610+
"Recommended action: file a single root-fix PR targeting main.",
611+
action: :file_root_fix_pr_on_main,
612+
detail: %{
613+
recommendation: "file_root_fix_pr_on_main",
614+
threshold: threshold
615+
}
616+
}
617+
end)
618+
end
619+
547620
# ─── scan/2 facade ────────────────────────────────────────────────────
548621

549622
@doc """

lib/rules/workflow_audit.ex

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,113 @@ defmodule Hypatia.Rules.WorkflowAudit do
15761576
end)
15771577
end
15781578

1579+
# ─── WF025: CodeQL cron-canonical conformance ─────────────────────────
1580+
#
1581+
# standards#286 fixed the canonical CodeQL schedule at `0 6 1 * *` (monthly,
1582+
# 06:00 UTC on the 1st of each month) — the estate-wide cron drift sweep
1583+
# that triggered this rule is tracked at standards#288 / standards#323 / #324.
1584+
#
1585+
# Two failure shapes (a sub-category drives gitbot-fleet routing):
1586+
#
1587+
# :weekly_to_monthly — `0 6 * * 1` style (any dow != *). Maps to the
1588+
# #288-class follow-up (weekly → monthly).
1589+
#
1590+
# :non_canonical_monthly — monthly but at a different (HH:MM) / day-of-
1591+
# month. Maps to the #324-class follow-up
1592+
# (non-canonical monthly → canonical monthly).
1593+
#
1594+
# Both are auto-fixable via the same recipe `codeql-cron-monthly` —
1595+
# rewrite to `0 6 1 * *`. Severity `:warn` (drift, not breakage).
1596+
1597+
@codeql_canonical_cron "0 6 1 * *"
1598+
1599+
@doc """
1600+
WF025: Detect `.github/workflows/codeql.yml` cron entries that are NOT the
1601+
canonical `0 6 1 * *` monthly schedule.
1602+
1603+
Sub-category drives downstream routing:
1604+
`:weekly_to_monthly` — #288-class follow-up
1605+
`:non_canonical_monthly` — #324-class follow-up
1606+
1607+
See standards#286 (canonical fix), standards#288 (estate cron drift sweep),
1608+
standards#323 (recurring drift-detection), standards#324 (non-canonical
1609+
one-off fan-out).
1610+
"""
1611+
def check_codeql_cron_drift(workflow_contents) do
1612+
Enum.flat_map(workflow_contents, fn {filename, content} ->
1613+
if codeql_workflow_file?(filename) do
1614+
stripped = strip_comments(content)
1615+
crons = extract_cron_expressions(stripped)
1616+
1617+
crons
1618+
|> Enum.reject(&(&1 == @codeql_canonical_cron))
1619+
|> Enum.map(fn raw ->
1620+
subcat = cron_drift_subcategory(raw)
1621+
1622+
%{
1623+
rule: "WF025",
1624+
rule_id: "WF-025",
1625+
type: :codeql_cron_drift,
1626+
sub_category: subcat,
1627+
file: filename,
1628+
severity: :warn,
1629+
auto_fixable: true,
1630+
recipe_id: "codeql-cron-monthly",
1631+
canonical: @codeql_canonical_cron,
1632+
observed: raw,
1633+
reason:
1634+
"CodeQL workflow uses non-canonical cron `#{raw}`. The estate-wide " <>
1635+
"canonical (per standards#286) is `#{@codeql_canonical_cron}` " <>
1636+
"(monthly, 06:00 UTC on the 1st). Sub-category " <>
1637+
"`#{subcat}` — routes through #{routing_label(subcat)}.",
1638+
action: :rewrite_cron_to_canonical
1639+
}
1640+
end)
1641+
else
1642+
[]
1643+
end
1644+
end)
1645+
end
1646+
1647+
@doc """
1648+
Public for testability: classify a cron expression against the canonical
1649+
`0 6 1 * *` shape.
1650+
1651+
`:canonical` — exact match
1652+
`:weekly_to_monthly` — DOW field is not `*` (fires N times per week)
1653+
`:non_canonical_monthly` — DOW is `*` but HH/MM/DOM differ
1654+
`:malformed` — not a valid 5-field cron expression
1655+
"""
1656+
def cron_drift_subcategory(@codeql_canonical_cron), do: :canonical
1657+
1658+
def cron_drift_subcategory(raw) when is_binary(raw) do
1659+
case String.split(String.trim(raw), ~r/\s+/, trim: true) do
1660+
[_min, _hour, _dom, _mon, dow] when dow != "*" -> :weekly_to_monthly
1661+
[_min, _hour, _dom, _mon, _dow] -> :non_canonical_monthly
1662+
_ -> :malformed
1663+
end
1664+
end
1665+
1666+
defp routing_label(:weekly_to_monthly), do: "standards#288 (weekly→monthly fan-out)"
1667+
1668+
defp routing_label(:non_canonical_monthly),
1669+
do: "standards#324 (non-canonical→canonical fan-out)"
1670+
1671+
defp routing_label(:malformed), do: "manual triage (malformed cron)"
1672+
defp routing_label(_), do: "manual triage"
1673+
1674+
defp codeql_workflow_file?(filename) do
1675+
base = Path.basename(filename)
1676+
base == "codeql.yml" or base == "codeql.yaml"
1677+
end
1678+
1679+
defp extract_cron_expressions(stripped) do
1680+
~r/^\s*-\s*cron:\s*["']?([^"'\n#]+?)["']?\s*$/m
1681+
|> Regex.scan(stripped, capture: :all_but_first)
1682+
|> List.flatten()
1683+
|> Enum.map(&String.trim/1)
1684+
end
1685+
15791686
# ─── WF024: Chapel ≥2.8.0 ABI / runs-on mismatch ──────────────────────
15801687

15811688
# Chapel debian packages (`chapel-*.deb` from chapel-lang/chapel releases,
@@ -1640,6 +1747,8 @@ defmodule Hypatia.Rules.WorkflowAudit do
16401747
file: filename,
16411748
severity: :high,
16421749
runs_on: bad,
1750+
auto_fixable: true,
1751+
recipe_id: "chapel-runs-on-pin-22-04",
16431752
reason:
16441753
"Workflow installs a Chapel ≥2.8.0 .deb (CHAPEL_DEB_URL or " <>
16451754
"chapel-*.deb), but `runs-on:` is `#{Enum.join(bad, ", ")}`. " <>
@@ -1657,4 +1766,129 @@ defmodule Hypatia.Rules.WorkflowAudit do
16571766
end
16581767
end)
16591768
end
1769+
1770+
# ─── WF024 sub-patterns: 4 prior Chapel-2.8.0 sharp edges ─────────────
1771+
#
1772+
# Surfaced by panic-attack#99 (Chapel Wave 2 — 6 cold-cache CI iterations).
1773+
# Each sub-pattern has its own fix recipe so future sessions can route
1774+
# them through the gitbot-fleet auto-remediation pipeline without rebuilding
1775+
# the diagnostic from scratch.
1776+
#
1777+
# A. `MANPATH` is unset on minimal runners — Chapel install scripts
1778+
# that do `MANPATH=...:$MANPATH` produce `unbound variable` under
1779+
# `set -u`. Recipe: guard with `${MANPATH:-}`.
1780+
#
1781+
# B. `CHPL_LLVM` is unset — chpl needs `CHPL_LLVM=bundled` (or `system`)
1782+
# to find its backend. Pre-2.8.0 builds defaulted; 2.8.0 errors.
1783+
# Recipe: export `CHPL_LLVM=bundled` (or `system`) before running.
1784+
#
1785+
# C. `chpl --about` dropped in 2.8.0 — diagnostics that scrape `--about`
1786+
# output now fail. Recipe: switch to `chpl --version`.
1787+
#
1788+
# D. `printchplenv --simple` output reformatted — old globs that match
1789+
# `comm-gasnet*` against the simple output now miss. Recipe: switch
1790+
# to `find <CHPL_HOME> -name comm-gasnet` (path-walk, not output-grep).
1791+
1792+
@doc """
1793+
WF024 sub-patterns: detect the 4 additional Chapel-2.8.0 sharp edges
1794+
alongside the runs-on ABI mismatch. Each emits its own finding with a
1795+
distinct `recipe_id` so the gitbot-fleet auto-remediation pipeline can
1796+
apply the right targeted fix without re-deriving the diagnostic.
1797+
1798+
Returned shapes share `rule: "WF024"`, `auto_fixable: true`, and
1799+
`severity: :medium` (the sharp edges are CI-fatal but each has a
1800+
one-line workaround once recognised — promoted from `:high` reserved
1801+
for the ABI mismatch itself).
1802+
"""
1803+
def check_chapel_sharp_edges(workflow_contents) do
1804+
Enum.flat_map(workflow_contents, fn {filename, content} ->
1805+
stripped = strip_comments(content)
1806+
1807+
chapel? =
1808+
Enum.any?(@chapel_marker_patterns, &Regex.match?(&1, stripped)) or
1809+
Regex.match?(~r/\bchpl\b/, stripped)
1810+
1811+
if chapel? do
1812+
[]
1813+
|> append_if(
1814+
# A. MANPATH unset under set -u
1815+
Regex.match?(~r/\bMANPATH=[^\n]*\$MANPATH\b/, stripped) and
1816+
not Regex.match?(~r/\$\{MANPATH:-\}/, stripped),
1817+
%{
1818+
rule: "WF024",
1819+
type: :chapel_manpath_unset,
1820+
file: filename,
1821+
severity: :medium,
1822+
auto_fixable: true,
1823+
recipe_id: "chapel-manpath-guard",
1824+
reason:
1825+
"Workflow extends `MANPATH` without guarding for the unset case " <>
1826+
"(`$MANPATH` on a minimal runner triggers `unbound variable` under " <>
1827+
"`set -u`). Replace with `${MANPATH:-}`. See panic-attack#99.",
1828+
action: :guard_manpath_default
1829+
}
1830+
)
1831+
|> append_if(
1832+
# B. CHPL_LLVM unset (no export anywhere in workflow)
1833+
not Regex.match?(~r/\bCHPL_LLVM\s*[:=]/, stripped),
1834+
%{
1835+
rule: "WF024",
1836+
type: :chapel_chpl_llvm_unset,
1837+
file: filename,
1838+
severity: :medium,
1839+
auto_fixable: true,
1840+
recipe_id: "chapel-chpl-llvm-export",
1841+
reason:
1842+
"Workflow invokes `chpl` without exporting `CHPL_LLVM`. Chapel 2.8.0 " <>
1843+
"no longer auto-detects the backend; add `export CHPL_LLVM=bundled` " <>
1844+
"(or `system`) before any chpl invocation. See panic-attack#99.",
1845+
action: :export_chpl_llvm_bundled
1846+
}
1847+
)
1848+
|> append_if(
1849+
# C. `chpl --about` was dropped in 2.8.0
1850+
Regex.match?(~r/\bchpl\s+--about\b/, stripped),
1851+
%{
1852+
rule: "WF024",
1853+
type: :chapel_chpl_about_dropped,
1854+
file: filename,
1855+
severity: :medium,
1856+
auto_fixable: true,
1857+
recipe_id: "chapel-replace-chpl-about-with-version",
1858+
reason:
1859+
"`chpl --about` was removed in Chapel 2.8.0. Diagnostics that scrape " <>
1860+
"`--about` output now fail. Replace with `chpl --version`. " <>
1861+
"See panic-attack#99.",
1862+
action: :replace_chpl_about_with_version
1863+
}
1864+
)
1865+
|> append_if(
1866+
# D. `printchplenv --simple | grep comm-gasnet*` (brittle glob)
1867+
Regex.match?(
1868+
~r/printchplenv\s+--simple[^|\n]*\|[^|\n]*\bcomm-gasnet/,
1869+
stripped
1870+
),
1871+
%{
1872+
rule: "WF024",
1873+
type: :chapel_printchplenv_glob_brittle,
1874+
file: filename,
1875+
severity: :medium,
1876+
auto_fixable: true,
1877+
recipe_id: "chapel-find-comm-gasnet",
1878+
reason:
1879+
"`printchplenv --simple` output reformatted in Chapel 2.8.0; " <>
1880+
"globs that grep its output for `comm-gasnet*` now miss. Switch to " <>
1881+
"`find \"$CHPL_HOME\" -name comm-gasnet` (path-walk, not output-grep). " <>
1882+
"See panic-attack#99.",
1883+
action: :replace_printchplenv_grep_with_find
1884+
}
1885+
)
1886+
else
1887+
[]
1888+
end
1889+
end)
1890+
end
1891+
1892+
defp append_if(list, true, finding), do: list ++ [finding]
1893+
defp append_if(list, false, _finding), do: list
16601894
end

0 commit comments

Comments
 (0)