Skip to content

Commit 2a2198d

Browse files
fix(SD005): emit summary finding when orphan gitlink count > 5 (#380)
## Summary - SD005 already emitted one `:critical` finding per orphan gitlink, but the *count* (which is what predicts `actions/checkout` startup_failure) was never surfaced as a single actionable signal - Adds a `:high` summary finding when orphan_count > 5 with reason text explicitly naming the `actions/checkout post-job cleanup will startup_failure` risk - Root cause: `developer-ecosystem@baab1534` — 69 stale gitlinks caused scorecard workflow `startup_failure` that required manual diagnosis ## Test plan - [ ] `mix test test/structural_drift_test.exs` — three new ExUnit cases: no gitlinks → empty, count=6 → summary present, count=5 (at threshold) → no summary - [ ] Existing SD005 per-link `:critical` findings are unchanged 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c0d01a5 commit 2a2198d

2 files changed

Lines changed: 118 additions & 9 deletions

File tree

lib/rules/structural_drift.ex

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,24 @@ defmodule Hypatia.Rules.StructuralDrift do
151151

152152
# ─── SD005: Orphan gitlinks without .gitmodules ────────────────────────
153153

154+
# When orphan gitlink count exceeds this threshold, actions/checkout
155+
# post-job cleanup (git submodule foreach) will startup_failure on
156+
# every gitlink it cannot resolve. Surfaced by the developer-ecosystem
157+
# fix (hyperpolymath/developer-ecosystem@baab1534): 69 mode-160000 stale
158+
# gitlinks caused scorecard workflow startup_failure on every push.
159+
@orphan_gitlink_checkout_failure_threshold 5
160+
154161
@doc """
155162
SD005: Detect gitlinks (mode 160000) without corresponding .gitmodules entry.
156163
This is how stray repos-inside-repos (like svalinn in ats2-tui) happen.
157164
Bot `git add -A` catches stray clones and creates orphan submodule refs.
158165
Severity: critical (data corruption risk).
159166
Action: investigate -- move nested repo to canonical location, remove gitlink.
160167
Triggers: intensive scan + alert user.
168+
169+
When the orphan count exceeds #{@orphan_gitlink_checkout_failure_threshold},
170+
emits one additional summary finding at severity :high with a reason that
171+
explicitly names the actions/checkout startup_failure risk.
161172
"""
162173
def sd005_orphan_gitlinks(repo_path) do
163174
gitmodules_path = Path.join(repo_path, ".gitmodules")
@@ -183,21 +194,50 @@ defmodule Hypatia.Rules.StructuralDrift do
183194
gitmodules_content =
184195
if has_gitmodules, do: File.read!(gitmodules_path), else: ""
185196

186-
Enum.flat_map(gitlinks, fn path ->
187-
if String.contains?(gitmodules_content, "path = #{path}") do
188-
[] # Legitimate submodule
189-
else
197+
per_link_findings =
198+
Enum.flat_map(gitlinks, fn path ->
199+
if String.contains?(gitmodules_content, "path = #{path}") do
200+
[] # Legitimate submodule
201+
else
202+
[%{
203+
rule: "SD005",
204+
file: path,
205+
severity: :critical,
206+
reason: "Orphan gitlink -- submodule ref without .gitmodules entry. Likely a stray clone caught by bot git-add-all.",
207+
action: :investigate,
208+
trigger_intensive: true,
209+
alert_user: true
210+
}]
211+
end
212+
end)
213+
214+
orphan_count = length(per_link_findings)
215+
216+
# When count > threshold, emit an additional summary finding that
217+
# explicitly calls out the actions/checkout startup_failure risk.
218+
# This is the count-level signal that per-link :critical findings
219+
# don't surface on their own (developer-ecosystem@baab1534 had 69
220+
# stale gitlinks and scorecard's startup_failure was only diagnosed
221+
# after manual inspection of the submodule foreach error).
222+
summary_finding =
223+
if orphan_count > @orphan_gitlink_checkout_failure_threshold do
190224
[%{
191225
rule: "SD005",
192-
file: path,
193-
severity: :critical,
194-
reason: "Orphan gitlink -- submodule ref without .gitmodules entry. Likely a stray clone caught by bot git-add-all.",
226+
file: ".git (index)",
227+
severity: :high,
228+
reason: "#{orphan_count} orphan gitlinks detected -- actions/checkout post-job cleanup " <>
229+
"(git submodule foreach) will startup_failure with this many stale refs. " <>
230+
"Remove all orphan gitlinks before next push.",
195231
action: :investigate,
196232
trigger_intensive: true,
197-
alert_user: true
233+
alert_user: true,
234+
count: orphan_count
198235
}]
236+
else
237+
[]
199238
end
200-
end)
239+
240+
per_link_findings ++ summary_finding
201241
end
202242

203243
_ -> []

test/structural_drift_test.exs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,75 @@ defmodule Hypatia.Rules.StructuralDriftTest do
8989
end
9090
end
9191

92+
describe "sd005_orphan_gitlinks/1" do
93+
test "returns empty when no gitlinks exist", %{repo: repo} do
94+
System.cmd("git", ["init"], cd: repo)
95+
System.cmd("git", ["config", "user.email", "test@test.com"], cd: repo)
96+
System.cmd("git", ["config", "user.name", "Test"], cd: repo)
97+
File.write!(Path.join(repo, "README.md"), "# test")
98+
System.cmd("git", ["add", "."], cd: repo)
99+
System.cmd("git", ["commit", "-m", "init"], cd: repo)
100+
101+
findings = StructuralDrift.sd005_orphan_gitlinks(repo)
102+
assert findings == []
103+
end
104+
105+
test "emits summary finding when orphan count exceeds threshold", %{repo: repo} do
106+
System.cmd("git", ["init"], cd: repo)
107+
System.cmd("git", ["config", "user.email", "test@test.com"], cd: repo)
108+
System.cmd("git", ["config", "user.name", "Test"], cd: repo)
109+
File.write!(Path.join(repo, "README.md"), "# test")
110+
System.cmd("git", ["add", "."], cd: repo)
111+
System.cmd("git", ["commit", "-m", "init"], cd: repo)
112+
113+
# Inject 6 orphan gitlinks directly via git update-index (no .gitmodules)
114+
fake_sha = "deadbeefdeadbeefdeadbeefdeadbeef00000001"
115+
for i <- 1..6 do
116+
path = "vendor/dep_#{i}"
117+
File.mkdir_p!(Path.join(repo, path))
118+
System.cmd("git", ["update-index", "--add", "--cacheinfo",
119+
"160000,#{fake_sha},#{path}"],
120+
cd: repo)
121+
end
122+
123+
findings = StructuralDrift.sd005_orphan_gitlinks(repo)
124+
summary = Enum.find(findings, & &1[:count] == 6)
125+
assert summary != nil, "Expected a summary finding with count=6"
126+
assert summary.severity == :high
127+
assert summary.rule == "SD005"
128+
assert String.contains?(summary.reason, "actions/checkout")
129+
assert String.contains?(summary.reason, "startup_failure")
130+
# Also should have 6 per-link critical findings
131+
per_link = Enum.filter(findings, & &1.file != ".git (index)")
132+
assert length(per_link) == 6
133+
assert Enum.all?(per_link, &(&1.severity == :critical))
134+
end
135+
136+
test "does not emit summary when orphan count is at or below threshold", %{repo: repo} do
137+
System.cmd("git", ["init"], cd: repo)
138+
System.cmd("git", ["config", "user.email", "test@test.com"], cd: repo)
139+
System.cmd("git", ["config", "user.name", "Test"], cd: repo)
140+
File.write!(Path.join(repo, "README.md"), "# test")
141+
System.cmd("git", ["add", "."], cd: repo)
142+
System.cmd("git", ["commit", "-m", "init"], cd: repo)
143+
144+
# Inject exactly 5 orphan gitlinks (at threshold — no summary)
145+
fake_sha = "deadbeefdeadbeefdeadbeefdeadbeef00000002"
146+
for i <- 1..5 do
147+
path = "vendor/sub_#{i}"
148+
File.mkdir_p!(Path.join(repo, path))
149+
System.cmd("git", ["update-index", "--add", "--cacheinfo",
150+
"160000,#{fake_sha},#{path}"],
151+
cd: repo)
152+
end
153+
154+
findings = StructuralDrift.sd005_orphan_gitlinks(repo)
155+
summary = Enum.find(findings, & &1[:count] != nil)
156+
assert summary == nil, "Expected no summary finding for count <= threshold"
157+
assert length(findings) == 5
158+
end
159+
end
160+
92161
describe "sd010_tracked_node_modules/1" do
93162
test "returns empty when no node_modules directory exists", %{repo: repo} do
94163
# Initialise a git repo so the git commands work

0 commit comments

Comments
 (0)