Skip to content

Commit 8bd0862

Browse files
kingpanther13claude
andcommitted
fix: judge conflicts by named probes first and polish diagnosis edges
Patch76 review round: - A named compliance probe now outranks a synthesized successor: a verdict must never be decided by a version the probe invented while one the violated specifier names exists (mcp<=1.24.0 admits the 1.24.0 floor and cannot hold the package below it, but rejecting the invented 1.24.0.0.1 got it blamed). Successors still stand in when no named seed survives the filter. - The passthrough branch of _worker_startup_failure gains the test that goes red on regression: a worker-composed EmbeddedServerError must surface verbatim, kind intact, without the doubled prefix. - The direct-reference audit branch requires a readable version, so a version-less installed dist no longer renders as "not installed". - The conflict action's plural counts distinct domains, not pin entries: one integration pinning two violated packages is one integration to remove. - _apply_embedded_only_skip carries the same annotations as the _apply_haos_tls_skip precedent it cites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QiuyTYBQ2opBVPp8xzMq3r
1 parent b6295c1 commit 8bd0862

4 files changed

Lines changed: 94 additions & 23 deletions

File tree

custom_components/ha_mcp_tools/dependency_diagnostics.py

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,12 @@ def audit_dependency_graph(
160160
# present-but-different origin stays unjudged, since pip
161161
# normalizes URLs and a re-hosted identical artifact is fine).
162162
if requirement.url is not None:
163-
unsatisfied = not _dist_has_direct_url(child_dist)
163+
# version non-None on this branch too: a dist whose METADATA
164+
# yields no version would otherwise render as "not installed"
165+
# in the violation sentence (Patch76 review on #2245).
166+
unsatisfied = version is not None and not _dist_has_direct_url(
167+
child_dist
168+
)
164169
else:
165170
unsatisfied = version is not None and not _specifier_allows(
166171
requirement, version
@@ -313,23 +318,30 @@ def _compliance_probes(violated: str) -> list[str]:
313318
"""Version literals a compliant install could sit at, read from ``violated``.
314319
315320
The lower-bound-ish clauses (``>=``, ``==``, ``===``, ``~=``, ``>``) seed
316-
the candidates — the version itself plus a nearby higher successor, so a
317-
seed the specifier itself rejects (an exclusive ``>`` bound, a ``!=``
318-
exclusion sitting on the floor) still leaves a compliant probe; upper
319-
bounds only exclude. A wildcard pin's trailing ``.*`` is stripped so the
320-
candidate parses as a version. Every candidate is then checked against
321-
the FULL violated specifier before it may serve as a probe: a probe that
322-
violates the specifier would acquit exactly the pin that preserves the
323-
conflict, and an empty probe list flips the caller conservative, blaming
324-
integrations that are compatible (both CodeRabbit on #2245: ``>1.24``
325-
probed with ``1.24`` acquitted a ``==1.24`` pinner, and
326-
``>=1.24,!=1.24`` yielded no probe at all).
321+
the candidates; upper bounds only exclude. A wildcard pin's trailing
322+
``.*`` is stripped so the candidate parses as a version, and every
323+
candidate is checked against the FULL violated specifier before it may
324+
serve as a probe: a probe that violates the specifier would acquit
325+
exactly the pin that preserves the conflict, and an empty probe list
326+
flips the caller conservative, blaming integrations that are compatible
327+
(both CodeRabbit on #2245: ``>1.24`` probed with ``1.24`` acquitted a
328+
``==1.24`` pinner, and ``>=1.24,!=1.24`` yielded no probe at all).
329+
330+
NAMED versions outrank synthesized ones: when any seed the violated
331+
specifier itself names survives the filter, only those serve as probes,
332+
and the nearby higher successors stand in solely when none do (an
333+
exclusive ``>`` bound, a floor exclusion). A verdict must never be
334+
decided by a version the probe invented while a named one exists —
335+
``mcp<=1.24.0`` admits the named floor of ``mcp<2.0,>=1.24.0`` and
336+
cannot hold the package below it, but rejecting the synthesized
337+
``1.24.0.0.1`` used to get it blamed (Patch76 review on #2245).
327338
"""
328339
try:
329340
specifier = Requirement(violated).specifier
330341
except InvalidRequirement:
331342
return []
332-
candidates: list[str] = []
343+
named: list[str] = []
344+
synthesized: list[str] = []
333345
for clause in specifier:
334346
if clause.operator not in (">=", "==", "===", "~=", ">"):
335347
continue
@@ -338,17 +350,22 @@ def _compliance_probes(violated: str) -> list[str]:
338350
parsed = Version(base)
339351
except InvalidVersion:
340352
continue
341-
candidates += [base, _successor(parsed)]
342-
probes: list[str] = []
343-
for candidate in candidates:
344-
if candidate in probes:
345-
continue
353+
named.append(base)
354+
synthesized.append(_successor(parsed))
355+
356+
def _surviving(candidates: list[str]) -> list[str]:
346357
# contains() answers False for a candidate it cannot parse (the
347358
# same quirk _specifier_allows documents), so a successor shape
348359
# that fails to parse is filtered here, never raised.
349-
if specifier.contains(candidate, prereleases=True):
350-
probes.append(candidate)
351-
return probes
360+
probes: list[str] = []
361+
for candidate in candidates:
362+
if candidate not in probes and specifier.contains(
363+
candidate, prereleases=True
364+
):
365+
probes.append(candidate)
366+
return probes
367+
368+
return _surviving(named) or _surviving(synthesized)
352369

353370

354371
def _successor(version: Version) -> str:
@@ -622,7 +639,11 @@ def _action_sentence(
622639
) -> str:
623640
"""The closing instruction, scaled to how much the diagnosis identified."""
624641
if pinners:
625-
subject = "integration" if len(pinners) == 1 else "integrations"
642+
# Distinct domains, not entries: one integration pinning two
643+
# violated packages is still one integration to update or remove
644+
# (Patch76 review on #2245).
645+
domains = {pinner.domain for pinner in pinners}
646+
subject = "integration" if len(domains) == 1 else "integrations"
626647
# The reinstall clause is load-bearing on the no-install fast path
627648
# (a pinned server spec, or auto-update off): removing the
628649
# integration deletes its pin but restores nothing, and the next

tests/src/e2e/conftest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,9 @@ def _apply_haos_tls_skip(item: Any, enabled: bool, skip_marker: Any) -> None:
259259
item.add_marker(skip_marker)
260260

261261

262-
def _apply_embedded_only_skip(item, embedded_selected, skip_marker):
262+
def _apply_embedded_only_skip(
263+
item: Any, embedded_selected: bool, skip_marker: Any
264+
) -> None:
263265
"""Skip an ``embedded_only`` item everywhere but the embedded lane.
264266
265267
Split out of ``pytest_collection_modifyitems`` for the same reason as

tests/src/unit/test_dependency_diagnostics.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,16 @@ def test_wildcard_floor_still_probes(self):
381381
assert not requirement_forces_conflict("mcp>=1", wildcard)
382382
assert requirement_forces_conflict("mcp==1.9.0", wildcard)
383383

384+
def test_upper_bound_admitting_the_named_floor_is_innocent(self):
385+
"""Patch76 review on #2245: a named probe outranks a synthesized one.
386+
387+
``mcp<=1.24.0`` admits the violated spec's own floor, so enforcing
388+
it can never hold the package below that floor — blaming it for
389+
rejecting the invented successor 1.24.0.0.1 pointed the user at an
390+
integration that cannot be the culprit.
391+
"""
392+
assert not requirement_forces_conflict("mcp<=1.24.0", self._VIOLATION)
393+
384394
def test_inactive_environment_marker_is_innocent(self):
385395
"""CodeRabbit on #2245: HA never installs a marker-inactive requirement."""
386396
assert not requirement_forces_conflict(
@@ -668,6 +678,23 @@ def test_several_pinners_read_as_plural(self):
668678
assert "one" in message
669679
assert "two" in message
670680

681+
def test_one_domain_with_two_pins_reads_as_singular(self):
682+
"""Patch76 review on #2245: one integration pinning two violated
683+
packages is still one integration to update or remove."""
684+
message = describe_dependency_failure(
685+
None,
686+
[],
687+
[
688+
PinningIntegration(domain="one", name="One", requirement="mcp==1.0"),
689+
PinningIntegration(
690+
domain="one", name="One", requirement="websockets==9.0"
691+
),
692+
],
693+
)
694+
695+
assert "conflicting integration," in message
696+
assert "conflicting integrations" not in message
697+
671698
def test_root_exception_alone_still_yields_an_action(self):
672699
message = describe_dependency_failure(ImportError(_ICON_IMPORT_ERROR), [], [])
673700

tests/src/unit/test_embedded_server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2185,6 +2185,27 @@ async def test_wait_ready_raises_on_early_thread_crash(self, tmp_path):
21852185
with pytest.raises(es.EmbeddedServerError, match="worker thread crashed"):
21862186
await mgr._async_wait_until_ready()
21872187

2188+
async def test_wait_ready_surfaces_worker_composed_error_verbatim(self, tmp_path):
2189+
"""A worker-composed EmbeddedServerError passes through UNWRAPPED.
2190+
2191+
The passthrough branch of _worker_startup_failure carries the
2192+
dependency diagnosis and its failure kind; re-wrapping is what
2193+
produced the doubled "failed to start:" prefix (#2239, Patch76
2194+
review: this branch had no test that goes red on regression).
2195+
"""
2196+
mgr, hass, _entry = _manager(tmp_path)
2197+
hass.loop.time = MagicMock(return_value=0.0)
2198+
composed = es.EmbeddedServerError(
2199+
"Installed mcp 1.14.1 does not satisfy 'mcp>=1.24.0'.",
2200+
kind="package",
2201+
)
2202+
mgr._thread_exc = composed
2203+
with pytest.raises(es.EmbeddedServerError) as excinfo:
2204+
await mgr._async_wait_until_ready()
2205+
assert excinfo.value is composed
2206+
assert excinfo.value.kind == "package"
2207+
assert "failed to start" not in str(excinfo.value)
2208+
21882209
async def test_wait_ready_raises_when_thread_exited(self, tmp_path):
21892210
mgr, hass, _entry = _manager(tmp_path)
21902211
hass.loop.time = MagicMock(return_value=0.0)

0 commit comments

Comments
 (0)