Skip to content

Deps redesign: package universe repositories, Resolver-owned selection - #142

Merged
JPDuchesne merged 49 commits into
mainfrom
deps/package-universe
Sep 8, 2026
Merged

Deps redesign: package universe repositories, Resolver-owned selection#142
JPDuchesne merged 49 commits into
mainfrom
deps/package-universe

Conversation

@JPDuchesne

@JPDuchesne JPDuchesne commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

The full deps-domain redesign (plan: "Extract Bundler Locker / package universe"): kill Repository#prepare and the untyped fetch(id) hash by separating the four concepts they conflate — identity, universe, requirement, pin — and moving constraint semantics into per-ecosystem domain services.

Layering rule, now enforced by the interfaces: Repository states facts, VersionScheme evaluates predicates, Resolver chooses.

  • Package universe domain types (typed: strict): PackageId (integration-keyed identity — fixes the cross-ecosystem name-collision latent in the bare-name resolved-set key), Package (the aggregate a Repository returns; no satisfies?/sort/best_match by design), PackageVersion (facts only, absence as empty forms), Artifact (dev-fetched bytes; digest is an enforcement input), Declarations (a version's declared-deps claim — see the regimes bullet).

  • Naming glossary / object model (final shape of the intent side): Declaration — the shared atom (name + integration + dev-shaped constraint hash; {} = unconstrained), stated by project rows and upstream manifests alike; Scope — the walk-inherited context (group/host/env) that rides parent → child as one unit and projects onto pins as host/env metadata; ScopedDeclaration — Declaration + Scope + per-row platform/post_install, replacing DependencyDeclaration (composition, deliberately not a subclass); Declarations — a sealed sum type, Resolved([Declaration…]) | ToolOwned; Dependency — the concretized pin, unchanged; Installer — was DependencyInstaller. DependencyEdge is dead: version facts reuse Declaration.

  • Transitive regimes stated by construction: every repository builds the Declarations variant its regime warrants — ficsit Resolved(...) with normalized, integration-stamped declarations; bundler/pip/luarocks/brew ToolOwned (the tool owns the closure); steam/git/xcode/url/gh Resolved([]) (self-contained by construction; gh's is a usage contract until subproject resolution). Constraints are normalized into dev's shape at the repository seam — upstream syntax crosses the boundary exactly once, and Resolver#normalize_constraint is deleted. Standing decisions recorded in the docs: lock files are never availability facts (Gemfile.lock is read for pinned versions only), Resolved([])ToolOwned, and version is never identity (same-package-twice is a per-context-resolution problem, not a PackageId problem). Follow-ups filed under the transitivity label: Resolver silently skips constraint checks on already-resolved transitive edges #147, Feature: prefer sub-lock pins as solver preferences (conservative resolution) #148, Cross-ecosystem constraint algebra: widen VersionScheme with intersect/compatible? #149, Define Scope propagation semantics for real transitive walks #151 (Scope propagation semantics for real transitive walks), Dev-manifest dependencies at a ref: read dependencies.rb from the pinned checkout #152 (dev-manifest dependencies at a ref). Promote source out of the constraint hash onto Declaration #150 is closed by this PR.

  • VersionScheme domain services: GemScheme, SemverScheme (ficsit), Pep440Scheme (pip subset), RockScheme (luarocks dotted+revision grammar — deliberately not semver), and real constraint semantics for every formerly "pinned" ecosystem: ExactScheme(key:) (gh — the constraint names one release tag out of the enumerated universe), GitScheme (cmake — tag/branch match the version's ref fact; a commit is a revision, not a constraint — see the two-operation bullet), SteamScheme (branch selection over enumerated branch tips + optional exact buildid assertion that fails loudly when stale), BrewScheme (formula version suffixes selecting a sibling out of the enumerated spec family via the version_suffix fact). PinnedScheme (satisfies-everything) is dead, and so is VersionScheme#pin. url and xcode register no scheme at all: no constraint grammar exists for them, only the empty constraint is legal, and anything else raises — never a silent pass. No repository evaluated constraints before this — ficsit silently ignored declared ^ ranges. satisfies? is fact-aware (takes the PackageVersion). Parse errors split by fault: InvalidConstraintError (user's declaration — propagates) vs InvalidVersionError (nonconforming universe version — skipped as non-candidate), rooted in shared VersionScheme bases.

  • Two-operation repository contract — find(id) and at(id, revision) across all twelve repositories. The filter hash (and its interim probe: successor) is fully retired; every ecosystem's version space splits into discrete (the published universe — enumerable, so find(id) takes identity and nothing else, reports every version with facts, and constraints select over it) and continuous (the space between published versions — git commit SHAs, exact Xcode versions — never enumerated; a declaration addresses it with Declaration#revision and at(id, revision) purely lifts the address, no I/O ever, trusted at resolve and verified at install, the same pin-as-assertion semantics a steam buildid has). Overriding at is the declaration that a continuous space exists (NoAddressableSpaceError base); declaring both a revision and a constraint is a loud DSL error; cmake commit: (strict 40-hex) and xcode versions sort into revisions. What the old filter smuggled now travels its own channel: source coordinates ride Declaration#sourcePackageId#source (closes Promote source out of the constraint hash onto Declaration #150); install instructions ride ScopedDeclaration#materialization and never reach a repository. Universes now genuinely enumerate: GhRepository joins the releases list (assets+digests) with the tags list (commit SHAs), unconstrained = latest release, MissingTagError dead; GitRepository enumerates ls-remote tags+heads (annotated tags peeled) with at as the pure SHA lift; BrewRepository enumerates the formula-spec family via versioned_formulae + one batched brew info; SteamRepository enumerates every branch tip in one app_info_print; casks stay a singleton whose name is the whole coordinate (versioned casks are distinct cask names, so version: on a cask is unsatisfiable by construction); FicsitRepository reports unconditional facts with projection moved to Resolver#mint; pip moved to the PyPI JSON API. gh SHA revisions deferred to deps: gh commit-SHA revisions (GhRepository#at) #153 (no consumer, no dead code). fetch/prepare deleted with all their dead helpers.

  • url is a real integration: cmake url: declarations route to a first-class :url registry entry instead of masquerading as git-backed cmake deps (previously they'd have hit GitRepository and failed). UrlRepository#find downloads and hashes the artifact as an unversioned singleton — the query is the observation, which is exactly why url is a find universe and not an at one (keeps the at-is-pure invariant). The author's tag: is a display label riding materialization, promoted into the pin's version slot at mint — naming, never selection. The :url entry installs through cmake's integration instance (new Registry::Entry#install_alias, Installer#dispatch groups by instance), so deps.cmake is written once, whole, when a project mixes both. deps: shared download utility — four hand-rolled curl call sites #154 filed for the four hand-rolled curl call sites (url/cmake/gh/steamcmd) — materialization-layer cleanup, separate PR.

  • BundlerLocker extraction: the whole-set bundle lock solve leaves BundlerRepository, which is now a pure Gemfile.lock reader.

  • Resolver rewrite: resolved set keyed by PackageId, so the same name under two integrations resolves independently against each one's universe and transitive edges stay inside the declaring dep's integration; a revision ask dispatches straight to at() — no universe query, no scheme, no selection (the author foreran resolution, and with it diamond reconciliation, by definition); conflict rejection per (integration, name) now compares constraint + source + revision + materialization (replaces silent first-wins; disagreeing install dirs are different asks), scheme-filtered selection with platform-union handling, pin minting from PackageVersion facts merged with the declaration's materialization plus artifact projection, and a transitive walk that cases on the version's Declarations claim (Resolved walks, ToolOwned has nothing to walk, T.absurd seals it) inheriting the parent's Scope as one unit.

  • Lockfile schema: entries nest by integration (brew:zlib: → attrs), carrying the same (integration, name) identity to disk — no more name-keyed collisions at the serialization seam. The reader keeps a legacy flat-format shim; once every consumer repo's lockfiles are rewritten by update-deps, the shim and its test are deleted. BuildContainer's install_dir/build-context scans parse via Lockfile instead of raw YAML, so format knowledge lives in one place.

  • Registry scheme/locker slots + lock-then-resolve pipeline in update-deps; consistency tests gain anti-drift guards for *_scheme.rb and *_locker.rb files.

  • docs/deps-architecture.md: ontology (five ideas + aggregate diagram), layer table, the discrete/continuous two-operation section (resolve sequence diagram branches on the constraint-vs-revision paths; revisions are opaque addresses validated at the DSL boundary, deliberately unstandardized because dev only forwards them), integrity regimes (dev-enforced / tool-enforced / identity-as-integrity), the new transitive-regimes table, the constraint standard (shape + per-integration scheme as interpreter; schemes widen, vocabularies never translate), new-ecosystem recipe, and the solve-ownership decision gate (per-ecosystem hybrid leaning dev-owned; bundler stays tool-owned behind the Locker; revisit criteria recorded).

  • Strict sigils across the whole chain: the "deps require chain must be stdlib-only pre-bundle" constraint was self-imposed — bin/test.rb/bin/tc.rb/bin/rbi.rb loaded dependencies.rb before bundler/setup for no live reason (EnsureBundler self-loads it post-setup), and every other pathway (dev CLI, docker's vendored keg gems) already has sorbet-runtime. With the early loads dropped, the chain — deps.rb, config.rb, dsl.rb, lockfile.rb, installer.rb, cli_ui.rb, tap.rb, ensure_bundler.rb (now module EnsureBundler), and Declaration/Scope/ScopedDeclaration — is typed: strict with full sigs; the Sorbet/StrictSigil exclusion list shrinks from 13 files to 2 genuine Sorbet holdouts (dependency.rb: Data.define kwargs-initialize, error 4010; fetcher.rb: consumer-repo Lockfile API).

Stacking / merge order

Verification

  • Full suite: 1201 tests, 0 failures. srb tc clean. RuboCop clean under Enforce Sorbet typed sigil #140's strict-sigil enforcement. Patch coverage: no PR-added line uncovered.
  • Repository tests rewritten against find/at; behavior unique to the old fetch paths (gh auth/API errors, brew tap retry, ficsit link fallback) ported rather than dropped. Each new scheme gets dedicated constraint-semantics tests.

Migration

Each consumer repo's deps.lock/build-deps.lock rewrites wholesale (same data, nested shape) on its next update-deps; build images re-tag once since lockfile content feeds the content-addressed tag. Old lockfiles keep installing via the read shim in the meantime. Sweep and shim removal tracked in #146.

JPDuchesne and others added 3 commits September 2, 2026 14:20
The deps domain conflated four concepts inside Repository#fetch's untyped
id hash: identity, available versions, requirement, and pin. This adds the
two missing halves as first-class types so the repository layer can state
facts without also carrying constraints or choices:

- PackageId: constraint-free, version-free identity, keyed by integration
  so two ecosystems publishing the same name no longer collide.
- Package: the aggregate a Repository returns — an identity plus its
  available versions, with no satisfies?/sort/best_match (those belong to
  VersionScheme and Resolver respectively).
- PackageVersion: one version's facts, with every optional fact in its
  empty form rather than nil.
- Artifact: bytes dev fetches itself, digest as an enforcement input.
- DependencyEdge: a version's outgoing requirement, constraint untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
Constraint semantics are a property of an ecosystem, not of any package or
repository, so they get their own strategy seam: Package states facts,
VersionScheme evaluates predicates (satisfies?/sort), Resolver chooses.
No repository evaluated constraints before this — ficsit silently ignored
its declared ^ ranges and took the newest version — so these schemes are
the missing predicate layer, not an extraction:

- GemScheme: Gem::Requirement/Gem::Version (bundler)
- SemverScheme: node-style ranges (^ ~ comparators, conjunction) for ficsit
- Pep440Scheme: the PEP 440 subset pip declarations use, pip-style cmpkey
- RockScheme: luarocks dotted+revision grammar, where 3.4-1 releases above
  3.4 rather than semver's prerelease-below reading
- PinnedScheme: brew/cmake/gh/steam/xcode universes arrive pre-narrowed by
  the backing service; everything satisfies, reported order stands

Co-authored-by: Cursor <cursoragent@cursor.com>
The facts-only repository contract: no lifecycle, no constraint, no
choice. #fetch and #prepare stay temporarily (marked deprecated) so each
repository can gain find in its own green commit; both die in the Resolver
cutover commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

JPDuchesne and others added 12 commits September 2, 2026 17:50
Two contract refinements the repository reshapes need:

- PackageVersion#metadata: ecosystem-specific facts the integration reads
  at install (mod_id, release assets, tap). The repository composes it, so
  minted pins keep today's lockfile metadata shapes exactly.
- Repository#find(id, filter:): the declaration constraint as a
  server-side locator. Pinned ecosystems (git tag, release tag, steam
  buildid, brew suffix) need it to locate their singleton universe;
  filtering returns all matching versions and never picks — range
  evaluation stays with VersionScheme, choice with the Resolver.

Co-authored-by: Cursor <cursoragent@cursor.com>
Every published version becomes a PackageVersion: targets as platforms,
each target's download as an Artifact carrying the API's SHA256
(dev-enforced integrity), required mod deps as edges, and the install
facts FicsitIntegration reads. This is the fix for ficsit ignoring semver
constraints: the whole universe is now visible, so the Resolver can pick
with SemverScheme instead of blindly taking versions.first.

A missing requested platform no longer raises here — the block just lacks
it, and disqualifying the version is the Resolver's call. ModNotFoundError
is now a Repository::PackageNotFoundError. #fetch is untouched until the
cutover.

Co-authored-by: Cursor <cursoragent@cursor.com>
luarocks search yields versions (deduplicated across arches) and nothing
more, so that is the universe: no digests — luarocks verifies rockspec
integrity itself at install, and the old resolve-time download-and-hash
produced an audit hash and a downloaded_path that nothing read — and no
edges. Constraint evaluation moves to RockScheme, fixing the old take-
first-ignore-constraint behavior. #fetch stays until the cutover.

Co-authored-by: Cursor <cursoragent@cursor.com>
brew info answers with a single current stable version, so the universe
is a singleton located by the filter: the declared version is a formula
suffix (18 selects llvm@18), tap scopes the name, cask switches to an
unversioned entry. Casks use an empty-string version stand-in that the
Resolver mints back to nil. Bottle SHA256 rides as the version digest.

Co-authored-by: Cursor <cursoragent@cursor.com>
GitHub refs are not an enumerable version index, so the filter's tag
locates the one release (prebuilt shape) or ref (source shape) the
declaration pins; the owner/repo slug rides as PackageId#source.
Install facts mirror today's pin metadata exactly — asset digests for
GhIntegration's download verification, commit SHA for provenance.
ReleaseNotFoundError is now a Repository::PackageNotFoundError.

Co-authored-by: Cursor <cursoragent@cursor.com>
One GET https://pypi.org/pypi/<name>/json yields every published version
with file digests — no more pip download at resolve time. Each version's
digest is its sdist SHA256 (platform-independent), wheel fallback, nil
for yanked/file-less releases. Edges stay empty: pip still owns the
transitive tree at install. Constraint evaluation moves to Pep440Scheme.

Co-authored-by: Cursor <cursoragent@cursor.com>
None of these ecosystems has an enumerable version index, so each find
reports a singleton universe located by the filter: git resolves the
declared tag/commit to its SHA via ls-remote (RefResolutionError is now a
PackageNotFoundError), steam pins an explicit buildid or the branch's
current one via SteamCMD, xcode's declared version IS the universe, and
url downloads once to mint a dev-enforced trust-on-first-use SHA256.
Install facts mirror today's pin metadata key for key.

Co-authored-by: Cursor <cursoragent@cursor.com>
The whole-set solve that hid in BundlerRepository#prepare gets its own
home: Locker is the batch seam (lock(declarations) -> tool lockfile), and
BundlerLocker owns Gemfile generation plus bundle lock. BundlerRepository
becomes what it always really was — a reader over Gemfile.lock: find
reports each gem's singleton universe (the joint solve's choice, with the
CHECKSUMS digest). MissingGemError is now a PackageNotFoundError.
prepare/fetch remain as thin deprecated delegations until the cutover.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Resolver needs one rescue point: a universe can legitimately contain
versions that ignore the ecosystem's conventions (old tags, oddball
uploads), and those candidates should be skipped as non-satisfying, not
fail the resolve — while a malformed constraint is the user's declaration
being wrong and must propagate. Each scheme's InvalidVersionError and
InvalidConstraintError now subclass the VersionScheme base pair.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Resolver is now the choice layer over pure facts: for each declaration
it asks the repository for the package universe (find, with the constraint
riding along as a server-side locator), filters candidates through the
integration's VersionScheme — treating scheme-unparseable universe versions
as non-satisfying rather than fatal — takes the highest satisfying version
that publishes every explicitly requested platform, mints the pin from that
version's facts, and walks its edges for transitives (which inherit the
declaring dep's group, host, and env). Disagreeing constraints on one name
are rejected up front.

With no callers left, the per-item fetch contract and the prepare lifecycle
hook are deleted from Repository and every implementation, along with their
now-dead private helpers and error classes; bundler's lock step lives only
in BundlerLocker, and BundlerRepository is a pure Gemfile.lock reader.
Repository tests covering behavior unique to fetch (gh auth/API errors,
brew tap retry, ficsit link fallback) are ported to find; the rest are
deleted as duplicates of existing find coverage.

Also fixes typed-strict debt srb tc surfaced in the new domain types
(untyped-receiver equality returning nilable booleans, redundant T.must
on Array#<=> results).

Co-authored-by: Cursor <cursoragent@cursor.com>
Every Registry entry now declares its VersionScheme (the ecosystem's
constraint semantics) alongside its repository, and entries whose tool owns
the whole-set solve declare a Locker (bundler -> BundlerLocker). update-deps
becomes a lock-then-resolve pipeline: each integration's locker runs over
its declarations first, so repositories read an already-solved universe,
then the Resolver is built from Registry.repositories + Registry.schemes.
BundlerRepository no longer takes ruby_version_requirement — that's the
locker's concern.

The registry consistency test grows two anti-drift guards: every
*_scheme.rb (bar the abstract base) and every *_locker.rb class must be
referenced by a registry entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
The reference the lib/dev/deps comments point at: the four-concept
ontology (identity / universe / requirement / pin), the layer table with
each class's one question, the lock-then-resolve pipeline, per-integration
constraint semantics, the three integrity regimes, the new-ecosystem
recipe, and the solve-ownership decision gate — per-ecosystem hybrid
leaning dev-owned, with the criteria for revisiting bundler/pip/luarocks
tool ownership recorded.

Co-authored-by: Cursor <cursoragent@cursor.com>
Base automatically changed from ai/139-enforce-sorbet-typed-sigil to main September 5, 2026 16:07
JPDuchesne and others added 6 commits September 5, 2026 13:20
Require blocks keep the redesign's package/package_id/package_version
requires and adopt main's convention of loading sorbet-runtime once in
src/dev.rb (applied to every new deps file, matching the sweep in
5a3024a). Ficsit's find keeps the redesign body with main's single
target binding ported. Dead main-side requires (tempfile, digest/open3/
tmpdir, repository.rb's dependency pair) dropped with the code that used
them.

Co-authored-by: Cursor <cursoragent@cursor.com>
The >/<= operator branches in Pep440Scheme, RockScheme, and SemverScheme
gain Where-table rows; the abstract Locker#lock raise gets its own test
mirroring VersionScheme's; PipRepository#get_project's HTTP seam is
asserted against the PyPI project URL; and update-deps' locker dispatch
is exercised with a manifest gem declaration and a mocked locker.

Co-authored-by: Cursor <cursoragent@cursor.com>
The same name under two integrations is two packages: each resolves
against its own integration's universe, transitive edges stay inside
the declaring dep's integration, constraint-conflict detection and
platform unioning are scoped per (integration, name).

Co-authored-by: Cursor <cursoragent@cursor.com>
The lockfile key becomes (integration, name), matching package identity,
so the same name under two integrations occupies two keys instead of
colliding. The reader keeps a legacy flat-format shim until every
consumer repo's lockfiles are rewritten by update-deps.

BuildContainer's install_dir/build-context scans now parse lockfiles
through Lockfile instead of raw YAML, so format knowledge (including
the shim) lives in one place; project_needs_llvm? tolerates the dep
key's indentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@JPDuchesne
JPDuchesne marked this pull request as ready for review September 5, 2026 19:17
JPDuchesne and others added 3 commits September 5, 2026 15:43
Co-authored-by: Cursor <cursoragent@cursor.com>
Declaration is the shared atom (name, integration, constraint) — the part
of a dependency ask that whoever authored it can state. Scope (group, host,
env) is the resolution context that rides the resolve walk parent -> child
as a unit; ScopedDeclaration composes the two plus the per-row axes
(platform, post_install) that deliberately do not inherit.

Composition, not subclassing: a scoped declaration must never pass where a
context-free Declaration is expected. The resolver now inherits transitive
context by copying one Scope object instead of three fields, and
attach_install_scoping collapses into Scope#to_metadata.

The three new types ride the pre-bundle bootstrap chain (dsl.rb), so they
stay sorbet-runtime-free and join the StrictSigil exclusion list.

Co-authored-by: Cursor <cursoragent@cursor.com>
…he seam

A PackageVersion's declared dependencies are now Declarations — the same
shared atom the project side wraps in ScopedDeclarations. The reporting
repository stamps the integration (ficsit mods require ficsit mods) and
normalizes the upstream constraint syntax into dev's shape at construction,
so constraints cross the system boundary exactly once.

That finishes Resolver#normalize_constraint's job at the right layer: the
resolver now receives finished Declarations and only stamps the walk
context (Scope) onto them, deleting the raw-constraint case analysis.

Co-authored-by: Cursor <cursoragent@cursor.com>
JPDuchesne and others added 5 commits September 6, 2026 11:02
A bare array can't say which regime the claim was made under — [] collapses
'affirmatively requires nothing' into 'the tool owns a closure dev never
sees'. Declarations::Resolved([Declaration...]) and Declarations::ToolOwned
make both states representable; sealed! so consumers can case-and-T.absurd.

The claim will travel with the data: each Repository constructs the variant
its regime warrants, making construction the dispatch (no resolver guard,
no registry attribute). Wired into PackageVersion in the next commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
The field now returns the sum type instead of a bare array, defaulting to
the affirmative Resolved([]) — ToolOwned must be stated explicitly. The
resolver walk cases on the variant: Resolved walks its declarations under
the parent's Scope, ToolOwned walks nothing (the tool owns the closure),
T.absurd seals the case. No guard, no rescue: construction is the dispatch.

The empty-forms doc paragraph is finally honest — 'declares nothing
requires nothing' is now true because tool-owned closures can no longer
hide inside the empty form.

Co-authored-by: Cursor <cursoragent@cursor.com>
Each of the ten repositories now constructs the Declarations variant its
regime warrants — construction is the dispatch, so no guard or enum exists:

- ficsit: Resolved(normalized, integration-stamped declarations)
- bundler, pip, luarocks, brew: ToolOwned — the ecosystem's tool resolves
  the closure. bundler's comment records that Gemfile.lock is read for
  pinned versions only, never mined for dependency declarations.
- steam, git, xcode, url: Resolved([]) — self-contained by construction.
- gh: Resolved([]) — prebuilt assets by guarantee; source builds as a usage
  contract (the consumer declares transitive needs) until subproject
  resolution lands.

Co-authored-by: Cursor <cursoragent@cursor.com>
Inside Dev::Deps the word 'Dependency' is the module's subject; as a prefix
it carries no information. Mechanical rename: file, class, requires, the
install-deps command factory, and the rules-file mention. Docs get their
full ontology rewrite in the next commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
The ontology table grows to five ideas (Declaration joins as the shared
atom; ScopedDeclaration replaces DependencyDeclaration as the requirement)
plus a compact aggregate diagram of intent/universe/pin. New sections: the
constraint standard (dev-shaped hash minted at the repository seam,
interpreted by the integration's VersionScheme — schemes widen, vocabularies
never translate) and the transitive-dependency regimes table with the two
standing decisions (lock files are never availability facts; Resolved([])
and ToolOwned are different claims). Sequence diagrams renamed to the new
types, with the transitive queueing wrapped in an opt fragment; the ASCII
pipeline block is gone (it repeated the diagrams with stale shapes). The
new-ecosystem recipe now tells a repository to state its regime by
construction and normalize constraints at find.

Co-authored-by: Cursor <cursoragent@cursor.com>
JPDuchesne and others added 18 commits September 6, 2026 11:45
The 'stdlib-only pre-bundle chain' constraint was self-imposed: only
bin/test.rb and bin/tc.rb loaded dependencies.rb before bundler/setup,
and that early load served no purpose (EnsureBundler self-loads it, post-
setup). Every real pathway — bin scripts, the dev CLI, docker's vendored
keg gems — has sorbet-runtime available.

- Drop the pre-bundle load of dependencies.rb from bin/test.rb, bin/tc.rb,
  and bin/rbi.rb; the chain now always loads with gems active.
- typed: strict with full sigs: deps.rb, cli_ui.rb, config.rb, dsl.rb,
  declaration.rb, scope.rb, scoped_declaration.rb, tap.rb, lockfile.rb,
  installer.rb, ensure_bundler.rb.
- ensure_bundler.rb becomes module EnsureBundler (top-level defs can't
  carry sigs); error nested as EnsureBundler::BundlerInstallError.
- Tap's Data.define-synthesized readers get sigs via an RBI shim; the
  now-visible nilability of Tap#url fixed properly in brew_integration.
- Sorbet/StrictSigil exclusions shrink from 13 files to 2 genuine
  holdouts (dependency.rb: Data.define kwargs-initialize, error 4010;
  fetcher.rb: consumer-repo Lockfile API).

Co-authored-by: Cursor <cursoragent@cursor.com>
Both lines were restructured by the strict-sigil pass and had no test:
register_tap's remote-URL branch and CliUI.available?'s memoized return.

Co-authored-by: Cursor <cursoragent@cursor.com>
…on field

source is identity-shaping and legitimately statable by both authors of the
atom (project rows and repository-reported manifest edges — cargo-style git
deps), so it lives on Declaration and will feed PackageId#source. Install
instructions (install_dir, asset globs, build recipes) are consumer-side and
non-inherited, so they live on ScopedDeclaration next to platform and
post_install — never on the shared atom, where they would be a structurally
vacuous field for every upstream edge. Both participate in value equality so
disagreeing sources or install dirs stay loud conflicts.

Co-authored-by: Cursor <cursoragent@cursor.com>
satisfies? now takes the whole PackageVersion: some ecosystems' constraints
match version facts rather than the version string (a Steam branch, the git
ref a SHA resolved from, a brew formula suffix). Range schemes read only
version.version. The new #pin extracts the exact coordinate a constraint
pins, for the Resolver to pass to Repository#find as the probe — the access
path for universes that cannot enumerate. Extraction lives on the scheme
because constraint keys are the scheme's vocabulary; the raw constraint hash
itself will stop reaching repositories in the next commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
…s, resolver projection

The filter hash smuggled three unrelated things through the repository
seam: version coordinates (tag/commit/buildid), source coordinates
(repo/url/tap/app), and install instructions (install_dir/assets/
platforms/target). Each now travels its own channel:

- Repository#find(id, probe:) — the probe is a single typed version
  coordinate, extracted by the integration's scheme (VersionScheme#pin),
  and only non-enumerable universes get one (gh tags, git refs, brew
  suffixes, xcode versions, url labels). Enumerable universes (ficsit,
  steam branches, pip, luarocks, bundler) ignore it.
- Source coordinates ride PackageId#source (from Declaration#source).
- Install instructions ride ScopedDeclaration#materialization and are
  stamped onto the pin at Resolver#mint, which also projects the
  declared platform union / ficsit target against the chosen version's
  artifacts (projection moved out of FicsitRepository).

Scheme cutover: PinnedScheme (satisfies-everything) is dead. Each
pinned-style ecosystem now states its real constraint semantics:
ExactScheme(key:) for gh/xcode/url, GitScheme for cmake refs,
SteamScheme for branch+buildid selection over enumerated branch tips,
BrewScheme for formula version suffixes. VersionScheme#satisfies? is
fact-aware (takes the PackageVersion, not the bare string) so schemes
can match against universe facts like branch or ref.

Repository fallout: SteamRepository enumerates every branch tip via
SteamCmd.resolve_branches; GhRepository always resolves the commit and
records all release assets as facts (glob selection moved to
GhIntegration at install, loud NoMatchingAssetsError); casks split into
BrewCaskRepository under the :cask integration — a genuinely separate
universe with no versions or bottle digests.

DSL verbs sort kwargs into constraint/source/materialization per
integration; SteamIntegration owns the dev-platform -> steamcmd
platform mapping.

Co-authored-by: Cursor <cursoragent@cursor.com>
ExactScheme/GitScheme/SteamScheme/BrewScheme each get their own
constraint-semantics tests (match, miss, unconstrained, pin, sort), and
SteamIntegration#steam_platform_for gets a mapping table test — the
provisioning fixture bypasses it, so nothing else executes it.

Co-authored-by: Cursor <cursoragent@cursor.com>
… identity stance

The find contract section, resolution pipeline, sequence diagram,
constraint-semantics table, and new-ecosystem recipe now describe the
probe/source/materialization channels instead of the retired filter
hash. Also records the standing stance that version is never identity:
same-package-twice is a per-context-resolution problem, not a
PackageId problem.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
An addressable revision (git SHA, exact Xcode version) is the other way
to ask: it forgoes resolution instead of selecting over a published
universe. The atom rejects revision+constraint at construction
(RevisionWithConstraintError), ScopedDeclaration delegates it, and the
Resolver's conflict rejection now treats disagreeing revisions as
disagreeing asks.

Co-authored-by: Cursor <cursoragent@cursor.com>
at(id, revision) lifts an address in an integration's continuous space
into a PackageVersion — pure, no I/O; the address is trusted at resolve
and verified at install. Overriding at IS the declaration that a
continuous space exists; the base raises NoAddressableSpaceError and the
Resolver lets the refusal propagate. A revision-pinned declaration
dispatches to at, skipping find and the scheme entirely, and mints the
pin from the lifted version (materialization merged as usual).

Co-authored-by: Cursor <cursoragent@cursor.com>
GitRepository#find now lists every tag and branch head in one ls-remote
call (peeled SHAs win for annotated tags) — the ref rides each SHA
version as the fact GitScheme's tag:/branch: constraints select on. A
commit pin is no longer a constraint: the cmake DSL verb sorts commit:
into the declaration's revision (full 40-hex only, killing the silent
resolve-short-commit-as-tag fallthrough) and GitRepository#at lifts it
purely. Ref-less git deps are rejected at the DSL boundary: an
unconstrained enumeration would pin an arbitrary ref.

Co-authored-by: Cursor <cursoragent@cursor.com>
GhRepository#find now lists releases (assets and digests ride the list
response) and tags (commit SHAs ditto) via two paginated endpoints — no
per-version calls, no probe. ExactScheme's tag selection is unchanged;
universe order puts releases last, newest at the end, so an
unconstrained gh dep pins the latest release. MissingTagError and
ReleaseNotFoundError die: a missing tag is now the Resolver's
NoSatisfyingVersionError over an honest universe, and a 404 on a list
endpoint can only mean the repo is invisible (RepoAccessError).

Co-authored-by: Cursor <cursoragent@cursor.com>
BrewRepository#find now reports the whole family — the bare spec plus
every versioned_formulae sibling, fetched in one batched brew info call.
Each sibling's @suffix rides its version as the version_suffix fact
BrewScheme's version: constraint matches; the bare spec sits last as the
unconstrained pick. Head-only siblings without a stable version are
skipped; tap formulae with empty families degrade to the old singleton.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nstraint

Apple publishes no queryable registry, so there is no discrete universe:
XcodeRepository#find now refuses (NoEnumerableUniverseError) and at lifts
the declared version as the identity. The DSL's xcode verb mints the
version as the declaration's revision (blank rejected loudly), and the
Registry's scheme slot goes nilable — a purely addressable type answers
'how do constraints work' with 'they never occur here'.

Co-authored-by: Cursor <cursoragent@cursor.com>
cmake url: declarations now route to a first-class :url registry entry
instead of masquerading as git-backed cmake deps. UrlRepository#find
downloads and hashes the artifact as an unversioned singleton (TOFU
integrity); the author's tag: becomes a display label riding
materialization, promoted into the pin's version slot at mint — never
a constraint, because no scheme grammar exists to evaluate one.

Scheme-less integrations get an explicit resolver posture: an empty
ask resolves over the universe as reported; a version constraint
raises UnknownIntegrationError instead of silently pretending to
evaluate it.

The :url entry aliases its installer to :cmake via install_alias, and
Installer#dispatch now groups by integration instance rather than
symbol, so cmake and url deps land in one install_all call and
batch artifacts (deps.cmake) can't clobber each other.

Co-authored-by: Cursor <cursoragent@cursor.com>
Every universe now enumerates (or is a singleton whose query is the
observation), so the probe — the access path we threaded through find
for universes that couldn't — has no remaining caller. Repository#find
is find(id): identity in, universe out. VersionScheme#pin and its
overrides die with it; constraints are now exclusively predicates the
scheme evaluates over reported facts, never coordinates smuggled to
the repository.

Casks follow brew's own universe: versioned casks are distinct cask
names (temurin@21), so the name is the whole coordinate and a
version: constraint on a cask is unsatisfiable by construction —
loudly, via BrewScheme finding no suffix fact to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
deps-architecture.md now teaches the split: find(id) enumerates the
discrete published universe (identity in, universe out — the I/O
operation, where a degenerate universe's query is the observation),
at(id, revision) purely lifts an address into the continuous space
between published versions, forgoing resolution by the author's own
hand. The resolve sequence diagram branches on the two paths, the
scheme table drops probe/pin vocabulary (constraints are predicates
over enumerated facts, revisions are forwarded addresses), and the
new-ecosystem recipe states that overriding at() is itself the
declaration of a continuous space.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sorbet rejects splatting a runtime-sized array into capture3's typed
signature (srb 7019); same escape hatch every other dynamic-argv
capture3 call site in the codebase uses.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread lib/dev/deps/artifact.rb Outdated
# @raise [MissingUriError] if uri is missing or blank
sig { params(uri: T.nilable(String), digest: T.nilable(String)).void }
def initialize(uri:, digest: nil)
raise MissingUriError, "an artifact without a uri cannot be fetched" if uri.nil? || uri.empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

if uri is required, let's make it non-nilable in the constructor

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in bdcc24buri: is String in the sig, so Sorbet rejects nil statically. Both producers already coerced at their real boundary (ficsit's download_url falls back to the canonical URL when the GraphQL link is absent; url's uri is the declaration's source), so the nilable param was only weakening the contract. The blank-string guard and MissingUriError stay: non-emptiness is the one invariant the type can't express.

…hecked

Both producers already coerce at their real boundary (ficsit's
download_url falls back to the canonical URL when the GraphQL link is
absent; url's uri is the declaration's source), so the nilable
parameter only weakened the contract for internal callers. Sorbet now
rejects a nil uri statically; the blank-string guard stays, since
non-emptiness is the one invariant the type can't express.

Addresses review feedback on #142.

Co-authored-by: Cursor <cursoragent@cursor.com>
@JPDuchesne
JPDuchesne merged commit 5da0ddd into main Sep 8, 2026
11 checks passed
@JPDuchesne
JPDuchesne deleted the deps/package-universe branch September 8, 2026 18:02
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.

Promote source out of the constraint hash onto Declaration

1 participant