diff --git a/.cursor/rules/separation-of-concerns.mdc b/.cursor/rules/separation-of-concerns.mdc
index c145075..0ef6882 100644
--- a/.cursor/rules/separation-of-concerns.mdc
+++ b/.cursor/rules/separation-of-concerns.mdc
@@ -59,7 +59,7 @@ end
# GOOD: separate classes for separate layers
class DependencyResolver # resolve + lock layer
-class DependencyInstaller # install layer
+class Installer # install layer
```
## Integration-specific logic stays in its integration
diff --git a/.cursor/rules/strong-types.mdc b/.cursor/rules/strong-types.mdc
index a76a29d..abab424 100644
--- a/.cursor/rules/strong-types.mdc
+++ b/.cursor/rules/strong-types.mdc
@@ -78,7 +78,7 @@ end
# GOOD: parser returns domain object immediately
def parse(path)
- DependencyDeclaration.new(name: yaml["name"], version: yaml["version"])
+ Declaration.new(name: yaml["name"], integration: :cmake, constraint: yaml["constraint"])
end
```
diff --git a/.rubocop.yml b/.rubocop.yml
index 4e44765..edc35ce 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -30,25 +30,9 @@ Sorbet/StrictSigil:
- src/**/*
- lib/**/*
Exclude:
- # The pre-bundle bootstrap chain (dependencies.rb -> dev/deps -> ...,
- # plus ensure_bundler) is loaded by bin/setup.rb and bin/test.rb BEFORE
- # the bundle exists, so it must stay stdlib-only: `sig` blocks would
- # require sorbet-runtime at load time. These files cap at `typed: true`
- # (or `typed: false` where noted).
- - lib/ensure_bundler.rb
- - lib/dev/deps.rb
- - lib/dev/deps/cli_ui.rb
- - lib/dev/deps/config.rb
- - lib/dev/deps/lockfile.rb
- - lib/dev/deps/tap.rb
- - lib/dev/deps/dependency_installer.rb
- # `typed: false` holdouts: Data.define with a keyword-args initialize
+ # `typed: false` holdouts. Data.define with a keyword-args initialize
# override is rejected by Sorbet (error 4010)...
- lib/dev/deps/dependency.rb
- - lib/dev/deps/dependency_declaration.rb
- # ...method_missing dispatch into a required-keyword method needs
- # T.unsafe, which the pre-bundle constraint forbids...
- - lib/dev/deps/dsl.rb
# ...and Fetcher consumes a consumer-repo Lockfile API (parse,
# runtime_ref_map) that doesn't resolve against this repo's Lockfile.
- lib/dev/deps/fetcher.rb
diff --git a/bin/install-build-deps.rb b/bin/install-build-deps.rb
index d3380da..aac81b2 100755
--- a/bin/install-build-deps.rb
+++ b/bin/install-build-deps.rb
@@ -48,7 +48,7 @@ def install_brew_entry(entry)
when Hash
entry.each do |name, opts|
# Host-gated entries (e.g. brew "xcodes", host: :darwin) skip
- # non-matching hosts — mirrors DependencyInstaller#filter_by_host.
+ # non-matching hosts — mirrors Installer#filter_by_host.
host = opts["host"]
if host && host.to_s != HOST
puts ">>> Skipping #{name} (host: #{host})"
diff --git a/bin/rbi.rb b/bin/rbi.rb
index b0c1146..3bbbb39 100755
--- a/bin/rbi.rb
+++ b/bin/rbi.rb
@@ -29,14 +29,13 @@
CLI::UI::StdoutRouter.enable
-load File.join(DEV_ROOT, "dependencies.rb")
require "ensure_bundler"
class TapiocaGemError < StandardError; end
CLI::UI.frame("Regenerating gem RBI files...") do
CLI::UI.spinner("Ensuring bundler is installed...") do
- ensure_bundler!(DEV_ROOT)
+ EnsureBundler.ensure!(DEV_ROOT)
end
CLI::UI.spinner("Generating gem RBI files...") do
diff --git a/bin/setup.rb b/bin/setup.rb
index e18d98d..8d5cac6 100755
--- a/bin/setup.rb
+++ b/bin/setup.rb
@@ -51,7 +51,7 @@
class BundleInstallError < StandardError; end
CLI::UI.frame("Setting up dev environment...") do
- unless CLI::UI.spinner("Installing bundler...") { ensure_bundler!(DEV_ROOT) }
+ unless CLI::UI.spinner("Installing bundler...") { EnsureBundler.ensure!(DEV_ROOT) }
exit 1
end
diff --git a/bin/tc.rb b/bin/tc.rb
index 7e9199f..02ef045 100755
--- a/bin/tc.rb
+++ b/bin/tc.rb
@@ -24,8 +24,6 @@
DEV_ROOT = File.expand_path("..", __dir__)
$LOAD_PATH.unshift(File.join(DEV_ROOT, "lib")) unless $LOAD_PATH.include?(File.join(DEV_ROOT, "lib"))
-load File.join(DEV_ROOT, "dependencies.rb")
-
ENV["BUNDLE_GEMFILE"] ||= File.join(DEV_ROOT, "Gemfile")
require "bundler/setup"
@@ -41,7 +39,7 @@ class RbiOutOfDateError < StandardError; end
# CLI::UI.spinner returns false when the task fails (debrief prints exceptions
# but never re-raises), so we check each return value explicitly.
CLI::UI.frame("Type checking...") do
- unless CLI::UI.spinner("Install Bundler") { ensure_bundler!(DEV_ROOT) }
+ unless CLI::UI.spinner("Install Bundler") { EnsureBundler.ensure!(DEV_ROOT) }
exit 1
end
diff --git a/bin/test.rb b/bin/test.rb
index b159c02..737e107 100755
--- a/bin/test.rb
+++ b/bin/test.rb
@@ -24,8 +24,6 @@
DEV_ROOT = File.expand_path("..", __dir__)
$LOAD_PATH.unshift(File.join(DEV_ROOT, "lib")) unless $LOAD_PATH.include?(File.join(DEV_ROOT, "lib"))
-load File.join(DEV_ROOT, "dependencies.rb")
-
ENV["BUNDLE_GEMFILE"] ||= File.join(DEV_ROOT, "Gemfile")
require "bundler/setup"
@@ -47,7 +45,7 @@ class TestError < StandardError; end
def main
requested_files = ARGV
CLI::UI.frame("Running tests...") do
- unless CLI::UI.spinner("Install Bundler") { ensure_bundler!(DEV_ROOT) }
+ unless CLI::UI.spinner("Install Bundler") { EnsureBundler.ensure!(DEV_ROOT) }
exit 1
end
diff --git a/dependencies.rb b/dependencies.rb
index d3e264f..de41396 100644
--- a/dependencies.rb
+++ b/dependencies.rb
@@ -3,10 +3,10 @@
# dev's own dependency manifest. Loaded in two ways:
# - by dev itself (before every command) to read the project toolchain;
-# - by bin/setup.rb and bin/test.rb BEFORE the bundle exists, for the
-# bootstrap constants below. The dev/deps require chain is stdlib-only,
-# so this file must stay loadable pre-bundle (both callers put lib/ on
-# the load path first). The guard keeps re-loads idempotent.
+# - by EnsureBundler (for the bin/ scripts), for the bootstrap constants
+# below. Every caller activates gems first (bundler/setup or plain
+# RubyGems), so the dev/deps require chain is free to use sorbet-runtime.
+# The guard keeps re-loads idempotent.
require "dev/deps"
Dev::Deps.define do
diff --git a/docs/deps-architecture.md b/docs/deps-architecture.md
new file mode 100644
index 0000000..0290e0d
--- /dev/null
+++ b/docs/deps-architecture.md
@@ -0,0 +1,364 @@
+# Dependency architecture
+
+How `dev` turns `dependencies.rb` declarations into installed, pinned,
+integrity-checked dependencies. This is the reference the `lib/dev/deps`
+code comments point at: the ontology, who owns which decision, how
+integrity works per ecosystem, and what to build when adding a new one.
+
+## Ontology
+
+Five ideas, kept strictly apart. Each has one class, and no class plays
+two roles.
+
+| Concept | Class | What it is |
+| --- | --- | --- |
+| Identity | `PackageId` | Which package: `integration` + `name`, plus `source` for source-addressed deps (a git URL, an `owner/repo` slug, a tap, a Steam app id). Value object, works as a Hash key. Version is deliberately not identity: two versions of one package are related candidates in one universe, never two packages — keeping selection (semver, ranges) possible over them. If a legitimate same-package-twice case ever appears, the fix is per-context resolution in the solver, not versioned identity. |
+| Universe | `Package` → `PackageVersion` | What exists: every version a repository reports, each carrying facts — `platforms`, `digest`, `artifacts` (dev-fetched bytes), `declarations` (its declared-deps claim), and `metadata` (ecosystem facts). Facts are unconditional: nothing in a universe depends on who asked. |
+| Declaration | `Declaration` | The shared atom: name + integration + constraint + optional `source` coordinate + optional `revision` address, always in dev's shape (`{}` = unconstrained). A constraint is a predicate over the published universe; a revision is a direct address into an ecosystem's continuous space (a git commit SHA, an exact Xcode version) that forgoes resolution entirely — declaring both is a loud error. Stated by whoever authored the thing — a project's `dependencies.rb` row or an upstream manifest — and context-free by type: where/when *you* install is not part of what is declared about a package. |
+| Requirement | `ScopedDeclaration` | A `Declaration` married to the context it resolves under: a `Scope` (`group`, `host`, `env` — inherited down the walk as one unit) plus the per-row axes that deliberately don't inherit (`platform`, `post_install`, `materialization` — install instructions like `install_dir`, asset globs, build recipes, artifact targets). What the DSL produces and the Resolver consumes. Composition, not a subclass: a scoped declaration must never pass where a context-free `Declaration` is expected. |
+| Pin | `Dependency` | What was chosen: exact version, integrity hash, metadata. What the lockfile serializes and integrations install. |
+
+Supporting types: `Artifact` (one downloadable file with an optional
+published digest), `Scope` (the walk-inherited context, projected onto
+pins as host/env metadata), and `Declarations` — a sealed sum type for a
+version's declared-deps claim: `Resolved([Declaration…])` (facts dev can
+walk; `Resolved([])` affirmatively requires nothing) or `ToolOwned` (the
+ecosystem's tool owns a closure dev never sees). A bare array could not
+keep those last two apart.
+
+```mermaid
+flowchart LR
+ subgraph intent [Intent]
+ Scoped["ScopedDeclaration"] --> ScopeObj["Scope
group, host, env"]
+ end
+ subgraph universe [Universe]
+ PackageId --> Package --> PackageVersion
+ PackageVersion --> Artifact
+ PackageVersion -->|"#declarations"| DeclADT["Declarations
Resolved | ToolOwned"]
+ end
+ subgraph outcome [Pin]
+ Dep["Dependency"] --> Lockfile
+ end
+ Scoped --> Atom["Declaration
name, integration, constraint"]
+ DeclADT -->|Resolved| Atom
+ Scoped -->|Resolver| Dep
+ Atom -.->|"walk marries with parent's Scope"| Scoped
+```
+
+## Layers and their one question
+
+| Layer | Class(es) | The one question it answers | Never does |
+| --- | --- | --- | --- |
+| Repository | `Repository#find(id) -> Package`, `Repository#at(id, revision) -> PackageVersion` | "What published versions of this package exist, and what are their facts?" (`find`) / "Lift this address into a version" (`at`) | Evaluate constraints; choose among candidates; see install instructions |
+| Scheme | `VersionScheme#satisfies?/#sort` | "Does this version satisfy this constraint, and how do versions order?" | Talk to the network; know about declarations |
+| Locker | `Locker#lock(declarations)` | "Given this whole declaration set, make the ecosystem tool solve it" | Read the result (that's the repository's find) |
+| Resolver | `Resolver#resolve(declarations) -> [Dependency]` | "Which version do we pin, and what transitives follow?" | Fetch bytes; know ecosystem constraint syntax |
+| Integration | `Integration#install_all` | "How do these pins become installed software on this machine?" | Resolve versions |
+
+The registry (`Registry::INTEGRATIONS`) is the single wiring table: one
+`Entry` per integration symbol declaring its repository, optional scheme
+(nil for integrations with no constraint grammar — url, xcode), optional
+locker, optional integration, optional `install_alias` (url installs
+through cmake's integration *instance*, so their shared batch artifact
+`deps.cmake` is written once, whole), and scope. Consistency tests fail
+the build if a `*_repository.rb`, `*_integration.rb`, `*_scheme.rb`, or
+`*_locker.rb` class exists without a registry entry.
+
+## Discrete and continuous: the two repository operations
+
+Every ecosystem's version space splits in two, and each half gets its own
+operation:
+
+- **Discrete** — the published universe: gh releases and tags, git refs,
+ brew formula-spec families, steam branch tips, pip releases.
+ Enumerable, so `find(id)` reports all of it with facts, and constraints
+ select over it. `find` is the I/O operation; for degenerate universes
+ the query *is* the observation — url downloads and hashes the artifact
+ (an observable-now singleton), cask checks nothing because the name's
+ presence is the whole fact.
+- **Continuous** — the space between published versions: any reachable
+ git commit SHA, any exact Xcode version. Never enumerated — no query
+ lists reachable SHAs at any cost. A declaration addresses it with a
+ `revision`, and `at(id, revision)` lifts that address into a
+ `PackageVersion` — **pure, no I/O, ever**. The address is trusted at
+ resolve time and dereferenced/verified at install, the same
+ pin-as-assertion semantics a steam `buildid:` has. No scheme runs over
+ the result and no selection happens: by pinning a revision the author
+ foreran resolution (and with it, any future diamond-dependency
+ reconciliation — a revision is exact by definition).
+
+Overriding `at` *is* the declaration that an integration has a continuous
+space (cmake/git commits, xcode versions); the base class refuses with
+`NoAddressableSpaceError` and the Resolver lets that refusal propagate.
+There is no registry flag to drift out of sync.
+
+Revisions are deliberately *not* standardized the way constraints are: a
+constraint is a predicate dev must evaluate, so it must be in dev's shape;
+a revision is an opaque address dev only forwards, spelled in the
+ecosystem's canonical form and validated at the DSL boundary (a cmake
+`commit:` must be a full 40-hex SHA).
+
+## The resolve pipeline
+
+`dev update-deps` runs:
+
+1. **Lock** — for each integration with a registered `Locker`, run it over
+ that integration's declarations. Today that is bundler only:
+ `BundlerLocker` writes the Gemfile and runs `bundle lock`, producing
+ `Gemfile.lock`. After this step, tool-solved universes are materialized
+ on disk.
+2. **Resolve** — the `Resolver`, per declaration:
+ - rejects declaration sets where one package (integration + name)
+ carries disagreeing constraints, sources, revisions, or
+ materializations (axes — group/platform/host/env — may differ; the
+ same name under two integrations is two packages, free to differ);
+ - if the declaration carries a `revision`, dispatches to
+ `at(id, revision)` and mints the pin from the lifted version
+ directly — no universe query, no scheme, no selection (the author
+ foreran resolution); integrations without a continuous space refuse
+ loudly (`NoAddressableSpaceError`);
+ - otherwise builds the `PackageId` (the declaration's `source` rides
+ the id) and calls `find(id)` — identity in, universe out; nothing
+ version-shaped crosses this seam;
+ - filters the reported versions through the integration's scheme
+ (`satisfies?`, fact-aware: schemes may match universe facts like a
+ steam branch or a git ref), treating scheme-unparseable universe
+ versions as non-candidates, and drops versions that don't publish
+ every explicitly requested platform; scheme-less integrations (url,
+ xcode) accept only the empty constraint — anything else is a loud
+ `UnknownIntegrationError`, never a silent pass;
+ - picks the highest satisfying version (`sort`), mints the
+ `Dependency` from that version's facts merged with the declaration's
+ `materialization` (install instructions meet version facts exactly
+ here — a url dep's `version_label` is promoted into the pin's
+ version slot when the universe reports none), projects the declared
+ platforms/target against the version's artifacts (the per-platform
+ `platforms` block or single-target digest), and projects the
+ declaration's `Scope` onto the pin's metadata (host/env keys,
+ present only when pinned);
+ - cases on the chosen version's `declarations` claim: a `Resolved`
+ claim's declarations are queued as synthetic `ScopedDeclaration`s
+ inheriting the parent's `Scope` as one unit (each already carries
+ the integration its repository stamped — the resolved set is keyed
+ by `PackageId`); a `ToolOwned` claim has nothing to walk.
+3. **Write** — pins go to `deps.lock` (app/test groups) and
+ `build-deps.lock` (build group), nested by integration
+ (`brew:` → `zlib:` → attrs) so the on-disk key carries the same
+ (integration, name) identity the resolver keys on. The reader also
+ accepts the pre-nesting flat format; that shim is deleted once every
+ consumer repo's lockfiles have been rewritten by `update-deps`.
+
+`dev install-deps` reads the lockfile and hands each integration its pins;
+no resolution happens at install time.
+
+### Resolution flow (`dev update-deps`)
+
+```mermaid
+sequenceDiagram
+ participant cmd as update_deps_command
+ participant lkr as BundlerLocker
+ participant bundler as bundler CLI
+ participant res as Resolver
+ participant rep as Repository (per integration)
+ participant backing as Backing service
+ participant sch as VersionScheme (per integration)
+ participant lock as Lockfile
+
+ Note over cmd: load dependencies.rb into ScopedDeclaration[]
+ cmd->>lkr: lock(bundler declarations)
+ lkr->>lkr: write Gemfile from declarations
+ lkr->>bundler: shadowenv exec -- bundle lock
+ bundler-->>lkr: Gemfile.lock written (or LockError)
+ cmd->>res: resolve(all declarations)
+ Note over res: reject disagreeing constraints per (integration, name) - ConflictingDeclarationError
+ loop until queue empty (declared + transitive)
+ alt declaration carries a revision (continuous space)
+ res->>rep: at(PackageId, revision)
+ Note over rep: pure lift, no I/O — the address is trusted now, verified at install
+ rep-->>res: PackageVersion
+ Note over res: no scheme, no selection — mint the pin directly
+ else constraint over the published universe (discrete space)
+ res->>rep: find(PackageId)
+ rep->>backing: query universe (registry API / Gemfile.lock / ls-remote / GraphQL)
+ backing-->>rep: raw versions, platforms, declared deps, digests
+ Note over rep: normalize upstream constraints into dev's shape, stamp its integration, state its Declarations claim (Resolved | ToolOwned)
+ rep-->>res: Package (PackageVersion facts)
+ res->>sch: satisfies?(version, constraint) each, then sort
+ sch-->>res: ordered satisfying candidates
+ Note over res: drop versions missing an explicitly requested platform, pick max (NoSatisfyingVersionError if none), mint the Dependency pin into the PackageId-keyed resolved set — version facts + declaration materialization + artifact projection + Scope
+ end
+ opt claim is Resolved
+ Note over res: queue its Declarations as ScopedDeclarations under the parent's Scope
+ end
+ end
+ res-->>cmd: Dependency[] pins
+ cmd->>lock: lock(pins, manifest_digest)
+ Note over lock: writes deps.lock and build-deps.lock, nested by integration
+```
+
+### Install flow (`dev install-deps`)
+
+```mermaid
+sequenceDiagram
+ participant up as install command
+ participant st as Staleness
+ participant inst as Installer
+ participant lock as Lockfile
+ participant integ as Integration (per type)
+ participant tool as Backing tool
+
+ up->>st: install_message (manifest vs lock vs installed-stamp digests)
+ up->>inst: install(env:, host:)
+ inst->>lock: read
+ lock-->>inst: Dependency[] pins
+ Note over inst: filter by env/host, dispatch build group first
+ inst->>integ: install_all(pins)
+ integ->>tool: shadowenv exec -- bundle install / brew / pip / steamcmd ...
+ tool-->>integ: installed (typed InstallError on failure)
+ up->>st: stamp_installed!
+```
+
+The repositories never appear in the install flow: pins are read from the
+lockfiles, and each Integration drives its backing tool. The Locker never
+appears inside the resolution loop: it runs once, before, sequenced by the
+command.
+
+## Constraint semantics per integration
+
+| Integration | Scheme | Constraint language |
+| --- | --- | --- |
+| bundler | `GemScheme` | rubygems requirements (`~>`, `>=`, …) — but selection is degenerate: the universe is the lock's singleton choice |
+| ficsit | `SemverScheme` | node-style ranges (`^`, `~`, comparators) |
+| pip | `Pep440Scheme` | PEP 440 specifiers (`==`, `~=`, wildcards, conjunction) |
+| luarocks | `RockScheme` | rockspec-style comparators and `~>` |
+| gh | `ExactScheme(key: "tag")` | the constraint names one release tag out of the enumerated releases+tags universe; no range grammar exists by design. Unconstrained selects the latest release. |
+| cmake | `GitScheme` | `tag:`/`branch:` match the version's `ref` fact over the enumerated `ls-remote` refs. `commit:` is not a constraint at all — it is a revision (continuous space, `at`). |
+| steam | `SteamScheme` | `branch:` selects by the version's branch fact (default `public`); optional `buildid:` is an exact assertion that fails loudly when it is no longer the branch tip. |
+| brew, cask | `BrewScheme` | `version:` is a formula *suffix* (`"18"` selects the `llvm@18` sibling out of the enumerated spec family), matched against the `version_suffix` fact; the reported stable version is brew's record, not the coordinate. Casks have no suffix fact — versioned casks are distinct cask names (`temurin@21`), so `version:` on a cask is unsatisfiable by construction. |
+| url, xcode | — (no scheme) | no constraint grammar exists: url's universe is an observable-now singleton, xcode is revision-addressed. Only the empty constraint is legal; anything else raises. A url `tag:` is a display label riding materialization, naming, never selection. |
+
+**The constraint standard is a shape plus an interpreter, never a
+grammar.** Every constraint in the system is a dev-shaped hash whose keys
+the integration's `VersionScheme` owns (`{ "version" => "^3.6" }`,
+`{ "tag" => "v1.0" }`, `{}` = unconstrained); repositories mint that shape
+at the `find` seam, normalizing whatever syntax the upstream manifest used
+— constraints cross the system boundary exactly once. A universal
+constraint grammar across semver/PEP 440/buildids would be a lie, so
+cross-ecosystem capability grows by widening the scheme algebra (a future
+`intersect`), never by translating vocabularies. Dev-native territory
+(no upstream scheme to inherit) defaults to SemVer via `SemverScheme`.
+
+Scheme parse failures split by whose fault they are:
+`VersionScheme::InvalidConstraintError` (the user's declaration is wrong —
+propagates) vs `VersionScheme::InvalidVersionError` (the universe contains
+a version that ignores the ecosystem's conventions — the Resolver skips
+that candidate).
+
+## Integrity regimes
+
+Who guarantees the bytes you install are the bytes that were resolved:
+
+- **dev-enforced** — the repository reports a digest fact, the pin carries
+ it, and the integration (or `Cache`) verifies downloaded bytes against
+ it. ficsit (per-target SHA256 from the API), url (trust-on-first-use:
+ download at resolve time, hash, pin), pip (sdist SHA256 from PyPI's
+ JSON API), bundler (`Gemfile.lock` CHECKSUMS, verified by
+ `bundle install --frozen`).
+- **tool-enforced** — the ecosystem tool verifies integrity itself at
+ install; dev records what it can for audit but doesn't gate on it.
+ brew (bottle SHA256s are brew's own check), gh (release assets carry
+ API digests; `gh` downloads), steam (Steam's own depot verification),
+ luarocks (rockspec digests checked by luarocks).
+- **identity-as-integrity** — git SHAs: pinning the 40-char commit *is*
+ the integrity statement; there is no separate digest.
+
+A nil `PackageVersion#digest` means exactly "upstream publishes none" —
+never "we didn't bother".
+
+## Transitive-dependency regimes
+
+Who owns an installed package's transitive closure. The claim travels
+*with the data*: each repository constructs the `Declarations` variant its
+regime warrants, so construction is the dispatch — there is no registry
+attribute, repository enum, or resolver guard to drift out of sync.
+
+| Regime | Integrations | Claim | How transitives happen |
+| --- | --- | --- | --- |
+| dev-resolved | ficsit | `Resolved(declarations)` | The resolver walks the declarations, inheriting the parent's `Scope`; every transitive becomes its own pin. |
+| tool-locked | bundler | `ToolOwned` | `BundlerLocker` makes the tool solve the whole set up front (`bundle lock`); the repository reads pinned versions back. |
+| tool-at-install | pip, luarocks, brew | `ToolOwned` | The tool resolves the closure when it installs; dev pins top-level packages only. |
+| self-contained | steam, git, xcode, url — and gh | `Resolved([])` | Nothing to resolve: the artifact carries everything it needs. steam/git/xcode/url guarantee it by construction; gh's is a usage contract (prebuilt assets are baked; a source build's needs are declared by the consuming project) until subproject resolution lands. |
+
+Two standing decisions:
+
+- **Lock files are never availability facts.** A repository answers "what
+ exists now"; an integration's lock file is a past solve's snapshot, so
+ its graph is never mined for `Resolved` declarations.
+ `BundlerRepository#find` reading `Gemfile.lock` is consistent with this:
+ the Locker regenerates that file in step 1 of the same run, and even
+ then only pinned versions are read — bundler stays `ToolOwned`.
+- **`Resolved([])` and `ToolOwned` are different claims.** "This version
+ affirmatively requires nothing" and "the tool owns a closure dev can't
+ see" used to collapse into the same empty array; the sum type keeps a
+ future solver from walking a universe that was never observable.
+
+## Adding a new ecosystem
+
+1. **Repository** — subclass `Repository`, implement `find(id) ->
+ Package`. Report facts for every published version — enumerate the
+ whole discrete universe; identity is the only input. If the ecosystem
+ also has a continuous space (addressable revisions between published
+ versions, like git SHAs), override `at(id, revision)` as a *pure* lift
+ — no I/O; the address is verified at install. Facts are unconditional
+ — never read install instructions, which don't reach this seam. State
+ your transitive regime by construction: build the `Declarations`
+ variant your ecosystem warrants, normalizing upstream constraint
+ syntax into dev's shape as you do (no upstream scheme means SemVer).
+ Raise a subclass of `Repository::PackageNotFoundError` when the
+ identity doesn't exist. Never pick a version.
+2. **Scheme** — subclass `VersionScheme` with your ecosystem's
+ `satisfies?`/`sort`, nesting
+ `InvalidConstraintError`/`InvalidVersionError` under the shared bases.
+ If the constraint names one exact coordinate, `ExactScheme(key:)`
+ probably already covers you. Every ecosystem with a constraint grammar
+ states its real semantics — there is no satisfies-everything scheme; an
+ ecosystem with *no* grammar registers `scheme: nil` and only the empty
+ constraint is legal against it.
+3. **Locker** — only if the ecosystem's own tool must own the whole-set
+ solve (transitive co-resolution you can't reproduce): subclass
+ `Locker`, make the tool materialize its lock, and have the repository
+ `find` read it.
+4. **Integration** — subclass `Integration` to install pins.
+5. **Registry** — add the `Entry`. The consistency tests will hold you to
+ it.
+6. **DSL** — add the declaration verb in `dsl.rb`, and its symbol to the
+ consistency test's `DECLARATION_INTEGRATIONS`.
+
+## Decision gate: who owns the solve
+
+Two models for whole-set dependency resolution:
+
+- **A. dev-owned** — dev enumerates universes (`find`), evaluates
+ constraints (schemes), and picks versions, including joint constraint
+ satisfaction across the graph. Full control; enables cross-project
+ resolution of another repo's `dependencies.rb`; requires implementing
+ real dependency solving per ecosystem.
+- **B. tool-owned** — the ecosystem tool solves (bundle lock / pip /
+ luarocks at install), and dev records its answer.
+
+**Current stance: per-ecosystem hybrid, leaning A.** The interfaces are
+Model A's — `find` + schemes + Resolver choice — and ficsit already
+resolves fully dev-owned (universe, ranges, transitives). bundler stays
+tool-owned behind `BundlerLocker` because reproducing Bundler's joint
+solve is high cost for zero behavioral gain. pip and luarocks currently
+pin top-level packages dev-owned and let the tool resolve transitives at
+install, same fidelity as before.
+
+Revisit (the gate): if we need cross-ecosystem joint solving, offline
+resolution of a foreign project, or reproducible pip/luarocks transitive
+pins, the missing piece is per-ecosystem *declared-deps facts* (a
+`Resolved` claim in `find`) plus a backtracking solver in the Resolver —
+the interfaces already accommodate both (`PackageVersion#declarations` is
+the slot). No interface change is expected; the cost is per-ecosystem
+declaration enumeration and solver work, so pay it per ecosystem when the
+need is real, not up front.
diff --git a/lib/dev/build_container.rb b/lib/dev/build_container.rb
index 1298276..61a8455 100644
--- a/lib/dev/build_container.rb
+++ b/lib/dev/build_container.rb
@@ -8,6 +8,7 @@
require "yaml"
require "dev/build_watcher"
+require "dev/deps/lockfile"
module Dev
# Content-addressed Docker image management for build containers.
@@ -33,11 +34,6 @@ module BuildContainer
# invalidates a prewarmed image. Missing files are skipped (see content_tag).
CONTENT_FILES = ["Dockerfile", ".dockerignore", "deps.lock", "build-deps.lock"].freeze
TAG_PREFIX = "content-"
- BUILD_DEPS_LOCK = "build-deps.lock"
- # Both lockfiles are scanned for version-keyed install_dir resolution: gh
- # build deps (e.g. the engine) land in build-deps.lock, while integration
- # deps (e.g. the Satisfactory server) land in deps.lock.
- LOCKFILES = ["deps.lock", "build-deps.lock"].freeze
module_function
@@ -233,21 +229,20 @@ def build_and_prewarm!(tag, config:, project_root:, build_args:, secrets:, prewa
# @return [Hash{String => String}] context name => absolute host path
sig { params(project_root: Pathname).returns(T::Hash[String, String]) }
def build_contexts_from_lockfile(project_root)
- path = Pathname(project_root) / BUILD_DEPS_LOCK
- return {} unless path.exist?
-
- yaml = YAML.safe_load(path.read, permitted_classes: [Symbol]) || {}
-
contexts = {}
- yaml.each do |name, attrs|
- next if name == "env" # env-scoped deps are not whole-image build inputs
- next unless attrs.is_a?(Hash)
- next unless attrs["group"] == "build" && attrs["install_dir"]
+ locked_deps(project_root).each do |dep|
+ # Env-scoped deps are not whole-image build inputs; group == :build
+ # limits us to build-deps.lock entries.
+ next unless dep.group == :build
+ next if dep.metadata&.key?("env")
+
+ install_dir = dep.metadata&.fetch("install_dir", nil)
+ next unless install_dir
- base = File.expand_path(attrs["install_dir"])
+ base = File.expand_path(install_dir)
# Point at the version-keyed subdir the integration publishes to, so the
# build context tracks the locked version (see resolve_versioned_volumes).
- contexts[name.downcase] = attrs["version"] ? File.join(base, attrs["version"].to_s) : base
+ contexts[dep.name.downcase] = dep.version ? File.join(base, dep.version.to_s) : base
end
contexts
end
@@ -282,33 +277,22 @@ def resolve_versioned_volumes(volumes, project_root:)
# @return [Hash{String => String}] expanded install_dir => version
sig { params(project_root: Pathname).returns(T::Hash[String, String]) }
def install_dir_versions(project_root)
- root = Pathname(project_root)
- LOCKFILES.each_with_object({}) do |file, acc|
- path = root / file
- next unless path.exist?
+ locked_deps(project_root).each_with_object({}) do |dep, acc|
+ install_dir = dep.metadata&.fetch("install_dir", nil)
+ next unless install_dir && dep.version
- yaml = YAML.safe_load(path.read, permitted_classes: [Symbol]) || {}
- collect_install_dir_versions(yaml, acc)
+ acc[File.expand_path(install_dir)] = dep.version.to_s
end
end
- # Recursively collect {expanded install_dir => version} from a lockfile hash,
- # descending into the nested env: section of build-deps.lock.
+ # All locked dependencies from both lockfiles, parsed by Lockfile so
+ # format knowledge (including the legacy flat format) lives in one place.
#
- # @param yaml [Hash]
- # @param acc [Hash{String => String}] accumulator (mutated)
- # @return [void]
- sig { params(yaml: T::Hash[T.untyped, T.untyped], acc: T::Hash[String, String]).void }
- def collect_install_dir_versions(yaml, acc)
- yaml.each do |name, attrs|
- next unless attrs.is_a?(Hash)
-
- if name == "env"
- attrs.each_value { |env_deps| collect_install_dir_versions(env_deps, acc) }
- elsif attrs["install_dir"] && attrs["version"]
- acc[File.expand_path(attrs["install_dir"])] = attrs["version"].to_s
- end
- end
+ # @param project_root [Pathname]
+ # @return [Array]
+ sig { params(project_root: Pathname).returns(T::Array[Dev::Deps::Dependency]) }
+ def locked_deps(project_root)
+ Dev::Deps::Lockfile.new(dir: project_root).read
end
# Build a docker run command for executing a shell command inside the container.
diff --git a/lib/dev/deps.rb b/lib/dev/deps.rb
index 5bcea30..d43c404 100644
--- a/lib/dev/deps.rb
+++ b/lib/dev/deps.rb
@@ -1,17 +1,25 @@
-# typed: true
+# typed: strict
# frozen_string_literal: true
+require "sorbet-runtime"
require_relative "deps/config"
require_relative "deps/cli_ui"
require_relative "deps/lockfile"
require_relative "deps/fetcher"
-require_relative "deps/dependency_installer"
+require_relative "deps/installer"
module Dev
module Deps
- @last_config = nil
+ @last_config = T.let(nil, T.nilable(Config))
class << self
+ extend T::Sig
+
+ # Evaluate a dependencies.rb DSL block into a Config and remember it.
+ #
+ # @param block [Proc] DSL block evaluated in DSL context
+ # @return [Config]
+ sig { params(block: T.nilable(T.proc.bind(DSL).void)).returns(Config) }
def define(&block)
@last_config = Config.define(&block)
end
@@ -20,12 +28,16 @@ def define(&block)
# Useful for retrieving the config after loading a dependencies.rb file.
#
# @return [Config, nil]
+ sig { returns(T.nilable(Config)) }
attr_reader :last_config
# Clears the last defined config. Call before loading a dependencies.rb:
# a file that never calls .define (e.g. dev's own bootstrap-constants
# dependencies.rb) would otherwise leave a previously loaded project's
# config visible as if it were its own.
+ #
+ # @return [void]
+ sig { void }
def reset!
@last_config = nil
end
@@ -39,6 +51,7 @@ def reset!
# detecting it — fix by declaration, not detection.
#
# @return [String] "ci" or "dev"
+ sig { returns(String) }
def detect_env
ENV["CI"].to_s =~ /\A(true|1)\z/i ? "ci" : "dev"
end
@@ -47,6 +60,7 @@ def detect_env
# declaration axis). Matches the symbols the DSL accepts (:darwin, :linux).
#
# @return [String] "darwin", "linux", or "windows"
+ sig { returns(String) }
def detect_host
case RUBY_PLATFORM
when /darwin/ then "darwin"
diff --git a/lib/dev/deps/artifact.rb b/lib/dev/deps/artifact.rb
new file mode 100644
index 0000000..db2488e
--- /dev/null
+++ b/lib/dev/deps/artifact.rb
@@ -0,0 +1,68 @@
+# typed: strict
+# frozen_string_literal: true
+
+module Dev
+ module Deps
+ # A single downloadable file that dev itself fetches.
+ #
+ # Artifacts exist only for ecosystems where dev does the downloading
+ # (ficsit target zips, gh release assets, url tarballs). Tool-mediated
+ # ecosystems have none: bundler fetches its own gems, so a bundler
+ # PackageVersion carries an empty artifact set rather than nil-stuffed
+ # placeholder entries.
+ #
+ # The digest here is an *enforcement input*: dev verifies downloaded bytes
+ # against it and keys the download cache with it. A nil digest has exactly
+ # one meaning — upstream publishes none — and is computed trust-on-first-use
+ # at fetch time. See the integrity regimes section of
+ # docs/deps-architecture.md for who enforces what, and where.
+ class Artifact
+ extend T::Sig
+
+ # The artifact has no URI, so dev cannot locate the bytes.
+ class MissingUriError < StandardError; end
+
+ # @return [String] where the bytes live
+ sig { returns(String) }
+ attr_reader :uri
+
+ # @return [String, nil] published integrity digest ("SHA256=…"), or nil
+ # when upstream publishes none
+ sig { returns(T.nilable(String)) }
+ attr_reader :digest
+
+ # The uri parameter is typed nilable because artifacts are built from
+ # backing-service payloads (registry JSON, GraphQL responses) where the
+ # field can be absent — validating it here is boundary coercion, not
+ # defensive programming against internal callers.
+ #
+ # @param uri [String, nil] where the bytes live
+ # @param digest [String, nil] published integrity digest, if any
+ # @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?
+
+ @uri = T.let(uri, String)
+ @digest = digest
+ freeze
+ end
+
+ # @param other [Object]
+ # @return [Boolean] whether other describes the same bytes
+ sig { params(other: T.untyped).returns(T::Boolean) }
+ def ==(other)
+ return false unless other.is_a?(Artifact)
+
+ [uri, digest] == [other.uri, other.digest]
+ end
+ alias_method :eql?, :==
+
+ # @return [Integer] hash code
+ sig { returns(Integer) }
+ def hash
+ [self.class, uri, digest].hash
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/brew_cask_repository.rb b/lib/dev/deps/brew_cask_repository.rb
new file mode 100644
index 0000000..a71f37b
--- /dev/null
+++ b/lib/dev/deps/brew_cask_repository.rb
@@ -0,0 +1,56 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
+require_relative "repository"
+
+module Dev
+ module Deps
+ # Reports Homebrew casks (:cask integration): an unversioned singleton
+ # universe.
+ #
+ # A separate universe from BrewRepository's formulae because the two are
+ # genuinely different package spaces with different facts: Homebrew
+ # exposes neither versions nor bottle digests for casks the way it does
+ # for formulae, so there is nothing to query — the name's presence is the
+ # whole fact. Integrity is delegated to brew at install time, the same
+ # nil-hash shape Steam uses.
+ class BrewCaskRepository < Repository
+ extend T::Sig
+
+ # Version stand-in for casks, whose versions Homebrew does not expose;
+ # the Resolver mints it back to a nil pin version.
+ UNVERSIONED = ""
+
+ # Report a cask's universe: one unversioned entry.
+ #
+ # Versioned casks are distinct cask names in Homebrew's own universe
+ # (`temurin@21`), so the name is the whole coordinate — there is no
+ # suffix fact to select over, and a `version:` constraint on a cask is
+ # unsatisfiable by construction (BrewScheme finds no suffix to match,
+ # loudly).
+ #
+ # @param id [PackageId] name is the cask name
+ # @return [Package] a singleton universe
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ metadata = { "cask" => true }
+
+ Package.new(
+ id: id,
+ versions: [
+ PackageVersion.new(
+ version: UNVERSIONED,
+ metadata: metadata,
+ # brew installs cask dependencies itself.
+ declarations: Declarations::ToolOwned.new,
+ ),
+ ],
+ )
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/brew_integration.rb b/lib/dev/deps/brew_integration.rb
index 3cd2f59..5447d48 100644
--- a/lib/dev/deps/brew_integration.rb
+++ b/lib/dev/deps/brew_integration.rb
@@ -76,12 +76,13 @@ def ensure_taps_registered
sig { params(tap: Tap).void }
def register_tap(tap)
project_dir = @project_dir
- if tap.local? && project_dir
- path = resolve_file_url(tap.url, project_dir)
+ url = tap.url
+ if tap.local? && project_dir && url
+ path = resolve_file_url(url, project_dir)
success = system("brew", "tap", tap.name, path)
raise TapRegistrationError, "brew tap #{tap.name} #{path} failed" unless success
- elsif tap.url
- url_str = tap.url.to_s
+ elsif url
+ url_str = url.to_s
success = system("brew", "tap", tap.name, url_str)
raise TapRegistrationError, "brew tap #{tap.name} #{url_str} failed" unless success
else
@@ -100,7 +101,8 @@ def setup_tap_env
return unless local_tap
ENV["TAP_NAME"] = local_tap.name
- ENV["LOCAL_TAP_DIR"] = resolve_file_url(local_tap.url, project_dir) if local_tap.url
+ url = local_tap.url
+ ENV["LOCAL_TAP_DIR"] = resolve_file_url(url, project_dir) if url
end
# Resolve a file:// URI to an absolute path relative to project_dir.
diff --git a/lib/dev/deps/brew_repository.rb b/lib/dev/deps/brew_repository.rb
index b1a720c..82827d2 100644
--- a/lib/dev/deps/brew_repository.rb
+++ b/lib/dev/deps/brew_repository.rb
@@ -3,93 +3,92 @@
require "json"
require "open3"
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
- # Fetches Homebrew formulae to exact version + bottle SHA256.
+ # Reports Homebrew formulae: the discrete universe is the formula-spec
+ # family — the bare spec plus its versioned siblings (llvm, llvm@18, …),
+ # each contributing the one stable version it currently has.
#
- # Uses `brew info --json=v1` for formulae. Cask entries get no version
- # or hash (Homebrew doesn't expose bottle hashes for casks in the same way).
+ # Brew is a moving registry (one current version per spec), but the
+ # family is enumerable first-class: `brew info --json=v1 ` reports
+ # the bare spec's facts plus its versioned_formulae list, and one batched
+ # info call fetches every sibling's facts. Each sibling's suffix rides
+ # its version as the version_suffix fact BrewScheme's version: constraint
+ # matches. Third-party tap formulae report an empty family and degrade to
+ # a singleton universe. Casks are a separate universe
+ # (BrewCaskRepository) under the :cask integration.
class BrewRepository < Repository
extend T::Sig
class BrewInfoError < StandardError; end
- # Resolve a brew dependency identifier to a pinned Dependency.
+ # Report a brew formula's universe: the spec family's current stable
+ # versions, bare spec last (the unconstrained pick — BrewScheme
+ # preserves order and the Resolver takes the last version).
#
- # For casks, returns a Dependency with nil version/hash.
- # For formulae, queries `brew info --json=v1` for the stable version
- # and bottle SHA256.
+ # The tap scoping the name is the package's source coordinate
+ # (PackageId#source). Suffix and tap ride metadata as facts: BrewScheme
+ # matches the suffix, BrewIntegration rebuilds the install spec from
+ # both. Head-only siblings without a stable version are not versions
+ # and are skipped.
#
- # @param id [Hash] must include "name", "integration", "group";
- # optionally "tap", "cask"
- # @return [Dependency]
- # @raise [BrewInfoError] if `brew info` fails for a formula
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- name = id["name"]
- # The declared `version:` is a brew formula version *suffix* (e.g. "18"
- # selects the llvm@18 formula), not a semver to resolve — same meaning the
- # container build path gives it. The resolved stable version below is the
- # exact installed version, recorded for the lockfile.
- version_suffix = id["version"]
-
- if id["cask"]
- cask_metadata = { "cask" => true }
- cask_metadata["version_suffix"] = version_suffix if version_suffix
- return Dependency.new(
- name: name,
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: nil,
- hash: nil,
- metadata: cask_metadata,
- )
- end
-
- info = brew_info_with_tap(build_formula_spec(name, id["tap"], version_suffix), id["tap"])
-
- version = info["versions"]["stable"]
+ # @param id [PackageId] name is the formula name; source is the tap
+ # @return [Package] one version per family spec
+ # @raise [BrewInfoError] if `brew info` fails for the formula
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ tap = id.source
+ base_info = brew_info_with_tap(build_formula_spec(id.name, tap), tap)
+
+ family = T.let(base_info["versioned_formulae"] || [], T::Array[String])
+ sibling_infos = family.empty? ? [] : brew_info_all(family.map { |name| build_formula_spec(name, tap) })
+
+ versions = (sibling_infos + [base_info])
+ .select { |info| info.dig("versions", "stable") }
+ .map { |info| version_from(info, tap) }
+ Package.new(id: id, versions: versions)
+ end
+
+ private
+
+ # One family member's facts as a version: its stable version string,
+ # its bottle digest, and the @suffix from its own spec name.
+ #
+ # @param info [Hash] parsed brew info JSON for one formula
+ # @param tap [String, nil] tap slug from the id
+ # @return [PackageVersion]
+ sig { params(info: T::Hash[String, T.untyped], tap: T.nilable(String)).returns(PackageVersion) }
+ def version_from(info, tap)
bottle_hash = extract_bottle_hash(info)
+ suffix = info["name"].to_s.split("@", 2)[1]
- # env/host scoping is attached by the Resolver (attach_install_scoping),
- # not read from the fetch id — the id describes what the dep is.
metadata = {}
- metadata["tap"] = id["tap"] if id["tap"]
- metadata["version_suffix"] = version_suffix if version_suffix
-
- Dependency.new(
- name: name,
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: version,
- hash: bottle_hash ? "SHA256=#{bottle_hash}" : nil,
- metadata: metadata.empty? ? {} : metadata,
+ metadata["tap"] = tap if tap
+ metadata["version_suffix"] = suffix if suffix
+
+ PackageVersion.new(
+ version: info["versions"]["stable"],
+ digest: bottle_hash ? "SHA256=#{bottle_hash}" : nil,
+ metadata: metadata,
+ # brew installs formula dependencies itself.
+ declarations: Declarations::ToolOwned.new,
)
end
- private
-
- # Build a brew formula spec: [tap/]name[@version_suffix]. Querying the
- # suffixed spec (e.g. "llvm@18") returns that versioned formula's stable
- # version and bottle, not the latest formula's.
+ # Build a brew formula spec: [tap/]name.
#
- # @param name [String] formula name
+ # @param name [String] formula name (possibly @-suffixed already)
# @param tap [String, nil] tap slug
- # @param version_suffix [String, nil] brew version suffix (e.g. "18")
# @return [String]
- sig do
- params(
- name: String,
- tap: T.nilable(String),
- version_suffix: T.nilable(String),
- ).returns(String)
- end
- def build_formula_spec(name, tap, version_suffix)
- base = tap ? "#{tap}/#{name}" : name
- version_suffix ? "#{base}@#{version_suffix}" : base
+ sig { params(name: String, tap: T.nilable(String)).returns(String) }
+ def build_formula_spec(name, tap)
+ tap ? "#{tap}/#{name}" : name
end
# Query brew info, registering the declaration's tap first when the
@@ -102,24 +101,24 @@ def build_formula_spec(name, tap, version_suffix)
# @raise [BrewInfoError] if the command fails
sig { params(formula: String, tap: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
def brew_info_with_tap(formula, tap)
- brew_info(formula)
+ T.must(brew_info_all([formula]).first)
rescue BrewInfoError
raise unless tap && register_tap(tap)
- brew_info(formula)
+ T.must(brew_info_all([formula]).first)
end
- # Query `brew info --json=v1` for a formula.
+ # Query `brew info --json=v1` for one or more formulae in a single call.
#
- # @param formula [String] formula spec (e.g. "cmake" or "d3mlabs/d3mlabs/powershell")
- # @return [Hash] parsed JSON info for the formula
+ # @param formulae [Array] formula specs
+ # @return [Array] parsed JSON info, one entry per formula
# @raise [BrewInfoError] if the command fails
- sig { params(formula: String).returns(T::Hash[String, T.untyped]) }
- def brew_info(formula)
- out, _err, status = Open3.capture3("brew", "info", "--json=v1", formula)
- raise BrewInfoError, "brew info --json=v1 #{formula} failed" unless status.success?
+ sig { params(formulae: T::Array[String]).returns(T::Array[T::Hash[String, T.untyped]]) }
+ def brew_info_all(formulae)
+ out, _err, status = T.unsafe(Open3).capture3("brew", "info", "--json=v1", *formulae)
+ raise BrewInfoError, "brew info --json=v1 #{formulae.join(" ")} failed" unless status.success?
- JSON.parse(out).first
+ JSON.parse(out)
end
# @param tap [String] tap slug (e.g. "xcodesorg/made")
diff --git a/lib/dev/deps/brew_scheme.rb b/lib/dev/deps/brew_scheme.rb
new file mode 100644
index 0000000..89321f3
--- /dev/null
+++ b/lib/dev/deps/brew_scheme.rb
@@ -0,0 +1,40 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "version_scheme"
+
+module Dev
+ module Deps
+ # Homebrew constraint semantics (:brew, :cask): the constraint's "version"
+ # is a formula *suffix* ("18" selects the llvm@18 formula), not a range
+ # over the reported stable versions — brew's own universe treats llvm@18
+ # as a distinct formula, so the suffix is the coordinate.
+ #
+ # The suffix is matched against the version's "version_suffix" fact (the
+ # reported stable version, e.g. "18.1.8", is brew's record, not the
+ # coordinate). BrewRepository enumerates the whole spec family (the bare
+ # formula plus its versioned_formulae siblings), so the suffix selects a
+ # sibling out of the reported universe.
+ class BrewScheme < VersionScheme
+ extend T::Sig
+
+ # @param version [PackageVersion] a candidate ("version_suffix" rides
+ # its metadata when the formula spec was suffixed)
+ # @param constraint [Hash] declaration constraint; "version" holds the suffix
+ # @return [Boolean]
+ sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ suffix = constraint["version"]
+ suffix.nil? || version.metadata["version_suffix"].to_s == suffix.to_s
+ end
+
+ # @param versions [Array] reported stable versions
+ # @return [Array] the same versions, order untouched — brew
+ # reports one current version per formula spec
+ sig { override.params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ versions.dup
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/bundler_locker.rb b/lib/dev/deps/bundler_locker.rb
new file mode 100644
index 0000000..a6516a7
--- /dev/null
+++ b/lib/dev/deps/bundler_locker.rb
@@ -0,0 +1,122 @@
+# typed: strict
+# frozen_string_literal: true
+
+require "open3"
+require "pathname"
+require_relative "locker"
+require_relative "scoped_declaration"
+
+module Dev
+ module Deps
+ # Whole-set gem solve through bundler.
+ #
+ # Generates a Gemfile from the declared :bundler deps (dependencies.rb is
+ # the source of truth) and runs `bundle lock` to pin the full gem graph
+ # into Gemfile.lock. BundlerRepository reads the pins back from there.
+ class BundlerLocker < Locker
+ extend T::Sig
+
+ # `bundle lock` exited non-zero — typically an unresolvable gem set.
+ class LockError < StandardError; end
+
+ GEMFILE = "Gemfile"
+ RUBYGEMS_SOURCE = "https://rubygems.org"
+
+ GENERATED_HEADER = <<~HEADER
+ # Generated by dev from dependencies.rb. Do not edit.
+ # Add or change gems in dependencies.rb and run `dev update-deps`.
+ HEADER
+
+ # @param project_root [Pathname, String] root the Gemfile/Gemfile.lock live in
+ # @param ruby_version_requirement [String, nil] requirement for the Gemfile's
+ # `ruby` directive (from dependencies.rb's ruby_version), or nil to omit it
+ sig do
+ params(
+ project_root: T.any(Pathname, String),
+ ruby_version_requirement: T.nilable(String),
+ ).void
+ end
+ def initialize(project_root:, ruby_version_requirement: nil)
+ super()
+ @project_root = T.let(Pathname(project_root), Pathname)
+ @ruby_version_requirement = ruby_version_requirement
+ end
+
+ # Generate the Gemfile from all gem declarations and lock it.
+ #
+ # @param declarations [Array] :bundler declarations
+ # @return [void]
+ # @raise [LockError] if bundle lock fails
+ sig { override.params(declarations: T::Array[ScopedDeclaration]).void }
+ def lock(declarations)
+ return if declarations.empty?
+
+ write_gemfile(declarations)
+ run_bundle_lock
+ end
+
+ private
+
+ # Generate a Gemfile from the declarations, mapping each dev group to a
+ # bundler group (the default group stays unscoped, like a hand-written
+ # Gemfile's top section).
+ #
+ # @param declarations [Array]
+ # @return [void]
+ sig { params(declarations: T::Array[ScopedDeclaration]).void }
+ def write_gemfile(declarations)
+ lines = [GENERATED_HEADER, %(source "#{RUBYGEMS_SOURCE}")]
+ lines << %(ruby "#{@ruby_version_requirement}") if @ruby_version_requirement
+
+ ungrouped, grouped = declarations.partition { |decl| decl.scope.group == DSL::DEFAULT_GEM_GROUP }
+
+ ungrouped.each { |decl| lines << gem_line(decl) }
+ grouped.group_by { |decl| decl.scope.group }.each do |group, group_decls|
+ lines << ""
+ lines << "group :#{group} do"
+ group_decls.each { |decl| lines << " #{gem_line(decl)}" }
+ lines << "end"
+ end
+
+ gemfile_path.write("#{lines.join("\n")}\n")
+ end
+
+ # Render a single `gem` line from a declaration's constraint. "version" is
+ # the positional requirement; any other constraint keys become gem options.
+ #
+ # @param decl [ScopedDeclaration]
+ # @return [String]
+ sig { params(decl: ScopedDeclaration).returns(String) }
+ def gem_line(decl)
+ parts = [%(gem "#{decl.name}")]
+ constraint = decl.constraint
+ parts << constraint["version"].inspect if constraint["version"]
+
+ options = constraint.reject { |key, _| key == "version" }
+ options.each { |key, value| parts << "#{key}: #{value.inspect}" }
+
+ parts.join(", ")
+ end
+
+ # Run `bundle lock` against the generated Gemfile to write Gemfile.lock.
+ # Isolated so tests can stub the bundler boundary.
+ #
+ # @raise [LockError] if bundle lock fails
+ # @return [void]
+ sig { void }
+ def run_bundle_lock
+ _out, err, status = Open3.capture3(
+ { "BUNDLE_GEMFILE" => gemfile_path.to_s }, "bundle", "lock",
+ chdir: @project_root.to_s,
+ )
+ raise LockError, "bundle lock failed: #{err}" unless status.success?
+ end
+
+ # @return [Pathname]
+ sig { returns(Pathname) }
+ def gemfile_path
+ @project_root / GEMFILE
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/bundler_repository.rb b/lib/dev/deps/bundler_repository.rb
index e7b7b94..3cfa20b 100644
--- a/lib/dev/deps/bundler_repository.rb
+++ b/lib/dev/deps/bundler_repository.rb
@@ -1,95 +1,69 @@
# typed: strict
# frozen_string_literal: true
-require "open3"
require "pathname"
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
- # Resolves Ruby gem dependencies through bundler.
+ # Fact universe over the bundler-materialized Gemfile.lock.
#
# Gems are a first-class dev-managed dependency type whose backing tool is
- # bundler — just as brew backs :brew and gh backs :gh. dependencies.rb is the
- # source of truth: dev generates a Gemfile from the declared :bundler deps,
- # runs `bundle lock` to pin the full graph, and reads back the locked versions.
- #
- # Unlike per-dependency repositories, bundler resolves all gems together, so
- # the work happens once in the batch #prepare hook; #fetch then just reads the
- # pin for each declared gem out of the parsed Gemfile.lock. Transitive gems
- # are left to `bundle install` (they live in Gemfile.lock, not deps.lock).
+ # bundler — just as brew backs :brew and gh backs :gh. BundlerLocker runs
+ # the whole-set solve (`bundle lock`); this repository reads the resulting
+ # pins back. Each gem's universe is a singleton: the one version the joint
+ # solve chose, with the CHECKSUMS integrity digest when the lockfile has
+ # one. Transitive gems are left to `bundle install` (they live in
+ # Gemfile.lock, not deps.lock).
class BundlerRepository < Repository
extend T::Sig
- class LockError < StandardError; end
- class MissingGemError < StandardError; end
+ # The gem is absent from Gemfile.lock — the lock step didn't cover it.
+ class MissingGemError < PackageNotFoundError; end
- GEMFILE = "Gemfile"
LOCKFILE = "Gemfile.lock"
- RUBYGEMS_SOURCE = "https://rubygems.org"
-
- GENERATED_HEADER = <<~HEADER
- # Generated by dev from dependencies.rb. Do not edit.
- # Add or change gems in dependencies.rb and run `dev update-deps`.
- HEADER
- # @param project_root [Pathname, String] root the Gemfile/Gemfile.lock live in
- # @param ruby_version_requirement [String, nil] requirement for the Gemfile's
- # `ruby` directive (from dependencies.rb's ruby_version), or nil to omit it
- sig do
- params(
- project_root: T.any(Pathname, String),
- ruby_version_requirement: T.nilable(String),
- ).void
- end
- def initialize(project_root:, ruby_version_requirement: nil)
+ # @param project_root [Pathname, String] root the Gemfile.lock lives in
+ sig { params(project_root: T.any(Pathname, String)).void }
+ def initialize(project_root:)
@project_root = T.let(Pathname(project_root), Pathname)
- @ruby_version_requirement = ruby_version_requirement
@pins = T.let(nil, T.nilable(T::Hash[String, T::Hash[Symbol, T.nilable(String)]]))
end
- # Batch hook: generate the Gemfile from all gem declarations, lock it, and
- # parse the resulting pins. Runs once before any #fetch.
- #
- # @param declarations [Array] :bundler declarations
- # @return [void]
- sig { params(declarations: T::Array[DependencyDeclaration]).void }
- def prepare(declarations)
- return if declarations.empty?
-
- write_gemfile(declarations)
- run_bundle_lock
- @pins = parse_lockfile
- end
-
- # Return the locked Dependency for a declared gem.
+ # Report a gem's locked pin from Gemfile.lock as a singleton universe.
#
- # @param id [Hash] must include "name", "integration", "group"
- # @return [Dependency]
+ # @param id [PackageId] name is the gem name
+ # @return [Package] a singleton universe
# @raise [MissingGemError] if the gem is absent from the parsed Gemfile.lock
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- name = id["name"]
- pin = pins.fetch(name) do
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ pin = pins.fetch(id.name) do
raise MissingGemError,
- "gem #{name.inspect} is not in #{LOCKFILE} — run `dev update-deps`"
+ "gem #{id.name.inspect} is not in #{LOCKFILE} — run `dev update-deps`"
end
- Dependency.new(
- name: name,
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: pin[:version],
- hash: pin[:hash],
- metadata: {},
+ Package.new(
+ id: id,
+ versions: [
+ PackageVersion.new(
+ version: T.must(pin[:version]),
+ digest: pin[:hash],
+ # bundler owns the transitive closure. Gemfile.lock is read for
+ # pinned versions only — it is the tool's solve snapshot, never
+ # mined for dependency declarations.
+ declarations: Declarations::ToolOwned.new,
+ ),
+ ],
)
end
private
- # Lazily ensure the lockfile has been parsed (prepare populates it during a
- # full resolve; this guards direct fetch calls in tests).
+ # Lazily ensure the lockfile has been parsed.
#
# @return [Hash{String => Hash}]
sig { returns(T::Hash[String, T::Hash[Symbol, T.nilable(String)]]) }
@@ -97,61 +71,6 @@ def pins
@pins ||= parse_lockfile
end
- # Generate a Gemfile from the declarations, mapping each dev group to a
- # bundler group (the default group stays unscoped, like a hand-written
- # Gemfile's top section).
- #
- # @param declarations [Array]
- # @return [void]
- sig { params(declarations: T::Array[DependencyDeclaration]).void }
- def write_gemfile(declarations)
- lines = [GENERATED_HEADER, %(source "#{RUBYGEMS_SOURCE}")]
- lines << %(ruby "#{@ruby_version_requirement}") if @ruby_version_requirement
-
- ungrouped, grouped = declarations.partition { |decl| decl.group == DSL::DEFAULT_GEM_GROUP }
-
- ungrouped.each { |decl| lines << gem_line(decl) }
- grouped.group_by(&:group).each do |group, group_decls|
- lines << ""
- lines << "group :#{group} do"
- group_decls.each { |decl| lines << " #{gem_line(decl)}" }
- lines << "end"
- end
-
- gemfile_path.write("#{lines.join("\n")}\n")
- end
-
- # Render a single `gem` line from a declaration's constraint. "version" is
- # the positional requirement; any other constraint keys become gem options.
- #
- # @param decl [DependencyDeclaration]
- # @return [String]
- sig { params(decl: DependencyDeclaration).returns(String) }
- def gem_line(decl)
- parts = [%(gem "#{decl.name}")]
- constraint = decl.constraint
- parts << constraint["version"].inspect if constraint["version"]
-
- options = constraint.reject { |key, _| key == "version" }
- options.each { |key, value| parts << "#{key}: #{value.inspect}" }
-
- parts.join(", ")
- end
-
- # Run `bundle lock` against the generated Gemfile to write Gemfile.lock.
- # Isolated so tests can stub the bundler boundary.
- #
- # @raise [LockError] if bundle lock fails
- # @return [void]
- sig { void }
- def run_bundle_lock
- _out, err, status = Open3.capture3(
- { "BUNDLE_GEMFILE" => gemfile_path.to_s }, "bundle", "lock",
- chdir: @project_root.to_s,
- )
- raise LockError, "bundle lock failed: #{err}" unless status.success?
- end
-
# Parse the generated Gemfile.lock into name => { version:, hash: } pins.
#
# Reads the lockfile text directly rather than via Bundler's parser so the
@@ -214,12 +133,6 @@ def parse_checksums(contents)
end
end
- # @return [Pathname]
- sig { returns(Pathname) }
- def gemfile_path
- @project_root / GEMFILE
- end
-
# @return [Pathname]
sig { returns(Pathname) }
def lockfile_path
diff --git a/lib/dev/deps/cli_ui.rb b/lib/dev/deps/cli_ui.rb
index a3650cc..a791819 100644
--- a/lib/dev/deps/cli_ui.rb
+++ b/lib/dev/deps/cli_ui.rb
@@ -1,6 +1,8 @@
-# typed: true
+# typed: strict
# frozen_string_literal: true
+require "sorbet-runtime"
+
module Dev
module Deps
# Shared CLI presentation helpers for dependency operations.
@@ -9,9 +11,18 @@ module Deps
# that needs progress/status output (integrations, fetcher, orchestrator)
# should call through this module rather than coupling to CLI::UI directly.
module CliUI
+ @available = T.let(nil, T.nilable(T::Boolean))
+
class << self
+ extend T::Sig
+
+ # Whether the optional cli/ui gem is loadable (memoized).
+ #
+ # @return [Boolean]
+ sig { returns(T::Boolean) }
def available?
- return @available if defined?(@available)
+ memo = @available
+ return memo unless memo.nil?
@available = begin
require "cli/ui"
@@ -24,6 +35,8 @@ def available?
# Print a success status line.
#
# @param name [String] label to display
+ # @return [void]
+ sig { params(name: String).void }
def step_ok(name)
if available?
CLI::UI.puts("#{CLI::UI::Glyph::CHECK} #{name}")
@@ -35,6 +48,8 @@ def step_ok(name)
# Print a failure status line.
#
# @param name [String] label to display
+ # @return [void]
+ sig { params(name: String).void }
def step_fail(name)
if available?
CLI::UI.puts("#{CLI::UI::Glyph::X} #{name}")
@@ -47,6 +62,8 @@ def step_fail(name)
#
# @param title [String] spinner label
# @yield block to execute during spinner
+ # @return [Object] the spinner's (or block's) return value
+ sig { params(title: String, block: T.untyped).returns(T.untyped) }
def with_spinner(title, &block)
if available?
CLI::UI::Spinner.spin(title, &block)
@@ -60,6 +77,7 @@ def with_spinner(title, &block)
#
# @param str [String, nil] input string
# @return [String, nil] UTF-8 safe string
+ sig { params(str: T.nilable(String)).returns(T.nilable(String)) }
def sanitize_utf8(str)
return str if str.nil? || (str.encoding == Encoding::UTF_8 && str.valid_encoding?)
diff --git a/lib/dev/deps/config.rb b/lib/dev/deps/config.rb
index c3beae3..3e84d2e 100644
--- a/lib/dev/deps/config.rb
+++ b/lib/dev/deps/config.rb
@@ -1,6 +1,7 @@
-# typed: true
+# typed: strict
# frozen_string_literal: true
+require "sorbet-runtime"
require_relative "dsl"
require_relative "tap"
@@ -8,17 +9,55 @@ module Dev
module Deps
# Parsed dependency configuration. Returned by Dev::Deps.define.
class Config
- attr_reader :taps, :groups, :declarations, :ruby_version_requirement,
- :lua_version, :python_version, :registered_integrations
+ extend T::Sig
+
+ # @return [Array] declared Homebrew taps
+ sig { returns(T::Array[Tap]) }
+ attr_reader :taps
+
+ # @return [Hash] group name → { "brew" => [...], "env" => {...} }
+ sig { returns(T::Hash[String, T.untyped]) }
+ attr_reader :groups
+
+ # @return [Array] all declared dependencies
+ sig { returns(T::Array[ScopedDeclaration]) }
+ attr_reader :declarations
+
+ # @return [String, nil] required Ruby version
+ sig { returns(T.nilable(String)) }
+ attr_reader :ruby_version_requirement
+
+ # @return [String, nil] Lua version for LuaRocks
+ sig { returns(T.nilable(String)) }
+ attr_reader :lua_version
+
+ # @return [String, nil] Python minor version for the pip venv
+ sig { returns(T.nilable(String)) }
+ attr_reader :python_version
+
+ # @return [Hash{Symbol => Class, String}] custom integration registrations
+ sig { returns(T::Hash[Symbol, T.untyped]) }
+ attr_reader :registered_integrations
# @param taps [Array] declared Homebrew taps
# @param groups [Hash] group name → { "brew" => [...], "env" => {...} }
- # @param declarations [Array] all declared dependencies
+ # @param declarations [Array] all declared dependencies
# (gems are :bundler declarations, brew formulae are :brew declarations, etc.)
# @param ruby_version_requirement [String, nil] required Ruby version
# @param lua_version [String, nil] Lua version for LuaRocks
# @param python_version [String, nil] Python minor version for the pip venv
# @param registered_integrations [Hash{Symbol => Class}] custom integration registrations
+ sig do
+ params(
+ taps: T::Array[Tap],
+ groups: T::Hash[String, T.untyped],
+ declarations: T::Array[ScopedDeclaration],
+ ruby_version_requirement: T.nilable(String),
+ lua_version: T.nilable(String),
+ python_version: T.nilable(String),
+ registered_integrations: T::Hash[Symbol, T.untyped],
+ ).void
+ end
def initialize(taps:, groups:, declarations:, ruby_version_requirement:,
lua_version:, python_version:, registered_integrations:)
@taps = taps
@@ -34,15 +73,19 @@ def initialize(taps:, groups:, declarations:, ruby_version_requirement:,
#
# @param name [String, Symbol] group name
# @return [Hash]
+ sig { params(name: T.any(String, Symbol)).returns(T::Hash[String, T.untyped]) }
def group(name)
@groups[name.to_s] || { "brew" => [], "env" => {} }
end
class << self
+ extend T::Sig
+
# Evaluate a DSL block and return a Config instance.
#
# @param block [Proc] DSL block evaluated in DSL context
# @return [Config]
+ sig { params(block: T.nilable(T.proc.bind(DSL).void)).returns(Config) }
def define(&block)
dsl = DSL.new
dsl.instance_eval(&block) if block
diff --git a/lib/dev/deps/declaration.rb b/lib/dev/deps/declaration.rb
new file mode 100644
index 0000000..15ba6a1
--- /dev/null
+++ b/lib/dev/deps/declaration.rb
@@ -0,0 +1,127 @@
+# typed: strict
+# frozen_string_literal: true
+
+require "sorbet-runtime"
+
+module Dev
+ module Deps
+ # The shared atom of the deps domain: "package NAME of INTEGRATION, under
+ # CONSTRAINT". Stated by whoever authored the thing — a project's
+ # dependencies.rb row (which wraps it in a ScopedDeclaration with its
+ # install context) or an upstream manifest (a PackageVersion's declared
+ # dependencies, reported by the integration's Repository).
+ #
+ # Context-free by type: where and when a dependency installs (group, host,
+ # env) is a property of the path the resolver walked to reach it, never of
+ # the declaration itself. The same upstream declaration reached via two
+ # parents inherits two different contexts — see Scope and
+ # ScopedDeclaration.
+ #
+ # The constraint is always in dev's shape: a hash whose keys are owned by
+ # the integration's VersionScheme (e.g. { "version" => "^3.6" },
+ # { "tag" => "v1.0" }); {} means unconstrained — a present empty form,
+ # never nil. Repositories normalize upstream syntax into this shape at
+ # construction, so constraints cross the system boundary exactly once.
+ # Only version-shaped keys live here: source coordinates are the `source`
+ # field, and install instructions are ScopedDeclaration's materialization.
+ #
+ # source is on the atom (not ScopedDeclaration) because both authors can
+ # legitimately state it: an upstream manifest edge can point at a source
+ # coordinate (cargo-style git deps) just as a project row can. It is
+ # identity-shaping — Resolver#package_id reads it onto PackageId#source.
+ #
+ # revision is the other way to ask: an address into an integration's
+ # continuous space (a git commit SHA, an exact Xcode version) instead of a
+ # selection over its discrete published universe. An address forgoes
+ # resolution — the Resolver hands it to Repository#at and no scheme runs —
+ # so a revision alongside version constraints is a contradiction and is
+ # rejected at construction. The spelling is the ecosystem's canonical one
+ # (dev's only operation on a revision is equality, in conflict rejection),
+ # validated by the DSL verb that mints it.
+ #
+ # See docs/deps-architecture.md for the ontology this belongs to.
+ class Declaration
+ extend T::Sig
+
+ # A declaration states both an address (revision) and a selection
+ # (version constraints) — asking dev to resolve what the author
+ # already forewent resolving.
+ class RevisionWithConstraintError < StandardError; end
+
+ # @return [String] the package's name within its integration's universe
+ sig { returns(String) }
+ attr_reader :name
+
+ # @return [Symbol] the integration whose universe the name lives in
+ sig { returns(Symbol) }
+ attr_reader :integration
+
+ # @return [Hash{String => Object}] version constraint in dev's shape;
+ # {} means unconstrained
+ sig { returns(T::Hash[String, T.untyped]) }
+ attr_reader :constraint
+
+ # @return [String, nil] source coordinate locating the package's
+ # universe (a git remote URL, an "owner/repo" slug, a brew tap, a
+ # Steam app id); nil for registry-backed integrations, where the name
+ # alone identifies the package
+ sig { returns(T.nilable(String)) }
+ attr_reader :source
+
+ # @return [String, nil] address into the integration's continuous space
+ # (a full git commit SHA, an exact Xcode version); nil for
+ # constraint-shaped asks, which select over the published universe
+ sig { returns(T.nilable(String)) }
+ attr_reader :revision
+
+ # @param name [String] the package's name
+ # @param integration [Symbol] :bundler, :ficsit, :cmake, …
+ # @param constraint [Hash{String => Object}] dev-shaped constraint;
+ # defaults to {} (unconstrained)
+ # @param source [String, nil] source coordinate; defaults to nil
+ # @param revision [String, nil] addressable revision; defaults to nil
+ # @raise [RevisionWithConstraintError] if both a revision and version
+ # constraints are stated
+ sig do
+ params(
+ name: String,
+ integration: Symbol,
+ constraint: T::Hash[String, T.untyped],
+ source: T.nilable(String),
+ revision: T.nilable(String),
+ ).void
+ end
+ def initialize(name:, integration:, constraint: {}, source: nil, revision: nil)
+ if revision && !constraint.empty?
+ raise RevisionWithConstraintError,
+ "#{integration}/#{name} pins revision #{revision.inspect} and constrains " \
+ "#{constraint.inspect} — an address forgoes resolution, a constraint asks for it"
+ end
+
+ @name = name
+ @integration = integration
+ @constraint = T.let(constraint.dup.freeze, T::Hash[String, T.untyped])
+ @source = source
+ @revision = revision
+ freeze
+ end
+
+ # @param other [Object]
+ # @return [Boolean] whether other states the same declaration
+ sig { params(other: Object).returns(T::Boolean) }
+ def ==(other)
+ return false unless other.is_a?(Declaration)
+
+ [name, integration, constraint, source, revision] ==
+ [other.name, other.integration, other.constraint, other.source, other.revision]
+ end
+ alias_method :eql?, :==
+
+ # @return [Integer] hash code, so declarations work as Hash keys
+ sig { returns(Integer) }
+ def hash
+ [self.class, name, integration, constraint, source, revision].hash
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/declarations.rb b/lib/dev/deps/declarations.rb
new file mode 100644
index 0000000..eeccf8c
--- /dev/null
+++ b/lib/dev/deps/declarations.rb
@@ -0,0 +1,93 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "declaration"
+
+module Dev
+ module Deps
+ # A PackageVersion's claim about its declared dependencies — a sum type,
+ # because a bare array cannot say which regime the claim was made under:
+ # [] would collapse "this version affirmatively requires nothing" into
+ # "the ecosystem's own tool owns a closure dev never sees".
+ #
+ # Exactly two variants, sealed so consumers can case-and-T.absurd:
+ #
+ # - Resolved(declarations): the repository reports the version's declared
+ # deps as facts dev can walk. Resolved([]) is the affirmative empty
+ # claim — including self-contained ecosystems (steam, gh artifacts)
+ # where the repository guarantees it by construction.
+ # - ToolOwned: the ecosystem's tool (bundler, pip, luarocks, brew) owns
+ # transitive resolution; dev sees only the top-level asks.
+ #
+ # The claim travels with the data: each Repository constructs the variant
+ # its regime warrants — construction is the dispatch, so no resolver
+ # guard, registry attribute, or repository enum exists.
+ #
+ # See the transitive-dependency regimes table in docs/deps-architecture.md.
+ class Declarations
+ extend T::Sig
+ extend T::Helpers
+ abstract!
+ sealed!
+
+ # The declared deps are facts dev can walk.
+ class Resolved < Declarations
+ extend T::Sig
+
+ # @return [Array] the version's declared dependencies,
+ # normalized and integration-stamped by the reporting Repository
+ sig { returns(T::Array[Declaration]) }
+ attr_reader :declarations
+
+ # @param declarations [Array] declared deps; [] is the
+ # affirmative "requires nothing"
+ sig { params(declarations: T::Array[Declaration]).void }
+ def initialize(declarations)
+ @declarations = T.let(declarations.dup.freeze, T::Array[Declaration])
+ freeze
+ end
+
+ # @param other [Object]
+ # @return [Boolean] whether other makes the same claim
+ sig { params(other: T.untyped).returns(T::Boolean) }
+ def ==(other)
+ return false unless other.is_a?(Resolved)
+
+ declarations == other.declarations
+ end
+ alias_method :eql?, :==
+
+ # @return [Integer] hash code
+ sig { returns(Integer) }
+ def hash
+ [self.class, declarations].hash
+ end
+ end
+
+ # The ecosystem's tool owns transitive resolution; dev cannot see the
+ # closure and must not pretend to.
+ class ToolOwned < Declarations
+ extend T::Sig
+
+ sig { void }
+ def initialize
+ freeze
+ end
+
+ # @param other [Object]
+ # @return [Boolean] whether other is also a tool-owned claim
+ sig { params(other: T.untyped).returns(T::Boolean) }
+ def ==(other)
+ other.is_a?(ToolOwned)
+ end
+ alias_method :eql?, :==
+
+ # @return [Integer] hash code
+ sig { returns(Integer) }
+ def hash
+ self.class.hash
+ end
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/dependency_declaration.rb b/lib/dev/deps/dependency_declaration.rb
deleted file mode 100644
index b3ede21..0000000
--- a/lib/dev/deps/dependency_declaration.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# typed: false
-# frozen_string_literal: true
-
-module Dev
- module Deps
- # A declared dependency before resolution.
- #
- # Represents what the user declares in the DSL — a name, integration type,
- # version constraint, and group. The Resolver consumes these and produces
- # fully resolved Dependency objects.
- #
- # The four declaration axes (see README "Dependency axes"):
- # - group: purpose (e.g. :app, :test, :build — user-defined)
- # - env: execution context the dep is for ("ci" / "dev"), or nil for all
- # - host: OS of the machine the dep installs on (:darwin / :linux), or nil for all
- # - platform: what artifact variant the dep targets (e.g. "LinuxServer"), or nil
- # to let the integration pick its default. Multi-arch integrations
- # (ficsit) resolve a dep for the union of the platforms of every
- # group that declares it.
- #
- # env and host are facts about *where/when the dep installs*, so they are
- # first-class fields here — never smuggled into the constraint hash, which
- # describes *what the dep is* (the resolver's fetch id).
- #
- # - name: dependency name (e.g. "boost", "luaunit")
- # - integration: symbol identifying the Integration type (:cmake, :luarocks, :brew, …)
- # - constraint: version constraint hash (integration-specific, e.g. { "tag" => "v1.0" })
- # - post_install: callable or array of callables to run after the dep is fetched.
- # Each callable receives (dep, project_root). Not serialized to lockfile.
- DependencyDeclaration = Data.define(
- :name, :integration, :constraint, :group, :platform, :host, :env, :post_install,
- ) do
- def initialize(name:, integration:, constraint: {}, group: :app, platform: nil,
- host: nil, env: nil, post_install: nil)
- super(name:, integration:, constraint:, group:, platform:,
- host: host&.to_sym, env: env&.to_s, post_install:)
- end
- end
- end
-end
diff --git a/lib/dev/deps/dsl.rb b/lib/dev/deps/dsl.rb
index 1bd3994..27a6b4f 100644
--- a/lib/dev/deps/dsl.rb
+++ b/lib/dev/deps/dsl.rb
@@ -1,28 +1,63 @@
-# typed: false
+# typed: strict
# frozen_string_literal: true
-require_relative "dependency_declaration"
+require "sorbet-runtime"
+require_relative "declaration"
+require_relative "scope"
+require_relative "scoped_declaration"
module Dev
module Deps
# Top-level DSL evaluated inside Dev::Deps.define { ... }.
class DSL
+ extend T::Sig
+
# Group a top-level `gem` declaration lands in when none is given. Bundler's
# default (unscoped) group, mirroring a hand-written Gemfile's top section.
DEFAULT_GEM_GROUP = :app
- attr_reader :taps, :groups, :declarations, :ruby_version_requirement,
- :lua_version_value, :python_version_value, :registered_integrations, :registered_methods
+ # @return [Hash{String => Hash}] declared taps by name
+ sig { returns(T::Hash[String, T::Hash[String, T.untyped]]) }
+ attr_reader :taps
+
+ # @return [Hash{String => Hash}] group name → group config
+ sig { returns(T::Hash[String, T.untyped]) }
+ attr_reader :groups
+
+ # @return [Array] all declared dependencies
+ sig { returns(T::Array[ScopedDeclaration]) }
+ attr_reader :declarations
+
+ # @return [String, nil] declared Ruby version
+ sig { returns(T.nilable(String)) }
+ attr_reader :ruby_version_requirement
+
+ # @return [String, nil] declared Lua version
+ sig { returns(T.nilable(String)) }
+ attr_reader :lua_version_value
+ # @return [String, nil] declared Python minor version
+ sig { returns(T.nilable(String)) }
+ attr_reader :python_version_value
+
+ # @return [Hash{Symbol => Class, String}] custom integration registrations
+ sig { returns(T::Hash[Symbol, T.untyped]) }
+ attr_reader :registered_integrations
+
+ # @return [Array] dynamically registered integration method names
+ sig { returns(T::Array[Symbol]) }
+ attr_reader :registered_methods
+
+ sig { void }
def initialize
- @taps = {}
- @groups = {}
- @declarations = []
- @ruby_version_requirement = nil
- @lua_version_value = nil
- @python_version_value = nil
- @registered_integrations = {}
- @registered_methods = []
+ @taps = T.let({}, T::Hash[String, T::Hash[String, T.untyped]])
+ @groups = T.let({}, T::Hash[String, T.untyped])
+ @declarations = T.let([], T::Array[ScopedDeclaration])
+ @ruby_version_requirement = T.let(nil, T.nilable(String))
+ @lua_version_value = T.let(nil, T.nilable(String))
+ @python_version_value = T.let(nil, T.nilable(String))
+ @registered_integrations = T.let({}, T::Hash[Symbol, T.untyped])
+ @registered_methods = T.let([], T::Array[Symbol])
end
# Declare the project's Ruby toolchain — a first-class dependency, on equal
@@ -33,6 +68,8 @@ def initialize
# interpreter every other dependency and command runs under.
#
# @param version [String, Symbol] exact Ruby version (e.g. "4.0.5")
+ # @return [void]
+ sig { params(version: T.any(String, Symbol)).void }
def ruby(version)
@ruby_version_requirement = version.to_s.strip
end
@@ -40,6 +77,8 @@ def ruby(version)
# Declare the Lua version for LuaRocks integration.
#
# @param version [String, Symbol] Lua version (e.g. "5.1")
+ # @return [void]
+ sig { params(version: T.any(String, Symbol)).void }
def lua_version(version)
@lua_version_value = version.to_s.strip
end
@@ -51,6 +90,8 @@ def lua_version(version)
# specially (pre-dispatch), not through the resolver -> lockfile pipeline.
#
# @param version [String, Symbol] Python minor version (e.g. "3.12")
+ # @return [void]
+ sig { params(version: T.any(String, Symbol)).void }
def python(version)
@python_version_value = version.to_s.strip
end
@@ -64,17 +105,23 @@ def python(version)
# @param name [String, Symbol] gem name
# @param version [String, nil] version requirement (e.g. "~> 1.17")
# @param opts [Hash] additional bundler options (e.g. require:, git:)
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), version: T.nilable(String), opts: T.untyped).void }
def gem(name, version = nil, **opts)
constraint = opts.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
constraint["version"] = version.to_s if version
- @declarations << DependencyDeclaration.new(
- name: name.to_s,
- integration: :bundler,
- constraint:,
- group: DEFAULT_GEM_GROUP,
+ @declarations << ScopedDeclaration.new(
+ declaration: Declaration.new(name: name.to_s, integration: :bundler, constraint:),
+ scope: Scope.new(group: DEFAULT_GEM_GROUP),
)
end
+ # Declare a Homebrew tap.
+ #
+ # @param name [String, Symbol] tap identifier (e.g. "d3mlabs/d3mlabs")
+ # @param url [String, nil] tap URL; file:// means a local tap
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), url: T.untyped).void }
def tap(name, url: nil)
name_str = name.to_s
@taps[name_str] = {
@@ -88,6 +135,8 @@ def tap(name, url: nil)
#
# @param name [Symbol, String] integration identifier (e.g. :wow_curseforge)
# @param klass [Class, String] Integration subclass or its name
+ # @return [void]
+ sig { params(name: T.any(Symbol, String), klass: T.untyped).void }
def register(name, klass)
sym = name.to_sym
@registered_integrations[sym] = klass
@@ -104,6 +153,15 @@ def register(name, klass)
# Sugar that stamps every member declaration, exactly as platform: does; install
# filters against the detected host OS (the lockfile stays universal — all hosts'
# deps are resolved and locked, filtering happens at install, never at resolve).
+ # @return [void]
+ sig do
+ params(
+ name: T.any(String, Symbol),
+ platform: T.nilable(String),
+ host: T.nilable(Symbol),
+ block: T.nilable(T.proc.bind(GroupDSL).void),
+ ).void
+ end
def group(name, platform: nil, host: nil, &block)
group_name = name.to_s
group_dsl = GroupDSL.new(group: group_name.to_sym, platform:, host:, registered_methods: @registered_methods)
@@ -115,23 +173,45 @@ def group(name, platform: nil, host: nil, &block)
# DSL for per-environment entries (inside group :build for env-specific brew).
class EnvDSL
+ extend T::Sig
+
class EmptyNameError < StandardError; end
+ # @return [Array] declarations made inside this env block
+ sig { returns(T::Array[ScopedDeclaration]) }
attr_reader :declarations
# @param group [Symbol] enclosing group, stamped onto declarations
# @param platform [String, nil] enclosing group's platform
# @param host [Symbol, nil] enclosing group's host OS
# @param env [String, nil] environment name ("ci" / "dev"), stamped onto declarations
+ sig do
+ params(
+ group: Symbol,
+ platform: T.nilable(String),
+ host: T.nilable(Symbol),
+ env: T.nilable(String),
+ ).void
+ end
def initialize(group: :app, platform: nil, host: nil, env: nil)
- @brew = []
- @declarations = []
+ @brew = T.let([], T::Array[T.untyped])
+ @declarations = T.let([], T::Array[ScopedDeclaration])
@group = group
@platform = platform
@host = host
@env = env
end
+ # Declare a Homebrew formula/cask scoped to this environment.
+ #
+ # The options are sorted into the declaration's fields: tap: is the
+ # source coordinate, cask: routes to the :cask integration (a separate
+ # universe), and what remains (version:) is the constraint.
+ #
+ # @param name [String, Symbol] formula or cask name
+ # @param opts [Hash] options (tap:, version:, cask:)
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), opts: T.untyped).void }
def brew(name, **opts)
name_str = name.to_s
raise EmptyNameError, "brew dependency name cannot be empty" if name_str.empty?
@@ -141,23 +221,33 @@ def brew(name, **opts)
else
@brew << { name_str => stringify_keys(opts) }
end
- @declarations << DependencyDeclaration.new(
- name: name_str,
- integration: :brew,
- constraint: stringify_keys(opts),
- group: @group,
+
+ constraint = opts.dup
+ cask = constraint.delete(:cask)
+ tap = constraint.delete(:tap)
+ @declarations << ScopedDeclaration.new(
+ declaration: Declaration.new(
+ name: name_str,
+ integration: cask ? :cask : :brew,
+ constraint: stringify_keys(constraint),
+ source: tap&.to_s,
+ ),
+ scope: Scope.new(group: @group, host: @host, env: @env),
platform: @platform,
- host: @host,
- env: @env,
)
end
+ # @return [Hash] container-build projection of this env block
+ sig { returns(T::Hash[String, T.untyped]) }
def to_h
{ "brew" => @brew }
end
private
+ # @param hash [Hash] symbol-keyed options
+ # @return [Hash] the same options with string keys
+ sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[String, T.untyped]) }
def stringify_keys(hash)
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
end
@@ -165,31 +255,106 @@ def stringify_keys(hash)
# DSL for group-scoped deps: declarations (app/test), brew + nested env (build).
class GroupDSL
+ extend T::Sig
+
class EmptyNameError < StandardError; end
+ # A commit: pin that is not a full 40-char SHA. Conflict rejection
+ # compares revisions textually, so only the canonical spelling keeps
+ # equality trustworthy — and today's silent fall-through (a short
+ # "commit" quietly resolved as a tag) was a lie worth killing.
+ class InvalidRevisionError < StandardError; end
+
+ # A git-backed cmake dep with nothing to select or address: no tag:, no
+ # branch:, no commit:. An unconstrained enumeration would pin an
+ # arbitrary ref, so the ask must say what it wants.
+ class MissingRefError < StandardError; end
+
+ # The canonical git address: a full 40-char lowercase hex SHA.
+ FULL_SHA = /\A[0-9a-f]{40}\z/
+
+ # The artifact target a ficsit mod materializes when no group platform
+ # says otherwise: the Windows game build, which every mod publishes.
+ FICSIT_DEFAULT_TARGET = "Windows"
+
+ # @return [Array] declarations made inside this group
+ sig { returns(T::Array[ScopedDeclaration]) }
attr_reader :declarations
# @param group [Symbol] group name (e.g. :app, :test, :build)
# @param platform [String, nil] platform stamped onto every declaration in this group
# @param host [Symbol, nil] host OS stamped onto every declaration in this group
# @param registered_methods [Array] dynamically registered integration methods
+ sig do
+ params(
+ group: Symbol,
+ platform: T.nilable(String),
+ host: T.nilable(Symbol),
+ registered_methods: T::Array[Symbol],
+ ).void
+ end
def initialize(group:, platform: nil, host: nil, registered_methods: [])
@group = group
@platform = platform
@host = host
- @declarations = []
- @brew = []
- @envs = {}
+ @declarations = T.let([], T::Array[ScopedDeclaration])
+ @brew = T.let([], T::Array[T.untyped])
+ @envs = T.let({}, T::Hash[String, T.untyped])
@registered_methods = registered_methods
end
# Declare a CMake dependency. Expands github: shorthand if present.
#
+ # Two universes behind one verb, split by the source's shape:
+ # - repo:/github: — a git universe under :cmake. tag:/branch: are
+ # constraints selecting over the remote's enumerated refs; commit:
+ # is a revision — an address into the continuous space, full
+ # 40-char SHA only.
+ # - url: — an artifact universe under :url. The URL is the entire
+ # address, so nothing selects: a tag: here is a display label
+ # riding materialization, not a constraint.
+ #
+ # cmake_targets:/cmake_namespace: are install instructions (they shape
+ # the generated deps.targets.cmake), so they ride materialization for
+ # both universes.
+ #
# @param name [String, Symbol] dependency name
- # @param spec [Hash] options (tag:, repo:, url:, github:, etc.)
+ # @param spec [Hash] options (tag:, branch:, commit:, repo:, url:, github:,
+ # cmake_targets:, cmake_namespace:, etc.)
+ # @return [void]
+ # @raise [InvalidRevisionError] if commit: is not a full 40-char SHA
+ # @raise [MissingRefError] if a git-backed dep names no ref at all
+ sig { params(name: T.any(String, Symbol), spec: T.untyped).void }
def cmake(name, **spec)
- spec = expand_github(name, spec)
- add_declaration(name, :cmake, spec)
+ spec = expand_github(name.to_s, spec)
+ url = spec.delete(:url)&.to_s
+ repo = spec.delete(:repo)&.to_s
+ revision = spec.delete(:commit)&.to_s
+
+ materialization = {}
+ %i[cmake_targets cmake_namespace].each do |key|
+ value = spec.delete(key)
+ materialization[key.to_s] = value if value
+ end
+
+ if url
+ label = spec.delete(:tag)
+ materialization["version_label"] = label.to_s if label
+ return add_declaration(name, :url, spec, source: url, materialization: materialization)
+ end
+
+ if revision && !revision.match?(FULL_SHA)
+ raise InvalidRevisionError,
+ "cmake #{name} pins commit: #{revision.inspect} — a commit is a full 40-char SHA " \
+ "(tags select with tag:)"
+ end
+ if revision.nil? && !spec.key?(:tag) && !spec.key?(:branch)
+ raise MissingRefError,
+ "cmake #{name} names no tag:, branch:, or commit: — an unconstrained git universe " \
+ "would pin an arbitrary ref"
+ end
+
+ add_declaration(name, :cmake, spec, source: repo, revision: revision, materialization: materialization)
end
# Declare a Ruby gem scoped to this group (group name -> bundler group).
@@ -197,6 +362,8 @@ def cmake(name, **spec)
# @param name [String, Symbol] gem name
# @param version [String, nil] version requirement (e.g. "~> 1.17")
# @param spec [Hash] additional bundler options (e.g. require:, git:)
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), version: T.nilable(String), spec: T.untyped).void }
def gem(name, version = nil, **spec)
spec[:version] = version if version
add_declaration(name, :bundler, spec)
@@ -207,6 +374,8 @@ def gem(name, version = nil, **spec)
# @param name [String, Symbol] rock name
# @param constraint [String, nil] version constraint (e.g. ">=3.5")
# @param spec [Hash] additional options
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), constraint: T.nilable(String), spec: T.untyped).void }
def luarocks(name, constraint = nil, **spec)
spec[:constraint] = constraint if constraint
add_declaration(name, :luarocks, spec)
@@ -219,6 +388,8 @@ def luarocks(name, constraint = nil, **spec)
# @param name [String, Symbol] distribution name (e.g. "totalsegmentator")
# @param version [String, nil] version constraint (e.g. ">=2.0", "2.0.5")
# @param spec [Hash] additional options (e.g. host:)
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), version: T.nilable(String), spec: T.untyped).void }
def pip(name, version = nil, **spec)
spec[:version] = version if version
add_declaration(name, :pip, spec)
@@ -226,12 +397,20 @@ def pip(name, version = nil, **spec)
# Declare a Satisfactory mod dependency from ficsit.app.
#
+ # target: is an install instruction — which of the mod's artifacts to
+ # fetch when no group platform says otherwise — so it rides the
+ # declaration's materialization, defaulting to the Windows game build
+ # (the target every ficsit mod publishes for players).
+ #
# @param mod_reference [String, Symbol] mod reference (e.g. "SML", "AreaActions")
# @param version [String, nil] semver constraint (e.g. "^3.12.0", ">=1.0")
# @param spec [Hash] additional options (target:, etc.)
+ # @return [void]
+ sig { params(mod_reference: T.any(String, Symbol), version: T.nilable(String), spec: T.untyped).void }
def ficsit(mod_reference, version: nil, **spec)
spec[:version] = version if version
- add_declaration(mod_reference, :ficsit, spec)
+ target = spec.delete(:target) || FICSIT_DEFAULT_TARGET
+ add_declaration(mod_reference, :ficsit, spec, materialization: { "target" => target.to_s })
end
# Declare a GitHub dependency, materialized one of two ways:
@@ -257,9 +436,22 @@ def ficsit(mod_reference, version: nil, **spec)
# @param assets [String, nil] glob selecting prebuilt release assets
# @param build [String, Symbol, nil] build-from-source recipe (script path / shell / :none)
# @param spec [Hash] additional options
+ # @return [void]
+ sig do
+ params(
+ name_or_slug: T.any(String, Symbol),
+ tag: String,
+ install_dir: String,
+ github: T.nilable(String),
+ repo: T.nilable(String),
+ assets: T.nilable(String),
+ build: T.nilable(T.any(String, Symbol)),
+ spec: T.untyped,
+ ).void
+ end
def gh(name_or_slug, tag:, install_dir:, github: nil, repo: nil, assets: nil, build: nil, **spec)
slug = (github || repo || name_or_slug).to_s
- name = (github || repo) ? name_or_slug.to_s : slug.split("/").last
+ name = (github || repo) ? name_or_slug.to_s : T.must(slug.split("/").last)
unless [assets, build].compact.size == 1
raise ArgumentError,
@@ -267,10 +459,14 @@ def gh(name_or_slug, tag:, install_dir:, github: nil, repo: nil, assets: nil, bu
"or build: (build from source)"
end
- spec = spec.merge(repo: slug, tag: tag, install_dir: install_dir)
- spec[:assets] = assets if assets
- spec[:build] = build.to_s if build
- add_declaration(name, :gh, spec)
+ # Sorted into fields: the slug is the source coordinate, the tag is
+ # the constraint, and how to materialize (where to install, which
+ # assets to fetch or how to build) is install instruction — the
+ # repository never sees any of it.
+ materialization = { "install_dir" => install_dir }
+ materialization["asset_pattern"] = assets if assets
+ materialization["build"] = build.to_s if build
+ add_declaration(name, :gh, spec.merge(tag: tag), source: slug, materialization: materialization)
end
# Declare a Steam application dependency (e.g. the Satisfactory Dedicated
@@ -285,9 +481,24 @@ def gh(name_or_slug, tag:, install_dir:, github: nil, repo: nil, assets: nil, bu
# @param install_dir [String] host directory the depot is installed into
# @param branch [String] Steam branch (default "public")
# @param spec [Hash] additional options (buildid:, etc.)
+ # @return [void]
+ sig do
+ params(
+ name: T.any(String, Symbol),
+ app: T.any(Integer, String),
+ install_dir: String,
+ branch: String,
+ spec: T.untyped,
+ ).void
+ end
def steam(name, app:, install_dir:, branch: "public", **spec)
- spec = spec.merge(app:, install_dir:, branch:)
- add_declaration(name, :steam, spec)
+ # Sorted into fields: the app id is the source coordinate (which app's
+ # universe), branch/buildid are the constraint, and the install dir
+ # plus the group's platform (which depot build SteamCMD provisions —
+ # an install instruction, not a version fact) are materialization.
+ materialization = { "install_dir" => install_dir }
+ materialization["platform"] = @platform if @platform
+ add_declaration(name, :steam, spec.merge(branch:), source: app.to_s, materialization: materialization)
end
# Declare a dependency using any registered integration by name.
@@ -295,6 +506,8 @@ def steam(name, app:, install_dir:, branch: "public", **spec)
# @param name [String, Symbol] dependency name
# @param integration [Symbol, String] integration identifier (e.g. :wow_curseforge)
# @param spec [Hash] additional options
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), integration: T.any(Symbol, String), spec: T.untyped).void }
def custom(name, integration:, **spec)
add_declaration(name, integration.to_sym, spec)
end
@@ -309,21 +522,37 @@ def custom(name, integration:, **spec)
#
# @param version [String, Symbol] exact Xcode version (e.g. "26.1.1")
# @param spec [Hash] additional options
+ # @return [void]
+ # @raise [ArgumentError] if the version is blank — Apple publishes no
+ # registry to select from, so the exact version is the whole ask
+ sig { params(version: T.any(String, Symbol), spec: T.untyped).void }
def xcode(version, **spec)
- spec[:version] = version.to_s.strip
- add_declaration("xcode", :xcode, spec)
+ revision = version.to_s.strip
+ raise ArgumentError, "xcode requires an exact version (e.g. xcode \"26.1.1\")" if revision.empty?
+
+ # The exact version is an address, not a constraint: there is no
+ # universe to select over, so it rides the declaration's revision and
+ # XcodeRepository#at lifts it as the identity.
+ add_declaration("xcode", :xcode, spec, revision: revision)
end
# Declare a Homebrew formula/cask.
#
# Dual-writes: the existing @brew/groups entry feeds the container build
- # path (bin/install-build-deps.rb), while the additional :brew declaration
+ # path (bin/install-build-deps.rb), while the additional declaration
# rides the resolver -> lockfile -> install pipeline so `dev install-deps`
# installs it on the host too. BrewIntegration skips already-installed
# formulae, so the host install is idempotent.
#
+ # The options are sorted into the declaration's fields: tap: is the
+ # source coordinate, cask: routes to the :cask integration (a separate
+ # universe — Homebrew publishes no versions or bottle digests for
+ # casks), and what remains (version:) is the constraint.
+ #
# @param name [String, Symbol] formula or cask name
# @param opts [Hash] options (tap:, version:, cask:)
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), opts: T.untyped).void }
def brew(name, **opts)
name_str = name.to_s
raise EmptyNameError, "brew dependency name cannot be empty" if name_str.empty?
@@ -333,13 +562,21 @@ def brew(name, **opts)
else
@brew << { name_str => stringify_keys(opts) }
end
- add_declaration(name_str, :brew, opts.dup)
+
+ constraint = opts.dup
+ cask = constraint.delete(:cask)
+ tap = constraint.delete(:tap)
+ add_declaration(name_str, cask ? :cask : :brew, constraint, source: tap&.to_s)
end
# Scope member declarations to an environment ("ci" / "dev"). The env
# name is a first-class declaration field (like host), landing in the
# lockfile's env section so install-deps filters it to the matching
# environment — never smuggled through the constraint hash.
+ #
+ # @param name [String, Symbol] environment name
+ # @return [void]
+ sig { params(name: T.any(String, Symbol), block: T.nilable(T.proc.bind(EnvDSL).void)).void }
def env(name, &block)
env_name = name.to_s
env_dsl = EnvDSL.new(group: @group, platform: @platform, host: @host, env: env_name)
@@ -348,6 +585,8 @@ def env(name, &block)
@declarations.concat(env_dsl.declarations)
end
+ # @return [Hash] container-build projection of this group
+ sig { returns(T::Hash[String, T.untyped]) }
def to_h
{ "brew" => @brew, "env" => @envs, "platform" => @platform }
end
@@ -358,31 +597,51 @@ def to_h
# @param method_name [Symbol] called method name
# @param args [Array] positional arguments (first is the dependency name)
# @param kwargs [Hash] keyword arguments passed to custom()
+ # @return [Object]
+ sig { params(method_name: Symbol, args: T.untyped, kwargs: T.untyped, block: T.untyped).returns(T.untyped) }
def method_missing(method_name, *args, **kwargs, &block)
if @registered_methods.include?(method_name.to_sym)
- custom(args.first, integration: method_name, **kwargs)
+ T.unsafe(self).custom(args.fetch(0), integration: method_name, **kwargs)
else
super
end
end
+ # @param method_name [Symbol] queried method name
+ # @param include_private [Boolean]
+ # @return [Boolean]
+ sig { params(method_name: T.any(Symbol, String), include_private: T::Boolean).returns(T::Boolean) }
def respond_to_missing?(method_name, include_private = false)
@registered_methods.include?(method_name.to_sym) || super
end
private
- # Create a DependencyDeclaration and store it.
+ # Create a ScopedDeclaration and store it.
#
- # host: is peeled off the spec into the first-class declaration field —
- # a per-declaration override of the group's host (e.g. `gh ..., host:
- # :darwin` outside a host-gated group). It never reaches the constraint,
- # which describes what the dep is, not where it installs.
+ # host: is peeled off the spec into the Scope — a per-declaration
+ # override of the group's host (e.g. `gh ..., host: :darwin` outside a
+ # host-gated group). It never reaches the constraint, which describes
+ # what the dep is, not where it installs.
#
# @param name [String, Symbol] dependency name
# @param integration [Symbol] integration type
# @param spec [Hash] constraint spec (symbol keys → stringified)
- def add_declaration(name, integration, spec)
+ # @param source [String, nil] source coordinate for the Declaration
+ # @param revision [String, nil] addressable revision for the Declaration
+ # @param materialization [Hash{String => Object}] install instructions
+ # @return [void]
+ sig do
+ params(
+ name: T.any(String, Symbol),
+ integration: Symbol,
+ spec: T::Hash[Symbol, T.untyped],
+ source: T.nilable(String),
+ revision: T.nilable(String),
+ materialization: T::Hash[String, T.untyped],
+ ).void
+ end
+ def add_declaration(name, integration, spec, source: nil, revision: nil, materialization: {})
name_str = name.to_s
raise EmptyNameError, "dependency name cannot be empty" if name_str.empty?
@@ -391,14 +650,12 @@ def add_declaration(name, integration, spec)
spec = expand_github(name_str, spec) if spec.key?(:github)
constraint = stringify_keys(spec)
- @declarations << DependencyDeclaration.new(
- name: name_str,
- integration:,
- constraint:,
- group: @group,
+ @declarations << ScopedDeclaration.new(
+ declaration: Declaration.new(name: name_str, integration:, constraint:, source:, revision:),
+ scope: Scope.new(group: @group, host:),
platform: @platform,
- host:,
post_install:,
+ materialization: materialization,
)
end
@@ -410,6 +667,7 @@ def add_declaration(name, integration, spec)
# @param name [String] dependency name (used as repo name for org-only shorthand)
# @param spec [Hash] spec hash; github: key is consumed and replaced with repo:
# @return [Hash] spec with github: replaced by repo:
+ sig { params(name: String, spec: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
def expand_github(name, spec)
github = spec.delete(:github)
return spec unless github
@@ -422,6 +680,9 @@ def expand_github(name, spec)
spec.merge(repo: repo_url)
end
+ # @param hash [Hash] symbol-keyed options
+ # @return [Hash] the same options with string keys
+ sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[String, T.untyped]) }
def stringify_keys(hash)
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
end
diff --git a/lib/dev/deps/exact_scheme.rb b/lib/dev/deps/exact_scheme.rb
new file mode 100644
index 0000000..2f5fc6c
--- /dev/null
+++ b/lib/dev/deps/exact_scheme.rb
@@ -0,0 +1,45 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "version_scheme"
+
+module Dev
+ module Deps
+ # Exact-coordinate constraint semantics (:gh tags): the constraint names
+ # one version, and a candidate satisfies it by being that version.
+ #
+ # These ecosystems have no range grammar — a GitHub tag is an exact ask
+ # by design, selecting one version out of the enumerated universe.
+ class ExactScheme < VersionScheme
+ extend T::Sig
+
+ # @return [String] the constraint key carrying the exact coordinate
+ sig { returns(String) }
+ attr_reader :key
+
+ # @param key [String] the constraint key this ecosystem pins with
+ # (e.g. "tag" for gh, "version" for xcode)
+ sig { params(key: String).void }
+ def initialize(key:)
+ @key = key
+ end
+
+ # @param version [PackageVersion] a candidate version
+ # @param constraint [Hash] declaration constraint; key names the coordinate
+ # @return [Boolean] true when unconstrained or the coordinate matches
+ sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ pinned = constraint[key]
+ pinned.nil? || pinned.to_s == version.version
+ end
+
+ # @param versions [Array] reported versions
+ # @return [Array] the same versions, order untouched — exact
+ # coordinates carry no order to impose
+ sig { override.params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ versions.dup
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/ficsit_repository.rb b/lib/dev/deps/ficsit_repository.rb
index bdc13a7..35f6a2a 100644
--- a/lib/dev/deps/ficsit_repository.rb
+++ b/lib/dev/deps/ficsit_repository.rb
@@ -4,8 +4,13 @@
require "json"
require "net/http"
require "uri"
+require_relative "artifact"
+require_relative "declaration"
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
@@ -17,13 +22,10 @@ class FicsitRepository < Repository
extend T::Sig
class ApiError < StandardError; end
- class ModNotFoundError < StandardError; end
- class NoVersionError < StandardError; end
- class TargetNotFoundError < StandardError; end
+ class ModNotFoundError < PackageNotFoundError; end
API_HOST = "https://api.ficsit.app"
GRAPHQL_ENDPOINT = T.let(URI("#{API_HOST}/v2/query"), URI::Generic)
- DEFAULT_TARGET = "Windows"
VERSIONS_QUERY = <<~GRAPHQL
query GetMod($modReference: ModReference!) {
@@ -51,99 +53,77 @@ class TargetNotFoundError < StandardError; end
}
GRAPHQL
- # Resolve a ficsit.app mod dependency to a pinned Dependency.
+ # Report a mod's published versions from ficsit.app.
#
- # Two shapes, selected by the fetch id:
- # - Multi-platform (id["platforms"] present): resolve the mod for every
- # requested platform and nest each platform's {hash, link} under
- # metadata["platforms"]. nil entries map to the default target (Windows).
- # The top-level hash is nil since integrity is tracked per platform.
- # - Single-platform (legacy): resolve one "target" (default Windows) and
- # carry the hash on the Dependency, as before.
+ # Each version carries its targets as platforms, each target's download
+ # as an Artifact (dev-enforced integrity: the SHA256 the API publishes),
+ # its required mods as a Resolved declarations claim, and the mod facts
+ # FicsitIntegration reads (mod_id, game_version). Which targets the pin
+ # describes is not this universe's business: the Resolver projects the
+ # declared platforms against the chosen version's artifacts at mint.
#
- # @param id [Hash] must include "name" (mod_reference), "integration", "group";
- # optionally "version" (semver constraint like "^3.12.0"),
- # "target" (e.g. "Windows") or "platforms" (Array)
- # @return [Dependency]
+ # @param id [PackageId] name is the mod_reference
+ # @return [Package]
# @raise [ModNotFoundError] if the mod_reference doesn't exist on ficsit.app
- # @raise [NoVersionError] if no versions are available
- # @raise [TargetNotFoundError] if a requested platform has no published target
# @raise [ApiError] if the GraphQL request fails
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- mod_reference = id["name"]
- mod_data = query_mod(mod_reference)
- versions = mod_data["versions"]
- raise NoVersionError, "no versions found for #{mod_reference}" if versions.nil? || versions.empty?
-
- version_data = versions.first
- metadata = {
- "mod_id" => mod_data["id"],
- "game_version" => version_data["game_version"],
- }
-
- requested = id["platforms"]
- if requested && !requested.empty?
- metadata["platforms"] = resolve_platforms(mod_reference, version_data, requested)
- hash = nil
- else
- target = id.fetch("target", DEFAULT_TARGET)
- target_data = find_target(version_data["targets"] || [], target)
- hash = target_data ? "SHA256=#{target_data["hash"]}" : nil
- metadata["target"] = target
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ mod_data = query_mod(id.name)
+ versions = (mod_data["versions"] || []).map do |version_data|
+ package_version(mod_data, version_data)
end
- transitive_deps = (version_data["dependencies"] || [])
- .reject { |d| d["optional"] }
- .map { |d| { name: d["mod_id"], constraint: d["condition"] } }
-
- Dependency.new(
- name: mod_reference,
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: version_data["version"],
- hash: hash,
- metadata: metadata,
- dependencies: transitive_deps,
- )
+ Package.new(id: id, versions: versions)
end
private
- # Resolve each requested platform to its {hash, link}, keyed by the actual
- # ficsit target name. nil maps to the default target; unlike the legacy
- # single-target path, a missing platform is a hard error here because the
- # caller asked for that specific arch.
+ # Map one GraphQL version object to a PackageVersion: universe facts
+ # only, unconditional — nothing here depends on who asked.
#
- # @param mod_reference [String] for error messages
- # @param version_data [Hash] the chosen version object
- # @param requested [Array] platforms to resolve
- # @return [Hash{String => Hash}] target name → { "hash" => …, "link" => … }
- # @raise [TargetNotFoundError] if a requested platform has no target
+ # @param mod_data [Hash] the mod object (for mod_id)
+ # @param version_data [Hash] one version object
+ # @return [PackageVersion]
sig do
params(
- mod_reference: String,
+ mod_data: T::Hash[String, T.untyped],
version_data: T::Hash[String, T.untyped],
- requested: T::Array[T.nilable(String)],
- ).returns(T::Hash[String, T::Hash[String, String]])
+ ).returns(PackageVersion)
end
- def resolve_platforms(mod_reference, version_data, requested)
+ def package_version(mod_data, version_data)
targets = version_data["targets"] || []
- target_names = requested.map { |platform| platform.nil? ? DEFAULT_TARGET : platform }.uniq
-
- target_names.each_with_object({}) do |target_name, acc|
- target_data = targets.find { |t| t["targetName"] == target_name }
- unless target_data
- available = targets.map { |t| t["targetName"] }.join(", ")
- raise TargetNotFoundError,
- "#{mod_reference} #{version_data["version"]} has no #{target_name} target (available: #{available})"
- end
-
- acc[target_name] = {
- "hash" => "SHA256=#{target_data["hash"]}",
- "link" => download_url(version_data, target_data),
- }
- end
+
+ PackageVersion.new(
+ version: version_data["version"],
+ platforms: targets.map { |t| t["targetName"] },
+ artifacts: targets.to_h do |t|
+ [t["targetName"], Artifact.new(uri: download_url(version_data, t), digest: "SHA256=#{t["hash"]}")]
+ end,
+ declarations: Declarations::Resolved.new(
+ (version_data["dependencies"] || [])
+ .reject { |d| d["optional"] }
+ .map { |d| edge_declaration(d) },
+ ),
+ metadata: {
+ "mod_id" => mod_data["id"],
+ "game_version" => version_data["game_version"],
+ },
+ )
+ end
+
+ # Normalize a ficsit dependency edge into a Declaration: the raw
+ # "condition" (a semver range string, possibly absent) becomes dev's
+ # constraint shape here, at the boundary — upstream syntax crosses into
+ # the system exactly once. The integration is stamped by this
+ # repository: ficsit mods require ficsit mods.
+ #
+ # @param dependency_data [Hash] one GraphQL dependency object
+ # @return [Declaration]
+ sig { params(dependency_data: T::Hash[String, T.untyped]).returns(Declaration) }
+ def edge_declaration(dependency_data)
+ condition = dependency_data["condition"]
+ constraint = condition && !condition.empty? ? { "version" => condition } : {}
+ Declaration.new(name: dependency_data["mod_id"], integration: :ficsit, constraint: constraint)
end
# Build the absolute download URL for a target. ficsit returns a relative
@@ -211,21 +191,6 @@ def post_graphql(body)
response
end
-
- # Find the target matching the requested platform.
- #
- # @param targets [Array] target objects from the version
- # @param target_name [String] platform name (e.g. "Windows")
- # @return [Hash, nil] matching target, or nil when targets is empty
- sig do
- params(
- targets: T::Array[T::Hash[String, T.untyped]],
- target_name: String,
- ).returns(T.nilable(T::Hash[String, T.untyped]))
- end
- def find_target(targets, target_name)
- targets.find { |t| t["targetName"] == target_name } || targets.first
- end
end
end
end
diff --git a/lib/dev/deps/gem_scheme.rb b/lib/dev/deps/gem_scheme.rb
new file mode 100644
index 0000000..68f077c
--- /dev/null
+++ b/lib/dev/deps/gem_scheme.rb
@@ -0,0 +1,71 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "version_scheme"
+
+module Dev
+ module Deps
+ # RubyGems constraint semantics (:bundler): Gem::Requirement syntax
+ # ("~> 1.17", ">= 1.0, < 2.0", exact pins) and Gem::Version ordering.
+ #
+ # A thin wrapper over rubygems' own classes — the authority on its own
+ # version grammar. Comma-separated requirements are conjunctive, matching
+ # Gemfile semantics.
+ class GemScheme < VersionScheme
+ extend T::Sig
+
+ # The requirement string is not valid Gem::Requirement syntax.
+ class InvalidConstraintError < VersionScheme::InvalidConstraintError; end
+ # The version string is not a valid Gem::Version.
+ class InvalidVersionError < VersionScheme::InvalidVersionError; end
+
+ # The constraint key carrying the version requirement (the gem DSL's
+ # positional requirement lands under "version").
+ CONSTRAINT_KEY = "version"
+
+ # @param version [PackageVersion] a candidate; only its version string matters
+ # @param constraint [Hash] declaration constraint; only "version" is a
+ # version requirement (other keys — require:, git: — are gem options)
+ # @return [Boolean]
+ # @raise [InvalidConstraintError] if the requirement does not parse
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ expression = constraint[CONSTRAINT_KEY].to_s.strip
+ return true if expression.empty?
+
+ requirement(expression).satisfied_by?(gem_version(version.version))
+ end
+
+ # @param versions [Array] gem version strings
+ # @return [Array] ascending by Gem::Version ordering
+ # @raise [InvalidVersionError] if any version does not parse
+ sig { override.params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ versions.sort_by { |version| gem_version(version) }
+ end
+
+ private
+
+ # @param expression [String] comma-separated requirement terms
+ # @return [Gem::Requirement]
+ # @raise [InvalidConstraintError] if any term does not parse
+ sig { params(expression: String).returns(Gem::Requirement) }
+ def requirement(expression)
+ Gem::Requirement.new(expression.split(",").map(&:strip))
+ rescue Gem::Requirement::BadRequirementError
+ raise InvalidConstraintError, "not a rubygems requirement: #{expression.inspect}"
+ end
+
+ # @param version [String]
+ # @return [Gem::Version]
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { params(version: String).returns(Gem::Version) }
+ def gem_version(version)
+ Gem::Version.new(version)
+ rescue ArgumentError
+ raise InvalidVersionError, "not a rubygems version: #{version.inspect}"
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/gem_skill_linker.rb b/lib/dev/deps/gem_skill_linker.rb
index 301f898..319bb8f 100644
--- a/lib/dev/deps/gem_skill_linker.rb
+++ b/lib/dev/deps/gem_skill_linker.rb
@@ -4,7 +4,7 @@
require "open3"
require "pathname"
require_relative "../skill_installer"
-require_relative "bundler_repository"
+require_relative "bundler_locker"
module Dev
module Deps
@@ -193,7 +193,7 @@ def prune_stale_links(expected_names)
# @return [Pathname]
sig { returns(Pathname) }
def gemfile_path
- @project_root / BundlerRepository::GEMFILE
+ @project_root / BundlerLocker::GEMFILE
end
# @return [Pathname]
diff --git a/lib/dev/deps/gh_integration.rb b/lib/dev/deps/gh_integration.rb
index 6fa4308..12fb3b4 100644
--- a/lib/dev/deps/gh_integration.rb
+++ b/lib/dev/deps/gh_integration.rb
@@ -46,6 +46,7 @@ class IntegrityError < StandardError; end
class ExtractionError < StandardError; end
class UnsupportedArchiveError < StandardError; end
class BuildError < StandardError; end
+ class NoMatchingAssetsError < StandardError; end
MARKER_FILE = ".dev-gh-release"
@@ -78,10 +79,15 @@ def install_all(dependencies)
sig { returns(T.nilable(Pathname)) }
attr_reader :project_root
+ # Dispatch on the declared materialization: an asset glob means the
+ # prebuilt shape, a build recipe means build-from-source. The pin's
+ # "assets" fact list can be present either way (the tag's release
+ # publishes what it publishes); what the declaration MEANT is the glob.
+ #
# @param dep [Dependency]
sig { params(dep: Dependency).void }
def install(dep)
- dep.metadata["assets"] ? install_prebuilt(dep) : install_from_source(dep)
+ dep.metadata["asset_pattern"] ? install_prebuilt(dep) : install_from_source(dep)
end
# @param dep [Dependency]
@@ -297,13 +303,18 @@ def download_assets(dep, archives_dir)
# Verify downloaded files against the digests locked at resolve time.
# Assets locked without a digest (older releases) are skipped.
#
+ # Only glob-matched assets are verified: the lock records every asset
+ # the release publishes (facts), while the declared pattern says which
+ # of them this dep materializes — the same selection `gh release
+ # download --pattern` applied to the download.
+ #
# @param dep [Dependency]
# @param archives_dir [Pathname]
# @raise [DownloadError] if a locked asset is missing from the download
# @raise [IntegrityError] if a digest does not match
sig { params(dep: Dependency, archives_dir: Pathname).void }
def verify_assets(dep, archives_dir)
- dep.metadata["assets"].each do |asset|
+ matching_assets(dep).each do |asset|
path = archives_dir / asset["name"]
raise DownloadError, "expected asset #{asset["name"]} was not downloaded" unless path.file?
@@ -318,6 +329,23 @@ def verify_assets(dep, archives_dir)
end
end
+ # The locked assets the declared glob selects.
+ #
+ # @param dep [Dependency]
+ # @return [Array] matching locked asset entries
+ # @raise [NoMatchingAssetsError] if the glob selects nothing — the tag's
+ # release publishes no matching asset, or the tag has no release
+ sig { params(dep: Dependency).returns(T::Array[T::Hash[String, T.untyped]]) }
+ def matching_assets(dep)
+ pattern = dep.metadata["asset_pattern"]
+ matching = (dep.metadata["assets"] || []).select { |asset| File.fnmatch(pattern, asset["name"]) }
+ return matching unless matching.empty?
+
+ raise NoMatchingAssetsError,
+ "no locked assets matching #{pattern.inspect} for #{dep.metadata["repo"]}@#{dep.version} " \
+ "— check the assets: glob, or run dev update-deps"
+ end
+
# Extract all downloaded archives into extracted_dir. Split archives
# (name.tar.zst.00, .01, ...) are grouped by base name and concatenated
# in part order before decompression.
diff --git a/lib/dev/deps/gh_repository.rb b/lib/dev/deps/gh_repository.rb
index 78b381b..065df1c 100644
--- a/lib/dev/deps/gh_repository.rb
+++ b/lib/dev/deps/gh_repository.rb
@@ -3,155 +3,146 @@
require "json"
require "open3"
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
- # Resolves GitHub release dependencies via the gh CLI.
+ # Reports GitHub-hosted dependencies via the gh CLI: the discrete
+ # universe is the repo's tags and releases, fully enumerated with facts.
#
- # Resolution is a single metadata API call — no artifact download.
- # Per-asset SHA256 digests reported by the GitHub API are recorded in
- # metadata so GhIntegration can verify downloads against the lockfile.
+ # Two paginated list calls cover everything: the releases list carries
+ # each release's assets (names, sizes, API-reported SHA256 digests) and
+ # the tags list carries each tag's commit SHA — so enumeration is
+ # facts-complete with no per-version calls. Resolution is metadata only,
+ # no artifact download; digests are recorded so GhIntegration can verify
+ # downloads against the lockfile. Asset selection against the declared
+ # glob happens at install, where the glob arrives via the pin's
+ # materialization.
#
# Declared in dependencies.rb as:
- # gh "satisfactorymodding/UnrealEngine",
- # tag: "5.6.1-css-83",
- # assets: "UnrealEngine-CSS-Editor-Linux.tar.zst.*",
- # install_dir: "~/.dev/engines/unreal-engine-css"
+ # gh "UnrealEngine",
+ # github: "d3mlabs/unreal-engine",
+ # tag: "5.8.0-wine-7",
+ # assets: "UnrealEngine-Wine-Editor-Linux.tar.zst.*",
+ # install_dir: "~/.dev/engines/ue5"
class GhRepository < Repository
extend T::Sig
class GhMissingError < StandardError; end
class AuthenticationError < StandardError; end
class RepoAccessError < StandardError; end
- class ReleaseNotFoundError < StandardError; end
- class NoMatchingAssetsError < StandardError; end
class ApiError < StandardError; end
- # Resolve a GitHub dependency to a pinned Dependency.
+ # GitHub's maximum page size; fewer results than this ends pagination.
+ PER_PAGE = 100
+
+ # Report a GitHub dependency's universe: every tag and release.
#
- # Two shapes, distinguished by the declaration: "assets" => prebuilt release
- # assets (download + verify); "build" => build from the tag's source archive.
+ # Universe order feeds the unconstrained pick (ExactScheme preserves
+ # order and the Resolver takes the last sorted version): tag-only
+ # versions first, then releases oldest to newest, so an unconstrained
+ # gh dep pins the latest release rather than an arbitrary tag.
#
- # @param id [Hash] must include "name", "repo" (owner/repo slug), "tag",
- # "install_dir", "integration", "group", and one of "assets"/"build"
- # @return [Dependency]
+ # @param id [PackageId] source is the "owner/repo" slug
+ # @return [Package] one version per tag/release, facts complete
# @raise [GhMissingError] if the gh CLI is not installed
# @raise [AuthenticationError] if gh is not authenticated
# @raise [RepoAccessError] if the repo is not visible to the account
- # @raise [ReleaseNotFoundError] if the tag has no release/ref
- # @raise [NoMatchingAssetsError] if no assets match the pattern
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- id["assets"] ? fetch_prebuilt(id) : fetch_source(id)
+ # @raise [PackageNotFoundError] if the repo has no tags or releases
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ repo_slug = T.must(id.source)
+ releases = list(repo_slug, "releases")
+ tags = list(repo_slug, "tags")
+ if releases.empty? && tags.empty?
+ raise PackageNotFoundError, "#{repo_slug} publishes no tags or releases"
+ end
+
+ commit_by_tag = tags.to_h { |tag| [tag["name"], tag.dig("commit", "sha")] }
+ # Drafts have no tag yet; they are not part of the published universe.
+ release_by_tag = releases.reject { |release| release["draft"] }
+ .to_h { |release| [release["tag_name"], release] }
+
+ tag_only = commit_by_tag.keys - release_by_tag.keys
+ ordered = tag_only + release_by_tag.keys.reverse
+
+ versions = ordered.map do |tag|
+ version_for(repo_slug, tag, commit_by_tag[tag], release_by_tag[tag])
+ end
+ Package.new(id: id, versions: versions)
end
private
- # Resolve a prebuilt-release dependency (download + verify path).
+ # Assemble one tag's facts: the commit SHA it points at and, when it
+ # publishes a release, every asset — unselected.
#
- # @param id [Hash]
- # @return [Dependency]
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch_prebuilt(id)
- repo_slug = id["repo"]
- tag = id["tag"]
- pattern = id["assets"]
-
- release = fetch_release(repo_slug, tag)
- assets = matching_assets(release, pattern)
- if assets.empty?
- raise NoMatchingAssetsError,
- "no assets matching #{pattern.inspect} in #{repo_slug}@#{tag}"
- end
-
- Dependency.new(
- name: id["name"],
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: tag,
- hash: nil,
- metadata: {
- "repo" => repo_slug,
- "asset_pattern" => pattern,
- "install_dir" => id["install_dir"],
- "assets" => assets.map { |asset| asset_metadata(asset) },
- },
- )
+ # @param repo_slug [String] "owner/repo"
+ # @param tag [String] tag name (the version string)
+ # @param commit [String, nil] commit SHA from the tags list
+ # @param release [Hash, nil] release object from the releases list
+ # @return [PackageVersion]
+ sig do
+ params(
+ repo_slug: String,
+ tag: String,
+ commit: T.nilable(String),
+ release: T.nilable(T::Hash[String, T.untyped]),
+ ).returns(PackageVersion)
end
+ def version_for(repo_slug, tag, commit, release)
+ metadata = T.let({ "repo" => repo_slug }, T::Hash[String, T.untyped])
+ metadata["commit"] = commit if commit
+ metadata["assets"] = (release["assets"] || []).map { |asset| asset_metadata(asset) } if release
- # Resolve a build-from-source dependency. Pins the tag's commit SHA (for
- # provenance) and records the build recipe; GhIntegration fetches the source
- # archive and runs the build. No release is required — the tag just needs to
- # exist as a ref (Epic ships UE as source, not release assets).
- #
- # @param id [Hash]
- # @return [Dependency]
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch_source(id)
- repo_slug = id["repo"]
- tag = id["tag"]
- commit = resolve_commit_sha(repo_slug, tag)
-
- Dependency.new(
- name: id["name"],
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
+ PackageVersion.new(
version: tag,
- hash: nil,
- metadata: {
- "repo" => repo_slug,
- "install_dir" => id["install_dir"],
- "build" => id["build"],
- "commit" => commit,
- },
+ metadata: metadata,
+ # Usage contract, not a guarantee: prebuilt assets baked their needs
+ # in at build time, and a source build's transitive needs are
+ # declared by the consuming project's own dependencies.rb rows.
+ # Revisit when subproject resolution lands.
+ declarations: Declarations::Resolved.new([]),
)
end
- # Resolve a tag to its commit SHA, mapping gh failures to actionable errors.
+ # Enumerate a paginated list endpoint to exhaustion.
#
# @param repo_slug [String] "owner/repo"
- # @param tag [String] tag/ref
- # @return [String] commit SHA
- sig { params(repo_slug: String, tag: String).returns(String) }
- def resolve_commit_sha(repo_slug, tag)
- out, err, status = run_gh_api("repos/#{repo_slug}/commits/#{tag}")
- return JSON.parse(out)["sha"] if status.success?
-
- raise_auth_error!(err)
- raise_not_found_error!(repo_slug, tag) if not_found?(err)
- raise ApiError, "gh api failed resolving #{repo_slug}@#{tag}: #{err.strip}"
- end
-
- # Fetch release metadata for a tag, mapping gh failures to actionable errors.
- #
- # @param repo_slug [String] "owner/repo"
- # @param tag [String] release tag
- # @return [Hash] parsed release JSON
- sig { params(repo_slug: String, tag: String).returns(T::Hash[String, T.untyped]) }
- def fetch_release(repo_slug, tag)
- out, err, status = run_gh_api("repos/#{repo_slug}/releases/tags/#{tag}")
- return JSON.parse(out) if status.success?
-
- raise_auth_error!(err)
- raise_not_found_error!(repo_slug, tag) if not_found?(err)
- raise ApiError, "gh api failed for #{repo_slug}@#{tag}: #{err.strip}"
+ # @param collection [String] "releases" or "tags"
+ # @return [Array] every object the endpoint lists
+ sig { params(repo_slug: String, collection: String).returns(T::Array[T::Hash[String, T.untyped]]) }
+ def list(repo_slug, collection)
+ results = T.let([], T::Array[T::Hash[String, T.untyped]])
+ page = 1
+ loop do
+ out, err, status = run_gh_api("repos/#{repo_slug}/#{collection}?per_page=#{PER_PAGE}&page=#{page}")
+ unless status.success?
+ raise_auth_error!(err)
+ raise_repo_access_error!(repo_slug) if not_found?(err)
+ raise ApiError, "gh api failed listing #{collection} for #{repo_slug}: #{err.strip}"
+ end
+
+ batch = JSON.parse(out)
+ results.concat(batch)
+ break if batch.size < PER_PAGE
+
+ page += 1
+ end
+ results
end
- # Distinguish "repo invisible" (account not linked) from "tag missing".
- # Forks of private repos 404 for accounts without access, so a second
- # probe of the repo itself tells us which problem the user has.
+ # A 404 on a list endpoint means the repo itself is invisible to the
+ # account (list endpoints answer [] for empty collections), which for
+ # Epic-gated repos has a known fix worth spelling out.
#
# @param repo_slug [String] "owner/repo"
- # @param tag [String] release tag
- sig { params(repo_slug: String, tag: String).void }
- def raise_not_found_error!(repo_slug, tag)
- _out, _err, status = run_gh_api("repos/#{repo_slug}")
- if status.success?
- raise ReleaseNotFoundError, "no release or tag #{tag.inspect} in #{repo_slug}"
- end
-
+ sig { params(repo_slug: String).void }
+ def raise_repo_access_error!(repo_slug)
raise RepoAccessError, <<~MSG
#{repo_slug} is not visible to your GitHub account.
For satisfactorymodding/UnrealEngine: link your GitHub account to Epic Games,
@@ -178,7 +169,7 @@ def not_found?(err)
# Run a gh api call. Isolated so tests can stub the CLI boundary.
#
- # @param path [String] API path (e.g. "repos/owner/repo/releases/tags/v1")
+ # @param path [String] API path (e.g. "repos/owner/repo/releases?per_page=100&page=1")
# @return [Array(String, String, Process::Status)] stdout, stderr, status
sig { params(path: String).returns([String, String, Process::Status]) }
def run_gh_api(path)
@@ -187,22 +178,6 @@ def run_gh_api(path)
raise GhMissingError, "gh CLI not found — install it with: brew install gh"
end
- # Select release assets whose names match the glob pattern.
- #
- # @param release [Hash] parsed release JSON
- # @param pattern [String] glob pattern (e.g. "*.tar.zst.*")
- # @return [Array] matching asset objects
- sig do
- params(
- release: T::Hash[String, T.untyped],
- pattern: String,
- ).returns(T::Array[T::Hash[String, T.untyped]])
- end
- def matching_assets(release, pattern)
- assets = release["assets"] || []
- assets.select { |asset| File.fnmatch(pattern, asset["name"]) }
- end
-
# Map an API asset object to lockfile metadata. The API digest is
# "sha256:"; we strip the prefix. Assets without a digest omit the
# key — GhIntegration only verifies assets that have one.
diff --git a/lib/dev/deps/git_repository.rb b/lib/dev/deps/git_repository.rb
index 179791c..56a317b 100644
--- a/lib/dev/deps/git_repository.rb
+++ b/lib/dev/deps/git_repository.rb
@@ -2,67 +2,96 @@
# frozen_string_literal: true
require "open3"
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
- # Fetches git-hosted dependencies: tag → SHA, branch → SHA, or commit passthrough.
+ # Reports git-hosted dependencies: the discrete universe is the remote's
+ # refs, the continuous space is its commit SHAs.
#
- # Uses `git ls-remote` to resolve tags and branches to full SHAs.
- # 40-char hex commit SHAs pass through without network calls.
- # Git SHAs are identifiers, not integrity hashes — hash field is nil.
+ # find enumerates every tag and branch head via one `git ls-remote` call —
+ # each version is the resolved full SHA carrying the ref it resolved from
+ # as a fact, which is what GitScheme's tag/branch matching selects on.
+ # Commit SHAs are unreachable by enumeration (`ls-remote` lists refs,
+ # never reachable commits), so a commit pin arrives as the declaration's
+ # revision and is lifted by at — pure, no network: a full SHA is
+ # self-certifying as an address, and existence surfaces at fetch time.
+ # Git SHAs are identifiers, not integrity hashes — digest is nil.
class GitRepository < Repository
extend T::Sig
- class RefResolutionError < StandardError; end
+ class RefResolutionError < PackageNotFoundError; end
- # Resolve a git dependency identifier to a pinned Dependency.
+ # Report a git dependency's universe: every tag and branch head,
+ # resolved to full SHAs.
#
- # @param id [Hash] must include "name", "repo", "integration", "group",
- # and one of "tag" or "commit"
- # @return [Dependency] with version set to the resolved full SHA
- # @raise [RefResolutionError] if the ref cannot be resolved via ls-remote
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- repo_url = id["repo"]
- tag = id["tag"]
- commit = id["commit"]
- ref = commit || tag
+ # @param id [PackageId] source is the git remote URL
+ # @return [Package] one version per ref, the ref riding as a fact
+ # @raise [RefResolutionError] if the remote's refs cannot be listed
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ repo_url = T.must(id.source)
+ refs = enumerate_refs(repo_url)
+ raise RefResolutionError, "no refs listable for #{id.name} at #{repo_url}" if refs.empty?
- sha = resolve_ref(repo_url, ref)
+ Package.new(
+ id: id,
+ versions: refs.map do |ref, sha|
+ PackageVersion.new(
+ version: sha,
+ metadata: { "repo" => repo_url, "ref" => ref },
+ # A checked-out source tree carries no manifest dev reads;
+ # consumers declare what they need alongside it.
+ declarations: Declarations::Resolved.new([]),
+ )
+ end,
+ )
+ end
- Dependency.new(
- name: id["name"],
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: sha,
- hash: nil,
- metadata: { "repo" => repo_url },
+ # Lift a commit SHA into a version. Pure — no network: the SHA is the
+ # version, the author already chose it, and a bad address surfaces at
+ # fetch time exactly like a force-pushed ref would.
+ #
+ # @param id [PackageId] source is the git remote URL
+ # @param revision [String] full 40-char commit SHA (DSL-validated)
+ # @return [PackageVersion]
+ sig { override.params(id: PackageId, revision: String).returns(PackageVersion) }
+ def at(id, revision)
+ PackageVersion.new(
+ version: revision,
+ metadata: { "repo" => T.must(id.source) },
+ declarations: Declarations::Resolved.new([]),
)
end
private
- # Resolve a git ref (tag, branch, or commit SHA) to a full 40-char SHA.
+ # List every tag and branch head with its commit SHA, in one call.
#
- # Tries in order: passthrough for 40-char hex, ls-remote --tags, ls-remote branch.
+ # Annotated tags list twice — the tag object and a peeled "[^{}"
+ # line pointing at the commit; the peeled SHA wins, because the commit
+ # is what a checkout materializes.
#
# @param repo [String] git remote URL
- # @param ref [String] tag name, branch name, or commit SHA
- # @return [String] full 40-char SHA
- # @raise [RefResolutionError] if no match found
- sig { params(repo: String, ref: String).returns(String) }
- def resolve_ref(repo, ref)
- return ref if ref.to_s.length == 40 && ref.to_s.match?(/\A[0-9a-f]+\z/)
-
- out, _err, status = Open3.capture3("git", "ls-remote", "--tags", repo, ref.to_s)
- return T.must(out.lines.first&.split&.first) if status.success? && !out.strip.empty?
+ # @return [Hash{String => String}] ref name (unprefixed) -> full SHA
+ # @raise [RefResolutionError] if ls-remote fails
+ sig { params(repo: String).returns(T::Hash[String, String]) }
+ def enumerate_refs(repo)
+ out, err, status = Open3.capture3("git", "ls-remote", "--tags", "--heads", repo)
+ raise RefResolutionError, "git ls-remote failed for #{repo}: #{err}" unless status.success?
- out, _err, status = Open3.capture3("git", "ls-remote", repo, "refs/heads/#{ref}")
- return T.must(out.lines.first&.split&.first) if status.success? && !out.strip.empty?
+ out.lines.each_with_object({}) do |line, refs|
+ sha, refname = line.split
+ next unless sha && refname
- raise RefResolutionError, "Could not resolve ref '#{ref}' for #{repo}"
+ peeled = refname.end_with?("^{}")
+ name = refname.delete_suffix("^{}").sub(%r{\Arefs/(tags|heads)/}, "")
+ refs[name] = sha if peeled || !refs.key?(name)
+ end
end
end
end
diff --git a/lib/dev/deps/git_scheme.rb b/lib/dev/deps/git_scheme.rb
new file mode 100644
index 0000000..bdd62fa
--- /dev/null
+++ b/lib/dev/deps/git_scheme.rb
@@ -0,0 +1,41 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "version_scheme"
+
+module Dev
+ module Deps
+ # Git ref constraint semantics (:cmake): the constraint names a ref —
+ # "tag" or "branch" — and the universe's versions are resolved SHAs
+ # carrying the ref they resolved from as a fact.
+ #
+ # A ref constraint matches the version's "ref" fact, never the version
+ # string: the SHA a ref points at is a repository fact the scheme cannot
+ # derive. Commit pins are not constraints at all — a SHA is an address
+ # into the continuous space, declared as the revision and lifted by
+ # GitRepository#at without any scheme running.
+ class GitScheme < VersionScheme
+ extend T::Sig
+
+ # @param version [PackageVersion] a candidate (version is the resolved
+ # SHA, "ref" rides its metadata)
+ # @param constraint [Hash] declaration constraint; "tag" or "branch"
+ # @return [Boolean]
+ sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ ref = constraint["tag"] || constraint["branch"]
+ return true if ref.nil?
+
+ version.metadata["ref"].to_s == ref.to_s
+ end
+
+ # @param versions [Array] resolved SHAs
+ # @return [Array] the same versions, order untouched — SHAs
+ # carry no order
+ sig { override.params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ versions.dup
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/dependency_installer.rb b/lib/dev/deps/installer.rb
similarity index 77%
rename from lib/dev/deps/dependency_installer.rb
rename to lib/dev/deps/installer.rb
index 472961a..23cfee0 100644
--- a/lib/dev/deps/dependency_installer.rb
+++ b/lib/dev/deps/installer.rb
@@ -1,15 +1,20 @@
-# typed: true
+# typed: strict
# frozen_string_literal: true
+require "sorbet-runtime"
+
module Dev
module Deps
# Reads locked dependencies and dispatches to integrations.
#
# Cross-cutting install concerns (env filtering, build-first ordering)
# live here — not in Integration or Lockfile.
- class DependencyInstaller
+ class Installer
+ extend T::Sig
+
# @param lockfile [Lockfile] lockfile reader
# @param integrations [Hash{Symbol => Integration}] integration type → integration
+ sig { params(lockfile: Lockfile, integrations: T::Hash[Symbol, T.untyped]).void }
def initialize(lockfile:, integrations:)
@lockfile = lockfile
@integrations = integrations
@@ -35,6 +40,8 @@ def initialize(lockfile:, integrations:)
#
# @param env [String, nil] environment name for filtering (nil = no filtering)
# @param host [String, nil] host OS name for filtering (nil = no filtering)
+ # @return [void]
+ sig { params(env: T.nilable(String), host: T.nilable(String)).void }
def install(env: nil, host: nil)
all_deps = @lockfile.read
all_deps = filter_by_env(all_deps, env) if env
@@ -48,12 +55,18 @@ def install(env: nil, host: nil)
private
- # Dispatch deps to their matching integrations, grouped by type.
+ # Dispatch deps to their matching integrations, grouped by integration
+ # INSTANCE rather than by type symbol: types that install through a
+ # shared instance (Registry install_alias — e.g. :url deps through
+ # :cmake's pipeline) must arrive in one install_all call, or an
+ # integration generating batch artifacts (deps.cmake) would overwrite
+ # its own output with each partial group.
#
# @param deps [Array] dependencies to install
+ # @return [void]
+ sig { params(deps: T::Array[Dependency]).void }
def dispatch(deps)
- deps.group_by(&:integration).each do |type, typed_deps|
- integration = @integrations[type]
+ deps.group_by { |dep| @integrations[dep.integration] }.each do |integration, typed_deps|
integration&.install_all(typed_deps)
end
end
@@ -64,6 +77,7 @@ def dispatch(deps)
# @param deps [Array] all deps
# @param env [String] target environment
# @return [Array]
+ sig { params(deps: T::Array[Dependency], env: String).returns(T::Array[Dependency]) }
def filter_by_env(deps, env)
deps.select do |dep|
dep_env = dep.metadata["env"]
@@ -78,6 +92,7 @@ def filter_by_env(deps, env)
# @param deps [Array] all deps
# @param host [String] detected host OS ("darwin" / "linux")
# @return [Array]
+ sig { params(deps: T::Array[Dependency], host: String).returns(T::Array[Dependency]) }
def filter_by_host(deps, host)
deps.select do |dep|
dep_host = dep.metadata["host"]
diff --git a/lib/dev/deps/locker.rb b/lib/dev/deps/locker.rb
new file mode 100644
index 0000000..771e513
--- /dev/null
+++ b/lib/dev/deps/locker.rb
@@ -0,0 +1,33 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "scoped_declaration"
+
+module Dev
+ module Deps
+ # Whole-set solve step for integrations whose ecosystem tool owns
+ # dependency resolution.
+ #
+ # A Locker takes every declaration of its integration type at once and
+ # delegates the joint solve to the external tool (bundler's `bundle
+ # lock`), materializing the tool's own lockfile. The integration's
+ # Repository then reads that lockfile back as a fact universe. This is
+ # the home of the batch semantics that used to hide in
+ # Repository#prepare: repositories answer per-package questions; lockers
+ # solve whole sets. Integrations without a tool-owned solve have no
+ # Locker. See docs/deps-architecture.md.
+ class Locker
+ extend T::Sig
+
+ # Solve the whole declaration set, materializing the tool's lockfile.
+ #
+ # @param declarations [Array] every declaration
+ # of this integration type
+ # @return [void]
+ sig { params(declarations: T::Array[ScopedDeclaration]).void }
+ def lock(declarations)
+ raise NotImplementedError, "#{self.class}#lock must be implemented"
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/lockfile.rb b/lib/dev/deps/lockfile.rb
index 7a1c177..49b865b 100644
--- a/lib/dev/deps/lockfile.rb
+++ b/lib/dev/deps/lockfile.rb
@@ -1,6 +1,7 @@
-# typed: true
+# typed: strict
# frozen_string_literal: true
+require "sorbet-runtime"
require "yaml"
require "fileutils"
require "pathname"
@@ -14,7 +15,22 @@ module Deps
# Two lockfiles, routing handled internally:
# deps.lock — app + test groups
# build-deps.lock — build group (env-scoped deps nested under env:)
+ #
+ # Entries nest by integration, mirroring package identity (integration +
+ # name), so the same name under two integrations occupies two keys:
+ #
+ # brew:
+ # zlib: { group: app, version: "1.3" }
+ # cmake:
+ # zlib: { group: app, version: "1.3.1" }
+ #
+ # The reader also accepts the legacy flat format (name-keyed, integration
+ # recorded in the value hash) so repos keep installing from lockfiles
+ # written before the nesting; that shim is deleted once every consumer
+ # repo's lockfiles have been rewritten by update-deps (issue #146).
class Lockfile
+ extend T::Sig
+
HEADER = <<~COMMENT
# Generated by dev. Do not edit.
# Edit dependencies.rb and run dev update-deps to change.
@@ -30,8 +46,10 @@ class Lockfile
BUILD_DEPS_LOCK_FILE = "build-deps.lock"
# @param dir [Pathname] directory containing lockfiles
+ sig { params(dir: T.any(Pathname, String)).void }
def initialize(dir:)
- @dir = Pathname(dir)
+ @dir = T.let(Pathname(dir), Pathname)
+ @manifest_digest = T.let(nil, T.nilable(String))
end
# Lock all dependencies — routes to the appropriate lockfile by group.
@@ -39,6 +57,8 @@ def initialize(dir:)
# @param deps [Array]
# @param manifest_digest [String, nil] SHA-256 hex of the dependencies.rb
# these locks were resolved from, recorded in each lockfile's header
+ # @return [void]
+ sig { params(deps: T::Array[Dependency], manifest_digest: T.nilable(String)).void }
def lock(deps, manifest_digest: nil)
@manifest_digest = manifest_digest
app_test_deps, build_deps = deps.partition { |d| d.group != :build }
@@ -52,6 +72,7 @@ def lock(deps, manifest_digest: nil)
# stale — legacy locks shouldn't nag until their next update-deps).
#
# @return [String, nil] SHA-256 hex
+ sig { returns(T.nilable(String)) }
def manifest_digest
[@dir / DEPS_LOCK_FILE, @dir / BUILD_DEPS_LOCK_FILE].each do |path|
next unless path.exist?
@@ -67,12 +88,19 @@ def manifest_digest
# Read all locked dependencies from both lockfiles.
#
# @return [Array]
+ sig { returns(T::Array[Dependency]) }
def read
read_lockfile(@dir / DEPS_LOCK_FILE) + read_lockfile(@dir / BUILD_DEPS_LOCK_FILE)
end
private
+ # Write deps to a lockfile at path, with the comment header.
+ #
+ # @param deps [Array]
+ # @param path [Pathname]
+ # @return [void]
+ sig { params(deps: T::Array[Dependency], path: Pathname).void }
def write_lockfile(deps, path)
FileUtils.mkdir_p(path.dirname)
yaml_hash = deps_to_yaml_hash(deps)
@@ -80,6 +108,9 @@ def write_lockfile(deps, path)
end
# The comment header, including the manifest digest when one was given.
+ #
+ # @return [String]
+ sig { returns(String) }
def header
lines = HEADER.dup
lines << "#{MANIFEST_DIGEST_PREFIX}#{@manifest_digest}\n" if @manifest_digest
@@ -87,6 +118,9 @@ def header
lines
end
+ # @param path [Pathname]
+ # @return [Array]
+ sig { params(path: Pathname).returns(T::Array[Dependency]) }
def read_lockfile(path)
return [] unless path.exist?
@@ -96,45 +130,100 @@ def read_lockfile(path)
yaml_hash_to_deps(yaml)
end
+ # Serialize deps as integration → name → attrs, one section per
+ # integration, so identity (integration + name) survives the trip to
+ # disk without a composite key.
+ #
+ # @param deps [Array]
+ # @return [Hash]
+ sig { params(deps: T::Array[Dependency]).returns(T::Hash[String, T.untyped]) }
def deps_to_yaml_hash(deps)
- result = {}
- deps.each { |dep| result[dep.name] = dep_to_hash(dep) }
+ result = T.let({}, T::Hash[String, T.untyped])
+ deps.each do |dep|
+ section = result[dep.integration.to_s] ||= {}
+ section[dep.name] = dep_to_hash(dep)
+ end
result
end
+ # A dep's value hash: group, version/hash when present, then metadata.
+ # The integration is not repeated here — it's the section key.
+ #
+ # @param dep [Dependency]
+ # @return [Hash]
+ sig { params(dep: Dependency).returns(T::Hash[String, T.untyped]) }
def dep_to_hash(dep)
- h = { "integration" => dep.integration.to_s, "group" => dep.group.to_s }
+ h = T.let({ "group" => dep.group.to_s }, T::Hash[String, T.untyped])
h["version"] = dep.version if dep.version
h["hash"] = dep.hash if dep.hash
dep.metadata&.each { |k, v| h[k.to_s] = v } unless dep.metadata.nil? || dep.metadata.empty?
h
end
+ # @param yaml [Hash] a parsed lockfile
+ # @return [Array]
+ sig { params(yaml: T::Hash[String, T.untyped]).returns(T::Array[Dependency]) }
def yaml_hash_to_deps(yaml)
- deps = []
+ deps = T.let([], T::Array[Dependency])
yaml.each do |key, value|
next unless value.is_a?(Hash)
if key == "env"
- value.each do |env_name, env_deps|
- env_deps.each { |dep_name, attrs| deps << hash_to_dep(dep_name, attrs, env: env_name) }
+ value.each do |env_name, env_section|
+ env_section.each { |k, v| deps.concat(entry_to_deps(k, v, env: env_name)) }
end
else
- deps << hash_to_dep(key, value)
+ deps.concat(entry_to_deps(key, value))
end
end
deps
end
- def hash_to_dep(name, attrs, env: nil)
+ # One lockfile entry, current or legacy format. An integration section
+ # maps names to attrs; a legacy entry is name-keyed and records its
+ # integration in the value hash — the discriminator, since the writer
+ # always recorded it there and never records it in sections.
+ #
+ # @param key [String] integration name, or a dep name (legacy)
+ # @param value [Hash] name → attrs section, or a dep's attrs (legacy)
+ # @param env [String, nil] env scope for build-deps env sections
+ # @return [Array]
+ sig do
+ params(
+ key: String,
+ value: T::Hash[String, T.untyped],
+ env: T.nilable(String),
+ ).returns(T::Array[Dependency])
+ end
+ def entry_to_deps(key, value, env: nil)
+ return [hash_to_dep(key, value, env: env)] if value.key?("integration")
+
+ value.map { |name, attrs| hash_to_dep(name, attrs, integration: key.to_sym, env: env) }
+ end
+
+ # @param name [String] the dep's name
+ # @param attrs [Hash] the dep's value hash
+ # @param integration [Symbol, nil] from the section key; nil for legacy
+ # entries, which carry it in attrs
+ # @param env [String, nil] env scope for build-deps env sections
+ # @return [Dependency]
+ sig do
+ params(
+ name: String,
+ attrs: T::Hash[String, T.untyped],
+ integration: T.nilable(Symbol),
+ env: T.nilable(String),
+ ).returns(Dependency)
+ end
+ def hash_to_dep(name, attrs, integration: nil, env: nil)
metadata = attrs.reject { |k, _| %w[integration group version hash].include?(k) }
metadata["env"] = env if env
Dependency.new(
name: name,
- integration: attrs["integration"].to_sym,
+ integration: integration || attrs["integration"].to_sym,
group: attrs["group"].to_sym,
version: attrs["version"],
hash: attrs["hash"],
@@ -142,21 +231,27 @@ def hash_to_dep(name, attrs, env: nil)
)
end
+ # Build deps split into global entries and env-scoped ones nested under
+ # env: → env name → integration → name (the env axis wraps the same
+ # integration-nested shape the global sections use).
+ #
+ # @param deps [Array]
+ # @return [void]
+ sig { params(deps: T::Array[Dependency]).void }
def write_build_lockfile(deps)
global_deps = deps.select { |d| d.metadata.nil? || !d.metadata.key?("env") }
env_deps = deps.select { |d| d.metadata&.key?("env") }
- yaml_hash = {}
- global_deps.each { |dep| yaml_hash[dep.name] = dep_to_hash(dep) }
+ yaml_hash = deps_to_yaml_hash(global_deps)
if env_deps.any?
- env_section = {}
+ env_section = T.let({}, T::Hash[String, T.untyped])
env_deps.each do |dep|
env_name = dep.metadata["env"]
- env_section[env_name] ||= {}
+ section = (env_section[env_name] ||= {})[dep.integration.to_s] ||= {}
h = dep_to_hash(dep)
h.delete("env")
- env_section[env_name][dep.name] = h
+ section[dep.name] = h
end
yaml_hash["env"] = env_section
end
diff --git a/lib/dev/deps/luarocks_repository.rb b/lib/dev/deps/luarocks_repository.rb
index 238d877..03f8f3b 100644
--- a/lib/dev/deps/luarocks_repository.rb
+++ b/lib/dev/deps/luarocks_repository.rb
@@ -1,88 +1,66 @@
# typed: strict
# frozen_string_literal: true
-require "digest"
require "open3"
-require "tempfile"
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
- # Fetches LuaRocks packages to exact version + SHA256.
- #
- # Uses `luarocks search --porcelain` to find available versions,
- # picks the best match for the constraint, downloads the rock to compute
- # SHA256. Callers are responsible for caching.
+ # Fact universe over the LuaRocks manifest, via
+ # `luarocks search --porcelain`.
class LuaRocksRepository < Repository
extend T::Sig
class SearchError < StandardError; end
- class NoVersionError < StandardError; end
- class DownloadError < StandardError; end
+ class RockNotFoundError < PackageNotFoundError; end
- # Resolve a LuaRocks package to an exact version + integrity hash.
+ # Report a rock's available versions from `luarocks search`.
#
- # @param id [Hash] identifier with "name", "integration", "group", "constraint"
- # @return [Dependency]
+ # Facts only: the manifest search yields versions, nothing more — no
+ # digests (luarocks verifies rockspec integrity itself at install; the
+ # old resolve-time download-and-hash was audit-only and read by nobody)
+ # and no edges (rock dependencies would require fetching each rockspec).
+ # Constraint evaluation moves to RockScheme, fixing the old behavior of
+ # taking the first version and ignoring the constraint entirely.
+ #
+ # @param id [PackageId] name is the rock name
+ # @return [Package]
# @raise [SearchError] if luarocks search fails
- # @raise [NoVersionError] if no versions match
- # @raise [DownloadError] if luarocks download fails
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- name = id["name"]
- version = find_best_version(name, id["constraint"])
- rock_path = download_rock(name, version)
- sha256_hex = Digest::SHA256.file(rock_path).hexdigest
- hash = "SHA256=#{sha256_hex}"
+ # @raise [RockNotFoundError] if the search yields no versions
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ versions = search_versions(id.name)
+ raise RockNotFoundError, "no rock named #{id.name} on luarocks.org" if versions.empty?
- Dependency.new(
- name: name,
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: version,
- hash: hash,
- metadata: { "downloaded_path" => rock_path },
+ Package.new(
+ id: id,
+ versions: versions.map do |version|
+ # luarocks resolves rock dependencies itself at install time.
+ PackageVersion.new(version: version, declarations: Declarations::ToolOwned.new)
+ end,
)
end
private
- # Find the best available version for a package.
+ # All versions the manifest lists for a rock, most recent first,
+ # deduplicated across arches.
#
# @param name [String] rock name
- # @param _constraint [String, nil] version constraint (not yet used)
- # @return [String] best matching version
+ # @return [Array] version strings as listed
# @raise [SearchError] if luarocks search command fails
- # @raise [NoVersionError] if no versions found
- sig { params(name: String, _constraint: T.nilable(String)).returns(String) }
- def find_best_version(name, _constraint)
+ sig { params(name: String).returns(T::Array[String]) }
+ def search_versions(name)
out, _err, status = Open3.capture3("luarocks", "search", name, "--porcelain")
raise SearchError, "luarocks search #{name} failed" unless status.success?
# String#scan with a capture group always yields arrays of captures.
matches = T.cast(out.scan(/^\s+(\S+)\s+\(/), T::Array[T::Array[String]])
- versions = matches.map(&:first)
- raise NoVersionError, "No versions found for #{name}" if versions.empty?
-
- T.must(versions.first)
- end
-
- # Download a source rock to a temp file.
- #
- # @param name [String] rock name
- # @param version [String] exact version
- # @return [String] path to downloaded rock file
- # @raise [DownloadError] if luarocks download command fails
- sig { params(name: String, version: String).returns(String) }
- def download_rock(name, version)
- tmp = Tempfile.new(["dev_deps_#{name}", ".src.rock"])
- tmp.close
- _out, err, status = Open3.capture3(
- "luarocks", "download", name, version, "--source", "--to=#{File.dirname(T.must(tmp.path))}",
- )
- raise DownloadError, "luarocks download #{name} #{version} failed: #{err}" unless status.success?
- T.must(tmp.path)
+ matches.map { |match| T.must(match.first) }.uniq
end
end
end
diff --git a/lib/dev/deps/package.rb b/lib/dev/deps/package.rb
new file mode 100644
index 0000000..e9e8696
--- /dev/null
+++ b/lib/dev/deps/package.rb
@@ -0,0 +1,61 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "package_id"
+require_relative "package_version"
+
+module Dev
+ module Deps
+ # A package and the versions its backing service currently offers — the
+ # aggregate a Repository returns from #find.
+ #
+ # Deliberately has no #satisfies?, #sort or #best_match. A Package states
+ # what exists; evaluating a constraint against a version is the
+ # integration's VersionScheme, and choosing among the satisfying versions
+ # is the Resolver's policy. Keeping all three apart is what lets a new
+ # ecosystem supply facts without also supplying selection logic, and lets
+ # dev change selection policy without touching any repository. See
+ # docs/deps-architecture.md.
+ #
+ # The versions are exposed through this aggregate rather than returned as a
+ # bare array so the universe always arrives with the identity it belongs to.
+ class Package
+ extend T::Sig
+
+ # @return [PackageId] the identity these versions belong to
+ sig { returns(PackageId) }
+ attr_reader :id
+
+ # @return [Array] available versions, in the order the
+ # repository reported them (no ordering promise: ordering is the
+ # VersionScheme's job)
+ sig { returns(T::Array[PackageVersion]) }
+ attr_reader :versions
+
+ # @param id [PackageId] the package's identity
+ # @param versions [Array] the available versions
+ sig { params(id: PackageId, versions: T::Array[PackageVersion]).void }
+ def initialize(id:, versions:)
+ @id = id
+ @versions = T.let(versions.dup.freeze, T::Array[PackageVersion])
+ freeze
+ end
+
+ # Look up one version by its exact version string.
+ #
+ # @param version [String] the version string to match exactly
+ # @return [PackageVersion, nil] the matching version, or nil if this
+ # package does not offer it
+ sig { params(version: String).returns(T.nilable(PackageVersion)) }
+ def version(version)
+ versions.find { |candidate| candidate.version == version }
+ end
+
+ # @return [Boolean] whether the backing service offers no versions at all
+ sig { returns(T::Boolean) }
+ def empty?
+ versions.empty?
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/package_id.rb b/lib/dev/deps/package_id.rb
new file mode 100644
index 0000000..59f4ea3
--- /dev/null
+++ b/lib/dev/deps/package_id.rb
@@ -0,0 +1,75 @@
+# typed: strict
+# frozen_string_literal: true
+
+module Dev
+ module Deps
+ # Identity of a package within its integration's universe: constraint-free
+ # and version-free.
+ #
+ # This is the "which package are we talking about" half of the domain,
+ # separated from "which versions exist" (Package), "what does the project
+ # want" (ScopedDeclaration) and "what did we choose" (Dependency). It
+ # is the Resolver's resolved-set key, which is why identity includes the
+ # integration: two ecosystems can each publish a package named "ffi", and
+ # keying on the bare name would silently collapse them. The lockfile
+ # carries the same identity to disk by nesting entries under their
+ # integration (Lockfile).
+ #
+ # A plain value class rather than Data.define: `source` carries a default,
+ # and Data.define with a keyword-args initialize override is rejected by
+ # Sorbet at `typed: strict` (error 4010).
+ #
+ # See docs/deps-architecture.md for the four-type ontology this belongs to.
+ class PackageId
+ extend T::Sig
+
+ # @return [Symbol] the integration whose universe this package lives in
+ sig { returns(Symbol) }
+ attr_reader :integration
+
+ # @return [String] the package's name within that universe
+ sig { returns(String) }
+ attr_reader :name
+
+ # @return [String, nil] source coordinates for source-based packages
+ sig { returns(T.nilable(String)) }
+ attr_reader :source
+
+ # @param integration [Symbol] :bundler, :ficsit, :cmake, …
+ # @param name [String] the package's name within that integration
+ # @param source [String, nil] source coordinates for source-based
+ # packages (a git repo URL); nil for registry-backed types, where the
+ # name alone identifies the package
+ sig { params(integration: Symbol, name: String, source: T.nilable(String)).void }
+ def initialize(integration:, name:, source: nil)
+ @integration = integration
+ @name = name
+ @source = source
+ freeze
+ end
+
+ # @param other [Object]
+ # @return [Boolean] whether other identifies the same package
+ sig { params(other: T.untyped).returns(T::Boolean) }
+ def ==(other)
+ return false unless other.is_a?(PackageId)
+
+ [integration, name, source] == [other.integration, other.name, other.source]
+ end
+ alias_method :eql?, :==
+
+ # @return [Integer] hash code, so ids work as Hash keys
+ sig { returns(Integer) }
+ def hash
+ [self.class, integration, name, source].hash
+ end
+
+ # @return [String] "integration/name", with the source appended when the
+ # package is source-based
+ sig { returns(String) }
+ def to_s
+ source ? "#{integration}/#{name} (#{source})" : "#{integration}/#{name}"
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/package_version.rb b/lib/dev/deps/package_version.rb
new file mode 100644
index 0000000..55f82a3
--- /dev/null
+++ b/lib/dev/deps/package_version.rb
@@ -0,0 +1,111 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "artifact"
+require_relative "declarations"
+
+module Dev
+ module Deps
+ # One available version of a package, and the facts the backing service
+ # reports about it.
+ #
+ # Facts only: a PackageVersion never decides whether it satisfies anything
+ # (that is the integration's VersionScheme) and never decides whether it is
+ # the one to install (that is the Resolver). It is the unit of the universe
+ # a Repository reports; the Resolver turns the chosen one into a Dependency
+ # pin.
+ #
+ # Every optional fact is modeled as its empty form rather than nil, because
+ # absence here is genuine absence: a version with no platforms has one
+ # default target, a version with no artifacts is fetched by its own tool,
+ # and a version whose claim is Resolved([]) affirmatively requires
+ # nothing. Declared deps are a claim, not a collection — Declarations
+ # keeps "requires nothing" and "the tool owns a closure dev can't see"
+ # (ToolOwned) from collapsing into one empty form. See
+ # docs/deps-architecture.md.
+ class PackageVersion
+ extend T::Sig
+
+ # @return [String] the version string, in the ecosystem's own vocabulary
+ # (semver, gem version, tag, commit SHA, Steam buildid)
+ sig { returns(String) }
+ attr_reader :version
+
+ # @return [Array] targets this version publishes; empty means the
+ # version has a single default artifact
+ sig { returns(T::Array[String]) }
+ attr_reader :platforms
+
+ # @return [String, nil] integrity digest for bytes the *tool* fetches
+ # (e.g. bundler's CHECKSUMS). Recorded for audit and drift detection;
+ # enforcement belongs to the tool. nil means the ecosystem publishes
+ # none.
+ sig { returns(T.nilable(String)) }
+ attr_reader :digest
+
+ # @return [Hash{String => Artifact}] bytes dev can fetch, keyed by
+ # platform; empty for tool-mediated ecosystems
+ sig { returns(T::Hash[String, Artifact]) }
+ attr_reader :artifacts
+
+ # @return [Declarations] the version's declared-deps claim: Resolved
+ # (declarations dev can walk, normalized and integration-stamped by
+ # the reporting Repository) or ToolOwned (the ecosystem's tool owns
+ # the closure)
+ sig { returns(Declarations) }
+ attr_reader :declarations
+
+ # @return [Hash{String => Object}] ecosystem-specific facts the
+ # integration needs at install time (this becomes the minted pin's
+ # metadata, before the Resolver stamps host/env)
+ sig { returns(T::Hash[String, T.untyped]) }
+ attr_reader :metadata
+
+ # @param version [String] the version string
+ # @param platforms [Array] published targets
+ # @param digest [String, nil] tool-fetched integrity digest, if published
+ # @param artifacts [Hash{String => Artifact}] dev-fetchable bytes by platform
+ # @param declarations [Declarations] declared-deps claim; defaults to
+ # the affirmative Resolved([]) — ToolOwned must be stated explicitly
+ # @param metadata [Hash{String => Object}] ecosystem-specific install facts
+ sig do
+ params(
+ version: String,
+ platforms: T::Array[String],
+ digest: T.nilable(String),
+ artifacts: T::Hash[String, Artifact],
+ declarations: Declarations,
+ metadata: T::Hash[String, T.untyped],
+ ).void
+ end
+ def initialize(version:, platforms: [], digest: nil, artifacts: {},
+ declarations: Declarations::Resolved.new([]), metadata: {})
+ @version = version
+ @platforms = T.let(platforms.dup.freeze, T::Array[String])
+ @digest = digest
+ @artifacts = T.let(artifacts.dup.freeze, T::Hash[String, Artifact])
+ @declarations = declarations
+ @metadata = T.let(metadata.dup.freeze, T::Hash[String, T.untyped])
+ freeze
+ end
+
+ # @param other [Object]
+ # @return [Boolean] whether other reports the same facts
+ sig { params(other: T.untyped).returns(T::Boolean) }
+ def ==(other)
+ return false unless other.is_a?(PackageVersion)
+
+ [version, platforms, digest, artifacts, declarations, metadata] ==
+ [other.version, other.platforms, other.digest, other.artifacts,
+ other.declarations, other.metadata]
+ end
+ alias_method :eql?, :==
+
+ # @return [Integer] hash code
+ sig { returns(Integer) }
+ def hash
+ [self.class, version, platforms, digest, artifacts, declarations, metadata].hash
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/pep440_scheme.rb b/lib/dev/deps/pep440_scheme.rb
new file mode 100644
index 0000000..aca0476
--- /dev/null
+++ b/lib/dev/deps/pep440_scheme.rb
@@ -0,0 +1,172 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "version_scheme"
+
+module Dev
+ module Deps
+ # PEP 440 constraint semantics (:pip): version specifiers over Python
+ # package versions.
+ #
+ # Supported — the subset dependencies.rb pip declarations use: comparators
+ # ("==", "!=", ">=", ">", "<=", "<"), compatible release ("~=2.0.3"),
+ # wildcard equality ("==2.0.*"), bare exact versions, and comma-separated
+ # conjunction. Version grammar: [epoch!]release[{a|b|rc}N][.postN][.devN];
+ # local version labels ("+cpu") are accepted and ignored for ordering.
+ # Deliberately NOT full PEP 440: arbitrary equality (===), post-release
+ # exclusion nuances of ">V", and environment markers are out of scope —
+ # they belong to a future dev-owned pip solve (docs/deps-architecture.md).
+ class Pep440Scheme < VersionScheme
+ extend T::Sig
+
+ # The specifier is not parseable PEP 440 specifier syntax.
+ class InvalidConstraintError < VersionScheme::InvalidConstraintError; end
+ # The version string is not a PEP 440 version.
+ class InvalidVersionError < VersionScheme::InvalidVersionError; end
+
+ # The constraint key carrying the specifier (the pip DSL's version:).
+ CONSTRAINT_KEY = "version"
+
+ VERSION_PATTERN = /
+ \A
+ (?:(\d+)!)? # epoch
+ (\d+(?:\.\d+)*) # release segments
+ (?:[._-]?(a|b|rc|alpha|beta|c|pre|preview)[._-]?(\d*))? # prerelease
+ (?:[._-]?post[._-]?(\d*))? # post release
+ (?:[._-]?dev[._-]?(\d*))? # dev release
+ (?:\+[0-9a-z.]+)? # local version label (ignored)
+ \z
+ /xi
+
+ TERM_PATTERN = /\A(==|!=|>=|<=|>|<|~=)?\s*(\S+)\z/
+
+ # Prerelease phase ranks; canonical spellings and PEP 440 aliases.
+ PHASE_RANKS = T.let(
+ { "a" => 0, "alpha" => 0, "b" => 1, "beta" => 1, "c" => 2, "pre" => 2, "preview" => 2, "rc" => 2 }.freeze,
+ T::Hash[String, Integer],
+ )
+
+ # @param version [PackageVersion] a candidate; only its version string matters
+ # @param constraint [Hash] declaration constraint; "version" holds the specifier
+ # @return [Boolean]
+ # @raise [InvalidConstraintError] if the specifier does not parse
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ expression = constraint[CONSTRAINT_KEY].to_s.strip
+ return true if expression.empty?
+
+ version_string = version.version
+ expression.split(",").map(&:strip).all? { |term| term_satisfied?(version_string, term) }
+ end
+
+ # @param versions [Array] PEP 440 versions
+ # @return [Array] ascending PEP 440 order (dev < pre < release < post)
+ # @raise [InvalidVersionError] if any version does not parse
+ sig { override.params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ versions.sort_by { |version| comparison_key(version) }
+ end
+
+ private
+
+ # @param version [String] the candidate version
+ # @param term [String] one specifier term, e.g. ">=2.0" or "==2.0.*"
+ # @return [Boolean]
+ # @raise [InvalidConstraintError] if the term does not parse
+ sig { params(version: String, term: String).returns(T::Boolean) }
+ def term_satisfied?(version, term)
+ match = TERM_PATTERN.match(term)
+ raise InvalidConstraintError, "not a PEP 440 specifier: #{term.inspect}" unless match
+
+ operator = match[1] || "=="
+ bound = T.must(match[2])
+ raise InvalidConstraintError, "not a PEP 440 specifier: #{term.inspect}" if bound.start_with?("=")
+
+ return wildcard_match?(version, bound.delete_suffix(".*")) == (operator == "==") if bound.end_with?(".*")
+ return compatible_release?(version, bound) if operator == "~="
+
+ comparison = T.let(comparison_key(version) <=> comparison_key(bound), Integer)
+ case operator
+ when "==" then comparison.zero?
+ when "!=" then !comparison.zero?
+ when ">=" then comparison >= 0
+ when ">" then comparison.positive?
+ when "<=" then comparison <= 0
+ else comparison.negative?
+ end
+ end
+
+ # "==X.Y.*": the version's release segments start with the prefix's.
+ #
+ # @param version [String] the candidate version
+ # @param prefix [String] the wildcard bound without its ".*"
+ # @return [Boolean]
+ sig { params(version: String, prefix: String).returns(T::Boolean) }
+ def wildcard_match?(version, prefix)
+ prefix_release = release_segments(prefix)
+ release = release_segments(version)
+ release = release + [0] * (prefix_release.size - release.size) if release.size < prefix_release.size
+ release.first(prefix_release.size) == prefix_release
+ end
+
+ # "~=X.Y[.Z]": at least the bound, and matching the bound with its last
+ # release segment made a wildcard (~=2.4.5 means >=2.4.5, ==2.4.*).
+ #
+ # @param version [String] the candidate version
+ # @param bound [String] the compatible-release bound
+ # @return [Boolean]
+ # @raise [InvalidConstraintError] if the bound has fewer than two segments
+ sig { params(version: String, bound: String).returns(T::Boolean) }
+ def compatible_release?(version, bound)
+ segments = release_segments(bound)
+ raise InvalidConstraintError, "~= needs at least two release segments: #{bound.inspect}" if segments.size < 2
+
+ prefix = segments[0..-2].to_a.join(".")
+ comparison = T.let(comparison_key(version) <=> comparison_key(bound), Integer)
+ comparison >= 0 && wildcard_match?(version, prefix)
+ end
+
+ # @param version [String]
+ # @return [Array] the release segments, trailing zeros stripped
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { params(version: String).returns(T::Array[Integer]) }
+ def release_segments(version)
+ match = VERSION_PATTERN.match(version)
+ raise InvalidVersionError, "not a PEP 440 version: #{version.inspect}" unless match
+
+ T.must(match[2]).split(".").map(&:to_i)
+ end
+
+ # PEP 440 comparison key, mirroring pip's _cmpkey: [epoch, release
+ # (trailing zeros stripped), pre-key, post-key, dev-key]. A dev release
+ # with no prerelease sorts below any prerelease of the same release.
+ #
+ # @param version [String]
+ # @return [Array]
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { params(version: String).returns(T::Array[T.untyped]) }
+ def comparison_key(version)
+ match = VERSION_PATTERN.match(version)
+ raise InvalidVersionError, "not a PEP 440 version: #{version.inspect}" unless match
+
+ epoch = match[1].to_i
+ release = T.must(match[2]).split(".").map(&:to_i)
+ release.pop while release.size > 1 && release.last.to_i.zero?
+
+ phase, phase_number, post, dev = match[3], match[4], match[5], match[6]
+ pre_key = if phase
+ [PHASE_RANKS.fetch(phase.downcase), phase_number.to_i]
+ elsif post.nil? && dev
+ [-1] # dev-only releases sort below every prerelease
+ else
+ [3]
+ end
+ post_key = post ? [0, post.to_i] : [-1]
+ dev_key = dev ? [0, dev.to_i] : [1]
+
+ [epoch, release, pre_key, post_key, dev_key]
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/pip_repository.rb b/lib/dev/deps/pip_repository.rb
index 386a0f2..8447284 100644
--- a/lib/dev/deps/pip_repository.rb
+++ b/lib/dev/deps/pip_repository.rb
@@ -1,100 +1,95 @@
# typed: strict
# frozen_string_literal: true
-require "digest"
-require "open3"
-require "tmpdir"
+require "json"
+require "net/http"
+require "uri"
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
- # Resolves a pip package to an exact version + SHA256 by downloading just
- # that package (no transitive deps) with pip and hashing the artifact.
+ # Fact universe over PyPI's JSON API.
#
# Fidelity mirrors LuaRocksRepository: it pins the top-level declared
# packages; their transitive dependency tree is resolved by pip at install
# time (PipIntegration), exactly as luarocks resolves a rock's deps on
- # install. Resolution uses whatever python3 is on PATH — update-deps runs on
- # the author's host, before the project venv necessarily exists.
+ # install.
class PipRepository < Repository
extend T::Sig
- class DownloadError < StandardError; end
- class NoVersionError < StandardError; end
+ class ProjectNotFoundError < PackageNotFoundError; end
+ class ApiError < StandardError; end
- PYTHON = "python3"
+ PYPI_HOST = "https://pypi.org"
- # Resolve a pip package to an exact version + integrity hash.
+ # Report a project's version universe from PyPI's JSON API.
#
- # @param id [Hash] identifier with "name", "integration", "group", and an
- # optional "version" constraint (e.g. ">=2.0", "2.0.5")
- # @return [Dependency]
- # @raise [DownloadError] if pip download fails or yields no artifact
- # @raise [NoVersionError] if the version can't be read from the artifact
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- name = id["name"]
- spec = "#{name}#{normalize_constraint(id["version"])}"
- artifact = download_artifact(spec)
- version = version_from_filename(File.basename(artifact), name)
- raise NoVersionError, "could not determine version for #{name} from #{File.basename(artifact)}" if version.nil?
+ # One API call yields every published version with its files' SHA256
+ # digests — no downloads. Each version's digest is its sdist's, falling
+ # back to the first file's; yanked or file-less versions carry nil.
+ # Edges stay empty: pip resolves the transitive tree itself at install
+ # (PipIntegration), exactly as before. Constraint evaluation moves to
+ # Pep440Scheme.
+ #
+ # @param id [PackageId] name is the PyPI project name
+ # @return [Package]
+ # @raise [ProjectNotFoundError] if PyPI has no such project
+ # @raise [ApiError] if the API request fails otherwise
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ releases = project_json(id.name)["releases"] || {}
+ versions = releases.map do |version, files|
+ # pip resolves the transitive tree itself at install time.
+ PackageVersion.new(
+ version: version,
+ digest: release_digest(files),
+ declarations: Declarations::ToolOwned.new,
+ )
+ end
- Dependency.new(
- name: name,
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: version,
- hash: "SHA256=#{Digest::SHA256.file(artifact).hexdigest}",
- metadata: {},
- )
+ Package.new(id: id, versions: versions)
end
private
- # A bare version ("2.0.5") becomes an exact pin ("==2.0.5"); an already-
- # operatored constraint (">=2.0") passes through; blank means unpinned.
+ # GET and parse https://pypi.org/pypi//json.
#
- # @param constraint [String, nil]
- # @return [String]
- sig { params(constraint: T.nilable(String)).returns(String) }
- def normalize_constraint(constraint)
- value = constraint.to_s.strip
- return "" if value.empty?
+ # @param name [String] project name
+ # @return [Hash] the parsed project document
+ # @raise [ProjectNotFoundError] on 404
+ # @raise [ApiError] on any other non-2xx response
+ sig { params(name: String).returns(T::Hash[String, T.untyped]) }
+ def project_json(name)
+ response = get_project(name)
+ return JSON.parse(T.must(response.body)) if response.is_a?(Net::HTTPSuccess)
+ raise ProjectNotFoundError, "no project named #{name} on PyPI" if response.is_a?(Net::HTTPNotFound)
- value.match?(/\A[<>=~!]/) ? value : "==#{value}"
+ raise ApiError, "PyPI API returned #{response.code} for #{name}: #{response.body}"
end
- # Download exactly one artifact (the best match for this host) into a temp
- # dir. --no-deps keeps it to the single top-level package.
+ # Perform the HTTP request. Isolated so tests can stub the boundary.
#
- # @param spec [String] pip requirement specifier (e.g. "totalsegmentator>=2.0")
- # @return [String] path to the downloaded wheel/sdist
- sig { params(spec: String).returns(String) }
- def download_artifact(spec)
- dir = Dir.mktmpdir("dev_pip_")
- _out, err, status = Open3.capture3(PYTHON, "-m", "pip", "download", "--no-deps", "--dest", dir, spec)
- raise DownloadError, "pip download #{spec} failed: #{err}" unless status.success?
-
- artifact = Dir[File.join(dir, "*")].reject { |path| File.directory?(path) }.min
- raise DownloadError, "pip download #{spec} produced no artifact" if artifact.nil?
-
- artifact
+ # @param name [String] project name
+ # @return [Net::HTTPResponse]
+ sig { params(name: String).returns(Net::HTTPResponse) }
+ def get_project(name)
+ Net::HTTP.get_response(URI("#{PYPI_HOST}/pypi/#{name}/json"))
end
- # Read the version from a wheel/sdist filename. Both formats put the
- # version as the first digit-leading, dash-delimited token after the
- # (possibly multi-token) distribution name:
- # totalsegmentator-2.0.5-py3-none-any.whl -> "2.0.5"
- # TotalSegmentator-2.0.5.tar.gz -> "2.0.5"
+ # A release's integrity digest: the sdist's SHA256 when one exists
+ # (platform-independent), else the first file's, else nil.
#
- # @param filename [String]
- # @param _name [String] declared package name (kept for signature clarity)
+ # @param files [Array] the release's file objects
# @return [String, nil]
- sig { params(filename: String, _name: String).returns(T.nilable(String)) }
- def version_from_filename(filename, _name)
- stem = filename.sub(/\.(?:whl|tar\.gz|tgz|zip)\z/, "")
- stem.split("-").find { |token| token.match?(/\A\d/) }
+ sig { params(files: T::Array[T::Hash[String, T.untyped]]).returns(T.nilable(String)) }
+ def release_digest(files)
+ file = files.find { |f| f["packagetype"] == "sdist" } || files.first
+ sha256 = file&.dig("digests", "sha256")
+ sha256 ? "SHA256=#{sha256}" : nil
end
end
end
diff --git a/lib/dev/deps/registry.rb b/lib/dev/deps/registry.rb
index 7723ae5..a1a5308 100644
--- a/lib/dev/deps/registry.rb
+++ b/lib/dev/deps/registry.rb
@@ -15,12 +15,25 @@
require_relative "gh_integration"
require_relative "steam_repository"
require_relative "steam_integration"
+require_relative "bundler_locker"
require_relative "bundler_repository"
require_relative "bundler_integration"
require_relative "xcode_repository"
require_relative "xcode_integration"
require_relative "pip_repository"
require_relative "pip_integration"
+require_relative "url_repository"
+require_relative "brew_cask_repository"
+require_relative "brew_scheme"
+require_relative "exact_scheme"
+require_relative "gem_scheme"
+require_relative "git_scheme"
+require_relative "locker"
+require_relative "pep440_scheme"
+require_relative "rock_scheme"
+require_relative "semver_scheme"
+require_relative "steam_scheme"
+require_relative "version_scheme"
module Dev
module Deps
@@ -49,15 +62,32 @@ module Registry
HOST_SCOPES = T.let([HOST, BOTH].freeze, T::Array[Symbol])
# @param symbol [Symbol] the DSL/declaration integration symbol (e.g. :brew)
- # @param repository [Class] Repository subclass that resolves this type
+ # @param repository [Class] Repository subclass that reports this type's universes
# @param repository_needs [Array] extra kwargs the repository takes
+ # @param scheme [Class, nil] VersionScheme subclass carrying this type's
+ # constraint semantics — every type must answer "how do constraints
+ # work", and nil is an answer: a purely addressable type (xcode) whose
+ # declarations carry a revision, never a constraint, has no scheme to
+ # run
+ # @param scheme_args [Hash{Symbol => Object}] constructor kwargs for the
+ # scheme (e.g. ExactScheme's key:)
+ # @param locker [Class, nil] Locker subclass for types whose ecosystem tool
+ # owns the whole-set solve (bundler), or nil
+ # @param locker_needs [Array] extra kwargs the locker takes
# @param integration [Class, nil] Integration subclass that installs this
# type, or nil for resolve-only / container-only types
# @param integration_needs [Array] extra kwargs the integration takes
# (beyond the always-passed repository: and cache:)
+ # @param install_alias [Symbol, nil] another entry's symbol whose
+ # integration INSTANCE installs this type's deps too (e.g. :url deps
+ # install through :cmake's pipeline). Sharing the instance matters:
+ # integrations that generate batch artifacts (deps.cmake) must see
+ # both types' deps in one install_all call, and the Installer groups
+ # dispatch by instance. Mutually exclusive with integration.
# @param scope [Symbol] one of HOST / CONTAINER / BOTH
Entry = Data.define(
- :symbol, :repository, :repository_needs, :integration, :integration_needs, :scope,
+ :symbol, :repository, :repository_needs, :scheme, :scheme_args, :locker, :locker_needs,
+ :integration, :integration_needs, :install_alias, :scope,
) do
extend T::Sig
@@ -74,12 +104,27 @@ def repository = to_h.fetch(:repository)
sig { returns(T::Array[Symbol]) }
def repository_needs = to_h.fetch(:repository_needs)
+ sig { returns(T.nilable(T.class_of(VersionScheme))) }
+ def scheme = to_h.fetch(:scheme)
+
+ sig { returns(T::Hash[Symbol, T.untyped]) }
+ def scheme_args = to_h.fetch(:scheme_args)
+
+ sig { returns(T.nilable(T.class_of(Locker))) }
+ def locker = to_h.fetch(:locker)
+
+ sig { returns(T::Array[Symbol]) }
+ def locker_needs = to_h.fetch(:locker_needs)
+
sig { returns(T.nilable(T.class_of(Integration))) }
def integration = to_h.fetch(:integration)
sig { returns(T::Array[Symbol]) }
def integration_needs = to_h.fetch(:integration_needs)
+ sig { returns(T.nilable(Symbol)) }
+ def install_alias = to_h.fetch(:install_alias)
+
sig { returns(Symbol) }
def scope = to_h.fetch(:scope)
@@ -87,14 +132,20 @@ def scope = to_h.fetch(:scope)
params(
symbol: Symbol,
repository: T.class_of(Repository),
+ scheme: T.nilable(T.class_of(VersionScheme)),
integration: T.nilable(T.class_of(Integration)),
scope: Symbol,
repository_needs: T::Array[Symbol],
+ scheme_args: T::Hash[Symbol, T.untyped],
+ locker: T.nilable(T.class_of(Locker)),
+ locker_needs: T::Array[Symbol],
integration_needs: T::Array[Symbol],
+ install_alias: T.nilable(Symbol),
).void
end
- def initialize(symbol:, repository:, integration:, scope:,
- repository_needs: [], integration_needs: [])
+ def initialize(symbol:, repository:, scheme:, integration:, scope:,
+ repository_needs: [], scheme_args: {}, locker: nil, locker_needs: [],
+ integration_needs: [], install_alias: nil)
super
end
@@ -110,7 +161,10 @@ def host?
Entry.new(
symbol: :bundler,
repository: BundlerRepository,
- repository_needs: %i[project_root ruby_version_requirement],
+ repository_needs: %i[project_root],
+ scheme: GemScheme,
+ locker: BundlerLocker,
+ locker_needs: %i[project_root ruby_version_requirement],
integration: BundlerIntegration,
integration_needs: %i[project_root],
scope: HOST,
@@ -118,20 +172,47 @@ def host?
Entry.new(
symbol: :brew,
repository: BrewRepository,
+ scheme: BrewScheme,
integration: BrewIntegration,
integration_needs: %i[taps project_dir],
scope: BOTH,
),
+ # Casks are declared with the brew DSL verb (cask: true) but are a
+ # separate universe: Homebrew publishes no versions or bottle
+ # digests for casks, so BrewRepository's formula facts don't apply.
+ Entry.new(
+ symbol: :cask,
+ repository: BrewCaskRepository,
+ scheme: BrewScheme,
+ integration: BrewIntegration,
+ scope: BOTH,
+ ),
Entry.new(
symbol: :cmake,
repository: GitRepository,
+ scheme: GitScheme,
integration: CmakeIntegration,
integration_needs: %i[project_root],
scope: HOST,
),
+ # url deps are declared with the cmake DSL verb (url:) but are a
+ # separate universe: the URL is the entire address and the artifact
+ # behind it is the one version, downloaded and TOFU-hashed by find.
+ # Scheme-less — a url declaration carries no constraint (the tag:
+ # label is naming, not selection) — and installed through cmake's
+ # integration instance so a mixed project generates one deps.cmake.
+ Entry.new(
+ symbol: :url,
+ repository: UrlRepository,
+ scheme: nil,
+ integration: nil,
+ install_alias: :cmake,
+ scope: HOST,
+ ),
Entry.new(
symbol: :luarocks,
repository: LuaRocksRepository,
+ scheme: RockScheme,
integration: LuaRocksIntegration,
integration_needs: %i[project_root],
scope: HOST,
@@ -139,12 +220,15 @@ def host?
Entry.new(
symbol: :ficsit,
repository: FicsitRepository,
+ scheme: SemverScheme,
integration: FicsitIntegration,
scope: HOST,
),
Entry.new(
symbol: :gh,
repository: GhRepository,
+ scheme: ExactScheme,
+ scheme_args: { key: "tag" },
integration: GhIntegration,
integration_needs: %i[project_root],
scope: HOST,
@@ -152,12 +236,16 @@ def host?
Entry.new(
symbol: :steam,
repository: SteamRepository,
+ scheme: SteamScheme,
integration: SteamIntegration,
scope: HOST,
),
Entry.new(
symbol: :xcode,
repository: XcodeRepository,
+ # Purely addressable: the DSL mints the exact version as the
+ # declaration's revision, so no constraint ever needs evaluating.
+ scheme: nil,
integration: XcodeIntegration,
integration_needs: %i[project_root],
scope: HOST,
@@ -165,6 +253,7 @@ def host?
Entry.new(
symbol: :pip,
repository: PipRepository,
+ scheme: Pep440Scheme,
integration: PipIntegration,
integration_needs: %i[project_root python_version],
scope: HOST,
@@ -179,17 +268,56 @@ class << self
# Build the integration-type -> Repository hash the Resolver consumes.
#
# @param project_root [Pathname] project root (threaded to repositories that need it)
- # @param ruby_version_requirement [String, nil] for the bundler-generated Gemfile
# @return [Hash{Symbol => Repository}]
+ sig { params(project_root: Pathname).returns(T::Hash[Symbol, Repository]) }
+ def repositories(project_root:)
+ context = { project_root: }
+ INTEGRATIONS.to_h { |entry| [entry.symbol, build_repository(entry, context)] }
+ end
+
+ # Build the integration-type -> VersionScheme hash the Resolver consumes.
+ # Schemes are stateless domain services; their only context is the
+ # entry's own scheme_args (e.g. which constraint key ExactScheme reads).
+ # Scheme-less (purely addressable) types are absent from the hash, so
+ # a constraint-shaped ask against one fails the Resolver's
+ # no-scheme-registered check loudly.
+ #
+ # @return [Hash{Symbol => VersionScheme}]
+ sig { returns(T::Hash[Symbol, VersionScheme]) }
+ def schemes
+ INTEGRATIONS.each_with_object({}) do |entry, schemes|
+ scheme = entry.scheme
+ next unless scheme
+
+ # T.unsafe: the keyword set is entry-declared (scheme_args); the
+ # scheme constructors' own sigs validate at runtime.
+ schemes[entry.symbol] = T.unsafe(scheme).new(**entry.scheme_args)
+ end
+ end
+
+ # Build the integration-type -> Locker hash for types whose ecosystem
+ # tool owns the whole-set solve. update-deps runs these before the
+ # Resolver so each tool lockfile is materialized when find reads it.
+ #
+ # @param project_root [Pathname] project root (threaded to lockers that need it)
+ # @param ruby_version_requirement [String, nil] for the bundler-generated Gemfile
+ # @return [Hash{Symbol => Locker}]
sig do
params(
project_root: Pathname,
ruby_version_requirement: T.nilable(String),
- ).returns(T::Hash[Symbol, Repository])
+ ).returns(T::Hash[Symbol, Locker])
end
- def repositories(project_root:, ruby_version_requirement: nil)
+ def lockers(project_root:, ruby_version_requirement: nil)
context = { project_root:, ruby_version_requirement: }
- INTEGRATIONS.to_h { |entry| [entry.symbol, build_repository(entry, context)] }
+ INTEGRATIONS.each_with_object({}) do |entry, lockers|
+ locker = entry.locker
+ next unless locker
+
+ # T.unsafe: the keyword set is runtime-selected (locker_needs);
+ # the locker constructors' own sigs validate at runtime.
+ lockers[entry.symbol] = T.unsafe(locker).new(**T.unsafe(context).slice(*entry.locker_needs))
+ end
end
# Build the integration-type -> Integration hash for host installs.
@@ -197,7 +325,8 @@ def repositories(project_root:, ruby_version_requirement: nil)
# @param project_root [Pathname] project root (threaded to integrations that need it)
# @param cache [Cache] shared download cache (passed to every integration)
# @param taps [Array] Homebrew taps for the brew integration
- # @param ruby_version_requirement [String, nil] for the bundler repository
+ # @param ruby_version_requirement [String, nil] accepted for caller
+ # convenience; install-time integrations don't need it today
# @param python_version [String, nil] for the pip integration's venv
# @return [Hash{Symbol => Integration}]
sig do
@@ -217,18 +346,27 @@ def host_integrations(project_root:, cache:, taps: [], ruby_version_requirement:
python_version:,
taps:,
}
- INTEGRATIONS.each_with_object({}) do |entry, integrations|
+ integrations = INTEGRATIONS.each_with_object({}) do |entry, hash|
next unless entry.host?
# T.unsafe: each entry's constructor takes a runtime-selected
# keyword set (integration_needs), which Sorbet cannot check
# statically; the constructors' own sigs validate at runtime.
- integrations[entry.symbol] = T.unsafe(T.must(entry.integration)).new(
+ hash[entry.symbol] = T.unsafe(T.must(entry.integration)).new(
repository: build_repository(entry, context),
cache:,
**T.unsafe(context).slice(*entry.integration_needs),
)
end
+
+ # Aliased types share their target's INSTANCE (not just its class):
+ # the Installer groups dispatch by instance, so both types' deps
+ # arrive in one install_all call and batch artifacts stay whole.
+ INTEGRATIONS.each do |entry|
+ alias_target = entry.install_alias
+ integrations[entry.symbol] = integrations.fetch(alias_target) if alias_target
+ end
+ integrations
end
# @param entry [Entry]
@@ -238,7 +376,7 @@ def host_integrations(project_root:, cache:, taps: [], ruby_version_requirement:
def build_repository(entry, context)
# T.unsafe: the keyword set is runtime-selected (repository_needs);
# the repository constructors' own sigs validate at runtime.
- T.unsafe(entry.repository).new(**T.unsafe(context).slice(*entry.repository_needs))
+ entry.repository.new(**T.unsafe(context).slice(*entry.repository_needs))
end
end
end
diff --git a/lib/dev/deps/repository.rb b/lib/dev/deps/repository.rb
index 552e633..ddebd96 100644
--- a/lib/dev/deps/repository.rb
+++ b/lib/dev/deps/repository.rb
@@ -1,36 +1,75 @@
# typed: strict
# frozen_string_literal: true
-require_relative "dependency"
-require_relative "dependency_declaration"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
module Dev
module Deps
- # Source adapter that fetches a dependency by its unique identifier.
+ # Source adapter over one integration's package universe: given an
+ # identity, report the versions the backing service offers and the facts
+ # attached to them.
#
- # Returns a Dependency domain object with all fields populated
- # (including transitive dependencies when the source supports it).
+ # Facts only — a Fowler-style repository. A Repository never sees a
+ # constraint, never selects a version, and has no lifecycle: it is fully
+ # functional from construction, a pure function of (id, datastore).
+ # Constraint evaluation belongs to the integration's VersionScheme,
+ # selection to the Resolver, and whole-set solves (bundle lock) to the
+ # integration's Locker. See docs/deps-architecture.md.
class Repository
extend T::Sig
- # Fetch a dependency by its unique identifier.
+ # The universe has no package under the requested identity.
+ class PackageNotFoundError < StandardError; end
+
+ # A revision was addressed against an integration whose universe has no
+ # continuous space (nothing exists outside the published versions).
+ class NoAddressableSpaceError < StandardError; end
+
+ # Report the package under this identity: every discrete, published
+ # version the universe offers, with its facts.
+ #
+ # Identity in, universe out — nothing else crosses this seam. The
+ # declaration's constraint hash never reaches a repository (evaluation
+ # is VersionScheme's job, choosing is the Resolver's), source
+ # coordinates ride PackageId#source, and install instructions
+ # (ScopedDeclaration#materialization) are merged into the pin by the
+ # Resolver. This is the I/O operation: for degenerate universes (url,
+ # cask) the query is the observation itself.
#
- # @param id [Hash] unique resource identifier within this repository
- # @return [Dependency]
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- raise NotImplementedError, "#{self.class}#fetch must be implemented"
+ # @param id [PackageId] the package's identity
+ # @return [Package] the available versions and their facts
+ # @raise [PackageNotFoundError] if the universe has no such package
+ sig { params(id: PackageId).returns(Package) }
+ def find(id)
+ raise NotImplementedError, "#{self.class}#find must be implemented"
end
- # Batch hook called once per integration type before any fetch, with all
- # declarations of this type. Repositories whose backing tool resolves the
- # whole set together (e.g. bundler running `bundle lock` once) override
- # this; per-dependency repositories inherit the no-op.
+ # Lift an addressable revision into a version — the continuous-space
+ # counterpart of find.
#
- # @param declarations [Array] this type's declarations
- # @return [void]
- sig { params(declarations: T::Array[DependencyDeclaration]).void }
- def prepare(declarations); end
+ # Where find queries the discrete published universe (I/O against the
+ # backing service), at lifts an address the author already chose: pure,
+ # no I/O, ever. The address is trusted at resolve time and
+ # dereferenced/verified at install — the same pin-as-assertion
+ # semantics a Steam buildid has. No scheme runs over the result and the
+ # Resolver mints the pin from it directly: a revision forgoes
+ # resolution by definition.
+ #
+ # Overriding this method is what declares that an integration has a
+ # continuous space at all (git commit SHAs, exact Xcode versions); the
+ # base refuses, and the Resolver lets that refusal propagate.
+ #
+ # @param id [PackageId] the package's identity
+ # @param revision [String] address in the ecosystem's canonical spelling
+ # @return [PackageVersion] the lifted version and its facts
+ # @raise [NoAddressableSpaceError] unless the integration overrides
+ sig { params(id: PackageId, revision: String).returns(PackageVersion) }
+ def at(id, revision)
+ raise NoAddressableSpaceError,
+ "#{self.class} has no addressable space: #{id.name} cannot be pinned at #{revision.inspect}"
+ end
end
end
end
diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb
index 0593a30..81b1838 100644
--- a/lib/dev/deps/resolver.rb
+++ b/lib/dev/deps/resolver.rb
@@ -1,83 +1,101 @@
# typed: strict
# frozen_string_literal: true
-require_relative "repository"
+require_relative "declaration"
+require_relative "declarations"
require_relative "dependency"
-require_relative "dependency_declaration"
+require_relative "package"
+require_relative "scoped_declaration"
+require_relative "package_id"
+require_relative "package_version"
+require_relative "repository"
+require_relative "version_scheme"
module Dev
module Deps
- # Resolves all dependency declarations into a flat list of Dependencies.
+ # Resolves all dependency declarations into a flat list of pinned
+ # Dependencies.
#
- # Iterates declared deps, queries each Repository to fetch the dependency,
- # then walks transitive deps via Dependency#dependencies.
- # Registries that support dependency metadata (LuaRocks, CurseForge, Brew)
- # get full transitive resolution. Source-based repos (Git, URL) return [].
+ # The choice layer of the deps pipeline: repositories report facts (which
+ # versions exist — Repository#find), schemes evaluate predicates (does a
+ # version satisfy a constraint — VersionScheme), and this class chooses:
+ # for each declaration it filters the package's universe through the
+ # integration's scheme, takes the highest satisfying version, mints the
+ # pin, and walks that version's edges to resolve transitives. Whole-set
+ # solves (bundle lock) happen before resolution, in the integration's
+ # Locker — by the time find runs, a tool-locked universe is already
+ # materialized. See docs/deps-architecture.md.
class Resolver
extend T::Sig
+ # A declaration names an integration the registry doesn't wire.
class UnknownIntegrationError < StandardError; end
+ # The same dependency is declared twice with disagreeing constraints.
+ class ConflictingDeclarationError < StandardError; end
+
+ # No version in the package's universe satisfies the declaration —
+ # constraint mismatch, missing requested platform, or empty universe.
+ class NoSatisfyingVersionError < StandardError; end
+
# @param repositories [Hash{Symbol => Repository}] integration type → repository
- sig { params(repositories: T::Hash[Symbol, Repository]).void }
- def initialize(repositories:)
+ # @param schemes [Hash{Symbol => VersionScheme}] integration type → constraint semantics
+ sig do
+ params(
+ repositories: T::Hash[Symbol, Repository],
+ schemes: T::Hash[Symbol, VersionScheme],
+ ).void
+ end
+ def initialize(repositories:, schemes:)
@repositories = repositories
+ @schemes = schemes
end
# Resolve all declarations into a flat Dependency list.
#
- # Iterates declared deps, queries each Repository to fetch the pinned
- # Dependency, then walks transitive deps via Dependency#dependencies.
+ # The resolved set is keyed by PackageId, so the same name under two
+ # integrations is two packages — each resolves against its own
+ # integration's universe. Transitive declarations resolve under the
+ # integration their reporting Repository stamped on them (today always
+ # its own).
#
- # @param declarations [Array] declared dependencies to resolve
+ # @param declarations [Array] declared dependencies to resolve
# @return [Array]
- # @raise [UnknownIntegrationError] if no repository is registered for a declaration's integration type
- sig { params(declarations: T::Array[DependencyDeclaration]).returns(T::Array[Dependency]) }
+ # @raise [ConflictingDeclarationError] if one package is declared with disagreeing constraints
+ # @raise [UnknownIntegrationError] if a declaration's integration has no repository or scheme
+ # @raise [NoSatisfyingVersionError] if a declaration cannot be satisfied
+ sig { params(declarations: T::Array[ScopedDeclaration]).returns(T::Array[Dependency]) }
def resolve(declarations)
- prepare_repositories(declarations)
+ reject_conflicts(declarations)
- platforms_by_name = platforms_by_name(declarations)
- resolved = {}
+ platforms = declared_platforms(declarations)
+ resolved = T.let({}, T::Hash[PackageId, Dependency])
queue = declarations.dup
while (decl = queue.shift)
- next if resolved.key?(decl.name)
-
- repo = @repositories[decl.integration]
- raise UnknownIntegrationError, "no repository registered for #{decl.integration.inspect}" unless repo
-
- id = decl.constraint.merge(
- "name" => decl.name,
- "integration" => decl.integration.to_s,
- "group" => decl.group.to_s,
- )
-
- # A dep declared in several groups is resolved once, for the union of
- # those groups' platforms. nil entries mean "the integration's default
- # platform" and are passed through so a multi-arch repository can expand
- # them. We only attach "platforms" when at least one group pinned an
- # explicit platform, so single-platform deps keep their legacy fetch id.
- platforms = platforms_by_name[decl.name] || []
- id["platforms"] = platforms if platforms.any? { |p| !p.nil? }
-
- dependency = repo.fetch(id)
- dependency = dependency.with(post_install: decl.post_install) if decl.post_install
- dependency = attach_install_scoping(dependency, decl)
- resolved[decl.name] = dependency
-
- # Transitive deps inherit the declaring dep's group, host, and env: a
- # dep only needed on one host/env can't need its transitive closure
- # anywhere else.
- dependency.dependencies.each do |tdep|
- next if resolved.key?(tdep[:name])
- queue << DependencyDeclaration.new(
- name: tdep[:name],
- integration: decl.integration,
- constraint: normalize_constraint(tdep[:constraint]),
- group: decl.group,
- host: decl.host,
- env: decl.env,
- )
+ id = package_id(decl)
+ next if resolved.key?(id)
+
+ declared = platforms[[decl.integration, decl.name]] || []
+ chosen = decl.revision ? address(decl) : choose(decl, declared)
+ resolved[id] = mint(chosen, decl, declared)
+
+ # Transitive deps inherit the declaring dep's Scope wholesale: a dep
+ # only needed in one group/host/env can't need its transitive
+ # closure anywhere else. Each Declaration arrives finished from the
+ # Repository (integration stamped, constraint normalized); only the
+ # context is stamped here, because context is a property of the
+ # path, not of the fact.
+ case (claim = chosen.declarations)
+ when Declarations::Resolved
+ claim.declarations.each do |edge|
+ edge_decl = ScopedDeclaration.new(declaration: edge, scope: decl.scope)
+ queue << edge_decl unless resolved.key?(package_id(edge_decl))
+ end
+ when Declarations::ToolOwned
+ # The ecosystem's tool owns the closure; there is nothing to walk.
+ else
+ T.absurd(claim)
end
end
@@ -86,75 +104,295 @@ def resolve(declarations)
private
+ # Lift an addressed ask. The author forewent resolution: no universe is
+ # queried, no scheme runs — the repository lifts the revision into a
+ # version (Repository#at is pure) and the pin is minted from it
+ # directly. A repository with no continuous space refuses, and that
+ # refusal propagates: an address into a space that doesn't exist is the
+ # declaration being wrong.
+ #
+ # @param decl [ScopedDeclaration] a revision-pinned declaration
+ # @return [PackageVersion] the lifted version
+ # @raise [UnknownIntegrationError] if the integration has no repository
+ # @raise [Repository::NoAddressableSpaceError] if the integration's
+ # universe has no continuous space
+ sig { params(decl: ScopedDeclaration).returns(PackageVersion) }
+ def address(decl)
+ repository = @repositories[decl.integration]
+ raise UnknownIntegrationError, "no repository registered for #{decl.integration.inspect}" unless repository
+
+ repository.at(package_id(decl), T.must(decl.revision))
+ end
+
+ # Ask the declaration's repository for the package universe and pick the
+ # highest version that satisfies the constraint (per the integration's
+ # scheme) and publishes every explicitly requested platform.
+ #
+ # @param decl [ScopedDeclaration] the declaration to satisfy
+ # @param platforms [Array] union of the declaring groups'
+ # platforms; nil entries mean "the integration's default"
+ # @return [PackageVersion] the chosen version
+ # @raise [UnknownIntegrationError] if repository or scheme is unwired
+ # @raise [NoSatisfyingVersionError] if nothing in the universe qualifies
+ sig do
+ params(
+ decl: ScopedDeclaration,
+ platforms: T::Array[T.nilable(String)],
+ ).returns(PackageVersion)
+ end
+ def choose(decl, platforms)
+ repository = @repositories[decl.integration]
+ raise UnknownIntegrationError, "no repository registered for #{decl.integration.inspect}" unless repository
+
+ # Scheme-less integrations (url) have no constraint grammar at all:
+ # an empty ask takes the universe as reported, a non-empty one is the
+ # declaration being wrong.
+ scheme = @schemes[decl.integration]
+ if scheme.nil? && !decl.constraint.empty?
+ raise UnknownIntegrationError,
+ "#{decl.integration.inspect} has no version scheme — it cannot evaluate " \
+ "constraint #{decl.constraint.inspect}"
+ end
+
+ package = repository.find(package_id(decl))
+ explicit = platforms.compact
+ candidates = package.versions.select do |version|
+ (scheme.nil? || satisfies?(scheme, version, decl.constraint)) &&
+ publishes_platforms?(version, explicit)
+ end
+ raise NoSatisfyingVersionError, no_satisfying_message(decl, package, explicit) if candidates.empty?
+
+ # A universe can list one version string several times (e.g. per-arch
+ # rows); selection is over distinct version strings, first fact wins.
+ by_version = T.let({}, T::Hash[String, PackageVersion])
+ candidates.each { |version| by_version[version.version] ||= version }
+ by_version.fetch(T.must(sorted_versions(scheme, by_version.keys).last))
+ end
+
+ # Order distinct version strings ascending: the scheme's total order,
+ # or the universe's reported order when no scheme exists.
+ #
+ # @param scheme [VersionScheme, nil] the integration's semantics, if any
+ # @param versions [Array] distinct version strings
+ # @return [Array]
+ sig { params(scheme: T.nilable(VersionScheme), versions: T::Array[String]).returns(T::Array[String]) }
+ def sorted_versions(scheme, versions)
+ scheme ? scheme.sort(versions) : versions
+ end
+
+ # Constraint satisfaction, treating versions the scheme cannot parse as
+ # non-satisfying: a universe can contain versions that predate or ignore
+ # the ecosystem's conventions, and they simply aren't candidates. A
+ # malformed constraint, by contrast, propagates — that's the user's
+ # declaration being wrong.
+ #
+ # @param scheme [VersionScheme] the integration's constraint semantics
+ # @param version [PackageVersion] the candidate
+ # @param constraint [Hash] the declaration constraint
+ # @return [Boolean]
+ sig do
+ params(
+ scheme: VersionScheme,
+ version: PackageVersion,
+ constraint: T::Hash[String, T.untyped],
+ ).returns(T::Boolean)
+ end
+ def satisfies?(scheme, version, constraint)
+ scheme.satisfies?(version, constraint)
+ rescue VersionScheme::InvalidVersionError
+ false
+ end
+
+ # Does the version publish every explicitly requested platform? Versions
+ # that declare no platforms at all are platform-agnostic and always
+ # qualify.
+ #
+ # @param version [PackageVersion] the candidate
+ # @param explicit [Array] explicitly requested platform names
+ # @return [Boolean]
+ sig { params(version: PackageVersion, explicit: T::Array[String]).returns(T::Boolean) }
+ def publishes_platforms?(version, explicit)
+ explicit.empty? || version.platforms.empty? || (explicit - version.platforms).empty?
+ end
+
+ # Mint the pin: the chosen version's facts become the Dependency, the
+ # declaration contributes name/integration/group, its materialization
+ # (install instructions the repository never saw), the post-install
+ # hook, and the install-scoping axes. The version digest becomes the
+ # pin's integrity hash uniformly; an empty version string (ecosystems
+ # that expose no version — brew casks, url artifacts) becomes a nil pin
+ # version, unless the author named a display label (a url tag:), which
+ # is promoted out of the materialization into the version slot.
+ # Versions carrying per-platform artifacts get them projected into
+ # install facts against the declared platforms.
+ #
+ # @param chosen [PackageVersion] the version the resolver picked
+ # @param decl [ScopedDeclaration] the declaration it satisfies
+ # @param platforms [Array] union of the declaring groups' platforms
+ # @return [Dependency]
+ sig do
+ params(
+ chosen: PackageVersion,
+ decl: ScopedDeclaration,
+ platforms: T::Array[T.nilable(String)],
+ ).returns(Dependency)
+ end
+ def mint(chosen, decl, platforms)
+ metadata = chosen.metadata.merge(decl.materialization)
+ pin_version = chosen.version.empty? ? metadata.delete("version_label") : chosen.version
+ hash = chosen.digest
+ if chosen.artifacts.any? && (platforms.any? { |p| !p.nil? } || metadata.key?("target"))
+ hash = project_artifacts(metadata, chosen, platforms)
+ end
+
+ dependency = Dependency.new(
+ name: decl.name,
+ integration: decl.integration,
+ group: decl.scope.group,
+ version: pin_version,
+ hash: hash,
+ metadata: metadata,
+ )
+ dependency = dependency.with(post_install: decl.post_install) if decl.post_install
+ attach_install_scoping(dependency, decl)
+ end
+
+ # Project the chosen version's per-platform artifacts (universe facts)
+ # into the pin's install facts (what the installer fetches). With
+ # explicitly declared platforms, a metadata["platforms"] block covering
+ # the targets the version actually publishes; otherwise the
+ # single-target shape — the materialization's "target" resolved to its
+ # artifact digest as the pin's hash. Lives here, not in a Repository:
+ # which targets a pin describes is a property of the declarations, and
+ # a repository never sees those.
+ #
+ # @param metadata [Hash] the pin metadata under construction (mutated)
+ # @param chosen [PackageVersion] the chosen version
+ # @param platforms [Array] declared platforms; nil entries
+ # fall back to the materialization's "target"
+ # @return [String, nil] the pin's integrity hash
+ sig do
+ params(
+ metadata: T::Hash[String, T.untyped],
+ chosen: PackageVersion,
+ platforms: T::Array[T.nilable(String)],
+ ).returns(T.nilable(String))
+ end
+ def project_artifacts(metadata, chosen, platforms)
+ default_target = metadata["target"]
+
+ if platforms.any? { |p| !p.nil? }
+ names = platforms.map { |p| p.nil? ? default_target : p }.compact.uniq
+ metadata["platforms"] = names.each_with_object({}) do |name, acc|
+ artifact = chosen.artifacts[name]
+ acc[name] = { "hash" => artifact.digest, "link" => artifact.uri } if artifact
+ end
+ # The block replaces the single-target shape; no one target is THE pin.
+ metadata.delete("target")
+ nil
+ else
+ artifact = chosen.artifacts[default_target] || chosen.artifacts.values.first
+ artifact&.digest
+ end
+ end
+
+ # The package's identity, from the declaration: the atom's source field
+ # is the source coordinate (which universe to ask), riding on the
+ # PackageId.
+ #
+ # @param decl [ScopedDeclaration]
+ # @return [PackageId]
+ sig { params(decl: ScopedDeclaration).returns(PackageId) }
+ def package_id(decl)
+ PackageId.new(
+ integration: decl.integration,
+ name: decl.name,
+ source: decl.source,
+ )
+ end
+
+ # Reject sets where one package is declared with disagreeing asks —
+ # constraint, source, revision, or materialization. A dep declared in several
+ # groups resolves once, so agreement is the precondition for that single
+ # resolution being right for everyone; disagreeing install dirs would
+ # otherwise let one row's materialization win silently. Grouping is per
+ # (integration, name): the same name under two integrations is two
+ # packages, free to carry different asks. (Platform, group, host, and
+ # env may differ — they are axes, not asks.)
+ #
+ # @param declarations [Array]
+ # @return [void]
+ # @raise [ConflictingDeclarationError]
+ sig { params(declarations: T::Array[ScopedDeclaration]).void }
+ def reject_conflicts(declarations)
+ declarations.group_by { |d| [d.integration, d.name] }.each do |(integration, name), decls|
+ asks = decls.map do |d|
+ { constraint: d.constraint, source: d.source, revision: d.revision, materialization: d.materialization }
+ end.uniq
+ next if asks.size <= 1
+
+ raise ConflictingDeclarationError,
+ "#{integration}/#{name} is declared with disagreeing asks: " \
+ "#{asks.map(&:inspect).join(" vs ")}"
+ end
+ end
+
# Stamp the declaration's install-scoping axes (host, env) onto the
# resolved dependency's metadata so they serialize into the lockfile and
# the installer can filter on them. Done here, uniformly, so no
# repository has to know these axes exist — a repository resolves what a
# dep IS; where it installs is resolver/installer plumbing.
#
- # @param dependency [Dependency] freshly fetched
- # @param decl [DependencyDeclaration] the declaration it came from
+ # @param dependency [Dependency] freshly minted
+ # @param decl [ScopedDeclaration] the declaration it came from
# @return [Dependency]
- sig { params(dependency: Dependency, decl: DependencyDeclaration).returns(Dependency) }
+ sig { params(dependency: Dependency, decl: ScopedDeclaration).returns(Dependency) }
def attach_install_scoping(dependency, decl)
- extra = {}
- extra["host"] = decl.host.to_s if decl.host
- extra["env"] = decl.env if decl.env
+ extra = decl.scope.to_metadata
return dependency if extra.empty?
dependency.with(metadata: dependency.metadata.merge(extra))
end
- # Give each repository a chance to batch-resolve all declarations of its
- # type before per-dependency fetches begin. Most repositories inherit the
- # no-op; bundler uses it to generate the Gemfile and run `bundle lock` once.
+ # Collect, per package (integration + name), the platforms of every group
+ # that declares it (preserving nils, which mean "integration default").
+ # This is how the same dep declared in two groups gets resolved for the
+ # union of their platforms without per-dep platform lists — scoped per
+ # integration so one ecosystem's platform pins never leak into another's.
#
- # @param declarations [Array] all declarations
- # @return [void]
- sig { params(declarations: T::Array[DependencyDeclaration]).void }
- def prepare_repositories(declarations)
- declarations.group_by(&:integration).each do |type, typed_declarations|
- @repositories[type]&.prepare(typed_declarations)
- end
- end
-
- # Collect, per dependency name, the platforms of every group that declares
- # it (preserving nils, which mean "integration default"). This is how the
- # same dep declared in two groups gets resolved for the union of their
- # platforms without per-dep platform lists.
- #
- # @param declarations [Array]
- # @return [Hash{String => Array}] name → de-duped platform list
+ # @param declarations [Array]
+ # @return [Hash{Array(Symbol, String) => Array}]
+ # (integration, name) → de-duped platform list
sig do
params(
- declarations: T::Array[DependencyDeclaration],
- ).returns(T::Hash[String, T::Array[T.nilable(String)]])
+ declarations: T::Array[ScopedDeclaration],
+ ).returns(T::Hash[[Symbol, String], T::Array[T.nilable(String)]])
end
- def platforms_by_name(declarations)
+ def declared_platforms(declarations)
result = Hash.new { |h, k| h[k] = [] }
- declarations.each { |decl| result[decl.name] << decl.platform }
+ declarations.each { |decl| result[[decl.integration, decl.name]] << decl.platform }
result.transform_values(&:uniq)
end
- # Normalize a transitive dep constraint to a Hash.
- #
- # Transitive deps from Dependency#dependencies may express constraints as
- # a string (e.g. ">= 1.0") or a Hash. Strings are wrapped so they are not
- # silently dropped when merged into the fetch ID.
+ # A NoSatisfyingVersionError message that says why: what was asked,
+ # what the universe held.
#
- # @param constraint [Hash, String, nil] raw constraint from Dependency#dependencies
- # @return [Hash]
+ # @param decl [ScopedDeclaration]
+ # @param package [Package]
+ # @param explicit [Array] explicitly requested platforms
+ # @return [String]
sig do
params(
- constraint: T.nilable(T.any(T::Hash[String, T.untyped], String)),
- ).returns(T::Hash[String, T.untyped])
- end
- def normalize_constraint(constraint)
- case constraint
- when Hash then constraint
- when String then { "version" => constraint }
- else {}
- end
+ decl: ScopedDeclaration,
+ package: Package,
+ explicit: T::Array[String],
+ ).returns(String)
+ end
+ def no_satisfying_message(decl, package, explicit)
+ wanted = decl.constraint.empty? ? "any version" : decl.constraint.inspect
+ wanted += " on #{explicit.join(", ")}" unless explicit.empty?
+ "no version of #{decl.name} satisfies #{wanted} " \
+ "(universe: #{package.versions.size} version(s))"
end
end
end
diff --git a/lib/dev/deps/rock_scheme.rb b/lib/dev/deps/rock_scheme.rb
new file mode 100644
index 0000000..f730a5b
--- /dev/null
+++ b/lib/dev/deps/rock_scheme.rb
@@ -0,0 +1,114 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "version_scheme"
+
+module Dev
+ module Deps
+ # LuaRocks constraint semantics (:luarocks): dotted numeric versions with a
+ # dash-separated rockspec revision ("3.4-1").
+ #
+ # NOT semver: the "-1" is a packaging revision that releases *above* its
+ # unrevised version (3.4-1 > 3.4), where semver would read it as a
+ # prerelease below it. That grammar difference is why luarocks gets its own
+ # scheme instead of riding SemverScheme.
+ #
+ # Supported constraint syntax, per rockspec dependency grammar: comparators
+ # (">=", ">", "<=", "<", "==", "="), pessimistic "~>" (rubygems-style), bare
+ # exact versions, and comma-separated conjunction.
+ class RockScheme < VersionScheme
+ extend T::Sig
+
+ # The constraint expression is not parseable rockspec constraint syntax.
+ class InvalidConstraintError < VersionScheme::InvalidConstraintError; end
+ # The version string is not a luarocks version.
+ class InvalidVersionError < VersionScheme::InvalidVersionError; end
+
+ # The constraint key carrying the expression (the luarocks DSL's
+ # positional constraint lands under "constraint").
+ CONSTRAINT_KEY = "constraint"
+
+ # Dotted numeric segments with an optional numeric -revision.
+ VERSION_PATTERN = /\A(\d+(?:\.\d+)*)(?:-(\d+))?\z/
+ TERM_PATTERN = /\A(~>|>=|<=|==|>|<|=)?\s*(\d\S*)\z/
+
+ # @param version [PackageVersion] a candidate; only its version string matters
+ # @param constraint [Hash] declaration constraint; "constraint" holds the expression
+ # @return [Boolean]
+ # @raise [InvalidConstraintError] if the expression does not parse
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ expression = constraint[CONSTRAINT_KEY].to_s.strip
+ return true if expression.empty?
+
+ key = comparison_key(version.version)
+ expression.split(",").map(&:strip).all? { |term| term_satisfied?(key, term) }
+ end
+
+ # @param versions [Array] luarocks versions
+ # @return [Array] ascending by dotted segments, then revision
+ # @raise [InvalidVersionError] if any version does not parse
+ sig { override.params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ versions.sort_by { |version| comparison_key(version) }
+ end
+
+ private
+
+ # @param key [Array] the candidate version's comparison key
+ # @param term [String] one constraint term, e.g. ">= 3.0" or "~> 1.0.5"
+ # @return [Boolean]
+ # @raise [InvalidConstraintError] if the term does not parse
+ sig { params(key: T::Array[T.untyped], term: String).returns(T::Boolean) }
+ def term_satisfied?(key, term)
+ match = TERM_PATTERN.match(term)
+ raise InvalidConstraintError, "not a rockspec constraint: #{term.inspect}" unless match
+
+ operator = match[1] || "=="
+ bound = T.must(match[2])
+ return pessimistic_match?(key, bound) if operator == "~>"
+
+ comparison = T.let(key <=> comparison_key(bound), Integer)
+ case operator
+ when "==", "=" then comparison.zero?
+ when ">=" then comparison >= 0
+ when ">" then comparison.positive?
+ when "<=" then comparison <= 0
+ else comparison.negative?
+ end
+ end
+
+ # Pessimistic constraint, rubygems-style: "~> 1.0.5" means >= 1.0.5 and
+ # < 1.1; "~> 1.0" means >= 1.0 and < 2.0.
+ #
+ # @param key [Array] the candidate version's comparison key
+ # @param bound [String] the pessimistic bound
+ # @return [Boolean]
+ sig { params(key: T::Array[T.untyped], bound: String).returns(T::Boolean) }
+ def pessimistic_match?(key, bound)
+ segments = comparison_key(bound).fetch(0)
+ upper = segments.size > 1 ? segments[0..-2].to_a : segments.dup
+ upper[-1] = upper.fetch(-1) + 1
+
+ lower = T.let(key <=> comparison_key(bound), Integer)
+ lower >= 0 && T.let(key <=> [upper, 0], Integer).negative?
+ end
+
+ # Comparison key: [release segments (trailing zeros stripped), revision].
+ #
+ # @param version [String]
+ # @return [Array]
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { params(version: String).returns(T::Array[T.untyped]) }
+ def comparison_key(version)
+ match = VERSION_PATTERN.match(version)
+ raise InvalidVersionError, "not a luarocks version: #{version.inspect}" unless match
+
+ segments = T.must(match[1]).split(".").map(&:to_i)
+ segments.pop while segments.size > 1 && segments.last.to_i.zero?
+ [segments, match[2].to_i]
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/scope.rb b/lib/dev/deps/scope.rb
new file mode 100644
index 0000000..928fc18
--- /dev/null
+++ b/lib/dev/deps/scope.rb
@@ -0,0 +1,84 @@
+# typed: strict
+# frozen_string_literal: true
+
+require "sorbet-runtime"
+
+module Dev
+ module Deps
+ # The resolution context a declaration is scoped under: which group asked
+ # for it, and where/when it installs. Rides the resolve walk parent ->
+ # child as a single unit — a dep only needed in one group/host/env can't
+ # need its transitive closure anywhere else — which is why these three
+ # axes live together and platform does not (platforms union per package
+ # across declaring groups instead of inheriting).
+ #
+ # host and env use nil as their all-encompassing empty form ("installs
+ # everywhere"), matching the declaration axes documented in the README;
+ # group always names a purpose and defaults to :app.
+ class Scope
+ extend T::Sig
+
+ # @return [Symbol] purpose the dep was declared for (:app, :test, :build, …)
+ sig { returns(Symbol) }
+ attr_reader :group
+
+ # @return [Symbol, nil] host OS the dep installs on (:darwin / :linux);
+ # nil means all hosts
+ sig { returns(T.nilable(Symbol)) }
+ attr_reader :host
+
+ # @return [String, nil] execution context the dep is for ("ci" / "dev");
+ # nil means all envs
+ sig { returns(T.nilable(String)) }
+ attr_reader :env
+
+ # @param group [Symbol] purpose group; defaults to :app
+ # @param host [Symbol, String, nil] host OS, coerced to a Symbol
+ # @param env [String, Symbol, nil] environment name, coerced to a String
+ sig do
+ params(
+ group: Symbol,
+ host: T.nilable(T.any(Symbol, String)),
+ env: T.nilable(T.any(String, Symbol)),
+ ).void
+ end
+ def initialize(group: :app, host: nil, env: nil)
+ @group = group
+ @host = T.let(host&.to_sym, T.nilable(Symbol))
+ @env = T.let(env&.to_s, T.nilable(String))
+ freeze
+ end
+
+ # Install-scoping projection stamped onto minted pins, so host/env land
+ # in the lockfile and the installer can filter on them. group is not
+ # projected — it is a first-class Dependency field.
+ #
+ # @return [Hash{String => String}] host/env keys, present only when pinned
+ sig { returns(T::Hash[String, String]) }
+ def to_metadata
+ meta = T.let({}, T::Hash[String, String])
+ h = host
+ e = env
+ meta["host"] = h.to_s if h
+ meta["env"] = e if e
+ meta
+ end
+
+ # @param other [Object]
+ # @return [Boolean] whether other is the same context
+ sig { params(other: Object).returns(T::Boolean) }
+ def ==(other)
+ return false unless other.is_a?(Scope)
+
+ [group, host, env] == [other.group, other.host, other.env]
+ end
+ alias_method :eql?, :==
+
+ # @return [Integer] hash code, so scopes work as Hash keys
+ sig { returns(Integer) }
+ def hash
+ [self.class, group, host, env].hash
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/scoped_declaration.rb b/lib/dev/deps/scoped_declaration.rb
new file mode 100644
index 0000000..edbe62c
--- /dev/null
+++ b/lib/dev/deps/scoped_declaration.rb
@@ -0,0 +1,117 @@
+# typed: strict
+# frozen_string_literal: true
+
+require "sorbet-runtime"
+require_relative "declaration"
+require_relative "scope"
+
+module Dev
+ module Deps
+ # A Declaration married to the context it resolves under: fact-in-context.
+ #
+ # Project rows are born with explicit context (the DSL group's axes);
+ # transitive declarations get the parent's Scope stamped by the Resolver
+ # at walk time — context is a property of the path, which is why the bare
+ # Declaration cannot carry it. Composition, deliberately not a subclass: a
+ # ScopedDeclaration must never pass where a context-free Declaration is
+ # expected (the facts side of the domain), and value equality across an
+ # inheritance boundary is a trap.
+ #
+ # platform, post_install, and materialization ride here rather than in
+ # Scope because they are per-row and do not inherit: platforms union per
+ # package across the declaring groups (Resolver#declared_platforms), hooks
+ # run only for the row that declared them, and install instructions
+ # describe how THIS project consumes the package. They also cannot live on
+ # Declaration: the atom is shared with repository-reported manifest edges,
+ # and no upstream manifest states where you install something.
+ class ScopedDeclaration
+ extend T::Sig
+
+ # @return [Declaration] the ask: name + integration + constraint + source
+ sig { returns(Declaration) }
+ attr_reader :declaration
+
+ # @return [Scope] the context the ask resolves under
+ sig { returns(Scope) }
+ attr_reader :scope
+
+ # @return [String, nil] artifact variant this row targets (e.g.
+ # "LinuxServer"); nil lets the integration pick its default
+ sig { returns(T.nilable(String)) }
+ attr_reader :platform
+
+ # @return [Proc, Array, nil] callable(s) run after the dep is
+ # fetched; never serialized to the lockfile
+ sig { returns(T.nilable(T.any(Proc, T::Array[Proc]))) }
+ attr_reader :post_install
+
+ # @return [Hash{String => Object}] install instructions for this row
+ # (install_dir, an asset glob, a build recipe); merged into the minted
+ # pin's metadata by the Resolver, never seen by a Repository. {} means
+ # the integration's tool owns layout.
+ sig { returns(T::Hash[String, T.untyped]) }
+ attr_reader :materialization
+
+ # @param declaration [Declaration] the ask
+ # @param scope [Scope] resolution context; defaults to the default scope
+ # @param platform [String, nil] targeted artifact variant
+ # @param post_install [Proc, Array, nil] post-fetch hook(s)
+ # @param materialization [Hash{String => Object}] install instructions;
+ # defaults to {} (tool-owned layout)
+ sig do
+ params(
+ declaration: Declaration,
+ scope: Scope,
+ platform: T.nilable(String),
+ post_install: T.nilable(T.any(Proc, T::Array[Proc])),
+ materialization: T::Hash[String, T.untyped],
+ ).void
+ end
+ def initialize(declaration:, scope: Scope.new, platform: nil, post_install: nil, materialization: {})
+ @declaration = declaration
+ @scope = scope
+ @platform = platform
+ @post_install = post_install
+ @materialization = T.let(materialization.dup.freeze, T::Hash[String, T.untyped])
+ freeze
+ end
+
+ # @return [String] the ask's package name (delegated)
+ sig { returns(String) }
+ def name = declaration.name
+
+ # @return [Symbol] the ask's integration (delegated)
+ sig { returns(Symbol) }
+ def integration = declaration.integration
+
+ # @return [Hash{String => Object}] the ask's constraint (delegated)
+ sig { returns(T::Hash[String, T.untyped]) }
+ def constraint = declaration.constraint
+
+ # @return [String, nil] the ask's source coordinate (delegated)
+ sig { returns(T.nilable(String)) }
+ def source = declaration.source
+
+ # @return [String, nil] the ask's addressable revision (delegated)
+ sig { returns(T.nilable(String)) }
+ def revision = declaration.revision
+
+ # @param other [Object]
+ # @return [Boolean] whether other is the same ask under the same context
+ sig { params(other: Object).returns(T::Boolean) }
+ def ==(other)
+ return false unless other.is_a?(ScopedDeclaration)
+
+ [declaration, scope, platform, post_install, materialization] ==
+ [other.declaration, other.scope, other.platform, other.post_install, other.materialization]
+ end
+ alias_method :eql?, :==
+
+ # @return [Integer] hash code
+ sig { returns(Integer) }
+ def hash
+ [self.class, declaration, scope, platform, post_install, materialization].hash
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/semver_scheme.rb b/lib/dev/deps/semver_scheme.rb
new file mode 100644
index 0000000..a60a802
--- /dev/null
+++ b/lib/dev/deps/semver_scheme.rb
@@ -0,0 +1,157 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "version_scheme"
+
+module Dev
+ module Deps
+ # Semver range semantics (:ficsit): node-style ranges over MAJOR.MINOR.PATCH
+ # versions with optional prerelease tags.
+ #
+ # Supported range syntax — the subset ficsit.app mod constraints actually
+ # use: caret ("^3.12.0"), tilde ("~1.2.3"), comparators (">=", ">", "<=",
+ # "<", "="), bare exact versions, and space/comma-separated conjunction
+ # (">=1.0.0 <2.0.0"). Prereleases order below their release
+ # ("1.0.0-rc.1" < "1.0.0"), with semver's numeric-below-alphanumeric
+ # identifier ordering.
+ class SemverScheme < VersionScheme
+ extend T::Sig
+
+ # The range expression is not parseable semver range syntax.
+ class InvalidConstraintError < VersionScheme::InvalidConstraintError; end
+ # The version string is not a semver version.
+ class InvalidVersionError < VersionScheme::InvalidVersionError; end
+
+ # The constraint key carrying the range (the ficsit DSL's version:).
+ CONSTRAINT_KEY = "version"
+
+ # MAJOR.MINOR.PATCH with optional -prerelease and ignored +build.
+ VERSION_PATTERN = /\A(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?\z/
+ # Operator-prefixed term inside a range expression. Bounds may be partial
+ # ("^3", ">=1.2"); missing segments are zero.
+ TERM_PATTERN = /\A(\^|~|>=|<=|>|<|=)?(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)\z/
+
+ # @param version [PackageVersion] a candidate; only its version string matters
+ # @param constraint [Hash] declaration constraint; "version" holds the range
+ # @return [Boolean]
+ # @raise [InvalidConstraintError] if the range does not parse
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ expression = constraint[CONSTRAINT_KEY].to_s.strip
+ return true if expression.empty?
+
+ key = comparison_key(version.version)
+ terms(expression).all? { |term| term_satisfied?(key, term) }
+ end
+
+ # @param versions [Array] semver versions
+ # @return [Array] ascending semver order
+ # @raise [InvalidVersionError] if any version does not parse
+ sig { override.params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ versions.sort_by { |version| comparison_key(version) }
+ end
+
+ private
+
+ # Split a range expression into [operator, release-triple, bound-key]
+ # terms. Partial bounds pad with zeros; a bound's prerelease tag is kept
+ # so terms like ">1.0.0-rc.1" compare at full precision.
+ #
+ # @param expression [String] e.g. "^3.12.0" or ">=1.0.0 <2.0.0"
+ # @return [Array<[String, Array, Array]>]
+ # @raise [InvalidConstraintError] if any term does not parse
+ sig { params(expression: String).returns(T::Array[[String, T::Array[Integer], T::Array[T.untyped]]]) }
+ def terms(expression)
+ expression.split(/[,\s]+/).map do |term|
+ match = TERM_PATTERN.match(term)
+ raise InvalidConstraintError, "not a semver range term: #{term.inspect}" unless match
+
+ release, _, prerelease = T.must(match[2]).partition("-")
+ triple = release.split(".").map(&:to_i)
+ triple << 0 while triple.size < 3
+ padded = triple.join(".") + (prerelease.empty? ? "" : "-#{prerelease}")
+ [match[1] || "=", triple, comparison_key(padded)]
+ end
+ end
+
+ # @param key [Array] the candidate version's comparison key
+ # @param term [Array] [operator, release-triple, bound-key]
+ # @return [Boolean]
+ sig do
+ params(
+ key: T::Array[T.untyped],
+ term: [String, T::Array[Integer], T::Array[T.untyped]],
+ ).returns(T::Boolean)
+ end
+ def term_satisfied?(key, term)
+ operator, triple, bound_key = term
+ comparison = T.let(key <=> bound_key, Integer)
+ case operator
+ when "^" then comparison >= 0 && T.let(key <=> release_key(caret_upper(triple)), Integer).negative?
+ when "~" then comparison >= 0 && T.let(key <=> release_key(tilde_upper(triple)), Integer).negative?
+ when ">=" then comparison >= 0
+ when ">" then comparison.positive?
+ when "<=" then comparison <= 0
+ when "<" then comparison.negative?
+ else comparison.zero?
+ end
+ end
+
+ # Exclusive upper bound for a caret range: the next release of the
+ # leftmost non-zero segment ("^1.2.3" → 2.0.0, "^0.2.3" → 0.3.0,
+ # "^0.0.3" → 0.0.4).
+ #
+ # @param bound [Array] [major, minor, patch]
+ # @return [Array]
+ sig { params(bound: T::Array[Integer]).returns(T::Array[Integer]) }
+ def caret_upper(bound)
+ major, minor, patch = bound
+ return [T.must(major) + 1, 0, 0] if T.must(major).positive?
+ return [0, T.must(minor) + 1, 0] if T.must(minor).positive?
+
+ [0, 0, T.must(patch) + 1]
+ end
+
+ # Exclusive upper bound for a tilde range: the next minor ("~1.2.3" → 1.3.0).
+ #
+ # @param bound [Array] [major, minor, patch]
+ # @return [Array]
+ sig { params(bound: T::Array[Integer]).returns(T::Array[Integer]) }
+ def tilde_upper(bound)
+ major, minor, _patch = bound
+ [T.must(major), T.must(minor) + 1, 0]
+ end
+
+ # Comparison key for a release triple (no prerelease): sorts above any
+ # prerelease of the same triple.
+ #
+ # @param triple [Array] [major, minor, patch]
+ # @return [Array]
+ sig { params(triple: T::Array[Integer]).returns(T::Array[T.untyped]) }
+ def release_key(triple)
+ [triple.fetch(0), triple.fetch(1), triple.fetch(2), 1, []]
+ end
+
+ # Semver comparison key: [major, minor, patch, release-flag, prerelease
+ # identifiers]. The release flag puts releases (1) above prereleases (0);
+ # prerelease identifiers compare numeric-below-alphanumeric per semver §11.
+ #
+ # @param version [String]
+ # @return [Array]
+ # @raise [InvalidVersionError] if the version does not parse
+ sig { params(version: String).returns(T::Array[T.untyped]) }
+ def comparison_key(version)
+ match = VERSION_PATTERN.match(version)
+ raise InvalidVersionError, "not a semver version: #{version.inspect}" unless match
+
+ prerelease = match[4]
+ identifiers = prerelease.to_s.split(".").map do |identifier|
+ identifier.match?(/\A\d+\z/) ? [0, identifier.to_i, ""] : [1, 0, identifier]
+ end
+ [match[1].to_i, match[2].to_i, match[3].to_i, prerelease ? 0 : 1, identifiers]
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/steam_cmd.rb b/lib/dev/deps/steam_cmd.rb
index 2f7df3b..a2361c4 100644
--- a/lib/dev/deps/steam_cmd.rb
+++ b/lib/dev/deps/steam_cmd.rb
@@ -66,35 +66,35 @@ def run(*commands, dir: DEFAULT_DIR)
T.unsafe(Open3).capture3(script, *commands)
end
- # Resolve the buildid published on a branch via +app_info_print.
+ # Resolve every branch's current buildid via +app_info_print — one call
+ # enumerates the whole branch universe.
#
# @param app [String, Integer] Steam app id
- # @param branch [String] branch name (default "public")
# @param dir [String] SteamCMD install dir
- # @return [String] the resolved buildid
- # @raise [SteamCmdError] if the command fails or no buildid is found
- sig { params(app: T.any(String, Integer), branch: String, dir: String).returns(String) }
- def resolve_build_id(app:, branch: "public", dir: DEFAULT_DIR)
+ # @return [Hash{String => String}] branch name → current buildid
+ # @raise [SteamCmdError] if the command fails
+ sig { params(app: T.any(String, Integer), dir: String).returns(T::Hash[String, String]) }
+ def resolve_branches(app:, dir: DEFAULT_DIR)
out, err, status = run("+login", "anonymous", "+app_info_print", app.to_s, "+quit", dir:)
Kernel.raise(SteamCmdError, "steamcmd app_info_print #{app} failed: #{err.strip}") unless status.success?
- build_id = parse_build_id(out, branch)
- Kernel.raise(SteamCmdError, "no buildid for app #{app} branch #{branch} in app_info_print output") unless build_id
-
- build_id
+ parse_branches(out)
end
- # Parse the buildid for a branch out of app_info_print's VDF output. The
- # public-branch block holds only scalars (buildid, timeupdated, …), so a
- # non-greedy match up to the closing brace is enough to scope to the branch.
+ # Parse every branch's buildid out of app_info_print's VDF output. Each
+ # branch block under "branches" holds only scalars (buildid,
+ # timeupdated, …), so a bracket-free match per block is enough; branches
+ # without a buildid (e.g. password-gated ones Steam redacts) are simply
+ # absent from the result.
#
# @param output [String] raw app_info_print stdout
- # @param branch [String] branch name
- # @return [String, nil] the buildid, or nil if absent
- sig { params(output: String, branch: String).returns(T.nilable(String)) }
- def parse_build_id(output, branch)
- match = output.match(/"#{Regexp.escape(branch)}"\s*\{[^}]*?"buildid"\s*"(\d+)"/m)
- match && match[1]
+ # @return [Hash{String => String}] branch name → buildid
+ sig { params(output: String).returns(T::Hash[String, String]) }
+ def parse_branches(output)
+ section = output[/"branches"\s*\{(.*)\z/m, 1] || ""
+ section.scan(/"([^"]+)"\s*\{[^{}]*?"buildid"\s*"(\d+)"/m).to_h do |branch, build_id|
+ [branch.to_s, build_id.to_s]
+ end
end
end
end
diff --git a/lib/dev/deps/steam_integration.rb b/lib/dev/deps/steam_integration.rb
index c69bc50..07a592d 100644
--- a/lib/dev/deps/steam_integration.rb
+++ b/lib/dev/deps/steam_integration.rb
@@ -80,7 +80,7 @@ def install(dep)
sig { params(dep: Dependency, server_dir: Pathname).void }
def provision(dep, server_dir)
_out, err, status = SteamCmd.run(
- "+@sSteamCmdForcePlatformType", dep.metadata["platform"],
+ "+@sSteamCmdForcePlatformType", steam_platform_for(dep.metadata["platform"]),
"+force_install_dir", server_dir.to_s,
"+login", "anonymous",
"+app_update", dep.metadata["app"], "validate",
@@ -91,6 +91,24 @@ def provision(dep, server_dir)
raise ProvisionError, "steamcmd app_update #{dep.metadata["app"]} failed: #{err.strip}"
end
+ # Map the declared platform (a dev platform name, riding the pin via the
+ # declaration's materialization) to a SteamCMD ForcePlatformType value.
+ # The mapping is this integration's vocabulary — the resolver and the
+ # lockfile carry the declared name untranslated. The dedicated server is
+ # Linux-only in our pipeline, so a missing platform defaults to "linux".
+ #
+ # @param platform [String, nil] declared platform (e.g. "LinuxServer")
+ # @return [String] steam platform type ("linux" / "windows")
+ sig { params(platform: T.nilable(String)).returns(String) }
+ def steam_platform_for(platform)
+ case platform
+ when "LinuxServer" then "linux"
+ when "WindowsServer", "Windows" then "windows"
+ when nil then "linux"
+ else platform.downcase
+ end
+ end
+
# Confirm the installed depot matches the locked buildid. A mismatch means
# the lock is stale (the public branch moved) — surface it so the user
# re-runs dev update-deps rather than silently testing a different build.
diff --git a/lib/dev/deps/steam_repository.rb b/lib/dev/deps/steam_repository.rb
index 666a850..6570f73 100644
--- a/lib/dev/deps/steam_repository.rb
+++ b/lib/dev/deps/steam_repository.rb
@@ -1,19 +1,21 @@
# typed: strict
# frozen_string_literal: true
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
require_relative "steam_cmd"
module Dev
module Deps
- # Resolves a Steam application (e.g. the Satisfactory Dedicated Server) to a
- # pinned build.
+ # Reports a Steam application's universe (e.g. the Satisfactory Dedicated
+ # Server): every branch's current buildid.
#
- # The "version" is the Steam buildid: either an explicitly pinned one from
- # the declaration, or the current public-branch buildid resolved via
- # SteamCMD's +app_info_print. There is no content hash — Steam exposes no
- # stable per-build digest, so integrity is delegated to SteamCMD's
+ # The "version" is the Steam buildid, resolved via SteamCMD's
+ # +app_info_print. There is no content hash — Steam exposes no stable
+ # per-build digest, so integrity is delegated to SteamCMD's
# `app_update … validate` at install time (the same nil-hash shape brew
# casks use).
#
@@ -24,61 +26,45 @@ module Deps
class SteamRepository < Repository
extend T::Sig
- # Resolve a Steam app dependency to a pinned Dependency.
+ # Report a Steam app's universe: the current buildid of every branch,
+ # one version per branch.
#
- # @param id [Hash] must include "name", "app", "install_dir", "integration",
- # "group"; optionally "branch" (default "public"), "buildid" (explicit
- # pin), and "platforms" (the consuming group's platform, e.g. ["LinuxServer"])
- # @return [Dependency]
- # @raise [SteamCmd::SteamCmdError] if resolving the buildid fails
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- app = id["app"]
- branch = id["branch"] || "public"
- build_id = id["buildid"] || resolve_build_id(app:, branch:)
+ # Steam exposes no build history, but branch tips ARE enumerable: one
+ # +app_info_print call reports every branch's current buildid.
+ # The branch a buildid is the tip of rides metadata as
+ # a fact for SteamScheme's branch selection. No digest: Steam publishes
+ # no stable per-build hash; integrity is SteamCMD's app_update …
+ # validate at install.
+ #
+ # @param id [PackageId] source is the Steam app id
+ # @return [Package] one version per branch
+ # @raise [SteamCmd::SteamCmdError] if querying the app fails
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ app = T.must(id.source)
+ versions = resolve_branches(app).map do |branch, build_id|
+ PackageVersion.new(
+ version: build_id,
+ metadata: { "app" => app, "branch" => branch },
+ # Steam depots are self-contained by construction: SteamCMD
+ # delivers the complete installed tree.
+ declarations: Declarations::Resolved.new([]),
+ )
+ end
+ raise PackageNotFoundError, "no branches with a buildid for Steam app #{app}" if versions.empty?
- Dependency.new(
- name: id["name"],
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: build_id.to_s,
- hash: nil,
- metadata: {
- "app" => app.to_s,
- "branch" => branch,
- "install_dir" => id["install_dir"],
- "platform" => steam_platform_for(id["platforms"]),
- },
- )
+ Package.new(id: id, versions: versions)
end
private
# Isolated so tests can stub the SteamCMD boundary.
#
- # @param app [String, Integer]
- # @param branch [String]
- # @return [String] resolved buildid
- sig { params(app: T.any(String, Integer), branch: String).returns(String) }
- def resolve_build_id(app:, branch:)
- SteamCmd.resolve_build_id(app:, branch:)
- end
-
- # Map the consuming group's platform to a SteamCMD ForcePlatformType value.
- # The dedicated server is Linux-only in our pipeline, so a missing platform
- # defaults to "linux".
- #
- # @param platforms [Array, nil] platforms from the resolver
- # @return [String] steam platform type ("linux" / "windows")
- sig { params(platforms: T.nilable(T::Array[T.nilable(String)])).returns(String) }
- def steam_platform_for(platforms)
- group_platform = Array(platforms).compact.first
- case group_platform
- when "LinuxServer" then "linux"
- when "WindowsServer", "Windows" then "windows"
- when nil then "linux"
- else group_platform.downcase
- end
+ # @param app [String]
+ # @return [Hash{String => String}] branch name → current buildid
+ sig { params(app: String).returns(T::Hash[String, String]) }
+ def resolve_branches(app)
+ SteamCmd.resolve_branches(app: app)
end
end
end
diff --git a/lib/dev/deps/steam_scheme.rb b/lib/dev/deps/steam_scheme.rb
new file mode 100644
index 0000000..e3ba711
--- /dev/null
+++ b/lib/dev/deps/steam_scheme.rb
@@ -0,0 +1,47 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "version_scheme"
+
+module Dev
+ module Deps
+ # Steam constraint semantics (:steam): the universe is one buildid per
+ # branch (SteamRepository enumerates every branch's tip), and the
+ # constraint selects by branch — a fact match, not a version-string
+ # match — plus an optional exact "buildid" assertion.
+ #
+ # A pinned buildid that is no longer the branch tip fails resolution
+ # loudly: Steam serves only current builds, so a stale pin cannot be
+ # honored and pretending otherwise would defer the failure to install.
+ # The branch tips are enumerable in one query, so a constraint always
+ # selects out of the reported universe.
+ class SteamScheme < VersionScheme
+ extend T::Sig
+
+ # Branch selected when the declaration names none.
+ DEFAULT_BRANCH = "public"
+
+ # @param version [PackageVersion] a candidate (version is the buildid,
+ # "branch" rides its metadata)
+ # @param constraint [Hash] declaration constraint; "branch" and
+ # optionally "buildid"
+ # @return [Boolean]
+ sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ branch = (constraint["branch"] || DEFAULT_BRANCH).to_s
+ return false unless version.metadata["branch"].to_s == branch
+
+ buildid = constraint["buildid"]
+ buildid.nil? || buildid.to_s == version.version
+ end
+
+ # @param versions [Array] buildids
+ # @return [Array] ascending numerically — buildids are
+ # monotonically increasing integers
+ sig { override.params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ versions.sort_by(&:to_i)
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/tap.rb b/lib/dev/deps/tap.rb
index 5509f2d..76ea707 100644
--- a/lib/dev/deps/tap.rb
+++ b/lib/dev/deps/tap.rb
@@ -1,6 +1,7 @@
-# typed: true
+# typed: strict
# frozen_string_literal: true
+require "sorbet-runtime"
require "uri"
module Dev
@@ -10,11 +11,17 @@ module Deps
# - name: tap identifier (e.g. "d3mlabs/d3mlabs")
# - url: optional URI; file:// URIs are local taps resolved relative to project root
Tap = Data.define(:name, :url) do
+ extend T::Sig
+
+ # @param name [String] tap identifier
+ # @param url [String, nil] tap URL; file:// means a local tap
+ sig { params(name: String, url: T.nilable(String)).void }
def initialize(name:, url: nil)
super(name:, url: url ? URI(url).freeze : nil)
end
# @return [Boolean] true if this is a local (file://) tap
+ sig { returns(T::Boolean) }
def local?
url&.scheme == "file"
end
diff --git a/lib/dev/deps/url_repository.rb b/lib/dev/deps/url_repository.rb
index 66223e6..720b889 100644
--- a/lib/dev/deps/url_repository.rb
+++ b/lib/dev/deps/url_repository.rb
@@ -4,43 +4,55 @@
require "digest"
require "open3"
require "tempfile"
+require_relative "artifact"
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
- # Fetches URL-based dependencies by downloading and computing SHA256.
+ # Reports URL-based dependencies (:url integration): the URL is the
+ # entire address, and the universe is whatever the URI serves right now —
+ # an observable-now singleton, the same semantics a Steam branch tip has.
#
- # The artifact is downloaded to a temp file and hashed.
- # Callers (e.g. Integration) are responsible for caching the result.
+ # find IS the observation: the artifact is downloaded and hashed
+ # (trust-on-first-use) so the SHA256 rides as the version's digest and
+ # installs verify against the lockfile. The download lives here, in find,
+ # deliberately — find is the I/O operation, and keeping it here is what
+ # keeps Repository#at pure. Versions don't exist in this universe (the
+ # declared tag: is a display label riding materialization), so the
+ # version is empty and the Resolver mints it back from the label.
class UrlRepository < Repository
extend T::Sig
class DownloadError < StandardError; end
- # Download a URL dependency and compute its SHA256 integrity hash.
+ # Report a URL dependency's universe: the one artifact behind the URL.
#
- # @param id [Hash] must include "name", "url", "integration", "group";
- # optionally "tag" for version
- # @return [Dependency] with hash set to "SHA256=" and
- # metadata["downloaded_path"] pointing to the temp file
+ # @param id [PackageId] source is the download URL
+ # @return [Package] a singleton universe
# @raise [DownloadError] if the download fails
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- url = id["url"]
- name = id["name"]
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ url = T.must(id.source)
+ path = download_to_tempfile(url, id.name)
+ digest = "SHA256=#{Digest::SHA256.file(path).hexdigest}"
- path = download_to_tempfile(url, name)
- sha256_hex = Digest::SHA256.file(path).hexdigest
- hash = "SHA256=#{sha256_hex}"
-
- Dependency.new(
- name: name,
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: id["tag"],
- hash: hash,
- metadata: { "url" => url, "downloaded_path" => path },
+ Package.new(
+ id: id,
+ versions: [
+ PackageVersion.new(
+ version: "",
+ digest: digest,
+ artifacts: { "default" => Artifact.new(uri: url, digest: digest) },
+ metadata: { "url" => url },
+ # A downloaded archive is self-contained: its contents are the
+ # whole dependency.
+ declarations: Declarations::Resolved.new([]),
+ ),
+ ],
)
end
diff --git a/lib/dev/deps/version_scheme.rb b/lib/dev/deps/version_scheme.rb
new file mode 100644
index 0000000..a022277
--- /dev/null
+++ b/lib/dev/deps/version_scheme.rb
@@ -0,0 +1,63 @@
+# typed: strict
+# frozen_string_literal: true
+
+require_relative "package_version"
+
+module Dev
+ module Deps
+ # Per-integration constraint semantics — a domain service, deliberately
+ # separate from the domain objects it evaluates.
+ #
+ # The layering rule: a Package states facts (which versions exist), a
+ # VersionScheme evaluates predicates (does this version satisfy that
+ # constraint, and how do this ecosystem's versions order), and the Resolver
+ # chooses (take the highest satisfying version). Constraint syntax is a
+ # property of an ecosystem, not of any one package's version set, which is
+ # why the predicate lives here and not on Package.
+ #
+ # This is also the seam for a future dev-native constraint syntax: the DSL
+ # boundary would parse dev syntax into a typed constraint value, and each
+ # scheme would translate it into its ecosystem's query — the satisfies?/sort
+ # signatures do not move. Every Registry entry must name its scheme, so
+ # adding an ecosystem mechanically demands answering "what are its
+ # constraint semantics". See docs/deps-architecture.md.
+ class VersionScheme
+ extend T::Sig
+
+ # Base for every scheme's constraint-parse failure. A bad constraint is
+ # the user's declaration being wrong, so the Resolver lets it propagate.
+ class InvalidConstraintError < StandardError; end
+
+ # Base for every scheme's version-parse failure. A universe can contain
+ # versions that predate or ignore the ecosystem's conventions; the
+ # Resolver treats such candidates as non-satisfying rather than failing
+ # the whole resolve.
+ class InvalidVersionError < StandardError; end
+
+ # Does one version satisfy the constraint, under this ecosystem's syntax
+ # and comparison rules?
+ #
+ # Takes the whole PackageVersion, not the bare string: 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.
+ #
+ # @param version [PackageVersion] a candidate version with its facts
+ # @param constraint [Hash] the declaration's constraint hash
+ # @return [Boolean]
+ sig { params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) }
+ def satisfies?(version, constraint)
+ raise NotImplementedError, "#{self.class}#satisfies? must be implemented"
+ end
+
+ # Total order for this ecosystem's version strings, ascending.
+ #
+ # @param versions [Array] version strings to order
+ # @return [Array] the same versions, ascending
+ sig { params(versions: T::Array[String]).returns(T::Array[String]) }
+ def sort(versions)
+ raise NotImplementedError, "#{self.class}#sort must be implemented"
+ end
+ end
+ end
+end
diff --git a/lib/dev/deps/xcode_repository.rb b/lib/dev/deps/xcode_repository.rb
index dfbdf14..f447818 100644
--- a/lib/dev/deps/xcode_repository.rb
+++ b/lib/dev/deps/xcode_repository.rb
@@ -1,39 +1,48 @@
# typed: strict
# frozen_string_literal: true
+require_relative "declarations"
+require_relative "package"
+require_relative "package_id"
+require_relative "package_version"
require_relative "repository"
-require_relative "dependency"
module Dev
module Deps
- # Resolves the `xcode ""` declaration to a pinned Dependency.
+ # Reports the Xcode toolchain: a purely addressable universe.
#
- # Xcode has no queryable registry to resolve against (Apple publishes no
- # version API dev could pin hashes from), so resolution is the identity:
- # the declared exact version IS the locked version. This still rides the
- # resolver -> lockfile pipeline so the pin lands in deps.lock like every
- # other dependency and the installer/accessor can find it there.
+ # Apple publishes no queryable version registry (nothing to enumerate, no
+ # hashes to pin), so there is no discrete universe and find refuses. The
+ # declared exact version is an address — the DSL's xcode verb mints it as
+ # the declaration's revision — and at lifts it as the identity. The pin
+ # still rides the resolver -> lockfile pipeline so it lands in deps.lock
+ # like every other dependency and the installer/accessor can find it
+ # there; existence is verified at install by the xcodes CLI.
class XcodeRepository < Repository
extend T::Sig
- class MissingVersionError < StandardError; end
+ # Constraint-shaped asks cannot work here: there is nothing to select
+ # over. Declarations arrive as revisions instead.
+ class NoEnumerableUniverseError < PackageNotFoundError; end
- # @param id [Hash] must include "name", "integration", "group", "version"
- # @return [Dependency]
- # @raise [MissingVersionError] when no exact version was declared
- sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) }
- def fetch(id)
- version = id["version"].to_s
- raise MissingVersionError, "xcode requires an exact version (e.g. xcode \"26.1.1\")" if version.empty?
+ # @param id [PackageId]
+ # @return [Package] never returns
+ # @raise [NoEnumerableUniverseError] always
+ sig { override.params(id: PackageId).returns(Package) }
+ def find(id)
+ raise NoEnumerableUniverseError,
+ "Apple publishes no queryable Xcode registry — declare an exact version (e.g. xcode \"26.1.1\")"
+ end
- Dependency.new(
- name: id["name"],
- integration: id["integration"].to_sym,
- group: id["group"].to_sym,
- version: version,
- hash: nil,
- metadata: {},
- )
+ # Lift the declared exact version — resolution is the identity.
+ #
+ # @param id [PackageId] name is the declaration name
+ # @param revision [String] exact Xcode version (e.g. "26.1.1")
+ # @return [PackageVersion]
+ sig { override.params(id: PackageId, revision: String).returns(PackageVersion) }
+ def at(id, revision)
+ # An Xcode install is self-contained: Apple ships the whole toolchain.
+ PackageVersion.new(version: revision, declarations: Declarations::Resolved.new([]))
end
end
end
diff --git a/lib/dev/shadowenv_llvm.rb b/lib/dev/shadowenv_llvm.rb
index 78204c2..82581de 100644
--- a/lib/dev/shadowenv_llvm.rb
+++ b/lib/dev/shadowenv_llvm.rb
@@ -93,7 +93,9 @@ def project_needs_llvm?(project_root)
return false unless lockfile.exist?
content = lockfile.read
- content.match?(/^llvm:\s*$/) || content.match?(/^brew llvm\b/)
+ # \s* tolerates the dep key's indentation under its integration section
+ # (and matches the legacy top-level key at column 0).
+ content.match?(/^\s*llvm:\s*$/) || content.match?(/^brew llvm\b/)
end
# --- internal helpers ------------------------------------------------
diff --git a/lib/ensure_bundler.rb b/lib/ensure_bundler.rb
index 6718901..785345e 100644
--- a/lib/ensure_bundler.rb
+++ b/lib/ensure_bundler.rb
@@ -1,32 +1,47 @@
-# typed: true
+# typed: strict
# frozen_string_literal: true
+require "sorbet-runtime"
require "open3"
-# Shared logic to ensure bundler version from dependencies.rb is installed.
-# Used by bin/setup.rb (dev up) and bin/test.rb.
+# Ensures the bundler version declared in dependencies.rb is installed.
+# Used by the bin/ scripts (setup, test, tc, rbi), which all activate gems
+# before requiring this file — via `require "bundler/setup"` or, for rbi.rb,
+# plain RubyGems against the default gem home the bundle installs into.
#
# Uses Open3.capture3 for gem install so output doesn't leak through
# the terminal when running inside a CLI::UI spinner.
+module EnsureBundler
+ class BundlerInstallError < StandardError; end
-class BundlerInstallError < StandardError; end
+ class << self
+ extend T::Sig
-def ensure_bundler!(dev_root)
- load File.join(dev_root, "dependencies.rb") unless defined?(BUNDLER_VERSION)
+ # Ensure the installed bundler satisfies dependencies.rb's BUNDLER_VERSION,
+ # installing it when absent or too old.
+ #
+ # @param dev_root [String] dev repo root (where dependencies.rb lives)
+ # @return [Boolean] true (raises on failure)
+ # @raise [BundlerInstallError] when gem install fails
+ sig { params(dev_root: String).returns(T::Boolean) }
+ def ensure!(dev_root)
+ load File.join(dev_root, "dependencies.rb") unless defined?(BUNDLER_VERSION)
- requirement = Gem::Requirement.new(BUNDLER_VERSION)
- current = begin
- out = `bundle --version 2>&1`.strip
- m = out.match(/Bundler version (\d+\.\d+\.\d+)/)
- m ? Gem::Version.new(m[1]) : nil
- end
- return true if current && requirement.satisfied_by?(current)
+ requirement = Gem::Requirement.new(BUNDLER_VERSION)
+ current = begin
+ out = `bundle --version 2>&1`.strip
+ m = out.match(/Bundler version (\d+\.\d+\.\d+)/)
+ m ? Gem::Version.new(T.must(m[1])) : nil
+ end
+ return true if current && requirement.satisfied_by?(current)
- puts "Ensuring bundler #{BUNDLER_VERSION}..."
- out, err, status = Open3.capture3("gem", "install", "bundler", "--no-document")
- unless status.success?
- raise BundlerInstallError, "Failed to install bundler: #{err}"
+ puts "Ensuring bundler #{BUNDLER_VERSION}..."
+ _out, err, status = Open3.capture3("gem", "install", "bundler", "--no-document")
+ unless status.success?
+ raise BundlerInstallError, "Failed to install bundler: #{err}"
+ end
+ Gem.clear_paths
+ true
+ end
end
- Gem.clear_paths
- true
end
diff --git a/sorbet/rbi/shims/data-define.rbi b/sorbet/rbi/shims/data-define.rbi
new file mode 100644
index 0000000..2591dd8
--- /dev/null
+++ b/sorbet/rbi/shims/data-define.rbi
@@ -0,0 +1,16 @@
+# typed: true
+
+# Sigs for Data.define-synthesized member readers, which Sorbet generates
+# without sigs (error 7017 under `typed: strict`). The classes themselves
+# stay strict; only the synthesized accessors need declaring here.
+module Dev
+ module Deps
+ class Tap
+ sig { returns(String) }
+ def name; end
+
+ sig { returns(T.nilable(URI::Generic)) }
+ def url; end
+ end
+ end
+end
diff --git a/src/dev/builtins/install_deps_command.rb b/src/dev/builtins/install_deps_command.rb
index b77084d..6ca2e4d 100644
--- a/src/dev/builtins/install_deps_command.rb
+++ b/src/dev/builtins/install_deps_command.rb
@@ -21,13 +21,13 @@ module Builtins
class InstallDepsCommand < BuiltinCommand
extend T::Sig
- # Builds the DependencyInstaller for a lockfile + integrations pair;
+ # Builds the Installer for a lockfile + integrations pair;
# injected so tests can substitute a fake without touching the host.
InstallerFactory = T.type_alias do
T.proc.params(
lockfile: Dev::Deps::Lockfile,
integrations: T::Hash[Symbol, Dev::Deps::Integration],
- ).returns(Dev::Deps::DependencyInstaller)
+ ).returns(Dev::Deps::Installer)
end
# Builds the project-scoped gem skill linker (the project root is a
@@ -45,7 +45,7 @@ class InstallDepsCommand < BuiltinCommand
end
def initialize(
installer_factory: ->(lockfile, integrations) {
- Dev::Deps::DependencyInstaller.new(lockfile:, integrations:)
+ Dev::Deps::Installer.new(lockfile:, integrations:)
},
gem_skill_linker_factory: ->(project_root) { Dev::Deps::GemSkillLinker.new(project_root:) },
synchronizer: Dev::Learnings::Synchronizer.for
diff --git a/src/dev/builtins/update_deps_command.rb b/src/dev/builtins/update_deps_command.rb
index 4edc18c..05f5986 100644
--- a/src/dev/builtins/update_deps_command.rb
+++ b/src/dev/builtins/update_deps_command.rb
@@ -35,14 +35,25 @@ def call(args:, context:)
Kernel.load(deps_rb.to_s) if deps_rb.exist?
deps_config = Dev::Deps.last_config || Dev::Deps.define {}
+ declarations = deps_config.declarations
+
+ # Lock, then resolve: integrations whose ecosystem tool owns the
+ # whole-set solve (bundler) materialize their tool lockfile first, so
+ # the repositories read an already-solved universe.
+ lockers = Dev::Deps::Registry.lockers(
+ project_root: context.project_root,
+ ruby_version_requirement: deps_config.ruby_version_requirement,
+ )
+ declarations.group_by(&:integration).each do |integration, typed_declarations|
+ lockers[integration]&.lock(typed_declarations)
+ end
+
resolver = Dev::Deps::Resolver.new(
- repositories: Dev::Deps::Registry.repositories(
- project_root: context.project_root,
- ruby_version_requirement: deps_config.ruby_version_requirement,
- ),
+ repositories: Dev::Deps::Registry.repositories(project_root: context.project_root),
+ schemes: Dev::Deps::Registry.schemes,
)
lockfile = Dev::Deps::Lockfile.new(dir: context.project_root)
- resolved = resolver.resolve(deps_config.declarations)
+ resolved = resolver.resolve(declarations)
# Record the manifest digest so the staleness check can tell whether
# dependencies.rb changed after this resolution (Dev::Deps::Staleness).
manifest_digest = deps_rb.exist? ? Digest::SHA256.file(deps_rb.to_s).hexdigest : nil
diff --git a/test/dev/build_container_test.rb b/test/dev/build_container_test.rb
index 7c23223..a57476f 100644
--- a/test/dev/build_container_test.rb
+++ b/test/dev/build_container_test.rb
@@ -683,6 +683,27 @@ class BuildContainerTest < Minitest::Test
FileUtils.rm_rf(dir)
end
+ test "build_contexts_from_lockfile reads integration-nested lockfiles" do
+ Given "a build-deps.lock in the nested format (integration -> name -> attrs)"
+ dir = Dir.mktmpdir("build-container-test-")
+ File.write(File.join(dir, "build-deps.lock"), <<~LOCK)
+ gh:
+ UnrealEngine:
+ group: build
+ version: "5.6.1-css-83"
+ install_dir: "~/.dev/engines/unreal-engine-css"
+ LOCK
+
+ When "computing build contexts"
+ contexts = Dev::BuildContainer.build_contexts_from_lockfile(Pathname(dir))
+
+ Then
+ contexts == { "unrealengine" => File.join(File.expand_path("~/.dev/engines/unreal-engine-css"), "5.6.1-css-83") }
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
test "build_contexts_from_lockfile points at the version-keyed subdir when a version is locked" do
Given "a build-deps.lock whose engine dep declares a version"
dir = Dir.mktmpdir("build-container-test-")
@@ -753,6 +774,29 @@ class BuildContainerTest < Minitest::Test
FileUtils.rm_rf(dir)
end
+ test "install_dir_versions collects env-nested deps from nested-format lockfiles" do
+ Given "a nested-format build-deps.lock with an env-scoped install_dir"
+ dir = Dir.mktmpdir("build-container-test-")
+ File.write(File.join(dir, "build-deps.lock"), <<~LOCK)
+ env:
+ ci:
+ gh:
+ UnrealEngine:
+ group: build
+ version: "5.6.1-css-83"
+ install_dir: "/opt/engines/ue"
+ LOCK
+
+ When "collecting install_dir versions"
+ versions = Dev::BuildContainer.install_dir_versions(Pathname(dir))
+
+ Then
+ versions == { "/opt/engines/ue" => "5.6.1-css-83" }
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
test "install_dir_versions collects env-nested build deps that declare a version" do
Given "a build-deps.lock with an env-scoped install_dir"
dir = Dir.mktmpdir("build-container-test-")
diff --git a/test/dev/builtins/install_deps_command_test.rb b/test/dev/builtins/install_deps_command_test.rb
index 133812d..81ecbf2 100644
--- a/test/dev/builtins/install_deps_command_test.rb
+++ b/test/dev/builtins/install_deps_command_test.rb
@@ -25,7 +25,7 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test
test "call provisions the pinned Ruby, installs for the detected env/host, then runs both hygiene hooks" do
Given "a command with every collaborator faked"
root = Pathname.new(Dir.mktmpdir("install-deps-"))
- installer = typed_mock(Dev::Deps::DependencyInstaller)
+ installer = typed_mock(Dev::Deps::Installer)
installer.expects(:install).with(env: Dev::Deps.detect_env, host: Dev::Deps.detect_host).once
linker = typed_mock(Dev::Deps::GemSkillLinker)
linker.expects(:link_all).once
@@ -57,7 +57,7 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test
test "call builds the installer over the project's lockfile and host integrations" do
Given "a factory that records its inputs"
root = Pathname.new(Dir.mktmpdir("install-deps-wiring-"))
- installer = typed_mock(Dev::Deps::DependencyInstaller)
+ installer = typed_mock(Dev::Deps::Installer)
installer.stubs(:install)
factory_inputs = []
command = Dev::Builtins::InstallDepsCommand.new(
@@ -112,7 +112,7 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test
def build_command
Dev::Builtins::InstallDepsCommand.new(
- installer_factory: ->(_lockfile, _integrations) { typed_mock(Dev::Deps::DependencyInstaller) },
+ installer_factory: ->(_lockfile, _integrations) { typed_mock(Dev::Deps::Installer) },
gem_skill_linker_factory: ->(_project_root) { typed_mock(Dev::Deps::GemSkillLinker) },
synchronizer: stub(sync: nil),
)
diff --git a/test/dev/builtins/update_deps_command_test.rb b/test/dev/builtins/update_deps_command_test.rb
index d68cbc1..eea299c 100644
--- a/test/dev/builtins/update_deps_command_test.rb
+++ b/test/dev/builtins/update_deps_command_test.rb
@@ -41,6 +41,32 @@ class Dev::Builtins::UpdateDepsCommandTest < Minitest::Test
FileUtils.rm_rf(root)
end
+ test "call locks each integration's declarations before resolving" do
+ Given "a manifest with a gem declaration, and a locker wired for :bundler"
+ root = Pathname.new(Dir.mktmpdir("update-deps-lock-"))
+ File.write(root / "dependencies.rb", <<~RUBY)
+ require "dev/deps"
+ Dev::Deps.define { gem "rake" }
+ RUBY
+ locker = mock
+ locker.expects(:lock).with { |*args| args.fetch(0).map(&:name) == ["rake"] }
+ Dev::Deps::Registry.expects(:lockers).returns({ bundler: locker })
+ Dev::Deps::Resolver.expects(:new).returns(stub(resolve: []))
+ command = Dev::Builtins::UpdateDepsCommand.new
+ old_stdout = $stdout
+ $stdout = StringIO.new
+
+ When "running update-deps"
+ command.call(args: [], context: build_context(root))
+
+ Then "the locker received the bundler declarations (asserted on the mock)"
+ $stdout.string.include?("lockfiles updated")
+
+ Cleanup
+ $stdout = old_stdout
+ FileUtils.rm_rf(root)
+ end
+
test "call does not mistake a previously loaded project's config for this one" do
Given "a stale config from an earlier load, and a dependencies.rb that never calls Dev::Deps.define"
Dev::Deps.define { ruby "9.9.9" }
diff --git a/test/dev/deps/artifact_test.rb b/test/dev/deps/artifact_test.rb
new file mode 100644
index 0000000..bb6eba4
--- /dev/null
+++ b/test/dev/deps/artifact_test.rb
@@ -0,0 +1,61 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/artifact"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::ArtifactTest < Minitest::Test
+ test "carries the uri and the published digest dev will enforce" do
+ Given "an artifact with a published digest"
+ artifact = Dev::Deps::Artifact.new(uri: "https://example.com/a.zip", digest: "SHA256=abc")
+
+ Expect
+ artifact.uri == "https://example.com/a.zip"
+ artifact.digest == "SHA256=abc"
+ end
+
+ test "a nil digest means upstream publishes none" do
+ Given "an artifact from a service that publishes no digests"
+ artifact = Dev::Deps::Artifact.new(uri: "https://example.com/a.tar.gz")
+
+ Expect "the uri stands and the digest is absent (trust-on-first-use at fetch)"
+ artifact.uri == "https://example.com/a.tar.gz"
+ artifact.digest.nil?
+ end
+
+ test "rejects a payload with no uri" do
+ When "a backing service omits the download location"
+ Dev::Deps::Artifact.new(uri: nil)
+
+ Then
+ raises Dev::Deps::Artifact::MissingUriError
+ end
+
+ test "rejects a payload with a blank uri" do
+ When "a backing service returns an empty download location"
+ Dev::Deps::Artifact.new(uri: "")
+
+ Then
+ raises Dev::Deps::Artifact::MissingUriError
+ end
+
+ test "is value-equal" do
+ Given "two artifacts describing the same bytes"
+ a = Dev::Deps::Artifact.new(uri: "https://example.com/a.zip", digest: "SHA256=abc")
+ b = Dev::Deps::Artifact.new(uri: "https://example.com/a.zip", digest: "SHA256=abc")
+
+ Expect
+ a == b
+ a.hash == b.hash
+ end
+
+ test "the same uri with a different digest is a different artifact" do
+ Given "two artifacts at one uri with differing digests"
+ a = Dev::Deps::Artifact.new(uri: "https://example.com/a.zip", digest: "SHA256=abc")
+ b = Dev::Deps::Artifact.new(uri: "https://example.com/a.zip", digest: "SHA256=def")
+
+ Expect
+ a != b
+ end
+end
diff --git a/test/dev/deps/brew_cask_repository_test.rb b/test/dev/deps/brew_cask_repository_test.rb
new file mode 100644
index 0000000..df1d482
--- /dev/null
+++ b/test/dev/deps/brew_cask_repository_test.rb
@@ -0,0 +1,35 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/brew_cask_repository"
+require "dev/deps/brew_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::BrewCaskRepositoryTest < Minitest::Test
+ test "find reports a cask as one unversioned, undigested, tool-owned entry" do
+ Given "a cask id"
+ repository = Dev::Deps::BrewCaskRepository.new
+
+ When "finding"
+ package = repository.find(Dev::Deps::PackageId.new(integration: :cask, name: "firefox"))
+
+ Then "brew exposes no cask version — an empty version stand-in, brew owns the closure"
+ package.versions.map(&:version) == [Dev::Deps::BrewCaskRepository::UNVERSIONED]
+ package.versions.first.digest.nil?
+ package.versions.first.metadata == { "cask" => true }
+ package.versions.first.declarations == Dev::Deps::Declarations::ToolOwned.new
+ end
+
+ test "a version constraint on a cask is unsatisfiable — the cask name is the coordinate" do
+ Given "a cask's singleton universe (versioned casks are distinct names, e.g. temurin@21)"
+ repository = Dev::Deps::BrewCaskRepository.new
+ package = repository.find(Dev::Deps::PackageId.new(integration: :cask, name: "temurin"))
+
+ When "evaluating a suffix constraint against it"
+ satisfied = Dev::Deps::BrewScheme.new.satisfies?(package.versions.first, { "version" => "21" })
+
+ Then "no suffix fact exists to match — the resolve fails loudly, not silently"
+ satisfied == false
+ end
+end
diff --git a/test/dev/deps/brew_integration_test.rb b/test/dev/deps/brew_integration_test.rb
index 87fa98d..a08de1b 100644
--- a/test/dev/deps/brew_integration_test.rb
+++ b/test/dev/deps/brew_integration_test.rb
@@ -132,6 +132,26 @@ class Dev::Deps::BrewIntegrationTest < Minitest::Test
FileUtils.rm_rf(dir)
end
+ test "install_all registers a remote URL tap with its URL" do
+ Given "an integration with a remote (non-file) tap"
+ dir = Dir.mktmpdir("dev-brew-int-test-")
+ cache = Dev::Deps::Cache.new(cache_dir: dir)
+ tap = Dev::Deps::Tap.new(name: "org/tap", url: "https://github.com/org/homebrew-tap")
+ integration = Dev::Deps::BrewIntegration.new(
+ repository: Dev::Deps::BrewRepository.new, cache: cache, taps: [tap], project_dir: dir,
+ )
+ integration.expects(:system).with("brew", "tap", "org/tap", "https://github.com/org/homebrew-tap").returns(true)
+
+ When "installing all (no deps, taps only)"
+ integration.install_all([])
+
+ Then "brew tap received the URL (expectation verified by Mocha)"
+ true
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
test "resolve_file_url resolves a ./ path against the project dir" do
Given "an integration with a project dir and a project-relative file URI"
dir = Dir.mktmpdir("dev-brew-int-test-")
diff --git a/test/dev/deps/brew_repository_test.rb b/test/dev/deps/brew_repository_test.rb
index fc676bb..0c7487c 100644
--- a/test/dev/deps/brew_repository_test.rb
+++ b/test/dev/deps/brew_repository_test.rb
@@ -3,108 +3,91 @@
require "test_helper"
require "dev/deps/brew_repository"
-require "dev/deps/cache"
-require "tmpdir"
require "json"
transform!(RSpock::AST::Transformation)
class Dev::Deps::BrewRepositoryTest < Minitest::Test
- test "fetch parses brew info JSON and returns a Dependency" do
- Given "a brew formula identifier"
- repository = Dev::Deps::BrewRepository.new
- brew_json = [{
- "name" => "cmake",
- "versions" => { "stable" => "3.31.4" },
- "bottle" => {
- "stable" => {
- "files" => {
- "arm64_sonoma" => { "sha256" => "abc123def456" },
- },
- },
- },
- }].to_json
+ def formula_json(name, stable:, sha: nil, versioned: [])
+ json = { "name" => name, "versions" => { "stable" => stable }, "versioned_formulae" => versioned }
+ if sha
+ json["bottle"] = { "stable" => { "files" => { "arm64_sonoma" => { "sha256" => sha } } } }
+ end
+ json
+ end
+ def stub_brew_info(specs, infos)
Open3.stubs(:capture3)
- .with("brew", "info", "--json=v1", "cmake")
- .returns([brew_json, "", stub(success?: true)])
-
- When "fetching the dependency"
- dep = repository.fetch(
- "name" => "cmake",
- "integration" => "brew",
- "group" => "build",
- )
+ .with("brew", "info", "--json=v1", *specs)
+ .returns([JSON.generate(infos), "", stub(success?: true)])
+ end
- Then
- dep.name == "cmake"
- dep.integration == :brew
- dep.group == :build
- dep.version == "3.31.4"
- dep.hash == "SHA256=abc123def456"
+ test "find reports a family-less formula as a singleton universe" do
+ Given "a formula with no versioned siblings"
+ repository = Dev::Deps::BrewRepository.new
+ stub_brew_info(["cmake"], [formula_json("cmake", stable: "3.31.4", sha: "abc123def456")])
+
+ When "finding the package"
+ package = repository.find(Dev::Deps::PackageId.new(integration: :brew, name: "cmake"))
+
+ Then "one version, carrying the bottle digest"
+ package.versions.map(&:version) == ["3.31.4"]
+ package.version("3.31.4").digest == "SHA256=abc123def456"
+ package.version("3.31.4").metadata == {}
end
- test "fetch treats declared version as a formula suffix and records the resolved version" do
- Given "a formula declared with a version suffix"
+ test "find enumerates the spec family — siblings' suffixes ride as facts, bare spec last" do
+ Given "llvm with two versioned siblings"
repository = Dev::Deps::BrewRepository.new
- brew_json = [{
- "name" => "llvm@18",
- "versions" => { "stable" => "18.1.8" },
- "bottle" => { "stable" => { "files" => { "arm64_sonoma" => { "sha256" => "llvm18" } } } },
- }].to_json
+ stub_brew_info(["llvm"],
+ [formula_json("llvm", stable: "21.1.0", sha: "llvm21", versioned: ["llvm@19", "llvm@18"])])
+ stub_brew_info(["llvm@19", "llvm@18"], [
+ formula_json("llvm@19", stable: "19.1.7", sha: "llvm19"),
+ formula_json("llvm@18", stable: "18.1.8", sha: "llvm18"),
+ ])
+
+ When "finding"
+ package = repository.find(Dev::Deps::PackageId.new(integration: :brew, name: "llvm"))
+
+ Then "one version per spec; the bare spec sits last as the unconstrained pick"
+ package.versions.map(&:version) == ["19.1.7", "18.1.8", "21.1.0"]
+ package.version("18.1.8").metadata == { "version_suffix" => "18" }
+ package.version("18.1.8").digest == "SHA256=llvm18"
+ package.version("21.1.0").metadata == {}
+ end
- Open3.stubs(:capture3)
- .with("brew", "info", "--json=v1", "llvm@18")
- .returns([brew_json, "", stub(success?: true)])
-
- When "fetching with version: 18"
- dep = repository.fetch(
- "name" => "llvm",
- "integration" => "brew",
- "group" => "build",
- "version" => "18",
- )
+ test "find skips head-only siblings without a stable version" do
+ Given "a family whose sibling reports no stable version"
+ repository = Dev::Deps::BrewRepository.new
+ stub_brew_info(["tool"], [formula_json("tool", stable: "2.0.0", versioned: ["tool@head"])])
+ stub_brew_info(["tool@head"], [formula_json("tool@head", stable: nil)])
+
+ When "finding"
+ package = repository.find(Dev::Deps::PackageId.new(integration: :brew, name: "tool"))
- Then "the resolved version is recorded and the suffix is kept for install"
- dep.name == "llvm"
- dep.version == "18.1.8"
- dep.hash == "SHA256=llvm18"
- dep.metadata["version_suffix"] == "18"
+ Then "no stable version means not a version"
+ package.versions.map(&:version) == ["2.0.0"]
end
- test "fetch includes tap in metadata when specified" do
- Given "a tapped formula identifier"
+ test "find qualifies family queries with the tap and records it as a fact" do
+ Given "a tapped formula"
repository = Dev::Deps::BrewRepository.new
- brew_json = [{
- "name" => "powershell",
- "versions" => { "stable" => "7.4.0" },
- "bottle" => { "stable" => { "files" => { "arm64_sonoma" => { "sha256" => "ps123" } } } },
- }].to_json
+ stub_brew_info(["someorg/sometap/mytool"],
+ [formula_json("mytool", stable: "1.2.0", sha: "mt12")])
- Open3.stubs(:capture3)
- .with("brew", "info", "--json=v1", "d3mlabs/d3mlabs/powershell")
- .returns([brew_json, "", stub(success?: true)])
-
- When "fetching with a tap"
- dep = repository.fetch(
- "name" => "powershell",
- "integration" => "brew",
- "group" => "build",
- "tap" => "d3mlabs/d3mlabs",
+ When "finding with the tap on the id"
+ package = repository.find(
+ Dev::Deps::PackageId.new(integration: :brew, name: "mytool", source: "someorg/sometap"),
)
Then
- dep.name == "powershell"
- dep.metadata["tap"] == "d3mlabs/d3mlabs"
+ package.versions.map(&:version) == ["1.2.0"]
+ package.version("1.2.0").metadata == { "tap" => "someorg/sometap" }
end
- test "fetch registers the declared tap and retries when brew info fails untapped" do
+ test "find registers the declared tap and retries when brew info fails untapped" do
Given "a tapped formula on a machine that has never tapped it"
repository = Dev::Deps::BrewRepository.new
- brew_json = [{
- "name" => "xcodes",
- "versions" => { "stable" => "1.6.2" },
- "bottle" => { "stable" => { "files" => { "arm64_sonoma" => { "sha256" => "xc123" } } } },
- }].to_json
+ brew_json = JSON.generate([formula_json("xcodes", stable: "1.6.2", sha: "xc123")])
Open3.stubs(:capture3)
.with("brew", "info", "--json=v1", "xcodesorg/made/xcodes")
@@ -114,52 +97,25 @@ class Dev::Deps::BrewRepositoryTest < Minitest::Test
.with("brew", "tap", "xcodesorg/made")
.returns(["", "", stub(success?: true)])
- When "fetching with the tap"
- dep = repository.fetch(
- "name" => "xcodes",
- "integration" => "brew",
- "group" => "build",
- "tap" => "xcodesorg/made",
+ When "finding with the tap on the id"
+ package = repository.find(
+ Dev::Deps::PackageId.new(integration: :brew, name: "xcodes", source: "xcodesorg/made"),
)
Then "the tap was registered and resolution succeeded on retry"
- dep.version == "1.6.2"
- dep.metadata["tap"] == "xcodesorg/made"
+ package.versions.map(&:version) == ["1.6.2"]
+ package.version("1.6.2").metadata["tap"] == "xcodesorg/made"
end
- test "fetch handles cask entries (no hash)" do
- Given "a cask identifier"
- repository = Dev::Deps::BrewRepository.new
-
- When "fetching a cask"
- dep = repository.fetch(
- "name" => "powershell",
- "integration" => "brew",
- "group" => "build",
- "cask" => true,
- )
-
- Then
- dep.name == "powershell"
- dep.version.nil?
- dep.hash.nil?
- dep.metadata["cask"] == true
- end
-
- test "fetch raises BrewInfoError when brew info fails" do
+ test "find raises BrewInfoError when brew info fails" do
Given "a formula that brew info cannot resolve"
repository = Dev::Deps::BrewRepository.new
- failed_status = stub(success?: false)
Open3.stubs(:capture3)
.with("brew", "info", "--json=v1", "nonexistent")
- .returns(["", "Error: No available formula", failed_status])
+ .returns(["", "Error: No available formula", stub(success?: false)])
- When "fetching the dependency"
- repository.fetch(
- "name" => "nonexistent",
- "integration" => "brew",
- "group" => "build",
- )
+ When "finding the package"
+ repository.find(Dev::Deps::PackageId.new(integration: :brew, name: "nonexistent"))
Then
raises Dev::Deps::BrewRepository::BrewInfoError
diff --git a/test/dev/deps/brew_scheme_test.rb b/test/dev/deps/brew_scheme_test.rb
new file mode 100644
index 0000000..15c27fd
--- /dev/null
+++ b/test/dev/deps/brew_scheme_test.rb
@@ -0,0 +1,46 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/brew_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::BrewSchemeTest < Minitest::Test
+ def scheme
+ Dev::Deps::BrewScheme.new
+ end
+
+ def pv(version, suffix: nil)
+ metadata = suffix ? { "version_suffix" => suffix } : {}
+ Dev::Deps::PackageVersion.new(version: version, metadata: metadata)
+ end
+
+ test "the version constraint is a formula suffix matched against the suffix fact" do
+ When "evaluating suffixed and unsuffixed candidates"
+ match = scheme.satisfies?(pv("18.1.8", suffix: "18"), { "version" => "18" })
+ miss = scheme.satisfies?(pv("19.1.0", suffix: "19"), { "version" => "18" })
+ unsuffixed_miss = scheme.satisfies?(pv("20.0.1"), { "version" => "18" })
+
+ Then "the reported stable version is brew's record, never the coordinate"
+ match == true
+ miss == false
+ unsuffixed_miss == false
+ end
+
+ test "no suffix constraint satisfies anything" do
+ When "evaluating an unconstrained declaration"
+ result = scheme.satisfies?(pv("3.31.4"), {})
+
+ Then
+ result == true
+ end
+
+ test "sort preserves order — one current version per formula spec" do
+ When "sorting"
+ sorted = scheme.sort(["b", "a"])
+
+ Then
+ sorted == ["b", "a"]
+ end
+end
diff --git a/test/dev/deps/bundler_locker_test.rb b/test/dev/deps/bundler_locker_test.rb
new file mode 100644
index 0000000..c35ff73
--- /dev/null
+++ b/test/dev/deps/bundler_locker_test.rb
@@ -0,0 +1,74 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps"
+require "dev/deps/bundler_locker"
+require "open3"
+require "tmpdir"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::BundlerLockerTest < Minitest::Test
+ def bundler_declarations(&block)
+ Dev::Deps.define(&block).declarations.select { |d| d.integration == :bundler }
+ end
+
+ test "lock generates a Gemfile mapping dev groups to bundler groups" do
+ Given "gem declarations in the default and test groups"
+ dir = Dir.mktmpdir("dev-bundler-locker-test-")
+ locker = Dev::Deps::BundlerLocker.new(project_root: dir, ruby_version_requirement: "~> 4.0")
+ decls = bundler_declarations do
+ gem "ffi", "~> 1.17"
+ group :test do
+ gem "minitest", "~> 5.0", require: false
+ end
+ end
+ Open3.stubs(:capture3).returns(["", "", stub(success?: true)])
+
+ When "locking the declaration set"
+ locker.lock(decls)
+ gemfile = (Pathname(dir) / "Gemfile").read
+
+ Then "the Gemfile pins the source, ruby, default gem, and grouped gem with options"
+ gemfile.include?(%(source "https://rubygems.org"))
+ gemfile.include?(%(ruby "~> 4.0"))
+ gemfile.include?(%(gem "ffi", "~> 1.17"))
+ gemfile.include?("group :test do")
+ gemfile.include?(%( gem "minitest", "~> 5.0", require: false))
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
+ test "lock is a no-op for an empty declaration set" do
+ Given "no bundler declarations"
+ dir = Dir.mktmpdir("dev-bundler-locker-test-")
+ locker = Dev::Deps::BundlerLocker.new(project_root: dir)
+ Open3.stubs(:capture3).raises("bundle lock should not run")
+
+ When "locking"
+ locker.lock([])
+
+ Then "no Gemfile is written"
+ !(Pathname(dir) / "Gemfile").exist?
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
+ test "lock raises LockError when bundle lock fails" do
+ Given "a bundle lock that will fail"
+ dir = Dir.mktmpdir("dev-bundler-locker-test-")
+ locker = Dev::Deps::BundlerLocker.new(project_root: dir)
+ Open3.stubs(:capture3).returns(["", "could not resolve", stub(success?: false)])
+
+ When "locking"
+ locker.lock(bundler_declarations { gem "ffi" })
+
+ Then
+ raises Dev::Deps::BundlerLocker::LockError
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+end
diff --git a/test/dev/deps/bundler_repository_test.rb b/test/dev/deps/bundler_repository_test.rb
index 955ff41..9edd089 100644
--- a/test/dev/deps/bundler_repository_test.rb
+++ b/test/dev/deps/bundler_repository_test.rb
@@ -2,10 +2,7 @@
# frozen_string_literal: true
require "test_helper"
-require "dev/deps"
require "dev/deps/bundler_repository"
-require "dev/deps/dependency_declaration"
-require "open3"
require "tmpdir"
transform!(RSpock::AST::Transformation)
@@ -32,93 +29,35 @@ class Dev::Deps::BundlerRepositoryTest < Minitest::Test
2.5.0
LOCK
- def bundler_declarations(&block)
- Dev::Deps.define(&block).declarations.select { |d| d.integration == :bundler }
- end
-
- test "prepare generates a Gemfile mapping dev groups to bundler groups" do
- Given "gem declarations in the default and test groups"
- dir = Dir.mktmpdir("dev-bundler-repo-test-")
- (Pathname(dir) / "Gemfile.lock").write(LOCKFILE_FIXTURE)
- repo = Dev::Deps::BundlerRepository.new(project_root: dir, ruby_version_requirement: "~> 4.0")
- decls = bundler_declarations do
- gem "ffi", "~> 1.17"
- group :test do
- gem "minitest", "~> 5.0"
- end
- end
- Open3.stubs(:capture3).returns(["", "", stub(success?: true)])
-
- When "preparing the repository"
- repo.prepare(decls)
- gemfile = (Pathname(dir) / "Gemfile").read
-
- Then "the Gemfile pins the source, ruby, default gem, and grouped gem"
- gemfile.include?(%(source "https://rubygems.org"))
- gemfile.include?(%(ruby "~> 4.0"))
- gemfile.include?(%(gem "ffi", "~> 1.17"))
- gemfile.include?("group :test do")
- gemfile.include?(%( gem "minitest", "~> 5.0"))
-
- Cleanup
- FileUtils.rm_rf(dir)
- end
-
- test "fetch returns the locked version and checksum for a declared gem" do
- Given "a prepared repository"
+ test "find reports the locked pin as a singleton universe" do
+ Given "a project with a materialized Gemfile.lock"
dir = Dir.mktmpdir("dev-bundler-repo-test-")
(Pathname(dir) / "Gemfile.lock").write(LOCKFILE_FIXTURE)
repo = Dev::Deps::BundlerRepository.new(project_root: dir)
- decls = bundler_declarations { gem "ffi", "~> 1.17" }
- Open3.stubs(:capture3).returns(["", "", stub(success?: true)])
- repo.prepare(decls)
- When "fetching the declared gem"
- dep = repo.fetch("name" => "ffi", "integration" => "bundler", "group" => "app")
+ When "finding a declared gem"
+ package = repo.find(Dev::Deps::PackageId.new(integration: :bundler, name: "ffi"))
- Then "it carries the pinned version and checksum from the lockfile"
- dep.name == "ffi"
- dep.integration == :bundler
- dep.version == "1.17.0"
- dep.hash == "SHA256=aaa111"
+ Then "one version — the joint solve's choice — with the CHECKSUMS digest"
+ package.versions.map(&:version) == ["1.17.0"]
+ package.version("1.17.0").digest == "SHA256=aaa111"
+ package.version("1.17.0").declarations == Dev::Deps::Declarations::ToolOwned.new
Cleanup
FileUtils.rm_rf(dir)
end
- test "fetch raises when the gem is missing from the lockfile" do
- Given "a prepared repository whose lockfile lacks the gem"
+ test "find raises MissingGemError, a PackageNotFoundError, for unlocked gems" do
+ Given "a lockfile lacking the gem"
dir = Dir.mktmpdir("dev-bundler-repo-test-")
(Pathname(dir) / "Gemfile.lock").write(LOCKFILE_FIXTURE)
repo = Dev::Deps::BundlerRepository.new(project_root: dir)
- Open3.stubs(:capture3).returns(["", "", stub(success?: true)])
- repo.prepare(bundler_declarations { gem "ffi" })
-
- When "fetching an undeclared gem"
- error = assert_raises(Dev::Deps::BundlerRepository::MissingGemError) do
- repo.fetch("name" => "absent", "integration" => "bundler", "group" => "app")
- end
-
- Then "the error names the missing gem"
- error.message.include?("absent")
-
- Cleanup
- FileUtils.rm_rf(dir)
- end
-
- test "prepare raises LockError when bundle lock fails" do
- Given "a repository whose bundle lock will fail"
- dir = Dir.mktmpdir("dev-bundler-repo-test-")
- repo = Dev::Deps::BundlerRepository.new(project_root: dir)
- Open3.stubs(:capture3).returns(["", "could not resolve", stub(success?: false)])
- When "preparing the repository"
- error = assert_raises(Dev::Deps::BundlerRepository::LockError) do
- repo.prepare(bundler_declarations { gem "ffi" })
- end
+ When "finding an unlocked gem"
+ repo.find(Dev::Deps::PackageId.new(integration: :bundler, name: "absent"))
- Then "the error surfaces the bundler failure"
- error.message.include?("bundle lock failed")
+ Then
+ raises Dev::Deps::Repository::PackageNotFoundError
Cleanup
FileUtils.rm_rf(dir)
diff --git a/test/dev/deps/cli_ui_test.rb b/test/dev/deps/cli_ui_test.rb
new file mode 100644
index 0000000..de5bf49
--- /dev/null
+++ b/test/dev/deps/cli_ui_test.rb
@@ -0,0 +1,20 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/cli_ui"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::CliUITest < Minitest::Test
+ test "available? probes once and memoizes the answer" do
+ Given "the availability probe has run once"
+ first = Dev::Deps::CliUI.available?
+
+ When "asking again"
+ second = Dev::Deps::CliUI.available?
+
+ Then "the memoized boolean is returned"
+ second == first
+ [true, false].include?(second)
+ end
+end
diff --git a/test/dev/deps/cmake_integration_test.rb b/test/dev/deps/cmake_integration_test.rb
index ba224f0..68eaff9 100644
--- a/test/dev/deps/cmake_integration_test.rb
+++ b/test/dev/deps/cmake_integration_test.rb
@@ -6,20 +6,25 @@
require "dev/deps/git_repository"
require "dev/deps/url_repository"
require "dev/deps/resolver"
-require "dev/deps/dependency_declaration"
+require "dev/deps/declaration"
+require "dev/deps/scope"
+require "dev/deps/scoped_declaration"
require "dev/deps/cache"
require "dev/deps/dependency"
+require "dev/deps/package"
+require "dev/deps/package_version"
+require "dev/deps/git_scheme"
require "pathname"
require "tmpdir"
-# Stub repository for end-to-end resolver tests.
+# Stub repository for end-to-end resolver tests: name -> [PackageVersion, ...].
class StubRepository < Dev::Deps::Repository
- def initialize(deps_by_name: {})
- @deps_by_name = deps_by_name
+ def initialize(universes: {})
+ @universes = universes
end
- def fetch(id)
- @deps_by_name.fetch(id["name"])
+ def find(id)
+ Dev::Deps::Package.new(id: id, versions: @universes.fetch(id.name))
end
end unless defined?(StubRepository)
@@ -324,17 +329,22 @@ def prepopulate_dep(root, name)
hook_calls = []
hook = ->(dep, root) { hook_calls << { name: dep.name, version: dep.version, root: root.to_s } }
- fetched = Dev::Deps::Dependency.new(
- name: "googletest", integration: :cmake, group: :test,
- version: "sha1", hash: nil,
+ universe = Dev::Deps::PackageVersion.new(
+ version: "sha1",
metadata: { "repo" => "https://github.com/google/googletest" },
)
- stub_repo = StubRepository.new(deps_by_name: { "googletest" => fetched })
- resolver = Dev::Deps::Resolver.new(repositories: { cmake: stub_repo })
+ stub_repo = StubRepository.new(universes: { "googletest" => [universe] })
+ resolver = Dev::Deps::Resolver.new(
+ repositories: { cmake: stub_repo },
+ schemes: { cmake: Dev::Deps::GitScheme.new },
+ )
declarations = [
- Dev::Deps::DependencyDeclaration.new(
- name: "googletest", integration: :cmake, group: :test,
- constraint: { "repo" => "https://github.com/google/googletest" },
+ Dev::Deps::ScopedDeclaration.new(
+ declaration: Dev::Deps::Declaration.new(
+ name: "googletest", integration: :cmake,
+ source: "https://github.com/google/googletest",
+ ),
+ scope: Dev::Deps::Scope.new(group: :test),
post_install: hook,
),
]
diff --git a/test/dev/deps/config_test.rb b/test/dev/deps/config_test.rb
index bd3ca1e..a66dd20 100644
--- a/test/dev/deps/config_test.rb
+++ b/test/dev/deps/config_test.rb
@@ -25,7 +25,7 @@ class Dev::Deps::ConfigTest < Minitest::Test
gems = config.declarations.select { |d| d.integration == :bundler }
gems.size == 2
gems[0].name == "cli-ui"
- gems[0].group == Dev::Deps::DSL::DEFAULT_GEM_GROUP
+ gems[0].scope.group == Dev::Deps::DSL::DEFAULT_GEM_GROUP
!gems[0].constraint.key?("version")
gems[1].name == "rake"
gems[1].constraint["version"] == "~> 13.0"
@@ -42,7 +42,7 @@ class Dev::Deps::ConfigTest < Minitest::Test
Then
decl = config.declarations.find { |d| d.name == "minitest" }
decl.integration == :bundler
- decl.group == :test
+ decl.scope.group == :test
decl.constraint["version"] == "~> 5.0"
end
@@ -129,22 +129,25 @@ class Dev::Deps::ConfigTest < Minitest::Test
end
end
- Then
- decls = config.declarations.select { |d| d.group == :app }
+ Then "the url dep rides :url with the tag as a label; the git dep rides :cmake"
+ decls = config.declarations.select { |d| d.scope.group == :app }
decls.size == 2
decls[0].name == "boost"
- decls[0].constraint["url"] == "https://example.com/boost.tar.gz"
- decls[0].constraint["tag"] == "boost-1.90.0"
- decls[0].constraint["cmake_targets"] == ["stacktrace"]
- decls[0].constraint["cmake_namespace"] == "Boost::"
+ decls[0].integration == :url
+ decls[0].source == "https://example.com/boost.tar.gz"
+ decls[0].constraint == {}
+ decls[0].materialization["version_label"] == "boost-1.90.0"
+ decls[0].materialization["cmake_targets"] == ["stacktrace"]
+ decls[0].materialization["cmake_namespace"] == "Boost::"
decls[1].name == "cereal"
- decls[1].constraint["repo"] == "https://github.com/USCiLab/cereal"
+ decls[1].integration == :cmake
+ decls[1].source == "https://github.com/USCiLab/cereal"
decls[1].constraint["tag"] == "v1.3.2"
end
- test "define test group with cmake_targets" do
+ test "define test group with cmake_targets riding materialization" do
When
config = Dev::Deps.define do
group :test do
@@ -155,9 +158,10 @@ class Dev::Deps::ConfigTest < Minitest::Test
end
end
- Then
+ Then "install instructions never pollute the constraint"
decl = config.declarations.find { |d| d.name == "googletest" }
- decl.constraint["cmake_targets"] == ["gtest", "gmock"]
+ decl.materialization["cmake_targets"] == ["gtest", "gmock"]
+ decl.constraint == { "tag" => "v1.17.0" }
end
test "missing group returns empty defaults" do
@@ -170,19 +174,20 @@ class Dev::Deps::ConfigTest < Minitest::Test
nonexistent["env"] == {}
end
- test "cmake dep with commit pin" do
+ test "cmake dep with commit pin lands as the declaration's revision" do
When
config = Dev::Deps.define do
group :app do
cmake "entityx",
repo: "https://github.com/alecthomas/entityx",
- commit: "ee3042f8b027"
+ commit: "ee3042f8b0279856061f91069a487e4ed6f69475"
end
end
- Then
+ Then "the address rides the revision slot, never the constraint"
decl = config.declarations.find { |d| d.name == "entityx" }
- decl.constraint["commit"] == "ee3042f8b027"
+ decl.revision == "ee3042f8b0279856061f91069a487e4ed6f69475"
+ decl.constraint == {}
end
test "brew dual-writes to both groups and declarations" do
@@ -202,10 +207,10 @@ class Dev::Deps::ConfigTest < Minitest::Test
config.group("build")["env"]["ci"]["brew"] == ["ruby"]
brew_decls = config.declarations.select { |d| d.integration == :brew }
brew_decls.map(&:name).sort == %w[cmake powershell ruby]
- brew_decls.all? { |d| d.group == :build }
- brew_decls.find { |d| d.name == "powershell" }.constraint["tap"] == "d3mlabs/d3mlabs"
+ brew_decls.all? { |d| d.scope.group == :build }
+ brew_decls.find { |d| d.name == "powershell" }.source == "d3mlabs/d3mlabs"
# env is a first-class declaration field, never smuggled into the constraint.
- brew_decls.find { |d| d.name == "ruby" }.env == "ci"
+ brew_decls.find { |d| d.name == "ruby" }.scope.env == "ci"
brew_decls.find { |d| d.name == "ruby" }.constraint["env"].nil?
end
end
diff --git a/test/dev/deps/declaration_test.rb b/test/dev/deps/declaration_test.rb
new file mode 100644
index 0000000..331f1c4
--- /dev/null
+++ b/test/dev/deps/declaration_test.rb
@@ -0,0 +1,131 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/declaration"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::DeclarationTest < Minitest::Test
+ test "states a package under a constraint in dev's shape" do
+ Given "a declaration with an explicit constraint hash"
+ decl = Dev::Deps::Declaration.new(
+ name: "SML", integration: :ficsit, constraint: { "version" => "^3.6.0" },
+ )
+
+ Expect "the atom's three facts are readable"
+ decl.name == "SML"
+ decl.integration == :ficsit
+ decl.constraint == { "version" => "^3.6.0" }
+ end
+
+ test "unconstrained is the empty hash, a present empty form" do
+ Given "a declaration without a constraint"
+ decl = Dev::Deps::Declaration.new(name: "boost", integration: :cmake)
+
+ Expect "the constraint is {} — never nil"
+ decl.constraint == {}
+ end
+
+ test "is value-equal and hash-stable" do
+ Given "two declarations built independently from the same facts"
+ a = Dev::Deps::Declaration.new(name: "ffi", integration: :bundler, constraint: { "version" => "~> 1.17" })
+ b = Dev::Deps::Declaration.new(name: "ffi", integration: :bundler, constraint: { "version" => "~> 1.17" })
+
+ Expect "they are equal and collapse to one hash key"
+ a == b
+ { a => 1 }.key?(b)
+ end
+
+ test "the same name under different integrations is a different declaration" do
+ Given "'ffi' declared against bundler and against pip"
+ gem_decl = Dev::Deps::Declaration.new(name: "ffi", integration: :bundler)
+ pip_decl = Dev::Deps::Declaration.new(name: "ffi", integration: :pip)
+
+ Expect
+ gem_decl != pip_decl
+ end
+
+ test "is frozen, constraint included" do
+ Given "a declaration"
+ decl = Dev::Deps::Declaration.new(name: "boost", integration: :cmake, constraint: { "tag" => "1.0" })
+
+ Expect "the value and its constraint resist mutation"
+ decl.frozen?
+ decl.constraint.frozen?
+ end
+
+ test "carries a source coordinate as an identity field, not a constraint key" do
+ Given "a source-based declaration"
+ decl = Dev::Deps::Declaration.new(
+ name: "fmt", integration: :cmake,
+ constraint: { "tag" => "11.0.2" }, source: "https://github.com/fmtlib/fmt",
+ )
+
+ Expect "source is a field and the constraint holds only version-shaped keys"
+ decl.source == "https://github.com/fmtlib/fmt"
+ decl.constraint == { "tag" => "11.0.2" }
+ end
+
+ test "source defaults to nil for registry-backed packages" do
+ Given "a registry-backed declaration"
+ decl = Dev::Deps::Declaration.new(name: "ffi", integration: :bundler)
+
+ Expect
+ decl.source.nil?
+ end
+
+ test "source participates in equality — same name, different universe, different declaration" do
+ Given "one name declared against two source coordinates"
+ a = Dev::Deps::Declaration.new(name: "fmt", integration: :cmake, source: "https://github.com/fmtlib/fmt")
+ b = Dev::Deps::Declaration.new(name: "fmt", integration: :cmake, source: "https://github.com/fork/fmt")
+
+ Expect
+ a != b
+ a.hash != b.hash
+ end
+
+ test "carries a revision as an address, not a constraint key" do
+ Given "a declaration pinning an addressable revision"
+ decl = Dev::Deps::Declaration.new(
+ name: "opencell", integration: :cmake,
+ source: "https://github.com/d3mlabs/opencell",
+ revision: "ee3042f8b0279856061f91069a487e4ed6f69475",
+ )
+
+ Expect "the revision is a field and the constraint stays empty"
+ decl.revision == "ee3042f8b0279856061f91069a487e4ed6f69475"
+ decl.constraint == {}
+ end
+
+ test "revision defaults to nil — most asks select over a universe" do
+ Given "a constraint-shaped declaration"
+ decl = Dev::Deps::Declaration.new(name: "googletest", integration: :cmake, constraint: { "tag" => "v1.17.0" })
+
+ Expect
+ decl.revision.nil?
+ end
+
+ test "revision participates in equality — two addresses are two asks" do
+ Given "one name pinned at two revisions"
+ a = Dev::Deps::Declaration.new(name: "opencell", integration: :cmake, revision: "a" * 40)
+ b = Dev::Deps::Declaration.new(name: "opencell", integration: :cmake, revision: "b" * 40)
+
+ Expect
+ a != b
+ a.hash != b.hash
+ end
+
+ test "a revision alongside version constraints is a contradiction, rejected loudly" do
+ When "declaring both an address and a selection"
+ act = lambda do
+ Dev::Deps::Declaration.new(
+ name: "opencell", integration: :cmake,
+ constraint: { "tag" => "v1.0" }, revision: "a" * 40,
+ )
+ end
+
+ Then "the atom refuses: an address forgoes resolution, a constraint asks for it"
+ error = assert_raises(Dev::Deps::Declaration::RevisionWithConstraintError) { act.call }
+ error.message.include?("opencell")
+ end
+end
diff --git a/test/dev/deps/declarations_test.rb b/test/dev/deps/declarations_test.rb
new file mode 100644
index 0000000..f5ba976
--- /dev/null
+++ b/test/dev/deps/declarations_test.rb
@@ -0,0 +1,98 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/declaration"
+require "dev/deps/declarations"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::DeclarationsTest < Minitest::Test
+ def atom(name)
+ Dev::Deps::Declaration.new(name: name, integration: :ficsit, constraint: { "version" => "^1.0" })
+ end
+
+ test "Resolved carries the declared dependency list" do
+ Given "a resolved claim over two declarations"
+ resolved = Dev::Deps::Declarations::Resolved.new([atom("SML"), atom("AreaActions")])
+
+ Expect
+ resolved.declarations == [atom("SML"), atom("AreaActions")]
+ end
+
+ test "Resolved([]) is an affirmative claim of requiring nothing" do
+ Given "a resolved claim with no declarations"
+ resolved = Dev::Deps::Declarations::Resolved.new([])
+
+ Expect "the list is present and empty — distinct from ToolOwned"
+ resolved.declarations == []
+ !resolved.is_a?(Dev::Deps::Declarations::ToolOwned)
+ end
+
+ test "Resolved is value-equal and hash-stable" do
+ Given "two claims built independently from the same declarations"
+ a = Dev::Deps::Declarations::Resolved.new([atom("SML")])
+ b = Dev::Deps::Declarations::Resolved.new([atom("SML")])
+
+ Expect
+ a == b
+ { a => 1 }.key?(b)
+ end
+
+ test "Resolved claims over different declarations are not equal" do
+ Given
+ a = Dev::Deps::Declarations::Resolved.new([atom("SML")])
+ b = Dev::Deps::Declarations::Resolved.new([atom("AreaActions")])
+
+ Expect
+ a != b
+ end
+
+ test "Resolved freezes its list and shrugs off caller mutation" do
+ Given "a claim built from a mutable array"
+ atoms = [atom("SML")]
+ resolved = Dev::Deps::Declarations::Resolved.new(atoms)
+
+ When "the caller mutates its own array afterwards"
+ atoms << atom("AreaActions")
+
+ Then
+ resolved.declarations.frozen?
+ resolved.declarations == [atom("SML")]
+ end
+
+ test "ToolOwned instances are value-equal and hash-stable" do
+ Given "two independently built tool-owned claims"
+ a = Dev::Deps::Declarations::ToolOwned.new
+ b = Dev::Deps::Declarations::ToolOwned.new
+
+ Expect
+ a == b
+ { a => 1 }.key?(b)
+ end
+
+ test "ToolOwned never equals Resolved, even an empty one" do
+ Given
+ tool_owned = Dev::Deps::Declarations::ToolOwned.new
+ empty = Dev::Deps::Declarations::Resolved.new([])
+
+ Expect
+ tool_owned != empty
+ empty != tool_owned
+ end
+
+ test "variants discriminate by class in a case expression" do
+ Given "one claim of each variant"
+ claims = [Dev::Deps::Declarations::Resolved.new([atom("SML")]), Dev::Deps::Declarations::ToolOwned.new]
+
+ When "casing on the variant"
+ kinds = claims.map do |claim|
+ case claim
+ when Dev::Deps::Declarations::Resolved then :resolved
+ when Dev::Deps::Declarations::ToolOwned then :tool_owned
+ end
+ end
+
+ Then
+ kinds == [:resolved, :tool_owned]
+ end
+end
diff --git a/test/dev/deps/dsl_test.rb b/test/dev/deps/dsl_test.rb
index 5eb149a..288997e 100644
--- a/test/dev/deps/dsl_test.rb
+++ b/test/dev/deps/dsl_test.rb
@@ -6,8 +6,8 @@
transform!(RSpock::AST::Transformation)
class Dev::Deps::DSLTest < Minitest::Test
- test "cmake() produces DependencyDeclaration with cmake integration" do
- When "defining a cmake dep"
+ test "cmake url: routes to the :url integration — the URL is the whole address" do
+ When "defining a url-backed cmake dep"
config = Dev::Deps.define do
group :app do
cmake "boost",
@@ -16,14 +16,15 @@ class Dev::Deps::DSLTest < Minitest::Test
end
end
- Then
+ Then "the url is the source; the tag is a display label, never a constraint"
decls = config.declarations
decls.size == 1
decls[0].name == "boost"
- decls[0].integration == :cmake
- decls[0].group == :app
- decls[0].constraint["url"] == "https://example.com/boost.tar.gz"
- decls[0].constraint["tag"] == "boost-1.90.0"
+ decls[0].integration == :url
+ decls[0].scope.group == :app
+ decls[0].source == "https://example.com/boost.tar.gz"
+ decls[0].constraint == {}
+ decls[0].materialization["version_label"] == "boost-1.90.0"
end
test "github: shorthand expands org/repo to full URL" do
@@ -34,10 +35,11 @@ class Dev::Deps::DSLTest < Minitest::Test
end
end
- Then
+ Then "the expanded URL lands as the source, not a constraint key"
decl = config.declarations[0]
- decl.constraint["repo"] == "https://github.com/USCiLab/cereal"
+ decl.source == "https://github.com/USCiLab/cereal"
!decl.constraint.key?("github")
+ !decl.constraint.key?("repo")
end
test "github: shorthand with org only appends dep name" do
@@ -49,10 +51,10 @@ class Dev::Deps::DSLTest < Minitest::Test
end
Then
- config.declarations[0].constraint["repo"] == "https://github.com/axmolengine/axmol"
+ config.declarations[0].source == "https://github.com/axmolengine/axmol"
end
- test "luarocks() produces DependencyDeclaration with luarocks integration" do
+ test "luarocks() produces ScopedDeclaration with luarocks integration" do
When "defining a luarocks dep"
config = Dev::Deps.define do
group :test do
@@ -64,11 +66,11 @@ class Dev::Deps::DSLTest < Minitest::Test
decl = config.declarations[0]
decl.name == "luaunit"
decl.integration == :luarocks
- decl.group == :test
+ decl.scope.group == :test
decl.constraint["constraint"] == ">=3.5"
end
- test "custom() produces DependencyDeclaration with arbitrary integration" do
+ test "custom() produces ScopedDeclaration with arbitrary integration" do
When "defining a custom integration dep"
config = Dev::Deps.define do
group :app do
@@ -110,7 +112,7 @@ class Dev::Deps::DSLTest < Minitest::Test
config.registered_integrations[:wow_curseforge] == "WoWCurseforgeIntegration"
end
- test "ficsit() produces DependencyDeclaration with ficsit integration" do
+ test "ficsit() produces ScopedDeclaration with ficsit integration" do
When "defining a ficsit mod dep"
config = Dev::Deps.define do
group :app do
@@ -122,7 +124,7 @@ class Dev::Deps::DSLTest < Minitest::Test
decl = config.declarations[0]
decl.name == "SML"
decl.integration == :ficsit
- decl.group == :app
+ decl.scope.group == :app
decl.constraint["version"] == "^3.12.0"
end
@@ -141,7 +143,7 @@ class Dev::Deps::DSLTest < Minitest::Test
decl.constraint == {}
end
- test "ficsit() with target passes target in constraint" do
+ test "ficsit() with target rides materialization, not the constraint" do
When "defining a ficsit dep with target"
config = Dev::Deps.define do
group :app do
@@ -149,10 +151,23 @@ class Dev::Deps::DSLTest < Minitest::Test
end
end
- Then
+ Then "which artifact to fetch is an install instruction"
decl = config.declarations[0]
decl.constraint["version"] == "^1.0"
- decl.constraint["target"] == "LinuxServer"
+ decl.materialization["target"] == "LinuxServer"
+ !decl.constraint.key?("target")
+ end
+
+ test "ficsit() defaults the materialization target to the Windows game build" do
+ When "defining a ficsit dep with no target"
+ config = Dev::Deps.define do
+ group :app do
+ ficsit "SML", version: "^3.12.0"
+ end
+ end
+
+ Then
+ config.declarations[0].materialization["target"] == "Windows"
end
test "group platform: stamps the platform onto every declaration in the group" do
@@ -166,7 +181,7 @@ class Dev::Deps::DSLTest < Minitest::Test
Then
decl = config.declarations[0]
decl.name == "SML"
- decl.group == :integration
+ decl.scope.group == :integration
decl.platform == "LinuxServer"
end
@@ -197,7 +212,7 @@ class Dev::Deps::DSLTest < Minitest::Test
sml = config.declarations.select { |d| d.name == "SML" }
sml.size == 2
sml.map(&:platform).sort_by(&:to_s) == [nil, "LinuxServer"].sort_by(&:to_s)
- sml.map { |d| d.group }.sort == [:app, :integration]
+ sml.map { |d| d.scope.group }.sort == [:app, :integration]
end
test "group host: stamps the host onto every declaration in the group" do
@@ -215,7 +230,7 @@ class Dev::Deps::DSLTest < Minitest::Test
Then "every member carries the group's host"
config.declarations.size == 2
- config.declarations.all? { |d| d.host == :darwin }
+ config.declarations.all? { |d| d.scope.host == :darwin }
config.declarations.all? { |d| d.constraint["host"].nil? }
end
@@ -234,11 +249,11 @@ class Dev::Deps::DSLTest < Minitest::Test
Then "the declaration carries the host as a first-class field only"
decl = config.declarations[0]
- decl.host == :linux
+ decl.scope.host == :linux
decl.constraint["host"].nil?
end
- test "xcode() declares a pinned xcode toolchain dep" do
+ test "xcode() declares a pinned xcode toolchain dep as a revision" do
When "pinning the Xcode toolchain"
config = Dev::Deps.define do
group :build do
@@ -246,12 +261,25 @@ class Dev::Deps::DSLTest < Minitest::Test
end
end
- Then "the declaration rides the :xcode integration with the exact version"
+ Then "the declaration rides the :xcode integration; the exact version is an address"
decl = config.declarations[0]
decl.name == "xcode"
decl.integration == :xcode
- decl.constraint["version"] == "26.1.1"
- decl.group == :build
+ decl.revision == "26.1.1"
+ decl.constraint == {}
+ decl.scope.group == :build
+ end
+
+ test "xcode() rejects a blank version — the exact version is the whole ask" do
+ When "pinning nothing"
+ Dev::Deps.define do
+ group :build do
+ xcode " "
+ end
+ end
+
+ Then
+ raises ArgumentError
end
test "env block stamps env as a first-class field, not a constraint key" do
@@ -266,12 +294,12 @@ class Dev::Deps::DSLTest < Minitest::Test
Then "env and the enclosing group's host both land as fields"
decl = config.declarations[0]
- decl.env == "ci"
- decl.host == :linux
+ decl.scope.env == "ci"
+ decl.scope.host == :linux
decl.constraint["env"].nil?
end
- test "gh() produces DependencyDeclaration named after the repo basename" do
+ test "gh() produces ScopedDeclaration named after the repo basename" do
When "defining a gh release dep"
config = Dev::Deps.define do
group :build do
@@ -282,15 +310,15 @@ class Dev::Deps::DSLTest < Minitest::Test
end
end
- Then
+ Then "slug is source, tag is the constraint, the rest is materialization"
decl = config.declarations[0]
decl.name == "UnrealEngine"
decl.integration == :gh
- decl.group == :build
- decl.constraint["repo"] == "satisfactorymodding/UnrealEngine"
- decl.constraint["tag"] == "5.6.1-css-83"
- decl.constraint["assets"] == "UnrealEngine-CSS-Editor-Linux.tar.zst.*"
- decl.constraint["install_dir"] == "~/.dev/engines/unreal-engine-css"
+ decl.scope.group == :build
+ decl.source == "satisfactorymodding/UnrealEngine"
+ decl.constraint == { "tag" => "5.6.1-css-83" }
+ decl.materialization["asset_pattern"] == "UnrealEngine-CSS-Editor-Linux.tar.zst.*"
+ decl.materialization["install_dir"] == "~/.dev/engines/unreal-engine-css"
end
test "gh() build-from-source with github: shorthand names the dep and keeps the slug" do
@@ -309,12 +337,12 @@ class Dev::Deps::DSLTest < Minitest::Test
decl = config.declarations[0]
decl.name == "UnrealEngine"
decl.integration == :gh
- decl.group == :game
- decl.constraint["repo"] == "EpicGames/UnrealEngine"
- decl.constraint["tag"] == "5.6.1-release"
- decl.constraint["build"] == "bin/build-ue.sh"
- decl.constraint["install_dir"] == "~/.dev/engines/ue5"
- !decl.constraint.key?("assets")
+ decl.scope.group == :game
+ decl.source == "EpicGames/UnrealEngine"
+ decl.constraint == { "tag" => "5.6.1-release" }
+ decl.materialization["build"] == "bin/build-ue.sh"
+ decl.materialization["install_dir"] == "~/.dev/engines/ue5"
+ !decl.materialization.key?("asset_pattern")
end
test "gh() stringifies a :none build recipe for header-only deps" do
@@ -327,7 +355,7 @@ class Dev::Deps::DSLTest < Minitest::Test
end
Then
- config.declarations[0].constraint["build"] == "none"
+ config.declarations[0].materialization["build"] == "none"
end
test "gh() raises when neither assets: nor build: is given" do
@@ -356,7 +384,7 @@ class Dev::Deps::DSLTest < Minitest::Test
raises ArgumentError
end
- test "steam() produces a DependencyDeclaration with steam integration" do
+ test "steam() produces a ScopedDeclaration with steam integration" do
When "defining a steam dep in a LinuxServer group"
config = Dev::Deps.define do
group :integration, platform: "LinuxServer" do
@@ -364,15 +392,16 @@ class Dev::Deps::DSLTest < Minitest::Test
end
end
- Then
+ Then "app id is source, branch is the constraint, install dir + platform materialize"
decl = config.declarations[0]
decl.name == "SatisfactoryServer"
decl.integration == :steam
- decl.group == :integration
+ decl.scope.group == :integration
decl.platform == "LinuxServer"
- decl.constraint["app"] == 1690800
- decl.constraint["install_dir"] == "~/.dev/satisfactory-server"
- decl.constraint["branch"] == "public"
+ decl.source == "1690800"
+ decl.constraint == { "branch" => "public" }
+ decl.materialization["install_dir"] == "~/.dev/satisfactory-server"
+ decl.materialization["platform"] == "LinuxServer"
end
test "steam() accepts an explicit buildid pin" do
@@ -405,6 +434,45 @@ class Dev::Deps::DSLTest < Minitest::Test
entry["wwise-cli"]["tap"] == "d3mlabs/d3mlabs"
end
+ test "cmake commit: is an address — full SHA to the revision slot, constraint stays empty" do
+ When "pinning a commit"
+ config = Dev::Deps.define do
+ group :app do
+ cmake "opencell", github: "d3mlabs/opencell",
+ commit: "ee3042f8b0279856061f91069a487e4ed6f69475"
+ end
+ end
+
+ Then
+ decl = config.declarations[0]
+ decl.revision == "ee3042f8b0279856061f91069a487e4ed6f69475"
+ decl.constraint == {}
+ end
+
+ test "cmake rejects a short commit — no more silent resolve-as-tag fallthrough" do
+ When "pinning an abbreviated SHA"
+ Dev::Deps.define do
+ group :app do
+ cmake "opencell", github: "d3mlabs/opencell", commit: "ee3042f8b027"
+ end
+ end
+
+ Then
+ raises Dev::Deps::GroupDSL::InvalidRevisionError
+ end
+
+ test "cmake rejects a git dep naming no ref at all" do
+ When "declaring with neither tag:, branch:, nor commit:"
+ Dev::Deps.define do
+ group :app do
+ cmake "boost", github: "boostorg/boost"
+ end
+ end
+
+ Then "an unconstrained git universe would pin an arbitrary ref"
+ raises Dev::Deps::GroupDSL::MissingRefError
+ end
+
test "cmake raises EmptyNameError for empty name" do
When "defining a cmake dep with empty name"
Dev::Deps.define do
@@ -442,8 +510,8 @@ class Dev::Deps::DSLTest < Minitest::Test
Then
config.declarations.size == 2
- config.declarations[0].group == :app
- config.declarations[1].group == :test
+ config.declarations[0].scope.group == :app
+ config.declarations[1].scope.group == :test
end
test "user-defined groups produce declarations with custom group names" do
@@ -456,7 +524,7 @@ class Dev::Deps::DSLTest < Minitest::Test
Then
config.declarations.size == 1
- config.declarations[0].group == :deploy
+ config.declarations[0].scope.group == :deploy
end
test "post_install callable is extracted from spec and stored on declaration" do
diff --git a/test/dev/deps/exact_scheme_test.rb b/test/dev/deps/exact_scheme_test.rb
new file mode 100644
index 0000000..12f71ac
--- /dev/null
+++ b/test/dev/deps/exact_scheme_test.rb
@@ -0,0 +1,39 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/exact_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::ExactSchemeTest < Minitest::Test
+ def scheme
+ Dev::Deps::ExactScheme.new(key: "tag")
+ end
+
+ def pv(version)
+ Dev::Deps::PackageVersion.new(version: version)
+ end
+
+ test "#{version} against tag #{tag.inspect} is #{expected}" do
+ When "evaluating exact-coordinate semantics"
+ result = scheme.satisfies?(pv(version), tag.nil? ? {} : { "tag" => tag })
+
+ Then
+ result == expected
+
+ Where
+ version | tag | expected
+ "5.6.1-css-83" | "5.6.1-css-83" | true
+ "5.6.1-css-83" | "5.6.1-css-84" | false
+ "5.6.1-css-83" | nil | true
+ end
+
+ test "sort preserves order — exact coordinates carry none to impose" do
+ When "sorting"
+ sorted = scheme.sort(["b", "a", "c"])
+
+ Then
+ sorted == ["b", "a", "c"]
+ end
+end
diff --git a/test/dev/deps/ficsit_repository_test.rb b/test/dev/deps/ficsit_repository_test.rb
index 49a732b..31385e5 100644
--- a/test/dev/deps/ficsit_repository_test.rb
+++ b/test/dev/deps/ficsit_repository_test.rb
@@ -7,8 +7,8 @@
transform!(RSpock::AST::Transformation)
class Dev::Deps::FicsitRepositoryTest < Minitest::Test
- test "fetch resolves mod to version, hash, and transitive deps" do
- Given "a repository with a stubbed GraphQL response"
+ test "find reports every published version with its universe facts" do
+ Given "a mod with two versions on ficsit.app"
repo = Dev::Deps::FicsitRepository.new
graphql_response = {
"data" => {
@@ -16,64 +16,72 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test
"id" => "abc123",
"name" => "Area Actions",
"mod_reference" => "AreaActions",
- "versions" => [{
- "id" => "ver1",
- "version" => "2.5.0",
- "game_version" => ">=491125",
- "targets" => [{
- "targetName" => "Windows",
- "hash" => "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
- "size" => 500_000,
- }],
- "dependencies" => [
- { "mod_id" => "SML", "condition" => "^3.12.0", "optional" => false },
- { "mod_id" => "OptionalMod", "condition" => ">=1.0", "optional" => true },
- ],
- }],
+ "versions" => [
+ {
+ "id" => "ver2",
+ "version" => "2.5.0",
+ "game_version" => ">=491125",
+ "targets" => [{
+ "targetName" => "Windows",
+ "hash" => "deadbeef",
+ "size" => 500_000,
+ "link" => "/v1/version/ver2/Windows/download",
+ }],
+ "dependencies" => [
+ { "mod_id" => "SML", "condition" => "^3.12.0", "optional" => false },
+ { "mod_id" => "OptionalMod", "condition" => ">=1.0", "optional" => true },
+ ],
+ },
+ {
+ "id" => "ver1",
+ "version" => "2.4.0",
+ "game_version" => ">=400000",
+ "targets" => [{ "targetName" => "Windows", "hash" => "cafebabe", "size" => 400_000 }],
+ "dependencies" => [],
+ },
+ ],
},
},
}
- stub_response = stub(body: JSON.generate(graphql_response), is_a?: true)
+ stub_response = stub(body: JSON.generate(graphql_response))
stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
repo.stubs(:post_graphql).returns(stub_response)
- When "fetching the dependency"
- dep = repo.fetch(
- "name" => "AreaActions",
- "integration" => "ficsit",
- "group" => "app",
- )
-
- Then
- dep.name == "AreaActions"
- dep.integration == :ficsit
- dep.group == :app
- dep.version == "2.5.0"
- dep.hash == "SHA256=deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678"
- dep.metadata["mod_id"] == "abc123"
- dep.metadata["game_version"] == ">=491125"
- dep.metadata["target"] == "Windows"
- dep.dependencies.size == 1
- dep.dependencies[0][:name] == "SML"
- dep.dependencies[0][:constraint] == "^3.12.0"
+ When "finding the package"
+ package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "AreaActions"))
+
+ Then "the whole universe is reported, facts attached, nothing projected"
+ package.versions.map(&:version) == ["2.5.0", "2.4.0"]
+ latest = package.version("2.5.0")
+ latest.platforms == ["Windows"]
+ latest.digest.nil?
+ latest.artifacts["Windows"].uri == "https://api.ficsit.app/v1/version/ver2/Windows/download"
+ latest.artifacts["Windows"].digest == "SHA256=deadbeef"
+ latest.declarations == Dev::Deps::Declarations::Resolved.new([
+ Dev::Deps::Declaration.new(name: "SML", integration: :ficsit, constraint: { "version" => "^3.12.0" }),
+ ])
+ latest.metadata == { "mod_id" => "abc123", "game_version" => ">=491125" }
+ package.version("2.4.0").artifacts["Windows"].digest == "SHA256=cafebabe"
end
- test "fetch uses specified target platform" do
- Given "a mod with multiple targets"
+ test "find reports every target's artifact — projection is the Resolver's job" do
+ Given "a mod with Windows and LinuxServer targets"
repo = Dev::Deps::FicsitRepository.new
graphql_response = {
"data" => {
"getModByReference" => {
"id" => "abc123",
- "name" => "TestMod",
- "mod_reference" => "TestMod",
+ "name" => "SML",
+ "mod_reference" => "SML",
"versions" => [{
"id" => "ver1",
- "version" => "1.0.0",
+ "version" => "3.12.0",
"game_version" => ">=491125",
"targets" => [
- { "targetName" => "Windows", "hash" => "winhash123", "size" => 100 },
- { "targetName" => "LinuxServer", "hash" => "linuxhash456", "size" => 200 },
+ { "targetName" => "Windows", "hash" => "winhash", "size" => 100,
+ "link" => "/v1/version/ver1/Windows/download" },
+ { "targetName" => "LinuxServer", "hash" => "linuxhash", "size" => 200,
+ "link" => "/v1/version/ver1/LinuxServer/download" },
],
"dependencies" => [],
}],
@@ -84,75 +92,42 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test
stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
repo.stubs(:post_graphql).returns(stub_response)
- When "fetching with target: LinuxServer"
- dep = repo.fetch(
- "name" => "TestMod",
- "integration" => "ficsit",
- "group" => "app",
- "target" => "LinuxServer",
- )
+ When "finding"
+ package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "SML"))
- Then
- dep.hash == "SHA256=linuxhash456"
- dep.metadata["target"] == "LinuxServer"
+ Then "both targets are artifacts with their own digests; no install facts minted here"
+ version = package.version("3.12.0")
+ version.platforms.sort == ["LinuxServer", "Windows"]
+ version.artifacts["Windows"].digest == "SHA256=winhash"
+ version.artifacts["LinuxServer"].digest == "SHA256=linuxhash"
+ !version.metadata.key?("platforms")
+ !version.metadata.key?("target")
end
- test "fetch defaults to Windows target" do
- Given "a mod with only Windows target"
+ test "find raises ModNotFoundError, a PackageNotFoundError, for unknown mods" do
+ Given "a repository returning null mod data"
repo = Dev::Deps::FicsitRepository.new
- graphql_response = {
- "data" => {
- "getModByReference" => {
- "id" => "abc123",
- "name" => "TestMod",
- "mod_reference" => "TestMod",
- "versions" => [{
- "id" => "ver1",
- "version" => "1.0.0",
- "game_version" => ">=491125",
- "targets" => [{ "targetName" => "Windows", "hash" => "winhash", "size" => 100 }],
- "dependencies" => [],
- }],
- },
- },
- }
- stub_response = stub(body: JSON.generate(graphql_response))
+ stub_response = stub(body: JSON.generate({ "data" => { "getModByReference" => nil } }))
stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
repo.stubs(:post_graphql).returns(stub_response)
- When "fetching without specifying target"
- dep = repo.fetch(
- "name" => "TestMod",
- "integration" => "ficsit",
- "group" => "app",
- )
+ When "finding a nonexistent mod"
+ repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "NonExistentMod"))
Then
- dep.metadata["target"] == "Windows"
- dep.hash == "SHA256=winhash"
+ raises Dev::Deps::Repository::PackageNotFoundError
end
- test "fetch resolves multiple platforms into nested metadata with absolute links" do
- Given "a mod with Windows and LinuxServer targets and relative links"
+ test "find reports a mod with no versions as an empty package" do
+ Given "a mod with empty versions"
repo = Dev::Deps::FicsitRepository.new
graphql_response = {
"data" => {
"getModByReference" => {
"id" => "abc123",
- "name" => "SML",
- "mod_reference" => "SML",
- "versions" => [{
- "id" => "ver1",
- "version" => "3.12.0",
- "game_version" => ">=491125",
- "targets" => [
- { "targetName" => "Windows", "hash" => "winhash", "size" => 100,
- "link" => "/v1/version/ver1/Windows/download" },
- { "targetName" => "LinuxServer", "hash" => "linuxhash", "size" => 200,
- "link" => "/v1/version/ver1/LinuxServer/download" },
- ],
- "dependencies" => [],
- }],
+ "name" => "EmptyMod",
+ "mod_reference" => "EmptyMod",
+ "versions" => [],
},
},
}
@@ -160,25 +135,14 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test
stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
repo.stubs(:post_graphql).returns(stub_response)
- When "fetching with a platform set including the nil default and LinuxServer"
- dep = repo.fetch(
- "name" => "SML",
- "integration" => "ficsit",
- "group" => "app",
- "platforms" => [nil, "LinuxServer"],
- )
+ When "finding the package"
+ package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "EmptyMod"))
- Then
- dep.version == "3.12.0"
- dep.hash.nil?
- dep.metadata["platforms"]["Windows"]["hash"] == "SHA256=winhash"
- dep.metadata["platforms"]["Windows"]["link"] == "https://api.ficsit.app/v1/version/ver1/Windows/download"
- dep.metadata["platforms"]["LinuxServer"]["hash"] == "SHA256=linuxhash"
- dep.metadata["platforms"]["LinuxServer"]["link"] == "https://api.ficsit.app/v1/version/ver1/LinuxServer/download"
- !dep.metadata.key?("target")
+ Then "an empty universe is a fact, not an error"
+ package.empty?
end
- test "fetch builds the download link from the version id when link is absent" do
+ test "find builds the artifact link from the version id when link is absent" do
Given "a target without a link field"
repo = Dev::Deps::FicsitRepository.new
graphql_response = {
@@ -201,144 +165,15 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test
stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
repo.stubs(:post_graphql).returns(stub_response)
- When "fetching the LinuxServer platform"
- dep = repo.fetch(
- "name" => "SML",
- "integration" => "ficsit",
- "group" => "integration",
- "platforms" => ["LinuxServer"],
- )
+ When "finding"
+ package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "SML"))
- Then
- dep.metadata["platforms"]["LinuxServer"]["link"] ==
+ Then "the link falls back to the /v1/version///download shape"
+ package.version("3.12.0").artifacts["LinuxServer"].uri ==
"https://api.ficsit.app/v1/version/ver1/LinuxServer/download"
end
- test "fetch raises TargetNotFoundError when a requested platform has no target" do
- Given "a mod with only a Windows target"
- repo = Dev::Deps::FicsitRepository.new
- graphql_response = {
- "data" => {
- "getModByReference" => {
- "id" => "abc123",
- "name" => "SML",
- "mod_reference" => "SML",
- "versions" => [{
- "id" => "ver1",
- "version" => "3.12.0",
- "game_version" => ">=491125",
- "targets" => [{ "targetName" => "Windows", "hash" => "winhash", "size" => 100,
- "link" => "/v1/version/ver1/Windows/download" }],
- "dependencies" => [],
- }],
- },
- },
- }
- stub_response = stub(body: JSON.generate(graphql_response))
- stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
- repo.stubs(:post_graphql).returns(stub_response)
-
- When "fetching a missing LinuxServer platform"
- repo.fetch(
- "name" => "SML",
- "integration" => "ficsit",
- "group" => "integration",
- "platforms" => ["LinuxServer"],
- )
-
- Then
- raises Dev::Deps::FicsitRepository::TargetNotFoundError
- end
-
- test "fetch raises ModNotFoundError when mod does not exist" do
- Given "a repository returning null mod data"
- repo = Dev::Deps::FicsitRepository.new
- graphql_response = { "data" => { "getModByReference" => nil } }
- stub_response = stub(body: JSON.generate(graphql_response))
- stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
- repo.stubs(:post_graphql).returns(stub_response)
-
- When "fetching a nonexistent mod"
- repo.fetch(
- "name" => "NonExistentMod",
- "integration" => "ficsit",
- "group" => "app",
- )
-
- Then
- raises Dev::Deps::FicsitRepository::ModNotFoundError
- end
-
- test "fetch raises NoVersionError when mod has no versions" do
- Given "a mod with empty versions"
- repo = Dev::Deps::FicsitRepository.new
- graphql_response = {
- "data" => {
- "getModByReference" => {
- "id" => "abc123",
- "name" => "EmptyMod",
- "mod_reference" => "EmptyMod",
- "versions" => [],
- },
- },
- }
- stub_response = stub(body: JSON.generate(graphql_response))
- stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
- repo.stubs(:post_graphql).returns(stub_response)
-
- When "fetching a mod with no versions"
- repo.fetch(
- "name" => "EmptyMod",
- "integration" => "ficsit",
- "group" => "app",
- )
-
- Then
- raises Dev::Deps::FicsitRepository::NoVersionError
- end
-
- test "fetch raises ApiError when GraphQL returns errors" do
- Given "a GraphQL error response"
- repo = Dev::Deps::FicsitRepository.new
- graphql_response = {
- "errors" => [{ "message" => "something went wrong" }],
- }
- stub_response = stub(body: JSON.generate(graphql_response))
- stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
- repo.stubs(:post_graphql).returns(stub_response)
-
- When "fetching triggers an API error"
- repo.fetch(
- "name" => "BadMod",
- "integration" => "ficsit",
- "group" => "app",
- )
-
- Then
- raises Dev::Deps::FicsitRepository::ApiError
- end
-
- test "fetch raises ApiError when HTTP request fails" do
- Given "a failing HTTP response"
- repo = Dev::Deps::FicsitRepository.new
- stub_response = stub(code: "500", body: "Internal Server Error")
- stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false)
- repo.stubs(:post_graphql).raises(
- Dev::Deps::FicsitRepository::ApiError.new("ficsit.app API returned 500: Internal Server Error")
- )
-
- When "fetching triggers an HTTP error"
- repo.fetch(
- "name" => "BadMod",
- "integration" => "ficsit",
- "group" => "app",
- )
-
- Then
- raises Dev::Deps::FicsitRepository::ApiError
- end
-
- test "fetch returns nil hash when no targets exist" do
+ test "find reports a targetless version with a nil digest" do
Given "a mod version with no targets"
repo = Dev::Deps::FicsitRepository.new
graphql_response = {
@@ -361,55 +196,26 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test
stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
repo.stubs(:post_graphql).returns(stub_response)
- When "fetching the dependency"
- dep = repo.fetch(
- "name" => "NoTargetMod",
- "integration" => "ficsit",
- "group" => "app",
- )
+ When "finding the package"
+ package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "NoTargetMod"))
- Then
- dep.hash.nil?
- dep.version == "1.0.0"
+ Then "no published bytes, no integrity fact"
+ package.version("1.0.0").digest.nil?
end
- test "fetch excludes optional dependencies from transitive list" do
- Given "a mod with both required and optional dependencies"
+ test "find raises ApiError when GraphQL returns errors" do
+ Given "a GraphQL error response"
repo = Dev::Deps::FicsitRepository.new
- graphql_response = {
- "data" => {
- "getModByReference" => {
- "id" => "abc123",
- "name" => "MixedDeps",
- "mod_reference" => "MixedDeps",
- "versions" => [{
- "id" => "ver1",
- "version" => "1.0.0",
- "game_version" => ">=491125",
- "targets" => [{ "targetName" => "Windows", "hash" => "aaa", "size" => 100 }],
- "dependencies" => [
- { "mod_id" => "SML", "condition" => "^3.12.0", "optional" => false },
- { "mod_id" => "OptionalLib", "condition" => ">=1.0", "optional" => true },
- { "mod_id" => "RequiredLib", "condition" => "^2.0", "optional" => false },
- ],
- }],
- },
- },
- }
+ graphql_response = { "errors" => [{ "message" => "something went wrong" }] }
stub_response = stub(body: JSON.generate(graphql_response))
stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
repo.stubs(:post_graphql).returns(stub_response)
- When "fetching the dependency"
- dep = repo.fetch(
- "name" => "MixedDeps",
- "integration" => "ficsit",
- "group" => "app",
- )
+ When "finding triggers an API error"
+ repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "BadMod"))
Then
- dep.dependencies.size == 2
- dep.dependencies.map { |d| d[:name] }.sort == ["RequiredLib", "SML"]
+ raises Dev::Deps::FicsitRepository::ApiError
end
test "post_graphql posts the query over TLS and returns the 2xx response" do
diff --git a/test/dev/deps/gem_scheme_test.rb b/test/dev/deps/gem_scheme_test.rb
new file mode 100644
index 0000000..0a8806b
--- /dev/null
+++ b/test/dev/deps/gem_scheme_test.rb
@@ -0,0 +1,68 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/gem_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::GemSchemeTest < Minitest::Test
+ def scheme
+ Dev::Deps::GemScheme.new
+ end
+
+ def pv(version)
+ Dev::Deps::PackageVersion.new(version: version)
+ end
+
+ test "#{version} against #{requirement.inspect} is #{expected}" do
+ When "evaluating the requirement under rubygems semantics"
+ result = scheme.satisfies?(pv(version), { "version" => requirement })
+
+ Then
+ result == expected
+
+ Where
+ version | requirement | expected
+ "1.17.4" | "~> 1.17" | true
+ "2.0.0" | "~> 1.17" | false
+ "1.17.4" | ">= 1.0, < 2.0" | true
+ "2.1.0" | ">= 1.0, < 2.0" | false
+ "1.17.4" | "1.17.4" | true
+ "1.17.5" | "1.17.4" | false
+ "1.0.0" | ">= 1.0.0.beta" | true
+ end
+
+ test "an empty constraint is satisfied by anything" do
+ Expect "no version requirement means unconstrained"
+ scheme.satisfies?(pv("1.17.4"), {})
+ scheme.satisfies?(pv("1.17.4"), { "require" => false })
+ end
+
+ test "sorts by rubygems version ordering, prereleases below their release" do
+ Given "versions out of order, one a prerelease"
+ versions = ["1.0.0", "1.0.0.beta", "0.9.0", "1.0.1"]
+
+ When "sorting"
+ sorted = scheme.sort(versions)
+
+ Then
+ sorted == ["0.9.0", "1.0.0.beta", "1.0.0", "1.0.1"]
+ end
+
+ test "rejects a version string rubygems cannot parse" do
+ When "sorting garbage"
+ scheme.sort(["not a version"])
+
+ Then
+ raises Dev::Deps::GemScheme::InvalidVersionError
+ end
+
+ test "rejects a requirement rubygems cannot parse" do
+ When "evaluating a malformed requirement"
+ scheme.satisfies?(pv("1.0.0"), { "version" => ">>>= nope" })
+
+ Then
+ raises Dev::Deps::GemScheme::InvalidConstraintError
+ end
+end
diff --git a/test/dev/deps/gh_integration_test.rb b/test/dev/deps/gh_integration_test.rb
index 4fdebcf..16c3439 100644
--- a/test/dev/deps/gh_integration_test.rb
+++ b/test/dev/deps/gh_integration_test.rb
@@ -74,7 +74,8 @@ def build_split_archive(dir, base_name, part_size:)
parts
end
- def build_dependency(parts, install_dir, tag: "5.6.1-css-83", sha256_overrides: {})
+ def build_dependency(parts, install_dir, tag: "5.6.1-css-83", sha256_overrides: {},
+ asset_pattern: "*.tar.zst.*")
assets = parts.map do |part|
name = part.basename.to_s
{
@@ -89,7 +90,7 @@ def build_dependency(parts, install_dir, tag: "5.6.1-css-83", sha256_overrides:
version: tag, hash: nil,
metadata: {
"repo" => "satisfactorymodding/UnrealEngine",
- "asset_pattern" => "*.tar.zst.*",
+ "asset_pattern" => asset_pattern,
"install_dir" => install_dir,
"assets" => assets,
},
@@ -202,7 +203,7 @@ def build_integration(fixture_files, cache_dir)
zip_path = Pathname(File.join(dir, "engine.zip"))
zip_path.binwrite("not actually a zip")
install_dir = File.join(dir, "engines", "unreal-engine-css")
- dep = build_dependency([zip_path], install_dir)
+ dep = build_dependency([zip_path], install_dir, asset_pattern: "*.zip")
integration = build_integration([zip_path], File.join(dir, "cache"))
When "installing the unsupported archive"
@@ -215,6 +216,24 @@ def build_integration(fixture_files, cache_dir)
FileUtils.rm_rf(dir)
end
+ test "install_all raises NoMatchingAssetsError when the glob selects no locked asset" do
+ Given "a locked asset list the declared glob does not cover"
+ dir = Dir.mktmpdir("dev-gh-int-test-")
+ parts = build_split_archive(dir, "engine.tar.zst", part_size: 64)
+ install_dir = File.join(dir, "engines", "unreal-engine-css")
+ dep = build_dependency(parts, install_dir, asset_pattern: "*.7z.*")
+ integration = build_integration(parts, File.join(dir, "cache"))
+
+ When "installing with a glob that matches nothing"
+ integration.install_all([dep])
+
+ Then "the mismatch is loud — never a silently empty install"
+ raises Dev::Deps::GhIntegration::NoMatchingAssetsError
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
# --- build-from-source path ------------------------------------------------
# A GitHub-style source tarball: a single top-level "-/" dir that
diff --git a/test/dev/deps/gh_repository_test.rb b/test/dev/deps/gh_repository_test.rb
index cc6a801..e28512b 100644
--- a/test/dev/deps/gh_repository_test.rb
+++ b/test/dev/deps/gh_repository_test.rb
@@ -7,8 +7,11 @@
transform!(RSpock::AST::Transformation)
class Dev::Deps::GhRepositoryTest < Minitest::Test
+ SLUG = "satisfactorymodding/UnrealEngine"
+
RELEASE_JSON = {
"tag_name" => "5.6.1-css-83",
+ "draft" => false,
"assets" => [
{
"name" => "UnrealEngine-CSS-Editor-Linux.tar.zst.00",
@@ -28,197 +31,191 @@ class Dev::Deps::GhRepositoryTest < Minitest::Test
],
}.freeze
- def fetch_id(overrides = {})
- {
- "name" => "UnrealEngine",
- "integration" => "gh",
- "group" => "build",
- "repo" => "satisfactorymodding/UnrealEngine",
- "tag" => "5.6.1-css-83",
- "assets" => "UnrealEngine-CSS-Editor-Linux.tar.zst.*",
- "install_dir" => "~/.dev/engines/unreal-engine-css",
- }.merge(overrides)
+ def prebuilt_id
+ Dev::Deps::PackageId.new(integration: :gh, name: "UnrealEngine", source: SLUG)
end
- test "fetch resolves release to tag, matching assets, and digests" do
- Given "a repository with a stubbed gh api response"
- repo = Dev::Deps::GhRepository.new
- repo.stubs(:run_gh_api)
- .with("repos/satisfactorymodding/UnrealEngine/releases/tags/5.6.1-css-83")
- .returns([JSON.generate(RELEASE_JSON), "", stub(success?: true)])
-
- When "fetching the dependency"
- dep = repo.fetch(fetch_id)
+ def tag_json(name, sha)
+ { "name" => name, "commit" => { "sha" => sha } }
+ end
- Then
- dep.name == "UnrealEngine"
- dep.integration == :gh
- dep.group == :build
- dep.version == "5.6.1-css-83"
- dep.hash.nil?
- dep.metadata["repo"] == "satisfactorymodding/UnrealEngine"
- dep.metadata["asset_pattern"] == "UnrealEngine-CSS-Editor-Linux.tar.zst.*"
- dep.metadata["install_dir"] == "~/.dev/engines/unreal-engine-css"
- dep.metadata["assets"].size == 2
- dep.metadata["assets"][0]["name"] == "UnrealEngine-CSS-Editor-Linux.tar.zst.00"
- dep.metadata["assets"][0]["size"] == 2_147_483_648
- dep.metadata["assets"][0]["sha256"] == "aaaa1111"
- dep.metadata["assets"][1]["sha256"] == "bbbb2222"
+ # Stub the two list endpoints the repository enumerates (single page each).
+ def stub_universe(repo, slug: SLUG, releases: [], tags: [])
+ repo.stubs(:run_gh_api)
+ .with("repos/#{slug}/releases?per_page=100&page=1")
+ .returns([JSON.generate(releases), "", stub(success?: true)])
+ repo.stubs(:run_gh_api)
+ .with("repos/#{slug}/tags?per_page=100&page=1")
+ .returns([JSON.generate(tags), "", stub(success?: true)])
end
- test "fetch omits sha256 for assets without an API digest" do
- Given "a release whose asset has no digest"
+ test "find enumerates tags and releases into one facts-complete universe" do
+ Given "a repo with a released tag and a source-only tag"
repo = Dev::Deps::GhRepository.new
- release = {
- "tag_name" => "v1.0",
- "assets" => [{ "name" => "tool-Linux.tar.zst", "size" => 100, "digest" => nil }],
- }
- repo.stubs(:run_gh_api).returns([JSON.generate(release), "", stub(success?: true)])
-
- When "fetching the dependency"
- dep = repo.fetch(fetch_id("assets" => "tool-Linux.tar.zst"))
+ stub_universe(repo,
+ releases: [RELEASE_JSON],
+ tags: [tag_json("5.6.1-css-83", "css83sha"), tag_json("5.6.1-release", "relsha")])
+
+ When "finding"
+ package = repo.find(prebuilt_id)
+
+ Then "the released tag carries commit + all assets; the bare tag just its commit"
+ package.versions.map(&:version).sort == ["5.6.1-css-83", "5.6.1-release"]
+ released = package.version("5.6.1-css-83")
+ released.digest.nil?
+ released.metadata["repo"] == SLUG
+ released.metadata["commit"] == "css83sha"
+ released.metadata["assets"].map { |a| a["sha256"] } == ["aaaa1111", "bbbb2222", "cccc3333"]
+ source_only = package.version("5.6.1-release")
+ source_only.metadata["commit"] == "relsha"
+ !source_only.metadata.key?("assets")
+ end
- Then
- dep.metadata["assets"].size == 1
- !dep.metadata["assets"][0].key?("sha256")
+ test "find orders releases last, newest at the end — the unconstrained pick" do
+ Given "two releases (API lists newest first) and a bare tag"
+ repo = Dev::Deps::GhRepository.new
+ stub_universe(repo,
+ releases: [
+ { "tag_name" => "v2.0", "draft" => false, "assets" => [] },
+ { "tag_name" => "v1.0", "draft" => false, "assets" => [] },
+ ],
+ tags: [tag_json("v2.0", "sha2"), tag_json("v1.0", "sha1"), tag_json("wip", "sha3")])
+
+ When "finding"
+ package = repo.find(prebuilt_id)
+
+ Then "tag-only first, then releases oldest to newest — last wins unconstrained"
+ package.versions.map(&:version) == ["wip", "v1.0", "v2.0"]
end
- test "fetch raises NoMatchingAssetsError when pattern matches nothing" do
- Given "a release without assets matching the pattern"
+ test "find skips draft releases — no tag exists until publish" do
+ Given "a draft release alongside a published one"
repo = Dev::Deps::GhRepository.new
- repo.stubs(:run_gh_api).returns([JSON.generate(RELEASE_JSON), "", stub(success?: true)])
+ stub_universe(repo,
+ releases: [
+ { "tag_name" => "v2.0-draft", "draft" => true, "assets" => [] },
+ { "tag_name" => "v1.0", "draft" => false, "assets" => [] },
+ ],
+ tags: [tag_json("v1.0", "sha1")])
- When "fetching with a non-matching pattern"
- repo.fetch(fetch_id("assets" => "*.7z.*"))
+ When "finding"
+ package = repo.find(prebuilt_id)
Then
- raises Dev::Deps::GhRepository::NoMatchingAssetsError
+ package.versions.map(&:version) == ["v1.0"]
end
- test "fetch raises ReleaseNotFoundError when tag is missing but repo is visible" do
- Given "a 404 on the release and a visible repo"
+ test "find paginates list endpoints to exhaustion" do
+ Given "a repo with 100 tags on page one and 1 on page two"
repo = Dev::Deps::GhRepository.new
+ page_one = (1..100).map { |i| tag_json("v#{i}", "sha#{i}") }
+ page_two = [tag_json("v101", "sha101")]
+ repo.stubs(:run_gh_api)
+ .with("repos/#{SLUG}/releases?per_page=100&page=1")
+ .returns([JSON.generate([]), "", stub(success?: true)])
repo.stubs(:run_gh_api)
- .with("repos/satisfactorymodding/UnrealEngine/releases/tags/9.9.9-css-1")
- .returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)])
+ .with("repos/#{SLUG}/tags?per_page=100&page=1")
+ .returns([JSON.generate(page_one), "", stub(success?: true)])
repo.stubs(:run_gh_api)
- .with("repos/satisfactorymodding/UnrealEngine")
- .returns([JSON.generate({ "full_name" => "satisfactorymodding/UnrealEngine" }), "", stub(success?: true)])
+ .with("repos/#{SLUG}/tags?per_page=100&page=2")
+ .returns([JSON.generate(page_two), "", stub(success?: true)])
- When "fetching a nonexistent tag"
- repo.fetch(fetch_id("tag" => "9.9.9-css-1"))
+ When "finding"
+ package = repo.find(prebuilt_id)
Then
- raises Dev::Deps::GhRepository::ReleaseNotFoundError
+ package.versions.size == 101
end
- test "fetch raises RepoAccessError when the repo itself is invisible" do
- Given "a 404 on both the release and the repo"
+ test "find claims an empty Resolved declaration set — self-contained by contract" do
+ Given "a repo with one tag"
repo = Dev::Deps::GhRepository.new
- repo.stubs(:run_gh_api).returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)])
+ stub_universe(repo, tags: [tag_json("v1", "sha")])
- When "fetching from an inaccessible repo"
- repo.fetch(fetch_id)
+ When "finding"
+ package = repo.find(prebuilt_id)
Then
- raises Dev::Deps::GhRepository::RepoAccessError
+ package.version("v1").declarations == Dev::Deps::Declarations::Resolved.new([])
end
- test "fetch raises AuthenticationError when gh is not logged in" do
- Given "gh demanding authentication"
+ test "find raises PackageNotFoundError for a repo with no tags or releases" do
+ Given "an empty universe"
repo = Dev::Deps::GhRepository.new
- err = "To get started with GitHub CLI, please run: gh auth login"
- repo.stubs(:run_gh_api).returns(["", err, stub(success?: false)])
+ stub_universe(repo)
- When "fetching without authentication"
- repo.fetch(fetch_id)
+ When "finding"
+ repo.find(prebuilt_id)
Then
- raises Dev::Deps::GhRepository::AuthenticationError
+ raises Dev::Deps::Repository::PackageNotFoundError
end
- test "fetch raises ApiError for other gh failures" do
- Given "a server error from gh"
+ test "find omits sha256 for assets without an API digest" do
+ Given "a release whose asset has no digest"
repo = Dev::Deps::GhRepository.new
- repo.stubs(:run_gh_api).returns(["", "gh: Internal Server Error (HTTP 500)", stub(success?: false)])
+ release = {
+ "tag_name" => "v1.0",
+ "draft" => false,
+ "assets" => [{ "name" => "tool-Linux.tar.zst", "size" => 100, "digest" => nil }],
+ }
+ stub_universe(repo, releases: [release], tags: [tag_json("v1.0", "sha")])
- When "fetching during an API outage"
- repo.fetch(fetch_id)
+ When "finding"
+ package = repo.find(prebuilt_id)
Then
- raises Dev::Deps::GhRepository::ApiError
+ assets = package.version("v1.0").metadata["assets"]
+ assets.size == 1
+ !assets[0].key?("sha256")
end
- test "fetch raises GhMissingError when the gh CLI is not installed" do
- Given "no gh binary on PATH"
+ test "find raises RepoAccessError when the repo is invisible — list endpoints 404 only then" do
+ Given "a 404 on the releases list"
repo = Dev::Deps::GhRepository.new
- Open3.stubs(:capture3).raises(Errno::ENOENT.new("gh"))
+ repo.stubs(:run_gh_api).returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)])
- When "fetching without gh installed"
- repo.fetch(fetch_id)
+ When "finding in an inaccessible repo"
+ repo.find(prebuilt_id)
Then
- raises Dev::Deps::GhRepository::GhMissingError
- end
-
- def source_id(overrides = {})
- {
- "name" => "UnrealEngine",
- "integration" => "gh",
- "group" => "game",
- "repo" => "EpicGames/UnrealEngine",
- "tag" => "5.6.1-release",
- "build" => "bin/build-ue.sh",
- "install_dir" => "~/.dev/engines/ue5",
- }.merge(overrides)
+ raises Dev::Deps::GhRepository::RepoAccessError
end
- test "fetch resolves a build-from-source dep to the tag's commit SHA and build recipe" do
- Given "a repository whose commit lookup is stubbed"
+ test "find raises AuthenticationError when gh is not logged in" do
+ Given "gh demanding authentication"
repo = Dev::Deps::GhRepository.new
- repo.stubs(:run_gh_api)
- .with("repos/EpicGames/UnrealEngine/commits/5.6.1-release")
- .returns([JSON.generate({ "sha" => "6978b63c" }), "", stub(success?: true)])
+ err = "To get started with GitHub CLI, please run: gh auth login"
+ repo.stubs(:run_gh_api).returns(["", err, stub(success?: false)])
- When "fetching the source dependency"
- dep = repo.fetch(source_id)
+ When "finding without authentication"
+ repo.find(prebuilt_id)
Then
- dep.name == "UnrealEngine"
- dep.version == "5.6.1-release"
- dep.metadata["repo"] == "EpicGames/UnrealEngine"
- dep.metadata["install_dir"] == "~/.dev/engines/ue5"
- dep.metadata["build"] == "bin/build-ue.sh"
- dep.metadata["commit"] == "6978b63c"
- !dep.metadata.key?("assets")
+ raises Dev::Deps::GhRepository::AuthenticationError
end
- test "fetch source raises ReleaseNotFoundError when the tag is missing but repo is visible" do
- Given "a 404 on the commit and a visible repo"
+ test "find raises ApiError for other gh failures" do
+ Given "a server error from gh"
repo = Dev::Deps::GhRepository.new
- repo.stubs(:run_gh_api)
- .with("repos/EpicGames/UnrealEngine/commits/9.9.9")
- .returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)])
- repo.stubs(:run_gh_api)
- .with("repos/EpicGames/UnrealEngine")
- .returns([JSON.generate({ "full_name" => "EpicGames/UnrealEngine" }), "", stub(success?: true)])
+ repo.stubs(:run_gh_api).returns(["", "gh: Internal Server Error (HTTP 500)", stub(success?: false)])
- When "fetching a nonexistent tag"
- repo.fetch(source_id("tag" => "9.9.9"))
+ When "finding during an API outage"
+ repo.find(prebuilt_id)
Then
- raises Dev::Deps::GhRepository::ReleaseNotFoundError
+ raises Dev::Deps::GhRepository::ApiError
end
- test "fetch source raises RepoAccessError when the repo is invisible (account not linked)" do
- Given "a 404 on both the commit and the repo"
+ test "find raises GhMissingError when the gh CLI is not installed" do
+ Given "no gh binary on PATH"
repo = Dev::Deps::GhRepository.new
- repo.stubs(:run_gh_api).returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)])
+ Open3.stubs(:capture3).raises(Errno::ENOENT.new("gh"))
- When "fetching from an inaccessible repo"
- repo.fetch(source_id)
+ When "finding without gh installed"
+ repo.find(prebuilt_id)
Then
- raises Dev::Deps::GhRepository::RepoAccessError
+ raises Dev::Deps::GhRepository::GhMissingError
end
end
diff --git a/test/dev/deps/git_repository_test.rb b/test/dev/deps/git_repository_test.rb
index 0c2f50e..12a4654 100644
--- a/test/dev/deps/git_repository_test.rb
+++ b/test/dev/deps/git_repository_test.rb
@@ -3,76 +3,94 @@
require "test_helper"
require "dev/deps/git_repository"
-require "dev/deps/cache"
require "tmpdir"
transform!(RSpock::AST::Transformation)
class Dev::Deps::GitRepositoryTest < Minitest::Test
- test "fetch passes through a 40-char hex commit SHA as-is" do
- Given "a commit SHA identifier"
+ REMOTE = "https://github.com/google/googletest"
+
+ def id(name: "googletest", source: REMOTE)
+ Dev::Deps::PackageId.new(integration: :cmake, name: name, source: source)
+ end
+
+ def stub_ls_remote(output, success: true)
+ Open3.stubs(:capture3)
+ .with("git", "ls-remote", "--tags", "--heads", REMOTE)
+ .returns([output, "", stub(success?: success)])
+ end
+
+ test "find enumerates every tag and branch head as SHA-versioned facts" do
+ Given "a remote listing tags and heads in one ls-remote call"
repo = Dev::Deps::GitRepository.new
+ stub_ls_remote(<<~LS)
+ 1111111111111111111111111111111111111111\trefs/heads/main
+ 2222222222222222222222222222222222222222\trefs/tags/v1.16.0
+ 3333333333333333333333333333333333333333\trefs/tags/v1.17.0
+ LS
- When "fetching by commit"
- dep = repo.fetch(
- "name" => "entityx",
- "repo" => "https://github.com/alecthomas/entityx",
- "commit" => "ee3042f8b0279856061f91069a487e4ed6f69475",
- "integration" => "cmake",
- "group" => "app",
- )
+ When "finding"
+ package = repo.find(id)
- Then
- dep.name == "entityx"
- dep.version == "ee3042f8b0279856061f91069a487e4ed6f69475"
- dep.integration == :cmake
- dep.group == :app
+ Then "one version per ref: the SHA, no digest, the ref riding as a fact"
+ package.versions.size == 3
+ package.version("3" * 40).metadata == { "repo" => REMOTE, "ref" => "v1.17.0" }
+ package.version("1" * 40).metadata == { "repo" => REMOTE, "ref" => "main" }
+ package.versions.all? { |v| v.digest.nil? }
+ package.versions.all? { |v| v.declarations == Dev::Deps::Declarations::Resolved.new([]) }
end
- test "fetch calls git ls-remote for a tag" do
- Given "a tag identifier"
+ test "find prefers the peeled SHA for annotated tags — the commit a checkout materializes" do
+ Given "an annotated tag listing its tag object and its peeled commit"
repo = Dev::Deps::GitRepository.new
- resolved_sha = "abcdef1234567890abcdef1234567890abcdef12"
- Open3.stubs(:capture3)
- .with("git", "ls-remote", "--tags", "https://github.com/google/googletest", "v1.17.0")
- .returns(["#{resolved_sha}\trefs/tags/v1.17.0\n", "", stub(success?: true)])
-
- When "fetching by tag"
- dep = repo.fetch(
- "name" => "googletest",
- "repo" => "https://github.com/google/googletest",
- "tag" => "v1.17.0",
- "integration" => "cmake",
- "group" => "test",
- )
+ stub_ls_remote(<<~LS)
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/v1.17.0
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\trefs/tags/v1.17.0^{}
+ LS
- Then
- dep.name == "googletest"
- dep.version == resolved_sha
- dep.integration == :cmake
- dep.group == :test
+ When "finding"
+ package = repo.find(id)
+
+ Then "the peeled commit SHA wins, as one version"
+ package.versions.map(&:version) == ["b" * 40]
end
- test "fetch raises RefResolutionError for unresolvable ref" do
- Given "a tag that does not exist on the remote"
+ test "find raises RefResolutionError when ls-remote fails" do
+ Given "an unreachable remote"
repo = Dev::Deps::GitRepository.new
- failed_status = stub(success?: false)
- Open3.stubs(:capture3)
- .with("git", "ls-remote", "--tags", "https://github.com/example/repo", "nonexistent-tag")
- .returns(["", "", failed_status])
- Open3.stubs(:capture3)
- .with("git", "ls-remote", "https://github.com/example/repo", "refs/heads/nonexistent-tag")
- .returns(["", "", failed_status])
-
- When "fetching by unresolvable tag"
- repo.fetch(
- "name" => "bad",
- "repo" => "https://github.com/example/repo",
- "tag" => "nonexistent-tag",
- "integration" => "cmake",
- "group" => "app",
- )
+ stub_ls_remote("", success: false)
+
+ When "finding"
+ repo.find(id)
Then
raises Dev::Deps::GitRepository::RefResolutionError
end
+
+ test "find raises RefResolutionError when the remote lists no refs" do
+ Given "an empty remote"
+ repo = Dev::Deps::GitRepository.new
+ stub_ls_remote("")
+
+ When "finding"
+ repo.find(id)
+
+ Then
+ raises Dev::Deps::Repository::PackageNotFoundError
+ end
+
+ test "at lifts a commit SHA purely — no network, the SHA is the version" do
+ Given "a commit address"
+ repo = Dev::Deps::GitRepository.new
+ sha = "ee3042f8b0279856061f91069a487e4ed6f69475"
+ Open3.expects(:capture3).never
+
+ When "lifting"
+ version = repo.at(id(name: "opencell", source: "https://github.com/d3mlabs/opencell"), sha)
+
+ Then "the address is trusted at resolve; existence surfaces at fetch"
+ version.version == sha
+ version.digest.nil?
+ version.metadata == { "repo" => "https://github.com/d3mlabs/opencell" }
+ version.declarations == Dev::Deps::Declarations::Resolved.new([])
+ end
end
diff --git a/test/dev/deps/git_scheme_test.rb b/test/dev/deps/git_scheme_test.rb
new file mode 100644
index 0000000..fd681c3
--- /dev/null
+++ b/test/dev/deps/git_scheme_test.rb
@@ -0,0 +1,58 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/git_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::GitSchemeTest < Minitest::Test
+ SHA = "abcdef1234567890abcdef1234567890abcdef12"
+
+ def scheme
+ Dev::Deps::GitScheme.new
+ end
+
+ def pv(version, ref: nil)
+ metadata = ref ? { "ref" => ref } : {}
+ Dev::Deps::PackageVersion.new(version: version, metadata: metadata)
+ end
+
+ test "a tag constraint matches the version's ref fact, not the SHA" do
+ When "evaluating a tag against resolved SHAs carrying their refs"
+ match = scheme.satisfies?(pv(SHA, ref: "v1.17.0"), { "tag" => "v1.17.0" })
+ miss = scheme.satisfies?(pv(SHA, ref: "v1.16.0"), { "tag" => "v1.17.0" })
+ sha_is_not_a_ref = scheme.satisfies?(pv(SHA), { "tag" => SHA })
+
+ Then
+ match == true
+ miss == false
+ sha_is_not_a_ref == false
+ end
+
+ test "a branch constraint matches the same way — heads are enumerated refs too" do
+ When "evaluating a branch"
+ match = scheme.satisfies?(pv(SHA, ref: "main"), { "branch" => "main" })
+ miss = scheme.satisfies?(pv(SHA, ref: "develop"), { "branch" => "main" })
+
+ Then
+ match == true
+ miss == false
+ end
+
+ test "no ref constraint satisfies anything" do
+ When "evaluating an unconstrained declaration"
+ result = scheme.satisfies?(pv(SHA), {})
+
+ Then
+ result == true
+ end
+
+ test "sort preserves order — SHAs carry none" do
+ When "sorting"
+ sorted = scheme.sort(["b", "a"])
+
+ Then
+ sorted == ["b", "a"]
+ end
+end
diff --git a/test/dev/deps/dependency_installer_test.rb b/test/dev/deps/installer_test.rb
similarity index 81%
rename from test/dev/deps/dependency_installer_test.rb
rename to test/dev/deps/installer_test.rb
index a525b19..e21b9da 100644
--- a/test/dev/deps/dependency_installer_test.rb
+++ b/test/dev/deps/installer_test.rb
@@ -2,7 +2,7 @@
# frozen_string_literal: true
require "test_helper"
-require "dev/deps/dependency_installer"
+require "dev/deps/installer"
require "dev/deps/lockfile"
require "dev/deps/dependency"
require "dev/deps/integration"
@@ -21,7 +21,7 @@ def install_all(dependencies)
end
transform!(RSpock::AST::Transformation)
-class Dev::Deps::DependencyInstallerTest < Minitest::Test
+class Dev::Deps::InstallerTest < Minitest::Test
test "install reads lockfiles and dispatches to integrations" do
Given "a lockfile with one cmake dep"
dir = Dir.mktmpdir("installer-test-")
@@ -32,7 +32,7 @@ class Dev::Deps::DependencyInstallerTest < Minitest::Test
]
lockfile.lock(deps)
cmake_integration = RecordingIntegration.new
- installer = Dev::Deps::DependencyInstaller.new(
+ installer = Dev::Deps::Installer.new(
lockfile:, integrations: { cmake: cmake_integration },
)
@@ -47,6 +47,38 @@ class Dev::Deps::DependencyInstallerTest < Minitest::Test
FileUtils.rm_rf(dir)
end
+ test "install dispatches types sharing one integration instance in a single call" do
+ Given "cmake and url deps both wired to one integration instance"
+ dir = Dir.mktmpdir("installer-test-")
+ lockfile = Dev::Deps::Lockfile.new(dir: Pathname(dir))
+ deps = [
+ Dev::Deps::Dependency.new(name: "cereal", integration: :cmake, group: :app,
+ version: "sha1", hash: nil, metadata: {}),
+ Dev::Deps::Dependency.new(name: "boost", integration: :url, group: :app,
+ version: nil, hash: "SHA256=aaa", metadata: {}),
+ ]
+ lockfile.lock(deps)
+
+ shared = RecordingIntegration.new
+ call_batches = []
+ shared.define_singleton_method(:install_all) do |dependencies|
+ call_batches << dependencies.map(&:name)
+ @installed_deps.concat(dependencies)
+ end
+ installer = Dev::Deps::Installer.new(
+ lockfile:, integrations: { cmake: shared, url: shared },
+ )
+
+ When "running install"
+ installer.install
+
+ Then "one install_all call with the union — batch artifacts (deps.cmake) stay whole"
+ call_batches == [["cereal", "boost"]]
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
test "install dispatches build group before others" do
Given "lockfiles with build and app deps"
dir = Dir.mktmpdir("installer-test-")
@@ -72,7 +104,7 @@ class Dev::Deps::DependencyInstallerTest < Minitest::Test
@installed_deps.concat(dependencies)
end
- installer = Dev::Deps::DependencyInstaller.new(
+ installer = Dev::Deps::Installer.new(
lockfile:, integrations: { cmake: cmake_int, brew: brew_int },
)
@@ -102,7 +134,7 @@ class Dev::Deps::DependencyInstallerTest < Minitest::Test
]
lockfile.lock(deps)
brew_int = RecordingIntegration.new
- installer = Dev::Deps::DependencyInstaller.new(
+ installer = Dev::Deps::Installer.new(
lockfile:, integrations: { brew: brew_int },
)
@@ -134,7 +166,7 @@ class Dev::Deps::DependencyInstallerTest < Minitest::Test
lockfile.lock(deps)
brew_int = RecordingIntegration.new
gh_int = RecordingIntegration.new
- installer = Dev::Deps::DependencyInstaller.new(
+ installer = Dev::Deps::Installer.new(
lockfile:, integrations: { brew: brew_int, gh: gh_int },
)
@@ -165,7 +197,7 @@ class Dev::Deps::DependencyInstallerTest < Minitest::Test
bundler_int = RecordingIntegration.new
luarocks_int = RecordingIntegration.new
brew_int = RecordingIntegration.new
- installer = Dev::Deps::DependencyInstaller.new(
+ installer = Dev::Deps::Installer.new(
lockfile:, integrations: { bundler: bundler_int, luarocks: luarocks_int, brew: brew_int },
)
@@ -190,7 +222,7 @@ class Dev::Deps::DependencyInstallerTest < Minitest::Test
version: "1.0", hash: "SHA256=aaa", metadata: {}),
]
lockfile.lock(deps)
- installer = Dev::Deps::DependencyInstaller.new(lockfile:, integrations: {})
+ installer = Dev::Deps::Installer.new(lockfile:, integrations: {})
When "running install with no matching integration"
installer.install
diff --git a/test/dev/deps/llvm_compat_test.rb b/test/dev/deps/llvm_compat_test.rb
index 05ab07e..468ea3e 100644
--- a/test/dev/deps/llvm_compat_test.rb
+++ b/test/dev/deps/llvm_compat_test.rb
@@ -31,6 +31,26 @@ class LlvmCompatTest < Minitest::Test
FileUtils.rm_rf(dir)
end
+ test "project_needs_llvm? detects llvm nested under its integration" do
+ Given "a nested-format lockfile with llvm under brew"
+ dir = Dir.mktmpdir("dev-llvm-compat-")
+ yaml_content = {
+ "brew" => {
+ "llvm" => { "group" => "build", "version" => "22.1.0" },
+ },
+ }
+ File.write(File.join(dir, "build-deps.lock"), YAML.dump(yaml_content))
+
+ When "checking for llvm"
+ result = Dev::ShadowenvLlvm.project_needs_llvm?(Pathname(dir))
+
+ Then
+ result == true
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
test "project_needs_llvm? returns false when no llvm in build-deps.lock" do
Given "a YAML lockfile without llvm"
dir = Dir.mktmpdir("dev-llvm-compat-")
diff --git a/test/dev/deps/locker_test.rb b/test/dev/deps/locker_test.rb
new file mode 100644
index 0000000..2e4d0f5
--- /dev/null
+++ b/test/dev/deps/locker_test.rb
@@ -0,0 +1,16 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/locker"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::LockerTest < Minitest::Test
+ test "base class lock raises NotImplementedError" do
+ When "asking the abstract locker to solve a declaration set"
+ Dev::Deps::Locker.new.lock([])
+
+ Then
+ raises NotImplementedError
+ end
+end
diff --git a/test/dev/deps/lockfile_test.rb b/test/dev/deps/lockfile_test.rb
index 8a42b8a..d7d86d2 100644
--- a/test/dev/deps/lockfile_test.rb
+++ b/test/dev/deps/lockfile_test.rb
@@ -70,7 +70,7 @@ class Dev::Deps::LockfileTest < Minitest::Test
File.read(File.join(dir, "build-deps.lock")).include?("# dependencies-digest: #{digest}")
lockfile.manifest_digest == digest
# The YAML payload stays pure deps — the digest rides a comment.
- YAML.safe_load(File.read(File.join(dir, "deps.lock"))).key?("boost")
+ YAML.safe_load(File.read(File.join(dir, "deps.lock")))["cmake"].key?("boost")
Cleanup
FileUtils.rm_rf(dir)
@@ -182,12 +182,12 @@ class Dev::Deps::LockfileTest < Minitest::Test
lockfile.lock(deps)
content = File.read(File.join(dir, "build-deps.lock"))
- Then
+ Then "env sections nest integration under env name"
content.include?("ccache:")
yaml = YAML.safe_load(content, permitted_classes: [Symbol])
- yaml["env"]["ci"]["ruby"]["version"] == "3.3.0"
- yaml["env"]["ci"]["powershell"]["tap"] == "d3mlabs/d3mlabs"
- yaml["env"]["dev"]["powershell"]["cask"] == true
+ yaml["env"]["ci"]["brew"]["ruby"]["version"] == "3.3.0"
+ yaml["env"]["ci"]["brew"]["powershell"]["tap"] == "d3mlabs/d3mlabs"
+ yaml["env"]["dev"]["brew"]["powershell"]["cask"] == true
Cleanup
FileUtils.rm_rf(dir)
@@ -255,6 +255,76 @@ class Dev::Deps::LockfileTest < Minitest::Test
FileUtils.rm_rf(dir)
end
+ test "locks the same name under two integrations into nested sections" do
+ Given "zlib declared under both cmake and brew"
+ dir = Dir.mktmpdir("dev-lockfile-test-")
+ lockfile = Dev::Deps::Lockfile.new(dir: dir)
+ deps = [
+ Dev::Deps::Dependency.new(name: "zlib", integration: :cmake, group: :app,
+ version: "1.3.1", hash: "SHA256=aaa", metadata: {}),
+ Dev::Deps::Dependency.new(name: "zlib", integration: :brew, group: :app,
+ version: "1.3", hash: "SHA256=bbb", metadata: {}),
+ ]
+
+ When "locking and reading back"
+ lockfile.lock(deps)
+ yaml = YAML.safe_load(File.read(File.join(dir, "deps.lock")))
+ read_deps = lockfile.read
+
+ Then "each lives in its integration's section, no integration field in values"
+ yaml["cmake"]["zlib"]["version"] == "1.3.1"
+ yaml["brew"]["zlib"]["version"] == "1.3"
+ !yaml["cmake"]["zlib"].key?("integration")
+ read_deps.size == 2
+ read_deps.map { |d| [d.integration, d.version] }.sort == [[:brew, "1.3"], [:cmake, "1.3.1"]].sort
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
+ test "reads legacy flat-format lockfiles" do
+ # Migration shim: delete this test with the legacy read path once every
+ # consumer repo's lockfiles have been rewritten by update-deps (issue #146).
+ Given "lockfiles in the pre-nested format, name-keyed with integration in the value"
+ dir = Dir.mktmpdir("dev-lockfile-test-")
+ File.write(File.join(dir, "deps.lock"), <<~YAML)
+ boost:
+ integration: cmake
+ group: app
+ version: 1.90.0
+ hash: SHA256=deadbeef
+ YAML
+ File.write(File.join(dir, "build-deps.lock"), <<~YAML)
+ ccache:
+ integration: brew
+ group: build
+ version: 4.10.2
+ env:
+ ci:
+ ruby:
+ integration: brew
+ group: build
+ version: 3.3.0
+ YAML
+ lockfile = Dev::Deps::Lockfile.new(dir: dir)
+
+ When "reading"
+ deps = lockfile.read
+
+ Then "all entries parse with their recorded integrations"
+ deps.size == 3
+ boost = deps.find { |d| d.name == "boost" }
+ boost.integration == :cmake
+ boost.version == "1.90.0"
+ deps.find { |d| d.name == "ccache" }.integration == :brew
+ ruby_dep = deps.find { |d| d.name == "ruby" }
+ ruby_dep.metadata["env"] == "ci"
+ ruby_dep.integration == :brew
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
test "post_install is not serialized to lockfile" do
Given "a dependency with a post_install hook"
dir = Dir.mktmpdir("dev-lockfile-test-")
diff --git a/test/dev/deps/luarocks_registry_test.rb b/test/dev/deps/luarocks_registry_test.rb
index 0d8a393..7092c4b 100644
--- a/test/dev/deps/luarocks_registry_test.rb
+++ b/test/dev/deps/luarocks_registry_test.rb
@@ -9,66 +9,49 @@
transform!(RSpock::AST::Transformation)
class Dev::Deps::LuaRocksRepositoryTest < Minitest::Test
- test "fetch parses luarocks search output and returns a Dependency" do
- Given "a stubbed luarocks search and download at the Open3 boundary"
+ test "find reports the manifest's versions, deduplicated, facts only" do
+ Given "a stubbed luarocks search listing two versions"
repository = Dev::Deps::LuaRocksRepository.new
- search_output = "luaunit\n 3.5-1 (src) - https://luarocks.org\n 3.4-1 (src) - https://luarocks.org\n"
-
+ search_output = "luaunit\n 3.5-1 (src) - https://luarocks.org\n " \
+ "3.5-1 (rockspec) - https://luarocks.org\n 3.4-1 (src) - https://luarocks.org\n"
Open3.stubs(:capture3)
.with("luarocks", "search", "luaunit", "--porcelain")
.returns([search_output, "", stub(success?: true)])
- Open3.stubs(:capture3)
- .with("luarocks", "download", "luaunit", "3.5-1", "--source", anything)
- .returns(["", "", stub(success?: true)])
- When "fetching the dependency"
- dep = repository.fetch(
- "name" => "luaunit",
- "integration" => "luarocks",
- "group" => "test",
- "constraint" => ">=3.5",
- )
+ When "finding the package"
+ package = repository.find(Dev::Deps::PackageId.new(integration: :luarocks, name: "luaunit"))
- Then "the dependency has the resolved version and integrity hash"
- dep.name == "luaunit"
- dep.integration == :luarocks
- dep.group == :test
- dep.version == "3.5-1"
- dep.hash.start_with?("SHA256=")
+ Then "the universe holds bare versions — no digests, no edges"
+ package.versions.map(&:version) == ["3.5-1", "3.4-1"]
+ package.version("3.5-1").digest.nil?
+ package.version("3.5-1").declarations == Dev::Deps::Declarations::ToolOwned.new
end
- test "fetch raises SearchError when luarocks search fails" do
- Given "a luarocks search that returns a non-zero exit"
+ test "find raises RockNotFoundError, a PackageNotFoundError, on empty search" do
+ Given "a luarocks search that returns no version lines"
repository = Dev::Deps::LuaRocksRepository.new
- failed_status = stub(success?: false)
Open3.stubs(:capture3)
.with("luarocks", "search", "missing", "--porcelain")
- .returns(["", "error", failed_status])
+ .returns(["missing\n", "", stub(success?: true)])
- When "fetching the dependency"
- error = assert_raises(Dev::Deps::LuaRocksRepository::SearchError) do
- repository.fetch("name" => "missing", "integration" => "luarocks",
- "group" => "runtime", "constraint" => ">=1.0")
- end
+ When "finding the package"
+ repository.find(Dev::Deps::PackageId.new(integration: :luarocks, name: "missing"))
- Then "the error mentions the package name"
- error.message.include?("missing")
+ Then
+ raises Dev::Deps::Repository::PackageNotFoundError
end
- test "fetch raises NoVersionError when no versions found" do
- Given "a luarocks search that returns no version lines"
+ test "find raises SearchError when luarocks search fails" do
+ Given "a luarocks search that returns a non-zero exit"
repository = Dev::Deps::LuaRocksRepository.new
Open3.stubs(:capture3)
- .with("luarocks", "search", "empty", "--porcelain")
- .returns(["empty\n", "", stub(success?: true)])
+ .with("luarocks", "search", "broken", "--porcelain")
+ .returns(["", "error", stub(success?: false)])
- When "fetching the dependency"
- error = assert_raises(Dev::Deps::LuaRocksRepository::NoVersionError) do
- repository.fetch("name" => "empty", "integration" => "luarocks",
- "group" => "runtime", "constraint" => ">=1.0")
- end
+ When "finding the package"
+ repository.find(Dev::Deps::PackageId.new(integration: :luarocks, name: "broken"))
- Then "the error mentions the package name"
- error.message.include?("empty")
+ Then
+ raises Dev::Deps::LuaRocksRepository::SearchError
end
end
diff --git a/test/dev/deps/package_id_test.rb b/test/dev/deps/package_id_test.rb
new file mode 100644
index 0000000..16d795c
--- /dev/null
+++ b/test/dev/deps/package_id_test.rb
@@ -0,0 +1,64 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_id"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::PackageIdTest < Minitest::Test
+ test "identifies a package by integration and name" do
+ Given "an id for a registry-backed package"
+ id = Dev::Deps::PackageId.new(integration: :bundler, name: "ffi")
+
+ Expect "the axes are readable and source defaults to nil"
+ id.integration == :bundler
+ id.name == "ffi"
+ id.source.nil?
+ end
+
+ test "is value-equal, so it works as a resolved-set key" do
+ Given "two ids built independently from the same coordinates"
+ a = Dev::Deps::PackageId.new(integration: :bundler, name: "ffi")
+ b = Dev::Deps::PackageId.new(integration: :bundler, name: "ffi")
+
+ Expect "they are equal and collapse to one hash key"
+ a == b
+ { a => 1 }.key?(b)
+ end
+
+ test "the same name under different integrations is a different identity" do
+ Given "'ffi' in the bundler universe and in the pip universe"
+ gem_id = Dev::Deps::PackageId.new(integration: :bundler, name: "ffi")
+ pip_id = Dev::Deps::PackageId.new(integration: :pip, name: "ffi")
+
+ Expect "they do not collide"
+ gem_id != pip_id
+ end
+
+ test "source-based ids carry their source coordinates" do
+ Given "a git-backed cmake dependency"
+ id = Dev::Deps::PackageId.new(integration: :cmake, name: "boost",
+ source: "https://github.com/boostorg/boost.git")
+
+ Expect "source participates in identity"
+ id.source == "https://github.com/boostorg/boost.git"
+ id != Dev::Deps::PackageId.new(integration: :cmake, name: "boost")
+ end
+
+ test "renders integration/name for registry ids" do
+ Given "a registry-backed id"
+ id = Dev::Deps::PackageId.new(integration: :brew, name: "cmake")
+
+ Expect
+ id.to_s == "brew/cmake"
+ end
+
+ test "renders the source alongside for source-based ids" do
+ Given "a source-based id"
+ id = Dev::Deps::PackageId.new(integration: :cmake, name: "boost",
+ source: "https://github.com/boostorg/boost.git")
+
+ Expect
+ id.to_s == "cmake/boost (https://github.com/boostorg/boost.git)"
+ end
+end
diff --git a/test/dev/deps/package_test.rb b/test/dev/deps/package_test.rb
new file mode 100644
index 0000000..b1dd598
--- /dev/null
+++ b/test/dev/deps/package_test.rb
@@ -0,0 +1,62 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package"
+require "dev/deps/package_id"
+require "dev/deps/package_version"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::PackageTest < Minitest::Test
+ def id
+ Dev::Deps::PackageId.new(integration: :bundler, name: "ffi")
+ end
+
+ test "encapsulates its identity and its available versions" do
+ Given "a package with two versions in repository-reported order"
+ v1 = Dev::Deps::PackageVersion.new(version: "1.17.0")
+ v2 = Dev::Deps::PackageVersion.new(version: "1.17.4")
+ package = Dev::Deps::Package.new(id: id, versions: [v2, v1])
+
+ Expect "id and versions read back, order preserved"
+ package.id == id
+ package.versions == [v2, v1]
+ end
+
+ test "the version list is frozen at construction" do
+ Given "a package built from a mutable array"
+ package = Dev::Deps::Package.new(id: id, versions: [Dev::Deps::PackageVersion.new(version: "1.0.0")])
+
+ Expect
+ package.versions.frozen?
+ end
+
+ test "looks up a version by exact string" do
+ Given "a package with a known version"
+ wanted = Dev::Deps::PackageVersion.new(version: "1.17.4")
+ package = Dev::Deps::Package.new(id: id, versions: [Dev::Deps::PackageVersion.new(version: "1.17.0"), wanted])
+
+ When "looking it up exactly"
+ found = package.version("1.17.4")
+
+ Then
+ found == wanted
+ end
+
+ test "exact lookup returns nil for an unknown version" do
+ Given "a package without the requested version"
+ package = Dev::Deps::Package.new(id: id, versions: [Dev::Deps::PackageVersion.new(version: "1.0.0")])
+
+ Expect
+ package.version("9.9.9").nil?
+ end
+
+ test "reports an empty universe" do
+ Given "a package the backing service knows no versions for"
+ package = Dev::Deps::Package.new(id: id, versions: [])
+
+ Expect
+ package.empty?
+ !Dev::Deps::Package.new(id: id, versions: [Dev::Deps::PackageVersion.new(version: "1.0.0")]).empty?
+ end
+end
diff --git a/test/dev/deps/package_version_test.rb b/test/dev/deps/package_version_test.rb
new file mode 100644
index 0000000..37cf0ee
--- /dev/null
+++ b/test/dev/deps/package_version_test.rb
@@ -0,0 +1,110 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/declaration"
+require "dev/deps/declarations"
+require "dev/deps/package_version"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::PackageVersionTest < Minitest::Test
+ test "a bare version defaults every optional fact to its empty form" do
+ Given "a version with no platform, digest, artifact, or declaration facts"
+ version = Dev::Deps::PackageVersion.new(version: "1.2.3")
+
+ Expect "absence is modeled as absence — and the declared-deps claim
+ defaults to the affirmative Resolved([]), never a hidden ToolOwned"
+ version.version == "1.2.3"
+ version.platforms == []
+ version.digest.nil?
+ version.artifacts == {}
+ version.declarations == Dev::Deps::Declarations::Resolved.new([])
+ version.metadata == {}
+ end
+
+ test "carries a ToolOwned claim when stated explicitly" do
+ Given "a version of a tool-owned ecosystem"
+ version = Dev::Deps::PackageVersion.new(
+ version: "7.1.0",
+ declarations: Dev::Deps::Declarations::ToolOwned.new,
+ )
+
+ Expect
+ version.declarations == Dev::Deps::Declarations::ToolOwned.new
+ end
+
+ test "carries ecosystem-specific install facts as metadata" do
+ Given "a version with facts its integration needs at install"
+ version = Dev::Deps::PackageVersion.new(
+ version: "3.12.0",
+ metadata: { "mod_id" => "abc123", "game_version" => ">=491125" },
+ )
+
+ Expect "the facts read back and are frozen"
+ version.metadata["mod_id"] == "abc123"
+ version.metadata.frozen?
+ end
+
+ test "carries the full fact set when the universe provides one" do
+ Given "a version with platforms, digest, per-platform artifacts, and declarations"
+ artifact = Dev::Deps::Artifact.new(uri: "https://example.com/sml-linux.zip", digest: "SHA256=abc")
+ edge = Dev::Deps::Declaration.new(name: "SML", integration: :ficsit, constraint: { "version" => "^3.0.0" })
+ version = Dev::Deps::PackageVersion.new(
+ version: "3.12.0",
+ platforms: ["Windows", "LinuxServer"],
+ digest: "SHA256=fff",
+ artifacts: { "LinuxServer" => artifact },
+ declarations: Dev::Deps::Declarations::Resolved.new([edge]),
+ )
+
+ Expect
+ version.platforms == ["Windows", "LinuxServer"]
+ version.digest == "SHA256=fff"
+ version.artifacts["LinuxServer"] == artifact
+ version.declarations == Dev::Deps::Declarations::Resolved.new([edge])
+ end
+
+ test "collection facts are frozen at construction" do
+ Given "a version built from mutable collections"
+ version = Dev::Deps::PackageVersion.new(
+ version: "1.0.0",
+ platforms: ["Windows"],
+ artifacts: { "Windows" => Dev::Deps::Artifact.new(uri: "https://example.com/a.zip") },
+ )
+
+ Expect "none of them can be mutated after the fact"
+ version.platforms.frozen?
+ version.artifacts.frozen?
+ end
+
+ test "mutating the arrays it was built from cannot change it" do
+ Given "collections handed to the constructor and then mutated"
+ platforms = ["Windows"]
+ version = Dev::Deps::PackageVersion.new(version: "1.0.0", platforms: platforms)
+
+ When "the caller mutates its own array afterwards"
+ platforms << "LinuxServer"
+
+ Then "the version's facts are unaffected"
+ version.platforms == ["Windows"]
+ end
+
+ test "is value-equal" do
+ Given "two versions built from the same facts"
+ a = Dev::Deps::PackageVersion.new(version: "1.0.0", digest: "SHA256=aaa")
+ b = Dev::Deps::PackageVersion.new(version: "1.0.0", digest: "SHA256=aaa")
+
+ Expect
+ a == b
+ a.hash == b.hash
+ end
+
+ test "differing facts are not equal" do
+ Given "two versions that differ only in digest"
+ a = Dev::Deps::PackageVersion.new(version: "1.0.0", digest: "SHA256=aaa")
+ b = Dev::Deps::PackageVersion.new(version: "1.0.0", digest: "SHA256=bbb")
+
+ Expect
+ a != b
+ end
+end
diff --git a/test/dev/deps/pep440_scheme_test.rb b/test/dev/deps/pep440_scheme_test.rb
new file mode 100644
index 0000000..9d6377e
--- /dev/null
+++ b/test/dev/deps/pep440_scheme_test.rb
@@ -0,0 +1,86 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/pep440_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::Pep440SchemeTest < Minitest::Test
+ def scheme
+ Dev::Deps::Pep440Scheme.new
+ end
+
+ def pv(version)
+ Dev::Deps::PackageVersion.new(version: version)
+ end
+
+ test "#{version} against #{requirement.inspect} is #{expected}" do
+ When "evaluating the specifier under PEP 440 semantics"
+ result = scheme.satisfies?(pv(version), { "version" => requirement })
+
+ Then
+ result == expected
+
+ Where
+ version | requirement | expected
+ "2.0.5" | ">=2.0" | true
+ "1.9" | ">=2.0" | false
+ "2.0.5" | "2.0.5" | true
+ "2.0.6" | "2.0.5" | false
+ "2.0.5" | "==2.0.5" | true
+ "2.0.5" | "==2.0.*" | true
+ "2.1.0" | "==2.0.*" | false
+ "2.0.5" | "~=2.0.3" | true
+ "2.1.0" | "~=2.0.3" | false
+ "3.5" | "~=3.0" | true
+ "4.0" | "~=3.0" | false
+ "2.0.0rc1" | ">=2.0.0" | false
+ "2.0.0" | "!=2.0.0" | false
+ "2.0.1" | "!=2.0.0" | true
+ "2.5" | ">=2.0,<3.0" | true
+ "3.0" | ">=2.0,<3.0" | false
+ "2.0" | "==2.0.0" | true
+ "2.1" | ">2.0" | true
+ "2.0" | ">2.0" | false
+ "2.0" | "<=2.0" | true
+ "2.1" | "<=2.0" | false
+ end
+
+ test "an empty constraint is satisfied by anything" do
+ Expect
+ scheme.satisfies?(pv("2.0.5"), {})
+ end
+
+ test "sorts by PEP 440 ordering: dev < pre < release < post" do
+ Given "a full spread of release phases"
+ versions = ["2.0.0", "2.0.0rc1", "2.0.0a1", "1.9.9", "2.0.0.post1", "2.0.0.dev1"]
+
+ When "sorting"
+ sorted = scheme.sort(versions)
+
+ Then
+ sorted == ["1.9.9", "2.0.0.dev1", "2.0.0a1", "2.0.0rc1", "2.0.0", "2.0.0.post1"]
+ end
+
+ test "an epoch outranks any epoch-less version" do
+ Expect
+ scheme.sort(["1!1.0", "99.0"]) == ["99.0", "1!1.0"]
+ end
+
+ test "rejects a version it cannot parse" do
+ When "sorting a non-PEP-440 string"
+ scheme.sort(["totally/wrong"])
+
+ Then
+ raises Dev::Deps::Pep440Scheme::InvalidVersionError
+ end
+
+ test "rejects a specifier it cannot parse" do
+ When "evaluating a malformed specifier"
+ scheme.satisfies?(pv("2.0"), { "version" => "=>2.0" })
+
+ Then
+ raises Dev::Deps::Pep440Scheme::InvalidConstraintError
+ end
+end
diff --git a/test/dev/deps/pip_repository_test.rb b/test/dev/deps/pip_repository_test.rb
index 4a7fb77..4af8ce7 100644
--- a/test/dev/deps/pip_repository_test.rb
+++ b/test/dev/deps/pip_repository_test.rb
@@ -6,34 +6,73 @@
transform!(RSpock::AST::Transformation)
class Dev::Deps::PipRepositoryTest < Minitest::Test
- test "reads the version #{expected} from #{filename}" do
- Given "a repository"
+ test "find reports every PyPI release with its sdist digest" do
+ Given "a stubbed PyPI JSON API response with two releases"
repo = Dev::Deps::PipRepository.new
+ project = {
+ "releases" => {
+ "2.0.5" => [
+ { "packagetype" => "bdist_wheel", "digests" => { "sha256" => "wheelsha" } },
+ { "packagetype" => "sdist", "digests" => { "sha256" => "sdistsha" } },
+ ],
+ "2.1.0" => [
+ { "packagetype" => "bdist_wheel", "digests" => { "sha256" => "onlywheel" } },
+ ],
+ "1.9.0" => [],
+ },
+ }
+ response = stub(body: JSON.generate(project))
+ response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
+ repo.stubs(:get_project).with("totalsegmentator").returns(response)
- Expect "the version is the first digit-leading token after the name"
- repo.send(:version_from_filename, filename, "totalsegmentator") == expected
+ When "finding the package"
+ package = repo.find(Dev::Deps::PackageId.new(integration: :pip, name: "totalsegmentator"))
- Where
- filename | expected
- "totalsegmentator-2.0.5-py3-none-any.whl" | "2.0.5"
- "TotalSegmentator-2.0.5.tar.gz" | "2.0.5"
- "kimimaro-3.4.0-cp312-cp312-macosx_11_0_arm64.whl" | "3.4.0"
- "some_pkg-1.0.zip" | "1.0"
+ Then "sdist digest preferred, wheel fallback, nil for file-less releases"
+ package.versions.map(&:version).sort == ["1.9.0", "2.0.5", "2.1.0"]
+ package.version("2.0.5").digest == "SHA256=sdistsha"
+ package.version("2.1.0").digest == "SHA256=onlywheel"
+ package.version("1.9.0").digest.nil?
+ package.version("2.0.5").declarations == Dev::Deps::Declarations::ToolOwned.new
end
- test "normalize_constraint maps #{input} to #{expected}" do
- Given "a repository"
+ test "find raises ProjectNotFoundError, a PackageNotFoundError, on 404" do
+ Given "a PyPI API that has no such project"
repo = Dev::Deps::PipRepository.new
+ response = stub(code: "404", body: "Not Found")
+ response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false)
+ response.stubs(:is_a?).with(Net::HTTPNotFound).returns(true)
+ repo.stubs(:get_project).with("no-such-project").returns(response)
- Expect "bare versions become == pins, operatored constraints pass through, blanks stay empty"
- repo.send(:normalize_constraint, input) == expected
+ When "finding the package"
+ repo.find(Dev::Deps::PackageId.new(integration: :pip, name: "no-such-project"))
- Where
- input | expected
- "2.0.5" | "==2.0.5"
- ">=2.0" | ">=2.0"
- "~=2.1" | "~=2.1"
- nil | ""
- "" | ""
+ Then
+ raises Dev::Deps::Repository::PackageNotFoundError
+ end
+
+ test "find raises ApiError on other HTTP failures" do
+ Given "a PyPI API returning a 500"
+ repo = Dev::Deps::PipRepository.new
+ response = stub(code: "500", body: "Server Error")
+ response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false)
+ response.stubs(:is_a?).with(Net::HTTPNotFound).returns(false)
+ repo.stubs(:get_project).with("flaky").returns(response)
+
+ When "finding the package"
+ repo.find(Dev::Deps::PackageId.new(integration: :pip, name: "flaky"))
+
+ Then
+ raises Dev::Deps::PipRepository::ApiError
+ end
+
+ test "get_project asks PyPI's JSON API for the project" do
+ Given "an HTTP layer primed for the project URL"
+ repo = Dev::Deps::PipRepository.new
+ response = Net::HTTPOK.new("1.1", "200", "OK")
+ Net::HTTP.expects(:get_response).with(URI("https://pypi.org/pypi/requests/json")).returns(response)
+
+ Expect "the response comes back from that URL"
+ repo.send(:get_project, "requests").equal?(response)
end
end
diff --git a/test/dev/deps/registry_consistency_test.rb b/test/dev/deps/registry_consistency_test.rb
index bb19c5d..c0929f8 100644
--- a/test/dev/deps/registry_consistency_test.rb
+++ b/test/dev/deps/registry_consistency_test.rb
@@ -4,6 +4,8 @@
require "test_helper"
require "dev/deps/registry"
require "dev/deps/dsl"
+require "dev/deps/cache"
+require "tmpdir"
# Anti-drift guard for the integration Registry (lib/dev/deps/registry.rb).
#
@@ -15,15 +17,17 @@
class Dev::Deps::RegistryConsistencyTest < Minitest::Test
DEPS_DIR = File.expand_path("../../../../lib/dev/deps", __dir__)
- # Repositories deliberately not owned by a single integration symbol.
- REPOSITORY_ALLOWLIST = {
- "url_repository.rb" =>
- "UrlRepository is a cmake fetch backend chosen per-dep, not its own integration type",
- }.freeze
+ # Repositories deliberately not owned by a single integration symbol (none today).
+ REPOSITORY_ALLOWLIST = {}.freeze
# Integrations deliberately not host-wired (none today).
INTEGRATION_ALLOWLIST = {}.freeze
+ # Schemes deliberately not owned by a single integration symbol.
+ SCHEME_ALLOWLIST = {
+ "version_scheme.rb" => "VersionScheme is the abstract base every scheme subclasses",
+ }.freeze
+
# Every GroupDSL verb that creates a declaration, mapped to its integration
# symbol. Adding a new declaration verb must add a Registry entry too.
DECLARATION_INTEGRATIONS = %i[bundler brew cmake luarocks ficsit gh steam pip].freeze
@@ -64,6 +68,50 @@ def deps_files(suffix)
assert_empty unwired, "integration classes missing from Registry::INTEGRATIONS: #{unwired.join(", ")}"
end
+ test "every version scheme class is wired into the registry or allowlisted" do
+ Given "the scheme files on disk and the registry's referenced schemes"
+ referenced = Dev::Deps::Registry::INTEGRATIONS.filter_map(&:scheme).uniq.map { |k| source_file(k) }
+
+ When "checking each *_scheme.rb file"
+ unwired = deps_files("_scheme").reject do |basename|
+ SCHEME_ALLOWLIST.key?(basename) ||
+ referenced.include?(File.realpath(File.join(DEPS_DIR, basename)))
+ end
+
+ Then "none are left unwired"
+ assert_empty unwired, "scheme classes missing from Registry::INTEGRATIONS: #{unwired.join(", ")}"
+ end
+
+ test "install_alias entries share their target's integration instance" do
+ Given "a scratch project root"
+ dir = Dir.mktmpdir("registry-alias-test-")
+
+ When "building host integrations from the registry"
+ integrations = Dev::Deps::Registry.host_integrations(
+ project_root: Pathname(dir),
+ cache: Dev::Deps::Cache.new(cache_dir: dir),
+ )
+
+ Then ":url deps install through :cmake's instance, so deps.cmake stays whole"
+ integrations.fetch(:cmake).equal?(integrations.fetch(:url))
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+ end
+
+ test "every locker class is wired into the registry" do
+ Given "the locker files on disk and the registry's referenced lockers"
+ referenced = Dev::Deps::Registry::INTEGRATIONS.filter_map(&:locker).uniq.map { |k| source_file(k) }
+
+ When "checking each *_locker.rb file"
+ unwired = deps_files("_locker").reject do |basename|
+ referenced.include?(File.realpath(File.join(DEPS_DIR, basename)))
+ end
+
+ Then "none are left unwired"
+ assert_empty unwired, "locker classes missing from Registry::INTEGRATIONS: #{unwired.join(", ")}"
+ end
+
test "every declaration DSL verb has a registry entry" do
Given "the integration symbols the registry knows"
known = Dev::Deps::Registry::INTEGRATIONS.map(&:symbol)
diff --git a/test/dev/deps/repository_test.rb b/test/dev/deps/repository_test.rb
index 7b73c9f..60901f6 100644
--- a/test/dev/deps/repository_test.rb
+++ b/test/dev/deps/repository_test.rb
@@ -3,17 +3,30 @@
require "test_helper"
require "dev/deps/repository"
+require "dev/deps/package_id"
transform!(RSpock::AST::Transformation)
class Dev::Deps::RepositoryTest < Minitest::Test
- test "base class fetch raises NotImplementedError" do
+ test "base class find raises NotImplementedError" do
Given "a base Repository instance"
repo = Dev::Deps::Repository.new
- When "fetching a dependency"
- repo.fetch({ "name" => "boost", "constraint" => ">=1.0" })
+ When "finding a package"
+ repo.find(Dev::Deps::PackageId.new(integration: :cmake, name: "boost"))
Then
raises NotImplementedError
end
+
+ test "base class at refuses — no continuous space unless a repository declares one" do
+ Given "a base Repository instance"
+ repo = Dev::Deps::Repository.new
+
+ When "addressing a revision"
+ act = lambda { repo.at(Dev::Deps::PackageId.new(integration: :brew, name: "llvm"), "a" * 40) }
+
+ Then "overriding at is the declaration of an addressable space; the base has none"
+ error = assert_raises(Dev::Deps::Repository::NoAddressableSpaceError) { act.call }
+ error.message.include?("llvm")
+ end
end
diff --git a/test/dev/deps/resolver_test.rb b/test/dev/deps/resolver_test.rb
index 5a2eb73..8603886 100644
--- a/test/dev/deps/resolver_test.rb
+++ b/test/dev/deps/resolver_test.rb
@@ -4,165 +4,242 @@
require "test_helper"
require "dev/deps/resolver"
require "dev/deps/repository"
-require "dev/deps/dependency"
-require "dev/deps/dependency_declaration"
-require "dev/deps/cache"
-require "tmpdir"
-
-# Stub repository that returns canned Dependencies without network calls.
-# Records fetch IDs for assertion.
+require "dev/deps/artifact"
+require "dev/deps/package"
+require "dev/deps/package_id"
+require "dev/deps/package_version"
+require "dev/deps/declaration"
+require "dev/deps/declarations"
+require "dev/deps/scope"
+require "dev/deps/scoped_declaration"
+require "dev/deps/exact_scheme"
+require "dev/deps/semver_scheme"
+
+# Stub repository over a canned universe: name -> [PackageVersion, ...].
+# Records every find call (id) and at call (id + revision) for assertion.
+# addressed: maps revision -> PackageVersion; an unmapped revision falls
+# through to the base's NoAddressableSpaceError.
class StubRepository < Dev::Deps::Repository
- attr_reader :fetched_ids
+ attr_reader :finds, :ats
+
+ def initialize(universes: {}, addressed: {})
+ @universes = universes
+ @addressed = addressed
+ @finds = []
+ @ats = []
+ end
- def initialize(deps_by_name: {})
- @deps_by_name = deps_by_name
- @fetched_ids = []
+ def find(id)
+ @finds << { id: id }
+ versions = @universes.fetch(id.name) do
+ raise Dev::Deps::Repository::PackageNotFoundError, "no package #{id.name}"
+ end
+ Dev::Deps::Package.new(id: id, versions: versions)
end
- def fetch(id)
- @fetched_ids << id
- @deps_by_name.fetch(id["name"])
+ def at(id, revision)
+ @ats << { id: id, revision: revision }
+ @addressed.fetch(revision) { super }
end
end
-# Records the declarations passed to the batch prepare hook.
-class PreparingRepository < Dev::Deps::Repository
- attr_reader :prepared_with
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::ResolverTest < Minitest::Test
+ # Shorthand: a PackageVersion universe entry. dependencies: takes the
+ # Declaration list of a Resolved claim; pass declarations: for ToolOwned.
+ def version(v, digest: nil, platforms: [], dependencies: [], metadata: {},
+ artifacts: {}, declarations: nil)
+ Dev::Deps::PackageVersion.new(
+ version: v, digest: digest, platforms: platforms, artifacts: artifacts,
+ declarations: declarations || Dev::Deps::Declarations::Resolved.new(dependencies),
+ metadata: metadata,
+ )
+ end
- def initialize(deps_by_name: {})
- @deps_by_name = deps_by_name
- @prepared_with = nil
+ # Shorthand: a transitive declaration as a Repository would report it —
+ # integration stamped, constraint already normalized to dev's shape.
+ def edge(name, constraint, integration: :ficsit)
+ Dev::Deps::Declaration.new(
+ name: name,
+ integration: integration,
+ constraint: constraint ? { "version" => constraint } : {},
+ )
end
- def prepare(declarations)
- @prepared_with = declarations
+ # Shorthand: assemble the Declaration + Scope composition from flat kwargs.
+ def declaration(name:, integration:, constraint: {}, source: nil, revision: nil, group: :app,
+ platform: nil, host: nil, env: nil, post_install: nil, materialization: {})
+ Dev::Deps::ScopedDeclaration.new(
+ declaration: Dev::Deps::Declaration.new(name:, integration:, constraint:, source:, revision:),
+ scope: Dev::Deps::Scope.new(group:, host:, env:),
+ platform:, post_install:, materialization:,
+ )
end
- def fetch(id)
- @deps_by_name.fetch(id["name"])
+ def resolver_for(integration, repo, scheme: Dev::Deps::ExactScheme.new(key: "version"))
+ Dev::Deps::Resolver.new(repositories: { integration => repo }, schemes: { integration => scheme })
end
-end
-transform!(RSpock::AST::Transformation)
-class Dev::Deps::ResolverTest < Minitest::Test
- test "calls prepare once per integration with that integration's declarations before fetching" do
- Given "a preparing repo with two declarations of its type"
- foo = Dev::Deps::Dependency.new(name: "foo", integration: :bundler, group: :app,
- version: "1.0", hash: nil, metadata: {})
- bar = Dev::Deps::Dependency.new(name: "bar", integration: :bundler, group: :test,
- version: "2.0", hash: nil, metadata: {})
- repo = PreparingRepository.new(deps_by_name: { "foo" => foo, "bar" => bar })
+ test "mints a pin per declaration from each package's facts" do
+ Given "two independent single-version universes"
+ repo = StubRepository.new(universes: {
+ "boost" => [version("sha1")],
+ "gtest" => [version("sha2", digest: "SHA256=abc")],
+ })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "foo", integration: :bundler, group: :app),
- Dev::Deps::DependencyDeclaration.new(name: "bar", integration: :bundler, group: :test),
+ declaration(name: "boost", integration: :cmake, group: :app),
+ declaration(name: "gtest", integration: :cmake, group: :test),
]
- resolver = Dev::Deps::Resolver.new(repositories: { bundler: repo })
When "resolving"
- result = resolver.resolve(declarations)
+ result = resolver_for(:cmake, repo).resolve(declarations)
- Then "prepare received all bundler declarations and resolution still works"
- repo.prepared_with.map(&:name).sort == ["bar", "foo"]
- result.map(&:name).sort == ["bar", "foo"]
+ Then "each pin carries the version's facts and the declaration's axes"
+ result.map(&:name).sort == ["boost", "gtest"]
+ result.find { |d| d.name == "boost" }.version == "sha1"
+ boost = result.find { |d| d.name == "boost" }
+ boost.integration == :cmake
+ boost.group == :app
+ result.find { |d| d.name == "gtest" }.hash == "SHA256=abc"
end
- test "attaches host and env from the declaration onto resolved metadata" do
- Given "declarations carrying the install-scoping axes"
- engine = Dev::Deps::Dependency.new(name: "UnrealEngineMac", integration: :gh, group: :editor,
- version: "5.8.0-mac-editor-1", hash: nil, metadata: { "repo" => "d3mlabs/unreal-engine" })
- ruby_dep = Dev::Deps::Dependency.new(name: "ruby", integration: :brew, group: :build,
- version: "4.0", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "UnrealEngineMac" => engine, "ruby" => ruby_dep })
+ test "picks the highest satisfying version per the integration's scheme" do
+ Given "a multi-version universe and a semver range"
+ repo = StubRepository.new(universes: {
+ "SML" => [version("3.12.0"), version("3.13.1"), version("4.0.0")],
+ })
+ declarations = [
+ declaration(name: "SML", integration: :ficsit, group: :app, constraint: { "version" => "^3.12.0" }),
+ ]
+
+ When "resolving with SemverScheme"
+ result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations)
+
+ Then "the highest in-range version wins — not the highest overall"
+ result[0].version == "3.13.1"
+ end
+
+ test "skips universe versions the scheme cannot parse instead of failing" do
+ Given "a universe polluted with a non-semver tag"
+ repo = StubRepository.new(universes: {
+ "SML" => [version("not-a-version"), version("3.12.0")],
+ })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "UnrealEngineMac", integration: :gh, group: :editor, host: :darwin),
- Dev::Deps::DependencyDeclaration.new(name: "ruby", integration: :brew, group: :build, env: "ci"),
+ declaration(name: "SML", integration: :ficsit, group: :app, constraint: { "version" => "^3.0.0" }),
+ ]
+
+ When "resolving with SemverScheme"
+ result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations)
+
+ Then "the unparseable candidate is simply not a candidate"
+ result[0].version == "3.12.0"
+ end
+
+ test "raises NoSatisfyingVersionError when nothing in the universe qualifies" do
+ Given "a universe entirely below the constraint"
+ repo = StubRepository.new(universes: { "SML" => [version("2.0.0")] })
+ declarations = [
+ declaration(name: "SML", integration: :ficsit, group: :app, constraint: { "version" => "^3.0.0" }),
]
- resolver = Dev::Deps::Resolver.new(repositories: { gh: repo, brew: repo })
When "resolving"
- result = resolver.resolve(declarations)
+ resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations)
- Then "host/env land in metadata (for the lockfile + install filtering), existing metadata intact"
- mac = result.find { |d| d.name == "UnrealEngineMac" }
- mac.metadata["host"] == "darwin"
- mac.metadata["repo"] == "d3mlabs/unreal-engine"
- result.find { |d| d.name == "ruby" }.metadata["env"] == "ci"
- # Neither axis leaks into the fetch id: the constraint describes what the dep is.
- repo.fetched_ids.none? { |id| id.key?("host") || id.key?("env") }
- end
-
- test "transitive dependencies inherit the declaring dep's host and env" do
- Given "a host/env-scoped dep with a transitive dependency"
- parent = Dev::Deps::Dependency.new(name: "parent", integration: :brew, group: :build,
- version: "1.0", hash: nil, metadata: {},
- dependencies: [{ name: "child", constraint: ">= 1.0" }])
- child = Dev::Deps::Dependency.new(name: "child", integration: :brew, group: :build,
- version: "2.0", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "parent" => parent, "child" => child })
+ Then
+ raises Dev::Deps::Resolver::NoSatisfyingVersionError
+ end
+
+ test "raises ConflictingDeclarationError when one name carries two constraints" do
+ Given "the same dep declared with disagreeing constraints"
+ repo = StubRepository.new(universes: { "SML" => [version("3.12.0")] })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "parent", integration: :brew, group: :build,
- host: :darwin, env: "ci"),
+ declaration(name: "SML", integration: :ficsit, group: :app, constraint: { "version" => "^3.0.0" }),
+ declaration(name: "SML", integration: :ficsit, group: :test, constraint: { "version" => "^2.0.0" }),
]
- resolver = Dev::Deps::Resolver.new(repositories: { brew: repo })
When "resolving"
- result = resolver.resolve(declarations)
+ resolver_for(:ficsit, repo).resolve(declarations)
- Then "the child carries the parent's scoping — it can't be needed anywhere the parent isn't"
- resolved_child = result.find { |d| d.name == "child" }
- resolved_child.metadata["host"] == "darwin"
- resolved_child.metadata["env"] == "ci"
+ Then
+ raises Dev::Deps::Resolver::ConflictingDeclarationError
end
- test "resolves a flat list of declarations with no transitive dependencies" do
- Given "two independent declarations"
- boost = Dev::Deps::Dependency.new(name: "boost", integration: :cmake, group: :app,
- version: "sha1", hash: nil, metadata: {})
- gtest = Dev::Deps::Dependency.new(name: "gtest", integration: :cmake, group: :test,
- version: "sha2", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "boost" => boost, "gtest" => gtest })
+ test "resolves the same name declared under two integrations independently" do
+ Given "ffi declared under both bundler and brew"
+ bundler_repo = StubRepository.new(universes: { "ffi" => [version("1.17.0")] })
+ brew_repo = StubRepository.new(universes: { "ffi" => [version("3.4.0")] })
+ resolver = Dev::Deps::Resolver.new(
+ repositories: { bundler: bundler_repo, brew: brew_repo },
+ schemes: {
+ bundler: Dev::Deps::ExactScheme.new(key: "version"),
+ brew: Dev::Deps::ExactScheme.new(key: "version"),
+ },
+ )
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "boost", integration: :cmake, group: :app),
- Dev::Deps::DependencyDeclaration.new(name: "gtest", integration: :cmake, group: :test),
+ declaration(name: "ffi", integration: :bundler, group: :app),
+ declaration(name: "ffi", integration: :brew, group: :app),
]
- resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo })
When "resolving"
result = resolver.resolve(declarations)
- Then
+ Then "both resolve, each in its own integration's universe"
result.size == 2
- result.map(&:name).sort == ["boost", "gtest"]
+ result.map { |d| [d.integration, d.version] }.sort == [[:brew, "3.4.0"], [:bundler, "1.17.0"]].sort
end
- test "resolves transitive dependencies via Dependency#dependencies" do
- Given "a parent with a transitive child"
- child = Dev::Deps::Dependency.new(name: "child", integration: :luarocks, group: :test,
- version: "2.0", hash: "SHA256=bbb", metadata: {})
- parent = Dev::Deps::Dependency.new(name: "parent", integration: :luarocks, group: :test,
- version: "1.0", hash: "SHA256=aaa", metadata: {},
- dependencies: [{ name: "child", constraint: ">= 1.0" }])
- repo = StubRepository.new(deps_by_name: { "parent" => parent, "child" => child })
+ test "same name under two integrations with different constraints is not a conflict" do
+ Given "each integration constrains its own ffi differently"
+ bundler_repo = StubRepository.new(universes: { "ffi" => [version("1.17.0")] })
+ brew_repo = StubRepository.new(universes: { "ffi" => [version("3.4.0")] })
+ resolver = Dev::Deps::Resolver.new(
+ repositories: { bundler: bundler_repo, brew: brew_repo },
+ schemes: { bundler: Dev::Deps::SemverScheme.new, brew: Dev::Deps::SemverScheme.new },
+ )
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "parent", integration: :luarocks, group: :test),
+ declaration(name: "ffi", integration: :bundler, group: :app, constraint: { "version" => "^1.0.0" }),
+ declaration(name: "ffi", integration: :brew, group: :app, constraint: { "version" => "^3.0.0" }),
]
- resolver = Dev::Deps::Resolver.new(repositories: { luarocks: repo })
When "resolving"
result = resolver.resolve(declarations)
- Then
+ Then "no ConflictingDeclarationError — constraints are scoped per integration"
result.size == 2
- result.map(&:name).sort == ["child", "parent"]
end
- test "raises UnknownIntegrationError for unregistered integration" do
- Given "a declaration referencing an unregistered integration"
- resolver = Dev::Deps::Resolver.new(repositories: {})
+ test "transitive edge resolves within its own integration's namespace" do
+ Given "a ficsit mod with an edge to zlib, and brew's zlib also declared"
+ ficsit_repo = StubRepository.new(universes: {
+ "SML" => [version("3.12.0", dependencies: [edge("zlib", nil)])],
+ "zlib" => [version("1.3.1")],
+ })
+ brew_repo = StubRepository.new(universes: { "zlib" => [version("1.2.0")] })
+ resolver = Dev::Deps::Resolver.new(
+ repositories: { ficsit: ficsit_repo, brew: brew_repo },
+ schemes: { ficsit: Dev::Deps::SemverScheme.new, brew: Dev::Deps::ExactScheme.new(key: "version") },
+ )
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "foo", integration: :unknown, group: :app),
+ declaration(name: "zlib", integration: :brew, group: :app),
+ declaration(name: "SML", integration: :ficsit, group: :app),
]
+ When "resolving"
+ result = resolver.resolve(declarations)
+
+ Then "zlib resolved twice, once per integration, from each one's universe"
+ result.size == 3
+ zlibs = result.select { |d| d.name == "zlib" }
+ zlibs.map(&:integration).sort == [:brew, :ficsit]
+ zlibs.find { |d| d.integration == :ficsit }.version == "1.3.1"
+ zlibs.find { |d| d.integration == :brew }.version == "1.2.0"
+ end
+
+ test "raises UnknownIntegrationError for unregistered integration" do
+ Given "a declaration referencing an unregistered integration"
+ resolver = Dev::Deps::Resolver.new(repositories: {}, schemes: {})
+ declarations = [declaration(name: "foo", integration: :unknown, group: :app)]
+
When "resolving"
resolver.resolve(declarations)
@@ -170,216 +247,372 @@ class Dev::Deps::ResolverTest < Minitest::Test
raises Dev::Deps::Resolver::UnknownIntegrationError
end
- test "does not duplicate already-resolved transitive deps" do
- Given "overlapping direct and transitive deps"
- a = Dev::Deps::Dependency.new(name: "a", integration: :cmake, group: :app,
- version: "1.0", hash: nil, metadata: {},
- dependencies: [{ name: "b", constraint: ">= 1.0" }])
- b = Dev::Deps::Dependency.new(name: "b", integration: :cmake, group: :app,
- version: "1.0", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "a" => a, "b" => b })
+ test "find receives identity only — the source on the id, never the constraint" do
+ Given "a pinned-identity declaration (gh-style)"
+ repo = StubRepository.new(universes: { "engine" => [version("5.8.0")] })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "a", integration: :cmake, group: :app),
- Dev::Deps::DependencyDeclaration.new(name: "b", integration: :cmake, group: :app),
+ declaration(name: "engine", integration: :gh, group: :editor,
+ constraint: { "tag" => "5.8.0" }, source: "d3mlabs/unreal-engine"),
]
- resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo })
- When "resolving"
- result = resolver.resolve(declarations)
+ When "resolving with the gh exact scheme"
+ resolver_for(:gh, repo, scheme: Dev::Deps::ExactScheme.new(key: "tag")).resolve(declarations)
- Then
- result.size == 2
- result.map(&:name).sort == ["a", "b"]
+ Then "the declaration's source rides the id; the tag stays scheme-side"
+ repo.finds == [{ id: Dev::Deps::PackageId.new(integration: :gh, name: "engine", source: "d3mlabs/unreal-engine") }]
end
- test "handles declarations with empty transitive dependencies" do
- Given "a declaration with no transitive deps"
- solo = Dev::Deps::Dependency.new(name: "solo", integration: :cmake, group: :app,
- version: "1.0", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "solo" => solo })
+ test "attaches host and env from the declaration onto minted metadata" do
+ Given "declarations carrying the install-scoping axes"
+ repo = StubRepository.new(universes: {
+ "UnrealEngineMac" => [version("5.8.0", metadata: { "repo" => "d3mlabs/unreal-engine" })],
+ "ruby" => [version("4.0")],
+ })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "solo", integration: :cmake, group: :app),
+ declaration(name: "UnrealEngineMac", integration: :gh, group: :editor, host: :darwin),
+ declaration(name: "ruby", integration: :gh, group: :build, env: "ci"),
]
- resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo })
When "resolving"
- result = resolver.resolve(declarations)
+ result = resolver_for(:gh, repo).resolve(declarations)
- Then
- result.size == 1
- result[0].name == "solo"
- end
-
- test "resolves deep transitive chains beyond depth 1" do
- Given "A depends on B, B depends on C"
- c = Dev::Deps::Dependency.new(name: "c", integration: :luarocks, group: :app,
- version: "3.0", hash: "SHA256=ccc", metadata: {})
- b = Dev::Deps::Dependency.new(name: "b", integration: :luarocks, group: :app,
- version: "2.0", hash: "SHA256=bbb", metadata: {},
- dependencies: [{ name: "c", constraint: ">= 3.0" }])
- a = Dev::Deps::Dependency.new(name: "a", integration: :luarocks, group: :app,
- version: "1.0", hash: "SHA256=aaa", metadata: {},
- dependencies: [{ name: "b", constraint: ">= 2.0" }])
- repo = StubRepository.new(deps_by_name: { "a" => a, "b" => b, "c" => c })
+ Then "host/env land in metadata, existing metadata intact, axes never reach find"
+ mac = result.find { |d| d.name == "UnrealEngineMac" }
+ mac.metadata["host"] == "darwin"
+ mac.metadata["repo"] == "d3mlabs/unreal-engine"
+ result.find { |d| d.name == "ruby" }.metadata["env"] == "ci"
+ end
+
+ test "walks transitive edges, inheriting group, host, and env" do
+ Given "a scoped parent whose chosen version has an edge"
+ repo = StubRepository.new(universes: {
+ "parent" => [version("1.0.0", dependencies: [edge("child", "^2.0.0")])],
+ "child" => [version("2.0.0"), version("3.0.0")],
+ })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "a", integration: :luarocks, group: :app),
+ declaration(name: "parent", integration: :ficsit, group: :test, host: :darwin, env: "ci"),
]
- resolver = Dev::Deps::Resolver.new(repositories: { luarocks: repo })
When "resolving"
- result = resolver.resolve(declarations)
+ result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations)
+
+ Then "the child resolved under the edge constraint with the parent's scoping"
+ child = result.find { |d| d.name == "child" }
+ child.version == "2.0.0"
+ child.group == :test
+ child.metadata["host"] == "darwin"
+ child.metadata["env"] == "ci"
+ end
- Then
- result.size == 3
+ test "resolves deep transitive chains and terminates on cycles" do
+ Given "a depends on b, b depends on c, c depends back on a"
+ repo = StubRepository.new(universes: {
+ "a" => [version("1.0.0", dependencies: [edge("b", nil)])],
+ "b" => [version("2.0.0", dependencies: [edge("c", nil)])],
+ "c" => [version("3.0.0", dependencies: [edge("a", nil)])],
+ })
+ declarations = [declaration(name: "a", integration: :ficsit, group: :app)]
+
+ When "resolving"
+ result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations)
+
+ Then "each package resolved exactly once"
result.map(&:name).sort == ["a", "b", "c"]
+ repo.finds.size == 3
end
test "resolves diamond dependencies without duplication" do
- Given "A depends on B and C, both depend on D"
- d = Dev::Deps::Dependency.new(name: "d", integration: :luarocks, group: :app,
- version: "1.0", hash: "SHA256=ddd", metadata: {})
- b = Dev::Deps::Dependency.new(name: "b", integration: :luarocks, group: :app,
- version: "1.0", hash: "SHA256=bbb", metadata: {},
- dependencies: [{ name: "d", constraint: ">= 1.0" }])
- c = Dev::Deps::Dependency.new(name: "c", integration: :luarocks, group: :app,
- version: "1.0", hash: "SHA256=ccc", metadata: {},
- dependencies: [{ name: "d", constraint: ">= 1.0" }])
- a = Dev::Deps::Dependency.new(name: "a", integration: :luarocks, group: :app,
- version: "1.0", hash: "SHA256=aaa", metadata: {},
- dependencies: [
- { name: "b", constraint: ">= 1.0" },
- { name: "c", constraint: ">= 1.0" },
- ])
- repo = StubRepository.new(deps_by_name: { "a" => a, "b" => b, "c" => c, "d" => d })
- declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "a", integration: :luarocks, group: :app),
- ]
- resolver = Dev::Deps::Resolver.new(repositories: { luarocks: repo })
+ Given "a depends on b and c, both depend on d"
+ repo = StubRepository.new(universes: {
+ "a" => [version("1.0.0", dependencies: [edge("b", nil), edge("c", nil)])],
+ "b" => [version("1.0.0", dependencies: [edge("d", nil)])],
+ "c" => [version("1.0.0", dependencies: [edge("d", nil)])],
+ "d" => [version("1.0.0")],
+ })
+ declarations = [declaration(name: "a", integration: :ficsit, group: :app)]
When "resolving"
- result = resolver.resolve(declarations)
+ result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations)
Then
result.size == 4
result.map(&:name).sort == ["a", "b", "c", "d"]
end
- test "terminates on cyclic transitive dependencies" do
- Given "A depends on B, B depends on A"
- a = Dev::Deps::Dependency.new(name: "a", integration: :cmake, group: :app,
- version: "1.0", hash: nil, metadata: {},
- dependencies: [{ name: "b", constraint: {} }])
- b = Dev::Deps::Dependency.new(name: "b", integration: :cmake, group: :app,
- version: "1.0", hash: nil, metadata: {},
- dependencies: [{ name: "a", constraint: {} }])
- repo = StubRepository.new(deps_by_name: { "a" => a, "b" => b })
+ test "does not duplicate deps declared directly and reachable transitively" do
+ Given "overlapping direct and transitive deps"
+ repo = StubRepository.new(universes: {
+ "a" => [version("1.0.0", dependencies: [edge("b", nil)])],
+ "b" => [version("1.0.0")],
+ })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "a", integration: :cmake, group: :app),
+ declaration(name: "a", integration: :ficsit, group: :app),
+ declaration(name: "b", integration: :ficsit, group: :app),
]
- resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo })
When "resolving"
- result = resolver.resolve(declarations)
+ result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations)
Then
result.size == 2
- result.map(&:name).sort == ["a", "b"]
end
- test "transitive dependencies inherit parent's group" do
- Given "a :test parent with a transitive child"
- child = Dev::Deps::Dependency.new(name: "child", integration: :luarocks, group: :test,
- version: "2.0", hash: "SHA256=bbb", metadata: {})
- parent = Dev::Deps::Dependency.new(name: "parent", integration: :luarocks, group: :test,
- version: "1.0", hash: "SHA256=aaa", metadata: {},
- dependencies: [{ name: "child", constraint: ">= 1.0" }])
- repo = StubRepository.new(deps_by_name: { "parent" => parent, "child" => child })
+ test "a ToolOwned claim resolves as a single pin with nothing walked" do
+ Given "a bundler-style version whose tool owns the transitive closure"
+ repo = StubRepository.new(universes: {
+ "rails" => [version("7.1.0", declarations: Dev::Deps::Declarations::ToolOwned.new)],
+ })
+ declarations = [declaration(name: "rails", integration: :bundler, group: :app)]
+
+ When "resolving"
+ result = resolver_for(:bundler, repo).resolve(declarations)
+
+ Then "one pin, one find — dev never pretends to see the tool's closure"
+ result.map(&:name) == ["rails"]
+ repo.finds.size == 1
+ end
+
+ test "unions platforms across groups into the pin's projected install facts" do
+ Given "SML declared in :app (no platform) and :integration (LinuxServer)"
+ artifacts = {
+ "Windows" => Dev::Deps::Artifact.new(uri: "https://x/win.zip", digest: "SHA256=w"),
+ "LinuxServer" => Dev::Deps::Artifact.new(uri: "https://x/linux.zip", digest: "SHA256=l"),
+ }
+ repo = StubRepository.new(universes: {
+ "SML" => [version("3.12.0", platforms: ["Windows", "LinuxServer"], artifacts: artifacts)],
+ })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "parent", integration: :luarocks, group: :test),
+ declaration(name: "SML", integration: :ficsit, group: :app, materialization: { "target" => "Windows" }),
+ declaration(name: "SML", integration: :ficsit, group: :integration, platform: "LinuxServer",
+ materialization: { "target" => "Windows" }),
]
- resolver = Dev::Deps::Resolver.new(repositories: { luarocks: repo })
When "resolving"
- resolver.resolve(declarations)
+ result = resolver_for(:ficsit, repo).resolve(declarations)
- Then "child was fetched with parent's :test group"
- child_id = repo.fetched_ids.find { |id| id["name"] == "child" }
- child_id["group"] == "test"
+ Then "found once; the pin's platforms block covers both groups' targets, no single target"
+ result.size == 1
+ repo.finds.size == 1
+ result[0].metadata["platforms"] == {
+ "LinuxServer" => { "hash" => "SHA256=l", "link" => "https://x/linux.zip" },
+ "Windows" => { "hash" => "SHA256=w", "link" => "https://x/win.zip" },
+ }
+ !result[0].metadata.key?("target")
+ result[0].hash.nil?
end
- test "normalizes string constraints on transitive deps to Hash" do
- Given "a parent whose transitive dep has a string constraint"
- child = Dev::Deps::Dependency.new(name: "child", integration: :luarocks, group: :app,
- version: "2.0", hash: "SHA256=bbb", metadata: {})
- parent = Dev::Deps::Dependency.new(name: "parent", integration: :luarocks, group: :app,
- version: "1.0", hash: "SHA256=aaa", metadata: {},
- dependencies: [{ name: "child", constraint: ">= 2.0" }])
- repo = StubRepository.new(deps_by_name: { "parent" => parent, "child" => child })
+ test "projects the materialization's target into the single-target pin shape" do
+ Given "a mod declared with no group platform"
+ artifacts = {
+ "Windows" => Dev::Deps::Artifact.new(uri: "https://x/win.zip", digest: "SHA256=w"),
+ }
+ repo = StubRepository.new(universes: {
+ "SML" => [version("3.12.0", platforms: ["Windows"], artifacts: artifacts)],
+ })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "parent", integration: :luarocks, group: :app),
+ declaration(name: "SML", integration: :ficsit, group: :app, materialization: { "target" => "Windows" }),
]
- resolver = Dev::Deps::Resolver.new(repositories: { luarocks: repo })
When "resolving"
- resolver.resolve(declarations)
+ result = resolver_for(:ficsit, repo).resolve(declarations)
- Then "string constraint was normalized to a version hash"
- child_id = repo.fetched_ids.find { |id| id["name"] == "child" }
- child_id["version"] == ">= 2.0"
+ Then "the pin carries the target and its artifact's digest as the hash"
+ result[0].metadata["target"] == "Windows"
+ result[0].hash == "SHA256=w"
end
- test "unions platforms across groups and resolves a duplicated dep once" do
- Given "SML declared in :app (no platform) and :integration (LinuxServer)"
- sml = Dev::Deps::Dependency.new(name: "SML", integration: :ficsit, group: :app,
- version: "3.12.0", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "SML" => sml })
+ test "merges the declaration's materialization into the pin's metadata" do
+ Given "an install-instruction-carrying declaration (gh-style)"
+ repo = StubRepository.new(universes: { "engine" => [version("5.8.0", metadata: { "commit" => "abc" })] })
+ declarations = [
+ declaration(name: "engine", integration: :gh, group: :editor,
+ constraint: { "tag" => "5.8.0" },
+ materialization: { "install_dir" => "~/.dev/engines/ue", "asset_pattern" => "*.tar.zst.*" }),
+ ]
+
+ When "resolving"
+ result = resolver_for(:gh, repo, scheme: Dev::Deps::ExactScheme.new(key: "tag")).resolve(declarations)
+
+ Then "version facts and install instructions meet in the pin, nothing lost"
+ result[0].metadata["commit"] == "abc"
+ result[0].metadata["install_dir"] == "~/.dev/engines/ue"
+ result[0].metadata["asset_pattern"] == "*.tar.zst.*"
+ end
+
+ test "a scheme-less integration resolves an empty ask over the universe as reported" do
+ Given "a url-style singleton universe and no scheme registered"
+ repo = StubRepository.new(universes: {
+ "boost" => [version("", digest: "SHA256=abc", metadata: { "url" => "https://example.com/b.tar.gz" })],
+ })
+ resolver = Dev::Deps::Resolver.new(repositories: { url: repo }, schemes: {})
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "SML", integration: :ficsit, group: :app),
- Dev::Deps::DependencyDeclaration.new(name: "SML", integration: :ficsit, group: :integration,
- platform: "LinuxServer"),
+ declaration(name: "boost", integration: :url, group: :app,
+ source: "https://example.com/b.tar.gz",
+ materialization: { "version_label" => "boost-1.90.0" }),
]
- resolver = Dev::Deps::Resolver.new(repositories: { ficsit: repo })
When "resolving"
result = resolver.resolve(declarations)
- Then "fetched once, with the union of both groups' platforms"
+ Then "the singleton pins; the display label is promoted into the version slot"
result.size == 1
- repo.fetched_ids.size == 1
- repo.fetched_ids[0]["platforms"].sort_by(&:to_s) == [nil, "LinuxServer"].sort_by(&:to_s)
+ result[0].version == "boost-1.90.0"
+ result[0].hash == "SHA256=abc"
+ result[0].metadata["url"] == "https://example.com/b.tar.gz"
+ !result[0].metadata.key?("version_label")
+ end
+
+ test "an unlabeled scheme-less singleton mints a nil pin version" do
+ Given "a url dep with no display label"
+ repo = StubRepository.new(universes: { "tool" => [version("", digest: "SHA256=t")] })
+ resolver = Dev::Deps::Resolver.new(repositories: { url: repo }, schemes: {})
+
+ When "resolving"
+ result = resolver.resolve([declaration(name: "tool", integration: :url, group: :app)])
+
+ Then
+ result[0].version.nil?
end
- test "omits platforms from the fetch id when no group pins a platform" do
- Given "a dep declared only in groups without a platform"
- boost = Dev::Deps::Dependency.new(name: "boost", integration: :cmake, group: :app,
- version: "1.0", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "boost" => boost })
+ test "a constraint against a scheme-less integration fails loudly" do
+ Given "a url dep declared with a version constraint no grammar can evaluate"
+ repo = StubRepository.new(universes: { "boost" => [version("")] })
+ resolver = Dev::Deps::Resolver.new(repositories: { url: repo }, schemes: {})
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "boost", integration: :cmake, group: :app),
+ declaration(name: "boost", integration: :url, group: :app, constraint: { "version" => "1.90" }),
]
- resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo })
When "resolving"
resolver.resolve(declarations)
- Then "no platforms key leaks into the fetch id"
- !repo.fetched_ids[0].key?("platforms")
+ Then "no scheme means no constraint grammar — the declaration is wrong"
+ raises Dev::Deps::Resolver::UnknownIntegrationError
+ end
+
+ test "a revision ask dispatches to at — no universe query, no scheme" do
+ Given "a repository with an addressable space and a revision-pinned declaration"
+ sha = "ee3042f8b0279856061f91069a487e4ed6f69475"
+ repo = StubRepository.new(addressed: {
+ sha => version(sha, metadata: { "repo" => "https://github.com/d3mlabs/opencell" }),
+ })
+ declarations = [
+ declaration(name: "opencell", integration: :cmake, group: :build,
+ source: "https://github.com/d3mlabs/opencell", revision: sha,
+ materialization: { "cmake_targets" => ["opencell"] }),
+ ]
+
+ When "resolving"
+ result = resolver_for(:cmake, repo).resolve(declarations)
+
+ Then "the pin is minted straight from the lifted address, materialization merged as usual"
+ repo.finds.empty?
+ repo.ats == [{ id: Dev::Deps::PackageId.new(integration: :cmake, name: "opencell",
+ source: "https://github.com/d3mlabs/opencell"),
+ revision: sha }]
+ result.size == 1
+ result[0].version == sha
+ result[0].metadata["repo"] == "https://github.com/d3mlabs/opencell"
+ result[0].metadata["cmake_targets"] == ["opencell"]
+ end
+
+ test "a revision ask against an integration with no continuous space fails loudly" do
+ Given "a revision pin on a repository that only enumerates"
+ repo = StubRepository.new(universes: { "llvm" => [version("18.1.8")] })
+ declarations = [declaration(name: "llvm", integration: :brew, group: :build, revision: "18")]
+
+ When "resolving"
+ resolver_for(:brew, repo).resolve(declarations)
+
+ Then "the repository's refusal propagates — no silent fallback to find"
+ raises Dev::Deps::Repository::NoAddressableSpaceError
+ end
+
+ test "raises ConflictingDeclarationError when one name is pinned at two revisions" do
+ Given "the same dep addressed at two commits"
+ repo = StubRepository.new(universes: { "opencell" => [version("a" * 40)] })
+ declarations = [
+ declaration(name: "opencell", integration: :cmake, group: :app, revision: "a" * 40),
+ declaration(name: "opencell", integration: :cmake, group: :test, revision: "b" * 40),
+ ]
+
+ When "resolving"
+ resolver_for(:cmake, repo).resolve(declarations)
+
+ Then "two addresses cannot both be the pin"
+ raises Dev::Deps::Resolver::ConflictingDeclarationError
+ end
+
+ test "raises ConflictingDeclarationError when one name carries two materializations" do
+ Given "the same dep declared into two install dirs"
+ repo = StubRepository.new(universes: { "engine" => [version("5.8.0")] })
+ declarations = [
+ declaration(name: "engine", integration: :gh, group: :app, materialization: { "install_dir" => "~/a" }),
+ declaration(name: "engine", integration: :gh, group: :test, materialization: { "install_dir" => "~/b" }),
+ ]
+
+ When "resolving"
+ resolver_for(:gh, repo).resolve(declarations)
+
+ Then "one row's install dir must not win silently"
+ raises Dev::Deps::Resolver::ConflictingDeclarationError
+ end
+
+ test "rejects versions that do not publish an explicitly requested platform" do
+ Given "the latest version dropped the requested platform"
+ repo = StubRepository.new(universes: {
+ "SML" => [
+ version("3.12.0", platforms: ["Windows", "LinuxServer"]),
+ version("3.13.0", platforms: ["Windows"]),
+ ],
+ })
+ declarations = [
+ declaration(name: "SML", integration: :ficsit, group: :app, platform: "LinuxServer",
+ constraint: { "version" => "^3.0.0" }),
+ ]
+
+ When "resolving"
+ result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations)
+
+ Then "the older version that still publishes the platform wins"
+ result[0].version == "3.12.0"
+ end
+
+ test "leaves artifact-less pins unprojected — the version digest is the hash" do
+ Given "a dep whose version carries a digest but no artifacts (brew-style)"
+ repo = StubRepository.new(universes: { "cmake" => [version("3.31.4", digest: "SHA256=abc")] })
+ declarations = [declaration(name: "cmake", integration: :brew, group: :build)]
+
+ When "resolving"
+ result = resolver_for(:brew, repo).resolve(declarations)
+
+ Then
+ result[0].hash == "SHA256=abc"
+ !result[0].metadata.key?("platforms")
+ end
+
+ test "an empty chosen version string becomes a nil pin version" do
+ Given "a versionless universe (brew cask style)"
+ repo = StubRepository.new(universes: { "docker" => [version("", metadata: { "cask" => true })] })
+ declarations = [declaration(name: "docker", integration: :brew, group: :app)]
+
+ When "resolving"
+ result = resolver_for(:brew, repo).resolve(declarations)
+
+ Then
+ result[0].version.nil?
+ result[0].metadata["cask"] == true
end
test "carries post_install from declaration to resolved dependency" do
Given "a declaration with a post_install hook"
hook = ->(dep, root) {}
- dep = Dev::Deps::Dependency.new(name: "gtest", integration: :cmake, group: :test,
- version: "sha1", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "gtest" => dep })
+ repo = StubRepository.new(universes: { "gtest" => [version("sha1")] })
declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "gtest", integration: :cmake, group: :test,
- post_install: hook),
+ declaration(name: "gtest", integration: :cmake, group: :test, post_install: hook),
]
- resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo })
When "resolving"
- result = resolver.resolve(declarations)
+ result = resolver_for(:cmake, repo).resolve(declarations)
Then
result[0].post_install == hook
@@ -387,18 +620,25 @@ class Dev::Deps::ResolverTest < Minitest::Test
test "post_install is nil when declaration has none" do
Given "a declaration without post_install"
- dep = Dev::Deps::Dependency.new(name: "boost", integration: :cmake, group: :app,
- version: "sha1", hash: nil, metadata: {})
- repo = StubRepository.new(deps_by_name: { "boost" => dep })
- declarations = [
- Dev::Deps::DependencyDeclaration.new(name: "boost", integration: :cmake, group: :app),
- ]
- resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo })
+ repo = StubRepository.new(universes: { "boost" => [version("sha1")] })
+ declarations = [declaration(name: "boost", integration: :cmake, group: :app)]
When "resolving"
- result = resolver.resolve(declarations)
+ result = resolver_for(:cmake, repo).resolve(declarations)
Then
result[0].post_install.nil?
end
+
+ test "lets PackageNotFoundError from the repository propagate" do
+ Given "a declaration nothing in the universe answers"
+ repo = StubRepository.new(universes: {})
+ declarations = [declaration(name: "ghost", integration: :cmake, group: :app)]
+
+ When "resolving"
+ resolver_for(:cmake, repo).resolve(declarations)
+
+ Then
+ raises Dev::Deps::Repository::PackageNotFoundError
+ end
end
diff --git a/test/dev/deps/rock_scheme_test.rb b/test/dev/deps/rock_scheme_test.rb
new file mode 100644
index 0000000..3623812
--- /dev/null
+++ b/test/dev/deps/rock_scheme_test.rb
@@ -0,0 +1,80 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/rock_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::RockSchemeTest < Minitest::Test
+ def scheme
+ Dev::Deps::RockScheme.new
+ end
+
+ def pv(version)
+ Dev::Deps::PackageVersion.new(version: version)
+ end
+
+ test "#{version} against #{requirement.inspect} is #{expected}" do
+ When "evaluating the requirement under luarocks semantics"
+ result = scheme.satisfies?(pv(version), { "constraint" => requirement })
+
+ Then
+ result == expected
+
+ Where
+ version | requirement | expected
+ "3.4-1" | ">= 3.0" | true
+ "2.1-2" | ">= 3.0" | false
+ "3.4-1" | "3.4-1" | true
+ "3.4-2" | "3.4-1" | false
+ "3.4-1" | "== 3.4-1" | true
+ "1.0.5-1" | "~> 1.0" | true
+ "2.0-1" | "~> 1.0" | false
+ "1.0.6-1" | "~> 1.0.5" | true
+ "1.1-1" | "~> 1.0.5" | false
+ "3.5-1" | ">= 3.0, < 4.0" | true
+ "4.0-1" | ">= 3.0, < 4.0" | false
+ "3.1-1" | "> 3.0" | true
+ "2.9-1" | "> 3.0" | false
+ "2.9-1" | "<= 3.0" | true
+ "3.1-1" | "<= 3.0" | false
+ end
+
+ test "an empty constraint is satisfied by anything" do
+ Expect
+ scheme.satisfies?(pv("3.4-1"), {})
+ end
+
+ test "a revision counts as a release above the unrevised version" do
+ Expect "3.4-1 is a release of 3.4, not a prerelease below it"
+ scheme.sort(["3.4-1", "3.4"]) == ["3.4", "3.4-1"]
+ end
+
+ test "sorts by dotted segments then revision" do
+ Given "rock versions with revisions out of order"
+ versions = ["3.4-2", "3.3-5", "3.4-1", "3.10-1"]
+
+ When "sorting"
+ sorted = scheme.sort(versions)
+
+ Then "numeric segment ordering, 3.10 above 3.4"
+ sorted == ["3.3-5", "3.4-1", "3.4-2", "3.10-1"]
+ end
+
+ test "rejects a version it cannot parse" do
+ When "sorting a non-rock string"
+ scheme.sort(["one.two"])
+
+ Then
+ raises Dev::Deps::RockScheme::InvalidVersionError
+ end
+
+ test "rejects a constraint it cannot parse" do
+ When "evaluating a malformed constraint"
+ scheme.satisfies?(pv("3.4-1"), { "constraint" => "~~> 3.0" })
+
+ Then
+ raises Dev::Deps::RockScheme::InvalidConstraintError
+ end
+end
diff --git a/test/dev/deps/scope_test.rb b/test/dev/deps/scope_test.rb
new file mode 100644
index 0000000..8a72bf8
--- /dev/null
+++ b/test/dev/deps/scope_test.rb
@@ -0,0 +1,54 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/scope"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::ScopeTest < Minitest::Test
+ test "defaults to the app group, all hosts, all envs" do
+ Given "a scope with no explicit context"
+ scope = Dev::Deps::Scope.new
+
+ Expect "group defaults and host/env are the all-encompassing empty form"
+ scope.group == :app
+ scope.host.nil?
+ scope.env.nil?
+ end
+
+ test "coerces host to a symbol and env to a string at construction" do
+ Given "a scope built from DSL-flavored inputs"
+ scope = Dev::Deps::Scope.new(group: :build, host: "darwin", env: :ci)
+
+ Expect
+ scope.group == :build
+ scope.host == :darwin
+ scope.env == "ci"
+ end
+
+ test "is value-equal, so inherited scopes compare cleanly" do
+ Given "two scopes built independently from the same context"
+ a = Dev::Deps::Scope.new(group: :build, host: :linux, env: "ci")
+ b = Dev::Deps::Scope.new(group: :build, host: :linux, env: "ci")
+
+ Expect
+ a == b
+ { a => 1 }.key?(b)
+ end
+
+ test "projects host and env into install-scoping metadata" do
+ Given "a fully-pinned scope"
+ scope = Dev::Deps::Scope.new(group: :build, host: :darwin, env: "ci")
+
+ Expect "host and env serialize; group does not (it is a first-class pin field)"
+ scope.to_metadata == { "host" => "darwin", "env" => "ci" }
+ end
+
+ test "projects nothing for the all-hosts, all-envs scope" do
+ Given "a default scope"
+ scope = Dev::Deps::Scope.new(group: :game)
+
+ Expect "the projection is empty — no absent keys smuggled in"
+ scope.to_metadata == {}
+ end
+end
diff --git a/test/dev/deps/scoped_declaration_test.rb b/test/dev/deps/scoped_declaration_test.rb
new file mode 100644
index 0000000..704e645
--- /dev/null
+++ b/test/dev/deps/scoped_declaration_test.rb
@@ -0,0 +1,121 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/declaration"
+require "dev/deps/scope"
+require "dev/deps/scoped_declaration"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::ScopedDeclarationTest < Minitest::Test
+ def atom(name: "boost", integration: :cmake, constraint: {})
+ Dev::Deps::Declaration.new(name:, integration:, constraint:)
+ end
+
+ test "marries a declaration to the context it resolves under" do
+ Given "an atom and an explicit scope"
+ scope = Dev::Deps::Scope.new(group: :build, host: :darwin, env: "ci")
+ scoped = Dev::Deps::ScopedDeclaration.new(declaration: atom, scope: scope, platform: "LinuxServer")
+
+ Expect "both halves are reachable and distinct"
+ scoped.declaration == atom
+ scoped.scope == scope
+ scoped.platform == "LinuxServer"
+ end
+
+ test "delegates the atom's facts for call-site ergonomics" do
+ Given "a scoped declaration"
+ scoped = Dev::Deps::ScopedDeclaration.new(
+ declaration: atom(name: "SML", integration: :ficsit, constraint: { "version" => "^3.6" }),
+ )
+
+ Expect "name/integration/constraint read through to the atom"
+ scoped.name == "SML"
+ scoped.integration == :ficsit
+ scoped.constraint == { "version" => "^3.6" }
+ end
+
+ test "defaults to the default scope, no platform, no hook, no materialization" do
+ Given "a scoped declaration with only an atom"
+ scoped = Dev::Deps::ScopedDeclaration.new(declaration: atom)
+
+ Expect
+ scoped.scope == Dev::Deps::Scope.new
+ scoped.platform.nil?
+ scoped.post_install.nil?
+ scoped.materialization == {}
+ end
+
+ test "carries install instructions as materialization, frozen" do
+ Given "a row with an install_dir and an asset glob"
+ scoped = Dev::Deps::ScopedDeclaration.new(
+ declaration: atom(name: "UnrealEngine", integration: :gh, constraint: { "tag" => "5.6.1-css-83" }),
+ materialization: { "install_dir" => "~/.dev/engines/ue", "asset_pattern" => "*.tar.zst.*" },
+ )
+
+ Expect "materialization is readable and immutable"
+ scoped.materialization == { "install_dir" => "~/.dev/engines/ue", "asset_pattern" => "*.tar.zst.*" }
+ scoped.materialization.frozen?
+ end
+
+ test "materialization participates in equality — disagreeing install dirs are different asks" do
+ Given "one atom materialized into two directories"
+ a = Dev::Deps::ScopedDeclaration.new(declaration: atom, materialization: { "install_dir" => "~/a" })
+ b = Dev::Deps::ScopedDeclaration.new(declaration: atom, materialization: { "install_dir" => "~/b" })
+
+ Expect
+ a != b
+ end
+
+ test "delegates the atom's source" do
+ Given "a scoped declaration over a source-based atom"
+ scoped = Dev::Deps::ScopedDeclaration.new(
+ declaration: Dev::Deps::Declaration.new(
+ name: "fmt", integration: :cmake, source: "https://github.com/fmtlib/fmt",
+ ),
+ )
+
+ Expect
+ scoped.source == "https://github.com/fmtlib/fmt"
+ end
+
+ test "delegates the atom's revision" do
+ Given "a scoped declaration over a revision-pinned atom"
+ scoped = Dev::Deps::ScopedDeclaration.new(
+ declaration: Dev::Deps::Declaration.new(
+ name: "opencell", integration: :cmake,
+ source: "https://github.com/d3mlabs/opencell", revision: "a" * 40,
+ ),
+ )
+
+ Expect
+ scoped.revision == "a" * 40
+ end
+
+ test "is value-equal across independently built compositions" do
+ Given "two scoped declarations from the same parts"
+ a = Dev::Deps::ScopedDeclaration.new(declaration: atom, scope: Dev::Deps::Scope.new(group: :test))
+ b = Dev::Deps::ScopedDeclaration.new(declaration: atom, scope: Dev::Deps::Scope.new(group: :test))
+
+ Expect
+ a == b
+ { a => 1 }.key?(b)
+ end
+
+ test "scope participates in equality — same ask, different context, different value" do
+ Given "one atom under two scopes"
+ a = Dev::Deps::ScopedDeclaration.new(declaration: atom, scope: Dev::Deps::Scope.new(group: :app))
+ b = Dev::Deps::ScopedDeclaration.new(declaration: atom, scope: Dev::Deps::Scope.new(group: :test))
+
+ Expect
+ a != b
+ end
+
+ test "is not a Declaration — composition keeps context off the facts side" do
+ Given "a scoped declaration"
+ scoped = Dev::Deps::ScopedDeclaration.new(declaration: atom)
+
+ Expect "it never passes an is_a?(Declaration) gate"
+ !scoped.is_a?(Dev::Deps::Declaration)
+ end
+end
diff --git a/test/dev/deps/semver_scheme_test.rb b/test/dev/deps/semver_scheme_test.rb
new file mode 100644
index 0000000..7e514a9
--- /dev/null
+++ b/test/dev/deps/semver_scheme_test.rb
@@ -0,0 +1,84 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/semver_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::SemverSchemeTest < Minitest::Test
+ def scheme
+ Dev::Deps::SemverScheme.new
+ end
+
+ def pv(version)
+ Dev::Deps::PackageVersion.new(version: version)
+ end
+
+ test "#{version} against #{requirement.inspect} is #{expected}" do
+ When "evaluating the requirement under semver range semantics"
+ result = scheme.satisfies?(pv(version), { "version" => requirement })
+
+ Then
+ result == expected
+
+ Where
+ version | requirement | expected
+ "3.12.0" | "^3.12.0" | true
+ "3.13.2" | "^3.12.0" | true
+ "4.0.0" | "^3.12.0" | false
+ "3.11.9" | "^3.12.0" | false
+ "0.2.5" | "^0.2.3" | true
+ "0.3.0" | "^0.2.3" | false
+ "0.0.3" | "^0.0.3" | true
+ "0.0.4" | "^0.0.3" | false
+ "1.2.9" | "~1.2.3" | true
+ "1.3.0" | "~1.2.3" | false
+ "2.5.0" | ">=1.0.0 <3.0.0" | true
+ "3.0.0" | ">=1.0.0 <3.0.0" | false
+ "1.0.0" | "1.0.0" | true
+ "1.0.1" | "1.0.0" | false
+ "1.0.0" | "=1.0.0" | true
+ "1.0.0-rc.1" | ">=1.0.0" | false
+ "1.0.0" | ">1.0.0-rc.1" | true
+ "1.0.0" | "<=1.0.0" | true
+ "1.0.1" | "<=1.0.0" | false
+ end
+
+ test "an empty constraint is satisfied by anything" do
+ Expect
+ scheme.satisfies?(pv("3.12.0"), {})
+ end
+
+ test "sorts semver-correctly, prereleases below their release" do
+ Given "releases and prereleases out of order"
+ versions = ["1.0.0", "1.0.0-rc.1", "0.9.0", "1.0.0-beta"]
+
+ When "sorting"
+ sorted = scheme.sort(versions)
+
+ Then
+ sorted == ["0.9.0", "1.0.0-beta", "1.0.0-rc.1", "1.0.0"]
+ end
+
+ test "orders numeric prerelease identifiers numerically" do
+ Expect "rc.10 sorts above rc.2 (numeric, not lexicographic)"
+ scheme.sort(["1.0.0-rc.10", "1.0.0-rc.2"]) == ["1.0.0-rc.2", "1.0.0-rc.10"]
+ end
+
+ test "rejects a version that is not semver" do
+ When "sorting a non-semver string"
+ scheme.sort(["not-semver"])
+
+ Then
+ raises Dev::Deps::SemverScheme::InvalidVersionError
+ end
+
+ test "rejects a constraint it cannot parse" do
+ When "evaluating an unparseable range"
+ scheme.satisfies?(pv("1.0.0"), { "version" => "^^nope" })
+
+ Then
+ raises Dev::Deps::SemverScheme::InvalidConstraintError
+ end
+end
diff --git a/test/dev/deps/steam_cmd_test.rb b/test/dev/deps/steam_cmd_test.rb
index 40314bc..693e13f 100644
--- a/test/dev/deps/steam_cmd_test.rb
+++ b/test/dev/deps/steam_cmd_test.rb
@@ -29,52 +29,50 @@ class Dev::Deps::SteamCmdTest < Minitest::Test
}
VDF
- test "parse_build_id extracts the buildid for the requested branch" do
- When "parsing the public branch"
- public_build = Dev::Deps::SteamCmd.parse_build_id(APP_INFO, "public")
- experimental_build = Dev::Deps::SteamCmd.parse_build_id(APP_INFO, "experimental")
+ test "parse_branches extracts every branch's buildid" do
+ When "parsing the branches section"
+ branches = Dev::Deps::SteamCmd.parse_branches(APP_INFO)
Then
- public_build == "15321746"
- experimental_build == "15400000"
+ branches == { "public" => "15321746", "experimental" => "15400000" }
end
- test "parse_build_id returns nil for an unknown branch" do
- When "parsing a missing branch"
- result = Dev::Deps::SteamCmd.parse_build_id(APP_INFO, "nonexistent")
+ test "parse_branches skips branches without a buildid and handles no section" do
+ Given "a redacted branch alongside a normal one, and empty output"
+ vdf = <<~VDF
+ "branches"
+ {
+ "public" { "buildid" "42" }
+ "gated" { "pwdrequired" "1" }
+ }
+ VDF
+
+ When "parsing"
+ branches = Dev::Deps::SteamCmd.parse_branches(vdf)
+ empty = Dev::Deps::SteamCmd.parse_branches("no branches here")
Then
- result.nil?
+ branches == { "public" => "42" }
+ empty == {}
end
- test "resolve_build_id returns the parsed buildid on success" do
+ test "resolve_branches returns the parsed branch map on success" do
Given "a successful app_info_print"
Dev::Deps::SteamCmd.stubs(:run).returns([APP_INFO, "", stub(success?: true)])
When "resolving"
- build_id = Dev::Deps::SteamCmd.resolve_build_id(app: 1690800, branch: "public")
+ branches = Dev::Deps::SteamCmd.resolve_branches(app: 1690800)
Then
- build_id == "15321746"
+ branches == { "public" => "15321746", "experimental" => "15400000" }
end
- test "resolve_build_id raises when steamcmd fails" do
+ test "resolve_branches raises when steamcmd fails" do
Given "a failing app_info_print"
Dev::Deps::SteamCmd.stubs(:run).returns(["", "Connection error", stub(success?: false)])
When "resolving"
- Dev::Deps::SteamCmd.resolve_build_id(app: 1690800, branch: "public")
-
- Then
- raises Dev::Deps::SteamCmd::SteamCmdError
- end
-
- test "resolve_build_id raises when the branch has no buildid" do
- Given "output missing the requested branch"
- Dev::Deps::SteamCmd.stubs(:run).returns([APP_INFO, "", stub(success?: true)])
-
- When "resolving a missing branch"
- Dev::Deps::SteamCmd.resolve_build_id(app: 1690800, branch: "nonexistent")
+ Dev::Deps::SteamCmd.resolve_branches(app: 1690800)
Then
raises Dev::Deps::SteamCmd::SteamCmdError
diff --git a/test/dev/deps/steam_integration_test.rb b/test/dev/deps/steam_integration_test.rb
index 3995df8..197bbac 100644
--- a/test/dev/deps/steam_integration_test.rb
+++ b/test/dev/deps/steam_integration_test.rb
@@ -155,4 +155,27 @@ def provision(_dep, _server_dir) = nil
Cleanup
FileUtils.rm_rf(dir)
end
+
+ test "steam_platform_for maps declared platform #{declared.inspect} to #{expected.inspect}" do
+ Given "an integration"
+ dir = Dir.mktmpdir("dev-steam-int-test-")
+ integration = build_integration(dir, manifest_build: "1")
+
+ When "mapping the declared platform to SteamCMD's vocabulary"
+ result = integration.send(:steam_platform_for, declared)
+
+ Then
+ result == expected
+
+ Cleanup
+ FileUtils.rm_rf(dir)
+
+ Where
+ declared | expected
+ "LinuxServer" | "linux"
+ "WindowsServer" | "windows"
+ "Windows" | "windows"
+ nil | "linux"
+ "MacOS" | "macos"
+ end
end
diff --git a/test/dev/deps/steam_repository_test.rb b/test/dev/deps/steam_repository_test.rb
index 7e2c689..f000161 100644
--- a/test/dev/deps/steam_repository_test.rb
+++ b/test/dev/deps/steam_repository_test.rb
@@ -6,68 +6,49 @@
transform!(RSpock::AST::Transformation)
class Dev::Deps::SteamRepositoryTest < Minitest::Test
- test "fetch uses an explicitly pinned buildid without invoking steamcmd" do
- Given "a declaration with a pinned buildid"
- repo = Dev::Deps::SteamRepository.new
- Dev::Deps::SteamCmd.stubs(:resolve_build_id).raises("steamcmd should not be called")
-
- When "fetching"
- dep = repo.fetch(
- "name" => "SatisfactoryServer",
- "integration" => "steam",
- "group" => "integration",
- "app" => 1690800,
- "install_dir" => "~/.dev/satisfactory-server",
- "buildid" => "15321746",
- "platforms" => ["LinuxServer"],
- )
+ def id
+ Dev::Deps::PackageId.new(integration: :steam, name: "SatisfactoryServer", source: "1690800")
+ end
- Then
- dep.name == "SatisfactoryServer"
- dep.integration == :steam
- dep.group == :integration
- dep.version == "15321746"
- dep.hash.nil?
- dep.metadata["app"] == "1690800"
- dep.metadata["branch"] == "public"
- dep.metadata["install_dir"] == "~/.dev/satisfactory-server"
- dep.metadata["platform"] == "linux"
+ test "find reports every branch's current buildid, one version per branch" do
+ Given "an app with public and experimental branch tips"
+ repo = Dev::Deps::SteamRepository.new
+ Dev::Deps::SteamCmd.stubs(:resolve_branches)
+ .with(app: "1690800")
+ .returns({ "public" => "15321746", "experimental" => "15400000" })
+
+ When "finding"
+ package = repo.find(id)
+
+ Then "each branch tip is a version, its branch riding metadata as a fact"
+ package.versions.map(&:version).sort == ["15321746", "15400000"]
+ public_tip = package.version("15321746")
+ public_tip.digest.nil?
+ public_tip.metadata == { "app" => "1690800", "branch" => "public" }
+ package.version("15400000").metadata["branch"] == "experimental"
end
- test "fetch resolves the current public buildid via steamcmd when not pinned" do
- Given "no pinned buildid and a stubbed steamcmd resolution"
+ test "find claims self-contained declarations — SteamCMD delivers the whole tree" do
+ Given "a single-branch app"
repo = Dev::Deps::SteamRepository.new
- Dev::Deps::SteamCmd.stubs(:resolve_build_id).with(app: 1690800, branch: "public").returns("99999")
+ Dev::Deps::SteamCmd.stubs(:resolve_branches).returns({ "public" => "1" })
- When "fetching"
- dep = repo.fetch(
- "name" => "SatisfactoryServer",
- "integration" => "steam",
- "group" => "integration",
- "app" => 1690800,
- "install_dir" => "~/.dev/satisfactory-server",
- "platforms" => ["LinuxServer"],
- )
+ When "finding"
+ package = repo.find(id)
Then
- dep.version == "99999"
+ package.version("1").declarations == Dev::Deps::Declarations::Resolved.new([])
end
- test "fetch defaults platform to linux when no group platform is set" do
- Given "a declaration with no platforms"
+ test "find raises PackageNotFoundError when no branch reports a buildid" do
+ Given "an app steamcmd reports no branches for"
repo = Dev::Deps::SteamRepository.new
+ Dev::Deps::SteamCmd.stubs(:resolve_branches).returns({})
- When "fetching with a pinned buildid"
- dep = repo.fetch(
- "name" => "SatisfactoryServer",
- "integration" => "steam",
- "group" => "integration",
- "app" => 1690800,
- "install_dir" => "/tmp/server",
- "buildid" => "1",
- )
+ When "finding"
+ repo.find(id)
Then
- dep.metadata["platform"] == "linux"
+ raises Dev::Deps::Repository::PackageNotFoundError
end
end
diff --git a/test/dev/deps/steam_scheme_test.rb b/test/dev/deps/steam_scheme_test.rb
new file mode 100644
index 0000000..0fd6bb6
--- /dev/null
+++ b/test/dev/deps/steam_scheme_test.rb
@@ -0,0 +1,43 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/steam_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::SteamSchemeTest < Minitest::Test
+ def scheme
+ Dev::Deps::SteamScheme.new
+ end
+
+ def pv(buildid, branch:)
+ Dev::Deps::PackageVersion.new(version: buildid, metadata: { "branch" => branch })
+ end
+
+ test "branch #{branch.inspect} buildid #{buildid.inspect} against #{constraint} is #{expected}" do
+ When "evaluating branch selection plus optional buildid assertion"
+ result = scheme.satisfies?(pv(buildid, branch: branch), constraint)
+
+ Then
+ result == expected
+
+ Where
+ branch | buildid | constraint | expected
+ "public" | "15321746" | {} | true
+ "public" | "15321746" | { "branch" => "public" } | true
+ "experimental" | "15400000" | { "branch" => "public" } | false
+ "experimental" | "15400000" | { "branch" => "experimental" } | true
+ "public" | "15321746" | { "buildid" => "15321746" } | true
+ "public" | "15321746" | { "buildid" => "99999" } | false
+ "experimental" | "15400000" | { "branch" => "experimental", "buildid" => "15400000" } | true
+ end
+
+ test "sort orders buildids numerically ascending" do
+ When "sorting"
+ sorted = scheme.sort(["15400000", "999", "15321746"])
+
+ Then "buildids are monotonically increasing integers, not lexical strings"
+ sorted == ["999", "15321746", "15400000"]
+ end
+end
diff --git a/test/dev/deps/url_repository_test.rb b/test/dev/deps/url_repository_test.rb
index 99b57d6..c199450 100644
--- a/test/dev/deps/url_repository_test.rb
+++ b/test/dev/deps/url_repository_test.rb
@@ -3,43 +3,41 @@
require "test_helper"
require "dev/deps/url_repository"
-require "dev/deps/cache"
require "tmpdir"
require "digest"
transform!(RSpock::AST::Transformation)
class Dev::Deps::UrlRepositoryTest < Minitest::Test
- test "fetch downloads URL and computes SHA256" do
- Given "a URL identifier with a stubbed download"
+ test "find downloads the URL and reports it as a digested, unversioned singleton" do
+ Given "a URL with a stubbed download"
dir = Dir.mktmpdir("dev-url-repo-test-")
repo = Dev::Deps::UrlRepository.new
-
fake_tarball = File.join(dir, "boost.tar.gz")
File.write(fake_tarball, "fake tarball content for hash test")
expected_hash = "SHA256=#{Digest::SHA256.file(fake_tarball).hexdigest}"
-
repo.stubs(:download_to_tempfile).returns(fake_tarball)
- When "fetching the dependency"
- dep = repo.fetch(
- "name" => "boost",
- "url" => "https://example.com/boost-1.90.0.tar.gz",
- "integration" => "cmake",
- "group" => "app",
+ When "finding"
+ package = repo.find(
+ Dev::Deps::PackageId.new(
+ integration: :url, name: "boost", source: "https://example.com/boost-1.90.0.tar.gz",
+ ),
)
- Then
- dep.name == "boost"
- dep.hash == expected_hash
- dep.integration == :cmake
- dep.group == :app
- dep.metadata["url"] == "https://example.com/boost-1.90.0.tar.gz"
+ Then "TOFU integrity: the downloaded bytes' SHA256 is the digest; no version exists"
+ package.versions.size == 1
+ version = package.versions.first
+ version.version == ""
+ version.digest == expected_hash
+ version.artifacts["default"].uri == "https://example.com/boost-1.90.0.tar.gz"
+ version.metadata == { "url" => "https://example.com/boost-1.90.0.tar.gz" }
+ version.declarations == Dev::Deps::Declarations::Resolved.new([])
Cleanup
FileUtils.rm_rf(dir)
end
- test "fetch raises DownloadError when curl fails" do
+ test "find raises DownloadError when curl fails" do
Given "a URL repository with a stubbed failing download"
repo = Dev::Deps::UrlRepository.new
failed_status = stub(success?: false)
@@ -47,12 +45,11 @@ class Dev::Deps::UrlRepositoryTest < Minitest::Test
.with("curl", "-fsSL", "-o", anything, "https://example.com/missing.tar.gz")
.returns(["", "404 Not Found", failed_status])
- When "fetching a non-existent URL"
- repo.fetch(
- "name" => "missing",
- "url" => "https://example.com/missing.tar.gz",
- "integration" => "cmake",
- "group" => "app",
+ When "finding a non-existent URL"
+ repo.find(
+ Dev::Deps::PackageId.new(
+ integration: :url, name: "missing", source: "https://example.com/missing.tar.gz",
+ ),
)
Then
diff --git a/test/dev/deps/version_scheme_test.rb b/test/dev/deps/version_scheme_test.rb
new file mode 100644
index 0000000..c754ca9
--- /dev/null
+++ b/test/dev/deps/version_scheme_test.rb
@@ -0,0 +1,26 @@
+# typed: false
+# frozen_string_literal: true
+
+require "test_helper"
+require "dev/deps/package_version"
+require "dev/deps/version_scheme"
+
+transform!(RSpock::AST::Transformation)
+class Dev::Deps::VersionSchemeTest < Minitest::Test
+ test "base class satisfies? raises NotImplementedError" do
+ When "asking the abstract scheme to evaluate a constraint"
+ version = Dev::Deps::PackageVersion.new(version: "1.0.0")
+ Dev::Deps::VersionScheme.new.satisfies?(version, { "version" => ">= 1.0" })
+
+ Then
+ raises NotImplementedError
+ end
+
+ test "base class sort raises NotImplementedError" do
+ When "asking the abstract scheme to order versions"
+ Dev::Deps::VersionScheme.new.sort(["1.0.0", "2.0.0"])
+
+ Then
+ raises NotImplementedError
+ end
+end
diff --git a/test/dev/deps/xcode_repository_test.rb b/test/dev/deps/xcode_repository_test.rb
index b005f68..85699ad 100644
--- a/test/dev/deps/xcode_repository_test.rb
+++ b/test/dev/deps/xcode_repository_test.rb
@@ -6,33 +6,27 @@
transform!(RSpock::AST::Transformation)
class Dev::Deps::XcodeRepositoryTest < Minitest::Test
- test "fetch resolves the declared exact version as the locked version" do
- Given "an xcode declaration id"
+ test "at lifts the declared version as the identity — no registry exists to consult" do
+ Given "an xcode revision"
repo = Dev::Deps::XcodeRepository.new
- id = { "name" => "xcode", "integration" => "xcode", "group" => "build", "version" => "26.1.1" }
- When "fetching"
- dep = repo.fetch(id)
+ When "lifting"
+ version = repo.at(Dev::Deps::PackageId.new(integration: :xcode, name: "xcode"), "26.1.1")
- Then "resolution is the identity — no registry exists to consult"
- dep.name == "xcode"
- dep.integration == :xcode
- dep.group == :build
- dep.version == "26.1.1"
- dep.hash.nil?
+ Then "the version is the address; Apple ships the whole toolchain"
+ version.version == "26.1.1"
+ version.digest.nil?
+ version.declarations == Dev::Deps::Declarations::Resolved.new([])
end
- test "fetch without an exact version raises" do
- Given "a declaration missing the version pin"
+ test "find refuses — there is no enumerable Xcode universe" do
+ Given "a constraint-shaped ask"
repo = Dev::Deps::XcodeRepository.new
- id = { "name" => "xcode", "integration" => "xcode", "group" => "build" }
- When "fetching"
- error = assert_raises(Dev::Deps::XcodeRepository::MissingVersionError) do
- repo.fetch(id)
- end
+ When "finding"
+ repo.find(Dev::Deps::PackageId.new(integration: :xcode, name: "xcode"))
Then
- error.message.include?("exact version")
+ raises Dev::Deps::XcodeRepository::NoEnumerableUniverseError
end
end
diff --git a/test/lib/ensure_bundler_test.rb b/test/lib/ensure_bundler_test.rb
index 2fe4a12..4130a35 100644
--- a/test/lib/ensure_bundler_test.rb
+++ b/test/lib/ensure_bundler_test.rb
@@ -11,7 +11,7 @@
transform!(RSpock::AST::Transformation)
class EnsureBundlerTest < Minitest::Test
- test "ensure_bundler! returns true when bundler satisfies requirement" do
+ test "EnsureBundler.ensure! returns true when bundler satisfies requirement" do
Given "a bundle command that reports a satisfying version"
tmpdir = Dir.mktmpdir("bundler-test-")
fake_bin = File.join(tmpdir, "bin")
@@ -21,8 +21,8 @@ class EnsureBundlerTest < Minitest::Test
original_path = ENV["PATH"]
ENV["PATH"] = "#{fake_bin}:#{original_path}"
- When "we call ensure_bundler!"
- result = ensure_bundler!(DEV_ROOT)
+ When "we call EnsureBundler.ensure!"
+ result = EnsureBundler.ensure!(DEV_ROOT)
Then "it returns true without attempting install"
result == true
@@ -32,7 +32,7 @@ class EnsureBundlerTest < Minitest::Test
FileUtils.rm_rf(tmpdir)
end
- test "ensure_bundler! installs and returns true when version does not satisfy" do
+ test "EnsureBundler.ensure! installs and returns true when version does not satisfy" do
Given "bundle reports version 1.0.0 and gem install succeeds"
tmpdir = Dir.mktmpdir("bundler-test-")
fake_bin = File.join(tmpdir, "bin")
@@ -44,8 +44,8 @@ class EnsureBundlerTest < Minitest::Test
original_path = ENV["PATH"]
ENV["PATH"] = "#{fake_bin}:#{original_path}"
- When "we call ensure_bundler!"
- result = ensure_bundler!(DEV_ROOT)
+ When "we call EnsureBundler.ensure!"
+ result = EnsureBundler.ensure!(DEV_ROOT)
Then "it returns true after installing"
_ * $stdout.puts
@@ -56,7 +56,7 @@ class EnsureBundlerTest < Minitest::Test
FileUtils.rm_rf(tmpdir)
end
- test "ensure_bundler! raises when gem install fails" do
+ test "EnsureBundler.ensure! raises when gem install fails" do
Given "bundle reports old version and gem install fails"
tmpdir = Dir.mktmpdir("bundler-test-")
fake_bin = File.join(tmpdir, "bin")
@@ -68,12 +68,12 @@ class EnsureBundlerTest < Minitest::Test
original_path = ENV["PATH"]
ENV["PATH"] = "#{fake_bin}:#{original_path}"
- When "we call ensure_bundler!"
- ensure_bundler!(DEV_ROOT)
+ When "we call EnsureBundler.ensure!"
+ EnsureBundler.ensure!(DEV_ROOT)
Then "it raises with a descriptive message"
_ * $stdout.puts
- raises BundlerInstallError
+ raises EnsureBundler::BundlerInstallError
Cleanup
@@ -81,7 +81,7 @@ class EnsureBundlerTest < Minitest::Test
FileUtils.rm_rf(tmpdir)
end
- test "ensure_bundler! installs when bundle command is not found" do
+ test "EnsureBundler.ensure! installs when bundle command is not found" do
Given "bundle command is not found and gem install succeeds"
tmpdir = Dir.mktmpdir("bundler-test-")
fake_bin = File.join(tmpdir, "bin")
@@ -94,8 +94,8 @@ class EnsureBundlerTest < Minitest::Test
ENV["PATH"] = "#{fake_bin}:#{original_path}"
- When "we call ensure_bundler!"
- result = ensure_bundler!(DEV_ROOT)
+ When "we call EnsureBundler.ensure!"
+ result = EnsureBundler.ensure!(DEV_ROOT)
Then "it returns true"
_ * $stdout.puts
]