Skip to content

fix(cli): resolve and install transitive registry dependencies#1396

Merged
jrusso1020 merged 3 commits into
mainfrom
fix/cli-transitive-registry-deps
Jun 13, 2026
Merged

fix(cli): resolve and install transitive registry dependencies#1396
jrusso1020 merged 3 commits into
mainfrom
fix/cli-transitive-registry-deps

Conversation

@jrusso1020

Copy link
Copy Markdown
Collaborator

What

Resolve and install transitive registryDependencies for registry items. Adds resolveItemWithDependencies (topological DFS) and routes all three single-item install paths through it: hyperframes add, hyperframes new (fetchRemoteTemplate), and the Studio "add block" action.

This is a reworked, conflict-free, review-feedback-addressed version of #414 — which is stale (branched off an author fork's main, carries unrelated merge commits, has been merge-conflicting against main for ~7 weeks, and never addressed its two requested changes). This PR should replace and close #414.

Why

registryDependencies has been declared on RegistryItem (packages/core/src/registry/types.ts:62) and in the JSON schema for a while, with an explicit TODO in the resolver to walk it — but nothing did. Every install path resolved exactly one item and silently dropped any dependencies it declared:

No catalog item ships deps today, so this scaffolds the install path before they do (the correct order). The first dep-bearing item would otherwise produce a silently incomplete install with no error or warning.

