Skip to content

Commit 7d69b8a

Browse files
fix(supply_chain): replace exponential Levenshtein with iterative DP (#329)
## Summary The previous \`levenshtein_rows\` implementation was a naïve recursive edit-distance that branched three ways at every position, giving O(3^min(n,m)) worst-case time. Fine on the 4-6 char fixtures the unit tests exercise; catastrophic on real GitHub-Action slugs like \`actions/checkout@v4\` (16 chars) — a single typosquat comparison took several seconds, and an estate-wide scan hung indefinitely (each workflow file kicks off ~30 comparisons against the known-good allowlist). ## What changed \`lib/rules/supply_chain.ex\` — \`levenshtein/2\` now uses: * **Iterative DP** — O(n*m) time, O(m) space (single rolling row). * **\`abs(n-m) > 2\` short-circuit** — typosquat detection only cares about edit-distance ≤ 2; we return 3 immediately when the length difference already exceeds that, skipping the DP work entirely. ## Smoke test Scanned \`gitbot-fleet\` (the repo that originally hung): | | Before | After | |---|---|---| | Elapsed | ≥60s (timeout, no result) | **20 ms** | | Findings | — | 3 | ## Why this matters Full estate self-scan (279 repos) previously hung at repo ~20 (timeouts cascaded). With this fix, the scan completes in ~30s and surfaced 8,025 findings across 21 rules — that's the corpus the rules-coverage handoff (see CLAUDE.md → "Outstanding work") works from. ## Test plan - [x] Smoke: gitbot-fleet scan returns in <30s - [ ] CI green - [ ] Existing unit tests for SC004 (\`test/supply_chain_test.exs\`) still pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 35f9f3c commit 7d69b8a

1 file changed

Lines changed: 37 additions & 16 deletions

File tree

lib/rules/supply_chain.ex

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -686,29 +686,50 @@ defmodule Hypatia.Rules.SupplyChain do
686686
end
687687
end
688688

689-
# Simple Levenshtein distance — adequate for the typosquat scope.
689+
# Iterative O(n*m) Levenshtein. The naïve recursive form was
690+
# exponential — fine on short test inputs, but ~3^32 on real
691+
# action-slug pairs which led to scan-time hangs.
690692
defp levenshtein(a, b) do
691693
a_list = String.graphemes(a)
692694
b_list = String.graphemes(b)
693-
levenshtein_rows(a_list, b_list)
695+
n = length(a_list)
696+
m = length(b_list)
697+
698+
cond do
699+
n == 0 -> m
700+
m == 0 -> n
701+
abs(n - m) > 2 -> 3 # short-circuit: caller only cares about <= 2
702+
true -> dp_levenshtein(a_list, b_list, m)
703+
end
694704
end
695705

696-
defp levenshtein_rows([], b), do: length(b)
697-
defp levenshtein_rows(a, []), do: length(a)
706+
defp dp_levenshtein(a_list, b_list, m) do
707+
init_row = Enum.to_list(0..m)
698708

699-
defp levenshtein_rows([ah | at], [bh | bt] = b) do
700-
cond do
701-
ah == bh ->
702-
levenshtein_rows(at, bt)
709+
Enum.with_index(a_list, 1)
710+
|> Enum.reduce(init_row, fn {ah, i}, prev_row ->
711+
[first | _] = prev_row
703712

704-
true ->
705-
1 +
706-
Enum.min([
707-
levenshtein_rows(at, b),
708-
levenshtein_rows([ah | at], bt),
709-
levenshtein_rows(at, bt)
710-
])
711-
end
713+
{row, _} =
714+
Enum.reduce(Enum.with_index(b_list, 1), {[i], prev_row}, fn {bh, _j},
715+
{acc, [pl, pr | rest]} ->
716+
cost = if ah == bh, do: 0, else: 1
717+
[cur | _] = acc
718+
719+
val =
720+
Enum.min([
721+
cur + 1,
722+
pr + 1,
723+
pl + cost
724+
])
725+
726+
{[val | acc], [pr | rest]}
727+
end)
728+
729+
_ = first
730+
Enum.reverse(row)
731+
end)
732+
|> List.last()
712733
end
713734

714735
defp fetch_repo_archived(slug) do

0 commit comments

Comments
 (0)