diff --git a/lib/rules/baseline_health.ex b/lib/rules/baseline_health.ex index 0a47f8fa..306af927 100644 --- a/lib/rules/baseline_health.ex +++ b/lib/rules/baseline_health.ex @@ -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 """ diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index a7bc7fa1..525d3aaa 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -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, @@ -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, ", ")}`. " <> @@ -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 -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 diff --git a/test/rules/baseline_health_inherited_debt_test.exs b/test/rules/baseline_health_inherited_debt_test.exs new file mode 100644 index 00000000..935c2bb0 --- /dev/null +++ b/test/rules/baseline_health_inherited_debt_test.exs @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: MPL-2.0 + +defmodule Hypatia.Rules.BaselineHealth.InheritedDebtTest do + use ExUnit.Case, async: true + + alias Hypatia.Rules.BaselineHealth + + # BH008 — when ≥ threshold (default 3) open PRs share the same failing + # check name the cause is almost always inherited from a degraded main + # baseline. Flag (warn) — never auto-fix; owner must direct the root fix. + + describe "bh008_inherited_main_debt/4 — threshold semantics" do + test "fires at exactly 3 PRs sharing a failing check (default threshold)" do + data = [ + %{number: 101, failed_checks: ["governance/security-policy"]}, + %{number: 102, failed_checks: ["governance/security-policy"]}, + %{number: 103, failed_checks: ["governance/security-policy"]} + ] + + assert [finding] = BaselineHealth.bh008_inherited_main_debt("foo", "bar", data) + assert finding.rule == "BH008" + assert finding.severity == :warn + assert finding.auto_fixable == false + assert finding.check_name == "governance/security-policy" + assert finding.pr_count == 3 + assert finding.affected_prs == [101, 102, 103] + assert finding.action == :file_root_fix_pr_on_main + end + + test "does NOT fire at 2 PRs (below threshold)" do + data = [ + %{number: 101, failed_checks: ["governance/security-policy"]}, + %{number: 102, failed_checks: ["governance/security-policy"]} + ] + + assert [] = BaselineHealth.bh008_inherited_main_debt("foo", "bar", data) + end + + test "honors custom :threshold opt" do + data = [ + %{number: 1, failed_checks: ["ci/main"]}, + %{number: 2, failed_checks: ["ci/main"]} + ] + + # threshold: 2 → should fire on 2 PRs + assert [finding] = + BaselineHealth.bh008_inherited_main_debt("foo", "bar", data, threshold: 2) + + assert finding.pr_count == 2 + assert finding.detail.threshold == 2 + end + + test "ignores per-PR-unique failures (not inherited)" do + data = [ + %{number: 1, failed_checks: ["tests/pr-1-only"]}, + %{number: 2, failed_checks: ["tests/pr-2-only"]}, + %{number: 3, failed_checks: ["tests/pr-3-only"]} + ] + + assert [] = BaselineHealth.bh008_inherited_main_debt("foo", "bar", data) + end + + test "emits one finding per distinct shared check name" do + data = [ + %{number: 1, failed_checks: ["a", "b"]}, + %{number: 2, failed_checks: ["a", "b"]}, + %{number: 3, failed_checks: ["a", "b"]} + ] + + findings = BaselineHealth.bh008_inherited_main_debt("foo", "bar", data) + assert length(findings) == 2 + checks = findings |> Enum.map(& &1.check_name) |> Enum.sort() + assert checks == ["a", "b"] + end + + test "deduplicates same PR + same check appearing twice in input" do + data = [ + %{number: 1, failed_checks: ["x"]}, + %{number: 1, failed_checks: ["x"]}, + %{number: 2, failed_checks: ["x"]} + ] + + # Only 2 distinct PRs → below default threshold of 3 → no finding + assert [] = BaselineHealth.bh008_inherited_main_debt("foo", "bar", data) + end + + test "finding never sets auto_fixable: true" do + data = + for i <- 1..5, + do: %{number: i, failed_checks: ["x"]} + + assert [finding] = BaselineHealth.bh008_inherited_main_debt("foo", "bar", data) + refute finding.auto_fixable + end + end +end diff --git a/test/rules/workflow_audit_chapel_test.exs b/test/rules/workflow_audit_chapel_test.exs new file mode 100644 index 00000000..0dca70f4 --- /dev/null +++ b/test/rules/workflow_audit_chapel_test.exs @@ -0,0 +1,182 @@ +# SPDX-License-Identifier: MPL-2.0 + +defmodule Hypatia.Rules.WorkflowAudit.ChapelTest do + use ExUnit.Case, async: true + + alias Hypatia.Rules.WorkflowAudit + + # Extends hypatia#414 (WF024) — auto-fix wiring plus 4 prior Chapel-2.8.0 + # sharp edges from panic-attack#99. Each sub-pattern must emit a finding + # with the matching `recipe_id` so the gitbot-fleet pipeline can route it. + + describe "check_chapel_abi_runs_on_mismatch/1 — auto_fixable + recipe_id" do + test "fires WF024 with recipe_id chapel-runs-on-pin-22-04 on ubuntu-latest + CHAPEL_DEB_URL" do + content = """ + jobs: + ci: + runs-on: ubuntu-latest + steps: + - run: curl -L $CHAPEL_DEB_URL -o /tmp/chapel.deb + """ + + assert [finding] = + WorkflowAudit.check_chapel_abi_runs_on_mismatch([{"chapel.yml", content}]) + + assert finding.rule == "WF024" + assert finding.auto_fixable == true + assert finding.recipe_id == "chapel-runs-on-pin-22-04" + assert finding.action == :pin_runs_on_ubuntu_2204 + end + + test "no finding when runs-on is ubuntu-22.04" do + content = """ + jobs: + ci: + runs-on: ubuntu-22.04 + steps: + - run: dpkg -i chapel-2.8.0.deb + """ + + assert [] = + WorkflowAudit.check_chapel_abi_runs_on_mismatch([{"chapel.yml", content}]) + end + end + + describe "check_chapel_sharp_edges/1 — four sub-recipe ids" do + test "fires chapel-manpath-guard on unguarded $MANPATH" do + content = """ + jobs: + ci: + steps: + - run: | + export MANPATH=/opt/chapel/man:$MANPATH + chpl --version + """ + + findings = WorkflowAudit.check_chapel_sharp_edges([{"chapel.yml", content}]) + recipes = Enum.map(findings, & &1.recipe_id) + assert "chapel-manpath-guard" in recipes + end + + test "does NOT fire chapel-manpath-guard when guarded with ${MANPATH:-}" do + content = """ + jobs: + ci: + steps: + - run: | + export MANPATH=/opt/chapel/man:${MANPATH:-} + export CHPL_LLVM=bundled + chpl --version + """ + + findings = WorkflowAudit.check_chapel_sharp_edges([{"chapel.yml", content}]) + recipes = Enum.map(findings, & &1.recipe_id) + refute "chapel-manpath-guard" in recipes + end + + test "fires chapel-chpl-llvm-export when CHPL_LLVM is not exported" do + content = """ + jobs: + ci: + steps: + - run: | + wget $CHAPEL_DEB_URL + chpl --version + """ + + findings = WorkflowAudit.check_chapel_sharp_edges([{"chapel.yml", content}]) + recipes = Enum.map(findings, & &1.recipe_id) + assert "chapel-chpl-llvm-export" in recipes + end + + test "does NOT fire chapel-chpl-llvm-export when CHPL_LLVM= is present" do + content = """ + jobs: + ci: + env: + CHPL_LLVM: bundled + steps: + - run: | + export MANPATH=/opt/chapel/man:${MANPATH:-} + chpl --version + """ + + findings = WorkflowAudit.check_chapel_sharp_edges([{"chapel.yml", content}]) + recipes = Enum.map(findings, & &1.recipe_id) + refute "chapel-chpl-llvm-export" in recipes + end + + test "fires chapel-replace-chpl-about-with-version on `chpl --about`" do + content = """ + jobs: + ci: + env: + CHPL_LLVM: bundled + steps: + - run: | + export MANPATH=/opt/chapel/man:${MANPATH:-} + chpl --about > /tmp/chapel-info.txt + """ + + findings = WorkflowAudit.check_chapel_sharp_edges([{"chapel.yml", content}]) + recipes = Enum.map(findings, & &1.recipe_id) + assert "chapel-replace-chpl-about-with-version" in recipes + end + + test "fires chapel-find-comm-gasnet on brittle printchplenv glob" do + content = """ + jobs: + ci: + env: + CHPL_LLVM: bundled + steps: + - run: | + export MANPATH=/opt/chapel/man:${MANPATH:-} + printchplenv --simple | grep comm-gasnet* + """ + + findings = WorkflowAudit.check_chapel_sharp_edges([{"chapel.yml", content}]) + recipes = Enum.map(findings, & &1.recipe_id) + assert "chapel-find-comm-gasnet" in recipes + end + + test "no Chapel markers => no findings (negative control)" do + content = """ + jobs: + ci: + runs-on: ubuntu-latest + steps: + - run: cargo test + """ + + assert [] = WorkflowAudit.check_chapel_sharp_edges([{"rust.yml", content}]) + end + + test "all 5 recipe_ids fire together on a maximally bad workflow" do + content = """ + jobs: + ci: + runs-on: ubuntu-latest + steps: + - run: | + wget $CHAPEL_DEB_URL -O chapel-2.8.0.deb + dpkg -i chapel-2.8.0.deb + export MANPATH=/opt/chapel/man:$MANPATH + chpl --about + printchplenv --simple | grep comm-gasnet* + """ + + abi = WorkflowAudit.check_chapel_abi_runs_on_mismatch([{"chapel.yml", content}]) + edges = WorkflowAudit.check_chapel_sharp_edges([{"chapel.yml", content}]) + recipes = Enum.map(abi ++ edges, & &1.recipe_id) + + assert "chapel-runs-on-pin-22-04" in recipes + assert "chapel-manpath-guard" in recipes + assert "chapel-chpl-llvm-export" in recipes + assert "chapel-replace-chpl-about-with-version" in recipes + assert "chapel-find-comm-gasnet" in recipes + # Ensures the WF024 rule label is shared across the family + assert Enum.all?(abi ++ edges, &(&1.rule == "WF024")) + end + end +end diff --git a/test/rules/workflow_audit_cron_drift_test.exs b/test/rules/workflow_audit_cron_drift_test.exs new file mode 100644 index 00000000..d38fe5d7 --- /dev/null +++ b/test/rules/workflow_audit_cron_drift_test.exs @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: MPL-2.0 + +defmodule Hypatia.Rules.WorkflowAudit.CronDriftTest do + use ExUnit.Case, async: true + + alias Hypatia.Rules.WorkflowAudit + + # WF025 — codeql.yml cron drift against canonical `0 6 1 * *` + # (standards#286 / standards#288 / standards#323 / standards#324). + + describe "cron_drift_subcategory/1 — classifier" do + test "canonical `0 6 1 * *` is :canonical (no drift)" do + assert WorkflowAudit.cron_drift_subcategory("0 6 1 * *") == :canonical + end + + test "weekly-style (DOW != *) → :weekly_to_monthly" do + assert WorkflowAudit.cron_drift_subcategory("0 6 * * 1") == :weekly_to_monthly + end + + test "monthly but non-canonical HH/MM → :non_canonical_monthly" do + assert WorkflowAudit.cron_drift_subcategory("30 4 15 * *") == :non_canonical_monthly + end + + test "malformed (3 fields) → :malformed" do + assert WorkflowAudit.cron_drift_subcategory("0 6 *") == :malformed + end + + test "monthly but different day-of-month → :non_canonical_monthly" do + assert WorkflowAudit.cron_drift_subcategory("0 6 15 * *") == :non_canonical_monthly + end + end + + describe "check_codeql_cron_drift/1 — file-level routing" do + test "fires :weekly_to_monthly with recipe_id codeql-cron-monthly" do + content = """ + name: CodeQL + on: + schedule: + - cron: '0 6 * * 1' + """ + + assert [finding] = + WorkflowAudit.check_codeql_cron_drift([ + {".github/workflows/codeql.yml", content} + ]) + + assert finding.rule == "WF025" + assert finding.severity == :warn + assert finding.auto_fixable == true + assert finding.recipe_id == "codeql-cron-monthly" + assert finding.sub_category == :weekly_to_monthly + assert finding.canonical == "0 6 1 * *" + end + + test "fires :non_canonical_monthly for differing HH/DOM" do + content = """ + on: + schedule: + - cron: '0 12 15 * *' + """ + + assert [finding] = + WorkflowAudit.check_codeql_cron_drift([ + {".github/workflows/codeql.yml", content} + ]) + + assert finding.sub_category == :non_canonical_monthly + assert finding.recipe_id == "codeql-cron-monthly" + end + + test "ignores non-codeql workflows" do + content = """ + on: + schedule: + - cron: '0 6 * * 1' + """ + + assert [] = + WorkflowAudit.check_codeql_cron_drift([ + {".github/workflows/scorecard.yml", content} + ]) + end + + test "canonical cron emits NO finding" do + content = """ + name: CodeQL + on: + schedule: + - cron: '0 6 1 * *' + """ + + assert [] = + WorkflowAudit.check_codeql_cron_drift([ + {".github/workflows/codeql.yml", content} + ]) + end + + test "multiple cron entries → one finding per non-canonical entry" do + content = """ + on: + schedule: + - cron: '0 6 1 * *' + - cron: '0 6 * * 1' + - cron: '30 4 15 * *' + """ + + findings = + WorkflowAudit.check_codeql_cron_drift([ + {".github/workflows/codeql.yml", content} + ]) + + assert length(findings) == 2 + + subcats = findings |> Enum.map(& &1.sub_category) |> Enum.sort() + assert subcats == [:non_canonical_monthly, :weekly_to_monthly] + end + end +end