fix(presets): treat an unreadable core template as missing - #3961
Merged
Conversation
_substitute_core_template() read the resolved core template with a bare read_text(), so one corrupted project-owned override in .specify/templates/commands/ crashed the whole wrap-strategy command registration with a raw UnicodeDecodeError. Both callers (CommandRegistrar.register_pack and _register_commands) are unguarded here, even though register_pack already skips an unreadable preset source with a warning a few lines above the call. Treat an unreadable core template like a missing one — warn and return the body unchanged with empty frontmatter — matching the function's documented no-core contract. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Handles unreadable core templates without crashing preset command registration.
Changes:
- Warns and falls back when core templates cannot be read or decoded.
- Adds regression coverage and updates the function contract.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/specify_cli/presets/__init__.py |
Handles unreadable core templates safely. |
tests/test_presets.py |
Tests invalid UTF-8 fallback behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Review follow-up: use pytest.warns so removing or changing the promised warning fails the test. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/specify_cli/presets/init.py:214
CommandRegistrarhas noregister_packmethod; the guarded caller isregister_commands, while the other direct caller isPresetManager._register_skills. Referencing the nonexistent method makes this rationale difficult to verify.
# the wrap-strategy callers already skip an unreadable preset source with
# a warning (CommandRegistrar.register_pack).
tests/test_presets.py:10732
- This rationale names nonexistent
CommandRegistrar.register_packand says both callers skip unreadable preset sources. The actual call sites areregister_commands(guarded) and_register_skills(whose source read is unguarded), so the regression explanation is inaccurate.
The wrap-strategy callers (``CommandRegistrar.register_pack`` and
``_register_commands``) skip an unreadable preset source with a
Contributor
There was a problem hiding this comment.
Review details
Suppressed comments (1)
tests/test_presets.py:10728
- This docstring incorrectly says both callers skip unreadable preset sources.
_register_commandsstill performs a baresource_file.read_text()atsrc/specify_cli/presets/__init__.py:2720; onlyCommandRegistrar.register_packhas that warning boundary. Reword this so the regression test does not document behavior that the other caller lacks.
The wrap-strategy callers (``CommandRegistrar.register_pack`` and
``_register_commands``) skip an unreadable preset source with a
warning, but the core template read inside
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Collaborator
|
Thank you! |
Noor-ul-ain001
added a commit
to Noor-ul-ain001/spec-kit
that referenced
this pull request
Aug 10, 2026
…estore 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 github#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>
mnriem
pushed a commit
that referenced
this pull request
Aug 10, 2026
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
_substitute_core_template()reads the resolved core template with a bareread_text(), so one corrupted project-owned override in.specify/templates/commands/crashes the whole wrap-strategy command registration with a rawUnicodeDecodeError. Both callers (CommandRegistrar.register_packand_register_commands) are unguarded here — even thoughregister_packalready skips an unreadable preset source with a warning a few lines above the call.Fix: treat an unreadable core template like a missing one — warn and return the body unchanged with empty frontmatter — matching the function's documented no-core contract ("Both are unchanged / empty when … the core template file does not exist").
Testing
uv run specify --helpuv sync && uv run pytest(6,310 passed, 176 skipped)test_substitute_core_template_unreadable_core_treated_as_missing(fails on main, passes with fix)ruff check src testscleanAI Disclosure
Implemented autonomously by GitHub Copilot CLI (model: Claude Fable 5) under human direction; TDD (failing test first), full suite and lint verified locally. Commit includes
Assisted-by/Co-authored-bytrailers.