From db656aa0be374f9b3b51510f34fbcb8de97d8db3 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 14:20:22 -0400 Subject: [PATCH 01/37] Introduce the package universe domain types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deps domain conflated four concepts inside Repository#fetch's untyped id hash: identity, available versions, requirement, and pin. This adds the two missing halves as first-class types so the repository layer can state facts without also carrying constraints or choices: - PackageId: constraint-free, version-free identity, keyed by integration so two ecosystems publishing the same name no longer collide. - Package: the aggregate a Repository returns — an identity plus its available versions, with no satisfies?/sort/best_match (those belong to VersionScheme and Resolver respectively). - PackageVersion: one version's facts, with every optional fact in its empty form rather than nil. - Artifact: bytes dev fetches itself, digest as an enforcement input. - DependencyEdge: a version's outgoing requirement, constraint untouched. Co-authored-by: Cursor --- lib/dev/deps/artifact.rb | 68 +++++++++++++++++++ lib/dev/deps/dependency_edge.rb | 58 ++++++++++++++++ lib/dev/deps/package.rb | 62 +++++++++++++++++ lib/dev/deps/package_id.rb | 76 +++++++++++++++++++++ lib/dev/deps/package_version.rb | 96 +++++++++++++++++++++++++++ test/dev/deps/artifact_test.rb | 61 +++++++++++++++++ test/dev/deps/dependency_edge_test.rb | 43 ++++++++++++ test/dev/deps/package_id_test.rb | 64 ++++++++++++++++++ test/dev/deps/package_test.rb | 62 +++++++++++++++++ test/dev/deps/package_version_test.rb | 85 ++++++++++++++++++++++++ 10 files changed, 675 insertions(+) create mode 100644 lib/dev/deps/artifact.rb create mode 100644 lib/dev/deps/dependency_edge.rb create mode 100644 lib/dev/deps/package.rb create mode 100644 lib/dev/deps/package_id.rb create mode 100644 lib/dev/deps/package_version.rb create mode 100644 test/dev/deps/artifact_test.rb create mode 100644 test/dev/deps/dependency_edge_test.rb create mode 100644 test/dev/deps/package_id_test.rb create mode 100644 test/dev/deps/package_test.rb create mode 100644 test/dev/deps/package_version_test.rb diff --git a/lib/dev/deps/artifact.rb b/lib/dev/deps/artifact.rb new file mode 100644 index 0000000..df2cc10 --- /dev/null +++ b/lib/dev/deps/artifact.rb @@ -0,0 +1,68 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +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 = uri + @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) + other.is_a?(Artifact) && uri == other.uri && digest == 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/dependency_edge.rb b/lib/dev/deps/dependency_edge.rb new file mode 100644 index 0000000..6189df8 --- /dev/null +++ b/lib/dev/deps/dependency_edge.rb @@ -0,0 +1,58 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +module Dev + module Deps + # An outgoing dependency edge of a specific PackageVersion: "this version + # requires that name, under this constraint". + # + # Edges are facts about a *version*, not about a chosen pin, which is why + # they hang off PackageVersion. The constraint stays exactly as the backing + # service reported it (a string like ">= 1.0", a hash, or nothing at all); + # normalizing it into dev's constraint shape is the Resolver's job, since + # only the Resolver knows the requirement vocabulary it will hand to the + # integration's VersionScheme. + class DependencyEdge + extend T::Sig + + # @return [String] the required package's name, within the same universe + sig { returns(String) } + attr_reader :name + + # @return [Hash, String, nil] the raw constraint as reported upstream + sig { returns(T.nilable(T.any(String, T::Hash[String, T.untyped]))) } + attr_reader :constraint + + # @param name [String] the required package's name + # @param constraint [Hash, String, nil] raw upstream constraint, or nil + # when the edge pins nothing + sig do + params( + name: String, + constraint: T.nilable(T.any(String, T::Hash[String, T.untyped])), + ).void + end + def initialize(name:, constraint:) + @name = name + @constraint = constraint + freeze + end + + # @param other [Object] + # @return [Boolean] whether other is the same edge + sig { params(other: T.untyped).returns(T::Boolean) } + def ==(other) + other.is_a?(DependencyEdge) && name == other.name && constraint == other.constraint + end + alias_method :eql?, :== + + # @return [Integer] hash code + sig { returns(Integer) } + def hash + [self.class, name, constraint].hash + end + end + end +end diff --git a/lib/dev/deps/package.rb b/lib/dev/deps/package.rb new file mode 100644 index 0000000..ee1ba23 --- /dev/null +++ b/lib/dev/deps/package.rb @@ -0,0 +1,62 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +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..a0d0ca2 --- /dev/null +++ b/lib/dev/deps/package_id.rb @@ -0,0 +1,76 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +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" (DependencyDeclaration) 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. + # + # 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) + other.is_a?(PackageId) && + integration == other.integration && + name == other.name && + source == 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..abd6718 --- /dev/null +++ b/lib/dev/deps/package_version.rb @@ -0,0 +1,96 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +require_relative "artifact" +require_relative "dependency_edge" + +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 with no edges requires nothing. 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 [Array] what this version requires + sig { returns(T::Array[DependencyEdge]) } + attr_reader :dependencies + + # @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 dependencies [Array] outgoing edges + sig do + params( + version: String, + platforms: T::Array[String], + digest: T.nilable(String), + artifacts: T::Hash[String, Artifact], + dependencies: T::Array[DependencyEdge], + ).void + end + def initialize(version:, platforms: [], digest: nil, artifacts: {}, dependencies: []) + @version = version + @platforms = T.let(platforms.dup.freeze, T::Array[String]) + @digest = digest + @artifacts = T.let(artifacts.dup.freeze, T::Hash[String, Artifact]) + @dependencies = T.let(dependencies.dup.freeze, T::Array[DependencyEdge]) + freeze + end + + # @param other [Object] + # @return [Boolean] whether other reports the same facts + sig { params(other: T.untyped).returns(T::Boolean) } + def ==(other) + other.is_a?(PackageVersion) && + version == other.version && + platforms == other.platforms && + digest == other.digest && + artifacts == other.artifacts && + dependencies == other.dependencies + end + alias_method :eql?, :== + + # @return [Integer] hash code + sig { returns(Integer) } + def hash + [self.class, version, platforms, digest, artifacts, dependencies].hash + end + end + end +end 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/dependency_edge_test.rb b/test/dev/deps/dependency_edge_test.rb new file mode 100644 index 0000000..a88f362 --- /dev/null +++ b/test/dev/deps/dependency_edge_test.rb @@ -0,0 +1,43 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/dependency_edge" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::DependencyEdgeTest < Minitest::Test + test "keeps a string constraint exactly as upstream reported it" do + Given "an edge from a service that expresses constraints as strings" + edge = Dev::Deps::DependencyEdge.new(name: "lua", constraint: ">= 5.1") + + Expect "no normalization happens here — that is the Resolver's job" + edge.name == "lua" + edge.constraint == ">= 5.1" + end + + test "keeps a hash constraint exactly as upstream reported it" do + Given "an edge from a service that expresses constraints as hashes" + edge = Dev::Deps::DependencyEdge.new(name: "lpeg", constraint: { "version" => "~> 1.0" }) + + Expect + edge.constraint == { "version" => "~> 1.0" } + end + + test "an edge can pin nothing" do + Given "an unconstrained edge" + edge = Dev::Deps::DependencyEdge.new(name: "openssl", constraint: nil) + + Expect + edge.constraint.nil? + end + + test "is value-equal" do + Given "two edges with the same name and constraint" + a = Dev::Deps::DependencyEdge.new(name: "lua", constraint: ">= 5.1") + b = Dev::Deps::DependencyEdge.new(name: "lua", constraint: ">= 5.1") + + Expect + a == b + a.hash == b.hash + 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..45fa782 --- /dev/null +++ b/test/dev/deps/package_version_test.rb @@ -0,0 +1,85 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +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 edge facts" + version = Dev::Deps::PackageVersion.new(version: "1.2.3") + + Expect "absence is modeled as absence — empty collections and nil digest" + version.version == "1.2.3" + version.platforms == [] + version.digest.nil? + version.artifacts == {} + version.dependencies == [] + end + + test "carries the full fact set when the universe provides one" do + Given "a version with platforms, digest, per-platform artifacts, and edges" + artifact = Dev::Deps::Artifact.new(uri: "https://example.com/sml-linux.zip", digest: "SHA256=abc") + edge = Dev::Deps::DependencyEdge.new(name: "SML", constraint: "^3.0.0") + version = Dev::Deps::PackageVersion.new( + version: "3.12.0", + platforms: ["Windows", "LinuxServer"], + digest: "SHA256=fff", + artifacts: { "LinuxServer" => artifact }, + dependencies: [edge], + ) + + Expect + version.platforms == ["Windows", "LinuxServer"] + version.digest == "SHA256=fff" + version.artifacts["LinuxServer"] == artifact + version.dependencies == [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") }, + dependencies: [Dev::Deps::DependencyEdge.new(name: "x", constraint: nil)], + ) + + Expect "none of them can be mutated after the fact" + version.platforms.frozen? + version.artifacts.frozen? + version.dependencies.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 From b10516d9fad9f2e43d12600e36c41d1ac8d4ad9c Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 17:41:52 -0400 Subject: [PATCH 02/37] Add per-integration VersionScheme domain services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constraint semantics are a property of an ecosystem, not of any package or repository, so they get their own strategy seam: Package states facts, VersionScheme evaluates predicates (satisfies?/sort), Resolver chooses. No repository evaluated constraints before this — ficsit silently ignored its declared ^ ranges and took the newest version — so these schemes are the missing predicate layer, not an extraction: - GemScheme: Gem::Requirement/Gem::Version (bundler) - SemverScheme: node-style ranges (^ ~ comparators, conjunction) for ficsit - Pep440Scheme: the PEP 440 subset pip declarations use, pip-style cmpkey - RockScheme: luarocks dotted+revision grammar, where 3.4-1 releases above 3.4 rather than semver's prerelease-below reading - PinnedScheme: brew/cmake/gh/steam/xcode universes arrive pre-narrowed by the backing service; everything satisfies, reported order stands Co-authored-by: Cursor --- lib/dev/deps/gem_scheme.rb | 72 +++++++++++ lib/dev/deps/pep440_scheme.rb | 171 +++++++++++++++++++++++++++ lib/dev/deps/pinned_scheme.rb | 38 ++++++ lib/dev/deps/rock_scheme.rb | 114 ++++++++++++++++++ lib/dev/deps/semver_scheme.rb | 157 ++++++++++++++++++++++++ lib/dev/deps/version_scheme.rb | 48 ++++++++ test/dev/deps/gem_scheme_test.rb | 63 ++++++++++ test/dev/deps/pep440_scheme_test.rb | 77 ++++++++++++ test/dev/deps/pinned_scheme_test.rb | 31 +++++ test/dev/deps/rock_scheme_test.rb | 71 +++++++++++ test/dev/deps/semver_scheme_test.rb | 77 ++++++++++++ test/dev/deps/version_scheme_test.rb | 24 ++++ 12 files changed, 943 insertions(+) create mode 100644 lib/dev/deps/gem_scheme.rb create mode 100644 lib/dev/deps/pep440_scheme.rb create mode 100644 lib/dev/deps/pinned_scheme.rb create mode 100644 lib/dev/deps/rock_scheme.rb create mode 100644 lib/dev/deps/semver_scheme.rb create mode 100644 lib/dev/deps/version_scheme.rb create mode 100644 test/dev/deps/gem_scheme_test.rb create mode 100644 test/dev/deps/pep440_scheme_test.rb create mode 100644 test/dev/deps/pinned_scheme_test.rb create mode 100644 test/dev/deps/rock_scheme_test.rb create mode 100644 test/dev/deps/semver_scheme_test.rb create mode 100644 test/dev/deps/version_scheme_test.rb diff --git a/lib/dev/deps/gem_scheme.rb b/lib/dev/deps/gem_scheme.rb new file mode 100644 index 0000000..c53301e --- /dev/null +++ b/lib/dev/deps/gem_scheme.rb @@ -0,0 +1,72 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +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 < StandardError; end + # The version string is not a valid Gem::Version. + class InvalidVersionError < StandardError; end + + # The constraint key carrying the version requirement (the gem DSL's + # positional requirement lands under "version"). + CONSTRAINT_KEY = "version" + + # @param version [String] a gem version string + # @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: String, 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)) + 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/pep440_scheme.rb b/lib/dev/deps/pep440_scheme.rb new file mode 100644 index 0000000..ef0fc32 --- /dev/null +++ b/lib/dev/deps/pep440_scheme.rb @@ -0,0 +1,171 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +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 < StandardError; end + # The version string is not a PEP 440 version. + class InvalidVersionError < StandardError; 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 [String] a PEP 440 version + # @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: String, 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? + + expression.split(",").map(&:strip).all? { |term| term_satisfied?(version, 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.must(comparison_key(version) <=> comparison_key(bound)) + 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_key(version) <=> comparison_key(bound)) >= 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(T.must(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/pinned_scheme.rb b/lib/dev/deps/pinned_scheme.rb new file mode 100644 index 0000000..91d9191 --- /dev/null +++ b/lib/dev/deps/pinned_scheme.rb @@ -0,0 +1,38 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +require_relative "version_scheme" + +module Dev + module Deps + # Constraint semantics for pinned universes (:brew, :cmake, :gh, :steam, + # :xcode): the backing service already applied the declared identity + # constraint while building the universe, so every reported version + # satisfies, and the reported order stands. + # + # These ecosystems' constraints name an identity (a git tag or commit, a + # release tag, a Steam buildid, an exact Xcode version, a brew formula + # suffix), not a range over an ordered version set — the repository + # queries exactly that identity and reports a degenerate (usually + # singleton) universe. There is nothing left to evaluate or to order. + class PinnedScheme < VersionScheme + extend T::Sig + + # @param version [String] any reported version + # @param constraint [Hash] ignored — already applied by the repository + # @return [Boolean] always true + sig { override.params(version: String, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } + def satisfies?(version, constraint) + true + end + + # @param versions [Array] reported versions + # @return [Array] the same versions, order untouched + 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/rock_scheme.rb b/lib/dev/deps/rock_scheme.rb new file mode 100644 index 0000000..6005180 --- /dev/null +++ b/lib/dev/deps/rock_scheme.rb @@ -0,0 +1,114 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +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 < StandardError; end + # The version string is not a luarocks version. + class InvalidVersionError < StandardError; 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 [String] a luarocks version ("3.4-1") + # @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: String, 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) + 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.must(key <=> comparison_key(bound)) + 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 + + (key <=> comparison_key(bound)) >= 0 && T.must(key <=> [upper, 0]).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/semver_scheme.rb b/lib/dev/deps/semver_scheme.rb new file mode 100644 index 0000000..869e43d --- /dev/null +++ b/lib/dev/deps/semver_scheme.rb @@ -0,0 +1,157 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +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 < StandardError; end + # The version string is not a semver version. + class InvalidVersionError < StandardError; 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 [String] a semver version + # @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: String, 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) + 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 + case operator + when "^" then (key <=> bound_key) >= 0 && (key <=> release_key(caret_upper(triple))) < 0 + when "~" then (key <=> bound_key) >= 0 && (key <=> release_key(tilde_upper(triple))) < 0 + when ">=" then (key <=> bound_key) >= 0 + when ">" then T.must(key <=> bound_key).positive? + when "<=" then (key <=> bound_key) <= 0 + when "<" then T.must(key <=> bound_key).negative? + else T.must(key <=> bound_key).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/version_scheme.rb b/lib/dev/deps/version_scheme.rb new file mode 100644 index 0000000..ea60054 --- /dev/null +++ b/lib/dev/deps/version_scheme.rb @@ -0,0 +1,48 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +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 + + # Does one version satisfy the constraint, under this ecosystem's syntax + # and comparison rules? + # + # @param version [String] a version string in this ecosystem's vocabulary + # @param constraint [Hash] the declaration's constraint hash + # @return [Boolean] + sig { params(version: String, 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/test/dev/deps/gem_scheme_test.rb b/test/dev/deps/gem_scheme_test.rb new file mode 100644 index 0000000..e180470 --- /dev/null +++ b/test/dev/deps/gem_scheme_test.rb @@ -0,0 +1,63 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/gem_scheme" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::GemSchemeTest < Minitest::Test + def scheme + Dev::Deps::GemScheme.new + end + + test "#{version} against #{requirement.inspect} is #{expected}" do + When "evaluating the requirement under rubygems semantics" + result = scheme.satisfies?(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?("1.17.4", {}) + scheme.satisfies?("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?("1.0.0", { "version" => ">>>= nope" }) + + Then + raises Dev::Deps::GemScheme::InvalidConstraintError + 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..2ab0c1a --- /dev/null +++ b/test/dev/deps/pep440_scheme_test.rb @@ -0,0 +1,77 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/pep440_scheme" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::Pep440SchemeTest < Minitest::Test + def scheme + Dev::Deps::Pep440Scheme.new + end + + test "#{version} against #{requirement.inspect} is #{expected}" do + When "evaluating the specifier under PEP 440 semantics" + result = scheme.satisfies?(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 + end + + test "an empty constraint is satisfied by anything" do + Expect + scheme.satisfies?("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?("2.0", { "version" => "=>2.0" }) + + Then + raises Dev::Deps::Pep440Scheme::InvalidConstraintError + end +end diff --git a/test/dev/deps/pinned_scheme_test.rb b/test/dev/deps/pinned_scheme_test.rb new file mode 100644 index 0000000..c96e7ea --- /dev/null +++ b/test/dev/deps/pinned_scheme_test.rb @@ -0,0 +1,31 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/pinned_scheme" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::PinnedSchemeTest < Minitest::Test + def scheme + Dev::Deps::PinnedScheme.new + end + + test "every reported version satisfies every constraint" do + Expect "the backing service already narrowed the universe to the declared identity" + scheme.satisfies?("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", { "tag" => "v1.2.3" }) + scheme.satisfies?("5.6.1-css-83", { "tag" => "5.6.1-css-83" }) + scheme.satisfies?("20240101", { "buildid" => "20240101" }) + scheme.satisfies?("26.1.1", {}) + end + + test "sort preserves the repository-reported order" do + Given "versions in the order the repository reported them" + versions = ["current", "older"] + + When "sorting" + sorted = scheme.sort(versions) + + Then "the order is untouched — a pinned universe has no version order to impose" + sorted == ["current", "older"] + 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..64983fe --- /dev/null +++ b/test/dev/deps/rock_scheme_test.rb @@ -0,0 +1,71 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/rock_scheme" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::RockSchemeTest < Minitest::Test + def scheme + Dev::Deps::RockScheme.new + end + + test "#{version} against #{requirement.inspect} is #{expected}" do + When "evaluating the requirement under luarocks semantics" + result = scheme.satisfies?(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 + end + + test "an empty constraint is satisfied by anything" do + Expect + scheme.satisfies?("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?("3.4-1", { "constraint" => "~~> 3.0" }) + + Then + raises Dev::Deps::RockScheme::InvalidConstraintError + 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..19ebfe1 --- /dev/null +++ b/test/dev/deps/semver_scheme_test.rb @@ -0,0 +1,77 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/semver_scheme" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::SemverSchemeTest < Minitest::Test + def scheme + Dev::Deps::SemverScheme.new + end + + test "#{version} against #{requirement.inspect} is #{expected}" do + When "evaluating the requirement under semver range semantics" + result = scheme.satisfies?(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 + end + + test "an empty constraint is satisfied by anything" do + Expect + scheme.satisfies?("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?("1.0.0", { "version" => "^^nope" }) + + Then + raises Dev::Deps::SemverScheme::InvalidConstraintError + end +end diff --git a/test/dev/deps/version_scheme_test.rb b/test/dev/deps/version_scheme_test.rb new file mode 100644 index 0000000..9f22aa9 --- /dev/null +++ b/test/dev/deps/version_scheme_test.rb @@ -0,0 +1,24 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +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" + Dev::Deps::VersionScheme.new.satisfies?("1.0.0", { "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 From 35bafa6768b67b8d5105219c9cd2617efb93fb9f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 17:45:01 -0400 Subject: [PATCH 03/37] Add Repository#find(PackageId) -> Package The facts-only repository contract: no lifecycle, no constraint, no choice. #fetch and #prepare stay temporarily (marked deprecated) so each repository can gain find in its own green commit; both die in the Resolver cutover commit. Co-authored-by: Cursor --- lib/dev/deps/repository.rb | 40 ++++++++++++++++++++++++++------ test/dev/deps/repository_test.rb | 12 ++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/lib/dev/deps/repository.rb b/lib/dev/deps/repository.rb index 46508a9..4a5d5d4 100644 --- a/lib/dev/deps/repository.rb +++ b/lib/dev/deps/repository.rb @@ -4,18 +4,43 @@ require "sorbet-runtime" require_relative "dependency" require_relative "dependency_declaration" +require_relative "package" +require_relative "package_id" 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 + # The universe has no package under the requested identity. + class PackageNotFoundError < StandardError; end + + # Report the package under this identity. + # + # @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 + # Fetch a dependency by its unique identifier. # + # DEPRECATED: the per-item pin contract, replaced by #find. It conflates + # identity, constraint, and choice in one untyped hash; it is deleted + # together with the Resolver cutover to find/VersionScheme. + # # @param id [Hash] unique resource identifier within this repository # @return [Dependency] sig { params(id: T::Hash[String, T.untyped]).returns(Dependency) } @@ -23,10 +48,11 @@ def fetch(id) raise NotImplementedError, "#{self.class}#fetch 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. + # Batch hook called once per integration type before any fetch. + # + # DEPRECATED: a lifecycle hook smuggling a whole-set solve through a + # per-item contract; its bundler use moves to BundlerLocker and the hook + # is deleted together with the Resolver cutover. # # @param declarations [Array] this type's declarations # @return [void] diff --git a/test/dev/deps/repository_test.rb b/test/dev/deps/repository_test.rb index 7b73c9f..6df2f78 100644 --- a/test/dev/deps/repository_test.rb +++ b/test/dev/deps/repository_test.rb @@ -3,9 +3,21 @@ 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 find raises NotImplementedError" do + Given "a base Repository instance" + repo = Dev::Deps::Repository.new + + When "finding a package" + repo.find(Dev::Deps::PackageId.new(integration: :cmake, name: "boost")) + + Then + raises NotImplementedError + end + test "base class fetch raises NotImplementedError" do Given "a base Repository instance" repo = Dev::Deps::Repository.new From c35d84d873f71566f1612a3c02fca4e6072fb55e Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 17:50:00 -0400 Subject: [PATCH 04/37] Give PackageVersion install-fact metadata and find a locator filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contract refinements the repository reshapes need: - PackageVersion#metadata: ecosystem-specific facts the integration reads at install (mod_id, release assets, tap). The repository composes it, so minted pins keep today's lockfile metadata shapes exactly. - Repository#find(id, filter:): the declaration constraint as a server-side locator. Pinned ecosystems (git tag, release tag, steam buildid, brew suffix) need it to locate their singleton universe; filtering returns all matching versions and never picks — range evaluation stays with VersionScheme, choice with the Resolver. Co-authored-by: Cursor --- lib/dev/deps/package_version.rb | 16 +++++++++++++--- lib/dev/deps/repository.rb | 14 ++++++++++++-- test/dev/deps/package_version_test.rb | 13 +++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/lib/dev/deps/package_version.rb b/lib/dev/deps/package_version.rb index abd6718..c44462c 100644 --- a/lib/dev/deps/package_version.rb +++ b/lib/dev/deps/package_version.rb @@ -50,11 +50,18 @@ class PackageVersion sig { returns(T::Array[DependencyEdge]) } attr_reader :dependencies + # @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 dependencies [Array] outgoing edges + # @param metadata [Hash{String => Object}] ecosystem-specific install facts sig do params( version: String, @@ -62,14 +69,16 @@ class PackageVersion digest: T.nilable(String), artifacts: T::Hash[String, Artifact], dependencies: T::Array[DependencyEdge], + metadata: T::Hash[String, T.untyped], ).void end - def initialize(version:, platforms: [], digest: nil, artifacts: {}, dependencies: []) + def initialize(version:, platforms: [], digest: nil, artifacts: {}, dependencies: [], metadata: {}) @version = version @platforms = T.let(platforms.dup.freeze, T::Array[String]) @digest = digest @artifacts = T.let(artifacts.dup.freeze, T::Hash[String, Artifact]) @dependencies = T.let(dependencies.dup.freeze, T::Array[DependencyEdge]) + @metadata = T.let(metadata.dup.freeze, T::Hash[String, T.untyped]) freeze end @@ -82,14 +91,15 @@ def ==(other) platforms == other.platforms && digest == other.digest && artifacts == other.artifacts && - dependencies == other.dependencies + dependencies == other.dependencies && + metadata == other.metadata end alias_method :eql?, :== # @return [Integer] hash code sig { returns(Integer) } def hash - [self.class, version, platforms, digest, artifacts, dependencies].hash + [self.class, version, platforms, digest, artifacts, dependencies, metadata].hash end end end diff --git a/lib/dev/deps/repository.rb b/lib/dev/deps/repository.rb index 4a5d5d4..f8340cb 100644 --- a/lib/dev/deps/repository.rb +++ b/lib/dev/deps/repository.rb @@ -27,11 +27,21 @@ class PackageNotFoundError < StandardError; end # Report the package under this identity. # + # The filter is the declaration's constraint hash, passed as a + # server-side locator: ecosystems whose constraint names an identity + # (a git tag, a release tag, a Steam buildid, a brew formula suffix) + # need it to locate their — typically singleton — universe, and + # registry-backed ecosystems may use it to narrow an expensive index. + # Filtering returns every matching version; it never picks one. A + # repository must not evaluate range constraints (VersionScheme's job) + # and must not choose among candidates (the Resolver's job). + # # @param id [PackageId] the package's identity + # @param filter [Hash] the declaration constraint, as a locator only # @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) + sig { params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) raise NotImplementedError, "#{self.class}#find must be implemented" end diff --git a/test/dev/deps/package_version_test.rb b/test/dev/deps/package_version_test.rb index 45fa782..3b944f9 100644 --- a/test/dev/deps/package_version_test.rb +++ b/test/dev/deps/package_version_test.rb @@ -16,6 +16,19 @@ class Dev::Deps::PackageVersionTest < Minitest::Test version.digest.nil? version.artifacts == {} version.dependencies == [] + version.metadata == {} + 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 From e0de0812a1d7d86974688b7a9239200508ac5b79 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 17:51:44 -0400 Subject: [PATCH 05/37] FicsitRepository#find: the mod's full version universe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every published version becomes a PackageVersion: targets as platforms, each target's download as an Artifact carrying the API's SHA256 (dev-enforced integrity), required mod deps as edges, and the install facts FicsitIntegration reads. This is the fix for ficsit ignoring semver constraints: the whole universe is now visible, so the Resolver can pick with SemverScheme instead of blindly taking versions.first. A missing requested platform no longer raises here — the block just lacks it, and disqualifying the version is the Resolver's call. ModNotFoundError is now a Repository::PackageNotFoundError. #fetch is untouched until the cutover. Co-authored-by: Cursor --- lib/dev/deps/ficsit_repository.rb | 113 +++++++++++++++- test/dev/deps/ficsit_repository_test.rb | 173 ++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 2 deletions(-) diff --git a/lib/dev/deps/ficsit_repository.rb b/lib/dev/deps/ficsit_repository.rb index 8924659..1ca011a 100644 --- a/lib/dev/deps/ficsit_repository.rb +++ b/lib/dev/deps/ficsit_repository.rb @@ -5,8 +5,13 @@ require "net/http" require "sorbet-runtime" require "uri" -require_relative "repository" +require_relative "artifact" require_relative "dependency" +require_relative "dependency_edge" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" module Dev module Deps @@ -18,7 +23,7 @@ class FicsitRepository < Repository extend T::Sig class ApiError < StandardError; end - class ModNotFoundError < StandardError; end + class ModNotFoundError < PackageNotFoundError; end class NoVersionError < StandardError; end class TargetNotFoundError < StandardError; end @@ -52,6 +57,30 @@ class TargetNotFoundError < StandardError; end } GRAPHQL + # Report a mod's published versions from ficsit.app. + # + # Each version carries its targets as platforms, each target's download + # as an Artifact (dev-enforced integrity: the SHA256 the API publishes), + # its required mod dependencies as edges, and the install facts + # FicsitIntegration reads (mod_id, game_version, and either a + # single-target digest or a per-platform block, per the filter). + # + # @param id [PackageId] name is the mod_reference + # @param filter [Hash] locator only; "platforms" (Array) + # or "target" select which targets the install facts describe + # @return [Package] + # @raise [ModNotFoundError] if the mod_reference doesn't exist on ficsit.app + # @raise [ApiError] if the GraphQL request fails + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + mod_data = query_mod(id.name) + versions = (mod_data["versions"] || []).map do |version_data| + package_version(mod_data, version_data, filter) + end + + Package.new(id: id, versions: versions) + end + # Resolve a ficsit.app mod dependency to a pinned Dependency. # # Two shapes, selected by the fetch id: @@ -111,6 +140,86 @@ def fetch(id) private + # Map one GraphQL version object to a PackageVersion. + # + # Universe facts (platforms, artifacts, edges) are unconditional. The + # install facts mirror the pin shapes FicsitIntegration reads: with + # requested platforms, a metadata["platforms"] block covering the + # targets this version actually publishes (the Resolver rejects the + # version if a requested one is missing); otherwise the legacy + # single-target shape (metadata["target"] plus the digest). + # + # @param mod_data [Hash] the mod object (for mod_id) + # @param version_data [Hash] one version object + # @param filter [Hash] the declaration constraint, as a locator + # @return [PackageVersion] + sig do + params( + mod_data: T::Hash[String, T.untyped], + version_data: T::Hash[String, T.untyped], + filter: T::Hash[String, T.untyped], + ).returns(PackageVersion) + end + def package_version(mod_data, version_data, filter) + targets = version_data["targets"] || [] + metadata = { + "mod_id" => mod_data["id"], + "game_version" => version_data["game_version"], + } + + requested = filter["platforms"] + if requested && !requested.empty? + digest = nil + metadata["platforms"] = platform_block(version_data, targets, requested) + else + target_data = find_target(targets, filter.fetch("target", DEFAULT_TARGET)) + digest = target_data ? "SHA256=#{target_data["hash"]}" : nil + metadata["target"] = filter.fetch("target", DEFAULT_TARGET) + end + + PackageVersion.new( + version: version_data["version"], + platforms: targets.map { |t| t["targetName"] }, + digest: digest, + artifacts: targets.to_h do |t| + [t["targetName"], Artifact.new(uri: download_url(version_data, t), digest: "SHA256=#{t["hash"]}")] + end, + dependencies: (version_data["dependencies"] || []) + .reject { |d| d["optional"] } + .map { |d| DependencyEdge.new(name: d["mod_id"], constraint: d["condition"]) }, + metadata: metadata, + ) + end + + # The {hash, link} block for each requested platform this version + # publishes. Non-raising: a missing target simply isn't in the block — + # whether that disqualifies the version is the Resolver's call. + # + # @param version_data [Hash] the version object + # @param targets [Array] its target objects + # @param requested [Array] platforms; nil means the default + # @return [Hash{String => Hash}] target name → { "hash" => …, "link" => … } + sig do + params( + version_data: T::Hash[String, T.untyped], + targets: T::Array[T::Hash[String, T.untyped]], + requested: T::Array[T.nilable(String)], + ).returns(T::Hash[String, T::Hash[String, String]]) + end + def platform_block(version_data, targets, requested) + 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 } + next unless target_data + + acc[target_name] = { + "hash" => "SHA256=#{target_data["hash"]}", + "link" => download_url(version_data, target_data), + } + end + end + # 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 diff --git a/test/dev/deps/ficsit_repository_test.rb b/test/dev/deps/ficsit_repository_test.rb index f143d97..eeeefec 100644 --- a/test/dev/deps/ficsit_repository_test.rb +++ b/test/dev/deps/ficsit_repository_test.rb @@ -7,6 +7,179 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::FicsitRepositoryTest < Minitest::Test + 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" => { + "getModByReference" => { + "id" => "abc123", + "name" => "Area Actions", + "mod_reference" => "AreaActions", + "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)) + stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + repo.stubs(:post_graphql).returns(stub_response) + + When "finding the package" + package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "AreaActions")) + + Then "the whole universe is reported, facts attached" + package.versions.map(&:version) == ["2.5.0", "2.4.0"] + latest = package.version("2.5.0") + latest.platforms == ["Windows"] + latest.digest == "SHA256=deadbeef" + latest.artifacts["Windows"].uri == "https://api.ficsit.app/v1/version/ver2/Windows/download" + latest.artifacts["Windows"].digest == "SHA256=deadbeef" + latest.dependencies == [Dev::Deps::DependencyEdge.new(name: "SML", constraint: "^3.12.0")] + latest.metadata["mod_id"] == "abc123" + latest.metadata["game_version"] == ">=491125" + latest.metadata["target"] == "Windows" + package.version("2.4.0").digest == "SHA256=cafebabe" + end + + test "find with a platforms filter nests per-platform install facts" do + Given "a mod with Windows and LinuxServer targets" + 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" => [], + }], + }, + }, + } + 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 "finding with the nil default and LinuxServer requested" + package = repo.find( + Dev::Deps::PackageId.new(integration: :ficsit, name: "SML"), + filter: { "platforms" => [nil, "LinuxServer"] }, + ) + + Then "install facts nest per platform and the top-level digest is nil" + version = package.version("3.12.0") + version.digest.nil? + version.metadata["platforms"]["Windows"]["hash"] == "SHA256=winhash" + version.metadata["platforms"]["Windows"]["link"] == "https://api.ficsit.app/v1/version/ver1/Windows/download" + version.metadata["platforms"]["LinuxServer"]["hash"] == "SHA256=linuxhash" + !version.metadata.key?("target") + end + + test "find omits a requested platform this version does not publish" do + Given "a mod publishing 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 }], + "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 "finding with LinuxServer requested" + package = repo.find( + Dev::Deps::PackageId.new(integration: :ficsit, name: "SML"), + filter: { "platforms" => ["LinuxServer"] }, + ) + + Then "the block simply lacks the platform — disqualifying is the Resolver's call" + version = package.version("3.12.0") + version.metadata["platforms"] == {} + version.platforms == ["Windows"] + end + + test "find raises ModNotFoundError, a PackageNotFoundError, for unknown mods" do + Given "a repository returning null mod data" + repo = Dev::Deps::FicsitRepository.new + 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 "finding a nonexistent mod" + repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "NonExistentMod")) + + Then + raises Dev::Deps::Repository::PackageNotFoundError + end + + 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" => "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 "finding the package" + package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "EmptyMod")) + + Then "an empty universe is a fact, not an error" + package.empty? + end + test "fetch resolves mod to version, hash, and transitive deps" do Given "a repository with a stubbed GraphQL response" repo = Dev::Deps::FicsitRepository.new From 9d98de1ce6d1a324ada455a6703ec08f6e75de0c Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 17:53:11 -0400 Subject: [PATCH 06/37] LuaRocksRepository#find: the manifest's version universe, facts only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit luarocks search yields versions (deduplicated across arches) and nothing more, so that is the universe: no digests — luarocks verifies rockspec integrity itself at install, and the old resolve-time download-and-hash produced an audit hash and a downloaded_path that nothing read — and no edges. Constraint evaluation moves to RockScheme, fixing the old take- first-ignore-constraint behavior. #fetch stays until the cutover. Co-authored-by: Cursor --- lib/dev/deps/luarocks_repository.rb | 47 ++++++++++++++++++++++++- test/dev/deps/luarocks_registry_test.rb | 46 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/lib/dev/deps/luarocks_repository.rb b/lib/dev/deps/luarocks_repository.rb index 66ebff9..e57216c 100644 --- a/lib/dev/deps/luarocks_repository.rb +++ b/lib/dev/deps/luarocks_repository.rb @@ -5,8 +5,11 @@ require "open3" require "sorbet-runtime" require "tempfile" -require_relative "repository" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" module Dev module Deps @@ -21,6 +24,32 @@ class LuaRocksRepository < Repository class SearchError < StandardError; end class NoVersionError < StandardError; end class DownloadError < StandardError; end + class RockNotFoundError < PackageNotFoundError; end + + # Report a rock's available versions from `luarocks search`. + # + # 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 + # @param filter [Hash] unused; the manifest search needs no locator + # @return [Package] + # @raise [SearchError] if luarocks search fails + # @raise [RockNotFoundError] if the search yields no versions + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + versions = search_versions(id.name) + raise RockNotFoundError, "no rock named #{id.name} on luarocks.org" if versions.empty? + + Package.new( + id: id, + versions: versions.map { |version| PackageVersion.new(version: version) }, + ) + end # Resolve a LuaRocks package to an exact version + integrity hash. # @@ -49,6 +78,22 @@ def fetch(id) private + # All versions the manifest lists for a rock, most recent first, + # deduplicated across arches. + # + # @param name [String] rock name + # @return [Array] version strings as listed + # @raise [SearchError] if luarocks search command fails + 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]]) + matches.map { |match| T.must(match.first) }.uniq + end + # Find the best available version for a package. # # @param name [String] rock name diff --git a/test/dev/deps/luarocks_registry_test.rb b/test/dev/deps/luarocks_registry_test.rb index 0d8a393..06bbb90 100644 --- a/test/dev/deps/luarocks_registry_test.rb +++ b/test/dev/deps/luarocks_registry_test.rb @@ -9,6 +9,52 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::LuaRocksRepositoryTest < Minitest::Test + 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.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)]) + + When "finding the package" + package = repository.find(Dev::Deps::PackageId.new(integration: :luarocks, name: "luaunit")) + + 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").dependencies == [] + end + + test "find raises RockNotFoundError, a PackageNotFoundError, on empty search" do + Given "a luarocks search that returns no version lines" + repository = Dev::Deps::LuaRocksRepository.new + Open3.stubs(:capture3) + .with("luarocks", "search", "missing", "--porcelain") + .returns(["missing\n", "", stub(success?: true)]) + + When "finding the package" + repository.find(Dev::Deps::PackageId.new(integration: :luarocks, name: "missing")) + + Then + raises Dev::Deps::Repository::PackageNotFoundError + end + + 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", "broken", "--porcelain") + .returns(["", "error", stub(success?: false)]) + + When "finding the package" + repository.find(Dev::Deps::PackageId.new(integration: :luarocks, name: "broken")) + + Then + raises Dev::Deps::LuaRocksRepository::SearchError + end + test "fetch parses luarocks search output and returns a Dependency" do Given "a stubbed luarocks search and download at the Open3 boundary" repository = Dev::Deps::LuaRocksRepository.new From 0a0461bc2bd2a1ec7ca8ce19ab4cc1e02a56544e Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 18:02:28 -0400 Subject: [PATCH 07/37] BrewRepository#find: the moving registry's singleton universe brew info answers with a single current stable version, so the universe is a singleton located by the filter: the declared version is a formula suffix (18 selects llvm@18), tap scopes the name, cask switches to an unversioned entry. Casks use an empty-string version stand-in that the Resolver mints back to nil. Bottle SHA256 rides as the version digest. Co-authored-by: Cursor --- lib/dev/deps/brew_repository.rb | 54 ++++++++++++++++++++++- test/dev/deps/brew_repository_test.rb | 62 +++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/lib/dev/deps/brew_repository.rb b/lib/dev/deps/brew_repository.rb index 614e132..c28818e 100644 --- a/lib/dev/deps/brew_repository.rb +++ b/lib/dev/deps/brew_repository.rb @@ -4,8 +4,11 @@ require "json" require "open3" require "sorbet-runtime" -require_relative "repository" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" module Dev module Deps @@ -18,6 +21,55 @@ class BrewRepository < Repository class BrewInfoError < StandardError; end + # Version stand-in for casks, whose versions Homebrew does not expose + # here; the Resolver mints it back to a nil pin version. + UNVERSIONED = "" + + # Report a brew package's universe: the one stable version the selected + # formula spec currently has. + # + # Brew is a moving registry — `brew info` answers with a single current + # version, so the universe is a singleton. The filter locates which + # formula that is: "version" is a formula *suffix* ("18" selects + # llvm@18), "tap" scopes the name, "cask" switches to an unversioned + # cask entry. PinnedScheme accepts whatever brew reports. + # + # @param id [PackageId] name is the formula or cask name + # @param filter [Hash] locator: "tap", "version" (suffix), "cask" + # @return [Package] a singleton universe + # @raise [BrewInfoError] if `brew info` fails for a formula + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + version_suffix = filter["version"] + + if filter["cask"] + metadata = { "cask" => true } + metadata["version_suffix"] = version_suffix if version_suffix + return Package.new( + id: id, + versions: [PackageVersion.new(version: UNVERSIONED, metadata: metadata)], + ) + end + + info = brew_info_with_tap(build_formula_spec(id.name, filter["tap"], version_suffix), filter["tap"]) + bottle_hash = extract_bottle_hash(info) + + metadata = {} + metadata["tap"] = filter["tap"] if filter["tap"] + metadata["version_suffix"] = version_suffix if version_suffix + + Package.new( + id: id, + versions: [ + PackageVersion.new( + version: info["versions"]["stable"], + digest: bottle_hash ? "SHA256=#{bottle_hash}" : nil, + metadata: metadata, + ), + ], + ) + end + # Resolve a brew dependency identifier to a pinned Dependency. # # For casks, returns a Dependency with nil version/hash. diff --git a/test/dev/deps/brew_repository_test.rb b/test/dev/deps/brew_repository_test.rb index fc676bb..0c36362 100644 --- a/test/dev/deps/brew_repository_test.rb +++ b/test/dev/deps/brew_repository_test.rb @@ -9,6 +9,68 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::BrewRepositoryTest < Minitest::Test + test "find reports the formula's current stable version as a singleton universe" do + Given "a formula on the moving brew registry" + repository = Dev::Deps::BrewRepository.new + brew_json = [{ + "name" => "cmake", + "versions" => { "stable" => "3.31.4" }, + "bottle" => { + "stable" => { "files" => { "arm64_sonoma" => { "sha256" => "abc123def456" } } }, + }, + }].to_json + Open3.stubs(:capture3) + .with("brew", "info", "--json=v1", "cmake") + .returns([brew_json, "", stub(success?: true)]) + + 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 "find locates the suffixed formula via the version filter" do + Given "a formula declared with a version suffix and a tap" + repository = Dev::Deps::BrewRepository.new + brew_json = [{ + "name" => "llvm@18", + "versions" => { "stable" => "18.1.8" }, + "bottle" => { "stable" => { "files" => { "arm64_sonoma" => { "sha256" => "llvm18" } } } }, + }].to_json + Open3.stubs(:capture3) + .with("brew", "info", "--json=v1", "someorg/sometap/llvm@18") + .returns([brew_json, "", stub(success?: true)]) + + When "finding with the suffix and tap as locator" + package = repository.find( + Dev::Deps::PackageId.new(integration: :brew, name: "llvm"), + filter: { "version" => "18", "tap" => "someorg/sometap" }, + ) + + Then "the suffixed formula's stable version, with locator facts recorded" + package.versions.map(&:version) == ["18.1.8"] + package.version("18.1.8").metadata == { "tap" => "someorg/sometap", "version_suffix" => "18" } + end + + test "find reports a cask as one unversioned, undigested entry" do + Given "a cask declaration" + repository = Dev::Deps::BrewRepository.new + + When "finding with the cask flag" + package = repository.find( + Dev::Deps::PackageId.new(integration: :brew, name: "firefox"), + filter: { "cask" => true }, + ) + + Then "brew exposes no cask version here — an empty version stand-in" + package.versions.map(&:version) == [Dev::Deps::BrewRepository::UNVERSIONED] + package.versions.first.digest.nil? + package.versions.first.metadata == { "cask" => true } + end + test "fetch parses brew info JSON and returns a Dependency" do Given "a brew formula identifier" repository = Dev::Deps::BrewRepository.new From 6a4f39235d4bb53bb64e77a04f15a20f7be51305 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 18:03:50 -0400 Subject: [PATCH 08/37] GhRepository#find: the declared tag as a singleton universe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub refs are not an enumerable version index, so the filter's tag locates the one release (prebuilt shape) or ref (source shape) the declaration pins; the owner/repo slug rides as PackageId#source. Install facts mirror today's pin metadata exactly — asset digests for GhIntegration's download verification, commit SHA for provenance. ReleaseNotFoundError is now a Repository::PackageNotFoundError. Co-authored-by: Cursor --- lib/dev/deps/gh_repository.rb | 98 ++++++++++++++++++++++++++++- test/dev/deps/gh_repository_test.rb | 71 +++++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/lib/dev/deps/gh_repository.rb b/lib/dev/deps/gh_repository.rb index d272123..c72ef27 100644 --- a/lib/dev/deps/gh_repository.rb +++ b/lib/dev/deps/gh_repository.rb @@ -4,8 +4,11 @@ require "json" require "open3" require "sorbet-runtime" -require_relative "repository" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" module Dev module Deps @@ -26,10 +29,40 @@ class GhRepository < Repository class GhMissingError < StandardError; end class AuthenticationError < StandardError; end class RepoAccessError < StandardError; end - class ReleaseNotFoundError < StandardError; end + class ReleaseNotFoundError < PackageNotFoundError; end class NoMatchingAssetsError < StandardError; end class ApiError < StandardError; end + # Report a GitHub dependency's universe: the declared tag, as a + # singleton. + # + # GitHub refs are not an enumerable version index — the filter's "tag" + # locates the one release (prebuilt shape, "assets" glob present) or + # ref (source shape, "build" recipe present) the declaration pins. + # Integrity is tool-enforced by the authenticated gh CLI; per-asset + # API digests ride in metadata for GhIntegration to verify downloads. + # + # @param id [PackageId] source is the "owner/repo" slug + # @param filter [Hash] locator: "tag", "install_dir", "assets" or "build" + # @return [Package] a singleton universe + # @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 { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + repo_slug = T.must(id.source) + tag = filter["tag"] + version = if filter["assets"] + prebuilt_version(repo_slug, tag, filter) + else + source_version(repo_slug, tag, filter) + end + + Package.new(id: id, versions: [version]) + end + # Resolve a GitHub dependency to a pinned Dependency. # # Two shapes, distinguished by the declaration: "assets" => prebuilt release @@ -50,6 +83,67 @@ def fetch(id) private + # The prebuilt shape: the tag's release, its glob-matched assets and + # their API digests as install facts. + # + # @param repo_slug [String] "owner/repo" + # @param tag [String] release tag + # @param filter [Hash] the declaration constraint + # @return [PackageVersion] + # @raise [NoMatchingAssetsError] if no assets match the pattern + sig do + params( + repo_slug: String, + tag: String, + filter: T::Hash[String, T.untyped], + ).returns(PackageVersion) + end + def prebuilt_version(repo_slug, tag, filter) + pattern = filter["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 + + PackageVersion.new( + version: tag, + metadata: { + "repo" => repo_slug, + "asset_pattern" => pattern, + "install_dir" => filter["install_dir"], + "assets" => assets.map { |asset| asset_metadata(asset) }, + }, + ) + end + + # The source shape: the tag's commit SHA (provenance) and the build + # recipe as install facts. + # + # @param repo_slug [String] "owner/repo" + # @param tag [String] tag/ref + # @param filter [Hash] the declaration constraint + # @return [PackageVersion] + sig do + params( + repo_slug: String, + tag: String, + filter: T::Hash[String, T.untyped], + ).returns(PackageVersion) + end + def source_version(repo_slug, tag, filter) + PackageVersion.new( + version: tag, + metadata: { + "repo" => repo_slug, + "install_dir" => filter["install_dir"], + "build" => filter["build"], + "commit" => resolve_commit_sha(repo_slug, tag), + }, + ) + end + # Resolve a prebuilt-release dependency (download + verify path). # # @param id [Hash] diff --git a/test/dev/deps/gh_repository_test.rb b/test/dev/deps/gh_repository_test.rb index cc6a801..5ec4156 100644 --- a/test/dev/deps/gh_repository_test.rb +++ b/test/dev/deps/gh_repository_test.rb @@ -40,6 +40,77 @@ def fetch_id(overrides = {}) }.merge(overrides) end + test "find reports the declared tag's release as a singleton universe" 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 "finding with the tag and asset glob as locator" + package = repo.find( + Dev::Deps::PackageId.new( + integration: :gh, name: "UnrealEngine", source: "satisfactorymodding/UnrealEngine", + ), + filter: { + "tag" => "5.6.1-css-83", + "assets" => "UnrealEngine-CSS-Editor-Linux.tar.zst.*", + "install_dir" => "~/.dev/engines/unreal-engine-css", + }, + ) + + Then "one version carrying the prebuilt install facts" + package.versions.map(&:version) == ["5.6.1-css-83"] + version = package.version("5.6.1-css-83") + version.digest.nil? + version.metadata["repo"] == "satisfactorymodding/UnrealEngine" + version.metadata["asset_pattern"] == "UnrealEngine-CSS-Editor-Linux.tar.zst.*" + version.metadata["install_dir"] == "~/.dev/engines/unreal-engine-css" + version.metadata["assets"].map { |a| a["sha256"] } == ["aaaa1111", "bbbb2222"] + end + + test "find pins the source shape to the tag with its commit SHA" do + Given "a repository resolving a tag to a commit" + repo = Dev::Deps::GhRepository.new + repo.stubs(:run_gh_api) + .with("repos/EpicGames/UnrealEngine/commits/5.6.1-release") + .returns([JSON.generate({ "sha" => "abc123sha" }), "", stub(success?: true)]) + + When "finding with a build recipe instead of assets" + package = repo.find( + Dev::Deps::PackageId.new(integration: :gh, name: "UnrealEngine", source: "EpicGames/UnrealEngine"), + filter: { "tag" => "5.6.1-release", "build" => "make", "install_dir" => "~/.dev/engines/ue" }, + ) + + Then "the singleton version carries the source install facts" + version = package.version("5.6.1-release") + version.metadata["commit"] == "abc123sha" + version.metadata["build"] == "make" + version.metadata["repo"] == "EpicGames/UnrealEngine" + end + + test "find raises ReleaseNotFoundError, a PackageNotFoundError, for a missing tag" do + Given "a gh api that 404s the release but sees the repo" + repo = Dev::Deps::GhRepository.new + 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)]) + repo.stubs(:run_gh_api) + .with("repos/satisfactorymodding/UnrealEngine") + .returns(["{}", "", stub(success?: true)]) + + When "finding a nonexistent tag" + repo.find( + Dev::Deps::PackageId.new( + integration: :gh, name: "UnrealEngine", source: "satisfactorymodding/UnrealEngine", + ), + filter: { "tag" => "9.9.9-css-1", "assets" => "*.tar.zst.*" }, + ) + + Then + raises Dev::Deps::Repository::PackageNotFoundError + 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 From c7ec925370943ca8c129638d987391f5a7d90c97 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 18:05:21 -0400 Subject: [PATCH 09/37] PipRepository#find: the PyPI JSON API's version universe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One GET https://pypi.org/pypi//json yields every published version with file digests — no more pip download at resolve time. Each version's digest is its sdist SHA256 (platform-independent), wheel fallback, nil for yanked/file-less releases. Edges stay empty: pip still owns the transitive tree at install. Constraint evaluation moves to Pep440Scheme. Co-authored-by: Cursor --- lib/dev/deps/pip_repository.rb | 71 +++++++++++++++++++++++++++- test/dev/deps/pip_repository_test.rb | 60 +++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/lib/dev/deps/pip_repository.rb b/lib/dev/deps/pip_repository.rb index f53d140..541a555 100644 --- a/lib/dev/deps/pip_repository.rb +++ b/lib/dev/deps/pip_repository.rb @@ -2,11 +2,17 @@ # frozen_string_literal: true require "digest" +require "json" +require "net/http" require "open3" require "sorbet-runtime" require "tmpdir" -require_relative "repository" +require "uri" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" module Dev module Deps @@ -23,8 +29,35 @@ class PipRepository < Repository class DownloadError < StandardError; end class NoVersionError < StandardError; end + class ProjectNotFoundError < PackageNotFoundError; end + class ApiError < StandardError; end PYTHON = "python3" + PYPI_HOST = "https://pypi.org" + + # Report a project's version universe from PyPI's JSON API. + # + # 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 + # @param filter [Hash] unused; the JSON API needs no locator + # @return [Package] + # @raise [ProjectNotFoundError] if PyPI has no such project + # @raise [ApiError] if the API request fails otherwise + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + releases = project_json(id.name)["releases"] || {} + versions = releases.map do |version, files| + PackageVersion.new(version: version, digest: release_digest(files)) + end + + Package.new(id: id, versions: versions) + end # Resolve a pip package to an exact version + integrity hash. # @@ -53,6 +86,42 @@ def fetch(id) private + # GET and parse https://pypi.org/pypi//json. + # + # @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) + + raise ApiError, "PyPI API returned #{response.code} for #{name}: #{response.body}" + end + + # Perform the HTTP request. Isolated so tests can stub the boundary. + # + # @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 + + # A release's integrity digest: the sdist's SHA256 when one exists + # (platform-independent), else the first file's, else nil. + # + # @param files [Array] the release's file objects + # @return [String, nil] + 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 + # A bare version ("2.0.5") becomes an exact pin ("==2.0.5"); an already- # operatored constraint (">=2.0") passes through; blank means unpinned. # diff --git a/test/dev/deps/pip_repository_test.rb b/test/dev/deps/pip_repository_test.rb index 4a7fb77..3bc07db 100644 --- a/test/dev/deps/pip_repository_test.rb +++ b/test/dev/deps/pip_repository_test.rb @@ -6,6 +6,66 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::PipRepositoryTest < Minitest::Test + 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) + + When "finding the package" + package = repo.find(Dev::Deps::PackageId.new(integration: :pip, name: "totalsegmentator")) + + 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").dependencies == [] + end + + 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) + + When "finding the package" + repo.find(Dev::Deps::PackageId.new(integration: :pip, name: "no-such-project")) + + 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 "reads the version #{expected} from #{filename}" do Given "a repository" repo = Dev::Deps::PipRepository.new From 252e17c17243d4673432f69026ae7563459bd5b8 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 18:07:31 -0400 Subject: [PATCH 10/37] Pin-universe repositories gain find: git, steam, xcode, url None of these ecosystems has an enumerable version index, so each find reports a singleton universe located by the filter: git resolves the declared tag/commit to its SHA via ls-remote (RefResolutionError is now a PackageNotFoundError), steam pins an explicit buildid or the branch's current one via SteamCMD, xcode's declared version IS the universe, and url downloads once to mint a dev-enforced trust-on-first-use SHA256. Install facts mirror today's pin metadata key for key. Co-authored-by: Cursor --- lib/dev/deps/git_repository.rb | 30 +++++++++++++- lib/dev/deps/steam_repository.rb | 40 ++++++++++++++++++- lib/dev/deps/url_repository.rb | 37 +++++++++++++++++- lib/dev/deps/xcode_repository.rb | 22 ++++++++++- test/dev/deps/git_repository_test.rb | 54 ++++++++++++++++++++++++++ test/dev/deps/steam_repository_test.rb | 43 ++++++++++++++++++++ test/dev/deps/url_repository_test.rb | 29 ++++++++++++++ test/dev/deps/xcode_repository_test.rb | 27 +++++++++++++ 8 files changed, 277 insertions(+), 5 deletions(-) diff --git a/lib/dev/deps/git_repository.rb b/lib/dev/deps/git_repository.rb index f6e1469..2f30add 100644 --- a/lib/dev/deps/git_repository.rb +++ b/lib/dev/deps/git_repository.rb @@ -3,8 +3,11 @@ require "open3" require "sorbet-runtime" -require_relative "repository" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" module Dev module Deps @@ -16,7 +19,30 @@ module Deps class GitRepository < Repository extend T::Sig - class RefResolutionError < StandardError; end + class RefResolutionError < PackageNotFoundError; end + + # Report a git dependency's universe: the declared ref resolved to its + # full SHA, as a singleton. + # + # A git remote is not a version index — the filter's "commit" or "tag" + # locates the one ref the declaration pins, and ls-remote turns it into + # a SHA. SHAs are identifiers, not integrity digests, so the version + # carries no digest. + # + # @param id [PackageId] source is the git remote URL + # @param filter [Hash] locator: one of "commit" or "tag" + # @return [Package] a singleton universe + # @raise [RefResolutionError] if the ref cannot be resolved via ls-remote + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + repo_url = T.must(id.source) + sha = resolve_ref(repo_url, filter["commit"] || filter["tag"]) + + Package.new( + id: id, + versions: [PackageVersion.new(version: sha, metadata: { "repo" => repo_url })], + ) + end # Resolve a git dependency identifier to a pinned Dependency. # diff --git a/lib/dev/deps/steam_repository.rb b/lib/dev/deps/steam_repository.rb index 1b6df53..b2c2327 100644 --- a/lib/dev/deps/steam_repository.rb +++ b/lib/dev/deps/steam_repository.rb @@ -2,8 +2,11 @@ # frozen_string_literal: true require "sorbet-runtime" -require_relative "repository" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" require_relative "steam_cmd" module Dev @@ -25,6 +28,41 @@ module Deps class SteamRepository < Repository extend T::Sig + # Report a Steam app's universe: one buildid, as a singleton. + # + # Steam exposes no enumerable build history — the filter locates the + # build: an explicit "buildid" pin, or the current buildid of "branch" + # (default public) via SteamCMD. No digest: Steam publishes no stable + # per-build hash; integrity is SteamCMD's app_update … validate at + # install. + # + # @param id [PackageId] name is the declaration name + # @param filter [Hash] locator: "app", "install_dir", optionally + # "branch", "buildid", "platforms" + # @return [Package] a singleton universe + # @raise [SteamCmd::SteamCmdError] if resolving the buildid fails + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + app = filter["app"] + branch = filter["branch"] || "public" + build_id = filter["buildid"] || resolve_build_id(app:, branch:) + + Package.new( + id: id, + versions: [ + PackageVersion.new( + version: build_id.to_s, + metadata: { + "app" => app.to_s, + "branch" => branch, + "install_dir" => filter["install_dir"], + "platform" => steam_platform_for(filter["platforms"]), + }, + ), + ], + ) + end + # Resolve a Steam app dependency to a pinned Dependency. # # @param id [Hash] must include "name", "app", "install_dir", "integration", diff --git a/lib/dev/deps/url_repository.rb b/lib/dev/deps/url_repository.rb index 95e6b3e..b175a43 100644 --- a/lib/dev/deps/url_repository.rb +++ b/lib/dev/deps/url_repository.rb @@ -5,8 +5,12 @@ require "open3" require "sorbet-runtime" require "tempfile" -require_relative "repository" +require_relative "artifact" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" module Dev module Deps @@ -19,6 +23,37 @@ class UrlRepository < Repository class DownloadError < StandardError; end + # Report a URL dependency's universe: the one artifact behind the URL, + # as a singleton. + # + # Dev-enforced integrity, trust-on-first-use: the artifact is downloaded + # and hashed at resolve time, and that SHA256 rides as the version's + # digest. The version is the filter's "tag"; URLs with no tag report an + # empty version the Resolver mints back to nil. + # + # @param id [PackageId] source is the download URL + # @param filter [Hash] locator: optionally "tag" for version + # @return [Package] a singleton universe + # @raise [DownloadError] if the download fails + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + url = T.must(id.source) + path = download_to_tempfile(url, id.name) + digest = "SHA256=#{Digest::SHA256.file(path).hexdigest}" + + Package.new( + id: id, + versions: [ + PackageVersion.new( + version: filter["tag"].to_s, + digest: digest, + artifacts: { "default" => Artifact.new(uri: url, digest: digest) }, + metadata: { "url" => url, "downloaded_path" => path }, + ), + ], + ) + end + # Download a URL dependency and compute its SHA256 integrity hash. # # @param id [Hash] must include "name", "url", "integration", "group"; diff --git a/lib/dev/deps/xcode_repository.rb b/lib/dev/deps/xcode_repository.rb index 568a08c..a7b703e 100644 --- a/lib/dev/deps/xcode_repository.rb +++ b/lib/dev/deps/xcode_repository.rb @@ -2,8 +2,11 @@ # frozen_string_literal: true require "sorbet-runtime" -require_relative "repository" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" module Dev module Deps @@ -19,6 +22,23 @@ class XcodeRepository < Repository class MissingVersionError < StandardError; end + # Report the Xcode universe: the declared version, as a singleton. + # + # Apple publishes no queryable version registry, so resolution is the + # identity — the filter's "version" IS the universe. + # + # @param id [PackageId] name is the declaration name + # @param filter [Hash] locator: "version" (exact, required) + # @return [Package] a singleton universe + # @raise [MissingVersionError] when no exact version was declared + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + version = filter["version"].to_s + raise MissingVersionError, "xcode requires an exact version (e.g. xcode \"26.1.1\")" if version.empty? + + Package.new(id: id, versions: [PackageVersion.new(version: version)]) + end + # @param id [Hash] must include "name", "integration", "group", "version" # @return [Dependency] # @raise [MissingVersionError] when no exact version was declared diff --git a/test/dev/deps/git_repository_test.rb b/test/dev/deps/git_repository_test.rb index 0c2f50e..c6d6097 100644 --- a/test/dev/deps/git_repository_test.rb +++ b/test/dev/deps/git_repository_test.rb @@ -8,6 +8,60 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::GitRepositoryTest < Minitest::Test + test "find reports the declared tag's SHA as a singleton universe" do + Given "a remote resolving the tag via ls-remote" + 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 "finding with the tag as locator" + package = repo.find( + Dev::Deps::PackageId.new( + integration: :cmake, name: "googletest", source: "https://github.com/google/googletest", + ), + filter: { "tag" => "v1.17.0" }, + ) + + Then "one version: the SHA, no digest (SHAs are identifiers, not integrity)" + package.versions.map(&:version) == [resolved_sha] + package.version(resolved_sha).digest.nil? + package.version(resolved_sha).metadata == { "repo" => "https://github.com/google/googletest" } + end + + test "find passes a 40-char commit SHA through without network calls" do + Given "a commit-pinned declaration" + repo = Dev::Deps::GitRepository.new + sha = "ee3042f8b0279856061f91069a487e4ed6f69475" + + When "finding with the commit as locator" + package = repo.find( + Dev::Deps::PackageId.new( + integration: :cmake, name: "entityx", source: "https://github.com/alecthomas/entityx", + ), + filter: { "commit" => sha }, + ) + + Then + package.versions.map(&:version) == [sha] + end + + test "find raises RefResolutionError, a PackageNotFoundError, for a bad ref" do + Given "a remote that knows no such ref" + repo = Dev::Deps::GitRepository.new + Open3.stubs(:capture3).returns(["", "", stub(success?: true)]) + + When "finding with an unresolvable tag" + repo.find( + Dev::Deps::PackageId.new(integration: :cmake, name: "ghost", source: "https://example.com/ghost"), + filter: { "tag" => "v0.0.0" }, + ) + + Then + raises Dev::Deps::Repository::PackageNotFoundError + end + test "fetch passes through a 40-char hex commit SHA as-is" do Given "a commit SHA identifier" repo = Dev::Deps::GitRepository.new diff --git a/test/dev/deps/steam_repository_test.rb b/test/dev/deps/steam_repository_test.rb index 7e2c689..94a1b20 100644 --- a/test/dev/deps/steam_repository_test.rb +++ b/test/dev/deps/steam_repository_test.rb @@ -6,6 +6,49 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::SteamRepositoryTest < Minitest::Test + test "find reports the pinned buildid as a singleton universe" 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 "finding with the pin as locator" + package = repo.find( + Dev::Deps::PackageId.new(integration: :steam, name: "SatisfactoryServer"), + filter: { + "app" => 1690800, + "install_dir" => "~/.dev/satisfactory-server", + "buildid" => "15321746", + "platforms" => ["LinuxServer"], + }, + ) + + Then "one version — the buildid — carrying the install facts, no digest" + package.versions.map(&:version) == ["15321746"] + version = package.version("15321746") + version.digest.nil? + version.metadata == { + "app" => "1690800", + "branch" => "public", + "install_dir" => "~/.dev/satisfactory-server", + "platform" => "linux", + } + end + + test "find resolves the current branch buildid via steamcmd when not pinned" do + Given "no pinned buildid and a stubbed steamcmd resolution" + repo = Dev::Deps::SteamRepository.new + Dev::Deps::SteamCmd.stubs(:resolve_build_id).with(app: 1690800, branch: "public").returns("99999") + + When "finding" + package = repo.find( + Dev::Deps::PackageId.new(integration: :steam, name: "SatisfactoryServer"), + filter: { "app" => 1690800, "install_dir" => "/tmp/server" }, + ) + + Then + package.versions.map(&:version) == ["99999"] + end + test "fetch uses an explicitly pinned buildid without invoking steamcmd" do Given "a declaration with a pinned buildid" repo = Dev::Deps::SteamRepository.new diff --git a/test/dev/deps/url_repository_test.rb b/test/dev/deps/url_repository_test.rb index 624f58e..098eb69 100644 --- a/test/dev/deps/url_repository_test.rb +++ b/test/dev/deps/url_repository_test.rb @@ -9,6 +9,35 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::UrlRepositoryTest < Minitest::Test + test "find downloads the URL and reports it as a digested 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 "finding with the tag as locator" + package = repo.find( + Dev::Deps::PackageId.new( + integration: :cmake, name: "boost", source: "https://example.com/boost-1.90.0.tar.gz", + ), + filter: { "tag" => "1.90.0" }, + ) + + Then "dev-enforced integrity: the downloaded bytes' SHA256 is the digest" + package.versions.map(&:version) == ["1.90.0"] + version = package.version("1.90.0") + 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.metadata["downloaded_path"] == fake_tarball + + Cleanup + FileUtils.rm_rf(dir) + end + test "fetch downloads URL and computes SHA256" do Given "a URL identifier with a stubbed download" dir = Dir.mktmpdir("dev-url-repo-test-") diff --git a/test/dev/deps/xcode_repository_test.rb b/test/dev/deps/xcode_repository_test.rb index b005f68..afa016c 100644 --- a/test/dev/deps/xcode_repository_test.rb +++ b/test/dev/deps/xcode_repository_test.rb @@ -6,6 +6,33 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::XcodeRepositoryTest < Minitest::Test + test "find reports the declared version as a singleton universe" do + Given "an xcode declaration" + repo = Dev::Deps::XcodeRepository.new + + When "finding with the exact version as locator" + package = repo.find( + Dev::Deps::PackageId.new(integration: :xcode, name: "xcode"), + filter: { "version" => "26.1.1" }, + ) + + Then "resolution is the identity — no registry exists to consult" + package.versions.map(&:version) == ["26.1.1"] + package.version("26.1.1").digest.nil? + package.version("26.1.1").metadata == {} + end + + test "find without an exact version raises MissingVersionError" do + Given "a declaration missing the version pin" + repo = Dev::Deps::XcodeRepository.new + + When "finding" + repo.find(Dev::Deps::PackageId.new(integration: :xcode, name: "xcode")) + + Then + raises Dev::Deps::XcodeRepository::MissingVersionError + end + test "fetch resolves the declared exact version as the locked version" do Given "an xcode declaration id" repo = Dev::Deps::XcodeRepository.new From fbf88c6dfc9e37b32556205de559e5523ae3cde0 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 18:09:53 -0400 Subject: [PATCH 11/37] Extract BundlerLocker; BundlerRepository#find over the lockfile pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-set solve that hid in BundlerRepository#prepare gets its own home: Locker is the batch seam (lock(declarations) -> tool lockfile), and BundlerLocker owns Gemfile generation plus bundle lock. BundlerRepository becomes what it always really was — a reader over Gemfile.lock: find reports each gem's singleton universe (the joint solve's choice, with the CHECKSUMS digest). MissingGemError is now a PackageNotFoundError. prepare/fetch remain as thin deprecated delegations until the cutover. Co-authored-by: Cursor --- lib/dev/deps/bundler_locker.rb | 123 +++++++++++++++++++++ lib/dev/deps/bundler_repository.rb | 130 ++++++++--------------- lib/dev/deps/locker.rb | 34 ++++++ test/dev/deps/bundler_locker_test.rb | 74 +++++++++++++ test/dev/deps/bundler_repository_test.rb | 33 ++++++ 5 files changed, 309 insertions(+), 85 deletions(-) create mode 100644 lib/dev/deps/bundler_locker.rb create mode 100644 lib/dev/deps/locker.rb create mode 100644 test/dev/deps/bundler_locker_test.rb diff --git a/lib/dev/deps/bundler_locker.rb b/lib/dev/deps/bundler_locker.rb new file mode 100644 index 0000000..c07b4eb --- /dev/null +++ b/lib/dev/deps/bundler_locker.rb @@ -0,0 +1,123 @@ +# typed: strict +# frozen_string_literal: true + +require "open3" +require "pathname" +require "sorbet-runtime" +require_relative "dependency_declaration" +require_relative "locker" + +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[DependencyDeclaration]).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[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 + + # @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 7a54a85..4113aa1 100644 --- a/lib/dev/deps/bundler_repository.rb +++ b/lib/dev/deps/bundler_repository.rb @@ -1,39 +1,35 @@ # typed: strict # frozen_string_literal: true -require "open3" require "pathname" require "sorbet-runtime" -require_relative "repository" +require_relative "bundler_locker" require_relative "dependency" +require_relative "package" +require_relative "package_id" +require_relative "package_version" +require_relative "repository" 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 + LockError = BundlerLocker::LockError - GEMFILE = "Gemfile" - LOCKFILE = "Gemfile.lock" - RUBYGEMS_SOURCE = "https://rubygems.org" + # The gem is absent from Gemfile.lock — the lock step didn't cover it. + class MissingGemError < PackageNotFoundError; end - 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 + LOCKFILE = "Gemfile.lock" # @param project_root [Pathname, String] root the Gemfile/Gemfile.lock live in # @param ruby_version_requirement [String, nil] requirement for the Gemfile's @@ -50,22 +46,48 @@ def initialize(project_root:, ruby_version_requirement: nil) @pins = T.let(nil, T.nilable(T::Hash[String, T::Hash[Symbol, T.nilable(String)]])) end + # Report a gem's locked pin from Gemfile.lock as a singleton universe. + # + # @param id [PackageId] name is the gem name + # @param filter [Hash] unused; the lockfile needs no locator + # @return [Package] a singleton universe + # @raise [MissingGemError] if the gem is absent from the parsed Gemfile.lock + sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } + def find(id, filter: {}) + pin = pins.fetch(id.name) do + raise MissingGemError, + "gem #{id.name.inspect} is not in #{LOCKFILE} — run `dev update-deps`" + end + + Package.new( + id: id, + versions: [PackageVersion.new(version: T.must(pin[:version]), digest: pin[:hash])], + ) + end + # Batch hook: generate the Gemfile from all gem declarations, lock it, and # parse the resulting pins. Runs once before any #fetch. # + # DEPRECATED: the lock step belongs to BundlerLocker, which the pipeline + # invokes before resolution; this delegation dies with the cutover. + # # @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 + BundlerLocker.new( + project_root: @project_root, + ruby_version_requirement: @ruby_version_requirement, + ).lock(declarations) @pins = parse_lockfile end # Return the locked Dependency for a declared gem. # + # DEPRECATED: replaced by #find; dies with the cutover. + # # @param id [Hash] must include "name", "integration", "group" # @return [Dependency] # @raise [MissingGemError] if the gem is absent from the parsed Gemfile.lock @@ -89,8 +111,7 @@ def fetch(id) 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)]]) } @@ -98,61 +119,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 @@ -215,12 +181,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/locker.rb b/lib/dev/deps/locker.rb new file mode 100644 index 0000000..9a90129 --- /dev/null +++ b/lib/dev/deps/locker.rb @@ -0,0 +1,34 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +require_relative "dependency_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[DependencyDeclaration]).void } + def lock(declarations) + raise NotImplementedError, "#{self.class}#lock must be implemented" + end + end + 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..353902a 100644 --- a/test/dev/deps/bundler_repository_test.rb +++ b/test/dev/deps/bundler_repository_test.rb @@ -64,6 +64,39 @@ def bundler_declarations(&block) FileUtils.rm_rf(dir) end + 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) + + When "finding a declared gem" + package = repo.find(Dev::Deps::PackageId.new(integration: :bundler, name: "ffi")) + + 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" + + Cleanup + FileUtils.rm_rf(dir) + end + + 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) + + When "finding an unlocked gem" + repo.find(Dev::Deps::PackageId.new(integration: :bundler, name: "absent")) + + Then + raises Dev::Deps::Repository::PackageNotFoundError + + Cleanup + FileUtils.rm_rf(dir) + end + test "fetch returns the locked version and checksum for a declared gem" do Given "a prepared repository" dir = Dir.mktmpdir("dev-bundler-repo-test-") From 9e4d4c425b32ea59b08bf743649c8b8c4dfdb73f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 2 Sep 2026 19:02:37 -0400 Subject: [PATCH 12/37] Root scheme parse errors in a shared VersionScheme hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Resolver needs one rescue point: a universe can legitimately contain versions that ignore the ecosystem's conventions (old tags, oddball uploads), and those candidates should be skipped as non-satisfying, not fail the resolve — while a malformed constraint is the user's declaration being wrong and must propagate. Each scheme's InvalidVersionError and InvalidConstraintError now subclass the VersionScheme base pair. Co-authored-by: Cursor --- lib/dev/deps/gem_scheme.rb | 4 ++-- lib/dev/deps/pep440_scheme.rb | 4 ++-- lib/dev/deps/rock_scheme.rb | 4 ++-- lib/dev/deps/semver_scheme.rb | 4 ++-- lib/dev/deps/version_scheme.rb | 10 ++++++++++ 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/dev/deps/gem_scheme.rb b/lib/dev/deps/gem_scheme.rb index c53301e..8abf912 100644 --- a/lib/dev/deps/gem_scheme.rb +++ b/lib/dev/deps/gem_scheme.rb @@ -16,9 +16,9 @@ class GemScheme < VersionScheme extend T::Sig # The requirement string is not valid Gem::Requirement syntax. - class InvalidConstraintError < StandardError; end + class InvalidConstraintError < VersionScheme::InvalidConstraintError; end # The version string is not a valid Gem::Version. - class InvalidVersionError < StandardError; end + class InvalidVersionError < VersionScheme::InvalidVersionError; end # The constraint key carrying the version requirement (the gem DSL's # positional requirement lands under "version"). diff --git a/lib/dev/deps/pep440_scheme.rb b/lib/dev/deps/pep440_scheme.rb index ef0fc32..e5a6542 100644 --- a/lib/dev/deps/pep440_scheme.rb +++ b/lib/dev/deps/pep440_scheme.rb @@ -21,9 +21,9 @@ class Pep440Scheme < VersionScheme extend T::Sig # The specifier is not parseable PEP 440 specifier syntax. - class InvalidConstraintError < StandardError; end + class InvalidConstraintError < VersionScheme::InvalidConstraintError; end # The version string is not a PEP 440 version. - class InvalidVersionError < StandardError; end + class InvalidVersionError < VersionScheme::InvalidVersionError; end # The constraint key carrying the specifier (the pip DSL's version:). CONSTRAINT_KEY = "version" diff --git a/lib/dev/deps/rock_scheme.rb b/lib/dev/deps/rock_scheme.rb index 6005180..6d289db 100644 --- a/lib/dev/deps/rock_scheme.rb +++ b/lib/dev/deps/rock_scheme.rb @@ -21,9 +21,9 @@ class RockScheme < VersionScheme extend T::Sig # The constraint expression is not parseable rockspec constraint syntax. - class InvalidConstraintError < StandardError; end + class InvalidConstraintError < VersionScheme::InvalidConstraintError; end # The version string is not a luarocks version. - class InvalidVersionError < StandardError; end + class InvalidVersionError < VersionScheme::InvalidVersionError; end # The constraint key carrying the expression (the luarocks DSL's # positional constraint lands under "constraint"). diff --git a/lib/dev/deps/semver_scheme.rb b/lib/dev/deps/semver_scheme.rb index 869e43d..0111afc 100644 --- a/lib/dev/deps/semver_scheme.rb +++ b/lib/dev/deps/semver_scheme.rb @@ -19,9 +19,9 @@ class SemverScheme < VersionScheme extend T::Sig # The range expression is not parseable semver range syntax. - class InvalidConstraintError < StandardError; end + class InvalidConstraintError < VersionScheme::InvalidConstraintError; end # The version string is not a semver version. - class InvalidVersionError < StandardError; end + class InvalidVersionError < VersionScheme::InvalidVersionError; end # The constraint key carrying the range (the ficsit DSL's version:). CONSTRAINT_KEY = "version" diff --git a/lib/dev/deps/version_scheme.rb b/lib/dev/deps/version_scheme.rb index ea60054..33f1479 100644 --- a/lib/dev/deps/version_scheme.rb +++ b/lib/dev/deps/version_scheme.rb @@ -24,6 +24,16 @@ module Deps 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? # From 99b63736156e2efd63e1ec85bd9e88de4a66f4d8 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 3 Sep 2026 09:08:56 -0400 Subject: [PATCH 13/37] Cut the Resolver over to find + VersionScheme; delete fetch/prepare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Resolver is now the choice layer over pure facts: for each declaration it asks the repository for the package universe (find, with the constraint riding along as a server-side locator), filters candidates through the integration's VersionScheme — treating scheme-unparseable universe versions as non-satisfying rather than fatal — takes the highest satisfying version that publishes every explicitly requested platform, mints the pin from that version's facts, and walks its edges for transitives (which inherit the declaring dep's group, host, and env). Disagreeing constraints on one name are rejected up front. With no callers left, the per-item fetch contract and the prepare lifecycle hook are deleted from Repository and every implementation, along with their now-dead private helpers and error classes; bundler's lock step lives only in BundlerLocker, and BundlerRepository is a pure Gemfile.lock reader. Repository tests covering behavior unique to fetch (gh auth/API errors, brew tap retry, ficsit link fallback) are ported to find; the rest are deleted as duplicates of existing find coverage. Also fixes typed-strict debt srb tc surfaced in the new domain types (untyped-receiver equality returning nilable booleans, redundant T.must on Array#<=> results). Co-authored-by: Cursor --- lib/dev/deps/artifact.rb | 6 +- lib/dev/deps/brew_repository.rb | 54 --- lib/dev/deps/bundler_repository.rb | 62 +-- lib/dev/deps/dependency_edge.rb | 4 +- lib/dev/deps/ficsit_repository.rb | 96 ----- lib/dev/deps/gem_skill_linker.rb | 4 +- lib/dev/deps/gh_repository.rb | 79 ---- lib/dev/deps/git_repository.rb | 26 -- lib/dev/deps/luarocks_repository.rb | 74 +--- lib/dev/deps/package_id.rb | 7 +- lib/dev/deps/package_version.rb | 12 +- lib/dev/deps/pep440_scheme.rb | 7 +- lib/dev/deps/pip_repository.rb | 83 +--- lib/dev/deps/repository.rb | 26 -- lib/dev/deps/resolver.rb | 266 ++++++++++--- lib/dev/deps/rock_scheme.rb | 5 +- lib/dev/deps/semver_scheme.rb | 15 +- lib/dev/deps/steam_repository.rb | 29 -- lib/dev/deps/url_repository.rb | 27 -- lib/dev/deps/xcode_repository.rb | 19 - test/dev/deps/brew_repository_test.rb | 133 +------ test/dev/deps/bundler_repository_test.rb | 95 ----- test/dev/deps/cmake_integration_test.rb | 25 +- test/dev/deps/ficsit_repository_test.rb | 362 +----------------- test/dev/deps/gh_repository_test.rb | 144 ++----- test/dev/deps/git_repository_test.rb | 68 ---- test/dev/deps/luarocks_registry_test.rb | 63 --- test/dev/deps/pip_repository_test.rb | 31 -- test/dev/deps/repository_test.rb | 11 - test/dev/deps/resolver_test.rb | 468 +++++++++++------------ test/dev/deps/steam_repository_test.rb | 63 +-- test/dev/deps/url_repository_test.rb | 43 +-- test/dev/deps/xcode_repository_test.rb | 30 -- 33 files changed, 555 insertions(+), 1882 deletions(-) diff --git a/lib/dev/deps/artifact.rb b/lib/dev/deps/artifact.rb index df2cc10..46cd370 100644 --- a/lib/dev/deps/artifact.rb +++ b/lib/dev/deps/artifact.rb @@ -45,7 +45,7 @@ class MissingUriError < StandardError; end def initialize(uri:, digest: nil) raise MissingUriError, "an artifact without a uri cannot be fetched" if uri.nil? || uri.empty? - @uri = uri + @uri = T.let(uri, String) @digest = digest freeze end @@ -54,7 +54,9 @@ def initialize(uri:, digest: nil) # @return [Boolean] whether other describes the same bytes sig { params(other: T.untyped).returns(T::Boolean) } def ==(other) - other.is_a?(Artifact) && uri == other.uri && digest == other.digest + return false unless other.is_a?(Artifact) + + [uri, digest] == [other.uri, other.digest] end alias_method :eql?, :== diff --git a/lib/dev/deps/brew_repository.rb b/lib/dev/deps/brew_repository.rb index c28818e..7438570 100644 --- a/lib/dev/deps/brew_repository.rb +++ b/lib/dev/deps/brew_repository.rb @@ -4,7 +4,6 @@ require "json" require "open3" require "sorbet-runtime" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -70,59 +69,6 @@ def find(id, filter: {}) ) end - # Resolve a brew dependency identifier to a pinned Dependency. - # - # For casks, returns a Dependency with nil version/hash. - # For formulae, queries `brew info --json=v1` for the stable version - # and bottle SHA256. - # - # @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"] - bottle_hash = extract_bottle_hash(info) - - # 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, - ) - end - private # Build a brew formula spec: [tap/]name[@version_suffix]. Querying the diff --git a/lib/dev/deps/bundler_repository.rb b/lib/dev/deps/bundler_repository.rb index 4113aa1..ceb0706 100644 --- a/lib/dev/deps/bundler_repository.rb +++ b/lib/dev/deps/bundler_repository.rb @@ -3,8 +3,6 @@ require "pathname" require "sorbet-runtime" -require_relative "bundler_locker" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -24,25 +22,15 @@ module Deps class BundlerRepository < Repository extend T::Sig - LockError = BundlerLocker::LockError - # The gem is absent from Gemfile.lock — the lock step didn't cover it. class MissingGemError < PackageNotFoundError; end LOCKFILE = "Gemfile.lock" - # @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 @@ -65,50 +53,6 @@ def find(id, filter: {}) ) end - # Batch hook: generate the Gemfile from all gem declarations, lock it, and - # parse the resulting pins. Runs once before any #fetch. - # - # DEPRECATED: the lock step belongs to BundlerLocker, which the pipeline - # invokes before resolution; this delegation dies with the cutover. - # - # @param declarations [Array] :bundler declarations - # @return [void] - sig { params(declarations: T::Array[DependencyDeclaration]).void } - def prepare(declarations) - return if declarations.empty? - - BundlerLocker.new( - project_root: @project_root, - ruby_version_requirement: @ruby_version_requirement, - ).lock(declarations) - @pins = parse_lockfile - end - - # Return the locked Dependency for a declared gem. - # - # DEPRECATED: replaced by #find; dies with the cutover. - # - # @param id [Hash] must include "name", "integration", "group" - # @return [Dependency] - # @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 - raise MissingGemError, - "gem #{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: {}, - ) - end - private # Lazily ensure the lockfile has been parsed. diff --git a/lib/dev/deps/dependency_edge.rb b/lib/dev/deps/dependency_edge.rb index 6189df8..ac8c2fb 100644 --- a/lib/dev/deps/dependency_edge.rb +++ b/lib/dev/deps/dependency_edge.rb @@ -44,7 +44,9 @@ def initialize(name:, constraint:) # @return [Boolean] whether other is the same edge sig { params(other: T.untyped).returns(T::Boolean) } def ==(other) - other.is_a?(DependencyEdge) && name == other.name && constraint == other.constraint + return false unless other.is_a?(DependencyEdge) + + [name, constraint] == [other.name, other.constraint] end alias_method :eql?, :== diff --git a/lib/dev/deps/ficsit_repository.rb b/lib/dev/deps/ficsit_repository.rb index 1ca011a..7103fec 100644 --- a/lib/dev/deps/ficsit_repository.rb +++ b/lib/dev/deps/ficsit_repository.rb @@ -6,7 +6,6 @@ require "sorbet-runtime" require "uri" require_relative "artifact" -require_relative "dependency" require_relative "dependency_edge" require_relative "package" require_relative "package_id" @@ -24,8 +23,6 @@ class FicsitRepository < Repository class ApiError < StandardError; end class ModNotFoundError < PackageNotFoundError; end - class NoVersionError < StandardError; end - class TargetNotFoundError < StandardError; end API_HOST = "https://api.ficsit.app" GRAPHQL_ENDPOINT = T.let(URI("#{API_HOST}/v2/query"), URI::Generic) @@ -81,63 +78,6 @@ def find(id, filter: {}) Package.new(id: id, versions: versions) end - # Resolve a ficsit.app mod dependency to a pinned Dependency. - # - # 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. - # - # @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] - # @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 - 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, - ) - end - private # Map one GraphQL version object to a PackageVersion. @@ -220,42 +160,6 @@ def platform_block(version_data, targets, requested) end end - # 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. - # - # @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 - sig do - params( - mod_reference: String, - version_data: T::Hash[String, T.untyped], - requested: T::Array[T.nilable(String)], - ).returns(T::Hash[String, T::Hash[String, String]]) - end - def resolve_platforms(mod_reference, version_data, requested) - 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 - end - # Build the absolute download URL for a target. ficsit returns a relative # "link" (e.g. "/v1/version///download"); fall back to the same # REST shape if the field is ever absent. diff --git a/lib/dev/deps/gem_skill_linker.rb b/lib/dev/deps/gem_skill_linker.rb index f6f0092..a189b01 100644 --- a/lib/dev/deps/gem_skill_linker.rb +++ b/lib/dev/deps/gem_skill_linker.rb @@ -5,7 +5,7 @@ require "pathname" require "sorbet-runtime" require_relative "../skill_installer" -require_relative "bundler_repository" +require_relative "bundler_locker" module Dev module Deps @@ -194,7 +194,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_repository.rb b/lib/dev/deps/gh_repository.rb index c72ef27..50427c6 100644 --- a/lib/dev/deps/gh_repository.rb +++ b/lib/dev/deps/gh_repository.rb @@ -4,7 +4,6 @@ require "json" require "open3" require "sorbet-runtime" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -63,24 +62,6 @@ def find(id, filter: {}) Package.new(id: id, versions: [version]) end - # Resolve a GitHub dependency to a pinned Dependency. - # - # Two shapes, distinguished by the declaration: "assets" => prebuilt release - # assets (download + verify); "build" => build from the tag's source archive. - # - # @param id [Hash] must include "name", "repo" (owner/repo slug), "tag", - # "install_dir", "integration", "group", and one of "assets"/"build" - # @return [Dependency] - # @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) - end - private # The prebuilt shape: the tag's release, its glob-matched assets and @@ -144,66 +125,6 @@ def source_version(repo_slug, tag, filter) ) end - # Resolve a prebuilt-release dependency (download + verify path). - # - # @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) }, - }, - ) - end - - # 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, - version: tag, - hash: nil, - metadata: { - "repo" => repo_slug, - "install_dir" => id["install_dir"], - "build" => id["build"], - "commit" => commit, - }, - ) - end - # Resolve a tag to its commit SHA, mapping gh failures to actionable errors. # # @param repo_slug [String] "owner/repo" diff --git a/lib/dev/deps/git_repository.rb b/lib/dev/deps/git_repository.rb index 2f30add..e4f8e4f 100644 --- a/lib/dev/deps/git_repository.rb +++ b/lib/dev/deps/git_repository.rb @@ -3,7 +3,6 @@ require "open3" require "sorbet-runtime" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -44,31 +43,6 @@ def find(id, filter: {}) ) end - # Resolve a git dependency identifier to a pinned Dependency. - # - # @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 - - sha = resolve_ref(repo_url, ref) - - Dependency.new( - name: id["name"], - integration: id["integration"].to_sym, - group: id["group"].to_sym, - version: sha, - hash: nil, - metadata: { "repo" => repo_url }, - ) - end - private # Resolve a git ref (tag, branch, or commit SHA) to a full 40-char SHA. diff --git a/lib/dev/deps/luarocks_repository.rb b/lib/dev/deps/luarocks_repository.rb index e57216c..966f1c3 100644 --- a/lib/dev/deps/luarocks_repository.rb +++ b/lib/dev/deps/luarocks_repository.rb @@ -1,11 +1,8 @@ # typed: strict # frozen_string_literal: true -require "digest" require "open3" require "sorbet-runtime" -require "tempfile" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -13,17 +10,12 @@ 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 # Report a rock's available versions from `luarocks search`. @@ -51,31 +43,6 @@ def find(id, filter: {}) ) end - # Resolve a LuaRocks package to an exact version + integrity hash. - # - # @param id [Hash] identifier with "name", "integration", "group", "constraint" - # @return [Dependency] - # @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}" - - Dependency.new( - name: name, - integration: id["integration"].to_sym, - group: id["group"].to_sym, - version: version, - hash: hash, - metadata: { "downloaded_path" => rock_path }, - ) - end - private # All versions the manifest lists for a rock, most recent first, @@ -93,43 +60,6 @@ def search_versions(name) matches = T.cast(out.scan(/^\s+(\S+)\s+\(/), T::Array[T::Array[String]]) matches.map { |match| T.must(match.first) }.uniq end - - # Find the best available version for a package. - # - # @param name [String] rock name - # @param _constraint [String, nil] version constraint (not yet used) - # @return [String] best matching version - # @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) - 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) - end end end end diff --git a/lib/dev/deps/package_id.rb b/lib/dev/deps/package_id.rb index a0d0ca2..45c1209 100644 --- a/lib/dev/deps/package_id.rb +++ b/lib/dev/deps/package_id.rb @@ -52,10 +52,9 @@ def initialize(integration:, name:, source: nil) # @return [Boolean] whether other identifies the same package sig { params(other: T.untyped).returns(T::Boolean) } def ==(other) - other.is_a?(PackageId) && - integration == other.integration && - name == other.name && - source == other.source + return false unless other.is_a?(PackageId) + + [integration, name, source] == [other.integration, other.name, other.source] end alias_method :eql?, :== diff --git a/lib/dev/deps/package_version.rb b/lib/dev/deps/package_version.rb index c44462c..56d6121 100644 --- a/lib/dev/deps/package_version.rb +++ b/lib/dev/deps/package_version.rb @@ -86,13 +86,11 @@ def initialize(version:, platforms: [], digest: nil, artifacts: {}, dependencies # @return [Boolean] whether other reports the same facts sig { params(other: T.untyped).returns(T::Boolean) } def ==(other) - other.is_a?(PackageVersion) && - version == other.version && - platforms == other.platforms && - digest == other.digest && - artifacts == other.artifacts && - dependencies == other.dependencies && - metadata == other.metadata + return false unless other.is_a?(PackageVersion) + + [version, platforms, digest, artifacts, dependencies, metadata] == + [other.version, other.platforms, other.digest, other.artifacts, + other.dependencies, other.metadata] end alias_method :eql?, :== diff --git a/lib/dev/deps/pep440_scheme.rb b/lib/dev/deps/pep440_scheme.rb index e5a6542..88a67e7 100644 --- a/lib/dev/deps/pep440_scheme.rb +++ b/lib/dev/deps/pep440_scheme.rb @@ -86,7 +86,7 @@ def term_satisfied?(version, term) return wildcard_match?(version, bound.delete_suffix(".*")) == (operator == "==") if bound.end_with?(".*") return compatible_release?(version, bound) if operator == "~=" - comparison = T.must(comparison_key(version) <=> comparison_key(bound)) + comparison = T.let(comparison_key(version) <=> comparison_key(bound), Integer) case operator when "==" then comparison.zero? when "!=" then !comparison.zero? @@ -123,7 +123,8 @@ def compatible_release?(version, bound) raise InvalidConstraintError, "~= needs at least two release segments: #{bound.inspect}" if segments.size < 2 prefix = segments[0..-2].to_a.join(".") - (comparison_key(version) <=> comparison_key(bound)) >= 0 && wildcard_match?(version, prefix) + comparison = T.let(comparison_key(version) <=> comparison_key(bound), Integer) + comparison >= 0 && wildcard_match?(version, prefix) end # @param version [String] @@ -155,7 +156,7 @@ def comparison_key(version) phase, phase_number, post, dev = match[3], match[4], match[5], match[6] pre_key = if phase - [PHASE_RANKS.fetch(T.must(phase).downcase), phase_number.to_i] + [PHASE_RANKS.fetch(phase.downcase), phase_number.to_i] elsif post.nil? && dev [-1] # dev-only releases sort below every prerelease else diff --git a/lib/dev/deps/pip_repository.rb b/lib/dev/deps/pip_repository.rb index 541a555..2f35474 100644 --- a/lib/dev/deps/pip_repository.rb +++ b/lib/dev/deps/pip_repository.rb @@ -1,14 +1,10 @@ # typed: strict # frozen_string_literal: true -require "digest" require "json" require "net/http" -require "open3" require "sorbet-runtime" -require "tmpdir" require "uri" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -16,23 +12,18 @@ 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" # Report a project's version universe from PyPI's JSON API. @@ -59,31 +50,6 @@ def find(id, filter: {}) Package.new(id: id, versions: versions) end - # Resolve a pip package to an exact version + integrity hash. - # - # @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? - - Dependency.new( - name: name, - integration: id["integration"].to_sym, - group: id["group"].to_sym, - version: version, - hash: "SHA256=#{Digest::SHA256.file(artifact).hexdigest}", - metadata: {}, - ) - end - private # GET and parse https://pypi.org/pypi//json. @@ -121,51 +87,6 @@ def release_digest(files) sha256 = file&.dig("digests", "sha256") sha256 ? "SHA256=#{sha256}" : nil end - - # A bare version ("2.0.5") becomes an exact pin ("==2.0.5"); an already- - # operatored constraint (">=2.0") passes through; blank means unpinned. - # - # @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? - - value.match?(/\A[<>=~!]/) ? value : "==#{value}" - 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. - # - # @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 - 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" - # - # @param filename [String] - # @param _name [String] declared package name (kept for signature clarity) - # @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/) } - end end end end diff --git a/lib/dev/deps/repository.rb b/lib/dev/deps/repository.rb index f8340cb..1dab6d4 100644 --- a/lib/dev/deps/repository.rb +++ b/lib/dev/deps/repository.rb @@ -2,8 +2,6 @@ # frozen_string_literal: true require "sorbet-runtime" -require_relative "dependency" -require_relative "dependency_declaration" require_relative "package" require_relative "package_id" @@ -44,30 +42,6 @@ class PackageNotFoundError < StandardError; end def find(id, filter: {}) raise NotImplementedError, "#{self.class}#find must be implemented" end - - # Fetch a dependency by its unique identifier. - # - # DEPRECATED: the per-item pin contract, replaced by #find. It conflates - # identity, constraint, and choice in one untyped hash; it is deleted - # together with the Resolver cutover to find/VersionScheme. - # - # @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" - end - - # Batch hook called once per integration type before any fetch. - # - # DEPRECATED: a lifecycle hook smuggling a whole-set solve through a - # per-item contract; its bundler use moves to BundlerLocker and the hook - # is deleted together with the Resolver cutover. - # - # @param declarations [Array] this type's declarations - # @return [void] - sig { params(declarations: T::Array[DependencyDeclaration]).void } - def prepare(declarations); end end end end diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb index 0975bed..de4f679 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -2,79 +2,85 @@ # frozen_string_literal: true require "sorbet-runtime" -require_relative "repository" require_relative "dependency" require_relative "dependency_declaration" +require_relative "package" +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. - # # @param declarations [Array] declared dependencies to resolve # @return [Array] - # @raise [UnknownIntegrationError] if no repository is registered for a declaration's integration type + # @raise [ConflictingDeclarationError] if one name 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[DependencyDeclaration]).returns(T::Array[Dependency]) } def resolve(declarations) - prepare_repositories(declarations) + reject_conflicts(declarations) platforms_by_name = platforms_by_name(declarations) - resolved = {} + resolved = T.let({}, T::Hash[String, 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 + chosen = choose(decl, platforms_by_name[decl.name] || []) + resolved[decl.name] = mint(chosen, decl) # 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]) + chosen.dependencies.each do |edge| + next if resolved.key?(edge.name) + queue << DependencyDeclaration.new( - name: tdep[:name], + name: edge.name, integration: decl.integration, - constraint: normalize_constraint(tdep[:constraint]), + constraint: normalize_constraint(edge.constraint), group: decl.group, host: decl.host, env: decl.env, @@ -87,13 +93,149 @@ def resolve(declarations) private + # 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 [DependencyDeclaration] 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: DependencyDeclaration, + 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 = @schemes[decl.integration] + raise UnknownIntegrationError, "no version scheme registered for #{decl.integration.inspect}" unless scheme + + # The constraint doubles as the repository's locator; platforms ride + # along only when at least one group pinned one explicitly, so + # single-platform deps keep their default-platform install facts. + filter = decl.constraint.dup + filter["platforms"] = platforms if platforms.any? { |p| !p.nil? } + + package = repository.find(package_id(decl), filter: filter) + explicit = platforms.compact + candidates = package.versions.select do |version| + 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(scheme.sort(by_version.keys).last)) + 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.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, with + # the declaration contributing name/integration/group, 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, e.g. brew casks) becomes a nil pin version. + # + # @param chosen [PackageVersion] the version the resolver picked + # @param decl [DependencyDeclaration] the declaration it satisfies + # @return [Dependency] + sig { params(chosen: PackageVersion, decl: DependencyDeclaration).returns(Dependency) } + def mint(chosen, decl) + dependency = Dependency.new( + name: decl.name, + integration: decl.integration, + group: decl.group, + version: chosen.version.empty? ? nil : chosen.version, + hash: chosen.digest, + metadata: chosen.metadata.dup, + ) + dependency = dependency.with(post_install: decl.post_install) if decl.post_install + attach_install_scoping(dependency, decl) + end + + # The package's identity, from the declaration: for source-based deps + # the constraint's "repo"/"url" is the source coordinate (which service + # to ask), so it rides on the PackageId rather than the filter. + # + # @param decl [DependencyDeclaration] + # @return [PackageId] + sig { params(decl: DependencyDeclaration).returns(PackageId) } + def package_id(decl) + PackageId.new( + integration: decl.integration, + name: decl.name, + source: decl.constraint["repo"] || decl.constraint["url"], + ) + end + + # Reject sets where one name is declared with disagreeing constraints. + # A dep declared in several groups resolves once, so agreement is the + # precondition for that single resolution being right for everyone. + # (Platform, group, host, and env may differ — they are axes, not + # constraints.) + # + # @param declarations [Array] + # @return [void] + # @raise [ConflictingDeclarationError] + sig { params(declarations: T::Array[DependencyDeclaration]).void } + def reject_conflicts(declarations) + declarations.group_by(&:name).each do |name, decls| + constraints = decls.map(&:constraint).uniq + next if constraints.size <= 1 + + raise ConflictingDeclarationError, + "#{name} is declared with disagreeing constraints: #{constraints.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 dependency [Dependency] freshly minted # @param decl [DependencyDeclaration] the declaration it came from # @return [Dependency] sig { params(dependency: Dependency, decl: DependencyDeclaration).returns(Dependency) } @@ -106,19 +248,6 @@ def attach_install_scoping(dependency, decl) 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. - # - # @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 @@ -137,13 +266,11 @@ def platforms_by_name(declarations) result.transform_values(&:uniq) end - # Normalize a transitive dep constraint to a Hash. + # Normalize a transitive edge constraint to a declaration constraint + # hash. Edges may express constraints as a bare string (ficsit's + # "^3.12.0"), which maps to the ecosystem's "version" key. # - # 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. - # - # @param constraint [Hash, String, nil] raw constraint from Dependency#dependencies + # @param constraint [Hash, String, nil] raw constraint from a DependencyEdge # @return [Hash] sig do params( @@ -157,6 +284,27 @@ def normalize_constraint(constraint) else {} end end + + # A NoSatisfyingVersionError message that says why: what was asked, + # what the universe held. + # + # @param decl [DependencyDeclaration] + # @param package [Package] + # @param explicit [Array] explicitly requested platforms + # @return [String] + sig do + params( + decl: DependencyDeclaration, + 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 end diff --git a/lib/dev/deps/rock_scheme.rb b/lib/dev/deps/rock_scheme.rb index 6d289db..8904251 100644 --- a/lib/dev/deps/rock_scheme.rb +++ b/lib/dev/deps/rock_scheme.rb @@ -70,7 +70,7 @@ def term_satisfied?(key, term) bound = T.must(match[2]) return pessimistic_match?(key, bound) if operator == "~>" - comparison = T.must(key <=> comparison_key(bound)) + comparison = T.let(key <=> comparison_key(bound), Integer) case operator when "==", "=" then comparison.zero? when ">=" then comparison >= 0 @@ -92,7 +92,8 @@ def pessimistic_match?(key, bound) upper = segments.size > 1 ? segments[0..-2].to_a : segments.dup upper[-1] = upper.fetch(-1) + 1 - (key <=> comparison_key(bound)) >= 0 && T.must(key <=> [upper, 0]).negative? + 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]. diff --git a/lib/dev/deps/semver_scheme.rb b/lib/dev/deps/semver_scheme.rb index 0111afc..f63b659 100644 --- a/lib/dev/deps/semver_scheme.rb +++ b/lib/dev/deps/semver_scheme.rb @@ -88,14 +88,15 @@ def terms(expression) end def term_satisfied?(key, term) operator, triple, bound_key = term + comparison = T.let(key <=> bound_key, Integer) case operator - when "^" then (key <=> bound_key) >= 0 && (key <=> release_key(caret_upper(triple))) < 0 - when "~" then (key <=> bound_key) >= 0 && (key <=> release_key(tilde_upper(triple))) < 0 - when ">=" then (key <=> bound_key) >= 0 - when ">" then T.must(key <=> bound_key).positive? - when "<=" then (key <=> bound_key) <= 0 - when "<" then T.must(key <=> bound_key).negative? - else T.must(key <=> bound_key).zero? + 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 diff --git a/lib/dev/deps/steam_repository.rb b/lib/dev/deps/steam_repository.rb index b2c2327..364107d 100644 --- a/lib/dev/deps/steam_repository.rb +++ b/lib/dev/deps/steam_repository.rb @@ -2,7 +2,6 @@ # frozen_string_literal: true require "sorbet-runtime" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -63,34 +62,6 @@ def find(id, filter: {}) ) end - # Resolve a Steam app dependency to a pinned Dependency. - # - # @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:) - - 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"]), - }, - ) - end - private # Isolated so tests can stub the SteamCMD boundary. diff --git a/lib/dev/deps/url_repository.rb b/lib/dev/deps/url_repository.rb index b175a43..468546a 100644 --- a/lib/dev/deps/url_repository.rb +++ b/lib/dev/deps/url_repository.rb @@ -6,7 +6,6 @@ require "sorbet-runtime" require "tempfile" require_relative "artifact" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -54,32 +53,6 @@ def find(id, filter: {}) ) end - # Download a URL dependency and compute its SHA256 integrity hash. - # - # @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 - # @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"] - - 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 }, - ) - end - private # Download a URL to a temp file via curl. diff --git a/lib/dev/deps/xcode_repository.rb b/lib/dev/deps/xcode_repository.rb index a7b703e..c470e43 100644 --- a/lib/dev/deps/xcode_repository.rb +++ b/lib/dev/deps/xcode_repository.rb @@ -2,7 +2,6 @@ # frozen_string_literal: true require "sorbet-runtime" -require_relative "dependency" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -38,24 +37,6 @@ def find(id, filter: {}) Package.new(id: id, versions: [PackageVersion.new(version: version)]) 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? - - Dependency.new( - name: id["name"], - integration: id["integration"].to_sym, - group: id["group"].to_sym, - version: version, - hash: nil, - metadata: {}, - ) - end end end end diff --git a/test/dev/deps/brew_repository_test.rb b/test/dev/deps/brew_repository_test.rb index 0c36362..bb916cc 100644 --- a/test/dev/deps/brew_repository_test.rb +++ b/test/dev/deps/brew_repository_test.rb @@ -71,95 +71,7 @@ class Dev::Deps::BrewRepositoryTest < Minitest::Test package.versions.first.metadata == { "cask" => true } end - 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 - - 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", - ) - - Then - dep.name == "cmake" - dep.integration == :brew - dep.group == :build - dep.version == "3.31.4" - dep.hash == "SHA256=abc123def456" - end - - test "fetch treats declared version as a formula suffix and records the resolved version" do - Given "a formula declared with a version suffix" - repository = Dev::Deps::BrewRepository.new - brew_json = [{ - "name" => "llvm@18", - "versions" => { "stable" => "18.1.8" }, - "bottle" => { "stable" => { "files" => { "arm64_sonoma" => { "sha256" => "llvm18" } } } }, - }].to_json - - 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", - ) - - 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" - end - - test "fetch includes tap in metadata when specified" do - Given "a tapped formula identifier" - repository = Dev::Deps::BrewRepository.new - brew_json = [{ - "name" => "powershell", - "versions" => { "stable" => "7.4.0" }, - "bottle" => { "stable" => { "files" => { "arm64_sonoma" => { "sha256" => "ps123" } } } }, - }].to_json - - 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", - ) - - Then - dep.name == "powershell" - dep.metadata["tap"] == "d3mlabs/d3mlabs" - 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 = [{ @@ -176,39 +88,18 @@ 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 as locator" + package = repository.find( + Dev::Deps::PackageId.new(integration: :brew, name: "xcodes"), + filter: { "tap" => "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) @@ -216,12 +107,8 @@ class Dev::Deps::BrewRepositoryTest < Minitest::Test .with("brew", "info", "--json=v1", "nonexistent") .returns(["", "Error: No available formula", failed_status]) - 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/bundler_repository_test.rb b/test/dev/deps/bundler_repository_test.rb index 353902a..e338b41 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,38 +29,6 @@ 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 "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-") @@ -96,64 +61,4 @@ def bundler_declarations(&block) Cleanup FileUtils.rm_rf(dir) end - - test "fetch returns the locked version and checksum for a declared gem" do - Given "a prepared repository" - 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") - - 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" - - 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" - 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 - - Then "the error surfaces the bundler failure" - error.message.include?("bundle lock failed") - - Cleanup - FileUtils.rm_rf(dir) - end end diff --git a/test/dev/deps/cmake_integration_test.rb b/test/dev/deps/cmake_integration_test.rb index 8931d98..f99abaf 100644 --- a/test/dev/deps/cmake_integration_test.rb +++ b/test/dev/deps/cmake_integration_test.rb @@ -9,16 +9,19 @@ require "dev/deps/dependency_declaration" require "dev/deps/cache" require "dev/deps/dependency" +require "dev/deps/package" +require "dev/deps/package_version" +require "dev/deps/pinned_scheme" 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, filter: {}) + Dev::Deps::Package.new(id: id, versions: @universes.fetch(id.name)) end end unless defined?(StubRepository) @@ -323,13 +326,15 @@ 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::PinnedScheme.new }, + ) declarations = [ Dev::Deps::DependencyDeclaration.new( name: "googletest", integration: :cmake, group: :test, diff --git a/test/dev/deps/ficsit_repository_test.rb b/test/dev/deps/ficsit_repository_test.rb index eeeefec..6afecd0 100644 --- a/test/dev/deps/ficsit_repository_test.rb +++ b/test/dev/deps/ficsit_repository_test.rb @@ -180,178 +180,7 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test package.empty? end - test "fetch resolves mod to version, hash, and transitive deps" do - Given "a repository with a stubbed GraphQL response" - repo = Dev::Deps::FicsitRepository.new - graphql_response = { - "data" => { - "getModByReference" => { - "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 }, - ], - }], - }, - }, - } - stub_response = stub(body: JSON.generate(graphql_response), is_a?: true) - 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" - end - - test "fetch uses specified target platform" do - Given "a mod with multiple targets" - 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" => "winhash123", "size" => 100 }, - { "targetName" => "LinuxServer", "hash" => "linuxhash456", "size" => 200 }, - ], - "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 with target: LinuxServer" - dep = repo.fetch( - "name" => "TestMod", - "integration" => "ficsit", - "group" => "app", - "target" => "LinuxServer", - ) - - Then - dep.hash == "SHA256=linuxhash456" - dep.metadata["target"] == "LinuxServer" - end - - test "fetch defaults to Windows target" do - Given "a mod with only Windows target" - 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.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", - ) - - Then - dep.metadata["target"] == "Windows" - dep.hash == "SHA256=winhash" - end - - test "fetch resolves multiple platforms into nested metadata with absolute links" do - Given "a mod with Windows and LinuxServer targets and relative links" - 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" => [], - }], - }, - }, - } - 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 with a platform set including the nil default and LinuxServer" - dep = repo.fetch( - "name" => "SML", - "integration" => "ficsit", - "group" => "app", - "platforms" => [nil, "LinuxServer"], - ) - - 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") - 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 = { @@ -374,144 +203,18 @@ 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 with the LinuxServer platform requested" + package = repo.find( + Dev::Deps::PackageId.new(integration: :ficsit, name: "SML"), + filter: { "platforms" => ["LinuxServer"] }, ) - Then - dep.metadata["platforms"]["LinuxServer"]["link"] == + Then "the link falls back to the /v1/version///download shape" + package.version("3.12.0").metadata["platforms"]["LinuxServer"]["link"] == "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 = { @@ -534,54 +237,25 @@ 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 end diff --git a/test/dev/deps/gh_repository_test.rb b/test/dev/deps/gh_repository_test.rb index 5ec4156..1c42bbb 100644 --- a/test/dev/deps/gh_repository_test.rb +++ b/test/dev/deps/gh_repository_test.rb @@ -28,18 +28,24 @@ class Dev::Deps::GhRepositoryTest < Minitest::Test ], }.freeze - def fetch_id(overrides = {}) + def prebuilt_id + Dev::Deps::PackageId.new( + integration: :gh, name: "UnrealEngine", source: "satisfactorymodding/UnrealEngine", + ) + end + + def prebuilt_filter(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) end + def source_id + Dev::Deps::PackageId.new(integration: :gh, name: "UnrealEngine", source: "EpicGames/UnrealEngine") + end + test "find reports the declared tag's release as a singleton universe" do Given "a repository with a stubbed gh api response" repo = Dev::Deps::GhRepository.new @@ -111,33 +117,7 @@ def fetch_id(overrides = {}) raises Dev::Deps::Repository::PackageNotFoundError 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) - - 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" - end - - test "fetch omits sha256 for assets without an API digest" do + test "find omits sha256 for assets without an API digest" do Given "a release whose asset has no digest" repo = Dev::Deps::GhRepository.new release = { @@ -146,125 +126,77 @@ def fetch_id(overrides = {}) } 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")) + When "finding the release" + package = repo.find(prebuilt_id, filter: prebuilt_filter("assets" => "tool-Linux.tar.zst", "tag" => "v1.0")) Then - dep.metadata["assets"].size == 1 - !dep.metadata["assets"][0].key?("sha256") + assets = package.version("v1.0").metadata["assets"] + assets.size == 1 + !assets[0].key?("sha256") end - test "fetch raises NoMatchingAssetsError when pattern matches nothing" do + test "find raises NoMatchingAssetsError when the pattern matches nothing" do Given "a release without assets matching the pattern" repo = Dev::Deps::GhRepository.new repo.stubs(:run_gh_api).returns([JSON.generate(RELEASE_JSON), "", stub(success?: true)]) - When "fetching with a non-matching pattern" - repo.fetch(fetch_id("assets" => "*.7z.*")) + When "finding with a non-matching pattern" + repo.find(prebuilt_id, filter: prebuilt_filter("assets" => "*.7z.*")) Then raises Dev::Deps::GhRepository::NoMatchingAssetsError end - test "fetch raises ReleaseNotFoundError when tag is missing but repo is visible" do - Given "a 404 on the release and a visible repo" - repo = Dev::Deps::GhRepository.new - 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)]) - repo.stubs(:run_gh_api) - .with("repos/satisfactorymodding/UnrealEngine") - .returns([JSON.generate({ "full_name" => "satisfactorymodding/UnrealEngine" }), "", stub(success?: true)]) - - When "fetching a nonexistent tag" - repo.fetch(fetch_id("tag" => "9.9.9-css-1")) - - Then - raises Dev::Deps::GhRepository::ReleaseNotFoundError - end - - test "fetch raises RepoAccessError when the repo itself is invisible" do + test "find raises RepoAccessError when the repo itself is invisible" do Given "a 404 on both the release and the repo" repo = Dev::Deps::GhRepository.new repo.stubs(:run_gh_api).returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)]) - When "fetching from an inaccessible repo" - repo.fetch(fetch_id) + When "finding in an inaccessible repo" + repo.find(prebuilt_id, filter: prebuilt_filter) Then raises Dev::Deps::GhRepository::RepoAccessError end - test "fetch raises AuthenticationError when gh is not logged in" do + test "find raises AuthenticationError when gh is not logged in" do Given "gh demanding authentication" 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)]) - When "fetching without authentication" - repo.fetch(fetch_id) + When "finding without authentication" + repo.find(prebuilt_id, filter: prebuilt_filter) Then raises Dev::Deps::GhRepository::AuthenticationError end - test "fetch raises ApiError for other gh failures" do + 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).returns(["", "gh: Internal Server Error (HTTP 500)", stub(success?: false)]) - When "fetching during an API outage" - repo.fetch(fetch_id) + When "finding during an API outage" + repo.find(prebuilt_id, filter: prebuilt_filter) Then raises Dev::Deps::GhRepository::ApiError end - test "fetch raises GhMissingError when the gh CLI is not installed" do + test "find raises GhMissingError when the gh CLI is not installed" do Given "no gh binary on PATH" repo = Dev::Deps::GhRepository.new Open3.stubs(:capture3).raises(Errno::ENOENT.new("gh")) - When "fetching without gh installed" - repo.fetch(fetch_id) + When "finding without gh installed" + repo.find(prebuilt_id, filter: prebuilt_filter) 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) - 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" - 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)]) - - When "fetching the source dependency" - dep = repo.fetch(source_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") - end - - test "fetch source raises ReleaseNotFoundError when the tag is missing but repo is visible" do + test "find source raises ReleaseNotFoundError when the tag is missing but repo is visible" do Given "a 404 on the commit and a visible repo" repo = Dev::Deps::GhRepository.new repo.stubs(:run_gh_api) @@ -274,20 +206,20 @@ def source_id(overrides = {}) .with("repos/EpicGames/UnrealEngine") .returns([JSON.generate({ "full_name" => "EpicGames/UnrealEngine" }), "", stub(success?: true)]) - When "fetching a nonexistent tag" - repo.fetch(source_id("tag" => "9.9.9")) + When "finding a nonexistent tag" + repo.find(source_id, filter: { "tag" => "9.9.9", "build" => "make" }) Then raises Dev::Deps::GhRepository::ReleaseNotFoundError end - test "fetch source raises RepoAccessError when the repo is invisible (account not linked)" do + test "find source raises RepoAccessError when the repo is invisible (account not linked)" do Given "a 404 on both the commit and the repo" repo = Dev::Deps::GhRepository.new repo.stubs(:run_gh_api).returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)]) - When "fetching from an inaccessible repo" - repo.fetch(source_id) + When "finding in an inaccessible repo" + repo.find(source_id, filter: { "tag" => "5.6.1-release", "build" => "make" }) Then raises Dev::Deps::GhRepository::RepoAccessError diff --git a/test/dev/deps/git_repository_test.rb b/test/dev/deps/git_repository_test.rb index c6d6097..d9f4f6a 100644 --- a/test/dev/deps/git_repository_test.rb +++ b/test/dev/deps/git_repository_test.rb @@ -61,72 +61,4 @@ class Dev::Deps::GitRepositoryTest < Minitest::Test Then raises Dev::Deps::Repository::PackageNotFoundError end - - test "fetch passes through a 40-char hex commit SHA as-is" do - Given "a commit SHA identifier" - repo = Dev::Deps::GitRepository.new - - When "fetching by commit" - dep = repo.fetch( - "name" => "entityx", - "repo" => "https://github.com/alecthomas/entityx", - "commit" => "ee3042f8b0279856061f91069a487e4ed6f69475", - "integration" => "cmake", - "group" => "app", - ) - - Then - dep.name == "entityx" - dep.version == "ee3042f8b0279856061f91069a487e4ed6f69475" - dep.integration == :cmake - dep.group == :app - end - - test "fetch calls git ls-remote for a tag" do - Given "a tag identifier" - 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", - ) - - Then - dep.name == "googletest" - dep.version == resolved_sha - dep.integration == :cmake - dep.group == :test - end - - test "fetch raises RefResolutionError for unresolvable ref" do - Given "a tag that does not exist on the 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", - ) - - Then - raises Dev::Deps::GitRepository::RefResolutionError - end end diff --git a/test/dev/deps/luarocks_registry_test.rb b/test/dev/deps/luarocks_registry_test.rb index 06bbb90..c96b1bf 100644 --- a/test/dev/deps/luarocks_registry_test.rb +++ b/test/dev/deps/luarocks_registry_test.rb @@ -54,67 +54,4 @@ class Dev::Deps::LuaRocksRepositoryTest < Minitest::Test Then raises Dev::Deps::LuaRocksRepository::SearchError end - - test "fetch parses luarocks search output and returns a Dependency" do - Given "a stubbed luarocks search and download at the Open3 boundary" - 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" - - 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", - ) - - 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=") - end - - test "fetch raises SearchError when luarocks search fails" do - Given "a luarocks search that returns a non-zero exit" - repository = Dev::Deps::LuaRocksRepository.new - failed_status = stub(success?: false) - Open3.stubs(:capture3) - .with("luarocks", "search", "missing", "--porcelain") - .returns(["", "error", failed_status]) - - When "fetching the dependency" - error = assert_raises(Dev::Deps::LuaRocksRepository::SearchError) do - repository.fetch("name" => "missing", "integration" => "luarocks", - "group" => "runtime", "constraint" => ">=1.0") - end - - Then "the error mentions the package name" - error.message.include?("missing") - end - - test "fetch raises NoVersionError when no versions found" do - Given "a luarocks search that returns no version lines" - repository = Dev::Deps::LuaRocksRepository.new - Open3.stubs(:capture3) - .with("luarocks", "search", "empty", "--porcelain") - .returns(["empty\n", "", stub(success?: true)]) - - When "fetching the dependency" - error = assert_raises(Dev::Deps::LuaRocksRepository::NoVersionError) do - repository.fetch("name" => "empty", "integration" => "luarocks", - "group" => "runtime", "constraint" => ">=1.0") - end - - Then "the error mentions the package name" - error.message.include?("empty") - end end diff --git a/test/dev/deps/pip_repository_test.rb b/test/dev/deps/pip_repository_test.rb index 3bc07db..2b22adf 100644 --- a/test/dev/deps/pip_repository_test.rb +++ b/test/dev/deps/pip_repository_test.rb @@ -65,35 +65,4 @@ class Dev::Deps::PipRepositoryTest < Minitest::Test Then raises Dev::Deps::PipRepository::ApiError end - - test "reads the version #{expected} from #{filename}" do - Given "a repository" - repo = Dev::Deps::PipRepository.new - - Expect "the version is the first digit-leading token after the name" - repo.send(:version_from_filename, filename, "totalsegmentator") == expected - - 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" - end - - test "normalize_constraint maps #{input} to #{expected}" do - Given "a repository" - repo = Dev::Deps::PipRepository.new - - Expect "bare versions become == pins, operatored constraints pass through, blanks stay empty" - repo.send(:normalize_constraint, input) == expected - - Where - input | expected - "2.0.5" | "==2.0.5" - ">=2.0" | ">=2.0" - "~=2.1" | "~=2.1" - nil | "" - "" | "" - end end diff --git a/test/dev/deps/repository_test.rb b/test/dev/deps/repository_test.rb index 6df2f78..d8384c7 100644 --- a/test/dev/deps/repository_test.rb +++ b/test/dev/deps/repository_test.rb @@ -17,15 +17,4 @@ class Dev::Deps::RepositoryTest < Minitest::Test Then raises NotImplementedError end - - test "base class fetch raises NotImplementedError" do - Given "a base Repository instance" - repo = Dev::Deps::Repository.new - - When "fetching a dependency" - repo.fetch({ "name" => "boost", "constraint" => ">=1.0" }) - - Then - raises NotImplementedError - end end diff --git a/test/dev/deps/resolver_test.rb b/test/dev/deps/resolver_test.rb index 5a2eb73..ad62477 100644 --- a/test/dev/deps/resolver_test.rb +++ b/test/dev/deps/resolver_test.rb @@ -4,164 +4,143 @@ require "test_helper" require "dev/deps/resolver" require "dev/deps/repository" -require "dev/deps/dependency" +require "dev/deps/package" +require "dev/deps/package_id" +require "dev/deps/package_version" +require "dev/deps/dependency_edge" require "dev/deps/dependency_declaration" -require "dev/deps/cache" -require "tmpdir" +require "dev/deps/pinned_scheme" +require "dev/deps/semver_scheme" -# Stub repository that returns canned Dependencies without network calls. -# Records fetch IDs for assertion. +# Stub repository over a canned universe: name -> [PackageVersion, ...]. +# Records every find call (id + filter) for assertion. class StubRepository < Dev::Deps::Repository - attr_reader :fetched_ids + attr_reader :finds - def initialize(deps_by_name: {}) - @deps_by_name = deps_by_name - @fetched_ids = [] + def initialize(universes: {}) + @universes = universes + @finds = [] end - def fetch(id) - @fetched_ids << id - @deps_by_name.fetch(id["name"]) + def find(id, filter: {}) + @finds << { id: id, filter: filter } + 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 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. + def version(v, digest: nil, platforms: [], dependencies: [], metadata: {}) + Dev::Deps::PackageVersion.new( + version: v, digest: digest, platforms: platforms, + dependencies: dependencies, metadata: metadata, + ) + end - def initialize(deps_by_name: {}) - @deps_by_name = deps_by_name - @prepared_with = nil + def edge(name, constraint) + Dev::Deps::DependencyEdge.new(name: name, constraint: constraint) end - def prepare(declarations) - @prepared_with = declarations + def declaration(**kwargs) + Dev::Deps::DependencyDeclaration.new(**kwargs) end - def fetch(id) - @deps_by_name.fetch(id["name"]) + def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) + 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 = [ - 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.12.0" }), ] - resolver = Dev::Deps::Resolver.new(repositories: { gh: repo, brew: repo }) - When "resolving" - result = resolver.resolve(declarations) + When "resolving with SemverScheme" + result = 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") } + Then "the highest in-range version wins — not the highest overall" + result[0].version == "3.13.1" 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 }) + 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: "parent", integration: :brew, group: :build, - host: :darwin, env: "ci"), + declaration(name: "SML", integration: :ficsit, group: :app, constraint: { "version" => "^3.0.0" }), ] - resolver = Dev::Deps::Resolver.new(repositories: { brew: repo }) - When "resolving" - result = resolver.resolve(declarations) + When "resolving with SemverScheme" + result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).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 "the unparseable candidate is simply not a candidate" + result[0].version == "3.12.0" 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 "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 = [ - Dev::Deps::DependencyDeclaration.new(name: "boost", integration: :cmake, group: :app), - Dev::Deps::DependencyDeclaration.new(name: "gtest", integration: :cmake, group: :test), + declaration(name: "SML", integration: :ficsit, group: :app, constraint: { "version" => "^3.0.0" }), ] - resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo }) When "resolving" - result = resolver.resolve(declarations) + resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations) Then - result.size == 2 - result.map(&:name).sort == ["boost", "gtest"] + raises Dev::Deps::Resolver::NoSatisfyingVersionError 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 "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: :luarocks, group: :test), + 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: { luarocks: repo }) When "resolving" - result = resolver.resolve(declarations) + resolver_for(:ficsit, repo).resolve(declarations) Then - result.size == 2 - result.map(&:name).sort == ["child", "parent"] + raises Dev::Deps::Resolver::ConflictingDeclarationError end test "raises UnknownIntegrationError for unregistered integration" do Given "a declaration referencing an unregistered integration" - resolver = Dev::Deps::Resolver.new(repositories: {}) - declarations = [ - Dev::Deps::DependencyDeclaration.new(name: "foo", integration: :unknown, group: :app), - ] + resolver = Dev::Deps::Resolver.new(repositories: {}, schemes: {}) + declarations = [declaration(name: "foo", integration: :unknown, group: :app)] When "resolving" resolver.resolve(declarations) @@ -170,216 +149,194 @@ 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 "passes the declaration constraint to find as the locator filter" 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: { "repo" => "d3mlabs/unreal-engine", "tag" => "5.8.0" }), ] - resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo }) When "resolving" - result = resolver.resolve(declarations) + resolver_for(:gh, repo).resolve(declarations) - Then - result.size == 2 - result.map(&:name).sort == ["a", "b"] + Then "the constraint rode along as the filter, and repo/url became the id's source" + repo.finds[0][:filter]["tag"] == "5.8.0" + repo.finds[0][:id].source == "d3mlabs/unreal-engine" + repo.finds[0][:id].name == "engine" + repo.finds[0][:id].integration == :gh 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" + 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" + repo.finds.none? { |call| call[:filter].key?("host") || call[:filter].key?("env") } 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 }) + 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 "unions platforms across groups and resolves a duplicated dep once" do + Given "SML declared in :app (no platform) and :integration (LinuxServer)" + repo = StubRepository.new(universes: { + "SML" => [version("3.12.0", platforms: ["Windows", "LinuxServer"])], + }) declarations = [ - Dev::Deps::DependencyDeclaration.new(name: "parent", integration: :luarocks, group: :test), + declaration(name: "SML", integration: :ficsit, group: :app), + declaration(name: "SML", integration: :ficsit, group: :integration, platform: "LinuxServer"), ] - 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, with the union of both groups' platforms in the filter" + result.size == 1 + repo.finds.size == 1 + repo.finds[0][:filter]["platforms"].sort_by(&:to_s) == [nil, "LinuxServer"].sort_by(&:to_s) 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 "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 = [ - Dev::Deps::DependencyDeclaration.new(name: "parent", integration: :luarocks, group: :app), + declaration(name: "SML", integration: :ficsit, group: :app, platform: "LinuxServer", + constraint: { "version" => "^3.0.0" }), ] - resolver = Dev::Deps::Resolver.new(repositories: { luarocks: repo }) When "resolving" - resolver.resolve(declarations) + result = resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).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 older version that still publishes the platform wins" + result[0].version == "3.12.0" 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 }) - declarations = [ - Dev::Deps::DependencyDeclaration.new(name: "SML", integration: :ficsit, group: :app), - Dev::Deps::DependencyDeclaration.new(name: "SML", integration: :ficsit, group: :integration, - platform: "LinuxServer"), - ] - resolver = Dev::Deps::Resolver.new(repositories: { ficsit: repo }) + test "omits platforms from the filter when no group pins a platform" do + Given "a dep declared only in groups without a platform" + repo = StubRepository.new(universes: { "boost" => [version("1.0")] }) + declarations = [declaration(name: "boost", integration: :cmake, group: :app)] When "resolving" - result = resolver.resolve(declarations) + resolver_for(:cmake, repo).resolve(declarations) - Then "fetched once, with the union of both groups' platforms" - result.size == 1 - repo.fetched_ids.size == 1 - repo.fetched_ids[0]["platforms"].sort_by(&:to_s) == [nil, "LinuxServer"].sort_by(&:to_s) + Then "no platforms key leaks into the filter" + !repo.finds[0][:filter].key?("platforms") 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 }) - declarations = [ - Dev::Deps::DependencyDeclaration.new(name: "boost", integration: :cmake, group: :app), - ] - resolver = Dev::Deps::Resolver.new(repositories: { cmake: repo }) + 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" - resolver.resolve(declarations) + result = resolver_for(:brew, repo).resolve(declarations) - Then "no platforms key leaks into the fetch id" - !repo.fetched_ids[0].key?("platforms") + 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 +344,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/steam_repository_test.rb b/test/dev/deps/steam_repository_test.rb index 94a1b20..f1bc3e4 100644 --- a/test/dev/deps/steam_repository_test.rb +++ b/test/dev/deps/steam_repository_test.rb @@ -49,68 +49,17 @@ class Dev::Deps::SteamRepositoryTest < Minitest::Test package.versions.map(&:version) == ["99999"] end - 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"], - ) - - 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" - end - - test "fetch resolves the current public buildid via steamcmd when not pinned" do - Given "no pinned buildid and a stubbed steamcmd resolution" - repo = Dev::Deps::SteamRepository.new - Dev::Deps::SteamCmd.stubs(:resolve_build_id).with(app: 1690800, branch: "public").returns("99999") - - When "fetching" - dep = repo.fetch( - "name" => "SatisfactoryServer", - "integration" => "steam", - "group" => "integration", - "app" => 1690800, - "install_dir" => "~/.dev/satisfactory-server", - "platforms" => ["LinuxServer"], - ) - - Then - dep.version == "99999" - end - - test "fetch defaults platform to linux when no group platform is set" do + test "find defaults platform to linux when no group platform is set" do Given "a declaration with no platforms" repo = Dev::Deps::SteamRepository.new - 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 with a pinned buildid" + package = repo.find( + Dev::Deps::PackageId.new(integration: :steam, name: "SatisfactoryServer"), + filter: { "app" => 1690800, "install_dir" => "/tmp/server", "buildid" => "1" }, ) Then - dep.metadata["platform"] == "linux" + package.version("1").metadata["platform"] == "linux" end end diff --git a/test/dev/deps/url_repository_test.rb b/test/dev/deps/url_repository_test.rb index 098eb69..1eb5b7a 100644 --- a/test/dev/deps/url_repository_test.rb +++ b/test/dev/deps/url_repository_test.rb @@ -38,37 +38,7 @@ class Dev::Deps::UrlRepositoryTest < Minitest::Test FileUtils.rm_rf(dir) end - test "fetch downloads URL and computes SHA256" do - Given "a URL identifier 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", - ) - - 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" - - 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) @@ -76,12 +46,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: :cmake, name: "missing", source: "https://example.com/missing.tar.gz", + ), ) Then diff --git a/test/dev/deps/xcode_repository_test.rb b/test/dev/deps/xcode_repository_test.rb index afa016c..7b41a97 100644 --- a/test/dev/deps/xcode_repository_test.rb +++ b/test/dev/deps/xcode_repository_test.rb @@ -32,34 +32,4 @@ class Dev::Deps::XcodeRepositoryTest < Minitest::Test Then raises Dev::Deps::XcodeRepository::MissingVersionError end - - test "fetch resolves the declared exact version as the locked version" do - Given "an xcode declaration id" - repo = Dev::Deps::XcodeRepository.new - id = { "name" => "xcode", "integration" => "xcode", "group" => "build", "version" => "26.1.1" } - - When "fetching" - dep = repo.fetch(id) - - 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? - end - - test "fetch without an exact version raises" do - Given "a declaration missing the version pin" - 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 - - Then - error.message.include?("exact version") - end end From 13469ee08b66f5c9882191ee024c4231edeebec4 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 3 Sep 2026 09:09:07 -0400 Subject: [PATCH 14/37] Wire schemes and lockers through the Registry and update-deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Registry entry now declares its VersionScheme (the ecosystem's constraint semantics) alongside its repository, and entries whose tool owns the whole-set solve declare a Locker (bundler -> BundlerLocker). update-deps becomes a lock-then-resolve pipeline: each integration's locker runs over its declarations first, so repositories read an already-solved universe, then the Resolver is built from Registry.repositories + Registry.schemes. BundlerRepository no longer takes ruby_version_requirement — that's the locker's concern. The registry consistency test grows two anti-drift guards: every *_scheme.rb (bar the abstract base) and every *_locker.rb class must be referenced by a registry entry. Co-authored-by: Cursor --- lib/dev/deps/registry.rb | 88 +++++++++++++++++++--- src/dev/builtins/update_deps_command.rb | 21 ++++-- test/dev/deps/registry_consistency_test.rb | 32 ++++++++ 3 files changed, 125 insertions(+), 16 deletions(-) diff --git a/lib/dev/deps/registry.rb b/lib/dev/deps/registry.rb index f6a35c9..7658e69 100644 --- a/lib/dev/deps/registry.rb +++ b/lib/dev/deps/registry.rb @@ -16,12 +16,20 @@ 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 "gem_scheme" +require_relative "locker" +require_relative "pep440_scheme" +require_relative "pinned_scheme" +require_relative "rock_scheme" +require_relative "semver_scheme" +require_relative "version_scheme" module Dev module Deps @@ -50,15 +58,21 @@ 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] VersionScheme subclass carrying this type's + # constraint semantics — every type must answer "how do constraints work" + # @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 scope [Symbol] one of HOST / CONTAINER / BOTH Entry = Data.define( - :symbol, :repository, :repository_needs, :integration, :integration_needs, :scope, + :symbol, :repository, :repository_needs, :scheme, :locker, :locker_needs, + :integration, :integration_needs, :scope, ) do extend T::Sig @@ -75,6 +89,15 @@ def repository = to_h.fetch(:repository) sig { returns(T::Array[Symbol]) } def repository_needs = to_h.fetch(:repository_needs) + sig { returns(T.class_of(VersionScheme)) } + def scheme = to_h.fetch(:scheme) + + 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) @@ -88,14 +111,17 @@ def scope = to_h.fetch(:scope) params( symbol: Symbol, repository: T.class_of(Repository), + scheme: T.class_of(VersionScheme), integration: T.nilable(T.class_of(Integration)), scope: Symbol, repository_needs: T::Array[Symbol], + locker: T.nilable(T.class_of(Locker)), + locker_needs: T::Array[Symbol], integration_needs: T::Array[Symbol], ).void end - def initialize(symbol:, repository:, integration:, scope:, - repository_needs: [], integration_needs: []) + def initialize(symbol:, repository:, scheme:, integration:, scope:, + repository_needs: [], locker: nil, locker_needs: [], integration_needs: []) super end @@ -111,7 +137,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, @@ -119,6 +148,7 @@ def host? Entry.new( symbol: :brew, repository: BrewRepository, + scheme: PinnedScheme, integration: BrewIntegration, integration_needs: %i[taps project_dir], scope: BOTH, @@ -126,6 +156,7 @@ def host? Entry.new( symbol: :cmake, repository: GitRepository, + scheme: PinnedScheme, integration: CmakeIntegration, integration_needs: %i[project_root], scope: HOST, @@ -133,6 +164,7 @@ def host? Entry.new( symbol: :luarocks, repository: LuaRocksRepository, + scheme: RockScheme, integration: LuaRocksIntegration, integration_needs: %i[project_root], scope: HOST, @@ -140,12 +172,14 @@ def host? Entry.new( symbol: :ficsit, repository: FicsitRepository, + scheme: SemverScheme, integration: FicsitIntegration, scope: HOST, ), Entry.new( symbol: :gh, repository: GhRepository, + scheme: PinnedScheme, integration: GhIntegration, integration_needs: %i[project_root], scope: HOST, @@ -153,12 +187,14 @@ def host? Entry.new( symbol: :steam, repository: SteamRepository, + scheme: PinnedScheme, integration: SteamIntegration, scope: HOST, ), Entry.new( symbol: :xcode, repository: XcodeRepository, + scheme: PinnedScheme, integration: XcodeIntegration, integration_needs: %i[project_root], scope: HOST, @@ -166,6 +202,7 @@ def host? Entry.new( symbol: :pip, repository: PipRepository, + scheme: Pep440Scheme, integration: PipIntegration, integration_needs: %i[project_root python_version], scope: HOST, @@ -180,17 +217,45 @@ 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, so they take no context. + # + # @return [Hash{Symbol => VersionScheme}] + sig { returns(T::Hash[Symbol, VersionScheme]) } + def schemes + INTEGRATIONS.to_h { |entry| [entry.symbol, entry.scheme.new] } + 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. @@ -198,7 +263,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 @@ -239,7 +305,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/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/deps/registry_consistency_test.rb b/test/dev/deps/registry_consistency_test.rb index bb19c5d..9f74f3c 100644 --- a/test/dev/deps/registry_consistency_test.rb +++ b/test/dev/deps/registry_consistency_test.rb @@ -24,6 +24,11 @@ class Dev::Deps::RegistryConsistencyTest < Minitest::Test # 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 +69,33 @@ 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.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 "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) From e8b6aa40155a11961def949f0dbffdb2338f3fcb Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Thu, 3 Sep 2026 09:10:21 -0400 Subject: [PATCH 15/37] Document the deps architecture: ontology, layers, integrity, extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference the lib/dev/deps comments point at: the four-concept ontology (identity / universe / requirement / pin), the layer table with each class's one question, the lock-then-resolve pipeline, per-integration constraint semantics, the three integrity regimes, the new-ecosystem recipe, and the solve-ownership decision gate — per-ecosystem hybrid leaning dev-owned, with the criteria for revisiting bundler/pip/luarocks tool ownership recorded. Co-authored-by: Cursor --- docs/deps-architecture.md | 167 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/deps-architecture.md diff --git a/docs/deps-architecture.md b/docs/deps-architecture.md new file mode 100644 index 0000000..605d1bd --- /dev/null +++ b/docs/deps-architecture.md @@ -0,0 +1,167 @@ +# 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 + +Four 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-based deps (a git URL, a `owner/repo` slug). Value object, works as a Hash key. | +| Universe | `Package` → `PackageVersion` | What exists: every version a repository reports, each carrying facts — `platforms`, `digest`, `artifacts` (dev-fetched bytes), `dependencies` (edges), and `metadata` (ecosystem install facts). | +| Requirement | `DependencyDeclaration` | What the user asked for: name, integration, constraint hash, and the install axes (`group`, `platform`, `host`, `env`). | +| 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), `DependencyEdge` (an outgoing requirement of a +`PackageVersion`, constraint left in the ecosystem's native syntax). + +## Layers and their one question + +| Layer | Class(es) | The one question it answers | Never does | +| --- | --- | --- | --- | +| Repository | `Repository#find(id, filter:) -> Package` | "What versions of this package exist, and what are their facts?" | Evaluate range constraints; choose among candidates | +| 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, scheme, optional +locker, optional integration, and scope. Consistency tests fail the build +if a `*_repository.rb`, `*_integration.rb`, `*_scheme.rb`, or +`*_locker.rb` class exists without a registry entry. + +## 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 name carries disagreeing + constraints (axes — group/platform/host/env — may differ; constraints + may not); + - builds the `PackageId` (the constraint's `repo`/`url` becomes the + id's source) and calls `find`, passing the constraint hash as the + `filter` — a *locator*, not a predicate: pinned ecosystems need the + tag/buildid/suffix to know which singleton universe to report; + - filters the reported versions through the integration's scheme + (`satisfies?`), treating scheme-unparseable universe versions as + non-candidates, and drops versions that don't publish every + explicitly requested platform; + - picks the highest satisfying version (`sort`), mints the + `Dependency` from that version's facts (digest → pin hash, metadata + → pin metadata), and stamps the declaration's `host`/`env` onto the + pin's metadata; + - queues the chosen version's `dependencies` edges as synthetic + declarations that inherit the parent's group/host/env. +3. **Write** — pins go to `deps.lock`. + +`dev install-deps` reads the lockfile and hands each integration its pins; +no resolution happens at install time. + +``` +update-deps ─▶ Locker.lock(decls) (bundler: Gemfile.lock appears) + ─▶ Resolver.resolve(decls) + ├─▶ Repository.find(id, filter) ─▶ Package{PackageVersion…} + ├─▶ VersionScheme.satisfies?/sort (choice) + └─▶ Dependency (pin) ─▶ deps.lock +install-deps ─▶ Integration.install_all(pins) +``` + +## 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 `~>` | +| brew, cmake, gh, steam, xcode | `PinnedScheme` | the constraint names an identity (formula suffix, tag/commit, release tag, buildid, exact version); the repository already applied it as the find locator, so every reported version satisfies | + +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". + +## Adding a new ecosystem + +1. **Repository** — subclass `Repository`, implement + `find(id, filter:) -> Package`. Report facts for every version you can + enumerate; if the ecosystem's constraint names an identity, use the + filter as your locator and report the (usually singleton) universe. + Raise a subclass of `Repository::PackageNotFoundError` when the + identity doesn't exist. Never pick a version. +2. **Scheme** — if the ecosystem has a native range language, subclass + `VersionScheme` with its `satisfies?`/`sort`, nesting + `InvalidConstraintError`/`InvalidVersionError` under the shared bases. + If constraints are identities, use `PinnedScheme`. +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 *edge facts* (dependency +metadata in `find`) plus a backtracking solver in the Resolver — the +interfaces already accommodate both (`PackageVersion#dependencies` is the +slot). No interface change is expected; the cost is per-ecosystem edge +enumeration and solver work, so pay it per ecosystem when the need is +real, not up front. From f9ba5c034773e3814357ec9de7e46ad5b9466d8b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 5 Sep 2026 13:24:01 -0400 Subject: [PATCH 16/37] test: cover the last uncovered patch lines The >/<= operator branches in Pep440Scheme, RockScheme, and SemverScheme gain Where-table rows; the abstract Locker#lock raise gets its own test mirroring VersionScheme's; PipRepository#get_project's HTTP seam is asserted against the PyPI project URL; and update-deps' locker dispatch is exercised with a manifest gem declaration and a mocked locker. Co-authored-by: Cursor --- test/dev/builtins/update_deps_command_test.rb | 26 +++++++++++++++++++ test/dev/deps/locker_test.rb | 16 ++++++++++++ test/dev/deps/pep440_scheme_test.rb | 4 +++ test/dev/deps/pip_repository_test.rb | 10 +++++++ test/dev/deps/rock_scheme_test.rb | 4 +++ test/dev/deps/semver_scheme_test.rb | 2 ++ 6 files changed, 62 insertions(+) create mode 100644 test/dev/deps/locker_test.rb 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/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/pep440_scheme_test.rb b/test/dev/deps/pep440_scheme_test.rb index 2ab0c1a..66cb9fb 100644 --- a/test/dev/deps/pep440_scheme_test.rb +++ b/test/dev/deps/pep440_scheme_test.rb @@ -36,6 +36,10 @@ def scheme "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 diff --git a/test/dev/deps/pip_repository_test.rb b/test/dev/deps/pip_repository_test.rb index 2b22adf..fb1aad8 100644 --- a/test/dev/deps/pip_repository_test.rb +++ b/test/dev/deps/pip_repository_test.rb @@ -65,4 +65,14 @@ class Dev::Deps::PipRepositoryTest < Minitest::Test 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/rock_scheme_test.rb b/test/dev/deps/rock_scheme_test.rb index 64983fe..f09063c 100644 --- a/test/dev/deps/rock_scheme_test.rb +++ b/test/dev/deps/rock_scheme_test.rb @@ -30,6 +30,10 @@ def scheme "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 diff --git a/test/dev/deps/semver_scheme_test.rb b/test/dev/deps/semver_scheme_test.rb index 19ebfe1..0f3788c 100644 --- a/test/dev/deps/semver_scheme_test.rb +++ b/test/dev/deps/semver_scheme_test.rb @@ -36,6 +36,8 @@ def scheme "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 From 43b73771964591228e110e0185feb6f49cb239c8 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 5 Sep 2026 14:58:18 -0400 Subject: [PATCH 17/37] Key the resolver's resolved set by PackageId The same name under two integrations is two packages: each resolves against its own integration's universe, transitive edges stay inside the declaring dep's integration, constraint-conflict detection and platform unioning are scoped per (integration, name). Co-authored-by: Cursor --- lib/dev/deps/resolver.rb | 50 +++++++++++++++---------- test/dev/deps/resolver_test.rb | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb index 58d3074..55f3c7d 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -51,32 +51,36 @@ def initialize(repositories:, schemes:) # Resolve all declarations into a flat Dependency list. # + # 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 edges likewise stay inside the + # declaring dep's integration. + # # @param declarations [Array] declared dependencies to resolve # @return [Array] - # @raise [ConflictingDeclarationError] if one name is declared with disagreeing constraints + # @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[DependencyDeclaration]).returns(T::Array[Dependency]) } def resolve(declarations) reject_conflicts(declarations) - platforms_by_name = platforms_by_name(declarations) - resolved = T.let({}, T::Hash[String, Dependency]) + platforms = declared_platforms(declarations) + resolved = T.let({}, T::Hash[PackageId, Dependency]) queue = declarations.dup while (decl = queue.shift) - next if resolved.key?(decl.name) + id = package_id(decl) + next if resolved.key?(id) - chosen = choose(decl, platforms_by_name[decl.name] || []) - resolved[decl.name] = mint(chosen, decl) + chosen = choose(decl, platforms[[decl.integration, decl.name]] || []) + resolved[id] = mint(chosen, decl) # 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. chosen.dependencies.each do |edge| - next if resolved.key?(edge.name) - - queue << DependencyDeclaration.new( + edge_decl = DependencyDeclaration.new( name: edge.name, integration: decl.integration, constraint: normalize_constraint(edge.constraint), @@ -84,6 +88,7 @@ def resolve(declarations) host: decl.host, env: decl.env, ) + queue << edge_decl unless resolved.key?(package_id(edge_decl)) end end @@ -208,9 +213,11 @@ def package_id(decl) ) end - # Reject sets where one name is declared with disagreeing constraints. + # Reject sets where one package is declared with disagreeing constraints. # A dep declared in several groups resolves once, so agreement is the # precondition for that single resolution being right for everyone. + # Grouping is per (integration, name): the same name under two + # integrations is two packages, free to carry different constraints. # (Platform, group, host, and env may differ — they are axes, not # constraints.) # @@ -219,12 +226,13 @@ def package_id(decl) # @raise [ConflictingDeclarationError] sig { params(declarations: T::Array[DependencyDeclaration]).void } def reject_conflicts(declarations) - declarations.group_by(&:name).each do |name, decls| + declarations.group_by { |d| [d.integration, d.name] }.each do |(integration, name), decls| constraints = decls.map(&:constraint).uniq next if constraints.size <= 1 raise ConflictingDeclarationError, - "#{name} is declared with disagreeing constraints: #{constraints.map(&:inspect).join(" vs ")}" + "#{integration}/#{name} is declared with disagreeing constraints: " \ + "#{constraints.map(&:inspect).join(" vs ")}" end end @@ -247,21 +255,23 @@ def attach_install_scoping(dependency, decl) dependency.with(metadata: dependency.metadata.merge(extra)) 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. + # 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] - # @return [Hash{String => Array}] name → de-duped platform list + # @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)]]) + ).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 diff --git a/test/dev/deps/resolver_test.rb b/test/dev/deps/resolver_test.rb index ad62477..e665238 100644 --- a/test/dev/deps/resolver_test.rb +++ b/test/dev/deps/resolver_test.rb @@ -137,6 +137,74 @@ def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) raises Dev::Deps::Resolver::ConflictingDeclarationError end + 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::PinnedScheme.new, brew: Dev::Deps::PinnedScheme.new }, + ) + declarations = [ + declaration(name: "ffi", integration: :bundler, group: :app), + declaration(name: "ffi", integration: :brew, group: :app), + ] + + When "resolving" + result = resolver.resolve(declarations) + + Then "both resolve, each in its own integration's universe" + result.size == 2 + result.map { |d| [d.integration, d.version] }.sort == [[:brew, "3.4.0"], [:bundler, "1.17.0"]].sort + end + + 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 = [ + declaration(name: "ffi", integration: :bundler, group: :app, constraint: { "version" => "^1.0.0" }), + declaration(name: "ffi", integration: :brew, group: :app, constraint: { "version" => "^3.0.0" }), + ] + + When "resolving" + result = resolver.resolve(declarations) + + Then "no ConflictingDeclarationError — constraints are scoped per integration" + result.size == 2 + end + + 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::PinnedScheme.new }, + ) + declarations = [ + 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: {}) From 3fbfa65eeaca0b817db96e9fdeae61be16a26931 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 5 Sep 2026 15:03:48 -0400 Subject: [PATCH 18/37] Nest lockfile entries by integration The lockfile key becomes (integration, name), matching package identity, so the same name under two integrations occupies two keys instead of colliding. The reader keeps a legacy flat-format shim until every consumer repo's lockfiles are rewritten by update-deps. BuildContainer's install_dir/build-context scans now parse lockfiles through Lockfile instead of raw YAML, so format knowledge (including the shim) lives in one place; project_needs_llvm? tolerates the dep key's indentation. Co-authored-by: Cursor --- lib/dev/build_container.rb | 60 +++++++++-------------- lib/dev/deps/lockfile.rb | 76 ++++++++++++++++++++++++----- lib/dev/shadowenv_llvm.rb | 4 +- test/dev/build_container_test.rb | 44 +++++++++++++++++ test/dev/deps/llvm_compat_test.rb | 20 ++++++++ test/dev/deps/lockfile_test.rb | 80 +++++++++++++++++++++++++++++-- 6 files changed, 229 insertions(+), 55 deletions(-) diff --git a/lib/dev/build_container.rb b/lib/dev/build_container.rb index 1298276..0bc2edf 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, T.must(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)] = T.must(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/lockfile.rb b/lib/dev/deps/lockfile.rb index 7a1c177..2a03ff7 100644 --- a/lib/dev/deps/lockfile.rb +++ b/lib/dev/deps/lockfile.rb @@ -14,6 +14,19 @@ 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. class Lockfile HEADER = <<~COMMENT # Generated by dev. Do not edit. @@ -96,20 +109,36 @@ 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] def deps_to_yaml_hash(deps) result = {} - deps.each { |dep| result[dep.name] = dep_to_hash(dep) } + 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] def dep_to_hash(dep) - h = { "integration" => dep.integration.to_s, "group" => dep.group.to_s } + h = { "group" => dep.group.to_s } 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] def yaml_hash_to_deps(yaml) deps = [] @@ -117,24 +146,45 @@ def yaml_hash_to_deps(yaml) 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] + 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] + 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 +192,25 @@ 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] 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_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/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/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/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/lockfile_test.rb b/test/dev/deps/lockfile_test.rb index 8a42b8a..4ba1302 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. + 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-") From f3553f9f4df0d4e5fd62377c78cc9f6db9c5b532 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 5 Sep 2026 15:04:18 -0400 Subject: [PATCH 19/37] Document the (integration, name) lockfile identity Co-authored-by: Cursor --- docs/deps-architecture.md | 18 +++++++++++++----- lib/dev/deps/package_id.rb | 4 +++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/deps-architecture.md b/docs/deps-architecture.md index 605d1bd..1d802f2 100644 --- a/docs/deps-architecture.md +++ b/docs/deps-architecture.md @@ -47,9 +47,10 @@ if a `*_repository.rb`, `*_integration.rb`, `*_scheme.rb`, or `Gemfile.lock`. After this step, tool-solved universes are materialized on disk. 2. **Resolve** — the `Resolver`, per declaration: - - rejects declaration sets where one name carries disagreeing - constraints (axes — group/platform/host/env — may differ; constraints - may not); + - rejects declaration sets where one package (integration + name) + carries disagreeing constraints (axes — group/platform/host/env — + may differ; constraints may not; the same name under two + integrations is two packages, free to differ); - builds the `PackageId` (the constraint's `repo`/`url` becomes the id's source) and calls `find`, passing the constraint hash as the `filter` — a *locator*, not a predicate: pinned ecosystems need the @@ -63,8 +64,15 @@ if a `*_repository.rb`, `*_integration.rb`, `*_scheme.rb`, or → pin metadata), and stamps the declaration's `host`/`env` onto the pin's metadata; - queues the chosen version's `dependencies` edges as synthetic - declarations that inherit the parent's group/host/env. -3. **Write** — pins go to `deps.lock`. + declarations that inherit the parent's group/host/env (edges stay + inside the declaring dep's integration — the resolved set is keyed + by `PackageId`). +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. diff --git a/lib/dev/deps/package_id.rb b/lib/dev/deps/package_id.rb index cb1cd31..d8c13af 100644 --- a/lib/dev/deps/package_id.rb +++ b/lib/dev/deps/package_id.rb @@ -11,7 +11,9 @@ module Deps # want" (DependencyDeclaration) 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. + # 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 From 0191754af4c632c6f939a59a8702848a57783825 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 5 Sep 2026 15:06:49 -0400 Subject: [PATCH 20/37] Drop redundant T.must on untyped Dependency#version Co-authored-by: Cursor --- lib/dev/build_container.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dev/build_container.rb b/lib/dev/build_container.rb index 0bc2edf..61a8455 100644 --- a/lib/dev/build_container.rb +++ b/lib/dev/build_container.rb @@ -242,7 +242,7 @@ def build_contexts_from_lockfile(project_root) 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[dep.name.downcase] = dep.version ? File.join(base, T.must(dep.version).to_s) : base + contexts[dep.name.downcase] = dep.version ? File.join(base, dep.version.to_s) : base end contexts end @@ -281,7 +281,7 @@ def install_dir_versions(project_root) install_dir = dep.metadata&.fetch("install_dir", nil) next unless install_dir && dep.version - acc[File.expand_path(install_dir)] = T.must(dep.version).to_s + acc[File.expand_path(install_dir)] = dep.version.to_s end end From 57483c31c048ccf1bcda50b642ee4f670fb65ade Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 5 Sep 2026 15:16:30 -0400 Subject: [PATCH 21/37] Point the lockfile legacy-read shim at its removal ticket (#146) Co-authored-by: Cursor --- lib/dev/deps/lockfile.rb | 2 +- test/dev/deps/lockfile_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dev/deps/lockfile.rb b/lib/dev/deps/lockfile.rb index 2a03ff7..9fa2039 100644 --- a/lib/dev/deps/lockfile.rb +++ b/lib/dev/deps/lockfile.rb @@ -26,7 +26,7 @@ module Deps # 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. + # repo's lockfiles have been rewritten by update-deps (issue #146). class Lockfile HEADER = <<~COMMENT # Generated by dev. Do not edit. diff --git a/test/dev/deps/lockfile_test.rb b/test/dev/deps/lockfile_test.rb index 4ba1302..d7d86d2 100644 --- a/test/dev/deps/lockfile_test.rb +++ b/test/dev/deps/lockfile_test.rb @@ -284,7 +284,7 @@ class Dev::Deps::LockfileTest < Minitest::Test 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. + # 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) From 138500ca9448afd2288b398185f9065b9acf47b8 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sat, 5 Sep 2026 15:43:42 -0400 Subject: [PATCH 22/37] Add resolve and install sequence diagrams to deps architecture doc Co-authored-by: Cursor --- docs/deps-architecture.md | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/docs/deps-architecture.md b/docs/deps-architecture.md index 1d802f2..ef9b1d7 100644 --- a/docs/deps-architecture.md +++ b/docs/deps-architecture.md @@ -86,6 +86,68 @@ update-deps ─▶ Locker.lock(decls) (bundler: Gemfile.lock appears) install-deps ─▶ Integration.install_all(pins) ``` +### 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 DependencyDeclaration[] + 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) + res->>rep: find(PackageId, filter: constraint) + rep->>backing: query universe (registry API / Gemfile.lock / ls-remote / GraphQL) + backing-->>rep: raw versions, platforms, edges, digests + 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, stamp host/env + Note over res: queue the chosen version's edges as declarations in the same integration + 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 DependencyInstaller + 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 | From 2bd61f13c60ea5da04dfc86c9232ca5ee2d4620a Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 10:56:01 -0400 Subject: [PATCH 23/37] Split DependencyDeclaration into Declaration + Scope + ScopedDeclaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declaration is the shared atom (name, integration, constraint) — the part of a dependency ask that whoever authored it can state. Scope (group, host, env) is the resolution context that rides the resolve walk parent -> child as a unit; ScopedDeclaration composes the two plus the per-row axes (platform, post_install) that deliberately do not inherit. Composition, not subclassing: a scoped declaration must never pass where a context-free Declaration is expected. The resolver now inherits transitive context by copying one Scope object instead of three fields, and attach_install_scoping collapses into Scope#to_metadata. The three new types ride the pre-bundle bootstrap chain (dsl.rb), so they stay sorbet-runtime-free and join the StrictSigil exclusion list. Co-authored-by: Cursor --- .rubocop.yml | 4 +- lib/dev/deps/bundler_locker.rb | 18 +++--- lib/dev/deps/config.rb | 2 +- lib/dev/deps/declaration.rb | 66 ++++++++++++++++++++ lib/dev/deps/dependency_declaration.rb | 40 ------------ lib/dev/deps/dsl.rb | 41 ++++++------- lib/dev/deps/locker.rb | 6 +- lib/dev/deps/package_id.rb | 2 +- lib/dev/deps/resolver.rb | 62 +++++++++---------- lib/dev/deps/scope.rb | 68 +++++++++++++++++++++ lib/dev/deps/scoped_declaration.rb | 78 ++++++++++++++++++++++++ test/dev/deps/cmake_integration_test.rb | 13 ++-- test/dev/deps/config_test.rb | 10 +-- test/dev/deps/declaration_test.rb | 56 +++++++++++++++++ test/dev/deps/dsl_test.rb | 44 ++++++------- test/dev/deps/resolver_test.rb | 14 ++++- test/dev/deps/scope_test.rb | 54 ++++++++++++++++ test/dev/deps/scoped_declaration_test.rb | 74 ++++++++++++++++++++++ 18 files changed, 508 insertions(+), 144 deletions(-) create mode 100644 lib/dev/deps/declaration.rb delete mode 100644 lib/dev/deps/dependency_declaration.rb create mode 100644 lib/dev/deps/scope.rb create mode 100644 lib/dev/deps/scoped_declaration.rb create mode 100644 test/dev/deps/declaration_test.rb create mode 100644 test/dev/deps/scope_test.rb create mode 100644 test/dev/deps/scoped_declaration_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 4e44765..561f43e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -39,13 +39,15 @@ Sorbet/StrictSigil: - lib/dev/deps.rb - lib/dev/deps/cli_ui.rb - lib/dev/deps/config.rb + - lib/dev/deps/declaration.rb - lib/dev/deps/lockfile.rb + - lib/dev/deps/scope.rb + - lib/dev/deps/scoped_declaration.rb - lib/dev/deps/tap.rb - lib/dev/deps/dependency_installer.rb # `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 diff --git a/lib/dev/deps/bundler_locker.rb b/lib/dev/deps/bundler_locker.rb index a8fa0f4..a6516a7 100644 --- a/lib/dev/deps/bundler_locker.rb +++ b/lib/dev/deps/bundler_locker.rb @@ -3,8 +3,8 @@ require "open3" require "pathname" -require_relative "dependency_declaration" require_relative "locker" +require_relative "scoped_declaration" module Dev module Deps @@ -44,10 +44,10 @@ def initialize(project_root:, ruby_version_requirement: nil) # Generate the Gemfile from all gem declarations and lock it. # - # @param declarations [Array] :bundler declarations + # @param declarations [Array] :bundler declarations # @return [void] # @raise [LockError] if bundle lock fails - sig { override.params(declarations: T::Array[DependencyDeclaration]).void } + sig { override.params(declarations: T::Array[ScopedDeclaration]).void } def lock(declarations) return if declarations.empty? @@ -61,17 +61,17 @@ def lock(declarations) # bundler group (the default group stays unscoped, like a hand-written # Gemfile's top section). # - # @param declarations [Array] + # @param declarations [Array] # @return [void] - sig { params(declarations: T::Array[DependencyDeclaration]).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.group == DSL::DEFAULT_GEM_GROUP } + ungrouped, grouped = declarations.partition { |decl| decl.scope.group == DSL::DEFAULT_GEM_GROUP } ungrouped.each { |decl| lines << gem_line(decl) } - grouped.group_by(&:group).each do |group, group_decls| + 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)}" } @@ -84,9 +84,9 @@ def write_gemfile(declarations) # 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] + # @param decl [ScopedDeclaration] # @return [String] - sig { params(decl: DependencyDeclaration).returns(String) } + sig { params(decl: ScopedDeclaration).returns(String) } def gem_line(decl) parts = [%(gem "#{decl.name}")] constraint = decl.constraint diff --git a/lib/dev/deps/config.rb b/lib/dev/deps/config.rb index c3beae3..bb44005 100644 --- a/lib/dev/deps/config.rb +++ b/lib/dev/deps/config.rb @@ -13,7 +13,7 @@ class Config # @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 diff --git a/lib/dev/deps/declaration.rb b/lib/dev/deps/declaration.rb new file mode 100644 index 0000000..07e1cca --- /dev/null +++ b/lib/dev/deps/declaration.rb @@ -0,0 +1,66 @@ +# typed: true +# frozen_string_literal: true + +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. + # + # No sorbet-runtime here: this file rides the dependencies.rb load chain + # (deps.rb -> config.rb -> dsl.rb), which must work under bare Ruby before + # bundler provisions any gem. + # + # See docs/deps-architecture.md for the ontology this belongs to. + class Declaration + # @return [String] the package's name within its integration's universe + attr_reader :name + + # @return [Symbol] the integration whose universe the name lives in + attr_reader :integration + + # @return [Hash{String => Object}] version constraint in dev's shape; + # {} means unconstrained + attr_reader :constraint + + # @param name [String] the package's name + # @param integration [Symbol] :bundler, :ficsit, :cmake, … + # @param constraint [Hash{String => Object}] dev-shaped constraint; + # defaults to {} (unconstrained) + def initialize(name:, integration:, constraint: {}) + @name = name + @integration = integration + @constraint = constraint.dup.freeze + freeze + end + + # @param other [Object] + # @return [Boolean] whether other states the same declaration + def ==(other) + return false unless other.is_a?(Declaration) + + [name, integration, constraint] == [other.name, other.integration, other.constraint] + end + alias_method :eql?, :== + + # @return [Integer] hash code, so declarations work as Hash keys + def hash + [self.class, name, integration, constraint].hash + 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..ab379ed 100644 --- a/lib/dev/deps/dsl.rb +++ b/lib/dev/deps/dsl.rb @@ -1,7 +1,9 @@ # typed: false # frozen_string_literal: true -require_relative "dependency_declaration" +require_relative "declaration" +require_relative "scope" +require_relative "scoped_declaration" module Dev module Deps @@ -67,11 +69,9 @@ def python(version) 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 @@ -141,14 +141,10 @@ 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, + @declarations << ScopedDeclaration.new( + declaration: Declaration.new(name: name_str, integration: :brew, constraint: stringify_keys(opts)), + scope: Scope.new(group: @group, host: @host, env: @env), platform: @platform, - host: @host, - env: @env, ) end @@ -372,12 +368,12 @@ def respond_to_missing?(method_name, include_private = false) 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 @@ -391,13 +387,10 @@ 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:), + scope: Scope.new(group: @group, host:), platform: @platform, - host:, post_install:, ) end diff --git a/lib/dev/deps/locker.rb b/lib/dev/deps/locker.rb index 2c95fe0..771e513 100644 --- a/lib/dev/deps/locker.rb +++ b/lib/dev/deps/locker.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -require_relative "dependency_declaration" +require_relative "scoped_declaration" module Dev module Deps @@ -21,10 +21,10 @@ class Locker # Solve the whole declaration set, materializing the tool's lockfile. # - # @param declarations [Array] every declaration + # @param declarations [Array] every declaration # of this integration type # @return [void] - sig { params(declarations: T::Array[DependencyDeclaration]).void } + sig { params(declarations: T::Array[ScopedDeclaration]).void } def lock(declarations) raise NotImplementedError, "#{self.class}#lock must be implemented" end diff --git a/lib/dev/deps/package_id.rb b/lib/dev/deps/package_id.rb index d8c13af..59f4ea3 100644 --- a/lib/dev/deps/package_id.rb +++ b/lib/dev/deps/package_id.rb @@ -8,7 +8,7 @@ module Deps # # This is the "which package are we talking about" half of the domain, # separated from "which versions exist" (Package), "what does the project - # want" (DependencyDeclaration) and "what did we choose" (Dependency). It + # 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 diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb index 55f3c7d..6f74796 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -1,9 +1,10 @@ # typed: strict # frozen_string_literal: true +require_relative "declaration" 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" @@ -56,12 +57,12 @@ def initialize(repositories:, schemes:) # integration's universe. Transitive edges likewise stay inside the # declaring dep's integration. # - # @param declarations [Array] declared dependencies to resolve + # @param declarations [Array] declared dependencies to resolve # @return [Array] # @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[DependencyDeclaration]).returns(T::Array[Dependency]) } + sig { params(declarations: T::Array[ScopedDeclaration]).returns(T::Array[Dependency]) } def resolve(declarations) reject_conflicts(declarations) @@ -76,17 +77,18 @@ def resolve(declarations) chosen = choose(decl, platforms[[decl.integration, decl.name]] || []) resolved[id] = mint(chosen, decl) - # 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. + # 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. Context is a property of the path, so it is + # stamped here — never by the repository that reported the edge. chosen.dependencies.each do |edge| - edge_decl = DependencyDeclaration.new( - name: edge.name, - integration: decl.integration, - constraint: normalize_constraint(edge.constraint), - group: decl.group, - host: decl.host, - env: decl.env, + edge_decl = ScopedDeclaration.new( + declaration: Declaration.new( + name: edge.name, + integration: decl.integration, + constraint: normalize_constraint(edge.constraint), + ), + scope: decl.scope, ) queue << edge_decl unless resolved.key?(package_id(edge_decl)) end @@ -101,7 +103,7 @@ def resolve(declarations) # highest version that satisfies the constraint (per the integration's # scheme) and publishes every explicitly requested platform. # - # @param decl [DependencyDeclaration] the declaration to satisfy + # @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 @@ -109,7 +111,7 @@ def resolve(declarations) # @raise [NoSatisfyingVersionError] if nothing in the universe qualifies sig do params( - decl: DependencyDeclaration, + decl: ScopedDeclaration, platforms: T::Array[T.nilable(String)], ).returns(PackageVersion) end @@ -182,14 +184,14 @@ def publishes_platforms?(version, explicit) # that expose no version, e.g. brew casks) becomes a nil pin version. # # @param chosen [PackageVersion] the version the resolver picked - # @param decl [DependencyDeclaration] the declaration it satisfies + # @param decl [ScopedDeclaration] the declaration it satisfies # @return [Dependency] - sig { params(chosen: PackageVersion, decl: DependencyDeclaration).returns(Dependency) } + sig { params(chosen: PackageVersion, decl: ScopedDeclaration).returns(Dependency) } def mint(chosen, decl) dependency = Dependency.new( name: decl.name, integration: decl.integration, - group: decl.group, + group: decl.scope.group, version: chosen.version.empty? ? nil : chosen.version, hash: chosen.digest, metadata: chosen.metadata.dup, @@ -202,9 +204,9 @@ def mint(chosen, decl) # the constraint's "repo"/"url" is the source coordinate (which service # to ask), so it rides on the PackageId rather than the filter. # - # @param decl [DependencyDeclaration] + # @param decl [ScopedDeclaration] # @return [PackageId] - sig { params(decl: DependencyDeclaration).returns(PackageId) } + sig { params(decl: ScopedDeclaration).returns(PackageId) } def package_id(decl) PackageId.new( integration: decl.integration, @@ -221,10 +223,10 @@ def package_id(decl) # (Platform, group, host, and env may differ — they are axes, not # constraints.) # - # @param declarations [Array] + # @param declarations [Array] # @return [void] # @raise [ConflictingDeclarationError] - sig { params(declarations: T::Array[DependencyDeclaration]).void } + sig { params(declarations: T::Array[ScopedDeclaration]).void } def reject_conflicts(declarations) declarations.group_by { |d| [d.integration, d.name] }.each do |(integration, name), decls| constraints = decls.map(&:constraint).uniq @@ -243,13 +245,11 @@ def reject_conflicts(declarations) # dep IS; where it installs is resolver/installer plumbing. # # @param dependency [Dependency] freshly minted - # @param decl [DependencyDeclaration] the declaration it came from + # @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)) @@ -261,12 +261,12 @@ def attach_install_scoping(dependency, decl) # 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] + # @param declarations [Array] # @return [Hash{Array(Symbol, String) => Array}] # (integration, name) → de-duped platform list sig do params( - declarations: T::Array[DependencyDeclaration], + declarations: T::Array[ScopedDeclaration], ).returns(T::Hash[[Symbol, String], T::Array[T.nilable(String)]]) end def declared_platforms(declarations) @@ -297,13 +297,13 @@ def normalize_constraint(constraint) # A NoSatisfyingVersionError message that says why: what was asked, # what the universe held. # - # @param decl [DependencyDeclaration] + # @param decl [ScopedDeclaration] # @param package [Package] # @param explicit [Array] explicitly requested platforms # @return [String] sig do params( - decl: DependencyDeclaration, + decl: ScopedDeclaration, package: Package, explicit: T::Array[String], ).returns(String) diff --git a/lib/dev/deps/scope.rb b/lib/dev/deps/scope.rb new file mode 100644 index 0000000..36cdfb4 --- /dev/null +++ b/lib/dev/deps/scope.rb @@ -0,0 +1,68 @@ +# typed: true +# frozen_string_literal: true + +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. + # + # No sorbet-runtime here: this file rides the dependencies.rb load chain, + # which must work under bare Ruby before bundler provisions any gem. + class Scope + # @return [Symbol] purpose the dep was declared for (:app, :test, :build, …) + attr_reader :group + + # @return [Symbol, nil] host OS the dep installs on (:darwin / :linux); + # nil means all hosts + attr_reader :host + + # @return [String, nil] execution context the dep is for ("ci" / "dev"); + # nil means all envs + 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 + def initialize(group: :app, host: nil, env: nil) + @group = group + @host = host&.to_sym + @env = env&.to_s + 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 + def to_metadata + meta = {} + meta["host"] = host.to_s if host + meta["env"] = env if env + meta + end + + # @param other [Object] + # @return [Boolean] whether other is the same context + 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 + 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..bdd175e --- /dev/null +++ b/lib/dev/deps/scoped_declaration.rb @@ -0,0 +1,78 @@ +# typed: true +# frozen_string_literal: true + +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 and post_install 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), and hooks run only for + # the row that declared them. + # + # No sorbet-runtime here: this file rides the dependencies.rb load chain, + # which must work under bare Ruby before bundler provisions any gem. + class ScopedDeclaration + # @return [Declaration] the ask: name + integration + constraint + attr_reader :declaration + + # @return [Scope] the context the ask resolves under + attr_reader :scope + + # @return [String, nil] artifact variant this row targets (e.g. + # "LinuxServer"); nil lets the integration pick its default + attr_reader :platform + + # @return [Proc, Array, nil] callable(s) run after the dep is + # fetched; never serialized to the lockfile + attr_reader :post_install + + # @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) + def initialize(declaration:, scope: Scope.new, platform: nil, post_install: nil) + @declaration = declaration + @scope = scope + @platform = platform + @post_install = post_install + freeze + end + + # @return [String] the ask's package name (delegated) + def name = declaration.name + + # @return [Symbol] the ask's integration (delegated) + def integration = declaration.integration + + # @return [Hash{String => Object}] the ask's constraint (delegated) + def constraint = declaration.constraint + + # @param other [Object] + # @return [Boolean] whether other is the same ask under the same context + def ==(other) + return false unless other.is_a?(ScopedDeclaration) + + [declaration, scope, platform, post_install] == + [other.declaration, other.scope, other.platform, other.post_install] + end + alias_method :eql?, :== + + # @return [Integer] hash code + def hash + [self.class, declaration, scope, platform, post_install].hash + end + end + end +end diff --git a/test/dev/deps/cmake_integration_test.rb b/test/dev/deps/cmake_integration_test.rb index 3be6ff1..b1c0740 100644 --- a/test/dev/deps/cmake_integration_test.rb +++ b/test/dev/deps/cmake_integration_test.rb @@ -6,7 +6,9 @@ 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" @@ -337,9 +339,12 @@ def prepopulate_dep(root, name) schemes: { cmake: Dev::Deps::PinnedScheme.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, + constraint: { "repo" => "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..d3b792f 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 @@ -130,7 +130,7 @@ class Dev::Deps::ConfigTest < Minitest::Test end Then - decls = config.declarations.select { |d| d.group == :app } + decls = config.declarations.select { |d| d.scope.group == :app } decls.size == 2 decls[0].name == "boost" @@ -202,10 +202,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.all? { |d| d.scope.group == :build } brew_decls.find { |d| d.name == "powershell" }.constraint["tap"] == "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..767f832 --- /dev/null +++ b/test/dev/deps/declaration_test.rb @@ -0,0 +1,56 @@ +# 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 +end diff --git a/test/dev/deps/dsl_test.rb b/test/dev/deps/dsl_test.rb index 5eb149a..73ab3e5 100644 --- a/test/dev/deps/dsl_test.rb +++ b/test/dev/deps/dsl_test.rb @@ -6,7 +6,7 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::DSLTest < Minitest::Test - test "cmake() produces DependencyDeclaration with cmake integration" do + test "cmake() produces ScopedDeclaration with cmake integration" do When "defining a cmake dep" config = Dev::Deps.define do group :app do @@ -21,7 +21,7 @@ class Dev::Deps::DSLTest < Minitest::Test decls.size == 1 decls[0].name == "boost" decls[0].integration == :cmake - decls[0].group == :app + decls[0].scope.group == :app decls[0].constraint["url"] == "https://example.com/boost.tar.gz" decls[0].constraint["tag"] == "boost-1.90.0" end @@ -52,7 +52,7 @@ class Dev::Deps::DSLTest < Minitest::Test config.declarations[0].constraint["repo"] == "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 +64,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 +110,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 +122,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 @@ -166,7 +166,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 +197,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 +215,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,7 +234,7 @@ 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 @@ -251,7 +251,7 @@ class Dev::Deps::DSLTest < Minitest::Test decl.name == "xcode" decl.integration == :xcode decl.constraint["version"] == "26.1.1" - decl.group == :build + decl.scope.group == :build end test "env block stamps env as a first-class field, not a constraint key" do @@ -266,12 +266,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 @@ -286,7 +286,7 @@ class Dev::Deps::DSLTest < Minitest::Test decl = config.declarations[0] decl.name == "UnrealEngine" decl.integration == :gh - decl.group == :build + decl.scope.group == :build decl.constraint["repo"] == "satisfactorymodding/UnrealEngine" decl.constraint["tag"] == "5.6.1-css-83" decl.constraint["assets"] == "UnrealEngine-CSS-Editor-Linux.tar.zst.*" @@ -309,7 +309,7 @@ class Dev::Deps::DSLTest < Minitest::Test decl = config.declarations[0] decl.name == "UnrealEngine" decl.integration == :gh - decl.group == :game + decl.scope.group == :game decl.constraint["repo"] == "EpicGames/UnrealEngine" decl.constraint["tag"] == "5.6.1-release" decl.constraint["build"] == "bin/build-ue.sh" @@ -356,7 +356,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 @@ -368,7 +368,7 @@ class Dev::Deps::DSLTest < Minitest::Test 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" @@ -442,8 +442,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 +456,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/resolver_test.rb b/test/dev/deps/resolver_test.rb index e665238..535f238 100644 --- a/test/dev/deps/resolver_test.rb +++ b/test/dev/deps/resolver_test.rb @@ -7,8 +7,10 @@ require "dev/deps/package" require "dev/deps/package_id" require "dev/deps/package_version" +require "dev/deps/declaration" require "dev/deps/dependency_edge" -require "dev/deps/dependency_declaration" +require "dev/deps/scope" +require "dev/deps/scoped_declaration" require "dev/deps/pinned_scheme" require "dev/deps/semver_scheme" @@ -45,8 +47,14 @@ def edge(name, constraint) Dev::Deps::DependencyEdge.new(name: name, constraint: constraint) end - def declaration(**kwargs) - Dev::Deps::DependencyDeclaration.new(**kwargs) + # Shorthand: assemble the Declaration + Scope composition from flat kwargs. + def declaration(name:, integration:, constraint: {}, group: :app, platform: nil, + host: nil, env: nil, post_install: nil) + Dev::Deps::ScopedDeclaration.new( + declaration: Dev::Deps::Declaration.new(name:, integration:, constraint:), + scope: Dev::Deps::Scope.new(group:, host:, env:), + platform:, post_install:, + ) end def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) 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..1a3aea1 --- /dev/null +++ b/test/dev/deps/scoped_declaration_test.rb @@ -0,0 +1,74 @@ +# 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" 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? + 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 From 8bca137074ca62a5836a47498184fc5c00c2099d Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 11:01:08 -0400 Subject: [PATCH 24/37] Kill DependencyEdge: version facts reuse Declaration, normalized at the seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PackageVersion's declared dependencies are now Declarations — the same shared atom the project side wraps in ScopedDeclarations. The reporting repository stamps the integration (ficsit mods require ficsit mods) and normalizes the upstream constraint syntax into dev's shape at construction, so constraints cross the system boundary exactly once. That finishes Resolver#normalize_constraint's job at the right layer: the resolver now receives finished Declarations and only stamps the walk context (Scope) onto them, deleting the raw-constraint case analysis. Co-authored-by: Cursor --- lib/dev/deps/dependency_edge.rb | 58 ------------------------- lib/dev/deps/ficsit_repository.rb | 19 +++++++- lib/dev/deps/package_version.rb | 14 +++--- lib/dev/deps/resolver.rb | 39 ++++------------- test/dev/deps/dependency_edge_test.rb | 43 ------------------ test/dev/deps/ficsit_repository_test.rb | 4 +- test/dev/deps/package_version_test.rb | 4 +- test/dev/deps/resolver_test.rb | 11 +++-- 8 files changed, 46 insertions(+), 146 deletions(-) delete mode 100644 lib/dev/deps/dependency_edge.rb delete mode 100644 test/dev/deps/dependency_edge_test.rb diff --git a/lib/dev/deps/dependency_edge.rb b/lib/dev/deps/dependency_edge.rb deleted file mode 100644 index ebe3630..0000000 --- a/lib/dev/deps/dependency_edge.rb +++ /dev/null @@ -1,58 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -module Dev - module Deps - # An outgoing dependency edge of a specific PackageVersion: "this version - # requires that name, under this constraint". - # - # Edges are facts about a *version*, not about a chosen pin, which is why - # they hang off PackageVersion. The constraint stays exactly as the backing - # service reported it (a string like ">= 1.0", a hash, or nothing at all); - # normalizing it into dev's constraint shape is the Resolver's job, since - # only the Resolver knows the requirement vocabulary it will hand to the - # integration's VersionScheme. - class DependencyEdge - extend T::Sig - - # @return [String] the required package's name, within the same universe - sig { returns(String) } - attr_reader :name - - # @return [Hash, String, nil] the raw constraint as reported upstream - sig { returns(T.nilable(T.any(String, T::Hash[String, T.untyped]))) } - attr_reader :constraint - - # @param name [String] the required package's name - # @param constraint [Hash, String, nil] raw upstream constraint, or nil - # when the edge pins nothing - sig do - params( - name: String, - constraint: T.nilable(T.any(String, T::Hash[String, T.untyped])), - ).void - end - def initialize(name:, constraint:) - @name = name - @constraint = constraint - freeze - end - - # @param other [Object] - # @return [Boolean] whether other is the same edge - sig { params(other: T.untyped).returns(T::Boolean) } - def ==(other) - return false unless other.is_a?(DependencyEdge) - - [name, constraint] == [other.name, other.constraint] - end - alias_method :eql?, :== - - # @return [Integer] hash code - sig { returns(Integer) } - def hash - [self.class, name, constraint].hash - end - end - end -end diff --git a/lib/dev/deps/ficsit_repository.rb b/lib/dev/deps/ficsit_repository.rb index d7280b6..6e83b62 100644 --- a/lib/dev/deps/ficsit_repository.rb +++ b/lib/dev/deps/ficsit_repository.rb @@ -5,7 +5,7 @@ require "net/http" require "uri" require_relative "artifact" -require_relative "dependency_edge" +require_relative "declaration" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -126,11 +126,26 @@ def package_version(mod_data, version_data, filter) end, dependencies: (version_data["dependencies"] || []) .reject { |d| d["optional"] } - .map { |d| DependencyEdge.new(name: d["mod_id"], constraint: d["condition"]) }, + .map { |d| edge_declaration(d) }, metadata: metadata, ) 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 + # The {hash, link} block for each requested platform this version # publishes. Non-raising: a missing target simply isn't in the block — # whether that disqualifies the version is the Resolver's call. diff --git a/lib/dev/deps/package_version.rb b/lib/dev/deps/package_version.rb index 96af9e9..a9dc3d7 100644 --- a/lib/dev/deps/package_version.rb +++ b/lib/dev/deps/package_version.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require_relative "artifact" -require_relative "dependency_edge" +require_relative "declaration" module Dev module Deps @@ -45,8 +45,10 @@ class PackageVersion sig { returns(T::Hash[String, Artifact]) } attr_reader :artifacts - # @return [Array] what this version requires - sig { returns(T::Array[DependencyEdge]) } + # @return [Array] what this version declares it requires, + # already normalized into dev's constraint shape and stamped with its + # integration by the reporting Repository + sig { returns(T::Array[Declaration]) } attr_reader :dependencies # @return [Hash{String => Object}] ecosystem-specific facts the @@ -59,7 +61,7 @@ class PackageVersion # @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 dependencies [Array] outgoing edges + # @param dependencies [Array] normalized declared deps # @param metadata [Hash{String => Object}] ecosystem-specific install facts sig do params( @@ -67,7 +69,7 @@ class PackageVersion platforms: T::Array[String], digest: T.nilable(String), artifacts: T::Hash[String, Artifact], - dependencies: T::Array[DependencyEdge], + dependencies: T::Array[Declaration], metadata: T::Hash[String, T.untyped], ).void end @@ -76,7 +78,7 @@ def initialize(version:, platforms: [], digest: nil, artifacts: {}, dependencies @platforms = T.let(platforms.dup.freeze, T::Array[String]) @digest = digest @artifacts = T.let(artifacts.dup.freeze, T::Hash[String, Artifact]) - @dependencies = T.let(dependencies.dup.freeze, T::Array[DependencyEdge]) + @dependencies = T.let(dependencies.dup.freeze, T::Array[Declaration]) @metadata = T.let(metadata.dup.freeze, T::Hash[String, T.untyped]) freeze end diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb index 6f74796..6ba3c3c 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -54,8 +54,9 @@ def initialize(repositories:, schemes:) # # 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 edges likewise stay inside the - # declaring dep's integration. + # 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 # @return [Array] @@ -79,17 +80,12 @@ def resolve(declarations) # 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. Context is a property of the path, so it is - # stamped here — never by the repository that reported the edge. + # closure anywhere else. The Declaration itself 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. chosen.dependencies.each do |edge| - edge_decl = ScopedDeclaration.new( - declaration: Declaration.new( - name: edge.name, - integration: decl.integration, - constraint: normalize_constraint(edge.constraint), - ), - scope: decl.scope, - ) + edge_decl = ScopedDeclaration.new(declaration: edge, scope: decl.scope) queue << edge_decl unless resolved.key?(package_id(edge_decl)) end end @@ -275,25 +271,6 @@ def declared_platforms(declarations) result.transform_values(&:uniq) end - # Normalize a transitive edge constraint to a declaration constraint - # hash. Edges may express constraints as a bare string (ficsit's - # "^3.12.0"), which maps to the ecosystem's "version" key. - # - # @param constraint [Hash, String, nil] raw constraint from a DependencyEdge - # @return [Hash] - 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 - end - # A NoSatisfyingVersionError message that says why: what was asked, # what the universe held. # diff --git a/test/dev/deps/dependency_edge_test.rb b/test/dev/deps/dependency_edge_test.rb deleted file mode 100644 index a88f362..0000000 --- a/test/dev/deps/dependency_edge_test.rb +++ /dev/null @@ -1,43 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "dev/deps/dependency_edge" - -transform!(RSpock::AST::Transformation) -class Dev::Deps::DependencyEdgeTest < Minitest::Test - test "keeps a string constraint exactly as upstream reported it" do - Given "an edge from a service that expresses constraints as strings" - edge = Dev::Deps::DependencyEdge.new(name: "lua", constraint: ">= 5.1") - - Expect "no normalization happens here — that is the Resolver's job" - edge.name == "lua" - edge.constraint == ">= 5.1" - end - - test "keeps a hash constraint exactly as upstream reported it" do - Given "an edge from a service that expresses constraints as hashes" - edge = Dev::Deps::DependencyEdge.new(name: "lpeg", constraint: { "version" => "~> 1.0" }) - - Expect - edge.constraint == { "version" => "~> 1.0" } - end - - test "an edge can pin nothing" do - Given "an unconstrained edge" - edge = Dev::Deps::DependencyEdge.new(name: "openssl", constraint: nil) - - Expect - edge.constraint.nil? - end - - test "is value-equal" do - Given "two edges with the same name and constraint" - a = Dev::Deps::DependencyEdge.new(name: "lua", constraint: ">= 5.1") - b = Dev::Deps::DependencyEdge.new(name: "lua", constraint: ">= 5.1") - - Expect - a == b - a.hash == b.hash - end -end diff --git a/test/dev/deps/ficsit_repository_test.rb b/test/dev/deps/ficsit_repository_test.rb index 94688df..ae9208a 100644 --- a/test/dev/deps/ficsit_repository_test.rb +++ b/test/dev/deps/ficsit_repository_test.rb @@ -57,7 +57,9 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test latest.digest == "SHA256=deadbeef" latest.artifacts["Windows"].uri == "https://api.ficsit.app/v1/version/ver2/Windows/download" latest.artifacts["Windows"].digest == "SHA256=deadbeef" - latest.dependencies == [Dev::Deps::DependencyEdge.new(name: "SML", constraint: "^3.12.0")] + latest.dependencies == [ + Dev::Deps::Declaration.new(name: "SML", integration: :ficsit, constraint: { "version" => "^3.12.0" }), + ] latest.metadata["mod_id"] == "abc123" latest.metadata["game_version"] == ">=491125" latest.metadata["target"] == "Windows" diff --git a/test/dev/deps/package_version_test.rb b/test/dev/deps/package_version_test.rb index 3b944f9..e6cafd5 100644 --- a/test/dev/deps/package_version_test.rb +++ b/test/dev/deps/package_version_test.rb @@ -34,7 +34,7 @@ class Dev::Deps::PackageVersionTest < Minitest::Test test "carries the full fact set when the universe provides one" do Given "a version with platforms, digest, per-platform artifacts, and edges" artifact = Dev::Deps::Artifact.new(uri: "https://example.com/sml-linux.zip", digest: "SHA256=abc") - edge = Dev::Deps::DependencyEdge.new(name: "SML", constraint: "^3.0.0") + 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"], @@ -56,7 +56,7 @@ class Dev::Deps::PackageVersionTest < Minitest::Test version: "1.0.0", platforms: ["Windows"], artifacts: { "Windows" => Dev::Deps::Artifact.new(uri: "https://example.com/a.zip") }, - dependencies: [Dev::Deps::DependencyEdge.new(name: "x", constraint: nil)], + dependencies: [Dev::Deps::Declaration.new(name: "x", integration: :ficsit)], ) Expect "none of them can be mutated after the fact" diff --git a/test/dev/deps/resolver_test.rb b/test/dev/deps/resolver_test.rb index 535f238..99d89b1 100644 --- a/test/dev/deps/resolver_test.rb +++ b/test/dev/deps/resolver_test.rb @@ -8,7 +8,6 @@ require "dev/deps/package_id" require "dev/deps/package_version" require "dev/deps/declaration" -require "dev/deps/dependency_edge" require "dev/deps/scope" require "dev/deps/scoped_declaration" require "dev/deps/pinned_scheme" @@ -43,8 +42,14 @@ def version(v, digest: nil, platforms: [], dependencies: [], metadata: {}) ) end - def edge(name, constraint) - Dev::Deps::DependencyEdge.new(name: name, constraint: constraint) + # 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 # Shorthand: assemble the Declaration + Scope composition from flat kwargs. From 2335616947b8ca96c243abc28de981974947cca5 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 11:02:17 -0400 Subject: [PATCH 25/37] Add Declarations: a sealed sum type for a version's declared-deps claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare array can't say which regime the claim was made under — [] collapses 'affirmatively requires nothing' into 'the tool owns a closure dev never sees'. Declarations::Resolved([Declaration...]) and Declarations::ToolOwned make both states representable; sealed! so consumers can case-and-T.absurd. The claim will travel with the data: each Repository constructs the variant its regime warrants, making construction the dispatch (no resolver guard, no registry attribute). Wired into PackageVersion in the next commit. Co-authored-by: Cursor --- lib/dev/deps/declarations.rb | 93 ++++++++++++++++++++++++++++ test/dev/deps/declarations_test.rb | 98 ++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 lib/dev/deps/declarations.rb create mode 100644 test/dev/deps/declarations_test.rb 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/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 From 11c7ced9460bdaa7101c025476194baace8dd124 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 11:06:17 -0400 Subject: [PATCH 26/37] PackageVersion#dependencies becomes #declarations, a Declarations claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field now returns the sum type instead of a bare array, defaulting to the affirmative Resolved([]) — ToolOwned must be stated explicitly. The resolver walk cases on the variant: Resolved walks its declarations under the parent's Scope, ToolOwned walks nothing (the tool owns the closure), T.absurd seals the case. No guard, no rescue: construction is the dispatch. The empty-forms doc paragraph is finally honest — 'declares nothing requires nothing' is now true because tool-owned closures can no longer hide inside the empty form. Co-authored-by: Cursor --- lib/dev/deps/ficsit_repository.rb | 13 ++++++---- lib/dev/deps/package_version.rb | 34 +++++++++++++++---------- lib/dev/deps/resolver.rb | 22 +++++++++++----- test/dev/deps/ficsit_repository_test.rb | 4 +-- test/dev/deps/luarocks_registry_test.rb | 2 +- test/dev/deps/package_version_test.rb | 28 ++++++++++++++------ test/dev/deps/pip_repository_test.rb | 2 +- test/dev/deps/resolver_test.rb | 25 +++++++++++++++--- 8 files changed, 89 insertions(+), 41 deletions(-) diff --git a/lib/dev/deps/ficsit_repository.rb b/lib/dev/deps/ficsit_repository.rb index 6e83b62..6da8859 100644 --- a/lib/dev/deps/ficsit_repository.rb +++ b/lib/dev/deps/ficsit_repository.rb @@ -6,6 +6,7 @@ require "uri" require_relative "artifact" require_relative "declaration" +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -57,7 +58,7 @@ class ModNotFoundError < PackageNotFoundError; end # # Each version carries its targets as platforms, each target's download # as an Artifact (dev-enforced integrity: the SHA256 the API publishes), - # its required mod dependencies as edges, and the install facts + # its required mods as a Resolved declarations claim, and the install facts # FicsitIntegration reads (mod_id, game_version, and either a # single-target digest or a per-platform block, per the filter). # @@ -81,7 +82,7 @@ def find(id, filter: {}) # Map one GraphQL version object to a PackageVersion. # - # Universe facts (platforms, artifacts, edges) are unconditional. The + # Universe facts (platforms, artifacts, declarations) are unconditional. The # install facts mirror the pin shapes FicsitIntegration reads: with # requested platforms, a metadata["platforms"] block covering the # targets this version actually publishes (the Resolver rejects the @@ -124,9 +125,11 @@ def package_version(mod_data, version_data, filter) artifacts: targets.to_h do |t| [t["targetName"], Artifact.new(uri: download_url(version_data, t), digest: "SHA256=#{t["hash"]}")] end, - dependencies: (version_data["dependencies"] || []) - .reject { |d| d["optional"] } - .map { |d| edge_declaration(d) }, + declarations: Declarations::Resolved.new( + (version_data["dependencies"] || []) + .reject { |d| d["optional"] } + .map { |d| edge_declaration(d) }, + ), metadata: metadata, ) end diff --git a/lib/dev/deps/package_version.rb b/lib/dev/deps/package_version.rb index a9dc3d7..55f82a3 100644 --- a/lib/dev/deps/package_version.rb +++ b/lib/dev/deps/package_version.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require_relative "artifact" -require_relative "declaration" +require_relative "declarations" module Dev module Deps @@ -18,7 +18,10 @@ module Deps # 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 with no edges requires nothing. See + # 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 @@ -45,11 +48,12 @@ class PackageVersion sig { returns(T::Hash[String, Artifact]) } attr_reader :artifacts - # @return [Array] what this version declares it requires, - # already normalized into dev's constraint shape and stamped with its - # integration by the reporting Repository - sig { returns(T::Array[Declaration]) } - attr_reader :dependencies + # @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 @@ -61,7 +65,8 @@ class PackageVersion # @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 dependencies [Array] normalized declared deps + # @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( @@ -69,16 +74,17 @@ class PackageVersion platforms: T::Array[String], digest: T.nilable(String), artifacts: T::Hash[String, Artifact], - dependencies: T::Array[Declaration], + declarations: Declarations, metadata: T::Hash[String, T.untyped], ).void end - def initialize(version:, platforms: [], digest: nil, artifacts: {}, dependencies: [], metadata: {}) + 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]) - @dependencies = T.let(dependencies.dup.freeze, T::Array[Declaration]) + @declarations = declarations @metadata = T.let(metadata.dup.freeze, T::Hash[String, T.untyped]) freeze end @@ -89,16 +95,16 @@ def initialize(version:, platforms: [], digest: nil, artifacts: {}, dependencies def ==(other) return false unless other.is_a?(PackageVersion) - [version, platforms, digest, artifacts, dependencies, metadata] == + [version, platforms, digest, artifacts, declarations, metadata] == [other.version, other.platforms, other.digest, other.artifacts, - other.dependencies, other.metadata] + other.declarations, other.metadata] end alias_method :eql?, :== # @return [Integer] hash code sig { returns(Integer) } def hash - [self.class, version, platforms, digest, artifacts, dependencies, metadata].hash + [self.class, version, platforms, digest, artifacts, declarations, metadata].hash end end end diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb index 6ba3c3c..72b495e 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require_relative "declaration" +require_relative "declarations" require_relative "dependency" require_relative "package" require_relative "scoped_declaration" @@ -80,13 +81,20 @@ def resolve(declarations) # 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. The Declaration itself 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. - chosen.dependencies.each do |edge| - edge_decl = ScopedDeclaration.new(declaration: edge, scope: decl.scope) - queue << edge_decl unless resolved.key?(package_id(edge_decl)) + # 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 diff --git a/test/dev/deps/ficsit_repository_test.rb b/test/dev/deps/ficsit_repository_test.rb index ae9208a..c7a76b4 100644 --- a/test/dev/deps/ficsit_repository_test.rb +++ b/test/dev/deps/ficsit_repository_test.rb @@ -57,9 +57,9 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test latest.digest == "SHA256=deadbeef" latest.artifacts["Windows"].uri == "https://api.ficsit.app/v1/version/ver2/Windows/download" latest.artifacts["Windows"].digest == "SHA256=deadbeef" - latest.dependencies == [ + 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" latest.metadata["game_version"] == ">=491125" latest.metadata["target"] == "Windows" diff --git a/test/dev/deps/luarocks_registry_test.rb b/test/dev/deps/luarocks_registry_test.rb index c96b1bf..4c2ad03 100644 --- a/test/dev/deps/luarocks_registry_test.rb +++ b/test/dev/deps/luarocks_registry_test.rb @@ -24,7 +24,7 @@ class Dev::Deps::LuaRocksRepositoryTest < Minitest::Test 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").dependencies == [] + package.version("3.5-1").declarations == Dev::Deps::Declarations::Resolved.new([]) end test "find raises RockNotFoundError, a PackageNotFoundError, on empty search" do diff --git a/test/dev/deps/package_version_test.rb b/test/dev/deps/package_version_test.rb index e6cafd5..37cf0ee 100644 --- a/test/dev/deps/package_version_test.rb +++ b/test/dev/deps/package_version_test.rb @@ -2,23 +2,37 @@ # 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 edge facts" + 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 — empty collections and nil digest" + 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.dependencies == [] + 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( @@ -32,7 +46,7 @@ class Dev::Deps::PackageVersionTest < Minitest::Test end test "carries the full fact set when the universe provides one" do - Given "a version with platforms, digest, per-platform artifacts, and edges" + 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( @@ -40,14 +54,14 @@ class Dev::Deps::PackageVersionTest < Minitest::Test platforms: ["Windows", "LinuxServer"], digest: "SHA256=fff", artifacts: { "LinuxServer" => artifact }, - dependencies: [edge], + declarations: Dev::Deps::Declarations::Resolved.new([edge]), ) Expect version.platforms == ["Windows", "LinuxServer"] version.digest == "SHA256=fff" version.artifacts["LinuxServer"] == artifact - version.dependencies == [edge] + version.declarations == Dev::Deps::Declarations::Resolved.new([edge]) end test "collection facts are frozen at construction" do @@ -56,13 +70,11 @@ class Dev::Deps::PackageVersionTest < Minitest::Test version: "1.0.0", platforms: ["Windows"], artifacts: { "Windows" => Dev::Deps::Artifact.new(uri: "https://example.com/a.zip") }, - dependencies: [Dev::Deps::Declaration.new(name: "x", integration: :ficsit)], ) Expect "none of them can be mutated after the fact" version.platforms.frozen? version.artifacts.frozen? - version.dependencies.frozen? end test "mutating the arrays it was built from cannot change it" do diff --git a/test/dev/deps/pip_repository_test.rb b/test/dev/deps/pip_repository_test.rb index fb1aad8..d5239ba 100644 --- a/test/dev/deps/pip_repository_test.rb +++ b/test/dev/deps/pip_repository_test.rb @@ -33,7 +33,7 @@ class Dev::Deps::PipRepositoryTest < Minitest::Test 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").dependencies == [] + package.version("2.0.5").declarations == Dev::Deps::Declarations::Resolved.new([]) end test "find raises ProjectNotFoundError, a PackageNotFoundError, on 404" do diff --git a/test/dev/deps/resolver_test.rb b/test/dev/deps/resolver_test.rb index 99d89b1..0119f1c 100644 --- a/test/dev/deps/resolver_test.rb +++ b/test/dev/deps/resolver_test.rb @@ -8,6 +8,7 @@ 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/pinned_scheme" @@ -34,11 +35,14 @@ def find(id, filter: {}) transform!(RSpock::AST::Transformation) class Dev::Deps::ResolverTest < Minitest::Test - # Shorthand: a PackageVersion universe entry. - def version(v, digest: nil, platforms: [], dependencies: [], metadata: {}) + # 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: {}, + declarations: nil) Dev::Deps::PackageVersion.new( version: v, digest: digest, platforms: platforms, - dependencies: dependencies, metadata: metadata, + declarations: declarations || Dev::Deps::Declarations::Resolved.new(dependencies), + metadata: metadata, ) end @@ -344,6 +348,21 @@ def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) result.size == 2 end + 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 and resolves a duplicated dep once" do Given "SML declared in :app (no platform) and :integration (LinuxServer)" repo = StubRepository.new(universes: { From 70375a10dbf83768ed3347e19490fe546eca6fe0 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 11:08:57 -0400 Subject: [PATCH 27/37] Every repository states its transitive regime by construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the ten repositories now constructs the Declarations variant its regime warrants — construction is the dispatch, so no guard or enum exists: - ficsit: Resolved(normalized, integration-stamped declarations) - bundler, pip, luarocks, brew: ToolOwned — the ecosystem's tool resolves the closure. bundler's comment records that Gemfile.lock is read for pinned versions only, never mined for dependency declarations. - steam, git, xcode, url: Resolved([]) — self-contained by construction. - gh: Resolved([]) — prebuilt assets by guarantee; source builds as a usage contract (the consumer declares transitive needs) until subproject resolution lands. Co-authored-by: Cursor --- lib/dev/deps/brew_repository.rb | 12 +++++++++++- lib/dev/deps/bundler_repository.rb | 12 +++++++++++- lib/dev/deps/gh_repository.rb | 8 ++++++++ lib/dev/deps/git_repository.rb | 11 ++++++++++- lib/dev/deps/luarocks_repository.rb | 6 +++++- lib/dev/deps/pip_repository.rb | 8 +++++++- lib/dev/deps/steam_repository.rb | 4 ++++ lib/dev/deps/url_repository.rb | 4 ++++ lib/dev/deps/xcode_repository.rb | 7 ++++++- test/dev/deps/bundler_repository_test.rb | 1 + test/dev/deps/luarocks_registry_test.rb | 2 +- test/dev/deps/pip_repository_test.rb | 2 +- 12 files changed, 69 insertions(+), 8 deletions(-) diff --git a/lib/dev/deps/brew_repository.rb b/lib/dev/deps/brew_repository.rb index d58452b..5e1c3f3 100644 --- a/lib/dev/deps/brew_repository.rb +++ b/lib/dev/deps/brew_repository.rb @@ -3,6 +3,7 @@ require "json" require "open3" +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -45,7 +46,14 @@ def find(id, filter: {}) metadata["version_suffix"] = version_suffix if version_suffix return Package.new( id: id, - versions: [PackageVersion.new(version: UNVERSIONED, metadata: metadata)], + versions: [ + PackageVersion.new( + version: UNVERSIONED, + metadata: metadata, + # brew installs formula dependencies itself. + declarations: Declarations::ToolOwned.new, + ), + ], ) end @@ -63,6 +71,8 @@ def find(id, filter: {}) version: info["versions"]["stable"], digest: bottle_hash ? "SHA256=#{bottle_hash}" : nil, metadata: metadata, + # brew installs formula dependencies itself. + declarations: Declarations::ToolOwned.new, ), ], ) diff --git a/lib/dev/deps/bundler_repository.rb b/lib/dev/deps/bundler_repository.rb index 215543b..0b00f14 100644 --- a/lib/dev/deps/bundler_repository.rb +++ b/lib/dev/deps/bundler_repository.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "pathname" +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -48,7 +49,16 @@ def find(id, filter: {}) Package.new( id: id, - versions: [PackageVersion.new(version: T.must(pin[:version]), digest: pin[:hash])], + 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 diff --git a/lib/dev/deps/gh_repository.rb b/lib/dev/deps/gh_repository.rb index 1cd3410..b3a35c6 100644 --- a/lib/dev/deps/gh_repository.rb +++ b/lib/dev/deps/gh_repository.rb @@ -3,6 +3,7 @@ require "json" require "open3" +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -95,6 +96,9 @@ def prebuilt_version(repo_slug, tag, filter) "install_dir" => filter["install_dir"], "assets" => assets.map { |asset| asset_metadata(asset) }, }, + # Prebuilt release assets are self-contained: whatever they needed + # was baked in at build time. + declarations: Declarations::Resolved.new([]), ) end @@ -121,6 +125,10 @@ def source_version(repo_slug, tag, filter) "build" => filter["build"], "commit" => resolve_commit_sha(repo_slug, tag), }, + # Usage contract, not a guarantee: 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 diff --git a/lib/dev/deps/git_repository.rb b/lib/dev/deps/git_repository.rb index e43b527..088979b 100644 --- a/lib/dev/deps/git_repository.rb +++ b/lib/dev/deps/git_repository.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "open3" +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -38,7 +39,15 @@ def find(id, filter: {}) Package.new( id: id, - versions: [PackageVersion.new(version: sha, metadata: { "repo" => repo_url })], + versions: [ + PackageVersion.new( + version: sha, + metadata: { "repo" => repo_url }, + # A checked-out source tree carries no manifest dev reads; + # consumers declare what they need alongside it. + declarations: Declarations::Resolved.new([]), + ), + ], ) end diff --git a/lib/dev/deps/luarocks_repository.rb b/lib/dev/deps/luarocks_repository.rb index 89ac7c0..e4ddcd5 100644 --- a/lib/dev/deps/luarocks_repository.rb +++ b/lib/dev/deps/luarocks_repository.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "open3" +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -38,7 +39,10 @@ def find(id, filter: {}) Package.new( id: id, - versions: versions.map { |version| PackageVersion.new(version: version) }, + versions: versions.map do |version| + # luarocks resolves rock dependencies itself at install time. + PackageVersion.new(version: version, declarations: Declarations::ToolOwned.new) + end, ) end diff --git a/lib/dev/deps/pip_repository.rb b/lib/dev/deps/pip_repository.rb index c032cd2..c0ce5ad 100644 --- a/lib/dev/deps/pip_repository.rb +++ b/lib/dev/deps/pip_repository.rb @@ -4,6 +4,7 @@ require "json" require "net/http" require "uri" +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -43,7 +44,12 @@ class ApiError < StandardError; end def find(id, filter: {}) releases = project_json(id.name)["releases"] || {} versions = releases.map do |version, files| - PackageVersion.new(version: version, digest: release_digest(files)) + # pip resolves the transitive tree itself at install time. + PackageVersion.new( + version: version, + digest: release_digest(files), + declarations: Declarations::ToolOwned.new, + ) end Package.new(id: id, versions: versions) diff --git a/lib/dev/deps/steam_repository.rb b/lib/dev/deps/steam_repository.rb index d12578c..bbc79ce 100644 --- a/lib/dev/deps/steam_repository.rb +++ b/lib/dev/deps/steam_repository.rb @@ -1,6 +1,7 @@ # typed: strict # frozen_string_literal: true +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -56,6 +57,9 @@ def find(id, filter: {}) "install_dir" => filter["install_dir"], "platform" => steam_platform_for(filter["platforms"]), }, + # Steam depots are self-contained by construction: SteamCMD + # delivers the complete installed tree. + declarations: Declarations::Resolved.new([]), ), ], ) diff --git a/lib/dev/deps/url_repository.rb b/lib/dev/deps/url_repository.rb index 3f0bd03..2b93f6b 100644 --- a/lib/dev/deps/url_repository.rb +++ b/lib/dev/deps/url_repository.rb @@ -5,6 +5,7 @@ require "open3" require "tempfile" require_relative "artifact" +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -47,6 +48,9 @@ def find(id, filter: {}) digest: digest, artifacts: { "default" => Artifact.new(uri: url, digest: digest) }, metadata: { "url" => url, "downloaded_path" => path }, + # A downloaded archive is self-contained: its contents are the + # whole dependency. + declarations: Declarations::Resolved.new([]), ), ], ) diff --git a/lib/dev/deps/xcode_repository.rb b/lib/dev/deps/xcode_repository.rb index dc87c05..8855f9a 100644 --- a/lib/dev/deps/xcode_repository.rb +++ b/lib/dev/deps/xcode_repository.rb @@ -1,6 +1,7 @@ # typed: strict # frozen_string_literal: true +require_relative "declarations" require_relative "package" require_relative "package_id" require_relative "package_version" @@ -34,7 +35,11 @@ def find(id, filter: {}) version = filter["version"].to_s raise MissingVersionError, "xcode requires an exact version (e.g. xcode \"26.1.1\")" if version.empty? - Package.new(id: id, versions: [PackageVersion.new(version: version)]) + # An Xcode install is self-contained: Apple ships the whole toolchain. + Package.new( + id: id, + versions: [PackageVersion.new(version: version, declarations: Declarations::Resolved.new([]))], + ) end end end diff --git a/test/dev/deps/bundler_repository_test.rb b/test/dev/deps/bundler_repository_test.rb index e338b41..9edd089 100644 --- a/test/dev/deps/bundler_repository_test.rb +++ b/test/dev/deps/bundler_repository_test.rb @@ -41,6 +41,7 @@ class Dev::Deps::BundlerRepositoryTest < Minitest::Test 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) diff --git a/test/dev/deps/luarocks_registry_test.rb b/test/dev/deps/luarocks_registry_test.rb index 4c2ad03..7092c4b 100644 --- a/test/dev/deps/luarocks_registry_test.rb +++ b/test/dev/deps/luarocks_registry_test.rb @@ -24,7 +24,7 @@ class Dev::Deps::LuaRocksRepositoryTest < Minitest::Test 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::Resolved.new([]) + package.version("3.5-1").declarations == Dev::Deps::Declarations::ToolOwned.new end test "find raises RockNotFoundError, a PackageNotFoundError, on empty search" do diff --git a/test/dev/deps/pip_repository_test.rb b/test/dev/deps/pip_repository_test.rb index d5239ba..4af8ce7 100644 --- a/test/dev/deps/pip_repository_test.rb +++ b/test/dev/deps/pip_repository_test.rb @@ -33,7 +33,7 @@ class Dev::Deps::PipRepositoryTest < Minitest::Test 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::Resolved.new([]) + package.version("2.0.5").declarations == Dev::Deps::Declarations::ToolOwned.new end test "find raises ProjectNotFoundError, a PackageNotFoundError, on 404" do From ff29709acdf99ec4e9e0a78126f4da3d6a448f42 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 11:10:52 -0400 Subject: [PATCH 28/37] Rename DependencyInstaller to Installer Inside Dev::Deps the word 'Dependency' is the module's subject; as a prefix it carries no information. Mechanical rename: file, class, requires, the install-deps command factory, and the rules-file mention. Docs get their full ontology rewrite in the next commit. Co-authored-by: Cursor --- .cursor/rules/separation-of-concerns.mdc | 2 +- .rubocop.yml | 2 +- bin/install-build-deps.rb | 2 +- lib/dev/deps.rb | 2 +- .../{dependency_installer.rb => installer.rb} | 2 +- src/dev/builtins/install_deps_command.rb | 6 +++--- test/dev/builtins/install_deps_command_test.rb | 6 +++--- ...dency_installer_test.rb => installer_test.rb} | 16 ++++++++-------- 8 files changed, 19 insertions(+), 19 deletions(-) rename lib/dev/deps/{dependency_installer.rb => installer.rb} (99%) rename test/dev/deps/{dependency_installer_test.rb => installer_test.rb} (93%) 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/.rubocop.yml b/.rubocop.yml index 561f43e..6f4fd46 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -44,7 +44,7 @@ Sorbet/StrictSigil: - lib/dev/deps/scope.rb - lib/dev/deps/scoped_declaration.rb - lib/dev/deps/tap.rb - - lib/dev/deps/dependency_installer.rb + - lib/dev/deps/installer.rb # `typed: false` holdouts: Data.define with a keyword-args initialize # override is rejected by Sorbet (error 4010)... - lib/dev/deps/dependency.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/lib/dev/deps.rb b/lib/dev/deps.rb index 5bcea30..36e82ea 100644 --- a/lib/dev/deps.rb +++ b/lib/dev/deps.rb @@ -5,7 +5,7 @@ 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 diff --git a/lib/dev/deps/dependency_installer.rb b/lib/dev/deps/installer.rb similarity index 99% rename from lib/dev/deps/dependency_installer.rb rename to lib/dev/deps/installer.rb index 472961a..b0fe131 100644 --- a/lib/dev/deps/dependency_installer.rb +++ b/lib/dev/deps/installer.rb @@ -7,7 +7,7 @@ module Deps # # Cross-cutting install concerns (env filtering, build-first ordering) # live here — not in Integration or Lockfile. - class DependencyInstaller + class Installer # @param lockfile [Lockfile] lockfile reader # @param integrations [Hash{Symbol => Integration}] integration type → integration def initialize(lockfile:, integrations:) 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/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/deps/dependency_installer_test.rb b/test/dev/deps/installer_test.rb similarity index 93% rename from test/dev/deps/dependency_installer_test.rb rename to test/dev/deps/installer_test.rb index a525b19..6228f12 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 }, ) @@ -72,7 +72,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 +102,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 +134,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 +165,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 +190,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 From 34f48894e186bfd6ad314a79a495839eabd27fdc Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 11:12:36 -0400 Subject: [PATCH 29/37] Docs: rewrite the deps ontology and add the transitive-regimes reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ontology table grows to five ideas (Declaration joins as the shared atom; ScopedDeclaration replaces DependencyDeclaration as the requirement) plus a compact aggregate diagram of intent/universe/pin. New sections: the constraint standard (dev-shaped hash minted at the repository seam, interpreted by the integration's VersionScheme — schemes widen, vocabularies never translate) and the transitive-dependency regimes table with the two standing decisions (lock files are never availability facts; Resolved([]) and ToolOwned are different claims). Sequence diagrams renamed to the new types, with the transitive queueing wrapped in an opt fragment; the ASCII pipeline block is gone (it repeated the diagrams with stale shapes). The new-ecosystem recipe now tells a repository to state its regime by construction and normalize constraints at find. Co-authored-by: Cursor --- .cursor/rules/strong-types.mdc | 2 +- docs/deps-architecture.md | 126 ++++++++++++++++++++++++--------- 2 files changed, 94 insertions(+), 34 deletions(-) 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/docs/deps-architecture.md b/docs/deps-architecture.md index ef9b1d7..3d17a42 100644 --- a/docs/deps-architecture.md +++ b/docs/deps-architecture.md @@ -7,19 +7,43 @@ integrity works per ecosystem, and what to build when adding a new one. ## Ontology -Four ideas, kept strictly apart. Each has one class, and no class plays +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-based deps (a git URL, a `owner/repo` slug). Value object, works as a Hash key. | -| Universe | `Package` → `PackageVersion` | What exists: every version a repository reports, each carrying facts — `platforms`, `digest`, `artifacts` (dev-fetched bytes), `dependencies` (edges), and `metadata` (ecosystem install facts). | -| Requirement | `DependencyDeclaration` | What the user asked for: name, integration, constraint hash, and the install axes (`group`, `platform`, `host`, `env`). | +| 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 install facts). | +| Declaration | `Declaration` | The shared atom: name + integration + constraint, always in dev's shape (`{}` = unconstrained). 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`). 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), `DependencyEdge` (an outgoing requirement of a -`PackageVersion`, constraint left in the ecosystem's native syntax). +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 @@ -61,12 +85,13 @@ if a `*_repository.rb`, `*_integration.rb`, `*_scheme.rb`, or explicitly requested platform; - picks the highest satisfying version (`sort`), mints the `Dependency` from that version's facts (digest → pin hash, metadata - → pin metadata), and stamps the declaration's `host`/`env` onto the - pin's metadata; - - queues the chosen version's `dependencies` edges as synthetic - declarations that inherit the parent's group/host/env (edges stay - inside the declaring dep's integration — the resolved set is keyed - by `PackageId`). + → pin metadata), 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 @@ -77,15 +102,6 @@ if a `*_repository.rb`, `*_integration.rb`, `*_scheme.rb`, or `dev install-deps` reads the lockfile and hands each integration its pins; no resolution happens at install time. -``` -update-deps ─▶ Locker.lock(decls) (bundler: Gemfile.lock appears) - ─▶ Resolver.resolve(decls) - ├─▶ Repository.find(id, filter) ─▶ Package{PackageVersion…} - ├─▶ VersionScheme.satisfies?/sort (choice) - └─▶ Dependency (pin) ─▶ deps.lock -install-deps ─▶ Integration.install_all(pins) -``` - ### Resolution flow (`dev update-deps`) ```mermaid @@ -99,7 +115,7 @@ sequenceDiagram participant sch as VersionScheme (per integration) participant lock as Lockfile - Note over cmd: load dependencies.rb into DependencyDeclaration[] + 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 @@ -109,12 +125,15 @@ sequenceDiagram loop until queue empty (declared + transitive) res->>rep: find(PackageId, filter: constraint) rep->>backing: query universe (registry API / Gemfile.lock / ls-remote / GraphQL) - backing-->>rep: raw versions, platforms, edges, digests + 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, stamp host/env - Note over res: queue the chosen version's edges as declarations in the same integration + 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, project Scope onto metadata + 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) @@ -127,7 +146,7 @@ sequenceDiagram sequenceDiagram participant up as install command participant st as Staleness - participant inst as DependencyInstaller + participant inst as Installer participant lock as Lockfile participant integ as Integration (per type) participant tool as Backing tool @@ -158,6 +177,17 @@ command. | luarocks | `RockScheme` | rockspec-style comparators and `~>` | | brew, cmake, gh, steam, xcode | `PinnedScheme` | the constraint names an identity (formula suffix, tag/commit, release tag, buildid, exact version); the repository already applied it as the find locator, so every reported version satisfies | +**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 @@ -185,14 +215,44 @@ Who guarantees the bytes you install are the bytes that were resolved: 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, filter:) -> Package`. Report facts for every version you can enumerate; if the ecosystem's constraint names an identity, use the filter as your locator and report the (usually singleton) universe. - Raise a subclass of `Repository::PackageNotFoundError` when the - identity doesn't exist. Never pick a version. + 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** — if the ecosystem has a native range language, subclass `VersionScheme` with its `satisfies?`/`sort`, nesting `InvalidConstraintError`/`InvalidVersionError` under the shared bases. @@ -229,9 +289,9 @@ 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 *edge facts* (dependency -metadata in `find`) plus a backtracking solver in the Resolver — the -interfaces already accommodate both (`PackageVersion#dependencies` is the -slot). No interface change is expected; the cost is per-ecosystem edge -enumeration and solver work, so pay it per ecosystem when the need is -real, not up front. +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. From 49113cf7d803d90e11c44c4b7c12e08ce158aaef Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 11:45:48 -0400 Subject: [PATCH 30/37] Restore typed: strict across the deps bootstrap chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'stdlib-only pre-bundle chain' constraint was self-imposed: only bin/test.rb and bin/tc.rb loaded dependencies.rb before bundler/setup, and that early load served no purpose (EnsureBundler self-loads it, post- setup). Every real pathway — bin scripts, the dev CLI, docker's vendored keg gems — has sorbet-runtime available. - Drop the pre-bundle load of dependencies.rb from bin/test.rb, bin/tc.rb, and bin/rbi.rb; the chain now always loads with gems active. - typed: strict with full sigs: deps.rb, cli_ui.rb, config.rb, dsl.rb, declaration.rb, scope.rb, scoped_declaration.rb, tap.rb, lockfile.rb, installer.rb, ensure_bundler.rb. - ensure_bundler.rb becomes module EnsureBundler (top-level defs can't carry sigs); error nested as EnsureBundler::BundlerInstallError. - Tap's Data.define-synthesized readers get sigs via an RBI shim; the now-visible nilability of Tap#url fixed properly in brew_integration. - Sorbet/StrictSigil exclusions shrink from 13 files to 2 genuine holdouts (dependency.rb: Data.define kwargs-initialize, error 4010; fetcher.rb: consumer-repo Lockfile API). Co-authored-by: Cursor --- .rubocop.yml | 20 +-- bin/rbi.rb | 3 +- bin/setup.rb | 2 +- bin/tc.rb | 4 +- bin/test.rb | 4 +- dependencies.rb | 8 +- lib/dev/deps.rb | 18 ++- lib/dev/deps/brew_integration.rb | 12 +- lib/dev/deps/cli_ui.rb | 22 +++- lib/dev/deps/config.rb | 49 +++++++- lib/dev/deps/declaration.rb | 18 ++- lib/dev/deps/dsl.rb | 188 ++++++++++++++++++++++++++--- lib/dev/deps/installer.rb | 13 +- lib/dev/deps/lockfile.rb | 53 +++++++- lib/dev/deps/scope.rb | 34 ++++-- lib/dev/deps/scoped_declaration.rb | 25 +++- lib/dev/deps/tap.rb | 9 +- lib/ensure_bundler.rb | 53 +++++--- sorbet/rbi/shims/data-define.rbi | 16 +++ test/lib/ensure_bundler_test.rb | 26 ++-- 20 files changed, 455 insertions(+), 122 deletions(-) create mode 100644 sorbet/rbi/shims/data-define.rbi diff --git a/.rubocop.yml b/.rubocop.yml index 6f4fd46..edc35ce 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -30,27 +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/declaration.rb - - lib/dev/deps/lockfile.rb - - lib/dev/deps/scope.rb - - lib/dev/deps/scoped_declaration.rb - - lib/dev/deps/tap.rb - - lib/dev/deps/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 - # ...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/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/lib/dev/deps.rb b/lib/dev/deps.rb index 36e82ea..d43c404 100644 --- a/lib/dev/deps.rb +++ b/lib/dev/deps.rb @@ -1,6 +1,7 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require_relative "deps/config" require_relative "deps/cli_ui" require_relative "deps/lockfile" @@ -9,9 +10,16 @@ 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/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/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 bb44005..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,8 +9,35 @@ 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" => {...} } @@ -19,6 +47,17 @@ class Config # @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 index 07e1cca..dd4b9c0 100644 --- a/lib/dev/deps/declaration.rb +++ b/lib/dev/deps/declaration.rb @@ -1,6 +1,8 @@ -# typed: true +# 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 @@ -21,35 +23,38 @@ module Deps # never nil. Repositories normalize upstream syntax into this shape at # construction, so constraints cross the system boundary exactly once. # - # No sorbet-runtime here: this file rides the dependencies.rb load chain - # (deps.rb -> config.rb -> dsl.rb), which must work under bare Ruby before - # bundler provisions any gem. - # # See docs/deps-architecture.md for the ontology this belongs to. class Declaration + extend T::Sig + # @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 # @param name [String] the package's name # @param integration [Symbol] :bundler, :ficsit, :cmake, … # @param constraint [Hash{String => Object}] dev-shaped constraint; # defaults to {} (unconstrained) + sig { params(name: String, integration: Symbol, constraint: T::Hash[String, T.untyped]).void } def initialize(name:, integration:, constraint: {}) @name = name @integration = integration - @constraint = constraint.dup.freeze + @constraint = T.let(constraint.dup.freeze, T::Hash[String, T.untyped]) 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) @@ -58,6 +63,7 @@ def ==(other) alias_method :eql?, :== # @return [Integer] hash code, so declarations work as Hash keys + sig { returns(Integer) } def hash [self.class, name, integration, constraint].hash end diff --git a/lib/dev/deps/dsl.rb b/lib/dev/deps/dsl.rb index ab379ed..54f6e42 100644 --- a/lib/dev/deps/dsl.rb +++ b/lib/dev/deps/dsl.rb @@ -1,6 +1,7 @@ -# typed: false +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require_relative "declaration" require_relative "scope" require_relative "scoped_declaration" @@ -9,22 +10,54 @@ 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 @@ -35,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 @@ -42,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 @@ -53,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 @@ -66,6 +105,8 @@ 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 @@ -75,6 +116,12 @@ def gem(name, version = nil, **opts) ) 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,41 @@ 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. + # + # @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? @@ -148,12 +224,17 @@ def brew(name, **opts) ) 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 @@ -161,21 +242,33 @@ 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 + # @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 @@ -183,8 +276,10 @@ def initialize(group:, platform: nil, host: nil, registered_methods: []) # # @param name [String, Symbol] dependency name # @param spec [Hash] options (tag:, repo:, url:, github:, etc.) + # @return [void] + sig { params(name: T.any(String, Symbol), spec: T.untyped).void } def cmake(name, **spec) - spec = expand_github(name, spec) + spec = expand_github(name.to_s, spec) add_declaration(name, :cmake, spec) end @@ -193,6 +288,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) @@ -203,6 +300,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) @@ -215,6 +314,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) @@ -225,6 +326,8 @@ def pip(name, version = nil, **spec) # @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) @@ -253,9 +356,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, @@ -281,6 +397,16 @@ 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) @@ -291,6 +417,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 @@ -305,6 +433,8 @@ def custom(name, integration:, **spec) # # @param version [String, Symbol] exact Xcode version (e.g. "26.1.1") # @param spec [Hash] additional options + # @return [void] + 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) @@ -320,6 +450,8 @@ def xcode(version, **spec) # # @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? @@ -336,6 +468,10 @@ def brew(name, **opts) # 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) @@ -344,6 +480,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 @@ -354,14 +492,20 @@ 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 @@ -378,6 +522,8 @@ def respond_to_missing?(method_name, include_private = false) # @param name [String, Symbol] dependency name # @param integration [Symbol] integration type # @param spec [Hash] constraint spec (symbol keys → stringified) + # @return [void] + sig { params(name: T.any(String, Symbol), integration: Symbol, spec: T::Hash[Symbol, T.untyped]).void } def add_declaration(name, integration, spec) name_str = name.to_s raise EmptyNameError, "dependency name cannot be empty" if name_str.empty? @@ -403,6 +549,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 @@ -415,6 +562,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/installer.rb b/lib/dev/deps/installer.rb index b0fe131..285da91 100644 --- a/lib/dev/deps/installer.rb +++ b/lib/dev/deps/installer.rb @@ -1,6 +1,8 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" + module Dev module Deps # Reads locked dependencies and dispatches to integrations. @@ -8,8 +10,11 @@ module Deps # Cross-cutting install concerns (env filtering, build-first ordering) # live here — not in Integration or Lockfile. 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 @@ -51,6 +58,8 @@ def install(env: nil, host: nil) # Dispatch deps to their matching integrations, grouped by type. # # @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] @@ -64,6 +73,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 +88,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/lockfile.rb b/lib/dev/deps/lockfile.rb index 9fa2039..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" @@ -28,6 +29,8 @@ module Deps # 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. @@ -43,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. @@ -52,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 } @@ -65,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? @@ -80,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) @@ -93,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 @@ -100,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? @@ -115,8 +136,9 @@ def read_lockfile(path) # # @param deps [Array] # @return [Hash] + sig { params(deps: T::Array[Dependency]).returns(T::Hash[String, T.untyped]) } def deps_to_yaml_hash(deps) - result = {} + 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) @@ -129,8 +151,9 @@ def deps_to_yaml_hash(deps) # # @param dep [Dependency] # @return [Hash] + sig { params(dep: Dependency).returns(T::Hash[String, T.untyped]) } def dep_to_hash(dep) - h = { "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? @@ -139,8 +162,9 @@ def dep_to_hash(dep) # @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) @@ -166,6 +190,13 @@ def yaml_hash_to_deps(yaml) # @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") @@ -178,6 +209,14 @@ def entry_to_deps(key, value, env: nil) # 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 @@ -197,6 +236,8 @@ def hash_to_dep(name, attrs, integration: nil, env: nil) # 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") } @@ -204,7 +245,7 @@ def write_build_lockfile(deps) 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"] section = (env_section[env_name] ||= {})[dep.integration.to_s] ||= {} diff --git a/lib/dev/deps/scope.rb b/lib/dev/deps/scope.rb index 36cdfb4..928fc18 100644 --- a/lib/dev/deps/scope.rb +++ b/lib/dev/deps/scope.rb @@ -1,6 +1,8 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" + module Dev module Deps # The resolution context a declaration is scoped under: which group asked @@ -13,28 +15,37 @@ module Deps # 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. - # - # No sorbet-runtime here: this file rides the dependencies.rb load chain, - # which must work under bare Ruby before bundler provisions any gem. 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 = host&.to_sym - @env = env&.to_s + @host = T.let(host&.to_sym, T.nilable(Symbol)) + @env = T.let(env&.to_s, T.nilable(String)) freeze end @@ -43,15 +54,19 @@ def initialize(group: :app, host: nil, env: nil) # 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 = {} - meta["host"] = host.to_s if host - meta["env"] = env if env + 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) @@ -60,6 +75,7 @@ def ==(other) 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 diff --git a/lib/dev/deps/scoped_declaration.rb b/lib/dev/deps/scoped_declaration.rb index bdd175e..1519c25 100644 --- a/lib/dev/deps/scoped_declaration.rb +++ b/lib/dev/deps/scoped_declaration.rb @@ -1,6 +1,7 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require_relative "declaration" require_relative "scope" @@ -20,28 +21,39 @@ module Deps # are per-row and do not inherit: platforms union per package across the # declaring groups (Resolver#declared_platforms), and hooks run only for # the row that declared them. - # - # No sorbet-runtime here: this file rides the dependencies.rb load chain, - # which must work under bare Ruby before bundler provisions any gem. class ScopedDeclaration + extend T::Sig + # @return [Declaration] the ask: name + integration + constraint + 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 # @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) + sig do + params( + declaration: Declaration, + scope: Scope, + platform: T.nilable(String), + post_install: T.nilable(T.any(Proc, T::Array[Proc])), + ).void + end def initialize(declaration:, scope: Scope.new, platform: nil, post_install: nil) @declaration = declaration @scope = scope @@ -51,16 +63,20 @@ def initialize(declaration:, scope: Scope.new, platform: nil, post_install: nil) 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 # @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) @@ -70,6 +86,7 @@ def ==(other) alias_method :eql?, :== # @return [Integer] hash code + sig { returns(Integer) } def hash [self.class, declaration, scope, platform, post_install].hash 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/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/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 From 808af757c997e6c816e655814c88af2e7e51fb67 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 11:51:33 -0400 Subject: [PATCH 31/37] Cover the remote-tap branch and CliUI availability memo Both lines were restructured by the strict-sigil pass and had no test: register_tap's remote-URL branch and CliUI.available?'s memoized return. Co-authored-by: Cursor --- test/dev/deps/brew_integration_test.rb | 20 ++++++++++++++++++++ test/dev/deps/cli_ui_test.rb | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 test/dev/deps/cli_ui_test.rb 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/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 From 38bb5b24dd2d6dd79b0cc525ac7b2639745c7dc1 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 14:24:10 -0400 Subject: [PATCH 32/37] Give Declaration a source field and ScopedDeclaration a materialization field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source is identity-shaping and legitimately statable by both authors of the atom (project rows and repository-reported manifest edges — cargo-style git deps), so it lives on Declaration and will feed PackageId#source. Install instructions (install_dir, asset globs, build recipes) are consumer-side and non-inherited, so they live on ScopedDeclaration next to platform and post_install — never on the shared atom, where they would be a structurally vacuous field for every upstream edge. Both participate in value equality so disagreeing sources or install dirs stay loud conflicts. Co-authored-by: Cursor --- lib/dev/deps/declaration.rb | 32 ++++++++++++++++++--- lib/dev/deps/scoped_declaration.rb | 36 ++++++++++++++++++------ test/dev/deps/declaration_test.rb | 30 ++++++++++++++++++++ test/dev/deps/scoped_declaration_test.rb | 36 +++++++++++++++++++++++- 4 files changed, 120 insertions(+), 14 deletions(-) diff --git a/lib/dev/deps/declaration.rb b/lib/dev/deps/declaration.rb index dd4b9c0..f6ba3ef 100644 --- a/lib/dev/deps/declaration.rb +++ b/lib/dev/deps/declaration.rb @@ -22,6 +22,13 @@ module Deps # { "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. # # See docs/deps-architecture.md for the ontology this belongs to. class Declaration @@ -40,15 +47,31 @@ class Declaration 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 + # @param name [String] the package's name # @param integration [Symbol] :bundler, :ficsit, :cmake, … # @param constraint [Hash{String => Object}] dev-shaped constraint; # defaults to {} (unconstrained) - sig { params(name: String, integration: Symbol, constraint: T::Hash[String, T.untyped]).void } - def initialize(name:, integration:, constraint: {}) + # @param source [String, nil] source coordinate; defaults to nil + sig do + params( + name: String, + integration: Symbol, + constraint: T::Hash[String, T.untyped], + source: T.nilable(String), + ).void + end + def initialize(name:, integration:, constraint: {}, source: nil) @name = name @integration = integration @constraint = T.let(constraint.dup.freeze, T::Hash[String, T.untyped]) + @source = source freeze end @@ -58,14 +81,15 @@ def initialize(name:, integration:, constraint: {}) def ==(other) return false unless other.is_a?(Declaration) - [name, integration, constraint] == [other.name, other.integration, other.constraint] + [name, integration, constraint, source] == + [other.name, other.integration, other.constraint, other.source] end alias_method :eql?, :== # @return [Integer] hash code, so declarations work as Hash keys sig { returns(Integer) } def hash - [self.class, name, integration, constraint].hash + [self.class, name, integration, constraint, source].hash end end end diff --git a/lib/dev/deps/scoped_declaration.rb b/lib/dev/deps/scoped_declaration.rb index 1519c25..75b5e57 100644 --- a/lib/dev/deps/scoped_declaration.rb +++ b/lib/dev/deps/scoped_declaration.rb @@ -17,14 +17,17 @@ module Deps # expected (the facts side of the domain), and value equality across an # inheritance boundary is a trap. # - # platform and post_install 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), and hooks run only for - # the row that declared them. + # 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 + # @return [Declaration] the ask: name + integration + constraint + source sig { returns(Declaration) } attr_reader :declaration @@ -42,23 +45,34 @@ class ScopedDeclaration 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) + 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 @@ -74,21 +88,25 @@ def integration = declaration.integration 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 + # @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] == - [other.declaration, other.scope, other.platform, other.post_install] + [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].hash + [self.class, declaration, scope, platform, post_install, materialization].hash end end end diff --git a/test/dev/deps/declaration_test.rb b/test/dev/deps/declaration_test.rb index 767f832..7580924 100644 --- a/test/dev/deps/declaration_test.rb +++ b/test/dev/deps/declaration_test.rb @@ -53,4 +53,34 @@ class Dev::Deps::DeclarationTest < Minitest::Test 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 end diff --git a/test/dev/deps/scoped_declaration_test.rb b/test/dev/deps/scoped_declaration_test.rb index 1a3aea1..426409e 100644 --- a/test/dev/deps/scoped_declaration_test.rb +++ b/test/dev/deps/scoped_declaration_test.rb @@ -35,7 +35,7 @@ def atom(name: "boost", integration: :cmake, constraint: {}) scoped.constraint == { "version" => "^3.6" } end - test "defaults to the default scope, no platform, no hook" do + 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) @@ -43,6 +43,40 @@ def atom(name: "boost", integration: :cmake, constraint: {}) 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 "is value-equal across independently built compositions" do From 84f0570b938f1fe57bf9dd847545e3482236eb2f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 14:28:03 -0400 Subject: [PATCH 33/37] Make VersionScheme fact-aware and give it a pin extraction hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit satisfies? now takes the whole PackageVersion: some ecosystems' constraints match version facts rather than the version string (a Steam branch, the git ref a SHA resolved from, a brew formula suffix). Range schemes read only version.version. The new #pin extracts the exact coordinate a constraint pins, for the Resolver to pass to Repository#find as the probe — the access path for universes that cannot enumerate. Extraction lives on the scheme because constraint keys are the scheme's vocabulary; the raw constraint hash itself will stop reaching repositories in the next commit. Co-authored-by: Cursor --- lib/dev/deps/gem_scheme.rb | 6 +++--- lib/dev/deps/pep440_scheme.rb | 7 ++++--- lib/dev/deps/pinned_scheme.rb | 4 ++-- lib/dev/deps/resolver.rb | 2 +- lib/dev/deps/rock_scheme.rb | 6 +++--- lib/dev/deps/semver_scheme.rb | 6 +++--- lib/dev/deps/version_scheme.rb | 26 ++++++++++++++++++++++++-- test/dev/deps/gem_scheme_test.rb | 13 +++++++++---- test/dev/deps/pep440_scheme_test.rb | 11 ++++++++--- test/dev/deps/pinned_scheme_test.rb | 13 +++++++++---- test/dev/deps/rock_scheme_test.rb | 11 ++++++++--- test/dev/deps/semver_scheme_test.rb | 11 ++++++++--- test/dev/deps/version_scheme_test.rb | 10 +++++++++- 13 files changed, 91 insertions(+), 35 deletions(-) diff --git a/lib/dev/deps/gem_scheme.rb b/lib/dev/deps/gem_scheme.rb index bbec702..68f077c 100644 --- a/lib/dev/deps/gem_scheme.rb +++ b/lib/dev/deps/gem_scheme.rb @@ -23,18 +23,18 @@ class InvalidVersionError < VersionScheme::InvalidVersionError; end # positional requirement lands under "version"). CONSTRAINT_KEY = "version" - # @param version [String] a gem version string + # @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: String, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } + 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)) + requirement(expression).satisfied_by?(gem_version(version.version)) end # @param versions [Array] gem version strings diff --git a/lib/dev/deps/pep440_scheme.rb b/lib/dev/deps/pep440_scheme.rb index 092a9dc..aca0476 100644 --- a/lib/dev/deps/pep440_scheme.rb +++ b/lib/dev/deps/pep440_scheme.rb @@ -46,17 +46,18 @@ class InvalidVersionError < VersionScheme::InvalidVersionError; end T::Hash[String, Integer], ) - # @param version [String] a PEP 440 version + # @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: String, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } + 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? - expression.split(",").map(&:strip).all? { |term| term_satisfied?(version, term) } + version_string = version.version + expression.split(",").map(&:strip).all? { |term| term_satisfied?(version_string, term) } end # @param versions [Array] PEP 440 versions diff --git a/lib/dev/deps/pinned_scheme.rb b/lib/dev/deps/pinned_scheme.rb index 714b631..04aa19f 100644 --- a/lib/dev/deps/pinned_scheme.rb +++ b/lib/dev/deps/pinned_scheme.rb @@ -18,10 +18,10 @@ module Deps class PinnedScheme < VersionScheme extend T::Sig - # @param version [String] any reported version + # @param version [PackageVersion] any reported version # @param constraint [Hash] ignored — already applied by the repository # @return [Boolean] always true - sig { override.params(version: String, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } + sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } def satisfies?(version, constraint) true end diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb index 72b495e..7603596 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -164,7 +164,7 @@ def choose(decl, platforms) ).returns(T::Boolean) end def satisfies?(scheme, version, constraint) - scheme.satisfies?(version.version, constraint) + scheme.satisfies?(version, constraint) rescue VersionScheme::InvalidVersionError false end diff --git a/lib/dev/deps/rock_scheme.rb b/lib/dev/deps/rock_scheme.rb index cfbd039..f730a5b 100644 --- a/lib/dev/deps/rock_scheme.rb +++ b/lib/dev/deps/rock_scheme.rb @@ -32,17 +32,17 @@ class InvalidVersionError < VersionScheme::InvalidVersionError; end VERSION_PATTERN = /\A(\d+(?:\.\d+)*)(?:-(\d+))?\z/ TERM_PATTERN = /\A(~>|>=|<=|==|>|<|=)?\s*(\d\S*)\z/ - # @param version [String] a luarocks version ("3.4-1") + # @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: String, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } + 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) + key = comparison_key(version.version) expression.split(",").map(&:strip).all? { |term| term_satisfied?(key, term) } end diff --git a/lib/dev/deps/semver_scheme.rb b/lib/dev/deps/semver_scheme.rb index 2c8fc73..a60a802 100644 --- a/lib/dev/deps/semver_scheme.rb +++ b/lib/dev/deps/semver_scheme.rb @@ -31,17 +31,17 @@ class InvalidVersionError < VersionScheme::InvalidVersionError; end # ("^3", ">=1.2"); missing segments are zero. TERM_PATTERN = /\A(\^|~|>=|<=|>|<|=)?(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)\z/ - # @param version [String] a semver version + # @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: String, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } + 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) + key = comparison_key(version.version) terms(expression).all? { |term| term_satisfied?(key, term) } end diff --git a/lib/dev/deps/version_scheme.rb b/lib/dev/deps/version_scheme.rb index a0f575e..6a382aa 100644 --- a/lib/dev/deps/version_scheme.rb +++ b/lib/dev/deps/version_scheme.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require_relative "package_version" + module Dev module Deps # Per-integration constraint semantics — a domain service, deliberately @@ -35,10 +37,15 @@ class InvalidVersionError < StandardError; end # Does one version satisfy the constraint, under this ecosystem's syntax # and comparison rules? # - # @param version [String] a version string in this ecosystem's vocabulary + # 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: String, constraint: T::Hash[String, T.untyped]).returns(T::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 @@ -51,6 +58,21 @@ def satisfies?(version, constraint) def sort(versions) raise NotImplementedError, "#{self.class}#sort must be implemented" end + + # The exact version coordinate this constraint pins, if any — the + # Resolver passes it to Repository#find as the probe, the access path + # for universes that cannot enumerate (a git commit, a brew @suffix + # formula). nil for range constraints and for enumerable ecosystems, + # whose schemes never override this. Extraction lives on the scheme + # because the constraint keys are the scheme's vocabulary; the raw + # constraint hash itself never reaches a Repository. + # + # @param constraint [Hash] the declaration's constraint hash + # @return [String, nil] the pinned coordinate, or nil + sig { params(constraint: T::Hash[String, T.untyped]).returns(T.nilable(String)) } + def pin(constraint) + nil + end end end end diff --git a/test/dev/deps/gem_scheme_test.rb b/test/dev/deps/gem_scheme_test.rb index e180470..0a8806b 100644 --- a/test/dev/deps/gem_scheme_test.rb +++ b/test/dev/deps/gem_scheme_test.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "test_helper" +require "dev/deps/package_version" require "dev/deps/gem_scheme" transform!(RSpock::AST::Transformation) @@ -10,9 +11,13 @@ 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?(version, { "version" => requirement }) + result = scheme.satisfies?(pv(version), { "version" => requirement }) Then result == expected @@ -30,8 +35,8 @@ def scheme test "an empty constraint is satisfied by anything" do Expect "no version requirement means unconstrained" - scheme.satisfies?("1.17.4", {}) - scheme.satisfies?("1.17.4", { "require" => false }) + 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 @@ -55,7 +60,7 @@ def scheme test "rejects a requirement rubygems cannot parse" do When "evaluating a malformed requirement" - scheme.satisfies?("1.0.0", { "version" => ">>>= nope" }) + scheme.satisfies?(pv("1.0.0"), { "version" => ">>>= nope" }) Then raises Dev::Deps::GemScheme::InvalidConstraintError diff --git a/test/dev/deps/pep440_scheme_test.rb b/test/dev/deps/pep440_scheme_test.rb index 66cb9fb..9d6377e 100644 --- a/test/dev/deps/pep440_scheme_test.rb +++ b/test/dev/deps/pep440_scheme_test.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "test_helper" +require "dev/deps/package_version" require "dev/deps/pep440_scheme" transform!(RSpock::AST::Transformation) @@ -10,9 +11,13 @@ 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?(version, { "version" => requirement }) + result = scheme.satisfies?(pv(version), { "version" => requirement }) Then result == expected @@ -44,7 +49,7 @@ def scheme test "an empty constraint is satisfied by anything" do Expect - scheme.satisfies?("2.0.5", {}) + scheme.satisfies?(pv("2.0.5"), {}) end test "sorts by PEP 440 ordering: dev < pre < release < post" do @@ -73,7 +78,7 @@ def scheme test "rejects a specifier it cannot parse" do When "evaluating a malformed specifier" - scheme.satisfies?("2.0", { "version" => "=>2.0" }) + scheme.satisfies?(pv("2.0"), { "version" => "=>2.0" }) Then raises Dev::Deps::Pep440Scheme::InvalidConstraintError diff --git a/test/dev/deps/pinned_scheme_test.rb b/test/dev/deps/pinned_scheme_test.rb index c96e7ea..b0d4a3d 100644 --- a/test/dev/deps/pinned_scheme_test.rb +++ b/test/dev/deps/pinned_scheme_test.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "test_helper" +require "dev/deps/package_version" require "dev/deps/pinned_scheme" transform!(RSpock::AST::Transformation) @@ -10,12 +11,16 @@ def scheme Dev::Deps::PinnedScheme.new end + def pv(version) + Dev::Deps::PackageVersion.new(version: version) + end + test "every reported version satisfies every constraint" do Expect "the backing service already narrowed the universe to the declared identity" - scheme.satisfies?("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", { "tag" => "v1.2.3" }) - scheme.satisfies?("5.6.1-css-83", { "tag" => "5.6.1-css-83" }) - scheme.satisfies?("20240101", { "buildid" => "20240101" }) - scheme.satisfies?("26.1.1", {}) + scheme.satisfies?(pv("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"), { "tag" => "v1.2.3" }) + scheme.satisfies?(pv("5.6.1-css-83"), { "tag" => "5.6.1-css-83" }) + scheme.satisfies?(pv("20240101"), { "buildid" => "20240101" }) + scheme.satisfies?(pv("26.1.1"), {}) end test "sort preserves the repository-reported order" do diff --git a/test/dev/deps/rock_scheme_test.rb b/test/dev/deps/rock_scheme_test.rb index f09063c..3623812 100644 --- a/test/dev/deps/rock_scheme_test.rb +++ b/test/dev/deps/rock_scheme_test.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "test_helper" +require "dev/deps/package_version" require "dev/deps/rock_scheme" transform!(RSpock::AST::Transformation) @@ -10,9 +11,13 @@ 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?(version, { "constraint" => requirement }) + result = scheme.satisfies?(pv(version), { "constraint" => requirement }) Then result == expected @@ -38,7 +43,7 @@ def scheme test "an empty constraint is satisfied by anything" do Expect - scheme.satisfies?("3.4-1", {}) + scheme.satisfies?(pv("3.4-1"), {}) end test "a revision counts as a release above the unrevised version" do @@ -67,7 +72,7 @@ def scheme test "rejects a constraint it cannot parse" do When "evaluating a malformed constraint" - scheme.satisfies?("3.4-1", { "constraint" => "~~> 3.0" }) + scheme.satisfies?(pv("3.4-1"), { "constraint" => "~~> 3.0" }) Then raises Dev::Deps::RockScheme::InvalidConstraintError diff --git a/test/dev/deps/semver_scheme_test.rb b/test/dev/deps/semver_scheme_test.rb index 0f3788c..7e514a9 100644 --- a/test/dev/deps/semver_scheme_test.rb +++ b/test/dev/deps/semver_scheme_test.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "test_helper" +require "dev/deps/package_version" require "dev/deps/semver_scheme" transform!(RSpock::AST::Transformation) @@ -10,9 +11,13 @@ 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?(version, { "version" => requirement }) + result = scheme.satisfies?(pv(version), { "version" => requirement }) Then result == expected @@ -42,7 +47,7 @@ def scheme test "an empty constraint is satisfied by anything" do Expect - scheme.satisfies?("3.12.0", {}) + scheme.satisfies?(pv("3.12.0"), {}) end test "sorts semver-correctly, prereleases below their release" do @@ -71,7 +76,7 @@ def scheme test "rejects a constraint it cannot parse" do When "evaluating an unparseable range" - scheme.satisfies?("1.0.0", { "version" => "^^nope" }) + scheme.satisfies?(pv("1.0.0"), { "version" => "^^nope" }) Then raises Dev::Deps::SemverScheme::InvalidConstraintError diff --git a/test/dev/deps/version_scheme_test.rb b/test/dev/deps/version_scheme_test.rb index 9f22aa9..3c23816 100644 --- a/test/dev/deps/version_scheme_test.rb +++ b/test/dev/deps/version_scheme_test.rb @@ -2,13 +2,15 @@ # 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" - Dev::Deps::VersionScheme.new.satisfies?("1.0.0", { "version" => ">= 1.0" }) + version = Dev::Deps::PackageVersion.new(version: "1.0.0") + Dev::Deps::VersionScheme.new.satisfies?(version, { "version" => ">= 1.0" }) Then raises NotImplementedError @@ -21,4 +23,10 @@ class Dev::Deps::VersionSchemeTest < Minitest::Test Then raises NotImplementedError end + + test "base class pin is nil — enumerable ecosystems never pin a probe" do + Expect "no constraint shape extracts a probe by default" + Dev::Deps::VersionScheme.new.pin({ "version" => ">= 1.0" }).nil? + Dev::Deps::VersionScheme.new.pin({}).nil? + end end From 4629028680bd8d28f953352655987439814e227f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 15:53:28 -0400 Subject: [PATCH 34/37] Retire Repository#find's filter: probe contract, per-ecosystem schemes, resolver projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter hash smuggled three unrelated things through the repository seam: version coordinates (tag/commit/buildid), source coordinates (repo/url/tap/app), and install instructions (install_dir/assets/ platforms/target). Each now travels its own channel: - Repository#find(id, probe:) — the probe is a single typed version coordinate, extracted by the integration's scheme (VersionScheme#pin), and only non-enumerable universes get one (gh tags, git refs, brew suffixes, xcode versions, url labels). Enumerable universes (ficsit, steam branches, pip, luarocks, bundler) ignore it. - Source coordinates ride PackageId#source (from Declaration#source). - Install instructions ride ScopedDeclaration#materialization and are stamped onto the pin at Resolver#mint, which also projects the declared platform union / ficsit target against the chosen version's artifacts (projection moved out of FicsitRepository). Scheme cutover: PinnedScheme (satisfies-everything) is dead. Each pinned-style ecosystem now states its real constraint semantics: ExactScheme(key:) for gh/xcode/url, GitScheme for cmake refs, SteamScheme for branch+buildid selection over enumerated branch tips, BrewScheme for formula version suffixes. VersionScheme#satisfies? is fact-aware (takes the PackageVersion, not the bare string) so schemes can match against universe facts like branch or ref. Repository fallout: SteamRepository enumerates every branch tip via SteamCmd.resolve_branches; GhRepository always resolves the commit and records all release assets as facts (glob selection moved to GhIntegration at install, loud NoMatchingAssetsError); casks split into BrewCaskRepository under the :cask integration — a genuinely separate universe with no versions or bottle digests. DSL verbs sort kwargs into constraint/source/materialization per integration; SteamIntegration owns the dev-platform -> steamcmd platform mapping. Co-authored-by: Cursor --- lib/dev/deps/brew_cask_repository.rb | 55 +++++++ lib/dev/deps/brew_repository.rb | 56 +++---- lib/dev/deps/brew_scheme.rb | 47 ++++++ lib/dev/deps/bundler_repository.rb | 6 +- lib/dev/deps/dsl.rb | 85 +++++++++-- lib/dev/deps/exact_scheme.rb | 56 +++++++ lib/dev/deps/ficsit_repository.rb | 96 ++---------- lib/dev/deps/gh_integration.rb | 32 +++- lib/dev/deps/gh_repository.rb | 152 ++++++------------- lib/dev/deps/git_repository.rb | 26 ++-- lib/dev/deps/git_scheme.rb | 48 ++++++ lib/dev/deps/luarocks_repository.rb | 6 +- lib/dev/deps/pinned_scheme.rb | 37 ----- lib/dev/deps/pip_repository.rb | 6 +- lib/dev/deps/registry.rb | 45 ++++-- lib/dev/deps/repository.rb | 28 ++-- lib/dev/deps/resolver.rb | 115 +++++++++++---- lib/dev/deps/steam_cmd.rb | 38 ++--- lib/dev/deps/steam_integration.rb | 20 ++- lib/dev/deps/steam_repository.rb | 98 +++++-------- lib/dev/deps/steam_scheme.rb | 46 ++++++ lib/dev/deps/url_repository.rb | 12 +- lib/dev/deps/xcode_repository.rb | 12 +- test/dev/deps/brew_cask_repository_test.rb | 36 +++++ test/dev/deps/brew_repository_test.rb | 31 +--- test/dev/deps/cmake_integration_test.rb | 8 +- test/dev/deps/config_test.rb | 6 +- test/dev/deps/dsl_test.rb | 62 +++++--- test/dev/deps/ficsit_repository_test.rb | 73 ++-------- test/dev/deps/gh_integration_test.rb | 25 +++- test/dev/deps/gh_repository_test.rb | 162 ++++++++++----------- test/dev/deps/git_repository_test.rb | 28 +++- test/dev/deps/pinned_scheme_test.rb | 36 ----- test/dev/deps/resolver_test.rb | 148 ++++++++++++++----- test/dev/deps/steam_cmd_test.rb | 50 +++---- test/dev/deps/steam_repository_test.rb | 77 +++++----- test/dev/deps/url_repository_test.rb | 4 +- test/dev/deps/xcode_repository_test.rb | 4 +- 38 files changed, 1078 insertions(+), 794 deletions(-) create mode 100644 lib/dev/deps/brew_cask_repository.rb create mode 100644 lib/dev/deps/brew_scheme.rb create mode 100644 lib/dev/deps/exact_scheme.rb create mode 100644 lib/dev/deps/git_scheme.rb delete mode 100644 lib/dev/deps/pinned_scheme.rb create mode 100644 lib/dev/deps/steam_scheme.rb create mode 100644 test/dev/deps/brew_cask_repository_test.rb delete mode 100644 test/dev/deps/pinned_scheme_test.rb diff --git a/lib/dev/deps/brew_cask_repository.rb b/lib/dev/deps/brew_cask_repository.rb new file mode 100644 index 0000000..46a152d --- /dev/null +++ b/lib/dev/deps/brew_cask_repository.rb @@ -0,0 +1,55 @@ +# 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. + # + # The declared suffix (rare for casks) rides metadata so BrewScheme can + # match it, mirroring the formula shape. + # + # @param id [PackageId] name is the cask name + # @param probe [String, nil] cask version suffix, if declared + # @return [Package] a singleton universe + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) + metadata = { "cask" => true } + metadata["version_suffix"] = probe if probe + + 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_repository.rb b/lib/dev/deps/brew_repository.rb index 5e1c3f3..2b6a627 100644 --- a/lib/dev/deps/brew_repository.rb +++ b/lib/dev/deps/brew_repository.rb @@ -13,56 +13,38 @@ module Dev module Deps # Fetches Homebrew formulae to exact version + bottle SHA256. # - # 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). + # Uses `brew info --json=v1` for formulae. Casks are a separate universe + # (BrewCaskRepository) under the :cask integration. class BrewRepository < Repository extend T::Sig class BrewInfoError < StandardError; end - # Version stand-in for casks, whose versions Homebrew does not expose - # here; the Resolver mints it back to a nil pin version. - UNVERSIONED = "" - - # Report a brew package's universe: the one stable version the selected + # Report a brew formula's universe: the one stable version the selected # formula spec currently has. # # Brew is a moving registry — `brew info` answers with a single current - # version, so the universe is a singleton. The filter locates which - # formula that is: "version" is a formula *suffix* ("18" selects - # llvm@18), "tap" scopes the name, "cask" switches to an unversioned - # cask entry. PinnedScheme accepts whatever brew reports. + # version per formula spec, so the universe is a singleton. Suffixed + # formulae (llvm@18) are distinct formulae brew will not enumerate under + # the bare name, which is why the probe (the declared suffix) is the + # access path. The tap scoping the name is the package's source + # coordinate (PackageId#source). The suffix and tap ride metadata as + # facts: BrewScheme matches the suffix, BrewIntegration rebuilds the + # install spec from both. # - # @param id [PackageId] name is the formula or cask name - # @param filter [Hash] locator: "tap", "version" (suffix), "cask" + # @param id [PackageId] name is the formula name; source is the tap + # @param probe [String, nil] formula version suffix ("18" selects llvm@18) # @return [Package] a singleton universe - # @raise [BrewInfoError] if `brew info` fails for a formula - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) - version_suffix = filter["version"] - - if filter["cask"] - metadata = { "cask" => true } - metadata["version_suffix"] = version_suffix if version_suffix - return Package.new( - id: id, - versions: [ - PackageVersion.new( - version: UNVERSIONED, - metadata: metadata, - # brew installs formula dependencies itself. - declarations: Declarations::ToolOwned.new, - ), - ], - ) - end - - info = brew_info_with_tap(build_formula_spec(id.name, filter["tap"], version_suffix), filter["tap"]) + # @raise [BrewInfoError] if `brew info` fails for the formula + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) + tap = id.source + info = brew_info_with_tap(build_formula_spec(id.name, tap, probe), tap) bottle_hash = extract_bottle_hash(info) metadata = {} - metadata["tap"] = filter["tap"] if filter["tap"] - metadata["version_suffix"] = version_suffix if version_suffix + metadata["tap"] = tap if tap + metadata["version_suffix"] = probe if probe Package.new( id: id, diff --git a/lib/dev/deps/brew_scheme.rb b/lib/dev/deps/brew_scheme.rb new file mode 100644 index 0000000..1c83da6 --- /dev/null +++ b/lib/dev/deps/brew_scheme.rb @@ -0,0 +1,47 @@ +# 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). Suffixed formulae are not enumerable — brew answers for + # one formula spec at a time — so the suffix doubles as the probe. + 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 + + # @param constraint [Hash] declaration constraint + # @return [String, nil] the suffix, for Repository#find's probe + sig { override.params(constraint: T::Hash[String, T.untyped]).returns(T.nilable(String)) } + def pin(constraint) + suffix = constraint["version"] + suffix&.to_s + end + end + end +end diff --git a/lib/dev/deps/bundler_repository.rb b/lib/dev/deps/bundler_repository.rb index 0b00f14..83a97de 100644 --- a/lib/dev/deps/bundler_repository.rb +++ b/lib/dev/deps/bundler_repository.rb @@ -37,11 +37,11 @@ def initialize(project_root:) # Report a gem's locked pin from Gemfile.lock as a singleton universe. # # @param id [PackageId] name is the gem name - # @param filter [Hash] unused; the lockfile needs no locator + # @param probe [String, nil] ignored — the lockfile is enumerable # @return [Package] a singleton universe # @raise [MissingGemError] if the gem is absent from the parsed Gemfile.lock - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) pin = pins.fetch(id.name) do raise MissingGemError, "gem #{id.name.inspect} is not in #{LOCKFILE} — run `dev update-deps`" diff --git a/lib/dev/deps/dsl.rb b/lib/dev/deps/dsl.rb index 54f6e42..8f8707d 100644 --- a/lib/dev/deps/dsl.rb +++ b/lib/dev/deps/dsl.rb @@ -204,6 +204,10 @@ def initialize(group: :app, platform: nil, host: nil, env: nil) # 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] @@ -217,8 +221,17 @@ def brew(name, **opts) else @brew << { name_str => stringify_keys(opts) } end + + constraint = opts.dup + cask = constraint.delete(:cask) + tap = constraint.delete(:tap) @declarations << ScopedDeclaration.new( - declaration: Declaration.new(name: name_str, integration: :brew, constraint: stringify_keys(opts)), + 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, ) @@ -246,6 +259,10 @@ class GroupDSL class EmptyNameError < StandardError; end + # 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 @@ -272,7 +289,9 @@ def initialize(group:, platform: nil, host: nil, registered_methods: []) @registered_methods = registered_methods end - # Declare a CMake dependency. Expands github: shorthand if present. + # Declare a CMake dependency. Expands github: shorthand if present; the + # resulting repo:/url: is the declaration's source coordinate, leaving + # tag:/commit: as the constraint. # # @param name [String, Symbol] dependency name # @param spec [Hash] options (tag:, repo:, url:, github:, etc.) @@ -280,7 +299,8 @@ def initialize(group:, platform: nil, host: nil, registered_methods: []) sig { params(name: T.any(String, Symbol), spec: T.untyped).void } def cmake(name, **spec) spec = expand_github(name.to_s, spec) - add_declaration(name, :cmake, spec) + source = spec.delete(:repo) || spec.delete(:url) + add_declaration(name, :cmake, spec, source: source&.to_s) end # Declare a Ruby gem scoped to this group (group name -> bundler group). @@ -323,6 +343,11 @@ 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.) @@ -330,7 +355,8 @@ def pip(name, version = nil, **spec) 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: @@ -379,10 +405,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 @@ -408,8 +438,13 @@ def gh(name_or_slug, tag:, install_dir:, github: nil, repo: nil, assets: nil, bu ).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. @@ -443,11 +478,16 @@ def xcode(version, **spec) # 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] @@ -461,7 +501,11 @@ 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 @@ -522,9 +566,19 @@ def respond_to_missing?(method_name, include_private = false) # @param name [String, Symbol] dependency name # @param integration [Symbol] integration type # @param spec [Hash] constraint spec (symbol keys → stringified) + # @param source [String, nil] source coordinate for the Declaration + # @param materialization [Hash{String => Object}] install instructions # @return [void] - sig { params(name: T.any(String, Symbol), integration: Symbol, spec: T::Hash[Symbol, T.untyped]).void } - def add_declaration(name, integration, spec) + sig do + params( + name: T.any(String, Symbol), + integration: Symbol, + spec: T::Hash[Symbol, T.untyped], + source: T.nilable(String), + materialization: T::Hash[String, T.untyped], + ).void + end + def add_declaration(name, integration, spec, source: nil, materialization: {}) name_str = name.to_s raise EmptyNameError, "dependency name cannot be empty" if name_str.empty? @@ -534,10 +588,11 @@ def add_declaration(name, integration, spec) constraint = stringify_keys(spec) @declarations << ScopedDeclaration.new( - declaration: Declaration.new(name: name_str, integration:, constraint:), + declaration: Declaration.new(name: name_str, integration:, constraint:, source:), scope: Scope.new(group: @group, host:), platform: @platform, post_install:, + materialization: materialization, ) end diff --git a/lib/dev/deps/exact_scheme.rb b/lib/dev/deps/exact_scheme.rb new file mode 100644 index 0000000..8d21216 --- /dev/null +++ b/lib/dev/deps/exact_scheme.rb @@ -0,0 +1,56 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "version_scheme" + +module Dev + module Deps + # Exact-coordinate constraint semantics (:gh tags, :xcode versions, :url + # labels): the constraint names one version, and a candidate satisfies it + # by being that version. + # + # These ecosystems have no range grammar — a GitHub tag or an Xcode + # version is an exact ask by design. The named coordinate doubles as the + # probe (see #pin): their universes answer for one version at a time, so + # the Resolver hands the coordinate to Repository#find as the access path. + 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 + + # @param constraint [Hash] declaration constraint + # @return [String, nil] the pinned coordinate, for Repository#find's probe + sig { override.params(constraint: T::Hash[String, T.untyped]).returns(T.nilable(String)) } + def pin(constraint) + pinned = constraint[key] + pinned&.to_s + end + end + end +end diff --git a/lib/dev/deps/ficsit_repository.rb b/lib/dev/deps/ficsit_repository.rb index 6da8859..ad201e2 100644 --- a/lib/dev/deps/ficsit_repository.rb +++ b/lib/dev/deps/ficsit_repository.rb @@ -26,7 +26,6 @@ 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!) { @@ -58,21 +57,21 @@ class ModNotFoundError < PackageNotFoundError; end # # 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 install facts - # FicsitIntegration reads (mod_id, game_version, and either a - # single-target digest or a per-platform block, per the filter). + # 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 [PackageId] name is the mod_reference - # @param filter [Hash] locator only; "platforms" (Array) - # or "target" select which targets the install facts describe + # @param probe [String, nil] ignored — the universe is enumerable # @return [Package] # @raise [ModNotFoundError] if the mod_reference doesn't exist on ficsit.app # @raise [ApiError] if the GraphQL request fails - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) mod_data = query_mod(id.name) versions = (mod_data["versions"] || []).map do |version_data| - package_version(mod_data, version_data, filter) + package_version(mod_data, version_data) end Package.new(id: id, versions: versions) @@ -80,48 +79,24 @@ def find(id, filter: {}) private - # Map one GraphQL version object to a PackageVersion. - # - # Universe facts (platforms, artifacts, declarations) are unconditional. The - # install facts mirror the pin shapes FicsitIntegration reads: with - # requested platforms, a metadata["platforms"] block covering the - # targets this version actually publishes (the Resolver rejects the - # version if a requested one is missing); otherwise the legacy - # single-target shape (metadata["target"] plus the digest). + # Map one GraphQL version object to a PackageVersion: universe facts + # only, unconditional — nothing here depends on who asked. # # @param mod_data [Hash] the mod object (for mod_id) # @param version_data [Hash] one version object - # @param filter [Hash] the declaration constraint, as a locator # @return [PackageVersion] sig do params( mod_data: T::Hash[String, T.untyped], version_data: T::Hash[String, T.untyped], - filter: T::Hash[String, T.untyped], ).returns(PackageVersion) end - def package_version(mod_data, version_data, filter) + def package_version(mod_data, version_data) targets = version_data["targets"] || [] - metadata = { - "mod_id" => mod_data["id"], - "game_version" => version_data["game_version"], - } - - requested = filter["platforms"] - if requested && !requested.empty? - digest = nil - metadata["platforms"] = platform_block(version_data, targets, requested) - else - target = filter.fetch("target", DEFAULT_TARGET) - target_data = find_target(targets, target) - digest = target_data ? "SHA256=#{target_data["hash"]}" : nil - metadata["target"] = target - end PackageVersion.new( version: version_data["version"], platforms: targets.map { |t| t["targetName"] }, - digest: digest, artifacts: targets.to_h do |t| [t["targetName"], Artifact.new(uri: download_url(version_data, t), digest: "SHA256=#{t["hash"]}")] end, @@ -130,7 +105,10 @@ def package_version(mod_data, version_data, filter) .reject { |d| d["optional"] } .map { |d| edge_declaration(d) }, ), - metadata: metadata, + metadata: { + "mod_id" => mod_data["id"], + "game_version" => version_data["game_version"], + }, ) end @@ -149,35 +127,6 @@ def edge_declaration(dependency_data) Declaration.new(name: dependency_data["mod_id"], integration: :ficsit, constraint: constraint) end - # The {hash, link} block for each requested platform this version - # publishes. Non-raising: a missing target simply isn't in the block — - # whether that disqualifies the version is the Resolver's call. - # - # @param version_data [Hash] the version object - # @param targets [Array] its target objects - # @param requested [Array] platforms; nil means the default - # @return [Hash{String => Hash}] target name → { "hash" => …, "link" => … } - sig do - params( - version_data: T::Hash[String, T.untyped], - targets: T::Array[T::Hash[String, T.untyped]], - requested: T::Array[T.nilable(String)], - ).returns(T::Hash[String, T::Hash[String, String]]) - end - def platform_block(version_data, targets, requested) - 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 } - next unless target_data - - acc[target_name] = { - "hash" => "SHA256=#{target_data["hash"]}", - "link" => download_url(version_data, target_data), - } - end - end - # Build the absolute download URL for a target. ficsit returns a relative # "link" (e.g. "/v1/version///download"); fall back to the same # REST shape if the field is ever absent. @@ -243,21 +192,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/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 b3a35c6..ad3e7db 100644 --- a/lib/dev/deps/gh_repository.rb +++ b/lib/dev/deps/gh_repository.rb @@ -13,9 +13,9 @@ module Dev module Deps # Resolves GitHub release dependencies via the gh CLI. # - # 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. + # Resolution is metadata API calls only — no artifact download. Per-asset + # SHA256 digests reported by the GitHub API are recorded in metadata so + # GhIntegration can verify downloads against the lockfile. # # Declared in dependencies.rb as: # gh "satisfactorymodding/UnrealEngine", @@ -29,108 +29,54 @@ class GhMissingError < StandardError; end class AuthenticationError < StandardError; end class RepoAccessError < StandardError; end class ReleaseNotFoundError < PackageNotFoundError; end - class NoMatchingAssetsError < StandardError; end + class MissingTagError < StandardError; end class ApiError < StandardError; end - # Report a GitHub dependency's universe: the declared tag, as a + # Report a GitHub dependency's universe: the probed tag, as a # singleton. # - # GitHub refs are not an enumerable version index — the filter's "tag" - # locates the one release (prebuilt shape, "assets" glob present) or - # ref (source shape, "build" recipe present) the declaration pins. - # Integrity is tool-enforced by the authenticated gh CLI; per-asset - # API digests ride in metadata for GhIntegration to verify downloads. + # The probe is required: GitHub refs are enumerable in principle, but + # each version's facts (commit SHA, release assets and digests) cost + # API calls per version, so this universe answers for one coordinate + # at a time. The version's facts are declaration-independent: the + # commit SHA the ref points at, and every release asset when the tag + # has a release — asset selection against the declared glob happens at + # install (GhIntegration), where the glob arrives via the pin's + # materialization. # # @param id [PackageId] source is the "owner/repo" slug - # @param filter [Hash] locator: "tag", "install_dir", "assets" or "build" + # @param probe [String, nil] the pinned tag; required # @return [Package] a singleton universe + # @raise [MissingTagError] if the declaration pins no tag # @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 { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) + # @raise [ReleaseNotFoundError] if the repo has no such tag + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) repo_slug = T.must(id.source) - tag = filter["tag"] - version = if filter["assets"] - prebuilt_version(repo_slug, tag, filter) - else - source_version(repo_slug, tag, filter) - end - - Package.new(id: id, versions: [version]) - end - - private - - # The prebuilt shape: the tag's release, its glob-matched assets and - # their API digests as install facts. - # - # @param repo_slug [String] "owner/repo" - # @param tag [String] release tag - # @param filter [Hash] the declaration constraint - # @return [PackageVersion] - # @raise [NoMatchingAssetsError] if no assets match the pattern - sig do - params( - repo_slug: String, - tag: String, - filter: T::Hash[String, T.untyped], - ).returns(PackageVersion) - end - def prebuilt_version(repo_slug, tag, filter) - pattern = filter["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 - - PackageVersion.new( - version: tag, - metadata: { - "repo" => repo_slug, - "asset_pattern" => pattern, - "install_dir" => filter["install_dir"], - "assets" => assets.map { |asset| asset_metadata(asset) }, - }, - # Prebuilt release assets are self-contained: whatever they needed - # was baked in at build time. + raise MissingTagError, "gh dependency #{id.name} declares no tag" if probe.nil? + + metadata = { + "repo" => repo_slug, + "commit" => resolve_commit_sha(repo_slug, probe), + } + release = fetch_release(repo_slug, probe) + metadata["assets"] = (release["assets"] || []).map { |asset| asset_metadata(asset) } if release + + version = PackageVersion.new( + version: probe, + 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([]), ) + Package.new(id: id, versions: [version]) end - # The source shape: the tag's commit SHA (provenance) and the build - # recipe as install facts. - # - # @param repo_slug [String] "owner/repo" - # @param tag [String] tag/ref - # @param filter [Hash] the declaration constraint - # @return [PackageVersion] - sig do - params( - repo_slug: String, - tag: String, - filter: T::Hash[String, T.untyped], - ).returns(PackageVersion) - end - def source_version(repo_slug, tag, filter) - PackageVersion.new( - version: tag, - metadata: { - "repo" => repo_slug, - "install_dir" => filter["install_dir"], - "build" => filter["build"], - "commit" => resolve_commit_sha(repo_slug, tag), - }, - # Usage contract, not a guarantee: 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 + private # Resolve a tag to its commit SHA, mapping gh failures to actionable errors. # @@ -147,18 +93,20 @@ def resolve_commit_sha(repo_slug, tag) raise ApiError, "gh api failed resolving #{repo_slug}@#{tag}: #{err.strip}" end - # Fetch release metadata for a tag, mapping gh failures to actionable errors. + # Fetch release metadata for a tag. A 404 is a fact, not a failure: the + # tag exists (resolve_commit_sha proved it) but publishes no release, so + # the version simply has no assets. # # @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]) } + # @return [Hash, nil] parsed release JSON, or nil when the tag has no release + sig { params(repo_slug: String, tag: String).returns(T.nilable(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? + return nil if not_found?(err) 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}" end @@ -210,22 +158,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 088979b..c50f5b7 100644 --- a/lib/dev/deps/git_repository.rb +++ b/lib/dev/deps/git_repository.rb @@ -20,29 +20,33 @@ class GitRepository < Repository class RefResolutionError < PackageNotFoundError; end - # Report a git dependency's universe: the declared ref resolved to its + # Report a git dependency's universe: the probed ref resolved to its # full SHA, as a singleton. # - # A git remote is not a version index — the filter's "commit" or "tag" - # locates the one ref the declaration pins, and ls-remote turns it into - # a SHA. SHAs are identifiers, not integrity digests, so the version - # carries no digest. + # The probe is required and is the canonical non-enumerable coordinate: + # `git ls-remote` lists refs, never reachable SHAs, so a commit can only + # be asked about, not discovered. The ref the SHA resolved from rides + # metadata as a fact for GitScheme's tag matching. SHAs are identifiers, + # not integrity digests, so the version carries no digest. # # @param id [PackageId] source is the git remote URL - # @param filter [Hash] locator: one of "commit" or "tag" + # @param probe [String, nil] the pinned ref (tag, branch, or SHA); required # @return [Package] a singleton universe - # @raise [RefResolutionError] if the ref cannot be resolved via ls-remote - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) + # @raise [RefResolutionError] if no ref is pinned or it cannot be + # resolved via ls-remote + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) repo_url = T.must(id.source) - sha = resolve_ref(repo_url, filter["commit"] || filter["tag"]) + raise RefResolutionError, "git dependency #{id.name} declares no tag: or commit:" if probe.nil? + + sha = resolve_ref(repo_url, probe) Package.new( id: id, versions: [ PackageVersion.new( version: sha, - metadata: { "repo" => repo_url }, + metadata: { "repo" => repo_url, "ref" => probe }, # A checked-out source tree carries no manifest dev reads; # consumers declare what they need alongside it. declarations: Declarations::Resolved.new([]), diff --git a/lib/dev/deps/git_scheme.rb b/lib/dev/deps/git_scheme.rb new file mode 100644 index 0000000..f088b58 --- /dev/null +++ b/lib/dev/deps/git_scheme.rb @@ -0,0 +1,48 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "version_scheme" + +module Dev + module Deps + # Git ref constraint semantics (:cmake): the constraint names a ref — + # "commit" (a SHA) or "tag" — and the universe's versions are resolved + # SHAs carrying the ref they resolved from as a fact. + # + # A commit constraint matches the version string itself (the SHA); a tag + # constraint matches the version's "ref" fact, because the SHA a tag + # points at is a repository fact the scheme cannot derive. Commits are the + # canonical non-enumerable coordinate: `git ls-remote` lists refs, never + # reachable SHAs, so the ref doubles as the probe (see #pin). + class GitScheme < VersionScheme + extend T::Sig + + # @param version [PackageVersion] a candidate (version is the resolved SHA) + # @param constraint [Hash] declaration constraint; "commit" or "tag" + # @return [Boolean] + sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } + def satisfies?(version, constraint) + ref = pin(constraint) + return true if ref.nil? + + version.version == ref || version.metadata["ref"].to_s == ref + 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 + + # @param constraint [Hash] declaration constraint + # @return [String, nil] the declared ref, for Repository#find's probe + sig { override.params(constraint: T::Hash[String, T.untyped]).returns(T.nilable(String)) } + def pin(constraint) + ref = constraint["commit"] || constraint["tag"] + ref&.to_s + end + end + end +end diff --git a/lib/dev/deps/luarocks_repository.rb b/lib/dev/deps/luarocks_repository.rb index e4ddcd5..4026651 100644 --- a/lib/dev/deps/luarocks_repository.rb +++ b/lib/dev/deps/luarocks_repository.rb @@ -28,12 +28,12 @@ class RockNotFoundError < PackageNotFoundError; end # taking the first version and ignoring the constraint entirely. # # @param id [PackageId] name is the rock name - # @param filter [Hash] unused; the manifest search needs no locator + # @param probe [String, nil] ignored — the universe is enumerable # @return [Package] # @raise [SearchError] if luarocks search fails # @raise [RockNotFoundError] if the search yields no versions - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) versions = search_versions(id.name) raise RockNotFoundError, "no rock named #{id.name} on luarocks.org" if versions.empty? diff --git a/lib/dev/deps/pinned_scheme.rb b/lib/dev/deps/pinned_scheme.rb deleted file mode 100644 index 04aa19f..0000000 --- a/lib/dev/deps/pinned_scheme.rb +++ /dev/null @@ -1,37 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require_relative "version_scheme" - -module Dev - module Deps - # Constraint semantics for pinned universes (:brew, :cmake, :gh, :steam, - # :xcode): the backing service already applied the declared identity - # constraint while building the universe, so every reported version - # satisfies, and the reported order stands. - # - # These ecosystems' constraints name an identity (a git tag or commit, a - # release tag, a Steam buildid, an exact Xcode version, a brew formula - # suffix), not a range over an ordered version set — the repository - # queries exactly that identity and reports a degenerate (usually - # singleton) universe. There is nothing left to evaluate or to order. - class PinnedScheme < VersionScheme - extend T::Sig - - # @param version [PackageVersion] any reported version - # @param constraint [Hash] ignored — already applied by the repository - # @return [Boolean] always true - sig { override.params(version: PackageVersion, constraint: T::Hash[String, T.untyped]).returns(T::Boolean) } - def satisfies?(version, constraint) - true - end - - # @param versions [Array] reported versions - # @return [Array] the same versions, order untouched - 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/pip_repository.rb b/lib/dev/deps/pip_repository.rb index c0ce5ad..94929e8 100644 --- a/lib/dev/deps/pip_repository.rb +++ b/lib/dev/deps/pip_repository.rb @@ -36,12 +36,12 @@ class ApiError < StandardError; end # Pep440Scheme. # # @param id [PackageId] name is the PyPI project name - # @param filter [Hash] unused; the JSON API needs no locator + # @param probe [String, nil] ignored — the universe is enumerable # @return [Package] # @raise [ProjectNotFoundError] if PyPI has no such project # @raise [ApiError] if the API request fails otherwise - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) releases = project_json(id.name)["releases"] || {} versions = releases.map do |version, files| # pip resolves the transitive tree itself at install time. diff --git a/lib/dev/deps/registry.rb b/lib/dev/deps/registry.rb index 4cfc19f..7b3b96a 100644 --- a/lib/dev/deps/registry.rb +++ b/lib/dev/deps/registry.rb @@ -22,12 +22,16 @@ require_relative "xcode_integration" require_relative "pip_repository" require_relative "pip_integration" +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 "pinned_scheme" require_relative "rock_scheme" require_relative "semver_scheme" +require_relative "steam_scheme" require_relative "version_scheme" module Dev @@ -61,6 +65,8 @@ module Registry # @param repository_needs [Array] extra kwargs the repository takes # @param scheme [Class] VersionScheme subclass carrying this type's # constraint semantics — every type must answer "how do constraints work" + # @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 @@ -70,7 +76,7 @@ module Registry # (beyond the always-passed repository: and cache:) # @param scope [Symbol] one of HOST / CONTAINER / BOTH Entry = Data.define( - :symbol, :repository, :repository_needs, :scheme, :locker, :locker_needs, + :symbol, :repository, :repository_needs, :scheme, :scheme_args, :locker, :locker_needs, :integration, :integration_needs, :scope, ) do extend T::Sig @@ -91,6 +97,9 @@ def repository_needs = to_h.fetch(:repository_needs) sig { returns(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) @@ -114,13 +123,14 @@ def scope = to_h.fetch(:scope) 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], ).void end def initialize(symbol:, repository:, scheme:, integration:, scope:, - repository_needs: [], locker: nil, locker_needs: [], integration_needs: []) + repository_needs: [], scheme_args: {}, locker: nil, locker_needs: [], integration_needs: []) super end @@ -147,15 +157,25 @@ def host? Entry.new( symbol: :brew, repository: BrewRepository, - scheme: PinnedScheme, + 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: PinnedScheme, + scheme: GitScheme, integration: CmakeIntegration, integration_needs: %i[project_root], scope: HOST, @@ -178,7 +198,8 @@ def host? Entry.new( symbol: :gh, repository: GhRepository, - scheme: PinnedScheme, + scheme: ExactScheme, + scheme_args: { key: "tag" }, integration: GhIntegration, integration_needs: %i[project_root], scope: HOST, @@ -186,14 +207,15 @@ def host? Entry.new( symbol: :steam, repository: SteamRepository, - scheme: PinnedScheme, + scheme: SteamScheme, integration: SteamIntegration, scope: HOST, ), Entry.new( symbol: :xcode, repository: XcodeRepository, - scheme: PinnedScheme, + scheme: ExactScheme, + scheme_args: { key: "version" }, integration: XcodeIntegration, integration_needs: %i[project_root], scope: HOST, @@ -224,12 +246,15 @@ def repositories(project_root:) end # Build the integration-type -> VersionScheme hash the Resolver consumes. - # Schemes are stateless domain services, so they take no context. + # Schemes are stateless domain services; their only context is the + # entry's own scheme_args (e.g. which constraint key ExactScheme reads). # # @return [Hash{Symbol => VersionScheme}] sig { returns(T::Hash[Symbol, VersionScheme]) } def schemes - INTEGRATIONS.to_h { |entry| [entry.symbol, entry.scheme.new] } + # T.unsafe: the keyword set is entry-declared (scheme_args); the + # scheme constructors' own sigs validate at runtime. + INTEGRATIONS.to_h { |entry| [entry.symbol, T.unsafe(entry.scheme).new(**entry.scheme_args)] } end # Build the integration-type -> Locker hash for types whose ecosystem diff --git a/lib/dev/deps/repository.rb b/lib/dev/deps/repository.rb index b478994..168711e 100644 --- a/lib/dev/deps/repository.rb +++ b/lib/dev/deps/repository.rb @@ -24,21 +24,27 @@ class PackageNotFoundError < StandardError; end # Report the package under this identity. # - # The filter is the declaration's constraint hash, passed as a - # server-side locator: ecosystems whose constraint names an identity - # (a git tag, a release tag, a Steam buildid, a brew formula suffix) - # need it to locate their — typically singleton — universe, and - # registry-backed ecosystems may use it to narrow an expensive index. - # Filtering returns every matching version; it never picks one. A - # repository must not evaluate range constraints (VersionScheme's job) - # and must not choose among candidates (the Resolver's job). + # The probe is the exact version coordinate the declaration pins (a git + # ref, a release tag, a brew formula suffix), extracted by the + # integration's VersionScheme#pin. It exists for universes that cannot + # enumerate — `git ls-remote` lists refs, never reachable SHAs; brew + # answers for one formula spec at a time — where it is the access path + # to the version being asked about. Enumerable universes ignore it. + # + # It is never a constraint and never selection: a repository must not + # evaluate range constraints (VersionScheme's job) and must not choose + # among candidates (the Resolver's job). The declaration's constraint + # hash, source coordinates (they ride PackageId#source), and install + # instructions (ScopedDeclaration#materialization, merged into the pin + # by the Resolver) never reach a repository. # # @param id [PackageId] the package's identity - # @param filter [Hash] the declaration constraint, as a locator only + # @param probe [String, nil] pinned version coordinate, as an access + # path for non-enumerable universes only # @return [Package] the available versions and their facts # @raise [PackageNotFoundError] if the universe has no such package - sig { params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) + sig { params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) raise NotImplementedError, "#{self.class}#find must be implemented" end end diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb index 7603596..b61f383 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -76,8 +76,9 @@ def resolve(declarations) id = package_id(decl) next if resolved.key?(id) - chosen = choose(decl, platforms[[decl.integration, decl.name]] || []) - resolved[id] = mint(chosen, decl) + declared = platforms[[decl.integration, decl.name]] || [] + chosen = 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 @@ -126,13 +127,10 @@ def choose(decl, platforms) scheme = @schemes[decl.integration] raise UnknownIntegrationError, "no version scheme registered for #{decl.integration.inspect}" unless scheme - # The constraint doubles as the repository's locator; platforms ride - # along only when at least one group pinned one explicitly, so - # single-platform deps keep their default-platform install facts. - filter = decl.constraint.dup - filter["platforms"] = platforms if platforms.any? { |p| !p.nil? } - - package = repository.find(package_id(decl), filter: filter) + # The probe is the constraint's exact coordinate (scheme-extracted), + # the access path for universes that cannot enumerate. The constraint + # itself never reaches the repository — evaluation happens below. + package = repository.find(package_id(decl), probe: scheme.pin(decl.constraint)) explicit = platforms.compact candidates = package.versions.select do |version| satisfies?(scheme, version, decl.constraint) && publishes_platforms?(version, explicit) @@ -181,32 +179,87 @@ 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, with - # the declaration contributing name/integration/group, the post-install + # 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, e.g. brew casks) becomes a nil pin version. + # 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 { params(chosen: PackageVersion, decl: ScopedDeclaration).returns(Dependency) } - def mint(chosen, decl) + 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) + 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: chosen.version.empty? ? nil : chosen.version, - hash: chosen.digest, - metadata: chosen.metadata.dup, + hash: hash, + metadata: metadata, ) dependency = dependency.with(post_install: decl.post_install) if decl.post_install attach_install_scoping(dependency, decl) end - # The package's identity, from the declaration: for source-based deps - # the constraint's "repo"/"url" is the source coordinate (which service - # to ask), so it rides on the PackageId rather than the filter. + # 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] @@ -215,17 +268,18 @@ def package_id(decl) PackageId.new( integration: decl.integration, name: decl.name, - source: decl.constraint["repo"] || decl.constraint["url"], + source: decl.source, ) end - # Reject sets where one package is declared with disagreeing constraints. - # A dep declared in several groups resolves once, so agreement is the - # precondition for that single resolution being right for everyone. - # Grouping is per (integration, name): the same name under two - # integrations is two packages, free to carry different constraints. - # (Platform, group, host, and env may differ — they are axes, not - # constraints.) + # Reject sets where one package is declared with disagreeing asks — + # constraint, source, 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] @@ -233,12 +287,13 @@ def package_id(decl) sig { params(declarations: T::Array[ScopedDeclaration]).void } def reject_conflicts(declarations) declarations.group_by { |d| [d.integration, d.name] }.each do |(integration, name), decls| - constraints = decls.map(&:constraint).uniq - next if constraints.size <= 1 + asks = decls.map { |d| { constraint: d.constraint, source: d.source, materialization: d.materialization } } + .uniq + next if asks.size <= 1 raise ConflictingDeclarationError, - "#{integration}/#{name} is declared with disagreeing constraints: " \ - "#{constraints.map(&:inspect).join(" vs ")}" + "#{integration}/#{name} is declared with disagreeing asks: " \ + "#{asks.map(&:inspect).join(" vs ")}" 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 bbc79ce..e0cf0e2 100644 --- a/lib/dev/deps/steam_repository.rb +++ b/lib/dev/deps/steam_repository.rb @@ -10,13 +10,12 @@ 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). # @@ -27,71 +26,46 @@ module Deps class SteamRepository < Repository extend T::Sig - # Report a Steam app's universe: one buildid, as a singleton. + # Report a Steam app's universe: the current buildid of every branch, + # one version per branch. # - # Steam exposes no enumerable build history — the filter locates the - # build: an explicit "buildid" pin, or the current buildid of "branch" - # (default public) via SteamCMD. No digest: Steam publishes no stable - # per-build hash; integrity is SteamCMD's app_update … validate at - # install. + # Steam exposes no build history, but branch tips ARE enumerable: one + # +app_info_print call reports every branch's current buildid, so no + # probe is needed. 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] name is the declaration name - # @param filter [Hash] locator: "app", "install_dir", optionally - # "branch", "buildid", "platforms" - # @return [Package] a singleton universe - # @raise [SteamCmd::SteamCmdError] if resolving the buildid fails - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) - app = filter["app"] - branch = filter["branch"] || "public" - build_id = filter["buildid"] || resolve_build_id(app:, branch:) + # @param id [PackageId] source is the Steam app id + # @param probe [String, nil] ignored — the universe is enumerable + # @return [Package] one version per branch + # @raise [SteamCmd::SteamCmdError] if querying the app fails + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) + 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? - Package.new( - id: id, - versions: [ - PackageVersion.new( - version: build_id.to_s, - metadata: { - "app" => app.to_s, - "branch" => branch, - "install_dir" => filter["install_dir"], - "platform" => steam_platform_for(filter["platforms"]), - }, - # Steam depots are self-contained by construction: SteamCMD - # delivers the complete installed tree. - declarations: Declarations::Resolved.new([]), - ), - ], - ) + 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..22b6acb --- /dev/null +++ b/lib/dev/deps/steam_scheme.rb @@ -0,0 +1,46 @@ +# 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. + # No probe (see VersionScheme#pin): the tips are enumerable in one query. + 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/url_repository.rb b/lib/dev/deps/url_repository.rb index 2b93f6b..1bfa715 100644 --- a/lib/dev/deps/url_repository.rb +++ b/lib/dev/deps/url_repository.rb @@ -27,15 +27,15 @@ class DownloadError < StandardError; end # # Dev-enforced integrity, trust-on-first-use: the artifact is downloaded # and hashed at resolve time, and that SHA256 rides as the version's - # digest. The version is the filter's "tag"; URLs with no tag report an - # empty version the Resolver mints back to nil. + # digest. The version is the probed "tag" label; URLs with no tag report + # an empty version the Resolver mints back to nil. # # @param id [PackageId] source is the download URL - # @param filter [Hash] locator: optionally "tag" for version + # @param probe [String, nil] optional version label for the artifact # @return [Package] a singleton universe # @raise [DownloadError] if the download fails - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) url = T.must(id.source) path = download_to_tempfile(url, id.name) digest = "SHA256=#{Digest::SHA256.file(path).hexdigest}" @@ -44,7 +44,7 @@ def find(id, filter: {}) id: id, versions: [ PackageVersion.new( - version: filter["tag"].to_s, + version: probe.to_s, digest: digest, artifacts: { "default" => Artifact.new(uri: url, digest: digest) }, metadata: { "url" => url, "downloaded_path" => path }, diff --git a/lib/dev/deps/xcode_repository.rb b/lib/dev/deps/xcode_repository.rb index 8855f9a..809af1b 100644 --- a/lib/dev/deps/xcode_repository.rb +++ b/lib/dev/deps/xcode_repository.rb @@ -21,18 +21,18 @@ class XcodeRepository < Repository class MissingVersionError < StandardError; end - # Report the Xcode universe: the declared version, as a singleton. + # Report the Xcode universe: the probed version, as a singleton. # # Apple publishes no queryable version registry, so resolution is the - # identity — the filter's "version" IS the universe. + # identity — the probe IS the universe. # # @param id [PackageId] name is the declaration name - # @param filter [Hash] locator: "version" (exact, required) + # @param probe [String, nil] the pinned exact version; required # @return [Package] a singleton universe # @raise [MissingVersionError] when no exact version was declared - sig { override.params(id: PackageId, filter: T::Hash[String, T.untyped]).returns(Package) } - def find(id, filter: {}) - version = filter["version"].to_s + sig { override.params(id: PackageId, probe: T.nilable(String)).returns(Package) } + def find(id, probe: nil) + version = probe.to_s raise MissingVersionError, "xcode requires an exact version (e.g. xcode \"26.1.1\")" if version.empty? # An Xcode install is self-contained: Apple ships the whole toolchain. 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..04c830d --- /dev/null +++ b/test/dev/deps/brew_cask_repository_test.rb @@ -0,0 +1,36 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/brew_cask_repository" + +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 "find records a declared suffix as the version_suffix fact" do + Given "a cask pinned to a versioned spec" + repository = Dev::Deps::BrewCaskRepository.new + + When "finding with a probe" + package = repository.find( + Dev::Deps::PackageId.new(integration: :cask, name: "temurin"), + probe: "21", + ) + + Then + package.versions.first.metadata == { "cask" => true, "version_suffix" => "21" } + end +end diff --git a/test/dev/deps/brew_repository_test.rb b/test/dev/deps/brew_repository_test.rb index bb916cc..9994ec6 100644 --- a/test/dev/deps/brew_repository_test.rb +++ b/test/dev/deps/brew_repository_test.rb @@ -32,7 +32,7 @@ class Dev::Deps::BrewRepositoryTest < Minitest::Test package.version("3.31.4").metadata == {} end - test "find locates the suffixed formula via the version filter" do + test "find locates the suffixed formula via the probe" do Given "a formula declared with a version suffix and a tap" repository = Dev::Deps::BrewRepository.new brew_json = [{ @@ -44,33 +44,17 @@ class Dev::Deps::BrewRepositoryTest < Minitest::Test .with("brew", "info", "--json=v1", "someorg/sometap/llvm@18") .returns([brew_json, "", stub(success?: true)]) - When "finding with the suffix and tap as locator" + When "finding with the suffix as probe and the tap on the id" package = repository.find( - Dev::Deps::PackageId.new(integration: :brew, name: "llvm"), - filter: { "version" => "18", "tap" => "someorg/sometap" }, + Dev::Deps::PackageId.new(integration: :brew, name: "llvm", source: "someorg/sometap"), + probe: "18", ) - Then "the suffixed formula's stable version, with locator facts recorded" + Then "the suffixed formula's stable version, with the facts recorded" package.versions.map(&:version) == ["18.1.8"] package.version("18.1.8").metadata == { "tap" => "someorg/sometap", "version_suffix" => "18" } end - test "find reports a cask as one unversioned, undigested entry" do - Given "a cask declaration" - repository = Dev::Deps::BrewRepository.new - - When "finding with the cask flag" - package = repository.find( - Dev::Deps::PackageId.new(integration: :brew, name: "firefox"), - filter: { "cask" => true }, - ) - - Then "brew exposes no cask version here — an empty version stand-in" - package.versions.map(&:version) == [Dev::Deps::BrewRepository::UNVERSIONED] - package.versions.first.digest.nil? - package.versions.first.metadata == { "cask" => true } - end - 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 @@ -88,10 +72,9 @@ class Dev::Deps::BrewRepositoryTest < Minitest::Test .with("brew", "tap", "xcodesorg/made") .returns(["", "", stub(success?: true)]) - When "finding with the tap as locator" + When "finding with the tap on the id" package = repository.find( - Dev::Deps::PackageId.new(integration: :brew, name: "xcodes"), - filter: { "tap" => "xcodesorg/made" }, + Dev::Deps::PackageId.new(integration: :brew, name: "xcodes", source: "xcodesorg/made"), ) Then "the tap was registered and resolution succeeded on retry" diff --git a/test/dev/deps/cmake_integration_test.rb b/test/dev/deps/cmake_integration_test.rb index b1c0740..90dd500 100644 --- a/test/dev/deps/cmake_integration_test.rb +++ b/test/dev/deps/cmake_integration_test.rb @@ -13,7 +13,7 @@ require "dev/deps/dependency" require "dev/deps/package" require "dev/deps/package_version" -require "dev/deps/pinned_scheme" +require "dev/deps/git_scheme" require "pathname" require "tmpdir" @@ -23,7 +23,7 @@ def initialize(universes: {}) @universes = universes end - def find(id, filter: {}) + def find(id, probe: nil) Dev::Deps::Package.new(id: id, versions: @universes.fetch(id.name)) end end unless defined?(StubRepository) @@ -336,13 +336,13 @@ def prepopulate_dep(root, name) stub_repo = StubRepository.new(universes: { "googletest" => [universe] }) resolver = Dev::Deps::Resolver.new( repositories: { cmake: stub_repo }, - schemes: { cmake: Dev::Deps::PinnedScheme.new }, + schemes: { cmake: Dev::Deps::GitScheme.new }, ) declarations = [ Dev::Deps::ScopedDeclaration.new( declaration: Dev::Deps::Declaration.new( name: "googletest", integration: :cmake, - constraint: { "repo" => "https://github.com/google/googletest" }, + 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 d3b792f..8adb341 100644 --- a/test/dev/deps/config_test.rb +++ b/test/dev/deps/config_test.rb @@ -134,13 +134,13 @@ class Dev::Deps::ConfigTest < Minitest::Test decls.size == 2 decls[0].name == "boost" - decls[0].constraint["url"] == "https://example.com/boost.tar.gz" + decls[0].source == "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[1].name == "cereal" - decls[1].constraint["repo"] == "https://github.com/USCiLab/cereal" + decls[1].source == "https://github.com/USCiLab/cereal" decls[1].constraint["tag"] == "v1.3.2" end @@ -203,7 +203,7 @@ class Dev::Deps::ConfigTest < Minitest::Test brew_decls = config.declarations.select { |d| d.integration == :brew } brew_decls.map(&:name).sort == %w[cmake powershell ruby] brew_decls.all? { |d| d.scope.group == :build } - brew_decls.find { |d| d.name == "powershell" }.constraint["tap"] == "d3mlabs/d3mlabs" + 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" }.scope.env == "ci" brew_decls.find { |d| d.name == "ruby" }.constraint["env"].nil? diff --git a/test/dev/deps/dsl_test.rb b/test/dev/deps/dsl_test.rb index 73ab3e5..81e7376 100644 --- a/test/dev/deps/dsl_test.rb +++ b/test/dev/deps/dsl_test.rb @@ -16,14 +16,15 @@ class Dev::Deps::DSLTest < Minitest::Test end end - Then + Then "the url is the source coordinate; only the tag remains a constraint" decls = config.declarations decls.size == 1 decls[0].name == "boost" decls[0].integration == :cmake decls[0].scope.group == :app - decls[0].constraint["url"] == "https://example.com/boost.tar.gz" + decls[0].source == "https://example.com/boost.tar.gz" decls[0].constraint["tag"] == "boost-1.90.0" + !decls[0].constraint.key?("url") 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,7 +51,7 @@ 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 ScopedDeclaration with luarocks integration" do @@ -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 @@ -282,15 +297,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.scope.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.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 @@ -310,11 +325,11 @@ class Dev::Deps::DSLTest < Minitest::Test decl.name == "UnrealEngine" decl.integration == :gh decl.scope.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.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 +342,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 @@ -364,15 +379,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.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 diff --git a/test/dev/deps/ficsit_repository_test.rb b/test/dev/deps/ficsit_repository_test.rb index c7a76b4..31385e5 100644 --- a/test/dev/deps/ficsit_repository_test.rb +++ b/test/dev/deps/ficsit_repository_test.rb @@ -50,23 +50,21 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test When "finding the package" package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "AreaActions")) - Then "the whole universe is reported, facts attached" + 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 == "SHA256=deadbeef" + 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" - latest.metadata["game_version"] == ">=491125" - latest.metadata["target"] == "Windows" - package.version("2.4.0").digest == "SHA256=cafebabe" + latest.metadata == { "mod_id" => "abc123", "game_version" => ">=491125" } + package.version("2.4.0").artifacts["Windows"].digest == "SHA256=cafebabe" end - test "find with a platforms filter nests per-platform install facts" do + 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 = { @@ -94,56 +92,18 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) repo.stubs(:post_graphql).returns(stub_response) - When "finding with the nil default and LinuxServer requested" - package = repo.find( - Dev::Deps::PackageId.new(integration: :ficsit, name: "SML"), - filter: { "platforms" => [nil, "LinuxServer"] }, - ) + When "finding" + package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "SML")) - Then "install facts nest per platform and the top-level digest is nil" + Then "both targets are artifacts with their own digests; no install facts minted here" version = package.version("3.12.0") - version.digest.nil? - version.metadata["platforms"]["Windows"]["hash"] == "SHA256=winhash" - version.metadata["platforms"]["Windows"]["link"] == "https://api.ficsit.app/v1/version/ver1/Windows/download" - version.metadata["platforms"]["LinuxServer"]["hash"] == "SHA256=linuxhash" + 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 "find omits a requested platform this version does not publish" do - Given "a mod publishing 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 }], - "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 "finding with LinuxServer requested" - package = repo.find( - Dev::Deps::PackageId.new(integration: :ficsit, name: "SML"), - filter: { "platforms" => ["LinuxServer"] }, - ) - - Then "the block simply lacks the platform — disqualifying is the Resolver's call" - version = package.version("3.12.0") - version.metadata["platforms"] == {} - version.platforms == ["Windows"] - end - test "find raises ModNotFoundError, a PackageNotFoundError, for unknown mods" do Given "a repository returning null mod data" repo = Dev::Deps::FicsitRepository.new @@ -205,14 +165,11 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test stub_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) repo.stubs(:post_graphql).returns(stub_response) - When "finding with the LinuxServer platform requested" - package = repo.find( - Dev::Deps::PackageId.new(integration: :ficsit, name: "SML"), - filter: { "platforms" => ["LinuxServer"] }, - ) + When "finding" + package = repo.find(Dev::Deps::PackageId.new(integration: :ficsit, name: "SML")) Then "the link falls back to the /v1/version///download shape" - package.version("3.12.0").metadata["platforms"]["LinuxServer"]["link"] == + package.version("3.12.0").artifacts["LinuxServer"].uri == "https://api.ficsit.app/v1/version/ver1/LinuxServer/download" 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 1c42bbb..c5c52c5 100644 --- a/test/dev/deps/gh_repository_test.rb +++ b/test/dev/deps/gh_repository_test.rb @@ -34,84 +34,94 @@ def prebuilt_id ) end - def prebuilt_filter(overrides = {}) - { - "tag" => "5.6.1-css-83", - "assets" => "UnrealEngine-CSS-Editor-Linux.tar.zst.*", - "install_dir" => "~/.dev/engines/unreal-engine-css", - }.merge(overrides) - end - def source_id Dev::Deps::PackageId.new(integration: :gh, name: "UnrealEngine", source: "EpicGames/UnrealEngine") end - test "find reports the declared tag's release as a singleton universe" do + # Stub both facts the repository gathers for a tag: the commit SHA it + # points at and (optionally) the release it publishes. + def stub_tag(repo, slug:, tag:, sha: "abc123sha", release: :none) + repo.stubs(:run_gh_api) + .with("repos/#{slug}/commits/#{tag}") + .returns([JSON.generate({ "sha" => sha }), "", stub(success?: true)]) + release_response = if release == :none + ["", "gh: Not Found (HTTP 404)", stub(success?: false)] + else + [JSON.generate(release), "", stub(success?: true)] + end + repo.stubs(:run_gh_api) + .with("repos/#{slug}/releases/tags/#{tag}") + .returns(release_response) + end + + test "find reports the probed tag as a singleton with every release asset as facts" 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 "finding with the tag and asset glob as locator" - package = repo.find( - Dev::Deps::PackageId.new( - integration: :gh, name: "UnrealEngine", source: "satisfactorymodding/UnrealEngine", - ), - filter: { - "tag" => "5.6.1-css-83", - "assets" => "UnrealEngine-CSS-Editor-Linux.tar.zst.*", - "install_dir" => "~/.dev/engines/unreal-engine-css", - }, - ) + stub_tag(repo, slug: "satisfactorymodding/UnrealEngine", tag: "5.6.1-css-83", + sha: "css83sha", release: RELEASE_JSON) - Then "one version carrying the prebuilt install facts" + When "finding with the tag as the probe" + package = repo.find(prebuilt_id, probe: "5.6.1-css-83") + + Then "one version carrying the tag's facts — all assets, unselected" package.versions.map(&:version) == ["5.6.1-css-83"] version = package.version("5.6.1-css-83") version.digest.nil? version.metadata["repo"] == "satisfactorymodding/UnrealEngine" - version.metadata["asset_pattern"] == "UnrealEngine-CSS-Editor-Linux.tar.zst.*" - version.metadata["install_dir"] == "~/.dev/engines/unreal-engine-css" - version.metadata["assets"].map { |a| a["sha256"] } == ["aaaa1111", "bbbb2222"] + version.metadata["commit"] == "css83sha" + version.metadata["assets"].map { |a| a["sha256"] } == ["aaaa1111", "bbbb2222", "cccc3333"] end - test "find pins the source shape to the tag with its commit SHA" do - Given "a repository resolving a tag to a commit" + test "find reports a release-less tag as a source-only version — no assets fact" do + Given "a repository resolving a tag that publishes no release" repo = Dev::Deps::GhRepository.new - repo.stubs(:run_gh_api) - .with("repos/EpicGames/UnrealEngine/commits/5.6.1-release") - .returns([JSON.generate({ "sha" => "abc123sha" }), "", stub(success?: true)]) + stub_tag(repo, slug: "EpicGames/UnrealEngine", tag: "5.6.1-release", sha: "abc123sha") - When "finding with a build recipe instead of assets" - package = repo.find( - Dev::Deps::PackageId.new(integration: :gh, name: "UnrealEngine", source: "EpicGames/UnrealEngine"), - filter: { "tag" => "5.6.1-release", "build" => "make", "install_dir" => "~/.dev/engines/ue" }, - ) + When "finding the tag" + package = repo.find(source_id, probe: "5.6.1-release") - Then "the singleton version carries the source install facts" + Then "the singleton version carries the commit and no assets key" version = package.version("5.6.1-release") version.metadata["commit"] == "abc123sha" - version.metadata["build"] == "make" version.metadata["repo"] == "EpicGames/UnrealEngine" + !version.metadata.key?("assets") + end + + test "find claims an empty Resolved declaration set — self-contained by contract" do + Given "a repository with a stubbed tag" + repo = Dev::Deps::GhRepository.new + stub_tag(repo, slug: "EpicGames/UnrealEngine", tag: "v1") + + When "finding" + package = repo.find(source_id, probe: "v1") + + Then + package.version("v1").declarations == Dev::Deps::Declarations::Resolved.new([]) + end + + test "find raises MissingTagError without a probe — this universe needs a coordinate" do + Given "a repository" + repo = Dev::Deps::GhRepository.new + + When "finding without a tag" + repo.find(prebuilt_id) + + Then + raises Dev::Deps::GhRepository::MissingTagError end test "find raises ReleaseNotFoundError, a PackageNotFoundError, for a missing tag" do - Given "a gh api that 404s the release but sees the repo" + Given "a gh api that 404s the commit but sees the repo" repo = Dev::Deps::GhRepository.new repo.stubs(:run_gh_api) - .with("repos/satisfactorymodding/UnrealEngine/releases/tags/9.9.9-css-1") + .with("repos/satisfactorymodding/UnrealEngine/commits/9.9.9-css-1") .returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)]) repo.stubs(:run_gh_api) .with("repos/satisfactorymodding/UnrealEngine") .returns(["{}", "", stub(success?: true)]) When "finding a nonexistent tag" - repo.find( - Dev::Deps::PackageId.new( - integration: :gh, name: "UnrealEngine", source: "satisfactorymodding/UnrealEngine", - ), - filter: { "tag" => "9.9.9-css-1", "assets" => "*.tar.zst.*" }, - ) + repo.find(prebuilt_id, probe: "9.9.9-css-1") Then raises Dev::Deps::Repository::PackageNotFoundError @@ -124,10 +134,10 @@ def source_id "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)]) + stub_tag(repo, slug: "satisfactorymodding/UnrealEngine", tag: "v1.0", release: release) When "finding the release" - package = repo.find(prebuilt_id, filter: prebuilt_filter("assets" => "tool-Linux.tar.zst", "tag" => "v1.0")) + package = repo.find(prebuilt_id, probe: "v1.0") Then assets = package.version("v1.0").metadata["assets"] @@ -135,25 +145,13 @@ def source_id !assets[0].key?("sha256") end - test "find raises NoMatchingAssetsError when the pattern matches nothing" do - Given "a release without assets matching the pattern" - repo = Dev::Deps::GhRepository.new - repo.stubs(:run_gh_api).returns([JSON.generate(RELEASE_JSON), "", stub(success?: true)]) - - When "finding with a non-matching pattern" - repo.find(prebuilt_id, filter: prebuilt_filter("assets" => "*.7z.*")) - - Then - raises Dev::Deps::GhRepository::NoMatchingAssetsError - end - test "find raises RepoAccessError when the repo itself is invisible" do - Given "a 404 on both the release and the repo" + Given "a 404 on both the commit and the repo" repo = Dev::Deps::GhRepository.new repo.stubs(:run_gh_api).returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)]) When "finding in an inaccessible repo" - repo.find(prebuilt_id, filter: prebuilt_filter) + repo.find(prebuilt_id, probe: "5.6.1-css-83") Then raises Dev::Deps::GhRepository::RepoAccessError @@ -166,7 +164,7 @@ def source_id repo.stubs(:run_gh_api).returns(["", err, stub(success?: false)]) When "finding without authentication" - repo.find(prebuilt_id, filter: prebuilt_filter) + repo.find(prebuilt_id, probe: "5.6.1-css-83") Then raises Dev::Deps::GhRepository::AuthenticationError @@ -178,7 +176,7 @@ def source_id repo.stubs(:run_gh_api).returns(["", "gh: Internal Server Error (HTTP 500)", stub(success?: false)]) When "finding during an API outage" - repo.find(prebuilt_id, filter: prebuilt_filter) + repo.find(prebuilt_id, probe: "5.6.1-css-83") Then raises Dev::Deps::GhRepository::ApiError @@ -190,38 +188,26 @@ def source_id Open3.stubs(:capture3).raises(Errno::ENOENT.new("gh")) When "finding without gh installed" - repo.find(prebuilt_id, filter: prebuilt_filter) + repo.find(prebuilt_id, probe: "5.6.1-css-83") Then raises Dev::Deps::GhRepository::GhMissingError end - test "find 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 when the release fetch fails for a non-404 reason" do + Given "a resolvable commit but a flaky release endpoint" 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)]) + .with("repos/EpicGames/UnrealEngine/commits/v1") + .returns([JSON.generate({ "sha" => "abc" }), "", stub(success?: true)]) repo.stubs(:run_gh_api) - .with("repos/EpicGames/UnrealEngine") - .returns([JSON.generate({ "full_name" => "EpicGames/UnrealEngine" }), "", stub(success?: true)]) - - When "finding a nonexistent tag" - repo.find(source_id, filter: { "tag" => "9.9.9", "build" => "make" }) + .with("repos/EpicGames/UnrealEngine/releases/tags/v1") + .returns(["", "gh: Internal Server Error (HTTP 500)", stub(success?: false)]) - Then - raises Dev::Deps::GhRepository::ReleaseNotFoundError - end - - test "find source raises RepoAccessError when the repo is invisible (account not linked)" do - Given "a 404 on both the commit and the repo" - repo = Dev::Deps::GhRepository.new - repo.stubs(:run_gh_api).returns(["", "gh: Not Found (HTTP 404)", stub(success?: false)]) - - When "finding in an inaccessible repo" - repo.find(source_id, filter: { "tag" => "5.6.1-release", "build" => "make" }) + When "finding" + repo.find(source_id, probe: "v1") Then - raises Dev::Deps::GhRepository::RepoAccessError + raises Dev::Deps::GhRepository::ApiError end end diff --git a/test/dev/deps/git_repository_test.rb b/test/dev/deps/git_repository_test.rb index d9f4f6a..38f1676 100644 --- a/test/dev/deps/git_repository_test.rb +++ b/test/dev/deps/git_repository_test.rb @@ -16,18 +16,19 @@ class Dev::Deps::GitRepositoryTest < Minitest::Test .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 "finding with the tag as locator" + When "finding with the tag as probe" package = repo.find( Dev::Deps::PackageId.new( integration: :cmake, name: "googletest", source: "https://github.com/google/googletest", ), - filter: { "tag" => "v1.17.0" }, + probe: "v1.17.0", ) - Then "one version: the SHA, no digest (SHAs are identifiers, not integrity)" + Then "one version: the SHA, no digest, the resolved ref riding as a fact" package.versions.map(&:version) == [resolved_sha] package.version(resolved_sha).digest.nil? - package.version(resolved_sha).metadata == { "repo" => "https://github.com/google/googletest" } + package.version(resolved_sha).metadata == + { "repo" => "https://github.com/google/googletest", "ref" => "v1.17.0" } end test "find passes a 40-char commit SHA through without network calls" do @@ -35,18 +36,31 @@ class Dev::Deps::GitRepositoryTest < Minitest::Test repo = Dev::Deps::GitRepository.new sha = "ee3042f8b0279856061f91069a487e4ed6f69475" - When "finding with the commit as locator" + When "finding with the commit as probe" package = repo.find( Dev::Deps::PackageId.new( integration: :cmake, name: "entityx", source: "https://github.com/alecthomas/entityx", ), - filter: { "commit" => sha }, + probe: sha, ) Then package.versions.map(&:version) == [sha] end + test "find raises RefResolutionError when no ref is pinned" do + Given "a probe-less find" + repo = Dev::Deps::GitRepository.new + + When "finding" + repo.find( + Dev::Deps::PackageId.new(integration: :cmake, name: "boost", source: "https://example.com/boost"), + ) + + Then "commits are not enumerable — a coordinate is mandatory" + raises Dev::Deps::GitRepository::RefResolutionError + end + test "find raises RefResolutionError, a PackageNotFoundError, for a bad ref" do Given "a remote that knows no such ref" repo = Dev::Deps::GitRepository.new @@ -55,7 +69,7 @@ class Dev::Deps::GitRepositoryTest < Minitest::Test When "finding with an unresolvable tag" repo.find( Dev::Deps::PackageId.new(integration: :cmake, name: "ghost", source: "https://example.com/ghost"), - filter: { "tag" => "v0.0.0" }, + probe: "v0.0.0", ) Then diff --git a/test/dev/deps/pinned_scheme_test.rb b/test/dev/deps/pinned_scheme_test.rb deleted file mode 100644 index b0d4a3d..0000000 --- a/test/dev/deps/pinned_scheme_test.rb +++ /dev/null @@ -1,36 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "dev/deps/package_version" -require "dev/deps/pinned_scheme" - -transform!(RSpock::AST::Transformation) -class Dev::Deps::PinnedSchemeTest < Minitest::Test - def scheme - Dev::Deps::PinnedScheme.new - end - - def pv(version) - Dev::Deps::PackageVersion.new(version: version) - end - - test "every reported version satisfies every constraint" do - Expect "the backing service already narrowed the universe to the declared identity" - scheme.satisfies?(pv("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"), { "tag" => "v1.2.3" }) - scheme.satisfies?(pv("5.6.1-css-83"), { "tag" => "5.6.1-css-83" }) - scheme.satisfies?(pv("20240101"), { "buildid" => "20240101" }) - scheme.satisfies?(pv("26.1.1"), {}) - end - - test "sort preserves the repository-reported order" do - Given "versions in the order the repository reported them" - versions = ["current", "older"] - - When "sorting" - sorted = scheme.sort(versions) - - Then "the order is untouched — a pinned universe has no version order to impose" - sorted == ["current", "older"] - end -end diff --git a/test/dev/deps/resolver_test.rb b/test/dev/deps/resolver_test.rb index 0119f1c..9f10e4d 100644 --- a/test/dev/deps/resolver_test.rb +++ b/test/dev/deps/resolver_test.rb @@ -4,6 +4,7 @@ require "test_helper" require "dev/deps/resolver" require "dev/deps/repository" +require "dev/deps/artifact" require "dev/deps/package" require "dev/deps/package_id" require "dev/deps/package_version" @@ -11,11 +12,11 @@ require "dev/deps/declarations" require "dev/deps/scope" require "dev/deps/scoped_declaration" -require "dev/deps/pinned_scheme" +require "dev/deps/exact_scheme" require "dev/deps/semver_scheme" # Stub repository over a canned universe: name -> [PackageVersion, ...]. -# Records every find call (id + filter) for assertion. +# Records every find call (id + probe) for assertion. class StubRepository < Dev::Deps::Repository attr_reader :finds @@ -24,8 +25,8 @@ def initialize(universes: {}) @finds = [] end - def find(id, filter: {}) - @finds << { id: id, filter: filter } + def find(id, probe: nil) + @finds << { id: id, probe: probe } versions = @universes.fetch(id.name) do raise Dev::Deps::Repository::PackageNotFoundError, "no package #{id.name}" end @@ -38,9 +39,9 @@ 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: {}, - declarations: nil) + artifacts: {}, declarations: nil) Dev::Deps::PackageVersion.new( - version: v, digest: digest, platforms: platforms, + version: v, digest: digest, platforms: platforms, artifacts: artifacts, declarations: declarations || Dev::Deps::Declarations::Resolved.new(dependencies), metadata: metadata, ) @@ -57,16 +58,16 @@ def edge(name, constraint, integration: :ficsit) end # Shorthand: assemble the Declaration + Scope composition from flat kwargs. - def declaration(name:, integration:, constraint: {}, group: :app, platform: nil, - host: nil, env: nil, post_install: nil) + def declaration(name:, integration:, constraint: {}, source: 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:), + declaration: Dev::Deps::Declaration.new(name:, integration:, constraint:, source:), scope: Dev::Deps::Scope.new(group:, host:, env:), - platform:, post_install:, + platform:, post_install:, materialization:, ) end - def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) + def resolver_for(integration, repo, scheme: Dev::Deps::ExactScheme.new(key: "version")) Dev::Deps::Resolver.new(repositories: { integration => repo }, schemes: { integration => scheme }) end @@ -160,7 +161,10 @@ def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) 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::PinnedScheme.new, brew: Dev::Deps::PinnedScheme.new }, + schemes: { + bundler: Dev::Deps::ExactScheme.new(key: "version"), + brew: Dev::Deps::ExactScheme.new(key: "version"), + }, ) declarations = [ declaration(name: "ffi", integration: :bundler, group: :app), @@ -204,7 +208,7 @@ def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) 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::PinnedScheme.new }, + schemes: { ficsit: Dev::Deps::SemverScheme.new, brew: Dev::Deps::ExactScheme.new(key: "version") }, ) declarations = [ declaration(name: "zlib", integration: :brew, group: :app), @@ -234,24 +238,38 @@ def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) raises Dev::Deps::Resolver::UnknownIntegrationError end - test "passes the declaration constraint to find as the locator filter" do + test "passes the scheme-extracted pin to find as the probe, and the source on the id" do Given "a pinned-identity declaration (gh-style)" repo = StubRepository.new(universes: { "engine" => [version("5.8.0")] }) declarations = [ declaration(name: "engine", integration: :gh, group: :editor, - constraint: { "repo" => "d3mlabs/unreal-engine", "tag" => "5.8.0" }), + constraint: { "tag" => "5.8.0" }, source: "d3mlabs/unreal-engine"), ] - When "resolving" - resolver_for(:gh, repo).resolve(declarations) + When "resolving with the gh exact scheme" + resolver_for(:gh, repo, scheme: Dev::Deps::ExactScheme.new(key: "tag")).resolve(declarations) - Then "the constraint rode along as the filter, and repo/url became the id's source" - repo.finds[0][:filter]["tag"] == "5.8.0" + Then "the probe is the pinned tag and the declaration's source rides the id" + repo.finds[0][:probe] == "5.8.0" repo.finds[0][:id].source == "d3mlabs/unreal-engine" repo.finds[0][:id].name == "engine" repo.finds[0][:id].integration == :gh end + test "the probe is nil for range constraints — enumerable universes get no coordinate" do + Given "a semver-ranged declaration" + repo = StubRepository.new(universes: { "SML" => [version("3.12.0")] }) + declarations = [ + declaration(name: "SML", integration: :ficsit, group: :app, constraint: { "version" => "^3.0.0" }), + ] + + When "resolving" + resolver_for(:ficsit, repo, scheme: Dev::Deps::SemverScheme.new).resolve(declarations) + + Then + repo.finds[0][:probe].nil? + end + test "attaches host and env from the declaration onto minted metadata" do Given "declarations carrying the install-scoping axes" repo = StubRepository.new(universes: { @@ -271,7 +289,7 @@ def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) mac.metadata["host"] == "darwin" mac.metadata["repo"] == "d3mlabs/unreal-engine" result.find { |d| d.name == "ruby" }.metadata["env"] == "ci" - repo.finds.none? { |call| call[:filter].key?("host") || call[:filter].key?("env") } + repo.finds.all? { |call| call[:probe].nil? } end test "walks transitive edges, inheriting group, host, and env" do @@ -363,23 +381,86 @@ def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) repo.finds.size == 1 end - test "unions platforms across groups and resolves a duplicated dep once" do + 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"])], + "SML" => [version("3.12.0", platforms: ["Windows", "LinuxServer"], artifacts: artifacts)], }) declarations = [ - declaration(name: "SML", integration: :ficsit, group: :app), - declaration(name: "SML", integration: :ficsit, group: :integration, platform: "LinuxServer"), + declaration(name: "SML", integration: :ficsit, group: :app, materialization: { "target" => "Windows" }), + declaration(name: "SML", integration: :ficsit, group: :integration, platform: "LinuxServer", + materialization: { "target" => "Windows" }), ] When "resolving" result = resolver_for(:ficsit, repo).resolve(declarations) - Then "found once, with the union of both groups' platforms in the filter" + Then "found once; the pin's platforms block covers both groups' targets, no single target" result.size == 1 repo.finds.size == 1 - repo.finds[0][:filter]["platforms"].sort_by(&:to_s) == [nil, "LinuxServer"].sort_by(&:to_s) + 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 "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 = [ + declaration(name: "SML", integration: :ficsit, group: :app, materialization: { "target" => "Windows" }), + ] + + When "resolving" + result = resolver_for(:ficsit, repo).resolve(declarations) + + 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 "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 "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 @@ -402,16 +483,17 @@ def resolver_for(integration, repo, scheme: Dev::Deps::PinnedScheme.new) result[0].version == "3.12.0" end - test "omits platforms from the filter when no group pins a platform" do - Given "a dep declared only in groups without a platform" - repo = StubRepository.new(universes: { "boost" => [version("1.0")] }) - declarations = [declaration(name: "boost", integration: :cmake, group: :app)] + 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" - resolver_for(:cmake, repo).resolve(declarations) + result = resolver_for(:brew, repo).resolve(declarations) - Then "no platforms key leaks into the filter" - !repo.finds[0][:filter].key?("platforms") + Then + result[0].hash == "SHA256=abc" + !result[0].metadata.key?("platforms") end test "an empty chosen version string becomes a nil pin version" do 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_repository_test.rb b/test/dev/deps/steam_repository_test.rb index f1bc3e4..3c08136 100644 --- a/test/dev/deps/steam_repository_test.rb +++ b/test/dev/deps/steam_repository_test.rb @@ -6,60 +6,61 @@ transform!(RSpock::AST::Transformation) class Dev::Deps::SteamRepositoryTest < Minitest::Test - test "find reports the pinned buildid as a singleton universe" do - Given "a declaration with a pinned buildid" + def id + Dev::Deps::PackageId.new(integration: :steam, name: "SatisfactoryServer", source: "1690800") + end + + 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_build_id).raises("steamcmd should not be called") + Dev::Deps::SteamCmd.stubs(:resolve_branches) + .with(app: "1690800") + .returns({ "public" => "15321746", "experimental" => "15400000" }) - When "finding with the pin as locator" - package = repo.find( - Dev::Deps::PackageId.new(integration: :steam, name: "SatisfactoryServer"), - filter: { - "app" => 1690800, - "install_dir" => "~/.dev/satisfactory-server", - "buildid" => "15321746", - "platforms" => ["LinuxServer"], - }, - ) + When "finding" + package = repo.find(id) - Then "one version — the buildid — carrying the install facts, no digest" - package.versions.map(&:version) == ["15321746"] - version = package.version("15321746") - version.digest.nil? - version.metadata == { - "app" => "1690800", - "branch" => "public", - "install_dir" => "~/.dev/satisfactory-server", - "platform" => "linux", - } + 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 "find resolves the current branch 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 "finding" - package = repo.find( - Dev::Deps::PackageId.new(integration: :steam, name: "SatisfactoryServer"), - filter: { "app" => 1690800, "install_dir" => "/tmp/server" }, - ) + package = repo.find(id) Then + package.version("1").declarations == Dev::Deps::Declarations::Resolved.new([]) + end + + test "find ignores the probe — branch tips are enumerable in one query" do + Given "a stubbed branch listing" + repo = Dev::Deps::SteamRepository.new + Dev::Deps::SteamCmd.stubs(:resolve_branches).returns({ "public" => "99999" }) + + When "finding with a probe" + package = repo.find(id, probe: "anything") + + Then "the universe is the same, probe or not" package.versions.map(&:version) == ["99999"] end - test "find 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 "finding with a pinned buildid" - package = repo.find( - Dev::Deps::PackageId.new(integration: :steam, name: "SatisfactoryServer"), - filter: { "app" => 1690800, "install_dir" => "/tmp/server", "buildid" => "1" }, - ) + When "finding" + repo.find(id) Then - package.version("1").metadata["platform"] == "linux" + raises Dev::Deps::Repository::PackageNotFoundError end end diff --git a/test/dev/deps/url_repository_test.rb b/test/dev/deps/url_repository_test.rb index 2bba020..fe3e4e2 100644 --- a/test/dev/deps/url_repository_test.rb +++ b/test/dev/deps/url_repository_test.rb @@ -18,12 +18,12 @@ class Dev::Deps::UrlRepositoryTest < Minitest::Test expected_hash = "SHA256=#{Digest::SHA256.file(fake_tarball).hexdigest}" repo.stubs(:download_to_tempfile).returns(fake_tarball) - When "finding with the tag as locator" + When "finding with the tag as probe" package = repo.find( Dev::Deps::PackageId.new( integration: :cmake, name: "boost", source: "https://example.com/boost-1.90.0.tar.gz", ), - filter: { "tag" => "1.90.0" }, + probe: "1.90.0", ) Then "dev-enforced integrity: the downloaded bytes' SHA256 is the digest" diff --git a/test/dev/deps/xcode_repository_test.rb b/test/dev/deps/xcode_repository_test.rb index 7b41a97..aef3054 100644 --- a/test/dev/deps/xcode_repository_test.rb +++ b/test/dev/deps/xcode_repository_test.rb @@ -10,10 +10,10 @@ class Dev::Deps::XcodeRepositoryTest < Minitest::Test Given "an xcode declaration" repo = Dev::Deps::XcodeRepository.new - When "finding with the exact version as locator" + When "finding with the exact version as probe" package = repo.find( Dev::Deps::PackageId.new(integration: :xcode, name: "xcode"), - filter: { "version" => "26.1.1" }, + probe: "26.1.1", ) Then "resolution is the identity — no registry exists to consult" From 73383968e5998b71e04809aa83b248b712a23146 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 15:55:27 -0400 Subject: [PATCH 35/37] Cover the new schemes and the steam platform mapping directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExactScheme/GitScheme/SteamScheme/BrewScheme each get their own constraint-semantics tests (match, miss, unconstrained, pin, sort), and SteamIntegration#steam_platform_for gets a mapping table test — the provisioning fixture bypasses it, so nothing else executes it. Co-authored-by: Cursor --- test/dev/deps/brew_scheme_test.rb | 56 ++++++++++++++++++++ test/dev/deps/exact_scheme_test.rb | 49 ++++++++++++++++++ test/dev/deps/git_scheme_test.rb | 68 +++++++++++++++++++++++++ test/dev/deps/steam_integration_test.rb | 23 +++++++++ test/dev/deps/steam_scheme_test.rb | 51 +++++++++++++++++++ 5 files changed, 247 insertions(+) create mode 100644 test/dev/deps/brew_scheme_test.rb create mode 100644 test/dev/deps/exact_scheme_test.rb create mode 100644 test/dev/deps/git_scheme_test.rb create mode 100644 test/dev/deps/steam_scheme_test.rb diff --git a/test/dev/deps/brew_scheme_test.rb b/test/dev/deps/brew_scheme_test.rb new file mode 100644 index 0000000..4a2789e --- /dev/null +++ b/test/dev/deps/brew_scheme_test.rb @@ -0,0 +1,56 @@ +# 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 "pin extracts the suffix as the probe" do + When "pinning" + pinned = scheme.pin({ "version" => "18" }) + unpinned = scheme.pin({}) + + Then "the suffix is the access path to the llvm@18 formula spec" + pinned == "18" + unpinned.nil? + 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/exact_scheme_test.rb b/test/dev/deps/exact_scheme_test.rb new file mode 100644 index 0000000..d096389 --- /dev/null +++ b/test/dev/deps/exact_scheme_test.rb @@ -0,0 +1,49 @@ +# 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 "pin extracts the coordinate under the configured key" do + When "pinning" + pinned = scheme.pin({ "tag" => "5.6.1-css-83" }) + unpinned = scheme.pin({}) + + Then "the coordinate doubles as the find probe" + pinned == "5.6.1-css-83" + unpinned.nil? + 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/git_scheme_test.rb b/test/dev/deps/git_scheme_test.rb new file mode 100644 index 0000000..c729e8a --- /dev/null +++ b/test/dev/deps/git_scheme_test.rb @@ -0,0 +1,68 @@ +# 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 commit constraint matches the version string — the SHA itself" do + When "evaluating" + match = scheme.satisfies?(pv(SHA), { "commit" => SHA }) + miss = scheme.satisfies?(pv("0" * 40), { "commit" => SHA }) + + Then + match == true + miss == false + end + + test "a tag constraint matches the version's ref fact, not the SHA" do + When "evaluating a tag against a resolved SHA carrying its ref" + 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" }) + + 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 "pin extracts commit over tag as the probe" do + When "pinning" + commit_pin = scheme.pin({ "commit" => SHA, "tag" => "v1" }) + tag_pin = scheme.pin({ "tag" => "v1.17.0" }) + no_pin = scheme.pin({}) + + Then + commit_pin == SHA + tag_pin == "v1.17.0" + no_pin.nil? + 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/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_scheme_test.rb b/test/dev/deps/steam_scheme_test.rb new file mode 100644 index 0000000..593f5e4 --- /dev/null +++ b/test/dev/deps/steam_scheme_test.rb @@ -0,0 +1,51 @@ +# 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 + + test "pin is nil — branch tips are enumerable, no probe needed" do + When "pinning a fully constrained declaration" + result = scheme.pin({ "branch" => "public", "buildid" => "15321746" }) + + Then + result.nil? + end +end From fc4151d8cc8b3d81bfaba07e420929e7135f4b08 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 15:56:44 -0400 Subject: [PATCH 36/37] Docs: probe contract, per-ecosystem schemes, materialization channel, identity stance The find contract section, resolution pipeline, sequence diagram, constraint-semantics table, and new-ecosystem recipe now describe the probe/source/materialization channels instead of the retired filter hash. Also records the standing stance that version is never identity: same-package-twice is a per-context-resolution problem, not a PackageId problem. Co-authored-by: Cursor --- docs/deps-architecture.md | 83 ++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/docs/deps-architecture.md b/docs/deps-architecture.md index 3d17a42..2bb67a7 100644 --- a/docs/deps-architecture.md +++ b/docs/deps-architecture.md @@ -12,10 +12,10 @@ two roles. | Concept | Class | What it is | | --- | --- | --- | -| Identity | `PackageId` | Which package: `integration` + `name`, plus `source` for source-based deps (a git URL, a `owner/repo` slug). Value object, works as a Hash key. | -| 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 install facts). | -| Declaration | `Declaration` | The shared atom: name + integration + constraint, always in dev's shape (`{}` = unconstrained). 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`). 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. | +| 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, always in dev's shape (`{}` = unconstrained). 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 @@ -49,8 +49,8 @@ flowchart LR | Layer | Class(es) | The one question it answers | Never does | | --- | --- | --- | --- | -| Repository | `Repository#find(id, filter:) -> Package` | "What versions of this package exist, and what are their facts?" | Evaluate range constraints; choose among candidates | -| Scheme | `VersionScheme#satisfies?/#sort` | "Does this version satisfy this constraint, and how do versions order?" | Talk to the network; know about declarations | +| Repository | `Repository#find(id, probe:) -> Package` | "What versions of this package exist, and what are their facts?" | Evaluate range constraints; choose among candidates; see install instructions | +| Scheme | `VersionScheme#satisfies?/#sort/#pin` | "Does this version satisfy this constraint, how do versions order, and does the constraint name an exact coordinate?" | 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 | @@ -72,21 +72,26 @@ if a `*_repository.rb`, `*_integration.rb`, `*_scheme.rb`, or on disk. 2. **Resolve** — the `Resolver`, per declaration: - rejects declaration sets where one package (integration + name) - carries disagreeing constraints (axes — group/platform/host/env — - may differ; constraints may not; the same name under two + carries disagreeing constraints, sources, or materializations (axes + — group/platform/host/env — may differ; the same name under two integrations is two packages, free to differ); - - builds the `PackageId` (the constraint's `repo`/`url` becomes the - id's source) and calls `find`, passing the constraint hash as the - `filter` — a *locator*, not a predicate: pinned ecosystems need the - tag/buildid/suffix to know which singleton universe to report; + - builds the `PackageId` (the declaration's `source` rides the id) and + calls `find`, passing the scheme-extracted `pin` as the `probe` — a + single typed version coordinate, present only for non-enumerable + universes (a gh tag, a git ref, a brew suffix); enumerable universes + get no coordinate and report everything; - filters the reported versions through the integration's scheme - (`satisfies?`), treating scheme-unparseable universe versions as - non-candidates, and drops versions that don't publish every - explicitly requested platform; + (`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; - picks the highest satisfying version (`sort`), mints the - `Dependency` from that version's facts (digest → pin hash, metadata - → pin metadata), and projects the declaration's `Scope` onto the - pin's metadata (host/env keys, present only when pinned); + `Dependency` from that version's facts merged with the declaration's + `materialization` (install instructions meet version facts exactly + here), 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 @@ -123,14 +128,14 @@ sequenceDiagram cmd->>res: resolve(all declarations) Note over res: reject disagreeing constraints per (integration, name) - ConflictingDeclarationError loop until queue empty (declared + transitive) - res->>rep: find(PackageId, filter: constraint) + res->>rep: find(PackageId, probe: scheme.pin(constraint)) 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, project Scope onto metadata + 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 opt claim is Resolved Note over res: queue its Declarations as ScopedDeclarations under the parent's Scope end @@ -175,7 +180,16 @@ command. | ficsit | `SemverScheme` | node-style ranges (`^`, `~`, comparators) | | pip | `Pep440Scheme` | PEP 440 specifiers (`==`, `~=`, wildcards, conjunction) | | luarocks | `RockScheme` | rockspec-style comparators and `~>` | -| brew, cmake, gh, steam, xcode | `PinnedScheme` | the constraint names an identity (formula suffix, tag/commit, release tag, buildid, exact version); the repository already applied it as the find locator, so every reported version satisfies | +| gh, xcode, url | `ExactScheme(key:)` | the constraint names one exact coordinate (release tag, exact version, url label) under the configured key; no range grammar exists by design. The coordinate doubles as the find probe. | +| cmake | `GitScheme` | `commit:` matches the version (the resolved SHA); `tag:` matches the version's `ref` fact. The ref doubles as the probe — `ls-remote` lists refs, never reachable SHAs. | +| 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. No probe: branch tips enumerate in one query. | +| brew, cask | `BrewScheme` | `version:` is a formula *suffix* (`"18"` selects the `llvm@18` formula spec), matched against the `version_suffix` fact; the reported stable version is brew's record, not the coordinate. The suffix doubles as the probe. | + +A scheme's `pin(constraint)` answers "does this constraint name one exact +coordinate?" — non-nil only for ecosystems whose universes are not +enumerable (gh tags, git refs, brew suffixes), where the Resolver hands it +to `find` as the `probe`. Enumerable ecosystems return nil and their +repositories report everything. **The constraint standard is a shape plus an interpreter, never a grammar.** Every constraint in the system is a dev-shaped hash whose keys @@ -245,18 +259,23 @@ Two standing decisions: ## Adding a new ecosystem 1. **Repository** — subclass `Repository`, implement - `find(id, filter:) -> Package`. Report facts for every version you can - enumerate; if the ecosystem's constraint names an identity, use the - filter as your locator and report the (usually singleton) universe. - 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** — if the ecosystem has a native range language, subclass - `VersionScheme` with its `satisfies?`/`sort`, nesting + `find(id, probe:) -> Package`. Report facts for every version you can + enumerate; if the ecosystem's universe is not enumerable (each version + must be asked about by coordinate), require the `probe` and report the + singleton it addresses. 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 constraints are identities, use `PinnedScheme`. + If the constraint names one exact coordinate, `ExactScheme(key:)` + probably already covers you; override `pin` when your universe needs + the coordinate as its access path. Every ecosystem states its real + constraint semantics — there is no satisfies-everything scheme. 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 From 8b1c05c33f6b768327f50afaf4449ce6ff4c7384 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 6 Sep 2026 15:57:29 -0400 Subject: [PATCH 37/37] Rubocop: layout autocorrect in exact scheme test Co-authored-by: Cursor --- test/dev/deps/exact_scheme_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dev/deps/exact_scheme_test.rb b/test/dev/deps/exact_scheme_test.rb index d096389..6230a12 100644 --- a/test/dev/deps/exact_scheme_test.rb +++ b/test/dev/deps/exact_scheme_test.rb @@ -23,7 +23,7 @@ def pv(version) result == expected Where - version | tag | expected + 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