diff --git a/.dagger/src/mat_vis_ci/main.py b/.dagger/src/mat_vis_ci/main.py index 5698b2ea..93408f54 100644 --- a/.dagger/src/mat_vis_ci/main.py +++ b/.dagger/src/mat_vis_ci/main.py @@ -11,7 +11,7 @@ dagger call bake # per-file hf-bake → atomic HF commit (#136 / ADR-0012) dagger call smoke-bake # dry-run bake against gerchowl/mat-vis-tst (#136) dagger call derive # per-file hf-derive (resize) (#204) - dagger call derive-ktx2 # per-file hf-derive-ktx2 (#204) + dagger call derive-ktx-2 # per-file hf-derive-ktx2 (#204; #240) dagger call integration-test # local end-to-end per-file bake + verify dagger call probe-sources # verify upstream API connectivity dagger call test-all # lint + test + smoke + probe @@ -20,8 +20,7 @@ dagger call test-client-shell # bash tests for shell reference client dagger call test-client-rust # cargo test for Rust reference client dagger call test-clients # all 4 client tests in parallel - dagger call test-e2e # nightly E2E against mat-vis-tst (#193) - dagger call validate-release # verify release assets are complete + dagger call test-e-2-e # nightly E2E against mat-vis-tst (#193; #240) dagger call preflight # verify GHCR auth before push dagger call push # preflight + build + push to GHCR """ @@ -850,6 +849,12 @@ async def derive_ktx2( Safety: defaults to ``gerchowl/mat-vis-tst``; any other target requires ``--allow-prod=true``. + + CLI surface: dagger's Go-side kebab-case conversion splits + letter→digit boundaries, so this Python ``derive_ktx2`` is + exposed as ``dagger call derive-ktx-2`` (not ``derive-ktx2``). + See #240 and the contract test in + ``tests/test_dagger_function_names.py``. """ self._guard_prod_target(repo_id, allow_prod) ctr = self._baker_container(context, with_ktx2=True, hf_token=hf_token) diff --git a/.github/workflows/derive.yml b/.github/workflows/derive.yml index b4f4b8a4..15be1f95 100644 --- a/.github/workflows/derive.yml +++ b/.github/workflows/derive.yml @@ -4,8 +4,12 @@ # # Shape: # plan — emits matrix sources (#233) + derives target-tier label -# derive — matrix[source] runs the Dagger `derive` or `derive-ktx2` +# derive — matrix[source] runs the Dagger `derive` or `derive-ktx-2` # function (which wraps mat-vis-baker hf-derive{,-ktx2}). +# #240: dagger's Go CLI splits letter→digit boundaries when +# converting camelCase to kebab, so `deriveKtx2` is exposed +# as `derive-ktx-2` (not `derive-ktx2`). Smoke test in +# tests/test_dagger_function_names.py guards this contract. # Each material batch is an atomic HF commit; a sentinel # `.tier_complete` file lands as the final commit per # (source, target_tier) so clients can probe atomicity @@ -207,7 +211,7 @@ jobs: verb: call module: .dagger args: >- - ${{ inputs.kind == 'resize' && 'derive' || 'derive-ktx2' }} + ${{ inputs.kind == 'resize' && 'derive' || 'derive-ktx-2' }} --context=. --source=${{ matrix.source }} ${{ inputs.kind == 'resize' && format('--target-tier={0}', inputs.target-tier) || '' }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bd1a4dff..6863a238 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -8,7 +8,10 @@ # escape hatch. # # Reproduce locally with: -# dagger call test-e2e --hf-token=env:HF_TOKEN --context=. +# dagger call test-e-2-e --hf-token=env:HF_TOKEN --context=. +# +# #240: dagger's Go CLI splits letter→digit boundaries when converting +# camelCase to kebab, so `testE2E`/`test_e2e` is exposed as `test-e-2-e`. name: E2E @@ -34,13 +37,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: test-e2e + - name: test-e-2-e uses: dagger/dagger-for-github@v8.4.1 with: verb: call module: .dagger args: >- - test-e2e + test-e-2-e --context=. --hf-token=env:HF_TOKEN --repo-id=gerchowl/mat-vis-tst diff --git a/tests/test_dagger_function_names.py b/tests/test_dagger_function_names.py new file mode 100644 index 00000000..b8e0dd1d --- /dev/null +++ b/tests/test_dagger_function_names.py @@ -0,0 +1,155 @@ +"""Regression gate for the Dagger CLI function-name contract (#240). + +Dagger's Python SDK converts a snake_case ``@function`` method name to +camelCase before exposing it on the GraphQL/engine surface. The Go-side +``dagger call`` CLI then converts that camelCase to kebab-case, splitting +on **both** lowercase→uppercase and lowercase→digit boundaries. So +``derive_ktx2`` (Python) → ``deriveKtx2`` (GQL) → ``derive-ktx-2`` (CLI), +**not** ``derive-ktx2``. + +#240: the ``derive.yml`` workflow used to invoke ``dagger call +derive-ktx2`` and failed in CI with ``unknown command``. This test pins +the kebab name our workflows must use, so any future @function rename +that breaks the contract trips a unit test instead of a CI dispatch. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +DAGGER_MAIN = Path(__file__).resolve().parent.parent / ".dagger" / "src" / "mat_vis_ci" / "main.py" + + +def _camel_to_kebab(camel: str) -> str: + """Replicate dagger's Go-side camelCase → kebab-case conversion. + + Inserts a hyphen before any uppercase letter or digit that follows + a lowercase letter, then lowercases the whole thing. Empirically + matches `dagger functions` output on engine v0.20.x for both + letter→letter (``probeSources`` → ``probe-sources``) and + letter→digit (``deriveKtx2`` → ``derive-ktx-2``, + ``testE2E`` → ``test-e-2-e``) boundaries. + """ + # Engine treats every letter↔digit and lower→upper transition as a + # word boundary. Empirically (engine v0.20.x) ``testE2E`` splits on + # all four edges → ``test-e-2-e``. Apply each rule in sequence on + # the running result so chained transitions all fire. + s = camel + s = re.sub(r"([a-z])([A-Z])", r"\1-\2", s) # lower → upper + s = re.sub(r"([a-zA-Z])([0-9])", r"\1-\2", s) # letter → digit + s = re.sub(r"([0-9])([a-zA-Z])", r"\1-\2", s) # digit → letter + return s.lower() + + +def _snake_to_camel(snake: str) -> str: + head, *tail = snake.split("_") + return head + "".join(p.title() for p in tail) + + +def _snake_to_kebab(snake: str) -> str: + return _camel_to_kebab(_snake_to_camel(snake)) + + +@pytest.fixture(scope="module") +def function_python_names() -> list[str]: + """Parse @function-decorated method names out of .dagger/.../main.py. + + Lightweight regex scan — avoids importing the dagger SDK (heavy + runtime + GraphQL engine deps) just to introspect names. + """ + src = DAGGER_MAIN.read_text() + # Match @function (with or without args) immediately followed by an + # async def or def line. + pattern = re.compile( + r"@function(?:\([^)]*\))?\s*\n\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", + re.MULTILINE, + ) + names = pattern.findall(src) + assert names, f"no @function-decorated methods found in {DAGGER_MAIN}" + return names + + +class TestKebabConversion: + """Pure-function tests for the kebab conversion helper.""" + + @pytest.mark.parametrize( + ("snake", "expected_kebab"), + [ + # Simple, no digits + ("derive", "derive"), + ("probe_sources", "probe-sources"), + ("build_materialx", "build-materialx"), + # #240: letter→digit boundary splits + ("derive_ktx2", "derive-ktx-2"), + ("test_e2e", "test-e-2-e"), + # Hypothetical future drift cases + ("foo_512", "foo-512"), # snake-cased digit segment + ("foo_bar2", "foo-bar-2"), + ("foo2_bar", "foo-2-bar"), + ], + ) + def test_snake_to_kebab(self, snake: str, expected_kebab: str) -> None: + assert _snake_to_kebab(snake) == expected_kebab + + +class TestDaggerFunctionNames: + """Pin the kebab CLI name our workflows invoke for each @function. + + If a future refactor renames ``derive_ktx2`` (and forgets to update + ``derive.yml``), this test fails in unit tests — long before a CI + dispatch hits the same ``unknown command`` error #240 chased. + """ + + def test_derive_ktx2_is_exposed_as_kebab_with_split_digit( + self, function_python_names: list[str] + ) -> None: + assert "derive_ktx2" in function_python_names, ( + "derive_ktx2 @function disappeared from .dagger/src/mat_vis_ci/main.py — " + "if it was renamed, update .github/workflows/derive.yml line 14 + 210" + ) + assert _snake_to_kebab("derive_ktx2") == "derive-ktx-2" + + def test_test_e2e_is_exposed_as_kebab_with_split_digit( + self, function_python_names: list[str] + ) -> None: + assert "test_e2e" in function_python_names + assert _snake_to_kebab("test_e2e") == "test-e-2-e" + + def test_workflow_derive_yml_uses_correct_kebab_name(self) -> None: + """The ternary in derive.yml must match the kebab name above.""" + derive_yml = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "derive.yml" + text = derive_yml.read_text() + assert "'derive-ktx-2'" in text, ( + "derive.yml ternary must invoke 'derive-ktx-2' (kebab with split " + "digit, per #240). Found:\n" + + "\n".join(line for line in text.splitlines() if "derive-ktx" in line) + ) + assert "'derive-ktx2'" not in text, ( + "derive.yml still references the broken 'derive-ktx2' literal — " + "update the ternary AND the header comment (#240)." + ) + + def test_workflow_e2e_yml_uses_correct_kebab_name(self) -> None: + e2e_yml = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "e2e.yml" + text = e2e_yml.read_text() + # `args:` block must invoke `test-e-2-e`. Header comment may + # also include the kebab form for documentation. + assert "test-e-2-e" in text, ( + "e2e.yml must invoke `test-e-2-e` (kebab with split digit, #240)" + ) + # The literal broken form should not appear as a CLI invocation. + # (Allow it inside a code reference like `test_e2e` Python name.) + assert re.search(r"\btest-e2e\b", text) is None, ( + "e2e.yml still references the broken `test-e2e` literal (#240)." + ) + + def test_all_decorated_functions_have_unique_kebab_names( + self, function_python_names: list[str] + ) -> None: + """Sanity check: no two @functions collide on their CLI name.""" + kebabs = [_snake_to_kebab(n) for n in function_python_names] + dupes = {k for k in kebabs if kebabs.count(k) > 1} + assert not dupes, f"duplicate kebab CLI names across @functions: {dupes}"