Skip to content

Commit 1a60d1b

Browse files
github-actions[bot]Copilotmnriem
authored
[bug-fix] Fix preset-wrap-drops-argument-hint: inherit argument-hint from core template (#3996)
* Fix preset-wrap-drops-argument-hint: inherit argument-hint from core Apply the remediation from the bug assessment on issue #3991. Extend the inheritance allowlist in _register_skills and _compose_layers to include 'argument-hint', so wrap-strategy presets that omit this key will inherit it from the core template rather than silently dropping it and risking its value being leaked into description. Refs #3991 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(presets): guard wrap argument-hint inheritance for unmapped command The existing regression test for #3991 wraps `speckit.specify`, whose stem is in Claude's ARGUMENT_HINTS map. The string-injection fallback in post_process_skill_content re-adds argument-hint even when wrap composition drops it, so that test passes with or without the inheritance fix and does not actually guard the regression. Add a parallel test that wraps an extension-like command (`speckit.myfeature`) absent from ARGUMENT_HINTS, so the wrap-composition inheritance is the only path that can carry argument-hint into the SKILL.md. This test fails without the fix and passes with it. Refs #3991 Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce
1 parent d751231 commit 1a60d1b

2 files changed

Lines changed: 159 additions & 2 deletions

File tree

src/specify_cli/presets/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2759,7 +2759,7 @@ def _register_skills(
27592759
if frontmatter.get("strategy") == "wrap":
27602760
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
27612761
frontmatter = dict(frontmatter)
2762-
for key in ("scripts", "agent_scripts"):
2762+
for key in ("scripts", "agent_scripts", "argument-hint"):
27632763
if key not in frontmatter and key in core_frontmatter:
27642764
frontmatter[key] = core_frontmatter[key]
27652765

@@ -5814,7 +5814,7 @@ def _parse_fm_yaml(fm_block: str) -> dict:
58145814
# Inherit scripts/agent_scripts from base frontmatter if missing
58155815
if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text:
58165816
base_fm = _parse_fm_yaml(base_frontmatter_text)
5817-
for key in ("scripts", "agent_scripts"):
5817+
for key in ("scripts", "agent_scripts", "argument-hint"):
58185818
if key not in top_fm and key in base_fm:
58195819
top_fm[key] = base_fm[key]
58205820

tests/test_presets.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4727,6 +4727,163 @@ def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir
47274727
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
47284728
assert "argument-hint" not in parsed
47294729

4730+
def test_wrap_preset_inherits_argument_hint_from_core(self, project_dir, temp_dir):
4731+
"""A wrap-strategy preset that omits argument-hint must inherit it from the core template.
4732+
4733+
Regression for issue #3991: the wrap-composition path in _register_skills
4734+
previously inherited only scripts/agent_scripts from core_frontmatter,
4735+
silently discarding argument-hint and leaking its value into description.
4736+
"""
4737+
core_arg_hint = "Describe the feature you want to specify"
4738+
preset_description = "Wrapped speckit.specify — extra project context added"
4739+
self._write_init_options(project_dir, ai="claude")
4740+
skills_dir = project_dir / ".claude" / "skills"
4741+
self._create_skill(skills_dir, "speckit-specify")
4742+
4743+
# Place a core template that declares argument-hint
4744+
core_cmds = project_dir / ".specify" / "templates" / "commands"
4745+
core_cmds.mkdir(parents=True, exist_ok=True)
4746+
(core_cmds / "specify.md").write_text(
4747+
"---\n"
4748+
"description: Core specify description.\n"
4749+
f'argument-hint: "{core_arg_hint}"\n'
4750+
"---\n\n"
4751+
"Core specify body.\n",
4752+
encoding="utf-8",
4753+
)
4754+
4755+
# Wrap preset: only declares description (no argument-hint)
4756+
preset_dir = temp_dir / "wrap-hint-preset"
4757+
preset_dir.mkdir()
4758+
(preset_dir / "commands").mkdir()
4759+
(preset_dir / "commands" / "speckit.specify.md").write_text(
4760+
"---\n"
4761+
f'description: "{preset_description}"\n'
4762+
"strategy: wrap\n"
4763+
"---\n\n"
4764+
"{CORE_TEMPLATE}\n",
4765+
encoding="utf-8",
4766+
)
4767+
manifest_data = {
4768+
"schema_version": "1.0",
4769+
"preset": {
4770+
"id": "wrap-hint-preset",
4771+
"name": "Wrap Hint Preset",
4772+
"version": "1.0.0",
4773+
"description": "Test wrap hint inheritance",
4774+
},
4775+
"requires": {"speckit_version": ">=0.1.0"},
4776+
"provides": {
4777+
"templates": [
4778+
{
4779+
"type": "command",
4780+
"name": "speckit.specify",
4781+
"file": "commands/speckit.specify.md",
4782+
"strategy": "wrap",
4783+
}
4784+
]
4785+
},
4786+
}
4787+
import yaml as _yaml
4788+
with open(preset_dir / "preset.yml", "w") as f:
4789+
_yaml.dump(manifest_data, f)
4790+
4791+
manager = PresetManager(project_dir)
4792+
manager.install_from_directory(preset_dir, "1.0.0")
4793+
4794+
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
4795+
assert skill_file.exists()
4796+
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
4797+
# argument-hint must be inherited from core, not dropped
4798+
assert parsed.get("argument-hint") == core_arg_hint, (
4799+
f"argument-hint was not inherited from core; parsed={parsed}"
4800+
)
4801+
# description must be exactly the preset's declared value, not concatenated
4802+
assert parsed["description"] == preset_description, (
4803+
f"description was corrupted; parsed={parsed}"
4804+
)
4805+
4806+
def test_wrap_preset_inherits_argument_hint_for_unmapped_command(self, project_dir, temp_dir):
4807+
"""Wrap inheritance must carry argument-hint for a command NOT in ARGUMENT_HINTS.
4808+
4809+
Regression guard for issue #3991. The companion test above wraps
4810+
``speckit.specify``, whose stem is in Claude's ``ARGUMENT_HINTS`` map, so
4811+
the string-injection fallback in ``post_process_skill_content`` re-adds
4812+
``argument-hint`` even when wrap composition drops it — masking the bug.
4813+
This test wraps an extension-like command (``speckit.myfeature``) that is
4814+
absent from that map, so the *only* thing that can carry the hint into the
4815+
SKILL.md is the wrap-composition inheritance fix itself. Without the fix
4816+
the key is dropped and this test fails.
4817+
"""
4818+
core_arg_hint = "Custom hint that lives only on the core template"
4819+
preset_description = "Wrapped speckit.myfeature — extra project context added"
4820+
self._write_init_options(project_dir, ai="claude")
4821+
skills_dir = project_dir / ".claude" / "skills"
4822+
self._create_skill(skills_dir, "speckit-myfeature")
4823+
4824+
# Place a core template (extension-like command) that declares argument-hint
4825+
core_cmds = project_dir / ".specify" / "templates" / "commands"
4826+
core_cmds.mkdir(parents=True, exist_ok=True)
4827+
(core_cmds / "myfeature.md").write_text(
4828+
"---\n"
4829+
"description: Core myfeature description.\n"
4830+
f'argument-hint: "{core_arg_hint}"\n'
4831+
"---\n\n"
4832+
"Core myfeature body.\n",
4833+
encoding="utf-8",
4834+
)
4835+
4836+
# Wrap preset: only declares description (no argument-hint)
4837+
preset_dir = temp_dir / "wrap-hint-preset-unmapped"
4838+
preset_dir.mkdir()
4839+
(preset_dir / "commands").mkdir()
4840+
(preset_dir / "commands" / "speckit.myfeature.md").write_text(
4841+
"---\n"
4842+
f'description: "{preset_description}"\n'
4843+
"strategy: wrap\n"
4844+
"---\n\n"
4845+
"{CORE_TEMPLATE}\n",
4846+
encoding="utf-8",
4847+
)
4848+
manifest_data = {
4849+
"schema_version": "1.0",
4850+
"preset": {
4851+
"id": "wrap-hint-preset-unmapped",
4852+
"name": "Wrap Hint Preset Unmapped",
4853+
"version": "1.0.0",
4854+
"description": "Test wrap hint inheritance for an unmapped command",
4855+
},
4856+
"requires": {"speckit_version": ">=0.1.0"},
4857+
"provides": {
4858+
"templates": [
4859+
{
4860+
"type": "command",
4861+
"name": "speckit.myfeature",
4862+
"file": "commands/speckit.myfeature.md",
4863+
"strategy": "wrap",
4864+
}
4865+
]
4866+
},
4867+
}
4868+
import yaml as _yaml
4869+
with open(preset_dir / "preset.yml", "w") as f:
4870+
_yaml.dump(manifest_data, f)
4871+
4872+
manager = PresetManager(project_dir)
4873+
manager.install_from_directory(preset_dir, "1.0.0")
4874+
4875+
skill_file = skills_dir / "speckit-myfeature" / "SKILL.md"
4876+
assert skill_file.exists()
4877+
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
4878+
# argument-hint must be inherited from core, not dropped
4879+
assert parsed.get("argument-hint") == core_arg_hint, (
4880+
f"argument-hint was not inherited from core; parsed={parsed}"
4881+
)
4882+
# description must be exactly the preset's declared value, not concatenated
4883+
assert parsed["description"] == preset_description, (
4884+
f"description was corrupted; parsed={parsed}"
4885+
)
4886+
47304887
def test_register_skills_resolves_command_refs(self, project_dir, temp_dir):
47314888
"""Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717).
47324889

0 commit comments

Comments
 (0)