Skip to content

fix(cli): a batch a CDN refuses is split before it falls back - #43

Merged
mhenrixon merged 1 commit into
mainfrom
fix/batch-resolver-isolation
Sep 19, 2026
Merged

mhenrixon merged 1 commit into
mainfrom
fix/batch-resolver-isolation

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

A CDN answers a batch of packages as a whole, so one spec its generator can't build was a "no" for every package beside it. bin/importmap update with three outdated packages printed Couldn't find any packages in ["cheap-ruler", "mapbox-gl", "mermaid"] on jspm, updated nothing and exited 0.

The provider chain made the other half worse: it handed the whole spec list to the next CDN, so a healthy md5 was silently re-pinned from esm.run and had its provenance comment rewritten because mermaid failed beside it.

Batch first, then split. A refused batch holding more than one spec is now asked for one spec at a time, along the path that spec would have taken alone — through the chain when the group named no CDN, of the same provider once when it did. Only the package the CDN actually refused travels the rest of the chain.

$ bin/importmap pin md5@2.2.0 mermaid@10.6.0
jspm couldn't resolve "md5@2.2.0", "mermaid@10.6.0" (No './dist/cytoscape.umd.js' exports subpath defined in cytoscape@3.34.3); asking for each on its own
Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js
jspm couldn't resolve "mermaid@10.6.0" (No './dist/cytoscape.umd.js' exports subpath defined in cytoscape@3.34.3); trying esm.run
Pinning "mermaid" to vendor/javascript/mermaid.js via download from https://cdn.jsdelivr.net/npm/mermaid@10.6.0/+esm
pin "md5" # @2.2.0
pin "mermaid" # @10.6.0 (esm.run)

pin, update and pristine now exit 1 when a package was left unresolved; the packages that did resolve are still written.

The batch stays the fast path — one round trip, and jspm still resolves the specs' shared dependencies as a single graph — because it is split only on refusal, and only when it holds more than one spec.

Files

  • lib/importmap/batch_resolver.rbnew, fork-only. All the new behaviour, because commands.rb is upstream-owned and packager.rb is at its size limit.
  • lib/importmap/commands.rb — net −12 lines: a require, a one-line delegation in for_each_import_grouped_by_provider, a memoised resolver reader, and three || resolver.unresolved.any? clauses. for_each_import_with_fallback (fork-added) is deleted; upstream's for_each_import, handle_package_not_found and unpin are untouched.
  • lib/importmap/packager.rbunchanged.
  • Docs: pinning.rb, updating.rb, cli.rb, upgrading.rb
  • CHANGELOG.md under the existing ## 1.2.0### Fixed. VERSION is not bumped — 1.2.0 is already in version.rb and unreleased.

Closes #32
Closes #34

