fix(bundler): read the authoritative default_integration field, not only its legacy aliases (#3880)

* fix(bundler): read the authoritative default_integration field

`active_integration()` resolves a project's integration with

    data.get("integration") or data.get("id") or data.get("active")

and never looks at `default_integration` — which is the key the CLI
actually writes. `integration_state.set_default_integration` persists
`data["default_integration"] = integration_key`, and the canonical
reader in that module orders it the other way round:

    key = state.get("default_integration") or state.get("integration")

So a project initialised by any current version of the CLI looks to the
bundler as though it has no active integration:

    {"default_integration": "copilot"}  -> None      (expected "copilot")
    {"integration": "copilot"}          -> "copilot" (legacy alias)

That silently changes bundler behaviour that keys off the active
integration, including the FR-019 clash guard, which treats an
undeterminable integration differently from a known one.

Read `default_integration` first and keep the three legacy aliases as
fallbacks for projects initialised by older versions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(bundler): correct the justification for reading default_integration

Review catch: the comment cited a nonexistent
`integration_state.set_default_integration` and overstated the impact.
The real writer is `write_integration_json`, which persists BOTH
`integration` and `default_integration` (integration_state.py:248-250), so
a marker produced by the current CLI already resolved through the
`integration` alias. Measured:

  {"integration": "copilot", "default_integration": "copilot"} -> 'copilot'
  {"default_integration": "copilot"}                           -> 'copilot' (after fix)

Reword both the source comment and the test docstring: this is about which
field is authoritative when they disagree, plus resolving a marker that
carries only `default_integration` — not about every current project being
undetectable. The precedence itself still has its precedent, the canonical
reader at integration_state.py:199.

Behaviour unchanged; comments and docstrings only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali jawwad
2026-08-13 22:19:58 +05:00
committed by GitHub
parent b66044ae8e
commit bfabf4ce65
2 changed files with 59 additions and 1 deletions
+15 -1
View File
@@ -82,7 +82,21 @@ def active_integration(project_root: Path) -> str | None:
except BundlerError:
return None
if isinstance(data, dict):
value = data.get("integration") or data.get("id") or data.get("active")
# ``default_integration`` first, matching the canonical reader in
# ``integration_state`` (line 199):
# ``state.get("default_integration") or state.get("integration")``.
# ``write_integration_json`` writes both keys, so a marker produced by
# the current CLI already resolved through the ``integration`` alias --
# this is about which field is authoritative when they disagree, and
# about resolving a marker that carries only ``default_integration``
# (hand-edited, or written by anything that follows the canonical
# reader's shape). ``integration``/``id``/``active`` stay as fallbacks.
value = (
data.get("default_integration")
or data.get("integration")
or data.get("id")
or data.get("active")
)
if isinstance(value, str) and value:
return value
return None
@@ -126,6 +126,50 @@ def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
assert active_integration(project) is None
def _write_marker(tmp_path: Path, payload: str) -> Path:
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
(project / ".specify" / "integration.json").write_text(
payload, encoding="utf-8"
)
return project
def test_active_integration_reads_default_integration(tmp_path: Path):
"""A marker carrying only ``default_integration`` must resolve.
``write_integration_json`` writes both ``integration`` and
``default_integration``, so a marker produced by the current CLI already
resolved through the alias. This covers the authoritative field on its own —
hand-edited, or written by anything that follows the shape of the canonical
reader (``integration_state`` line 199:
``state.get("default_integration") or state.get("integration")``).
"""
from specify_cli.bundler.lib.project import active_integration
project = _write_marker(tmp_path, '{"default_integration": "copilot"}')
assert active_integration(project) == "copilot"
def test_active_integration_prefers_default_over_legacy_alias(tmp_path: Path):
"""When both are present the authoritative field wins, matching
``integration_state``'s own ordering."""
from specify_cli.bundler.lib.project import active_integration
project = _write_marker(
tmp_path, '{"integration": "stale", "default_integration": "copilot"}'
)
assert active_integration(project) == "copilot"
def test_active_integration_still_reads_legacy_alias(tmp_path: Path):
"""Projects initialised by older versions carry only ``integration``."""
from specify_cli.bundler.lib.project import active_integration
project = _write_marker(tmp_path, '{"integration": "copilot"}')
assert active_integration(project) == "copilot"
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
from specify_cli.bundler.commands_impl import catalog_config as cc