From 470279f4701c076d7c9bac205c1d23af34d47261 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 06:06:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(rules):=20Group-B=20#333-cohort=20detector?= =?UTF-8?q?s=20=E2=80=94=20SD021,=20WF023,=20stale-issue-refs=20(#363,=20#?= =?UTF-8?q?364,=20#366)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pure detectors, each taking its external signal as a parameter so they unit-test without live API access; the live-signal scan-flow wiring is the documented follow-up on each issue: - StructuralDrift.check_workflow_branch_refs/3 (SD021, #363): workflow trigger branches not in the repo's live branch list (inline + block form; globs and the default branch exempt). - WorkflowAudit.check_stale_continue_on_error/2 (WF023, #364): continue-on-error masks whose "until/remove when #N" comment names a closed issue. - HonestCompletion.check_stale_issue_refs/2 (#366): comments referencing a closed/merged issue via stale-marker phrasing. test/rules/group_b_detectors_test.exs covers sensitivity + specificity for each; STATE.a2ml records the session's rule work. #339 (phantom required contexts) was already covered by BP008; #361 (optionalDependencies) overlaps build_system_rules and is deferred to avoid a near-duplicate. Verified locally (Elixir 1.14): each module compiles with no new warnings; format-isolation shows pure additions; real compiled modules pass all fixtures. Refs #363 #364 #366 https://claude.ai/code/session_01J8oLNn6MjKDRRUF65e2jLf --- .machine_readable/6a2/STATE.a2ml | 9 ++++ CHANGELOG.adoc | 19 ++++++++ CHANGELOG.md | 3 ++ lib/rules/honest_completion.ex | 29 ++++++++++++ lib/rules/structural_drift.ex | 61 +++++++++++++++++++++++++ lib/rules/workflow_audit.ex | 38 +++++++++++++++ test/rules/group_b_detectors_test.exs | 66 +++++++++++++++++++++++++++ 7 files changed, 225 insertions(+) create mode 100644 test/rules/group_b_detectors_test.exs diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index b8031bc3..39f1f757 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -84,6 +84,15 @@ actions = [ ] [session-history] +# 2026-05-31: Cohort #333 + Group-B CI/CD detection rules. Merged +# duplicate_cron_schedule (#362), WF021 concurrency_missing_readonly +# (#365), WF022 unanchored_heading_regex (#360). Reverted a duplicate +# #390 scorecard rule (already shipped as WF018 in #403). Added Group-B +# detectors SD021 workflow_branch_refs (#363), WF023 +# stale_continue_on_error (#364), HonestCompletion stale_issue_refs +# (#366) — pure functions taking an injected signal (branch list / +# closed-issue set); the live-signal scan-flow wiring is the remaining +# follow-up noted on each issue. #339 was already covered by BP008. # 2026-05-25: PRs #314 + #315 + #319 — post-#313 batch + repo health tidy. # PR #314 (15 commits, merged): M17 GNN/VAE/Sequence state/restore # persistence; M18 cross-org VCL federation with policy gates; M9 diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index accb0e88..93fd5688 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -12,6 +12,25 @@ https://semver.org/[Semantic Versioning]. === Added +==== Group-B detectors — SD021 / WF023 / stale-issue-refs (2026-05-31, #363/#364/#366) + +Three #333-cohort detectors, each a pure function taking its external signal +(injected by the caller) so they unit-test without live API access: + +* **SD021** `StructuralDrift.check_workflow_branch_refs/3` (#363) — workflow + trigger branches (`on.push`/`pull_request.branches`, inline or block form) + that aren't in the repo's live branch list; globs and the default branch + are exempt. +* **WF023** `WorkflowAudit.check_stale_continue_on_error/2` (#364) — a + `continue-on-error: true` job whose "until/remove when #N" comment names an + issue in the injected closed-issue set. +* `HonestCompletion.check_stale_issue_refs/2` (#366) — source/workflow + comments referencing a closed/merged issue via stale-marker phrasing. + +Covered by `test/rules/group_b_detectors_test.exs` (sensitivity + specificity +each). The live-signal wiring (fetching the branch list / issue states in the +scan flow) is the remaining follow-up noted on each issue. + ==== `WorkflowAudit` rule `WF022` unanchored-heading-regex (2026-05-30, #360) Flags a markdown-heading-detection regex used *unanchored* inside inline diff --git a/CHANGELOG.md b/CHANGELOG.md index 085f293f..114fcc3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(rules): StructuralDrift SD021 `workflow_branch_refs` — flag workflow trigger branches that aren't real repo branches (#363) +- feat(rules): WorkflowAudit WF023 `stale_continue_on_error` — flag continue-on-error masks gated on a now-closed issue (#364) +- feat(rules): HonestCompletion `stale_issue_refs` — flag comments referencing closed/merged issues (#366) - feat(rules): WorkflowAudit WF022 `unanchored_heading_regex` — flag inline-python heading-detection regexes not anchored to `^#` (#360) - feat(rules): WorkflowAudit WF021 `concurrency_missing_readonly` — flag read-only PR/push check workflows lacking a `concurrency:` block (#365) - feat(rules): CicdRules `duplicate_cron_schedule` — flag workflows with redundant cron entries on the same day-of-week / daily-subset (#362) diff --git a/lib/rules/honest_completion.ex b/lib/rules/honest_completion.ex index c1ede149..8cb958b1 100644 --- a/lib/rules/honest_completion.ex +++ b/lib/rules/honest_completion.ex @@ -344,4 +344,33 @@ defmodule Hypatia.Rules.HonestCompletion do patterns = ["todo!", "unimplemented!", "raise \"not implemented\"", "raise \"TODO\""] Enum.sum(Enum.map(patterns, fn p -> count_pattern(repo_path, p) end)) end + + # ─── Stale issue references in comments (#366) ───────────────────────── + # Comments like "blocked by #N" / "until #N" that name a now-closed issue + # are documentation rot. `closed_issues` is injected. Cohort #333 pattern 7. + @doc """ + Flag comments in `file_contents` (path => content) that reference an issue + in `closed_issues` via stale-marker phrasing. + """ + def check_stale_issue_refs(file_contents, closed_issues) do + closed = MapSet.new(closed_issues) + + Enum.flat_map(file_contents, fn {file, content} -> + ~r/(?:until|pending|gated on|awaits|blocked by|remove when)\s+#(\d+)/i + |> Regex.scan(content) + |> Enum.map(fn [_, n] -> String.to_integer(n) end) + |> Enum.filter(&MapSet.member?(closed, &1)) + |> Enum.uniq() + |> Enum.map(fn n -> + %{ + type: :stale_issue_reference, + file: file, + detail: "comment references ##{n}, which is closed/merged — update or delete it", + severity: :low, + deduction: 5, + issue: n + } + end) + end) + end end diff --git a/lib/rules/structural_drift.ex b/lib/rules/structural_drift.ex index ea259ff3..d39cce8b 100644 --- a/lib/rules/structural_drift.ex +++ b/lib/rules/structural_drift.ex @@ -742,4 +742,65 @@ defmodule Hypatia.Rules.StructuralDrift do end) end end + + # ─── SD021: Workflow trigger references a non-existent branch (#363) ─── + # + # `on.push.branches` / `on.pull_request.branches` entries that don't + # resolve to a real branch are dead config (consolidation drift). Globs + # and the default branch are exempt. The actual-branch list is injected by + # the caller (git index / GitHub API). Cohort hypatia#333, pattern 4. + @doc """ + SD021: flag workflow trigger branch refs that aren't real branches. + + `actual_branches` is the repo's live branch list (injected). Glob patterns + and `opts[:default_branch]` (default `"main"`) are exempt. Returns one + finding per (file, dead-branch). + """ + def check_workflow_branch_refs(workflow_contents, actual_branches, opts \\ []) do + default = Keyword.get(opts, :default_branch, "main") + + Enum.flat_map(workflow_contents, fn {file, content} -> + content + |> trigger_branches() + |> Enum.filter(fn b -> + b != default and not String.contains?(b, "*") and b not in actual_branches + end) + |> Enum.uniq() + |> Enum.map(fn b -> + %{ + rule: "SD021", + file: file, + severity: :low, + reason: + "workflow trigger references branch `#{b}`, which is not a real branch in this repo (dead config / consolidation drift)", + action: :update_reference, + branch: b + } + end) + end) + end + + defp trigger_branches(content) do + inline = + ~r/branches(?:-ignore)?:\s*\[([^\]]+)\]/ + |> Regex.scan(content) + |> Enum.flat_map(fn [_, inner] -> String.split(inner, ",") end) + |> Enum.map(&(&1 |> String.trim() |> String.trim("\"") |> String.trim("'"))) + + block = content |> String.split("\n") |> branch_block_items(false, []) + Enum.reject(inline ++ block, &(&1 == "")) + end + + defp branch_block_items([], _in?, acc), do: Enum.reverse(acc) + + defp branch_block_items([line | rest], in?, acc) do + item = Regex.run(~r/^\s*-\s*['"]?([A-Za-z0-9._\/*-]+)['"]?\s*$/, line) + + cond do + Regex.match?(~r/^\s*branches(?:-ignore)?:\s*$/, line) -> branch_block_items(rest, true, acc) + in? and Regex.match?(~r/^\s*#/, line) -> branch_block_items(rest, true, acc) + in? and item != nil -> branch_block_items(rest, true, [Enum.at(item, 1) | acc]) + true -> branch_block_items(rest, false, acc) + end + end end diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index 4ba6c177..5cfd9bf0 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -1537,4 +1537,42 @@ defmodule Hypatia.Rules.WorkflowAudit do heading_like? = Regex.match?(~r/[A-Z][a-z]+\s+(?:[A-Z][a-z]+|\[[A-Za-z])/, pat) heading_like? and not String.starts_with?(pat, "^#") end + + # ─── WF023: Stale continue-on-error mask tied to a closed issue (#364) ─ + # + # A job carrying `continue-on-error: true` with a nearby comment like + # "remove when #N" / "until #N" is a deliberate temporary mask. Once #N + # closes, the mask is stale and erodes gate quality. `closed_issues` (a + # list of closed issue numbers) is injected by the caller. Standalone (not + # wired into audit/3 — it needs the issue-state signal). Cohort #333 p5. + @doc """ + WF023: flag `continue-on-error: true` workflows whose "until/remove when + #N" comment names an issue in `closed_issues`. + """ + def check_stale_continue_on_error(workflow_contents, closed_issues) do + closed = MapSet.new(closed_issues) + + Enum.flat_map(workflow_contents, fn {file, content} -> + if Regex.match?(~r/continue-on-error:\s*true/, content) do + ~r/(?:until|remove when|gated on|pending|once)\s+#(\d+)/i + |> Regex.scan(content) + |> Enum.map(fn [_, n] -> String.to_integer(n) end) + |> Enum.filter(&MapSet.member?(closed, &1)) + |> Enum.uniq() + |> Enum.map(fn n -> + %{ + rule: "WF023", + type: :stale_continue_on_error, + file: file, + severity: :medium, + reason: + "`continue-on-error: true` is gated on ##{n}, which is closed; drop the mask and let the job report accurately", + issue: n + } + end) + else + [] + end + end) + end end diff --git a/test/rules/group_b_detectors_test.exs b/test/rules/group_b_detectors_test.exs new file mode 100644 index 00000000..1a1c5977 --- /dev/null +++ b/test/rules/group_b_detectors_test.exs @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: MPL-2.0 + +defmodule Hypatia.Rules.GroupBDetectorsTest do + use ExUnit.Case, async: true + + alias Hypatia.Rules.StructuralDrift + alias Hypatia.Rules.WorkflowAudit + alias Hypatia.Rules.HonestCompletion + + describe "StructuralDrift.check_workflow_branch_refs/3 (SD021, #363)" do + test "flags inline trigger branches that aren't real branches" do + wf = %{"ci.yml" => "on:\n push:\n branches: [main, master, develop]\n"} + findings = StructuralDrift.check_workflow_branch_refs(wf, ["main"]) + assert findings |> Enum.map(& &1.branch) |> Enum.sort() == ["develop", "master"] + assert hd(findings).rule == "SD021" + assert hd(findings).severity == :low + end + + test "flags block-style dead branches, exempts globs and default" do + body = + "on:\n pull_request:\n branches:\n - main\n - feature/*\n - legacy\n" + + findings = StructuralDrift.check_workflow_branch_refs(%{"ci.yml" => body}, ["main"]) + assert Enum.map(findings, & &1.branch) == ["legacy"] + end + + test "silent when all trigger branches exist" do + wf = %{"ci.yml" => "on:\n push:\n branches: [main, master]\n"} + assert StructuralDrift.check_workflow_branch_refs(wf, ["main", "master"]) == [] + end + end + + describe "WorkflowAudit.check_stale_continue_on_error/2 (WF023, #364)" do + @coe "jobs:\n x:\n # remove when #104 lands\n continue-on-error: true\n" + + test "flags a continue-on-error mask gated on a closed issue" do + [f] = WorkflowAudit.check_stale_continue_on_error(%{"ci.yml" => @coe}, [104]) + assert f.rule == "WF023" + assert f.issue == 104 + assert f.severity == :medium + end + + test "silent when the gating issue is still open" do + assert WorkflowAudit.check_stale_continue_on_error(%{"ci.yml" => @coe}, []) == [] + end + + test "silent when there is no continue-on-error" do + body = "# remove when #104\n" + assert WorkflowAudit.check_stale_continue_on_error(%{"ci.yml" => body}, [104]) == [] + end + end + + describe "HonestCompletion.check_stale_issue_refs/2 (#366)" do + test "flags a comment referencing a closed issue" do + [f] = + HonestCompletion.check_stale_issue_refs(%{"x.ex" => "# blocked by #50 upstream\n"}, [50]) + + assert f.type == :stale_issue_reference + assert f.issue == 50 + end + + test "silent when the referenced issue is still open" do + assert HonestCompletion.check_stale_issue_refs(%{"x.ex" => "# blocked by #50\n"}, []) == [] + end + end +end