From ee2b8bcbae7366794222b883a3d38dfb5fc4f95a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:05:02 -0500 Subject: [PATCH 1/3] fix(presets): use _repo_root() for bundled-core source-checkout fallback The tier-5 fallback in PresetResolver.resolve() and _find_bundled_core() computed the repo root as Path(__file__).parent.parent.parent. After presets.py was moved to presets/__init__.py (#2826) that chain is one level short, resolving to src/ and looking for src/templates/commands/.md, which never exists. As a result, wrap-strategy presets found no core base layer in source/editable installs. Use the shared _repo_root() helper so both fallbacks resolve against the actual repo-root templates/ tree. Wheel installs were unaffected (core_pack path), so this only impacts source/editable checkouts. Refs #3086 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 8 ++++---- tests/test_presets.py | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index f8b9bac698..99e914674c 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -2703,7 +2703,7 @@ def resolve( # (source-checkout / editable install). This is the canonical home for # speckit's built-in command/template files and must always be checked # so that strategy:wrap presets can locate {CORE_TEMPLATE}. - from specify_cli import _locate_core_pack # local import to avoid cycles + from specify_cli import _locate_core_pack, _repo_root # local import to avoid cycles _core_pack = _locate_core_pack() if _core_pack is not None: # Wheel install path @@ -2723,7 +2723,7 @@ def resolve( return candidate else: # Source-checkout / editable install: templates live at repo root - repo_root = Path(__file__).parent.parent.parent + repo_root = _repo_root() if template_type == "template": candidate = repo_root / "templates" / f"{template_name}.md" elif template_type == "command": @@ -3075,7 +3075,7 @@ def _find_bundled_core( ``.specify/templates/`` doesn't contain the core file. """ try: - from specify_cli import _locate_core_pack + from specify_cli import _locate_core_pack, _repo_root except ImportError: return None @@ -3098,7 +3098,7 @@ def _find_bundled_core( if c.exists(): return c else: - repo_root = Path(__file__).parent.parent.parent + repo_root = _repo_root() for name in names: if template_type == "template": c = repo_root / "templates" / f"{name}.md" diff --git a/tests/test_presets.py b/tests/test_presets.py index de6054d99c..1c6fda6c13 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1094,7 +1094,33 @@ def test_resolve_core_returns_none_when_nothing_found(self, project_dir): result = resolver.resolve_core("nonexistent", "command") assert result is None - def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): + def test_collect_all_layers_finds_bundled_core_without_specify_commands( + self, project_dir + ): + """Tier-5 fallback locates the bundled core command when + .specify/templates/commands/ has no matching file. + + Regression test for #3086: a stale ``.parent`` chain made the + source-checkout fallback resolve to ``src/templates/...`` (which does + not exist), so ``wrap`` presets found no base layer. The fallback must + resolve against the real repo-root ``templates/commands`` tree. + """ + # project_dir's commands dir is empty, so tier-4 cannot satisfy this. + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.implement", "command") + assert layers, "expected a bundled core base layer to be found" + assert layers[-1]["source"] == "core (bundled)" + assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") + + def test_resolve_command_falls_back_to_bundled_core(self, project_dir): + """resolve() tier-5 returns the bundled core command when + .specify/templates/commands/ lacks it (regression for #3086).""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("speckit.implement", "command") + assert result is not None + assert result.parts[-2:] == ("commands", "implement.md") + + """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" import unittest.mock as mock From c52ee42cab2607085c9f0b51804a68a4d76e2023 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:14:23 -0500 Subject: [PATCH 2/3] test(presets): restore dropped def for oserror-manifest test A prior edit accidentally removed the def test_resolve_extension_command_via_manifest_skips_oserror_manifests line, orphaning its body inside the new bundled-core test. Restore the test definition so pytest collects it again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_presets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_presets.py b/tests/test_presets.py index 1c6fda6c13..4c51214d97 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1120,7 +1120,7 @@ def test_resolve_command_falls_back_to_bundled_core(self, project_dir): assert result is not None assert result.parts[-2:] == ("commands", "implement.md") - + def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" import unittest.mock as mock From 129514ce4204e8b908ce65c8b0d96c86dae15b3d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:21:58 -0500 Subject: [PATCH 3/3] test(presets): move bundled-core tests into TestPresetResolver The two tier-5 fallback regression tests exercise collect_all_layers() and resolve(), not resolve_core(), so they belong in TestPresetResolver rather than TestResolveCore. Relocate them for clearer suite navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_presets.py | 52 +++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/test_presets.py b/tests/test_presets.py index 4c51214d97..1e0209cb7c 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1033,6 +1033,32 @@ def test_resolve_skips_hidden_extension_dirs(self, project_dir): result = resolver.resolve("hidden-template") assert result is None + def test_collect_all_layers_finds_bundled_core_without_specify_commands( + self, project_dir + ): + """Tier-5 fallback locates the bundled core command when + .specify/templates/commands/ has no matching file. + + Regression test for #3086: a stale ``.parent`` chain made the + source-checkout fallback resolve to ``src/templates/...`` (which does + not exist), so ``wrap`` presets found no base layer. The fallback must + resolve against the real repo-root ``templates/commands`` tree. + """ + # project_dir's commands dir is empty, so tier-4 cannot satisfy this. + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.implement", "command") + assert layers, "expected a bundled core base layer to be found" + assert layers[-1]["source"] == "core (bundled)" + assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") + + def test_resolve_command_falls_back_to_bundled_core(self, project_dir): + """resolve() tier-5 returns the bundled core command when + .specify/templates/commands/ lacks it (regression for #3086).""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("speckit.implement", "command") + assert result is not None + assert result.parts[-2:] == ("commands", "implement.md") + class TestResolveCore: """Test PresetResolver.resolve_core() skips the installed-presets tier.""" @@ -1094,32 +1120,6 @@ def test_resolve_core_returns_none_when_nothing_found(self, project_dir): result = resolver.resolve_core("nonexistent", "command") assert result is None - def test_collect_all_layers_finds_bundled_core_without_specify_commands( - self, project_dir - ): - """Tier-5 fallback locates the bundled core command when - .specify/templates/commands/ has no matching file. - - Regression test for #3086: a stale ``.parent`` chain made the - source-checkout fallback resolve to ``src/templates/...`` (which does - not exist), so ``wrap`` presets found no base layer. The fallback must - resolve against the real repo-root ``templates/commands`` tree. - """ - # project_dir's commands dir is empty, so tier-4 cannot satisfy this. - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("speckit.implement", "command") - assert layers, "expected a bundled core base layer to be found" - assert layers[-1]["source"] == "core (bundled)" - assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") - - def test_resolve_command_falls_back_to_bundled_core(self, project_dir): - """resolve() tier-5 returns the bundled core command when - .specify/templates/commands/ lacks it (regression for #3086).""" - resolver = PresetResolver(project_dir) - result = resolver.resolve("speckit.implement", "command") - assert result is not None - assert result.parts[-2:] == ("commands", "implement.md") - def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" import unittest.mock as mock