Skip to content

Commit d460d6c

Browse files
feat(rules): Group-B #333-cohort detectors — SD021/WF023/stale-issue-refs (#363/#364/#366) (#411)
Three #333-cohort detectors, each a pure function taking its external signal as a parameter (so they unit-test without live API): SD021 workflow_branch_refs (#363), WF023 stale_continue_on_error (#364), HonestCompletion stale_issue_refs (#366). Tests in test/rules/group_b_detectors_test.exs; CHANGELOG + STATE.a2ml updated. #339 was already covered by BP008; #361 overlaps build_system_rules and is deferred. Live-signal scan-flow wiring is the documented follow-up on each issue. Verified locally (Elixir 1.14): no new warnings, pure-addition format, real modules pass all fixtures. Refs #363 #364 #366 https://claude.ai/code/session_01J8oLNn6MjKDRRUF65e2jLf
1 parent b1782ca commit d460d6c

7 files changed

Lines changed: 225 additions & 0 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ actions = [
8484
]
8585

8686
[session-history]
87+
# 2026-05-31: Cohort #333 + Group-B CI/CD detection rules. Merged
88+
# duplicate_cron_schedule (#362), WF021 concurrency_missing_readonly
89+
# (#365), WF022 unanchored_heading_regex (#360). Reverted a duplicate
90+
# #390 scorecard rule (already shipped as WF018 in #403). Added Group-B
91+
# detectors SD021 workflow_branch_refs (#363), WF023
92+
# stale_continue_on_error (#364), HonestCompletion stale_issue_refs
93+
# (#366) — pure functions taking an injected signal (branch list /
94+
# closed-issue set); the live-signal scan-flow wiring is the remaining
95+
# follow-up noted on each issue. #339 was already covered by BP008.
8796
# 2026-05-25: PRs #314 + #315 + #319 — post-#313 batch + repo health tidy.
8897
# PR #314 (15 commits, merged): M17 GNN/VAE/Sequence state/restore
8998
# persistence; M18 cross-org VCL federation with policy gates; M9

CHANGELOG.adoc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ https://semver.org/[Semantic Versioning].
1212

1313
=== Added
1414

