Two broken adapter/profile distributions that publish the same entry-point name collapse to a single recorded error, so bmad-loop adapters and bmad-loop validate report one failure where there are two.
Where
Both external-load error maps key on the entry-point name alone:
src/bmad_loop/adapters/registry.py:244 — _EXTERNAL_ERRORS[ep.name] = f"{type(exc).__name__}: {exc}"
src/bmad_loop/adapters/profile.py:519 — _PROFILE_LOAD_ERRORS[ep.name] = f"{type(exc).__name__}: {exc}"
Why it bites
importlib.metadata.entry_points(group=...) does not deduplicate across distributions — two installed packages may both publish bmad_loop.adapters = acme. This was proved directly while reviewing #239 (real *.dist-info on both 3.11 and 3.13): a same-name tie is resolved by sys.path order, and both entry points are yielded by the scan.
So when two same-named distributions are both broken, the scan writes _EXTERNAL_ERRORS["acme"] twice and the second write wins. The operator sees one warning: external adapter 'acme' failed to load: ... and fixes it, only to hit the second package's failure on the next run with no indication it was ever there. The recorded-degrade contract — a failure is never a crash, but it is always surfaced — is silently under-delivered.
Both accessors are affected identically: external_adapter_errors() (registry) and external_profile_errors() (profile).
Why this was not folded into #239
The entry-point name is not just a dict key — it is the value of entry_point in the validate finding detail:
report.warn(
"adapter.external",
f"external adapter '{ep_name}' failed to load: {reason}",
{"entry_point": ep_name, "error": reason},
)
(cli.py, for both adapter.external and adapter.external-profile), and it appears verbatim in the human-readable warning. detail is part of the --json contract in machine.py, so changing the key's shape is a compatibility call that deserves its own change rather than riding along on the registry seam.
Candidate shapes
- Key on
(dist, name) and render as dist:name — most informative, biggest --json impact.
- Append reasons instead of overwriting — keeps the key stable, so no contract change; the
detail string just carries both reasons.
- Add the distribution to the human message only, leaving
detail untouched — smallest change, no contract impact, but --json consumers still see one row.
(2) and (3) are contract-preserving; (1) is not. Worth deciding explicitly.
Acceptance
Two same-named entry points from different distributions, both failing to load ⇒ both reasons reach external_adapter_errors() / external_profile_errors() and both surface through bmad-loop adapters and bmad-loop validate.
Ablation: restore the single-key write and confirm the new test fails — a test that merely asserts "two reasons present" can pass for the wrong reason if the fixture accidentally uses distinct names.
Related
Two broken adapter/profile distributions that publish the same entry-point name collapse to a single recorded error, so
bmad-loop adaptersandbmad-loop validatereport one failure where there are two.Where
Both external-load error maps key on the entry-point name alone:
src/bmad_loop/adapters/registry.py:244—_EXTERNAL_ERRORS[ep.name] = f"{type(exc).__name__}: {exc}"src/bmad_loop/adapters/profile.py:519—_PROFILE_LOAD_ERRORS[ep.name] = f"{type(exc).__name__}: {exc}"Why it bites
importlib.metadata.entry_points(group=...)does not deduplicate across distributions — two installed packages may both publishbmad_loop.adapters = acme. This was proved directly while reviewing #239 (real*.dist-infoon both 3.11 and 3.13): a same-name tie is resolved bysys.pathorder, and both entry points are yielded by the scan.So when two same-named distributions are both broken, the scan writes
_EXTERNAL_ERRORS["acme"]twice and the second write wins. The operator sees onewarning: external adapter 'acme' failed to load: ...and fixes it, only to hit the second package's failure on the next run with no indication it was ever there. The recorded-degrade contract — a failure is never a crash, but it is always surfaced — is silently under-delivered.Both accessors are affected identically:
external_adapter_errors()(registry) andexternal_profile_errors()(profile).Why this was not folded into #239
The entry-point name is not just a dict key — it is the value of
entry_pointin the validate findingdetail:(
cli.py, for bothadapter.externalandadapter.external-profile), and it appears verbatim in the human-readable warning.detailis part of the--jsoncontract inmachine.py, so changing the key's shape is a compatibility call that deserves its own change rather than riding along on the registry seam.Candidate shapes
(dist, name)and render asdist:name— most informative, biggest--jsonimpact.detailstring just carries both reasons.detailuntouched — smallest change, no contract impact, but--jsonconsumers still see one row.(2) and (3) are contract-preserving; (1) is not. Worth deciding explicitly.
Acceptance
Two same-named entry points from different distributions, both failing to load ⇒ both reasons reach
external_adapter_errors()/external_profile_errors()and both surface throughbmad-loop adaptersandbmad-loop validate.Ablation: restore the single-key write and confirm the new test fails — a test that merely asserts "two reasons present" can pass for the wrong reason if the fixture accidentally uses distinct names.
Related
registry.pyis added whole; the_PROFILE_LOAD_ERRORSwrites are new). Found during its review and deliberately deferred for the contract reason above, not because it predates the PR.register_multiplexerhas the same lazy-builtins/first-wins shape; that one is reachable pre-feat(adapters): coding-CLI adapter registry (Seam A) — out-of-tree adapters with zero core edits #239 via a plugin[python]module. Distinct bug, already filed.