From 4d6217243f58fd5764c20cb4f103e614cae32636 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:32:36 +0000 Subject: [PATCH] test(actions-lock): closure contract corpus shared with standards Owner decision on standards#963, criterion 5: Hypatia is the authority for actions.lock closure. standards adds `close-lock.sh --check` as a local pre-push guard, and a shared corpus binds the two implementations. test/fixtures/actions_lock/closure/ is a byte-identical copy of standards' scripts/tests/fixtures/actions-lock-closure/. It holds five locks and expected.tsv: - closed v0.0.2; - closed v0.0.3 with hostname; - closed, with owner/repo spelled in a different case from its record; - dangling, with a record's nested uses: naming a ref that has no record (the standards#963 shape); - dangling, with a workflows: ref that has no record. The new test reads expected.tsv at compile time (@external_resource). It checks that there is one row per lock, that ActionsLock.parse/1 returns {:ok, _} for each closed lock, and that a dangling lock fails as workflow_dependencies_missing or transitive_dependencies_missing with exactly the listed refs, both sides compared through normalise_ref/1. The parser is unchanged. At aa34a4cc it already agrees on all five rows. Verification: Elixir 1.14 / OTP 25, running the parser and the test files standalone because hex.pm is unreachable from this session. - This file passes 6/6. - actions_lock_test.exs is unchanged, at 23 tests with 1 failure. The failure is the legacy Hypatia.Rules case, which needs the full engine, exactly as on main. - With validate_transitive_dependencies stubbed to :ok, the new test fails exactly the dangling-nested row. - Code.format_string!/1 leaves the file unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TbbnKqskQA5mj5gfMniPzi --- .../closure/closed-case-variant.lock | 13 ++++ .../actions_lock/closure/closed-v0.0.2.lock | 21 ++++++ .../actions_lock/closure/closed-v0.0.3.lock | 21 ++++++ .../actions_lock/closure/dangling-nested.lock | 15 +++++ .../closure/dangling-workflow-ref.lock | 14 ++++ .../actions_lock/closure/expected.tsv | 23 +++++++ .../actions_lock_closure_corpus_test.exs | 67 +++++++++++++++++++ 7 files changed, 174 insertions(+) create mode 100644 test/fixtures/actions_lock/closure/closed-case-variant.lock create mode 100644 test/fixtures/actions_lock/closure/closed-v0.0.2.lock create mode 100644 test/fixtures/actions_lock/closure/closed-v0.0.3.lock create mode 100644 test/fixtures/actions_lock/closure/dangling-nested.lock create mode 100644 test/fixtures/actions_lock/closure/dangling-workflow-ref.lock create mode 100644 test/fixtures/actions_lock/closure/expected.tsv create mode 100644 test/rules/actions_lock_closure_corpus_test.exs diff --git a/test/fixtures/actions_lock/closure/closed-case-variant.lock b/test/fixtures/actions_lock/closure/closed-case-variant.lock new file mode 100644 index 00000000..6eee99b4 --- /dev/null +++ b/test/fixtures/actions_lock/closure/closed-case-variant.lock @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed: owner/repo spelled in another case than its record (GitHub canonicalises owner/repo). Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/rust.yml': + - 'Example/Rust-Cache@5555555555555555555555555555555555555555' +dependencies: + 'example/rust-cache@5555555555555555555555555555555555555555': + ref: 'v2.9.2' + commit: 'sha1-5555555555555555555555555555555555555555' + owner_id: 101 + repo_id: 205 diff --git a/test/fixtures/actions_lock/closure/closed-v0.0.2.lock b/test/fixtures/actions_lock/closure/closed-v0.0.2.lock new file mode 100644 index 00000000..5a2d04c0 --- /dev/null +++ b/test/fixtures/actions_lock/closure/closed-v0.0.2.lock @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed, format v0.0.2. Contract: expected.tsv in this directory. +version: 'v0.0.2' +workflows: + '.github/workflows/ci.yml': + - 'example/build@1111111111111111111111111111111111111111' + '.github/workflows/empty.yml': [] +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 + uses: + - 'example/setup@2222222222222222222222222222222222222222' + 'example/setup@2222222222222222222222222222222222222222': + ref: 'v2.0.0' + commit: 'sha1-2222222222222222222222222222222222222222' + owner_id: 101 + repo_id: 202 diff --git a/test/fixtures/actions_lock/closure/closed-v0.0.3.lock b/test/fixtures/actions_lock/closure/closed-v0.0.3.lock new file mode 100644 index 00000000..381077f1 --- /dev/null +++ b/test/fixtures/actions_lock/closure/closed-v0.0.3.lock @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed, format v0.0.3 with the optional hostname field. Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/ci.yml': + - 'example/build@1111111111111111111111111111111111111111' +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 + hostname: 'github.com' + uses: + - 'example/setup@2222222222222222222222222222222222222222' + 'example/setup@2222222222222222222222222222222222222222': + ref: 'v2.0.0' + commit: 'sha1-2222222222222222222222222222222222222222' + owner_id: 101 + repo_id: 202 diff --git a/test/fixtures/actions_lock/closure/dangling-nested.lock b/test/fixtures/actions_lock/closure/dangling-nested.lock new file mode 100644 index 00000000..1b4fb80d --- /dev/null +++ b/test/fixtures/actions_lock/closure/dangling-nested.lock @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: dangling: a record's nested uses: names a ref with no record (the standards#963 shape). Contract: expected.tsv in this directory. +version: 'v0.0.2' +workflows: + '.github/workflows/push.yml': + - 'example/push-signed@3333333333333333333333333333333333333333' +dependencies: + 'example/push-signed@3333333333333333333333333333333333333333': + ref: 'v1.3' + commit: 'sha1-3333333333333333333333333333333333333333' + owner_id: 101 + repo_id: 203 + uses: + - 'example/setup-python@v2' diff --git a/test/fixtures/actions_lock/closure/dangling-workflow-ref.lock b/test/fixtures/actions_lock/closure/dangling-workflow-ref.lock new file mode 100644 index 00000000..3bae2099 --- /dev/null +++ b/test/fixtures/actions_lock/closure/dangling-workflow-ref.lock @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: dangling: a ref under workflows: with no record. Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/release.yml': + - 'example/build@1111111111111111111111111111111111111111' + - 'example/generator@4444444444444444444444444444444444444444' +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 diff --git a/test/fixtures/actions_lock/closure/expected.tsv b/test/fixtures/actions_lock/closure/expected.tsv new file mode 100644 index 00000000..9e07a443 --- /dev/null +++ b/test/fixtures/actions_lock/closure/expected.tsv @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Closure corpus for actions.lock (standards#963, criterion 5). Each lock is +# CLOSED when every ref under workflows: and every ref in a record's nested +# uses: has a top-level dependency record, comparing owner/repo +# case-insensitively and the ref exactly (GitHub's lockfile parser +# canonicalises owner/repo case; see #1294 and #1314). Otherwise it is +# DANGLING, and the third column lists the dangling refs as the lock spells +# them, space-separated. +# +# Two implementations must agree with this table: +# * scripts/close-lock.sh --check (scripts/tests/close-lock-test.sh) +# * Hypatia.Rules.ActionsLock.parse/1 in hyperpolymath/hypatia, which +# vendors a byte-identical copy of this directory. +# A third, GitHub's runner, is the reason the table exists. +# +# file verdict dangling +closed-v0.0.2.lock closed - +closed-v0.0.3.lock closed - +closed-case-variant.lock closed - +dangling-nested.lock dangling example/setup-python@v2 +dangling-workflow-ref.lock dangling example/generator@4444444444444444444444444444444444444444 diff --git a/test/rules/actions_lock_closure_corpus_test.exs b/test/rules/actions_lock_closure_corpus_test.exs new file mode 100644 index 00000000..9f5bf0e6 --- /dev/null +++ b/test/rules/actions_lock_closure_corpus_test.exs @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: MPL-2.0 + +defmodule Hypatia.Rules.ActionsLockClosureCorpusTest do + @moduledoc """ + Contract test for actions.lock closure, shared with hyperpolymath/standards + (standards#963, criterion 5). + + A lock is closed when every ref under `workflows:` and in a record's nested + `uses:` has a top-level record of its own, with owner/repo compared + case-insensitively and the ref exactly. `test/fixtures/actions_lock/closure/` + is a byte-identical copy of standards' + `scripts/tests/fixtures/actions-lock-closure/`. Its `expected.tsv` gives each + lock's verdict and, for a dangling lock, the refs that dangle. + + standards' `close-lock.sh --check` runs against the same table, so the two + implementations cannot drift apart without one of these suites going red. + Change both copies together. + """ + use ExUnit.Case, async: true + + alias Hypatia.Rules.ActionsLock + + @dir "test/fixtures/actions_lock/closure" + @table Path.join(@dir, "expected.tsv") + @external_resource @table + + @rows (for line <- String.split(File.read!(@table), "\n"), + line != "", + not String.starts_with?(line, "#") do + [file, verdict, dangling] = String.split(line, "\t") + refs = if dangling == "-", do: [], else: String.split(dangling, " ") + {file, verdict, refs} + end) + + @dangling_kinds [:workflow_dependencies_missing, :transitive_dependencies_missing] + + test "expected.tsv has exactly one row for each lock in the corpus" do + locks = + @dir + |> Path.join("*.lock") + |> Path.wildcard() + |> Enum.map(&Path.basename/1) + |> Enum.sort() + + assert locks == @rows |> Enum.map(&elem(&1, 0)) |> Enum.sort() + assert length(locks) >= 5 + end + + for {file, verdict, refs} <- @rows do + test "#{file} is #{verdict}" do + result = ActionsLock.parse(File.read!(Path.join(@dir, unquote(file)))) + assert_verdict(result, unquote(verdict), unquote(refs)) + end + end + + defp assert_verdict(result, "closed", []) do + assert {:ok, %ActionsLock{}} = result + end + + defp assert_verdict(result, "dangling", refs) do + assert {:error, {kind, entries}} = result + assert kind in @dangling_kinds + + got = entries |> Enum.map(&elem(&1, 1)) |> Enum.map(&ActionsLock.normalise_ref/1) + assert Enum.sort(got) == refs |> Enum.map(&ActionsLock.normalise_ref/1) |> Enum.sort() + end +end