15+
==== Group-B detectors — SD021 / WF023 / stale-issue-refs (2026-05-31, #363/#364/#366)
16+
17+
Three #333-cohort detectors, each a pure function taking its external signal
18+
(injected by the caller) so they unit-test without live API access:
19+
20+
* **SD021** `StructuralDrift.check_workflow_branch_refs/3` (#363) — workflow
21+
trigger branches (`on.push`/`pull_request.branches`, inline or block form)
22+
that aren't in the repo's live branch list; globs and the default branch
23+
are exempt.
24+
* **WF023** `WorkflowAudit.check_stale_continue_on_error/2` (#364) — a
25+
`continue-on-error: true` job whose "until/remove when #N" comment names an
26+
issue in the injected closed-issue set.
27+
* `HonestCompletion.check_stale_issue_refs/2` (#366) — source/workflow
28+
comments referencing a closed/merged issue via stale-marker phrasing.
29+
30+
Covered by `test/rules/group_b_detectors_test.exs` (sensitivity + specificity
31+
each). The live-signal wiring (fetching the branch list / issue states in the
32+
scan flow) is the remaining follow-up noted on each issue.
33+
1534
==== `WorkflowAudit` rule `WF022` unanchored-heading-regex (2026-05-30, #360)
1635

1736
Flags a markdown-heading-detection regex used *unanchored* inside inline

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Added
2222

23+
- feat(rules): StructuralDrift SD021 `workflow_branch_refs` — flag workflow trigger branches that aren't real repo branches (#363)
24+
- feat(rules): WorkflowAudit WF023 `stale_continue_on_error` — flag continue-on-error masks gated on a now-closed issue (#364)
25+
- feat(rules): HonestCompletion `stale_issue_refs` — flag comments referencing closed/merged issues (#366)
2326
- feat(rules): WorkflowAudit WF022 `unanchored_heading_regex` — flag inline-python heading-detection regexes not anchored to `^#` (#360)
2427
- feat(rules): WorkflowAudit WF021 `concurrency_missing_readonly` — flag read-only PR/push check workflows lacking a `concurrency:` block (#365)
2528
- feat(rules): CicdRules `duplicate_cron_schedule` — flag workflows with redundant cron entries on the same day-of-week / daily-subset (#362)

lib/rules/honest_completion.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,4 +344,33 @@ defmodule Hypatia.Rules.HonestCompletion do
344344
patterns = ["todo!", "unimplemented!", "raise \"not implemented\"", "raise \"TODO\""]
345345
Enum.sum(Enum.map(patterns, fn p -> count_pattern(repo_path, p) end))
346346
end
347+
348+
# ─── Stale issue references in comments (#366) ─────────────────────────
349+
# Comments like "blocked by #N" / "until #N" that name a now-closed issue
350+
# are documentation rot. `closed_issues` is injected. Cohort #333 pattern 7.
351+
@doc """
352+
Flag comments in `file_contents` (path => content) that reference an issue
353+
in `closed_issues` via stale-marker phrasing.
354+
"""
355+
def check_stale_issue_refs(file_contents, closed_issues) do
356+
closed = MapSet.new(closed_issues)
357+
358+
Enum.flat_map(file_contents, fn {file, content} ->
359+
~r/(?:until|pending|gated on|awaits|blocked by|remove when)\s+#(\d+)/i
360+
|> Regex.scan(content)
361+
|> Enum.map(fn [_, n] -> String.to_integer(n) end)
362+
|> Enum.filter(&MapSet.member?(closed, &1))
363+
|> Enum.uniq()
364+
|> Enum.map(fn n ->
365+
%{
366+
type: :stale_issue_reference,
367+
file: file,
368+
detail: "comment references ##{n}, which is closed/merged — update or delete it",
369+
severity: :low,
370+
deduction: 5,
371+
issue: n
372+
}
373+
end)
374+
end)
375+
end
347376
end

lib/rules/structural_drift.ex

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,4 +742,65 @@ defmodule Hypatia.Rules.StructuralDrift do
742742
end)
743743
end
744744
end
745+
746+
# ─── SD021: Workflow trigger references a non-existent branch (#363) ───
747+
#
748+
# `on.push.branches` / `on.pull_request.branches` entries that don't
749+
# resolve to a real branch are dead config (consolidation drift). Globs
750+
# and the default branch are exempt. The actual-branch list is injected by
751+
# the caller (git index / GitHub API). Cohort hypatia#333, pattern 4.
752+
@doc """
753+
SD021: flag workflow trigger branch refs that aren't real branches.
754+
755+
`actual_branches` is the repo's live branch list (injected). Glob patterns
756+
and `opts[:default_branch]` (default `"main"`) are exempt. Returns one
757+
finding per (file, dead-branch).
758+
"""
759+
def check_workflow_branch_refs(workflow_contents, actual_branches, opts \\ []) do
760+
default = Keyword.get(opts, :default_branch, "main")
761+
762+
Enum.flat_map(workflow_contents, fn {file, content} ->
763+
content
764+
|> trigger_branches()
765+
|> Enum.filter(fn b ->
766+
b != default and not String.contains?(b, "*") and b not in actual_branches
767+
end)
768+
|> Enum.uniq()
769+
|> Enum.map(fn b ->
770+
%{
771+
rule: "SD021",
772+
file: file,
773+
severity: :low,
774+
reason:
775+
"workflow trigger references branch `#{b}`, which is not a real branch in this repo (dead config / consolidation drift)",
776+
action: :update_reference,
777+
branch: b
778+
}
779+
end)
780+
end)
781+
end
782+
783+
defp trigger_branches(content) do
784+
inline =
785+
~r/branches(?:-ignore)?:\s*\[([^\]]+)\]/
786+
|> Regex.scan(content)
787+
|> Enum.flat_map(fn [_, inner] -> String.split(inner, ",") end)
788+
|> Enum.map(&(&1 |> String.trim() |> String.trim("\"") |> String.trim("'")))
789+
790+
block = content |> String.split("\n") |> branch_block_items(false, [])
791+
Enum.reject(inline ++ block, &(&1 == ""))
792+
end
793+
794+
defp branch_block_items([], _in?, acc), do: Enum.reverse(acc)
795+
796+
defp branch_block_items([line | rest], in?, acc) do
797+
item = Regex.run(~r/^\s*-\s*['"]?([A-Za-z0-9._\/*-]+)['"]?\s*$/, line)
798+
799+
cond do
800+
Regex.match?(~r/^\s*branches(?:-ignore)?:\s*$/, line) -> branch_block_items(rest, true, acc)
801+
in? and Regex.match?(~r/^\s*#/, line) -> branch_block_items(rest, true, acc)
802+
in? and item != nil -> branch_block_items(rest, true, [Enum.at(item, 1) | acc])
803+
true -> branch_block_items(rest, false, acc)
804+
end
805+
end
745806
end

lib/rules/workflow_audit.ex

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,4 +1537,42 @@ defmodule Hypatia.Rules.WorkflowAudit do
15371537
heading_like? = Regex.match?(~r/[A-Z][a-z]+\s+(?:[A-Z][a-z]+|\[[A-Za-z])/, pat)
15381538
heading_like? and not String.starts_with?(pat, "^#")
15391539
end
1540+
1541+
# ─── WF023: Stale continue-on-error mask tied to a closed issue (#364) ─
1542+
#
1543+
# A job carrying `continue-on-error: true` with a nearby comment like
1544+
# "remove when #N" / "until #N" is a deliberate temporary mask. Once #N
1545+
# closes, the mask is stale and erodes gate quality. `closed_issues` (a
1546+
# list of closed issue numbers) is injected by the caller. Standalone (not
1547+
# wired into audit/3 — it needs the issue-state signal). Cohort #333 p5.
1548+
@doc """
1549+
WF023: flag `continue-on-error: true` workflows whose "until/remove when
1550+
#N" comment names an issue in `closed_issues`.
1551+
"""
1552+
def check_stale_continue_on_error(workflow_contents, closed_issues) do
1553+
closed = MapSet.new(closed_issues)
1554+
1555+
Enum.flat_map(workflow_contents, fn {file, content} ->
1556+
if Regex.match?(~r/continue-on-error:\s*true/, content) do
1557+
~r/(?:until|remove when|gated on|pending|once)\s+#(\d+)/i
1558+
|> Regex.scan(content)
1559+
|> Enum.map(fn [_, n] -> String.to_integer(n) end)
1560+
|> Enum.filter(&MapSet.member?(closed, &1))
1561+
|> Enum.uniq()
1562+
|> Enum.map(fn n ->
1563+
%{
1564+
rule: "WF023",
1565+
type: :stale_continue_on_error,
1566+
file: file,
1567+
severity: :medium,
1568+
reason:
1569+
"`continue-on-error: true` is gated on ##{n}, which is closed; drop the mask and let the job report accurately",
1570+
issue: n
1571+
}
1572+
end)
1573+
else
1574+
[]
1575+
end
1576+
end)
1577+
end
15401578
end
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
3+
defmodule Hypatia.Rules.GroupBDetectorsTest do
4+
use ExUnit.Case, async: true
5+
6+
alias Hypatia.Rules.StructuralDrift
7+
alias Hypatia.Rules.WorkflowAudit
8+
alias Hypatia.Rules.HonestCompletion
9+
10+
describe "StructuralDrift.check_workflow_branch_refs/3 (SD021, #363)" do
11+
test "flags inline trigger branches that aren't real branches" do
12+
wf = %{"ci.yml" => "on:\n push:\n branches: [main, master, develop]\n"}
13+
findings = StructuralDrift.check_workflow_branch_refs(wf, ["main"])
14+
assert findings |> Enum.map(& &1.branch) |> Enum.sort() == ["develop", "master"]
15+
assert hd(findings).rule == "SD021"
16+
assert hd(findings).severity == :low
17+
end
18+
19+
test "flags block-style dead branches, exempts globs and default" do
20+
body =
21+
"on:\n pull_request:\n branches:\n - main\n - feature/*\n - legacy\n"
22+
23+
findings = StructuralDrift.check_workflow_branch_refs(%{"ci.yml" => body}, ["main"])
24+
assert Enum.map(findings, & &1.branch) == ["legacy"]
25+
end
26+
27+
test "silent when all trigger branches exist" do
28+
wf = %{"ci.yml" => "on:\n push:\n branches: [main, master]\n"}
29+
assert StructuralDrift.check_workflow_branch_refs(wf, ["main", "master"]) == []
30+
end
31+
end
32+
33+
describe "WorkflowAudit.check_stale_continue_on_error/2 (WF023, #364)" do
34+
@coe "jobs:\n x:\n # remove when #104 lands\n continue-on-error: true\n"
35+
36+
test "flags a continue-on-error mask gated on a closed issue" do
37+
[f] = WorkflowAudit.check_stale_continue_on_error(%{"ci.yml" => @coe}, [104])
38+
assert f.rule == "WF023"
39+
assert f.issue == 104
40+
assert f.severity == :medium
41+
end
42+
43+
test "silent when the gating issue is still open" do
44+
assert WorkflowAudit.check_stale_continue_on_error(%{"ci.yml" => @coe}, []) == []
45+
end
46+
47+
test "silent when there is no continue-on-error" do
48+
body = "# remove when #104\n"
49+
assert WorkflowAudit.check_stale_continue_on_error(%{"ci.yml" => body}, [104]) == []
50+
end
51+
end
52+
53+
describe "HonestCompletion.check_stale_issue_refs/2 (#366)" do
54+
test "flags a comment referencing a closed issue" do
55+
[f] =
56+
HonestCompletion.check_stale_issue_refs(%{"x.ex" => "# blocked by #50 upstream\n"}, [50])
57+
58+
assert f.type == :stale_issue_reference
59+
assert f.issue == 50
60+
end
61+
62+
test "silent when the referenced issue is still open" do
63+
assert HonestCompletion.check_stale_issue_refs(%{"x.ex" => "# blocked by #50\n"}, []) == []
64+
end
65+
end
66+
end

0 commit comments

Comments
 (0)