@@ -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/ \b chpl\b / , stripped )
1810+
1811+ if chapel? do
1812+ [ ]
1813+ |> append_if (
1814+ # A. MANPATH unset under set -u
1815+ Regex . match? ( ~r/ \b MANPATH=[^\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/ \b CHPL_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/ \b chpl\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 ]*\b comm-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
16601894end
0 commit comments