From 6faaf1f7087cadce5c56e15c9263a467cec4d90a Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Fri, 11 Sep 2026 21:02:13 +0500 Subject: [PATCH 1/2] fix(workflows): match overlay file extensions case-insensitively `ProjectOverlaySource.collect` matched `path.suffix` verbatim: if not path.is_file() or path.suffix not in (".yml", ".yaml"): continue so a hand-placed overlay named `.YML` or `.Yaml` was skipped and never applied, with nothing reported to say the file had been ignored. Overlay files are explicitly hand-authored (docs/reference/workflows.md documents the format and tells users to write them), so the casing is the author's choice. Reproduced on main -- three overlays in one directory, differing only in extension case: files on disk: ['Mixed.Yaml', 'UPPER.YML', 'lower.yml'] COLLECTED : ['lower'] SKIPPED : ['mixed', 'upper'] Every other YAML discovery path in the package already lowercases before matching: engine.py:941, _commands.py:1325, :1894, :2128. This brings the overlay loader in line with them. Co-Authored-By: Claude Opus 5 (1M context) --- .../workflows/overlays/layer_sources.py | 7 ++- tests/workflows/test_overlay_layer_sources.py | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/overlays/layer_sources.py b/src/specify_cli/workflows/overlays/layer_sources.py index a62cef9340..071b45c0eb 100644 --- a/src/specify_cli/workflows/overlays/layer_sources.py +++ b/src/specify_cli/workflows/overlays/layer_sources.py @@ -147,7 +147,12 @@ def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[L workflow_overlay_dir, [f"Cannot enumerate overlays: {exc}"] ) from exc for path in entries: - if not path.is_file() or path.suffix not in (".yml", ".yaml"): + # Match the extension case-insensitively. A hand-placed overlay + # named ``.YML`` or ``.Yaml`` was skipped here and never + # applied, with nothing reported to say the file had been ignored. + # Every other YAML discovery path in the package already lowercases + # before matching (engine.py:941, _commands.py:1325/1894/2128). + if not path.is_file() or path.suffix.lower() not in (".yml", ".yaml"): continue if path.is_symlink(): raise OverlayLoadError(path, ["Symlinked overlay files are not allowed"]) diff --git a/tests/workflows/test_overlay_layer_sources.py b/tests/workflows/test_overlay_layer_sources.py index d852cb7622..339d1072d6 100644 --- a/tests/workflows/test_overlay_layer_sources.py +++ b/tests/workflows/test_overlay_layer_sources.py @@ -85,6 +85,50 @@ def test_empty_document_still_reports_missing_fields( ) +class TestProjectOverlaySourceExtensionMatching: + """Overlay file extensions are matched case-insensitively.""" + + @pytest.mark.parametrize( + "filename", ["upper.YML", "mixed.Yaml", "shouty.YAML", "title.Yml"] + ) + def test_uppercase_extension_is_collected( + self, project_dir: Path, filename: str + ) -> None: + """A hand-placed `.YML` must not be silently ignored. + + `collect` matched `path.suffix` verbatim against `(".yml", ".yaml")`, + so an overlay whose extension differed only in case was skipped with + nothing reported — the author's overlay simply never applied. Every + other YAML discovery path in the package lowercases before matching. + """ + ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + ov_dir.mkdir(parents=True, exist_ok=True) + (ov_dir / filename).write_text( + yaml.safe_dump( + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + + layers = ProjectOverlaySource(project_dir).collect("wf") + + assert [layer.content.id for layer in layers] == ["lint"] + + def test_non_yaml_extensions_are_still_skipped(self, project_dir: Path) -> None: + """Broadening case must not broaden which extensions are accepted.""" + ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + ov_dir.mkdir(parents=True, exist_ok=True) + for name in ("notes.txt", "backup.yml.bak", "README.md", "data.json"): + (ov_dir / name).write_text("id: lint\n", encoding="utf-8") + + assert ProjectOverlaySource(project_dir).collect("wf") == [] + + class TestProjectOverlaySourceFileReadErrors: """File-read errors must be wrapped in OverlayLoadError, not leaked as raw tracebacks.""" From c8ab0ac192afa523fb0f7bea4c32be19f5144a12 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Thu, 24 Sep 2026 20:50:54 +0500 Subject: [PATCH 2/2] fix(workflows): let overlay management find uppercase-extension overlays Addresses review feedback: making `ProjectOverlaySource.collect` match the suffix case-insensitively left `_find_overlay_file` matching it case-sensitively. The two disagreed, so a `.YML` overlay was ACTIVE during resolution yet reported "not found" by every management command: resolution collects it: ['lint'] overlay disable -> exit 1: Error: Overlay 'lint' not found ... overlay enable -> exit 1: Error: Overlay 'lint' not found ... overlay remove -> exit 1: Error: Overlay 'lint' not found ... That is worse than the original bug: before this PR the file was ignored consistently; after it, the overlay applied but could not be managed. `_find_overlay_file` now lowercases the suffix too, so both readers agree: overlay disable -> exit 0; enabled now = False overlay enable -> exit 0; enabled now = True overlay remove -> exit 0; file still exists = False New CLI-level test drives disable, enable and remove against `.YML` and `.Yaml` overlays. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../workflows/overlays/_commands.py | 6 ++- tests/workflows/test_overlay_commands.py | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/overlays/_commands.py b/src/specify_cli/workflows/overlays/_commands.py index 06c4dca835..a457dd1179 100644 --- a/src/specify_cli/workflows/overlays/_commands.py +++ b/src/specify_cli/workflows/overlays/_commands.py @@ -112,7 +112,11 @@ def _find_overlay_file(project_root: Path, workflow_id: str, overlay_id: str) -> return None matches: list[Path] = [] for path in entries: - if not path.is_file() or path.suffix not in (".yml", ".yaml"): + # Must match ``ProjectOverlaySource.collect`` exactly. With the loader + # lowercasing the suffix and this helper not, a ``.YML`` overlay was + # ACTIVE during resolution yet reported "not found" by overlay + # enable / disable / remove -- applied, but impossible to manage. + if not path.is_file() or path.suffix.lower() not in (".yml", ".yaml"): continue if path.is_symlink(): continue diff --git a/tests/workflows/test_overlay_commands.py b/tests/workflows/test_overlay_commands.py index c28f53b050..64e0f5282a 100644 --- a/tests/workflows/test_overlay_commands.py +++ b/tests/workflows/test_overlay_commands.py @@ -1002,3 +1002,55 @@ def test_duplicate_manifest_id_is_rejected(self, project_dir, monkeypatch): with pytest.raises(typer.Exit): _find_overlay_file(project_dir, "wf", "lint") + + +class TestOverlayManagementMatchesUppercaseExtension: + """Overlay management finds the same files the resolver loads.""" + + @pytest.mark.parametrize("filename", ["lint.YML", "lint.Yaml"]) + def test_uppercase_extension_overlay_is_manageable( + self, project_dir, monkeypatch, filename + ): + """An overlay the resolver applies must be reachable by enable/disable/remove. + + `ProjectOverlaySource.collect` matches the suffix case-insensitively, so + a `lint.YML` overlay is ACTIVE during resolution. `_find_overlay_file` + matched it case-sensitively, so the same overlay was reported "not + found" by every management command — applied, but impossible to manage. + """ + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + ov_dir.mkdir(parents=True, exist_ok=True) + overlay = ov_dir / filename + overlay.write_text( + yaml.safe_dump( + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "overlay", "disable", "wf", "lint"]) + assert result.exit_code == 0, result.output + assert yaml.safe_load(overlay.read_text(encoding="utf-8"))["enabled"] is False + + result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "lint"]) + assert result.exit_code == 0, result.output + assert yaml.safe_load(overlay.read_text(encoding="utf-8"))["enabled"] is True + + result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "lint"]) + assert result.exit_code == 0, result.output + assert not overlay.exists()