Test plan

  • bundle exec rake test — 459 runs, 0 failures, with bun installed so the --minify cases ran (2 runs, 0 skips)
  • bundle exec ruby -Itest test/batch_resolver_test.rb — 9 new cases, each seen failing first; every case asserts both what was yielded and what was recorded unresolved
  • bundle exec ruby -Itest test/provider_chain_test.rb — unchanged, green (ProviderChain's contract is not touched)
  • cd docs && bundle exec rake lint && bundle exec rspec — 53 files clean, 54 specs pass
  • Manual, in a copy of test/dummy:
    • bin/importmap pin md5@2.2.0 mermaid@10.6.0 → exit 0, pin "md5" # @2.2.0 (no provider — jspm), pin "mermaid" # @10.6.0 (esm.run), both vendored
    • bin/importmap pin md5@2.2.0 mermaid@10.6.0 --from jspmexit 1, md5 still pinned, mermaid absent, no trying esm.run in the output

Three existing live cases now exit 1 and were switched to run_importmap_command_expecting_failure; their assertions are unchanged.

No live update case: a live update targets the registry's moving latest, so whether jspm can build it is not a stable contract. update shares the code path and is covered by the unit tests.

Deviations & judgment calls

Discoveries

Judgment calls

  • on_miss: callable, not a class method. The issue offered either. A callable leaves handle_package_not_found — upstream-owned — completely untouched, so the "couldn't find" sentence still lives in exactly one place.
  • A Packager::Error on the batch splits; it isn't only a 401/404 that does. In fallback mode the batch is asked of jspm directly rather than through the chain, so a jspm transport failure has to reach esm.run somehow — splitting sends each spec down the chain, which handles errors properly. A named-provider batch splits for the same reason: a transport failure is evidence about the run, not about any one spec.
  • resolve_one rescues Packager::Error for a single-spec group too. The issue scoped the rescue to "the specs after it". Extending it means a raised error becomes Couldn't resolve "x" from jspm: … plus exit 1 instead of a Thor backtrace, and one provider group can no longer abort the next.
  • The shared-dependency rules (@named / @seen) are scoped per group, not per run. The issue's rule is about split responses, which happen inside one group. Per-run scoping would silently change how two provider groups that share a dependency behave today — update of photoswipe + photoswipe/lightbox is exactly that shape.
  • version() falls back to the whole URL when a URL carries no @x.y.z, so the Keeping … sentence can't print at with nothing after it.
  • FakePackager was not extracted into a shared file. provider_chain_test.rb's is keyed by provider; rekeying its thirteen cases to [ from, specs ] is noise for no gain. batch_resolver_test.rb has its own, which delegates package_key_for and extract_package_version_from to a real Packager so the fake can't drift from it.

https://claude.ai/code/session_01B8NAp4J4RS7BBUDDQtRA7W


Summary by cubic

Fixes batch package resolution so a CDN refusing one spec no longer blocks (or silently moves) the rest. Previously the whole batch was answered as one request, so one unresolvable spec failed every package with exit 0; now the batch is split on refusal and only the refused spec falls back.

Behavior

  • The split asks for each spec on its own; the refused one travels the fallback chain, others stay on their CDN.
  • pin, update, and pristine exit 1 when any package is left unresolved, while still writing the ones that succeeded.
  • The batch stays the fast path since it is split only on refusal.
  • Docs and changelog describe the new exit codes and split behavior.

Written for commit 6fbdf82. Summary will update on new commits.

Review in cubic

A CDN answers a batch of packages as a whole, so one spec its generator
can't build was a "no" for every package beside it: `bin/importmap update`
with three outdated packages reported `Couldn't find any packages in
["cheap-ruler", "mapbox-gl", "mermaid"] on jspm`, updated nothing and exited
0. Commenting out the one package let the other two through.

The provider chain made half of that worse rather than better. It handed the
whole spec list to the next CDN, so `md5` — vendored from jspm and perfectly
healthy — was silently re-pinned from esm.run and had its provenance comment
rewritten because `mermaid` failed beside it.

A refused batch holding more than one spec is now asked for one spec at a
time, along the path that spec would have taken alone: through the chain when
the group named no CDN, of the same provider once when it did. Only the
package the CDN actually refused travels the rest of the chain. The batch
stays the fast path — one round trip, and jspm still resolves the specs'
shared dependencies as a single graph — because it is split only on refusal.

`pin`, `update` and `pristine` exit 1 when a package was left unresolved, so
`bin/importmap update && git commit` can't commit an import map that quietly
missed one. The packages that did resolve are still written.

The work lives in Importmap::BatchResolver, a fork-only collaborator, because
commands.rb is upstream-owned and packager.rb is at its size limit. commands.rb
is a require, a delegation, a memoised reader and three `||` clauses shorter
than it was; upstream's for_each_import and unpin are untouched.

## Test coverage
- test/batch_resolver_test.rb: which requests each shape makes, that the
  refused spec alone changes CDN, that an unreachable CDN for one spec doesn't
  stop the next, and both halves of the shared-dependency rule
- test/commands_test.rb: the live jspm contract for the split, both with the
  fallback chain and with --from jspm, including the exit code and the
  provenance comment each package ends up with

Refs #34
Fixes #32

Claude-Session: https://claude.ai/code/session_01B8NAp4J4RS7BBUDDQtRA7W
@mhenrixon mhenrixon self-assigned this Sep 19, 2026
@mhenrixon
mhenrixon merged commit 79794cf into main Sep 19, 2026
47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(cli): one package a CDN can't resolve no longer blocks or moves the rest of the batch Update fails if 1 out of 3 packages is not found

1 participant