How

  • resolveItemWithDependencies(name, options) in registry/resolver.ts: DFS topological sort (dependencies first, requested item last) with cycle detection (throws with the offending path), missing-dependency errors (names the absent item), an item cache, and diamond dedup (a shared dep appears exactly once). Deps are fetched serially — a comment notes this is deliberate to keep the cycle bookkeeping simple; the registry is small.
  • resolveItem becomes a thin guard: it throws if the item declares deps, pointing callers at the plural API. This removes the silent-discard footgun for all current and future single-item callers (directly addresses Resolve and install transitive registry Dependencies for registry items #414's review concern that the second caller — fetchRemoteTemplate — silently dropped deps).
  • runAdd resolves deps, compatibility-gates every item before any write, installs in topo order, and returns an ordered installed: string[]. Extracted assertCompatibleOrThrow / installAll helpers keep it under the complexity gate.
  • fetchRemoteTemplate and installRegistryBlock now install the full resolved list.

Review feedback from #414 (James + Vai), addressed here:

  • fetchRemoteTemplate no longer silently discards deps (required)
  • ✅ no out-of-scope IMPROVEMENT_OPPORTUNITIES.md / unrelated merge commits (required)
  • ✅ dead null-checks dropped (resolved[resolved.length - 1]!)
  • ✅ diamond-dependency test added
  • ✅ serial-fetch tradeoff documented in a comment
  • ➕ also fixed the third silent-discard path (installRegistryBlock), which the original PR didn't touch

I did not add a remote.test.ts dependency test: fetchRemoteTemplate can't pass skipCache, and the manifest/item cache is on-disk keyed by URL (fetchItemManifest reads it unconditionally), so such a test is flaky on any machine that's used the real CLI. The shared resolveItemWithDependencies engine — which fetchRemoteTemplate just loops over — is fully covered by the resolver tests.

Test plan

How was this tested?

  • Unit tests added/updated — resolver.test.ts: linear-chain ordering, single-item (no deps), diamond dedup, missing transitive dep, cycle detection, and the resolveItem throw-on-deps guard. add.test.ts: transitive deps installed before the requested item with the correct installed order + files on disk.
  • Full CLI suite green (bunx vitest run → 741 passing), plus tsc --noEmit, oxlint, oxfmt --check, and fallow audit --fail-on-issues all clean.
  • Manual testing performed — N/A (no catalog item declares deps yet; covered by unit tests against a mocked registry)

Co-authored with the original #414 author (@rakibulism), credited via Co-authored-by on the commit — the core DFS/cycle-detection algorithm is theirs.

🤖 Generated with Claude Code

`hyperframes add`, `hyperframes new` (fetchRemoteTemplate), and the studio
"add block" path each resolved a single registry item and silently dropped
any `registryDependencies` it declared.

Add `resolveItemWithDependencies` (DFS topological sort, cycle detection,
missing-dependency errors, and dedup of shared/diamond deps) and route all
three install paths through it so dependencies are installed before the item
that needs them. `resolveItem` becomes a thin guard that throws on dep-bearing
items, so no future caller can silently reintroduce the drop. `runAdd` now
returns the ordered `installed` list and compatibility-gates every dependency
before any write.

Reworks the stale PR #414 onto current main and addresses its review feedback:
fetchRemoteTemplate installs deps, no out-of-scope files, dead null-checks
dropped, diamond test added, and the deliberate serial-fetch tradeoff is noted.

Co-authored-by: Rakibul Islam <40rakib70@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strengths:

  • resolver.ts:87-96 — The resolveItem guard that throws on dep-bearing items is the right call. No future single-item caller can silently drop deps; they're forced to opt into the dep-aware API. Clean footgun elimination.
  • resolver.ts:175-176 — Cycle detection via separate visiting/visited sets is textbook DFS. The path argument correctly reconstructs the cycle string (traced the gamma → beta → alpha → gamma case manually — it produces the right output).
  • add.ts:112-127assertCompatibleOrThrow gates ALL items before any write. Fail-fast before install starts, no partial installs on compat failure.
  • Test coverage is solid: linear chain, single item, diamond dedup, missing dep, cycle detection, and an integration test that verifies files land on disk in the right order.

CI: All checks green (CLI smoke, Test, Typecheck, Build, windows — everything). ✅

Nit (non-blocking): resolver.ts:137-146getItem is typed as (): Promise<RegistryItem> but throws synchronously rather than returning Promise.reject(...). Works correctly (the async machinery in visit wraps the throw into a rejection), but the inconsistency between the return type annotation and the actual control flow is a minor maintenance hazard. Could annotate as getItem(itemName: string): Promise<RegistryItem> with a return Promise.reject(new Error(...)) to be explicit, or mark it never-returning on the throw branch. Not blocking — the behavior is correct and the tests exercise this path.

No blockers. Good rework of #414 — addresses both required-change items from that review (fetchRemoteTemplate + no out-of-scope noise) and adds the third missed path (installRegistryBlock) as a bonus.

Verdict: APPROVE
Reasoning: Correct DFS implementation with cycle detection and diamond dedup, three install paths properly threaded through the dep-aware resolver, full CI green. One nit on getItem's return-type consistency but it doesn't affect correctness.

— Magi

Addresses review nit on #1396: getItem was typed Promise<RegistryItem> but
threw synchronously on a missing dependency. Marking it async keeps the
control flow consistent with the return type — the throw now becomes a
rejection. The body has no await, so the item cache is still populated
synchronously on first request and dedup is unaffected.

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

Copy link
Copy Markdown
Collaborator Author

Thanks @miguel-heygen — addressed the getItem nit in 99b6e83: marked it async so the missing-dependency path surfaces as a promise rejection rather than a synchronous throw, making the control flow honest about the Promise<RegistryItem> return type. The body has no await, so the item cache is still populated synchronously on first request and the dedup behavior is unchanged. Resolver tests still green.

Addresses Via's review on #1396: `assertCompatibleOrThrow` only ran inside
`runAdd`, so `fetchRemoteTemplate` (hyperframes new) and the Studio
"add block" action installed resolved items — now including transitive
dependencies — with no minCliVersion enforcement or deprecation warnings. A
pre-existing single-item asymmetry that this PR's dep loops amplify across N
items.

- Add shared `gateRegistryItemsCompatibility` + `RegistryCompatibilityError`
  to compatibility.ts; all three install paths now gate the full resolved set
  before any write. `runAdd` keeps its AddError mapping by wrapping the shared
  gate.
- Surface deprecation warnings from the template/studio paths to stderr.
- Extract the studio viewport rewrite into `rewriteWrittenToHostViewport`
  (also drops redundant dynamic node:fs imports) and document that it
  intentionally rewrites dep-shipped .html too (Via item 3).
- Unit-test the shared gate directly (no fetch/cache flakiness): compatible
  set, accumulated deprecation warnings, and throw-on-incompatible.

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

Copy link
Copy Markdown
Collaborator Author

Thanks @vanceingalls (Via) — these were sharp. Addressed all three in 86a9b98:

1. Compat-gate scope gap (the weighty one) — fixed. You're right that the gate only ran in runAdd while fetchRemoteTemplate and installRegistryBlock installed resolved items (now incl. transitive deps) ungated. I confirmed it's a live gap, not dormant: checkRegistryItemCompatibility defaults currentCliVersion to the real VERSION, so add genuinely enforces minCliVersion today while the other two paths enforced nothing. Rather than document the asymmetry, I closed it: added a shared gateRegistryItemsCompatibility (+ RegistryCompatibilityError) in compatibility.ts that all three paths now run over the full resolved set before any write. runAdd keeps its AddError mapping by wrapping the shared gate. (Note: I deliberately did not lift this into resolveItemWithDependencies itself — runAdd catches resolver errors as unknown-item, so a compat error thrown from the resolver would get mis-categorized. Keeping the gate as a distinct post-resolve step avoids that.)

2. Integration-test scope gap — addressed, with a correction. One nuance: installRegistryBlock actually does share fetchRemoteTemplate's on-disk-cache limitation — it calls resolveItemWithDependencies(opts.blockName) with no baseUrl/skipCache, so it hits the same default-URL manifest/item cache (fetchItemManifest reads it unconditionally), which is what makes an end-to-end test there equally flaky. So instead of a per-path e2e test, I unit-tested the shared gate directly (compatibility.test.ts): compatible set, accumulated deprecation warnings, and throw-on-first-incompatible. That pins the compat behavior for all three paths without the cache flakiness, and the dep-resolution/ordering is already covered by the resolver tests.

3. Viewport-rewrite emergent behavior — documented + refactored. Extracted the viewport rewrite into rewriteWrittenToHostViewport with a comment making explicit that it intentionally rewrites every written .html, including any a dependency ships. (Bonus: the old method had redundant dynamic node:fs/node:path imports that the file already imports statically — removed those, which also brought the method back under the complexity gate.)

Also fixed Magi's getItem async nit earlier (99b6e83). CI/lint/typecheck/fallow all green locally; 744 CLI tests pass.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review on 86a9b98e — confirming each nit I raised from round 1.

✅ Fixed

getItem async nit (99b6e834) — confirmed. resolver.ts now declares const getItem = async (itemName: string): Promise<RegistryItem>, with the comment explaining the await-less body and dedup correctness preserved. Control flow and return type are now consistent.


CI: all checks green or pre-existing on main (Render on windows-latest pending but passes on main — not introduced by this PR). Approval stands.

— Magi

@jrusso1020 jrusso1020 merged commit 8eac7e1 into main Jun 13, 2026
36 checks passed
@jrusso1020 jrusso1020 deleted the fix/cli-transitive-registry-deps branch June 13, 2026 00:18
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.

2 participants