diff --git a/README.md b/README.md index e44415e..d2d3e1a 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,11 @@ end Four orthogonal axes scope a declaration; each answers a different question: - **`group`** — *purpose* (`:app`, `:test`, `:build`, `:game`, `:editor`, …). User-defined; `:build` installs first. + The vocabulary is dev-owned and integrations translate it natively (e.g. group names become bundler groups in the generated Gemfile): + - `:app` — runtime dependencies the project needs to run. The default: declarations outside any group block land here, mirroring a hand-written Gemfile's top section. + - `:development` / `:test` — the human's workbench and the test suite, per the Gemfile convention. + - `:build` — the build-time toolchain (compilers, caches, codegen). Installed before every other group, and the group `bin/install-build-deps.rb` materializes into container build images. + - Domain groups (`:game`, `:editor`, `:integration`, …) are welcome — any symbol works; the name is documentation plus a filter handle. - **`env`** — *execution context* the dep is for (`"ci"` / `"dev"`), declared via `env :ci do ... end` inside a group. Filtered at install against the detected env (`CI` variable only — a Linux workstation is `dev`, a Mac CI runner is `ci`). - **`host`** — *OS of the machine the dep installs on* (`:darwin` / `:linux`). Declared per-group (`group :editor, host: :darwin do ... end`) or per-declaration (`gh ..., host: :linux`). Filtered at install against the detected host OS — deps for other hosts are still resolved and locked, so the lockfile stays the single source of truth for every machine. - **`platform`** — *what artifact variant the dep targets* (e.g. `"LinuxServer"`), for multi-arch integrations like ficsit. A resolve-time concern, not an install filter. @@ -338,7 +343,7 @@ All built-in integrations are declared in one place — `lib/dev/deps/registry.r `xcode "26.1.1"` pins the Xcode toolchain (macOS only; a no-op on other hosts). dev installs the pin to `/Applications/Xcode-.app` via the [xcodes](https://github.com/XcodesOrg/xcodes) CLI — declare `brew "xcodes", host: :darwin` in `:build` so it exists first — and publishes `DEVELOPER_DIR` into the project shadowenv. Interactive runs pass any Apple ID/2FA/sudo prompt through to you; headless runs fail fast with remediation instead of hanging (normal practice: pre-install the pin interactively once during machine bring-up, e.g. a CI runner's). -`gem()` declares Ruby gems: dev generates a `Gemfile`/`Gemfile.lock` from your declarations (a top-level `gem` lands in the default group; `group(:test) { gem ... }` scopes it to a bundler group), and `dev install-deps` runs `bundle install`. `brew()` dual-writes — the container build path keeps reading the group structure while `dev install-deps` also installs the formulae on the host (idempotently). +`gem()` declares Ruby gems: dev generates a `Gemfile`/`Gemfile.lock` from your declarations (a top-level `gem` lands in the default group; `group(:test) { gem ... }` scopes it to a bundler group), and `dev install-deps` runs `bundle install`. Every verb works at the top level too and lands in `:app`. Declarations are the single data model: `brew()` entries ride the resolver → lockfile → install pipeline on the host (idempotently), and the container build path (`bin/install-build-deps.rb`) derives its install list from the same `:build`-group declarations. `python "3.12"` pins the Python toolchain: dev provisions the interpreter (Homebrew `python@3.12`) and a project-local `.venv`, and publishes it into the project shadowenv (`VIRTUAL_ENV` + `.venv/bin` on `PATH`). `pip()` declares packages installed into that venv — like `luarocks()`, you declare only the top-level packages and pip resolves the transitive tree at install time. Gate heavy, platform-specific stacks (e.g. a PyTorch-backed ML tool) with `host:` so only the machines that use them pay the download. diff --git a/bin/install-build-deps.rb b/bin/install-build-deps.rb index d3380da..1ef1270 100755 --- a/bin/install-build-deps.rb +++ b/bin/install-build-deps.rb @@ -22,68 +22,55 @@ config = Dev::Deps.last_config abort "No config found — dependencies.rb must call Dev::Deps.define" unless config -env = "ci" -build_group = config.group("build") - -config.taps.each do |tap| - puts ">>> Registering tap: #{tap.name}" - system("brew", "tap", tap.name) || abort("brew tap #{tap.name} failed") -end - -# Collect brew entries: global + matching env scope. -brew_entries = Array(build_group["brew"]) -env_section = build_group.dig("env", env) -brew_entries += Array(env_section["brew"]) if env_section +ENV_NAME = "ci" # Host OS of the container being built, for host-gated brew entries. Docker # builds are Linux; the constant spares a RUBY_PLATFORM sniff that could never # say anything else here. -HOST = "linux" +HOST = :linux -def install_brew_entry(entry) - case entry - when String - puts ">>> Installing: #{entry}" - system("brew", "install", entry) || abort("brew install #{entry} failed") - 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. - host = opts["host"] - if host && host.to_s != HOST - puts ">>> Skipping #{name} (host: #{host})" - next - end - - post_install = opts.delete("post_install") - tap = opts["tap"] - version = opts["version"] - cask = opts["cask"] +config.taps.each do |tap| + puts ">>> Registering tap: #{tap.name}" + system("brew", "tap", tap.name) || abort("brew tap #{tap.name} failed") +end - if cask - puts ">>> Installing cask: #{name}" - system("brew", "install", "--cask", name) || abort("brew install --cask #{name} failed") - else - spec = if tap - version_suffix = version ? "@#{version}" : "" - "#{tap}/#{name}#{version_suffix}" - elsif version - "#{name}@#{version}" - else - name - end - puts ">>> Installing: #{spec}" - system("brew", "install", spec) || abort("brew install #{spec} failed") - end +def install_brew_declaration(decl) + tap = decl.constraint["tap"] + version = decl.constraint["version"] - if post_install - puts ">>> Running post_install for #{name}" - post_install.call(name, opts) - end + if decl.constraint["cask"] + puts ">>> Installing cask: #{decl.name}" + system("brew", "install", "--cask", decl.name) || abort("brew install --cask #{decl.name} failed") + else + spec = if tap + version_suffix = version ? "@#{version}" : "" + "#{tap}/#{decl.name}#{version_suffix}" + elsif version + "#{decl.name}@#{version}" + else + decl.name end + puts ">>> Installing: #{spec}" + system("brew", "install", spec) || abort("brew install #{spec} failed") end + + if decl.post_install + puts ">>> Running post_install for #{decl.name}" + decl.post_install.call(decl.name, decl.constraint) + end +end + +# The build image materializes the :build group's brew declarations for this +# env — the same declarations the resolver/lockfile pipeline reads; env/host +# filtering mirrors DependencyInstaller (nil means "everywhere"). +build_brews = config.declarations.select do |decl| + decl.integration == :brew && + decl.group == :build && + (decl.env.nil? || decl.env == ENV_NAME) end -brew_entries.each { |entry| install_brew_entry(entry) } +hosted, skipped = build_brews.partition { |decl| decl.host.nil? || decl.host == HOST } +skipped.each { |decl| puts ">>> Skipping #{decl.name} (host: #{decl.host})" } +hosted.each { |decl| install_brew_declaration(decl) } puts ">>> All build dependencies installed" diff --git a/lib/dev/deps/config.rb b/lib/dev/deps/config.rb index 9e2df5b..540a966 100644 --- a/lib/dev/deps/config.rb +++ b/lib/dev/deps/config.rb @@ -7,21 +7,19 @@ module Dev module Deps # Parsed dependency configuration. Returned by Dev::Deps.define. class Config - attr_reader :taps, :groups, :declarations, :ruby_version_requirement, + attr_reader :taps, :declarations, :ruby_version_requirement, :lua_version, :python_version, :registered_integrations # @param taps [Array] declared Homebrew taps - # @param groups [Hash] group name → { "brew" => [...], "env" => {...} } # @param declarations [Array] all declared dependencies # (gems are :bundler declarations, brew formulae are :brew declarations, etc.) # @param ruby_version_requirement [String, nil] required Ruby version # @param lua_version [String, nil] Lua version for LuaRocks # @param python_version [String, nil] Python minor version for the pip venv # @param registered_integrations [Hash{Symbol => Class}] custom integration registrations - def initialize(taps:, groups:, declarations:, ruby_version_requirement:, + def initialize(taps:, declarations:, ruby_version_requirement:, lua_version:, python_version:, registered_integrations:) @taps = taps - @groups = groups @declarations = declarations @ruby_version_requirement = ruby_version_requirement @lua_version = lua_version @@ -29,14 +27,6 @@ def initialize(taps:, groups:, declarations:, ruby_version_requirement:, @registered_integrations = registered_integrations end - # Return the config for a named group, with safe defaults for missing groups. - # - # @param name [String, Symbol] group name - # @return [Hash] - def group(name) - @groups[name.to_s] || { "brew" => [], "env" => {} } - end - class << self # Evaluate a DSL block and return a Config instance. # @@ -52,7 +42,6 @@ def define(&block) new( taps:, - groups: dsl.groups, declarations: dsl.declarations, ruby_version_requirement: dsl.ruby_version_requirement, lua_version: dsl.lua_version_value, diff --git a/lib/dev/deps/dsl.rb b/lib/dev/deps/dsl.rb index d30589d..1ba1e57 100644 --- a/lib/dev/deps/dsl.rb +++ b/lib/dev/deps/dsl.rb @@ -4,194 +4,27 @@ module Dev module Deps - # Top-level DSL evaluated inside Dev::Deps.define { ... }. - class DSL - # 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 - - def initialize - @taps = {} - @groups = {} - @declarations = [] - @ruby_version_requirement = nil - @lua_version_value = nil - @python_version_value = nil - @registered_integrations = {} - @registered_methods = [] - end - - # Declare the project's Ruby toolchain — a first-class dependency, on equal - # footing with brew/cmake/gh. dev provisions this exact version (rbenv + - # shadowenv) before any command and writes it as the generated Gemfile's - # `ruby` directive. It is resolved specially (early, pre-dispatch) rather than - # through the resolver -> lockfile -> install pipeline because it is the - # interpreter every other dependency and command runs under. - # - # @param version [String, Symbol] exact Ruby version (e.g. "4.0.5") - def ruby(version) - @ruby_version_requirement = version.to_s.strip - end - - # Declare the Lua version for LuaRocks integration. - # - # @param version [String, Symbol] Lua version (e.g. "5.1") - def lua_version(version) - @lua_version_value = version.to_s.strip - end - - # Declare the project's Python toolchain. Like `ruby`, this is a first-class - # toolchain: dev provisions the interpreter (Homebrew python@) and a - # project-local .venv (ShadowenvPython) before commands run, and pip deps - # (declared with `pip` inside a group) install into that venv. Resolved - # specially (pre-dispatch), not through the resolver -> lockfile pipeline. - # - # @param version [String, Symbol] Python minor version (e.g. "3.12") - def python(version) - @python_version_value = version.to_s.strip - end - - # Declare a Ruby gem. Gems are a first-class dev-managed dependency type - # backed by bundler: this records a :bundler declaration that rides the - # normal resolver -> lockfile -> install pipeline (dev generates the - # Gemfile/Gemfile.lock from these). A top-level gem lands in the default - # group; use a group block to scope it (e.g. group(:test) { gem ... }). - # - # @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:) - 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, - ) - end - - def tap(name, url: nil) - name_str = name.to_s - @taps[name_str] = { - "name" => name_str, - "url" => url && url.to_s, - } - end - - # Register a custom integration: maps the name to an Integration class - # and creates a DSL method so it can be used inside group blocks. - # - # @param name [Symbol, String] integration identifier (e.g. :wow_curseforge) - # @param klass [Class, String] Integration subclass or its name - def register(name, klass) - sym = name.to_sym - @registered_integrations[sym] = klass - @registered_methods << sym - end - - # Declare a dependency group, optionally pinned to a platform and/or host OS. - # - # @param name [String, Symbol] group name (e.g. :app, :test, :integration) - # @param platform [String, nil] platform the group's deps target (e.g. "LinuxServer"). - # Stamped onto every declaration in the group so the resolver can union platforms - # across groups for multi-arch integrations. nil lets each integration pick its default. - # @param host [Symbol, nil] host OS the group's deps install on (:darwin / :linux). - # 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). - 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) - group_dsl.instance_eval(&block) if block - @groups[group_name] = group_dsl.to_h - @declarations.concat(group_dsl.declarations) - end - end - - # DSL for per-environment entries (inside group :build for env-specific brew). - class EnvDSL - class EmptyNameError < StandardError; end - - 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 - def initialize(group: :app, platform: nil, host: nil, env: nil) - @brew = [] - @declarations = [] - @group = group - @platform = platform - @host = host - @env = env - end - - def brew(name, **opts) - name_str = name.to_s - raise EmptyNameError, "brew dependency name cannot be empty" if name_str.empty? - - if opts.empty? - @brew << name_str - else - @brew << { name_str => stringify_keys(opts) } - end - @declarations << DependencyDeclaration.new( - name: name_str, - integration: :brew, - constraint: stringify_keys(opts), - group: @group, - platform: @platform, - host: @host, - env: @env, - ) - end - - def to_h - { "brew" => @brew } - end - - private - - def stringify_keys(hash) - hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v } - end - end - - # DSL for group-scoped deps: declarations (app/test), brew + nested env (build). - class GroupDSL + # The dependency verbs, shared by the top-level DSL (implicit default + # group) and named group blocks. Every verb funnels into one + # DependencyDeclaration append — the declarations list is the single data + # model; consumers (resolver, lockfile, the container image build) derive + # their views from it. The includer provides the group context via + # @group / @platform / @host and the declaration sink via @declarations. + module DependencyVerbs class EmptyNameError < StandardError; end 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 - def initialize(group:, platform: nil, host: nil, registered_methods: []) - @group = group - @platform = platform - @host = host - @declarations = [] - @brew = [] - @envs = {} - @registered_methods = registered_methods - end - # Declare a CMake dependency. Expands github: shorthand if present. # # @param name [String, Symbol] dependency name # @param spec [Hash] options (tag:, repo:, url:, github:, etc.) def cmake(name, **spec) - spec = expand_github(name, spec) add_declaration(name, :cmake, spec) end - # Declare a Ruby gem scoped to this group (group name -> bundler group). + # Declare a Ruby gem (group name -> bundler group; the top level is + # bundler's default group, mirroring a hand-written Gemfile). # # @param name [String, Symbol] gem name # @param version [String, nil] version requirement (e.g. "~> 1.17") @@ -313,42 +146,15 @@ def xcode(version, **spec) add_declaration("xcode", :xcode, spec) end - # Declare a Homebrew formula/cask. - # - # Dual-writes: the existing @brew/groups entry feeds the container build - # path (bin/install-build-deps.rb), while the additional :brew declaration - # 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. + # Declare a Homebrew formula/cask. One :brew declaration rides the + # resolver -> lockfile -> install pipeline; the container image build + # (bin/install-build-deps.rb) derives its list from the same + # declarations — there is no separate build-image data structure. # # @param name [String, Symbol] formula or cask name - # @param opts [Hash] options (tap:, version:, cask:) + # @param opts [Hash] options (tap:, version:, cask:, post_install:) def brew(name, **opts) - name_str = name.to_s - raise EmptyNameError, "brew dependency name cannot be empty" if name_str.empty? - - if opts.empty? - @brew << name_str - else - @brew << { name_str => stringify_keys(opts) } - end - add_declaration(name_str, :brew, opts.dup) - end - - # Scope member declarations to an environment ("ci" / "dev"). The env - # name is a first-class declaration field (like host), landing in the - # lockfile's env section so install-deps filters it to the matching - # environment — never smuggled through the constraint hash. - def env(name, &block) - env_name = name.to_s - env_dsl = EnvDSL.new(group: @group, platform: @platform, host: @host, env: env_name) - env_dsl.instance_eval(&block) if block - @envs[env_name] = env_dsl.to_h - @declarations.concat(env_dsl.declarations) - end - - def to_h - { "brew" => @brew, "env" => @envs, "platform" => @platform } + add_declaration(name, :brew, opts) end # Dispatch dynamically registered integration methods (e.g. wow_curseforge). @@ -376,7 +182,9 @@ def respond_to_missing?(method_name, include_private = false) # 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. + # which describes what the dep is, not where it installs. github: is + # expanded to a full repo: URL. env comes from the includer (an env + # block), nil elsewhere. # # @param name [String, Symbol] dependency name # @param integration [Symbol] integration type @@ -397,6 +205,7 @@ def add_declaration(name, integration, spec) group: @group, platform: @platform, host:, + env: @env, post_install:, ) end @@ -425,5 +234,147 @@ def stringify_keys(hash) hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v } end end + + # Top-level DSL evaluated inside Dev::Deps.define { ... }. Dependency + # verbs work here exactly as inside a group block — top-level declarations + # land in the default group (bundler's unscoped group, mirroring a + # hand-written Gemfile's top section). + class DSL + include DependencyVerbs + + # Group a top-level declaration lands in when no group block scopes it. + DEFAULT_GEM_GROUP = :app + + attr_reader :taps, :ruby_version_requirement, + :lua_version_value, :python_version_value, :registered_integrations, :registered_methods + + def initialize + @taps = {} + @declarations = [] + @group = DEFAULT_GEM_GROUP + @platform = nil + @host = nil + @env = nil + @ruby_version_requirement = nil + @lua_version_value = nil + @python_version_value = nil + @registered_integrations = {} + @registered_methods = [] + end + + # Declare the project's Ruby toolchain — a first-class dependency, on equal + # footing with brew/cmake/gh. dev provisions this exact version (rbenv + + # shadowenv) before any command and writes it as the generated Gemfile's + # `ruby` directive. It is resolved specially (early, pre-dispatch) rather than + # through the resolver -> lockfile -> install pipeline because it is the + # interpreter every other dependency and command runs under. + # + # @param version [String, Symbol] exact Ruby version (e.g. "4.0.5") + def ruby(version) + @ruby_version_requirement = version.to_s.strip + end + + # Declare the Lua version for LuaRocks integration. + # + # @param version [String, Symbol] Lua version (e.g. "5.1") + def lua_version(version) + @lua_version_value = version.to_s.strip + end + + # Declare the project's Python toolchain. Like `ruby`, this is a first-class + # toolchain: dev provisions the interpreter (Homebrew python@) and a + # project-local .venv (ShadowenvPython) before commands run, and pip deps + # (declared with `pip` inside a group) install into that venv. Resolved + # specially (pre-dispatch), not through the resolver -> lockfile pipeline. + # + # @param version [String, Symbol] Python minor version (e.g. "3.12") + def python(version) + @python_version_value = version.to_s.strip + end + + def tap(name, url: nil) + name_str = name.to_s + @taps[name_str] = { + "name" => name_str, + "url" => url && url.to_s, + } + end + + # Register a custom integration: maps the name to an Integration class + # and creates a DSL method so it can be used in any declaration context. + # + # @param name [Symbol, String] integration identifier (e.g. :wow_curseforge) + # @param klass [Class, String] Integration subclass or its name + def register(name, klass) + sym = name.to_sym + @registered_integrations[sym] = klass + @registered_methods << sym + end + + # Declare a dependency group, optionally pinned to a platform and/or host OS. + # + # @param name [String, Symbol] group name (e.g. :app, :test, :integration) + # @param platform [String, nil] platform the group's deps target (e.g. "LinuxServer"). + # Stamped onto every declaration in the group so the resolver can union platforms + # across groups for multi-arch integrations. nil lets each integration pick its default. + # @param host [Symbol, nil] host OS the group's deps install on (:darwin / :linux). + # 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). + def group(name, platform: nil, host: nil, &block) + group_dsl = GroupDSL.new(group: name.to_sym, platform:, host:, registered_methods: @registered_methods) + group_dsl.instance_eval(&block) if block + @declarations.concat(group_dsl.declarations) + end + end + + # DSL for per-environment entries (inside a group's env block); every + # member declaration is stamped with the environment name. + class EnvDSL + include DependencyVerbs + + # @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 + # @param registered_methods [Array] dynamically registered integration methods + def initialize(group: :app, platform: nil, host: nil, env: nil, registered_methods: []) + @declarations = [] + @group = group + @platform = platform + @host = host + @env = env + @registered_methods = registered_methods + end + end + + # DSL for group-scoped declarations, plus nested env blocks. + class GroupDSL + include DependencyVerbs + + # @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 + def initialize(group:, platform: nil, host: nil, registered_methods: []) + @group = group + @platform = platform + @host = host + @env = nil + @declarations = [] + @registered_methods = registered_methods + end + + # Scope member declarations to an environment ("ci" / "dev"). The env + # name is a first-class declaration field (like host), landing in the + # lockfile's env section so install-deps filters it to the matching + # environment — never smuggled through the constraint hash. + def env(name, &block) + env_dsl = EnvDSL.new(group: @group, platform: @platform, host: @host, + env: name.to_s, registered_methods: @registered_methods) + env_dsl.instance_eval(&block) if block + @declarations.concat(env_dsl.declarations) + end + end end end diff --git a/test/dev/deps/config_test.rb b/test/dev/deps/config_test.rb index bd3ca1e..4db2788 100644 --- a/test/dev/deps/config_test.rb +++ b/test/dev/deps/config_test.rb @@ -87,11 +87,11 @@ class Dev::Deps::ConfigTest < Minitest::Test end Then - build = config.group("build") - build["brew"].size == 3 - build["brew"][0] == "ccache" - build["brew"][1] == "cmake" - build["brew"][2] == { "powershell" => { "version" => "7.4.0", "tap" => "d3mlabs/d3mlabs" } } + brews = config.declarations.select { |d| d.integration == :brew } + brews.map(&:name) == %w[ccache cmake powershell] + brews.all? { |d| d.group == :build } + brews[0].constraint == {} + brews[2].constraint == { "version" => "7.4.0", "tap" => "d3mlabs/d3mlabs" } end test "define build group with env-specific brew" do @@ -108,10 +108,12 @@ class Dev::Deps::ConfigTest < Minitest::Test end end - Then - build = config.group("build") - build["env"]["ci"]["brew"] == ["ruby"] - build["env"]["dev"]["brew"] == [{ "powershell" => { "cask" => true } }] + Then "env lands as a first-class field on each declaration" + config.declarations.find { |d| d.name == "cmake" }.env.nil? + config.declarations.find { |d| d.name == "ruby" }.env == "ci" + powershell = config.declarations.find { |d| d.name == "powershell" } + powershell.env == "dev" + powershell.constraint == { "cask" => true } end test "define app group with cmake deps produces declarations" do @@ -160,14 +162,12 @@ class Dev::Deps::ConfigTest < Minitest::Test decl.constraint["cmake_targets"] == ["gtest", "gmock"] end - test "missing group returns empty defaults" do + test "an empty define produces no declarations" do When config = Dev::Deps.define {} Then - nonexistent = config.group("nonexistent") - nonexistent["brew"] == [] - nonexistent["env"] == {} + config.declarations == [] end test "cmake dep with commit pin" do @@ -185,7 +185,7 @@ class Dev::Deps::ConfigTest < Minitest::Test decl.constraint["commit"] == "ee3042f8b027" end - test "brew dual-writes to both groups and declarations" do + test "brew declarations are the single source for host installs and image builds" do When config = Dev::Deps.define do group :build do @@ -197,9 +197,7 @@ class Dev::Deps::ConfigTest < Minitest::Test end end - Then "the container groups path is unchanged and brew also rides the lockfile pipeline" - config.group("build")["brew"].size == 2 - config.group("build")["env"]["ci"]["brew"] == ["ruby"] + Then "every brew entry is one declaration carrying its full install metadata" 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 } @@ -208,4 +206,34 @@ class Dev::Deps::ConfigTest < Minitest::Test brew_decls.find { |d| d.name == "ruby" }.env == "ci" brew_decls.find { |d| d.name == "ruby" }.constraint["env"].nil? end + + test "top-level verbs land in the default group like a top-level gem does" do + When "declaring brew and gh deps outside any group block" + config = Dev::Deps.define do + brew "shellcheck" + gh "css-engine", github: "example/css-engine", tag: "v1.0", + assets: "*.tar.gz", install_dir: "~/.dev/css-engine" + end + + Then "both land in the default group with no platform or host" + config.declarations.size == 2 + config.declarations.all? { |d| d.group == Dev::Deps::DSL::DEFAULT_GEM_GROUP } + config.declarations.all? { |d| d.platform.nil? && d.host.nil? } + config.declarations[0].integration == :brew + config.declarations[1].integration == :gh + end + + test "top-level declarations keep source order alongside grouped ones" do + When "interleaving top-level and grouped declarations" + config = Dev::Deps.define do + gem "first" + group :test do + gem "second" + end + brew "third" + end + + Then "declarations preserve manifest source order (groups append at close)" + config.declarations.map(&:name) == %w[first second third] + end end diff --git a/test/dev/deps/dsl_test.rb b/test/dev/deps/dsl_test.rb index 5eb149a..d246e81 100644 --- a/test/dev/deps/dsl_test.rb +++ b/test/dev/deps/dsl_test.rb @@ -387,7 +387,7 @@ class Dev::Deps::DSLTest < Minitest::Test config.declarations[0].constraint["buildid"] == "15321746" end - test "brew with post_install stores callable in opts" do + test "brew with post_install stores the callable on the declaration" do Given "a post_install callable" hook = ->(name, opts) {} @@ -398,11 +398,54 @@ class Dev::Deps::DSLTest < Minitest::Test end end + Then "the hook is a first-class field, never a constraint key" + decl = config.declarations[0] + decl.post_install == hook + decl.constraint == { "tap" => "d3mlabs/d3mlabs" } + end + + test "env-scoped brew peels post_install and host onto the declaration" do + Given "a post_install callable" + hook = ->(name, opts) {} + + When "declaring an env-scoped brew dep with post_install and host" + config = Dev::Deps.define do + group :build do + env :ci do + brew "wwise-cli", tap: "d3mlabs/d3mlabs", post_install: hook, host: :linux + end + end + end + + Then "both land as first-class fields, exactly like group-level brew" + decl = config.declarations[0] + decl.env == "ci" + decl.host == :linux + decl.post_install == hook + decl.constraint == { "tap" => "d3mlabs/d3mlabs" } + end + + test "registered integration verbs work at the top level" do + When "registering an integration and using it outside any group" + config = Dev::Deps.define do + register :wow_curseforge, "WoWCurseforgeIntegration" + wow_curseforge "CombatMode", version: ">=1.0" + end + + Then "the declaration lands in the default group" + decl = config.declarations[0] + decl.integration == :wow_curseforge + decl.group == Dev::Deps::DSL::DEFAULT_GEM_GROUP + end + + test "top-level brew raises EmptyNameError for empty name" do + When "declaring a top-level brew dep with empty name" + Dev::Deps.define do + brew "" + end + Then - entry = config.group("build")["brew"][0] - entry.is_a?(Hash) - entry["wwise-cli"]["post_install"] == hook - entry["wwise-cli"]["tap"] == "d3mlabs/d3mlabs" + raises Dev::Deps::GroupDSL::EmptyNameError end test "cmake raises EmptyNameError for empty name" do