Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .machine_readable/6a2/STATE.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions lib/rules/honest_completion.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions lib/rules/structural_drift.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 38 additions & 0 deletions lib/rules/workflow_audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 66 additions & 0 deletions test/rules/group_b_detectors_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading