Skip to content

Commit da59aae

Browse files
jawwad-aliclaude
andauthored
fix(bundler): resolve the active integration like the canonical reader (#4541)
* fix(bundler): resolve the active integration like the canonical reader `active_integration`'s own comment says it matches the canonical reader in `integration_state`, but it diverged from it in three ways: 1. It only checked `isinstance(value, str) and value`, while the canonical reader runs every value through `clean_integration_key`. A whitespace-only key is truthy, so it was returned as a real integration and suppressed the "not determinable" fallback; a padded key was returned verbatim and matches no registered integration. 2. Normalizing only the first truthy raw field loses a valid legacy key behind a blank `default_integration`: {"default_integration": " ", "integration": "copilot"} raw-then-clean -> None canonical -> 'copilot' Each candidate is now cleaned before it is selected. 3. Installed-only state (`installed_integrations` populated, no default recorded) returned None, while `normalize_integration_state` promotes `installed_integrations[0]`. None tells `bundle install` / `bundle update` the integration "cannot be determined", which lets an explicit `--integration` bypass the FR-019 integration-clash guard. The fallback is consulted last, so no marker that already resolved changes. Tests live beside the existing `active_integration` tests in tests/specify_cli/bundles/test_security_paths.py; the installed-only cases assert agreement with `default_integration_key(normalize_integration_state())`. Rebased onto current main (files moved in the workflow/bundler restructure). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(bundler): name the full canonical path active_integration matches The comment called `integration_state.default_integration_key` the canonical reader, but that helper expects normalized state: on a raw marker its `state.get("default_integration") or state.get("integration")` picks a whitespace-only default over a valid legacy key, and it ignores `installed_integrations`. What `active_integration` matches is `normalize_integration_state` followed by `default_integration_key`: raw dk(raw) dk(normalize(raw)) {"default_integration": " ", "integration": "copilot"} None 'copilot' {"installed_integrations": ["claude"]} None 'claude' Say so in the comment and in the two test docstrings that referred to "the canonical reader". Comment/docstring-only change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 961be9c commit da59aae

2 files changed

Lines changed: 163 additions & 17 deletions

File tree

‎src/specify_cli/bundles/project.py‎

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55

66
from .._project import _resolve_init_dir_override
7+
from ..integration_state import clean_integration_key, dedupe_integration_keys
78
from . import BundlerError
89
from .yamlio import ensure_within, load_json
910

@@ -82,21 +83,44 @@ def active_integration(project_root: Path) -> str | None:
8283
except BundlerError:
8384
return None
8485
if isinstance(data, dict):
85-
# ``default_integration`` first, matching the canonical reader in
86-
# ``integration_state`` (line 199):
87-
# ``state.get("default_integration") or state.get("integration")``.
88-
# ``write_integration_json`` writes both keys, so a marker produced by
89-
# the current CLI already resolved through the ``integration`` alias --
90-
# this is about which field is authoritative when they disagree, and
91-
# about resolving a marker that carries only ``default_integration``
92-
# (hand-edited, or written by anything that follows the canonical
93-
# reader's shape). ``integration``/``id``/``active`` stay as fallbacks.
94-
value = (
95-
data.get("default_integration")
96-
or data.get("integration")
97-
or data.get("id")
98-
or data.get("active")
99-
)
100-
if isinstance(value, str) and value:
101-
return value
86+
# Resolve the way the canonical path does: normalize first
87+
# (``normalize_integration_state``), then read the default
88+
# (``default_integration_key``). ``default_integration_key`` alone is
89+
# not that reader -- it expects *normalized* state, and on a raw marker
90+
# its ``state.get("default_integration") or state.get("integration")``
91+
# picks a whitespace-only default (truthy) over a valid legacy key
92+
# behind it.
93+
#
94+
# ``default_integration`` is authoritative; ``integration`` (the legacy
95+
# alias ``write_integration_json`` also writes), then ``id``/``active``,
96+
# are fallbacks. Clean EACH candidate before selecting it, as
97+
# ``normalize_integration_state`` does with
98+
# ``clean_integration_key(data.get("default_integration")) or
99+
# legacy_key``, rather than picking the first truthy raw value and
100+
# normalizing only that one:
101+
# {"default_integration": " ", "integration": "copilot"}
102+
# raw-then-clean -> None canonical -> 'copilot'
103+
#
104+
# Normalizing through the shared helper also fixes the original
105+
# divergence: ``isinstance(value, str) and value`` accepted a
106+
# whitespace-only key as real -- truthy, so it suppressed the "not
107+
# determinable" fallback -- and returned a padded key verbatim, which
108+
# matches no registered integration.
109+
for field in ("default_integration", "integration", "id", "active"):
110+
cleaned = clean_integration_key(data.get(field))
111+
if cleaned:
112+
return cleaned
113+
# Installed-only state -- ``installed_integrations`` populated but no
114+
# default recorded -- still has an active integration: the canonical
115+
# ``normalize_integration_state`` promotes ``installed_integrations[0]``
116+
# to the default. Returning None here instead told callers the
117+
# integration "cannot be determined", which lets an explicit
118+
# ``--integration`` bypass the FR-019 integration-clash guard in
119+
# ``bundle install`` / ``bundle update``. Checked last, so a marker
120+
# that already resolved through the fields above is unaffected.
121+
installed = data.get("installed_integrations")
122+
if isinstance(installed, list):
123+
installed_keys = dedupe_integration_keys(installed)
124+
if installed_keys:
125+
return installed_keys[0]
102126
return None

‎tests/specify_cli/bundles/test_security_paths.py‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66
from __future__ import annotations
77

8+
import json
89
import os
910
from pathlib import Path
1011

@@ -170,6 +171,127 @@ def test_active_integration_still_reads_legacy_alias(tmp_path: Path):
170171
assert active_integration(project) == "copilot"
171172

172173

174+
@pytest.mark.parametrize(
175+
"recorded,expected",
176+
[
177+
("copilot", "copilot"),
178+
(" copilot ", "copilot"), # padded: previously returned verbatim
179+
(" ", None), # whitespace-only: previously truthy
180+
("\t\n", None),
181+
("", None),
182+
(None, None),
183+
(5, None),
184+
],
185+
ids=["plain", "padded", "spaces", "tabs", "empty", "null", "non_string"],
186+
)
187+
def test_active_integration_matches_the_canonical_key_reader(
188+
tmp_path: Path, recorded, expected
189+
):
190+
"""`active_integration` must normalize the way the canonical reader does.
191+
192+
The canonical path -- `normalize_integration_state`, then
193+
`default_integration_key` -- runs every candidate through
194+
`clean_integration_key`, while this one only checked
195+
`isinstance(value, str) and value`. A whitespace-only key
196+
is truthy, so it was returned as a real integration *and* suppressed the
197+
"not determinable" fallback; a padded key was returned verbatim and
198+
matches no registered integration.
199+
"""
200+
from specify_cli.bundles.project import active_integration
201+
202+
project = _write_marker(tmp_path, json.dumps({"default_integration": recorded}))
203+
assert active_integration(project) == expected
204+
205+
206+
@pytest.mark.parametrize(
207+
"recorded,expected",
208+
[
209+
({"default_integration": " ", "integration": "copilot"}, "copilot"),
210+
({"default_integration": "\t\n", "integration": " copilot "}, "copilot"),
211+
({"default_integration": 5, "integration": "copilot"}, "copilot"),
212+
({"integration": " ", "id": "claude"}, "claude"),
213+
({"default_integration": " ", "integration": " "}, None),
214+
({"default_integration": "cursor", "integration": "copilot"}, "cursor"),
215+
],
216+
ids=[
217+
"blank_default",
218+
"blank_default_padded_legacy",
219+
"non_string_default",
220+
"blank_legacy_falls_to_id",
221+
"all_blank",
222+
"precedence_kept",
223+
],
224+
)
225+
def test_active_integration_cleans_each_candidate_before_selecting(
226+
tmp_path: Path, recorded, expected
227+
):
228+
"""Each candidate must be cleaned before selection, not just the winner.
229+
230+
A raw `or` chain selects a whitespace-only `default_integration` (truthy)
231+
and then normalizes it to None, losing the valid legacy key behind it --
232+
while `normalize_integration_state` does
233+
`clean_integration_key(default) or legacy_key` and falls through.
234+
"""
235+
from specify_cli.bundles.project import active_integration
236+
237+
project = _write_marker(tmp_path, json.dumps(recorded))
238+
assert active_integration(project) == expected
239+
240+
241+
@pytest.mark.parametrize(
242+
"recorded,expected",
243+
[
244+
({"installed_integrations": ["claude", "copilot"]}, "claude"),
245+
({"installed_integrations": [" ", " claude "]}, "claude"),
246+
({"installed_integrations": []}, None),
247+
({"installed_integrations": "claude"}, None),
248+
],
249+
ids=["installed_only", "blank_first_entry", "empty_list", "non_list"],
250+
)
251+
def test_active_integration_resolves_installed_only_state(
252+
tmp_path: Path, recorded, expected
253+
):
254+
"""Installed-only state resolves exactly as the canonical reader does
255+
(``normalize_integration_state``, then ``default_integration_key``).
256+
257+
With ``installed_integrations`` populated but no default recorded,
258+
``normalize_integration_state`` promotes the first installed key to the
259+
default. ``active_integration`` returned None instead -- "cannot be
260+
determined" -- which lets an explicit ``--integration`` bypass the FR-019
261+
integration-clash guard in ``bundle install`` / ``bundle update``.
262+
"""
263+
from specify_cli.bundles.project import active_integration
264+
from specify_cli.integration_state import (
265+
default_integration_key,
266+
normalize_integration_state,
267+
)
268+
269+
project = _write_marker(tmp_path, json.dumps(recorded))
270+
assert active_integration(project) == expected
271+
assert expected == default_integration_key(normalize_integration_state(recorded))
272+
273+
274+
@pytest.mark.parametrize(
275+
"recorded,expected",
276+
[
277+
({"integration": "copilot", "installed_integrations": ["claude"]}, "copilot"),
278+
({"id": "cursor", "installed_integrations": ["claude"]}, "cursor"),
279+
],
280+
ids=["recorded_default_wins", "legacy_id_still_wins"],
281+
)
282+
def test_active_integration_installed_fallback_is_checked_last(
283+
tmp_path: Path, recorded, expected
284+
):
285+
"""The installed-only fallback must not change any marker that already
286+
resolved: it is consulted only after every recorded field, so it can turn
287+
a None into a key but never replace a key this function already returned.
288+
"""
289+
from specify_cli.bundles.project import active_integration
290+
291+
project = _write_marker(tmp_path, json.dumps(recorded))
292+
assert active_integration(project) == expected
293+
294+
173295
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
174296
from specify_cli.bundles import catalog_config as cc
175297

0 commit comments

Comments
 (0)