Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ specify preset list

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

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.

## Preset Info

```bash
Expand Down
12 changes: 11 additions & 1 deletion src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,15 @@ def preset_list():
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
return

console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
# Sort by actual resolution precedence: lower priority number wins, ties
# broken by preset id (matching PresetRegistry.list_by_priority()). This
# keeps the printed order aligned with how presets are composed/resolved.
installed = sorted(
installed,
key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))),
Comment thread
mnriem marked this conversation as resolved.
)

console.print("\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n")
for pack in installed:
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
pri = pack.get('priority', 10)
Expand All @@ -75,6 +83,8 @@ def preset_list():
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()

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


@preset_app.command("add")
def preset_add(
Expand Down
67 changes: 67 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13680,6 +13680,73 @@ def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir
assert "[base]" in output, output
assert "[append]" in output, output


class TestPresetListOrdering:
"""``preset list`` must print presets in actual resolution/precedence order.

Regression coverage for #4086: the printed order was registry/insertion
order, so a preset with a *higher* priority number (lower precedence) could
appear before one with a lower number, misleading users about which preset
wins. Output must be sorted by (priority, id) to match
``PresetRegistry.list_by_priority()``.
"""

def _install(self, temp_dir, project_dir, pack_id, priority):
from specify_cli.presets import PresetManager

src = temp_dir / f"src-{pack_id}"
(src / "templates").mkdir(parents=True)
(src / "templates" / "spec-template.md").write_text("# tmpl\n")
(src / "preset.yml").write_text(yaml.dump({
"schema_version": "1.0",
"preset": {
"id": pack_id,
"name": pack_id,
"version": "1.0.0",
"description": "plain description",
},
"requires": {"speckit_version": ">=0.0.1"},
"provides": {"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
}]},
}))
PresetManager(project_dir).install_from_directory(src, "9.9.9", priority)

def _invoke(self, project_dir, args):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

with patch.object(Path, "cwd", return_value=project_dir):
return CliRunner().invoke(app, args)

def test_list_sorted_by_priority(self, temp_dir, project_dir):
"""Lower priority number is listed first regardless of install order."""
# Install in an order that does NOT match precedence.
self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100)
self._install(temp_dir, project_dir, "lean", priority=10)

result = self._invoke(project_dir, ["preset", "list"])
assert result.exit_code == 0, result.output
output = strip_ansi(result.output)
# `lean` (priority 10) must appear before `copilot-sub-agents` (100).
assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output
assert "resolution order" in output, output
assert "Ties are broken by preset id" in output, output

def test_list_ties_broken_by_id(self, temp_dir, project_dir):
"""Equal priority ties are broken alphabetically by preset id."""
self._install(temp_dir, project_dir, "zebra", priority=10)
self._install(temp_dir, project_dir, "alpha", priority=10)

result = self._invoke(project_dir, ["preset", "list"])
assert result.exit_code == 0, result.output
output = strip_ansi(result.output)
assert output.index("(alpha)") < output.index("(zebra)"), output


class TestConstitutionSyncPreset:
"""The bundled opt-in ``constitution-sync`` preset re-adds materialization.

Expand Down