Skip to content

Commit 2290229

Browse files
mnriemCopilot
andauthored
feat(presets): list presets in resolution/precedence order (#4086) (#4104)
`specify preset list` now sorts installed presets by (priority, id) so the printed order matches the actual resolution/composition order used by PresetRegistry.list_by_priority(). Lower priority number = higher precedence; ties are broken alphabetically by preset id. Adds a header and footer note clarifying the ordering, updates the presets reference docs, and adds tests. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e79fa25 commit 2290229

3 files changed

Lines changed: 80 additions & 1 deletion

File tree

docs/reference/presets.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ specify preset list
4747

4848
Lists installed presets with their versions, descriptions, template counts, and current status.
4949

50+
Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files.
51+
5052
## Preset Info
5153

5254
```bash

src/specify_cli/presets/_commands.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,15 @@ def preset_list():
5959
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
6060
return
6161

62-
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
62+
# Sort by actual resolution precedence: lower priority number wins, ties
63+
# broken by preset id (matching PresetRegistry.list_by_priority()). This
64+
# keeps the printed order aligned with how presets are composed/resolved.
65+
installed = sorted(
66+
installed,
67+
key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))),
68+
)
69+
70+
console.print("\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n")
6371
for pack in installed:
6472
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
6573
pri = pack.get('priority', 10)
@@ -75,6 +83,8 @@ def preset_list():
7583
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
7684
console.print()
7785

86+
console.print("[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]")
87+
7888

7989
@preset_app.command("add")
8090
def preset_add(

tests/test_presets.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13776,6 +13776,73 @@ def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir
1377613776
assert "[base]" in output, output
1377713777
assert "[append]" in output, output
1377813778

13779+
13780+
class TestPresetListOrdering:
13781+
"""``preset list`` must print presets in actual resolution/precedence order.
13782+
13783+
Regression coverage for #4086: the printed order was registry/insertion
13784+
order, so a preset with a *higher* priority number (lower precedence) could
13785+
appear before one with a lower number, misleading users about which preset
13786+
wins. Output must be sorted by (priority, id) to match
13787+
``PresetRegistry.list_by_priority()``.
13788+
"""
13789+
13790+
def _install(self, temp_dir, project_dir, pack_id, priority):
13791+
from specify_cli.presets import PresetManager
13792+
13793+
src = temp_dir / f"src-{pack_id}"
13794+
(src / "templates").mkdir(parents=True)
13795+
(src / "templates" / "spec-template.md").write_text("# tmpl\n")
13796+
(src / "preset.yml").write_text(yaml.dump({
13797+
"schema_version": "1.0",
13798+
"preset": {
13799+
"id": pack_id,
13800+
"name": pack_id,
13801+
"version": "1.0.0",
13802+
"description": "plain description",
13803+
},
13804+
"requires": {"speckit_version": ">=0.0.1"},
13805+
"provides": {"templates": [{
13806+
"type": "template",
13807+
"name": "spec-template",
13808+
"file": "templates/spec-template.md",
13809+
}]},
13810+
}))
13811+
PresetManager(project_dir).install_from_directory(src, "9.9.9", priority)
13812+
13813+
def _invoke(self, project_dir, args):
13814+
from typer.testing import CliRunner
13815+
from unittest.mock import patch
13816+
from specify_cli import app
13817+
13818+
with patch.object(Path, "cwd", return_value=project_dir):
13819+
return CliRunner().invoke(app, args)
13820+
13821+
def test_list_sorted_by_priority(self, temp_dir, project_dir):
13822+
"""Lower priority number is listed first regardless of install order."""
13823+
# Install in an order that does NOT match precedence.
13824+
self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100)
13825+
self._install(temp_dir, project_dir, "lean", priority=10)
13826+
13827+
result = self._invoke(project_dir, ["preset", "list"])
13828+
assert result.exit_code == 0, result.output
13829+
output = strip_ansi(result.output)
13830+
# `lean` (priority 10) must appear before `copilot-sub-agents` (100).
13831+
assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output
13832+
assert "resolution order" in output, output
13833+
assert "Ties are broken by preset id" in output, output
13834+
13835+
def test_list_ties_broken_by_id(self, temp_dir, project_dir):
13836+
"""Equal priority ties are broken alphabetically by preset id."""
13837+
self._install(temp_dir, project_dir, "zebra", priority=10)
13838+
self._install(temp_dir, project_dir, "alpha", priority=10)
13839+
13840+
result = self._invoke(project_dir, ["preset", "list"])
13841+
assert result.exit_code == 0, result.output
13842+
output = strip_ansi(result.output)
13843+
assert output.index("(alpha)") < output.index("(zebra)"), output
13844+
13845+
1377913846
class TestConstitutionSyncPreset:
1378013847
"""The bundled opt-in ``constitution-sync`` preset re-adds materialization.
1378113848

0 commit comments

Comments
 (0)