Skip to content

Commit 44bc562

Browse files
mnriemCopilot
andcommitted
fix(bob): fail closed when preset registry is unreadable (review #3415)
Address review 4744636079: - _migrate_commands: the preset guard previously failed *open* β€” a registry read/parse error returned an empty "no presets" list, so a --force layout-changing upgrade could delete preset-overridden command files while their registry state was unknown. Read the registry file directly and raise _PresetRegistryUnreadableError on any read/parse failure or malformed structure, rejecting the migration before any mutation. A genuinely absent registry still returns [] (safe). - bob: correct the is_skills_mode docstring β€” upgrade *does* run setup(); disk detection is needed because legacy Bob 1.x installs never persisted a legacy_commands option, so the stored mode is unavailable. - tests: add fail-closed E2E (corrupted registry rejected, valid-empty allowed) plus a unit test for _installed_presets_affecting_agent covering absent / corrupted / malformed / valid / affecting-agent cases. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf
1 parent 9cdc8d0 commit 44bc562

3 files changed

Lines changed: 170 additions & 18 deletions

File tree

β€Žsrc/specify_cli/integrations/_migrate_commands.pyβ€Ž

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""specify integration switch / upgrade command handlers."""
22
from __future__ import annotations
33

4+
import json
45
import os
5-
from pathlib import PurePath
6+
from pathlib import Path, PurePath
67

78
import typer
89

@@ -53,6 +54,16 @@ def _manifest_tracks_skill_layout(manifest) -> bool:
5354
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
5455

5556

57+
class _PresetRegistryUnreadableError(Exception):
58+
"""Raised when an existing preset registry cannot be read or parsed.
59+
60+
Distinct from a *genuinely absent* registry (no presets installed): an
61+
unreadable registry means we cannot verify whether preset overrides would
62+
be orphaned by a layout change, so the migration must be rejected rather
63+
than proceeding on a false "no presets" assumption.
64+
"""
65+
66+
5667
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
5768
"""Return IDs of installed presets with artifacts registered for *agent_key*.
5869
@@ -64,18 +75,36 @@ def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str
6475
Callers use this to detect the unsafe case and reject the migration rather
6576
than silently orphaning preset files / leaving stale registry entries.
6677
67-
Best-effort: any error resolving preset state yields an empty list so a
68-
broken/absent preset registry never blocks an otherwise-valid upgrade.
78+
Fails **closed**: a genuinely absent registry (no presets ever installed)
79+
returns an empty list, but if the registry file exists and cannot be read
80+
or parsed (e.g. a permission error or corruption) this raises
81+
:class:`_PresetRegistryUnreadableError`. Reporting "no presets" in that
82+
case would let a ``--force`` layout-changing upgrade delete
83+
preset-overridden files while their registry state can't be reconciled β€”
84+
the exact inconsistency the guard exists to prevent.
6985
"""
70-
try:
71-
from ..presets import PresetManager
86+
from ..presets import PresetRegistry
7287

73-
presets = PresetManager(project_root).registry.list()
74-
except Exception:
88+
registry_path = (
89+
Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE
90+
)
91+
# Genuinely absent registry β†’ no presets installed β†’ safe to proceed.
92+
if not registry_path.exists():
7593
return []
7694

95+
# The registry exists: any failure to read or parse it must surface as an
96+
# error, not be swallowed into an empty ("no presets") result.
97+
try:
98+
data = json.loads(registry_path.read_text(encoding="utf-8"))
99+
except (OSError, ValueError) as exc:
100+
raise _PresetRegistryUnreadableError(str(exc)) from exc
101+
if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict):
102+
raise _PresetRegistryUnreadableError(
103+
"preset registry structure is malformed"
104+
)
105+
77106
affected: list[str] = []
78-
for preset_id, meta in presets.items():
107+
for preset_id, meta in data.get("presets", {}).items():
79108
if not isinstance(meta, dict):
80109
continue
81110
registered_commands = meta.get("registered_commands", {})
@@ -463,7 +492,21 @@ def integration_upgrade(
463492
if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
464493
parsed_options, project_root
465494
):
466-
affected_presets = _installed_presets_affecting_agent(project_root, key)
495+
try:
496+
affected_presets = _installed_presets_affecting_agent(project_root, key)
497+
except _PresetRegistryUnreadableError as exc:
498+
console.print(
499+
f"[red]Error:[/red] Cannot change '{key}' command layout: the "
500+
f"preset registry could not be read to verify installed presets."
501+
)
502+
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
503+
console.print(
504+
"A layout change cannot reconcile preset artifacts, so the "
505+
"migration is refused while the preset registry state is "
506+
"unknown. Fix or restore "
507+
"[cyan].specify/presets/.registry[/cyan] and retry."
508+
)
509+
raise typer.Exit(1)
467510
if affected_presets:
468511
preset_list = ", ".join(sorted(affected_presets))
469512
console.print(

β€Žsrc/specify_cli/integrations/bob/__init__.pyβ€Ž

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -196,15 +196,19 @@ def is_skills_mode(
196196
4. A fresh project (no managed artifacts, no flags) defaults to skills.
197197
198198
The disk-detection fallback exists because on ``use`` / ``switch`` /
199-
``upgrade`` (without ``--skills``) no ``setup()`` runs and
200-
*parsed_options* is typically empty β€” existing Bob 1.x installs never
201-
stored ``legacy_commands``. Defaulting to skills there would rewrite
202-
such a project's ``ai_skills`` flag to ``True`` even though it still
203-
only contains a command layout, silently switching its extension /
204-
command-reference handling. So the layout is inferred from managed
205-
Spec Kit artifacts, not the mere presence of a ``.bob/skills/``
206-
directory: a user may keep unrelated Bob 2 skills in ``.bob/skills/``
207-
while their Spec Kit commands still live in
199+
``upgrade`` (without an explicit ``--skills`` / ``--legacy-commands``)
200+
*parsed_options* is typically empty: no flag was passed, and existing
201+
Bob 1.x installs never persisted a ``legacy_commands`` option to
202+
recover. This is independent of whether ``setup()`` runs β€” ``upgrade``
203+
*does* call :meth:`setup` (see ``_migrate_commands.integration_upgrade``),
204+
but it passes those same empty *parsed_options*, so without disk
205+
detection the mode would resolve to the skills default. Defaulting to
206+
skills there would rewrite such a project's ``ai_skills`` flag to
207+
``True`` even though it still only contains a command layout, silently
208+
switching its extension / command-reference handling. So the layout is
209+
inferred from managed Spec Kit artifacts, not the mere presence of a
210+
``.bob/skills/`` directory: a user may keep unrelated Bob 2 skills in
211+
``.bob/skills/`` while their Spec Kit commands still live in
208212
``.bob/commands/speckit.*.md``. We therefore treat the project as
209213
legacy (command) mode only when managed Spec Kit command files exist
210214
and no managed Spec Kit skills (``speckit-*`` skill dirs) do. Passing

β€Žtests/integrations/test_integration_subcommand.pyβ€Ž

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2662,6 +2662,58 @@ def test_upgrade_bob_layout_change_rejected_with_presets_installed(self, tmp_pat
26622662
f"same-layout upgrade must not be blocked by presets: {result.output}"
26632663
)
26642664

2665+
def test_upgrade_bob_layout_change_rejected_when_preset_registry_unreadable(
2666+
self, tmp_path
2667+
):
2668+
"""Regression (review #3415, 4744636079).
2669+
2670+
The preset guard must fail *closed*: if the preset registry exists but
2671+
cannot be read/parsed (corruption, permissions), the layout-changing
2672+
upgrade must be rejected before any mutation rather than proceeding on
2673+
a false "no presets installed" assumption (which would let ``--force``
2674+
delete preset-overridden command files while their registry state is
2675+
unknown). A genuinely absent registry must still be allowed.
2676+
"""
2677+
project = _init_project(
2678+
tmp_path, "bob", integration_options="--legacy-commands"
2679+
)
2680+
commands = project / ".bob" / "commands"
2681+
skills = project / ".bob" / "skills"
2682+
assert sorted(commands.glob("speckit.*.md"))
2683+
2684+
# Corrupted (unparseable) registry: exists but cannot be read as JSON.
2685+
presets_dir = project / ".specify" / "presets"
2686+
presets_dir.mkdir(parents=True, exist_ok=True)
2687+
(presets_dir / ".registry").write_text("{ not valid json", encoding="utf-8")
2688+
2689+
result = _run_in_project(project, [
2690+
"integration", "upgrade", "bob",
2691+
"--integration-options", "--skills",
2692+
"--script", "sh", "--force",
2693+
])
2694+
assert result.exit_code != 0, (
2695+
"layout change must be rejected when preset registry is unreadable"
2696+
)
2697+
assert "preset registry" in result.output.lower()
2698+
assert not skills.exists(), "no skills layout may be scaffolded on rejection"
2699+
assert sorted(commands.glob("speckit.*.md")), (
2700+
"legacy command files must be untouched when failing closed"
2701+
)
2702+
2703+
# A valid, empty registry must NOT block the migration.
2704+
(presets_dir / ".registry").write_text(
2705+
json.dumps({"presets": {}}), encoding="utf-8"
2706+
)
2707+
result = _run_in_project(project, [
2708+
"integration", "upgrade", "bob",
2709+
"--integration-options", "--skills",
2710+
"--script", "sh", "--force",
2711+
])
2712+
assert result.exit_code == 0, (
2713+
f"valid empty preset registry must not block migration: {result.output}"
2714+
)
2715+
assert skills.exists(), "skills layout should be scaffolded once unblocked"
2716+
26652717
def test_upgrade_secondary_bob_layout_change_preserves_active_agent_skills(
26662718
self, tmp_path
26672719
):
@@ -2858,6 +2910,59 @@ def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path):
28582910
"deleted extension skill (#2886)"
28592911
)
28602912

2913+
def test_installed_presets_affecting_agent_absent_vs_unreadable(self, tmp_path):
2914+
"""Unit (review #3415, 4744636079): fail closed only when unreadable.
2915+
2916+
The preset guard helper must return an empty list for a genuinely
2917+
absent registry, but raise ``_PresetRegistryUnreadableError`` when the
2918+
registry exists yet cannot be read/parsed β€” so a layout-changing
2919+
upgrade never proceeds on a false "no presets" result.
2920+
"""
2921+
from specify_cli.integrations._migrate_commands import (
2922+
_PresetRegistryUnreadableError,
2923+
_installed_presets_affecting_agent,
2924+
)
2925+
2926+
project = tmp_path / "proj"
2927+
project.mkdir()
2928+
2929+
# Genuinely absent registry β†’ empty list (safe to proceed).
2930+
assert _installed_presets_affecting_agent(project, "bob") == []
2931+
2932+
presets_dir = project / ".specify" / "presets"
2933+
presets_dir.mkdir(parents=True)
2934+
registry = presets_dir / ".registry"
2935+
2936+
# Corrupted JSON β†’ unreadable β†’ raise.
2937+
registry.write_text("{ not json", encoding="utf-8")
2938+
with pytest.raises(_PresetRegistryUnreadableError):
2939+
_installed_presets_affecting_agent(project, "bob")
2940+
2941+
# Malformed structure (presets not a dict) β†’ unreadable β†’ raise.
2942+
registry.write_text(json.dumps({"presets": []}), encoding="utf-8")
2943+
with pytest.raises(_PresetRegistryUnreadableError):
2944+
_installed_presets_affecting_agent(project, "bob")
2945+
2946+
# Valid, empty registry β†’ empty list.
2947+
registry.write_text(json.dumps({"presets": {}}), encoding="utf-8")
2948+
assert _installed_presets_affecting_agent(project, "bob") == []
2949+
2950+
# Valid registry with a preset registered for bob β†’ reported.
2951+
registry.write_text(
2952+
json.dumps({
2953+
"presets": {
2954+
"p1": {"registered_commands": {"bob": ["speckit.plan"]}},
2955+
"p2": {"registered_commands": {"codex": ["speckit.plan"]}},
2956+
"p3": {"registered_skills": ["speckit-x"]},
2957+
}
2958+
}),
2959+
encoding="utf-8",
2960+
)
2961+
assert sorted(_installed_presets_affecting_agent(project, "bob")) == [
2962+
"p1",
2963+
"p3",
2964+
]
2965+
28612966

28622967
# ── Full lifecycle ───────────────────────────────────────────────────
28632968

0 commit comments

Comments
Β (0)