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