fix: broaden legacy skill provenance inference to command-backed agents

_infer_legacy_skill_provenance() only probed agents whose registrar
config statically declares extension == "/SKILL.md", excluding
command-backed agents (e.g. Copilot) that can also render preset
overrides as SKILL.md files when ai_skills is enabled. A real
preset-owned .github/skills/.../SKILL.md written while Copilot was the
active skills-mode agent was therefore never probed and got
misattributed entirely to whichever agent activated first after the
upgrade, permanently orphaning Copilot's override on later removal.

Broaden the candidate set to every configured integration
(CommandRegistrar.AGENT_CONFIGS), reusing the existing safe-path
helper (_safe_skills_dir_for_agent, itself built on the shared
_get_skills_dir resolver) rather than inventing new path-construction
logic. The existing preset-marker match (metadata.source ==
"preset:<pack_id>") continues to gate every attribution, so
command-mode agents that never rendered this preset's skill are not
falsely attributed.

Add red-first regression tests: a legacy flat-list entry owned by
Copilot in skills mode, switched directly to Claude with no
intervening Copilot rescaffold, now migrates to a per-agent dict
covering both agents, and removal restores both agents' files instead
of orphaning Copilot's override; plus a negative-case test confirming
a command-mode Copilot with no preset-owned skill marker is not
falsely attributed during the same migration.

Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
marcelsafin
2026-07-11 09:03:53 +02:00
parent 1bdb5b3473
commit ee03a5055e
2 changed files with 191 additions and 31 deletions

View File

@@ -4756,6 +4756,157 @@ class TestPresetSkills:
)
assert "Core specify body" in content
def test_rescaffold_legacy_flat_list_infers_command_backed_skills_owner(
self, project_dir, temp_dir
):
"""Legacy provenance inference must also probe command-backed agents
that were running in skills mode, not only agents whose command
registrar config is statically ``/SKILL.md``-only.
Copilot is command-backed (``extension: ".agent.md"``), but with
``ai_skills`` enabled its preset overrides render as ``SKILL.md``
files under ``.github/skills`` exactly like a native skill-only
agent (claude, codex, ...). Before the fix,
``_infer_legacy_skill_provenance`` only probed agents whose
registrar config has a static ``extension == "/SKILL.md"``, so a
real preset-owned ``.github/skills/.../SKILL.md`` written while
Copilot was the active, skills-mode agent was never found — the
legacy flat list was misattributed entirely to whichever agent the
first post-upgrade switch happened to activate, permanently
orphaning Copilot's override on later removal (#2948).
"""
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
(core_cmds / "specify.md").write_text(
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
encoding="utf-8",
)
copilot_skills_dir = project_dir / ".github" / "skills"
self._create_skill(copilot_skills_dir, "speckit-specify")
claude_skills_dir = project_dir / ".claude" / "skills"
self._create_skill(claude_skills_dir, "speckit-specify")
preset_dir = self._create_command_preset(
temp_dir, "legacy-copilot-skills-preset", "speckit.specify",
"Legacy copilot skills test", "preset body",
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md"
assert "preset:legacy-copilot-skills-preset" in copilot_skill.read_text(), (
"sanity: install should have written the override under "
"copilot's skills directory while copilot was active in "
"skills mode"
)
# Sanity: no command-mode artifact was written either — copilot's
# command file and skills file are mutually exclusive.
assert not list((project_dir / ".github" / "agents").glob("*specify*")), (
"sanity: copilot in skills mode must not also write a command "
"file that could be falsely attributed instead"
)
# Simulate a pre-#2948 registry: a flat list with no per-agent
# provenance, even though the file on disk was actually written
# under copilot's skills directory.
manager.registry.update(
"legacy-copilot-skills-preset",
{"registered_skills": ["speckit-specify"]},
)
# Directly switch to claude — no intervening rescaffold for
# copilot — mirroring `integration use claude` run right after
# upgrading spec-kit versions.
self._write_init_options(project_dir, ai="claude", ai_skills=True)
manager.register_enabled_presets_for_agent("claude")
metadata = manager.registry.get("legacy-copilot-skills-preset")
registered_skills = metadata.get("registered_skills")
assert isinstance(registered_skills, dict)
assert set(registered_skills) == {"copilot", "claude"}, (
"migrating a legacy flat-list entry on a direct switch must "
"infer the actual writer (copilot, running in skills mode) "
"even though copilot's registrar config is command-backed, "
"not just agents with a static /SKILL.md extension (#2948)"
)
assert manager.remove("legacy-copilot-skills-preset") is True
claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md"
for skill_file, label in ((copilot_skill, "copilot"), (claude_skill, "claude")):
assert skill_file.exists(), f"{label} skill file should still exist after removal"
content = skill_file.read_text()
assert "preset:legacy-copilot-skills-preset" not in content, (
f"{label}'s preset override must be restored on removal, "
"not permanently orphaned by a legacy migration that "
"failed to probe command-backed skills-mode agents (#2948)"
)
assert "Core specify body" in content
def test_infer_legacy_skill_provenance_does_not_falsely_attribute_command_mode_copilot(
self, project_dir, temp_dir
):
"""Broadening provenance inference to command-backed agents must not
falsely attribute ownership to an agent's directory that has no
preset-owned marker.
Copilot stays in plain command mode throughout (no skills ever
rendered there), so ``.github/skills`` never receives this
preset's ``SKILL.md``. Probing copilot's skills directory anyway
(now that inference isn't restricted to static ``/SKILL.md``
agents) must find nothing there and must not invent a false
``"copilot"`` entry (#2948).
"""
self._write_init_options(project_dir, ai="claude", ai_skills=True)
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
(core_cmds / "specify.md").write_text(
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
encoding="utf-8",
)
claude_skills_dir = project_dir / ".claude" / "skills"
self._create_skill(claude_skills_dir, "speckit-specify")
# Copilot has never been active; its command directory holds an
# unrelated file so the directory exists, but no skills directory
# or SKILL.md was ever written for it.
copilot_commands_dir = project_dir / ".github" / "agents"
copilot_commands_dir.mkdir(parents=True)
preset_dir = self._create_command_preset(
temp_dir, "no-false-attribution-preset", "speckit.specify",
"No false attribution test", "preset body",
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
manager.registry.update(
"no-false-attribution-preset",
{"registered_skills": ["speckit-specify"]},
)
# Rescaffold again for the same agent (claude) with unchanged
# names, triggering the legacy migration path.
manager.register_enabled_presets_for_agent("claude")
metadata = manager.registry.get("no-false-attribution-preset")
registered_skills = metadata.get("registered_skills")
assert isinstance(registered_skills, dict)
assert set(registered_skills) == {"claude"}, (
"copilot must not appear in the migrated registry when it has "
"never actually rendered this preset's skill — probing its "
"directory for a marker match must not create a false "
"attribution (#2948)"
)
assert not (project_dir / ".github" / "skills").exists(), (
"no .github/skills directory should have been created as a "
"side effect of probing for provenance (#2948)"
)
def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir):
"""Removal must validate a recorded skill directory before touching it.