Files
github--spec-kit/tests
Noor ul ain 1a44a6aa08 fix(presets): skip an unreadable restore source in preset remove (#4020)
* fix(presets): skip an unreadable restore source in `preset remove`

`_unregister_skills_in_dir` restores each preset-owned SKILL.md from a core
command template or an extension source. Both of those reads were bare
`read_text(encoding="utf-8")` calls, so a project-owned override in
`.specify/templates/commands/` that exists but cannot be read or decoded
raised a raw `UnicodeDecodeError`/`OSError` straight out of
`PresetManager.remove()`, which has no handler for it — `specify preset
remove` dies with a traceback.

Every other failure in this loop degrades with `continue`: an unsafe
registry name, a missing skill subdirectory, a foreign owner. Sibling reads
of the very same directory are already guarded — `_infer_legacy_skill_
provenance` and `_delete_agent_preset_skills` both wrap their SKILL.md read
in `except (OSError, UnicodeDecodeError): continue`, and the read inside
`_substitute_core_template` was just given the same boundary in #3961. The
two restore reads were the remaining gap.

`continue` is the right recovery here rather than falling through: the
`else` branch below removes the skill outright, so treating an unreadable
source as "no source" would delete a user's skill at exactly the moment its
replacement cannot be generated. Skipping leaves the skill in place and
keeps it out of the returned `mutated_names`, so callers don't record a
restore that never happened.

Two regression tests, one per exception arm: a non-UTF-8 core template, and
a mocked `PermissionError` so the `OSError` half is also covered under
privileged CI where permission bits aren't enforced. Both assert the skill
survives untouched and is not reported as mutated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(presets): warn when a skill keeps preset content after a failed restore

Review follow-up on two points.

Surface the skipped restore. Skipping is still the correct recovery — the
alternative branch deletes the skill — but it was silent, and it is a
partial removal: `remove()` goes on to delete the preset directory and the
registry entry, while this `SKILL.md` keeps the removed preset's content,
and leaving the name out of `mutated_names` also keeps it out of
reconciliation, so nothing retries it. Both arms now emit a warning naming
the skill, the unreadable source, and the exception, and pointing at the
re-run that refreshes it once the file is fixed. `warnings.warn` matches
how the surrounding code reports non-fatal degradation (the reconciliation
failures in `remove()`/`install_from_directory`, the unreadable core
template in `_substitute_core_template` from #3961).

Cover the extension arm. A skill backed by an installed extension never
reaches the core-template read, so the two branches can regress
independently and both prior tests exercised only the core one.
`test_unregister_skills_in_dir_unreadable_extension_source_skips` installs
an extension whose command file is non-UTF-8 and asserts the skill survives
byte-for-byte and is absent from `mutated_names`. Verified it raises the
raw `UnicodeDecodeError` against unpatched source. The two existing tests
now assert the warning via `pytest.warns` so dropping it fails the suite.

pytest tests/test_presets.py -> 583 passed, 2 skipped, 7 failed; the 7 are
the pre-existing Windows symlink tests that need elevation, unchanged from
main. ruff check passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:26:07 -05:00
..