From a1093cf7cd801cb558ad22b0ba2d8808f76f087f Mon Sep 17 00:00:00 2001 From: "d3mlabs-ai-flow[bot]" <305891656+d3mlabs-ai-flow[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:50:09 -0400 Subject: [PATCH] ai-flow /build: Enforce Sorbet typed sigil Co-authored-by: JPDuchesne <2636122+JPDuchesne@users.noreply.github.com> --- .rubocop.yml | 45 +++++ Gemfile | 1 + Gemfile.lock | 5 + dependencies.rb | 1 + lib/build_container.rb | 134 +++++++++++++-- lib/build_watcher.rb | 43 ++++- lib/dev/cd.rb | 1 + lib/dev/cd/accessor.rb | 37 ++++- lib/dev/cd/hook_installer.rb | 6 + lib/dev/cd/matcher.rb | 20 ++- lib/dev/cd/repo.rb | 7 + lib/dev/cd/repo_discovery.rb | 11 +- lib/dev/clone.rb | 1 + lib/dev/clone/accessor.rb | 28 +++- lib/dev/clone/gh_cloner.rb | 11 +- lib/dev/clone/repo_spec.rb | 13 +- lib/dev/credential_accessor.rb | 16 +- lib/dev/credentials.rb | 40 ++++- lib/dev/deps.rb | 1 + lib/dev/deps/accessor.rb | 25 ++- lib/dev/deps/brew_integration.rb | 40 ++++- lib/dev/deps/brew_repository.rb | 18 +- lib/dev/deps/bundler_integration.rb | 23 ++- lib/dev/deps/bundler_repository.rb | 27 ++- lib/dev/deps/cache.rb | 16 +- lib/dev/deps/cache_gc.rb | 27 ++- lib/dev/deps/cli_ui.rb | 1 + lib/dev/deps/cmake_integration.rb | 36 +++- lib/dev/deps/config.rb | 1 + lib/dev/deps/dependency.rb | 1 + lib/dev/deps/dependency_declaration.rb | 1 + lib/dev/deps/dependency_installer.rb | 1 + lib/dev/deps/dsl.rb | 1 + lib/dev/deps/fetcher.rb | 1 + lib/dev/deps/ficsit_integration.rb | 26 ++- lib/dev/deps/ficsit_repository.rb | 32 +++- lib/dev/deps/gem_skill_linker.rb | 48 ++++-- lib/dev/deps/gh_integration.rb | 39 ++++- lib/dev/deps/gh_repository.rb | 20 +++ lib/dev/deps/git_repository.rb | 10 +- lib/dev/deps/hooks/unreal_module.rb | 10 ++ lib/dev/deps/hooks/wwise_download.rb | 11 +- lib/dev/deps/integration.rb | 23 ++- lib/dev/deps/lockfile.rb | 1 + lib/dev/deps/luarocks_integration.rb | 21 ++- lib/dev/deps/luarocks_repository.rb | 17 +- lib/dev/deps/pip_integration.rb | 22 ++- lib/dev/deps/pip_repository.rb | 8 + lib/dev/deps/registry.rb | 201 +++++++++++++++-------- lib/dev/deps/repository.rb | 11 +- lib/dev/deps/resolver.rb | 19 +++ lib/dev/deps/staleness.rb | 40 +++-- lib/dev/deps/steam_cmd.rb | 21 ++- lib/dev/deps/steam_integration.rb | 8 + lib/dev/deps/steam_repository.rb | 7 + lib/dev/deps/tap.rb | 1 + lib/dev/deps/url_repository.rb | 10 +- lib/dev/deps/xcode_integration.rb | 55 +++++-- lib/dev/deps/xcode_repository.rb | 5 + lib/dev/learnings.rb | 1 + lib/dev/learnings/accessor.rb | 105 ++++++++---- lib/dev/learnings/cache.rb | 37 ++++- lib/dev/learnings/invariants_renderer.rb | 16 +- lib/dev/learnings/layout.rb | 17 +- lib/dev/learnings/scaffolder.rb | 7 + lib/dev/learnings/synchronizer.rb | 33 +++- lib/dev/plan.rb | 3 + lib/dev/plan/accessor.rb | 109 ++++++++++-- lib/dev/plan/content.rb | 20 ++- lib/dev/plan/executor.rb | 9 +- lib/dev/plan/frontmatter.rb | 17 +- lib/dev/plan/github_issues.rb | 18 ++ lib/dev/plan/header.rb | 25 ++- lib/dev/plan/merge.rb | 10 +- lib/dev/plan/merge_base.rb | 11 +- lib/dev/plan/templates.rb | 19 ++- lib/dev/plan/workspace.rb | 16 +- lib/dev/runner_setup.rb | 54 +++++- lib/dev/settings.rb | 12 +- lib/dev/shell_rc_hook.rb | 19 ++- lib/dev/skill_installer.rb | 21 ++- lib/ensure_bundler.rb | 1 + lib/rake_test_argv.rb | 6 + lib/shadowenv_llvm.rb | 13 ++ lib/shadowenv_lua.rb | 10 ++ lib/shadowenv_python.rb | 13 ++ lib/shadowenv_ruby.rb | 60 ++++++- lib/shadowenv_unreal.rb | 32 +++- lib/shadowenv_xcode.rb | 15 ++ sorbet/tapioca/config.yml | 1 + src/dev/build_container_config.rb | 20 +-- src/dev/builtins/cache_command.rb | 4 +- src/dev/builtins/cd_command.rb | 2 +- src/dev/builtins/check_command.rb | 2 +- src/dev/builtins/clone_command.rb | 2 +- src/dev/builtins/cred_command.rb | 2 +- src/dev/builtins/deps_command.rb | 2 +- src/dev/builtins/help_command.rb | 8 +- src/dev/builtins/install_deps_command.rb | 6 +- src/dev/builtins/learnings_command.rb | 2 +- src/dev/builtins/plan_command.rb | 2 +- src/dev/builtins/runner_setup_command.rb | 4 +- src/dev/builtins/up_command.rb | 4 +- src/dev/cli/ui.rb | 2 +- src/dev/command.rb | 14 +- src/dev/command_executor.rb | 6 +- src/dev/command_runner.rb | 14 +- src/dev/command_service.rb | 6 +- src/dev/dependency_service.rb | 2 +- src/dev/global_dispatch.rb | 8 +- src/dev/overridden_executor.rb | 4 +- src/dev/project_executor.rb | 2 +- src/dev/project_manifest_loader.rb | 2 +- src/dev/runner.rb | 8 +- src/dev/runner_setup_config.rb | 8 +- test/dev/deps/pip_integration_test.rb | 7 +- test/dev/deps/repository_test.rb | 2 +- test/test_helper.rb | 1 + test/test_loader.rb | 1 + 119 files changed, 1788 insertions(+), 395 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 2fd7261..dbfacaf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,6 +5,9 @@ inherit_mode: merge: - Exclude +plugins: + - rubocop-sorbet + AllCops: SuggestExtensions: false Exclude: @@ -13,6 +16,48 @@ AllCops: - bin/**/* - tmp/**/* +# Every Ruby file must declare a `typed:` sigil (bin/ and tmp/ stay excluded +# via AllCops above). +Sorbet/ValidSigil: + RequireSigilOnAllFiles: true + +# Production code is `typed: strict`. Test files are exempt (RSpock's AST +# transform and type pinnings in setup methods keep them at `typed: false`); +# sigil presence is still enforced there by Sorbet/ValidSigil. +Sorbet/StrictSigil: + Enabled: true + Include: + - src/**/* + - lib/**/* + Exclude: + # The pre-bundle bootstrap chain (dependencies.rb -> dev/deps -> ..., + # plus ensure_bundler) is loaded by bin/setup.rb and bin/test.rb BEFORE + # the bundle exists, so it must stay stdlib-only: `sig` blocks would + # require sorbet-runtime at load time. These files cap at `typed: true` + # (or `typed: false` where noted). + - lib/ensure_bundler.rb + - lib/dev/deps.rb + - lib/dev/deps/cli_ui.rb + - lib/dev/deps/config.rb + - lib/dev/deps/lockfile.rb + - lib/dev/deps/tap.rb + - lib/dev/deps/dependency_installer.rb + # `typed: false` holdouts: Data.define with a keyword-args initialize + # override is rejected by Sorbet (error 4010)... + - lib/dev/deps/dependency.rb + - lib/dev/deps/dependency_declaration.rb + # `typed: true` holdout outside the pre-bundle chain: Sorbet demands sigs + # on Data.define-synthesized readers at strict, and wrapping them in the + # define block would destroy the originals. Fix by converting to a plain + # value class, as clone/repo_spec.rb already did. + - lib/dev/cd/repo.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 + # dev's tests are written in the RSpock dialect: bare comparisons in # Then/Expect blocks, block-name constants (Given/When/Then/Where), and # Where-table rows (`a | b`, sometimes with identical operands) are rewritten diff --git a/Gemfile b/Gemfile index 5328882..6c44099 100644 --- a/Gemfile +++ b/Gemfile @@ -25,6 +25,7 @@ gem "pry-byebug", "~> 3.11" # Style gem "rubocop-shopify", "~> 3.0", require: false +gem "rubocop-sorbet", "~> 0.10", require: false # Sorbet: static + runtime type checking gem "sorbet", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index c9f18ee..bab3b0e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,6 +86,9 @@ GEM rubocop-shopify (3.0.1) lint_roller rubocop (~> 1.72, >= 1.72.1) + rubocop-sorbet (0.13.2) + lint_roller + rubocop (>= 1.75.2) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) simplecov (0.22.0) @@ -154,6 +157,7 @@ DEPENDENCIES rbs (~> 4.0.0.dev.5) rspock (~> 3.0) rubocop-shopify (~> 3.0) + rubocop-sorbet (~> 0.10) simplecov (~> 0.22) simplecov-cobertura (~> 3.0) sorbet @@ -200,6 +204,7 @@ CHECKSUMS rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db rubocop-shopify (3.0.1) sha256=4adffa6313294bd9da2b0896ae44c5eb8e419336b2413de20c38b7691a7e6774 + rubocop-sorbet (0.13.2) sha256=7901e57b6b8e9e9b970d941dbdbb89f2c9de0183bfd2551460468f8aa9548655 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 diff --git a/dependencies.rb b/dependencies.rb index 046b40f..d3e264f 100644 --- a/dependencies.rb +++ b/dependencies.rb @@ -1,3 +1,4 @@ +# typed: false # frozen_string_literal: true # dev's own dependency manifest. Loaded in two ways: diff --git a/lib/build_container.rb b/lib/build_container.rb index 7126fda..03fa171 100644 --- a/lib/build_container.rb +++ b/lib/build_container.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true require "digest" require "pathname" require "securerandom" +require "sorbet-runtime" require "tmpdir" require "yaml" @@ -21,6 +23,11 @@ # BuildContainer.content_tag(project_root: Pathname("...")) # # returns the content-addressed tag without side effects module BuildContainer + extend T::Sig + # Kernel is re-included so Sorbet knows its methods (system, raise, backticks, + # Pathname) exist on the module's instance-method side under module_function. + include Kernel + # Always-hashed inputs. deps.lock (app/test deps, e.g. SML) and build-deps.lock # (build deps, e.g. the engine) join the Dockerfile so a dependency bump # invalidates a prewarmed image. Missing files are skipped (see content_tag). @@ -47,6 +54,13 @@ module BuildContainer # (and the tag), while editing a module's dependency list does not. Sorted # for determinism; missing matches contribute nothing. # @return [String] tag like "content-a1b2c3d4e5f6" + sig do + params( + project_root: Pathname, + extra_globs: T::Array[String], + structure_globs: T::Array[String], + ).returns(String) + end def content_tag(project_root:, extra_globs: [], structure_globs: []) root = Pathname(project_root) file_content = CONTENT_FILES @@ -82,6 +96,7 @@ def content_tag(project_root:, extra_globs: [], structure_globs: []) # @param config [Dev::BuildContainerConfig] # @param project_root [Pathname] # @return [String] e.g. "jpduchesne89/snappy-linux:content-a1b2c3d4e5f6" + sig { params(config: Dev::BuildContainerConfig, project_root: Pathname).returns(String) } def image_with_tag(config, project_root:) globs = config.respond_to?(:content_globs) ? config.content_globs : [] structure_globs = config.respond_to?(:structure_globs) ? config.structure_globs : [] @@ -122,6 +137,16 @@ def image_with_tag(config, project_root:) # @param build_args_provider [#call, nil] returns Hash{String => String} of build args # @param secrets_provider [#call, nil] returns Hash{String => String} of secret id => value # @return [String] the full image:tag string + sig do + params( + config: Dev::BuildContainerConfig, + project_root: Pathname, + push: T::Boolean, + publish: T::Boolean, + build_args_provider: T.nilable(T.proc.returns(T::Hash[String, String])), + secrets_provider: T.nilable(T.proc.returns(T::Hash[String, String])), + ).returns(String) + end def ensure_image!(config, project_root:, push: true, publish: false, build_args_provider: nil, secrets_provider: nil) tag = image_with_tag(config, project_root:) @@ -172,6 +197,16 @@ def ensure_image!(config, project_root:, push: true, publish: false, # @param build_args [Hash{String => String}] # @param secrets [Hash{String => String}] secret id => value # @param prewarm [String] shell command to run inside the base container + sig do + params( + tag: String, + config: Dev::BuildContainerConfig, + project_root: Pathname, + build_args: T::Hash[String, String], + secrets: T::Hash[String, String], + prewarm: String, + ).void + end def build_and_prewarm!(tag, config:, project_root:, build_args:, secrets:, prewarm:) base_tag = "#{tag}-base" # The base is engine-free and secret-free: no build-contexts, no BuildKit @@ -181,8 +216,10 @@ def build_and_prewarm!(tag, config:, project_root:, build_args:, secrets:, prewa prewarm_commit!(base_tag, tag, volumes:, prewarm:, secrets:) ensure # The committed image references the base's layers, so dropping the base tag - # frees the name without removing shared data. - remove_image(base_tag) + # frees the name without removing shared data. T.must: Sorbet sees base_tag + # as possibly uninitialized in ensure, but its assignment is the first + # statement and cannot raise. + remove_image(T.must(base_tag)) end # Named build-contexts derived from build-deps.lock: every build-group @@ -194,6 +231,7 @@ def build_and_prewarm!(tag, config:, project_root:, build_args:, secrets:, prewa # # @param project_root [Pathname] # @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? @@ -224,13 +262,14 @@ def build_contexts_from_lockfile(project_root) # @param volumes [Array] configured "host:container[:opts]" specs # @param project_root [Pathname] # @return [Array] specs with matching host paths version-resolved + sig { params(volumes: T::Array[String], project_root: Pathname).returns(T::Array[String]) } def resolve_versioned_volumes(volumes, project_root:) versions = install_dir_versions(project_root) return volumes if versions.empty? volumes.map do |spec| host, container = spec.split(":", 2) - version = versions[File.expand_path(host)] + version = versions[File.expand_path(T.must(host))] version ? "#{host}/#{version}:#{container}" : spec end end @@ -241,6 +280,7 @@ def resolve_versioned_volumes(volumes, project_root:) # # @param project_root [Pathname] # @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| @@ -258,6 +298,7 @@ def install_dir_versions(project_root) # @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) @@ -279,6 +320,15 @@ def collect_install_dir_versions(yaml, acc) # paths may use ~ (e.g. "~/.dev/engines/unreal-engine-css:/ue") # @param env [Hash{String => String}] env vars to inject via `-e` # @return [Array] docker run command array + sig do + params( + image_tag: String, + project_root: Pathname, + shell_cmd: String, + volumes: T::Array[String], + env: T::Hash[String, String], + ).returns(T::Array[String]) + end def docker_run_command(image_tag, project_root:, shell_cmd:, volumes: [], env: {}) env_flags = env.flat_map { |name, value| ["-e", "#{name}=#{value}"] } @@ -298,10 +348,11 @@ def docker_run_command(image_tag, project_root:, shell_cmd:, volumes: [], env: { # # @param volumes [Array] # @return [Array] + sig { params(volumes: T::Array[String]).returns(T::Array[String]) } def volume_flags(volumes) volumes.flat_map do |spec| host, container = spec.split(":", 2) - ["-v", "#{File.expand_path(host)}:#{container}"] + ["-v", "#{File.expand_path(T.must(host))}:#{container}"] end end @@ -320,6 +371,7 @@ def volume_flags(volumes) # @param project_root [Pathname] bind-mounted at /project # @param volumes [Array] extra "host:container" mounts (e.g. engine) # @return [String] the running container's name + sig { params(image_tag: String, project_root: Pathname, volumes: T::Array[String]).returns(String) } def ensure_service!(image_tag, project_root:, volumes: []) name = service_container_name(image_tag, project_root) reap_stale_services!(image_tag, project_root) @@ -339,6 +391,13 @@ def ensure_service!(image_tag, project_root:, volumes: []) # @param shell_cmd [String] # @param env [Hash{String => String}] env vars to inject via `-e` # @return [Array] docker exec command array + sig do + params( + container: String, + shell_cmd: String, + env: T::Hash[String, String], + ).returns(T::Array[String]) + end def docker_exec_command(container, shell_cmd:, env: {}) env_flags = env.flat_map { |name, value| ["-e", "#{name}=#{value}"] } ["docker", "exec", *env_flags, "-w", "/project", container, "sh", "-c", shell_cmd] @@ -352,6 +411,7 @@ def docker_exec_command(container, shell_cmd:, env: {}) # @param image_tag [String] # @param project_root [Pathname] the checkout whose containers to remove # @return [Array] names of the removed containers + sig { params(image_tag: String, project_root: Pathname).returns(T::Array[String]) } def reset_service!(image_tag, project_root) names = service_containers(service_name_prefix(image_tag, project_root)) names.each { |name| remove_container(name) } @@ -372,6 +432,15 @@ def reset_service!(image_tag, project_root) # (already version-resolved by the caller via resolve_versioned_volumes) # @param prewarm [String] shell command run via `sh -c` # @param secrets [Hash{String => String}] secret id => value + sig do + params( + base_tag: String, + final_tag: String, + volumes: T::Array[String], + prewarm: String, + secrets: T::Hash[String, String], + ).void + end def prewarm_commit!(base_tag, final_tag, volumes:, prewarm:, secrets:) container = prewarm_container_name secret_files = write_secret_files(secrets) @@ -388,7 +457,9 @@ def prewarm_commit!(base_tag, final_tag, volumes:, prewarm:, secrets:) raise "Prewarm run failed for #{final_tag}" unless run_watched(run_argv, container: container) raise "docker commit failed for #{final_tag}" unless system("docker", "commit", container, final_tag) ensure - system("docker", "rm", "-f", container, out: File::NULL, err: File::NULL) + # T.must: Sorbet sees container as possibly uninitialized in ensure, but + # its assignment is the first statement and cannot raise. + system("docker", "rm", "-f", T.must(container), out: File::NULL, err: File::NULL) secret_files&.each_value { |path| File.delete(path) if File.exist?(path) } end @@ -400,6 +471,7 @@ def prewarm_commit!(base_tag, final_tag, volumes:, prewarm:, secrets:) # @param argv [Array] docker run command # @param container [String] the run's --name, so a stall can be killed # @return [Boolean] whether a run succeeded within the retry budget + sig { params(argv: T::Array[String], container: String).returns(T::Boolean) } def run_watched(argv, container:) BuildWatcher.new(container_name: container).run(argv) end @@ -409,6 +481,7 @@ def run_watched(argv, container:) # # @param secrets [Hash{String => String}] # @return [Hash{String => String}] secret id => temp file path + sig { params(secrets: T::Hash[String, String]).returns(T::Hash[String, String]) } def write_secret_files(secrets) secrets.each_with_object({}) do |(id, value), files| path = File.join(Dir.tmpdir, "dev-secret-#{SecureRandom.hex(8)}") @@ -421,10 +494,12 @@ def write_secret_files(secrets) # Unique name for the throwaway prewarm container; pid + random suffix so # concurrent dev invocations never collide. + sig { returns(String) } def prewarm_container_name "dev-prewarm-#{Process.pid}-#{rand(1_000_000)}" end + sig { params(image_tag: String).void } def remove_image(image_tag) system("docker", "image", "rm", "-f", image_tag, out: File::NULL, err: File::NULL) end @@ -442,38 +517,43 @@ def remove_image(image_tag) # silently building and testing the wrong tree on every run. Keying by workspace # gives each checkout its own long-lived container, each bound correctly, with # no cross-thrash when a machine is both a dev box and a CI runner. + sig { params(image_tag: String, project_root: Pathname).returns(String) } def service_container_name(image_tag, project_root) image = image_basename(image_tag) - tag = image_tag.split(":").last + tag = T.must(image_tag.split(":").last) "dev-#{sanitize_container_name(image)}-#{workspace_id(project_root)}-#{sanitize_container_name(tag)}" end # Image + workspace prefix shared by every tag's container for one checkout, # used to find and reap stale ones without touching OTHER checkouts' containers. # E.g. "reg/snappy-linux:content-abc" in /work/snappy -> "dev-snappy-linux-9f86d08-". + sig { params(image_tag: String, project_root: Pathname).returns(String) } def service_name_prefix(image_tag, project_root) "dev-#{sanitize_container_name(image_basename(image_tag))}-#{workspace_id(project_root)}-" end # Bare image name (no registry, no tag). E.g. # "reg/snappy-linux:content-abc" -> "snappy-linux". + sig { params(image_tag: String).returns(String) } def image_basename(image_tag) - image_tag.split("/").last.split(":").first + T.must(T.must(image_tag.split("/").last).split(":").first) end # Short, stable identifier for the checkout a persistent container is bound to, # so the container name is unique per workspace (see service_container_name). # Hash of the resolved real path: different directories differ, the same # directory is stable across runs, and symlinked paths normalize to one id. + sig { params(project_root: Pathname).returns(String) } def workspace_id(project_root) path = begin File.realpath(project_root.to_s) rescue Errno::ENOENT File.expand_path(project_root.to_s) end - Digest::SHA256.hexdigest(path)[0, 10] + T.must(Digest::SHA256.hexdigest(path)[0, 10]) end + sig { params(str: String).returns(String) } def sanitize_container_name(str) str.gsub(/[^a-zA-Z0-9_.-]/, "-") end @@ -482,6 +562,7 @@ def sanitize_container_name(str) # tag's name, so a Dockerfile/dep bump (new tag) doesn't leave the old one # running alongside the new. Scoped to the workspace prefix, so a tag bump in # one checkout never reaps another checkout's container. + sig { params(image_tag: String, project_root: Pathname).void } def reap_stale_services!(image_tag, project_root) keep = service_container_name(image_tag, project_root) service_containers(service_name_prefix(image_tag, project_root)).each do |name| @@ -491,19 +572,23 @@ def reap_stale_services!(image_tag, project_root) # Names of existing containers (running or stopped) whose name matches the # project prefix. `^` anchors the regex name filter to the start. + sig { params(prefix: String).returns(T::Array[String]) } def service_containers(prefix) out = `docker ps -a --filter name=^#{prefix} --format {{.Names}}` out.split("\n").map(&:strip).reject(&:empty?) end + sig { params(name: String).returns(T.nilable(T::Boolean)) } def container_exists?(name) system("docker", "container", "inspect", name, out: File::NULL, err: File::NULL) end + sig { params(name: String).returns(T::Boolean) } def container_running?(name) `docker container inspect -f {{.State.Running}} #{name} 2>/dev/null`.strip == "true" end + sig { params(name: String).void } def start_container(name) system("docker", "start", name, out: File::NULL, err: File::NULL) end @@ -511,23 +596,26 @@ def start_container(name) # Create the detached, idle service container: the project at /project, any # extra volumes (e.g. the engine), and `sleep infinity` so it stays up for # `docker exec`. + sig { params(name: String, image_tag: String, project_root: Pathname, volumes: T::Array[String]).void } def create_service_container(name, image_tag, project_root:, volumes: []) - success = system( + argv = [ "docker", "run", "-d", "--name", name, "-v", "#{project_root}:/project", *volume_flags(volumes), "-w", "/project", image_tag, "sleep", "infinity", - out: File::NULL, err: File::NULL, - ) + ] + success = system(*T.unsafe(argv), out: File::NULL, err: File::NULL) raise "Failed to create service container #{name}" unless success end + sig { params(name: String).void } def remove_container(name) system("docker", "rm", "-f", name, out: File::NULL, err: File::NULL) end + sig { params(image_tag: String).returns(T.nilable(T::Boolean)) } def local_image?(image_tag) system("docker", "image", "inspect", image_tag, out: File::NULL, err: File::NULL) end @@ -536,6 +624,7 @@ def local_image?(image_tag) # on a fresh CI runner) shows layer-by-layer liveness instead of looking hung. # stderr stays silenced: the pull doubles as a cache probe, so "manifest not # found" on a miss is expected noise, not an error worth surfacing. + sig { params(image_tag: String).returns(T.nilable(T::Boolean)) } def pull(image_tag) system("docker", "pull", image_tag, err: File::NULL) end @@ -551,6 +640,15 @@ def pull(image_tag) # @param build_args [Hash{String => String}] # @param build_contexts [Hash{String => String}] context name => host path # @param secrets [Hash{String => String}] secret id => value + sig do + params( + image_tag: String, + project_root: Pathname, + build_args: T::Hash[String, String], + build_contexts: T::Hash[String, String], + secrets: T::Hash[String, String], + ).void + end def build!(image_tag, project_root:, build_args: {}, build_contexts: {}, secrets: {}) arg_flags = build_args.flat_map { |name, value| ["--build-arg", "#{name}=#{value}"] } context_flags = build_contexts.flat_map { |name, path| ["--build-context", "#{name}=#{path}"] } @@ -565,17 +663,18 @@ def build!(image_tag, project_root:, build_args: {}, build_contexts: {}, secrets # pipes) and goes near-silent, so a long image build (msvc-wine download, # WineHQ install) looks hung for tens of minutes. Plain progress prints every # step with timestamps and streams RUN output, giving CI logs a heartbeat. - success = system( - env, + argv = [ "docker", "build", "--progress=plain", "-t", image_tag, *arg_flags, *context_flags, *secret_flags, project_root.to_s, - ) + ] + success = system(*T.unsafe([env, *argv])) raise "Docker build failed for #{image_tag}" unless success end # Stream push progress for the same liveness reason as pull: publishing a # multi-GB image can take many minutes and CI logs need a heartbeat. + sig { params(image_tag: String).returns(T.nilable(T::Boolean)) } def push!(image_tag) system("docker", "push", image_tag) end @@ -590,7 +689,9 @@ def push!(image_tag) # retries, since the registry still lacks the tag. # # @param image_tag [String] - # @return [Boolean] whether the registry has the tag after this call + # @return [Boolean, nil] whether the registry has the tag after this call + # (nil when the push command itself could not be executed) + sig { params(image_tag: String).returns(T.nilable(T::Boolean)) } def publish!(image_tag) if registry_has?(image_tag) $stderr.puts "dev: Container image already published — #{image_tag}" @@ -608,7 +709,8 @@ def publish!(image_tag) # download), so this is a cheap existence check. # # @param image_tag [String] - # @return [Boolean] + # @return [Boolean, nil] nil when the docker command could not be executed + sig { params(image_tag: String).returns(T.nilable(T::Boolean)) } def registry_has?(image_tag) system("docker", "manifest", "inspect", image_tag, out: File::NULL, err: File::NULL) end diff --git a/lib/build_watcher.rb b/lib/build_watcher.rb index adf65c2..c9e8cc2 100644 --- a/lib/build_watcher.rb +++ b/lib/build_watcher.rb @@ -1,6 +1,9 @@ +# typed: strict # frozen_string_literal: true require "open3" +require "sorbet-runtime" +require "stringio" # Runs a long containerized build with hung-build detection and bounded retries. # @@ -28,6 +31,8 @@ # Policy (stalled?, classify_failure) is pure and unit-tested; the OS mechanism # (run_once and its docker probes) is isolated so it can be overridden in tests. class BuildWatcher + extend T::Sig + # Seconds without any build output before the build is *eligible* to be judged # stalled (combined with near-zero CPU, to avoid killing a slow-but-working # compile action). @@ -40,7 +45,7 @@ class BuildWatcher DEFAULT_MAX_ATTEMPTS = 5 # Crash signatures worth retrying (flaky Rosetta/clang emulation failures). - CRASH_SIGNATURES = [ + CRASH_SIGNATURES = T.let([ /rosetta error/i, /segmentation fault/i, /caught signal/i, @@ -48,16 +53,16 @@ class BuildWatcher /llvm error/i, /internal compiler error|\bICE\b/i, /unable to spawn process|posix_spawn failed/i, - ].freeze + ].freeze, T::Array[Regexp]) # Genuine compile/link errors — fail fast, retrying won't help. - COMPILE_ERROR_SIGNATURES = [ + COMPILE_ERROR_SIGNATURES = T.let([ /(?:^|\s)error:\s/i, /fatal error:/i, /undefined reference to/i, /ld(?:\.lld)?:\s*error/i, /\bUnrealBuildTool\b.*\bERROR\b/, - ].freeze + ].freeze, T::Array[Regexp]) Result = Struct.new(:outcome, :output) # outcome: :success | :stalled | :failed @@ -67,7 +72,17 @@ class BuildWatcher # @param cpu_floor [Float] CPU% at/under which counts as idle # @param poll [Integer] probe interval in seconds # @param max_attempts [Integer] retry cap - # @param out [IO] progress/diagnostic stream + # @param out [IO, StringIO] progress/diagnostic stream + sig do + params( + container_name: String, + stall_after: Integer, + cpu_floor: Float, + poll: Integer, + max_attempts: Integer, + out: T.any(IO, StringIO), + ).void + end def initialize(container_name:, stall_after: DEFAULT_STALL_AFTER, cpu_floor: DEFAULT_CPU_FLOOR, poll: DEFAULT_POLL, max_attempts: DEFAULT_MAX_ATTEMPTS, out: $stderr) @container_name = container_name @@ -82,6 +97,7 @@ def initialize(container_name:, stall_after: DEFAULT_STALL_AFTER, cpu_floor: DEF # # @param argv [Array] the docker run command to execute # @return [Boolean] true if a run succeeded within the attempt budget + sig { params(argv: T::Array[String]).returns(T::Boolean) } def run(argv) @max_attempts.times do |attempt| result = run_once(argv) @@ -103,6 +119,7 @@ def run(argv) # @param idle_seconds [Numeric] seconds since the last build output # @param cpu_percent [Numeric] current container CPU percent # @return [Boolean] + sig { params(idle_seconds: Numeric, cpu_percent: Numeric).returns(T::Boolean) } def stalled?(idle_seconds:, cpu_percent:) idle_seconds >= @stall_after && cpu_percent <= @cpu_floor end @@ -114,6 +131,7 @@ def stalled?(idle_seconds:, cpu_percent:) # # @param output [String] captured combined output # @return [Symbol] :retry (transient) or :fail (genuine) + sig { params(output: String).returns(Symbol) } def classify_failure(output) return :retry if CRASH_SIGNATURES.any? { |re| output.match?(re) } @@ -124,6 +142,7 @@ def classify_failure(output) # @param result [Result] # @return [String, nil] human reason to retry, or nil to stop + sig { params(result: Result).returns(T.nilable(String)) } def retry_reason(result) return "hung build detected (no output + idle CPU)" if result.outcome == :stalled return "transient crash signature" if classify_failure(result.output) == :retry @@ -137,12 +156,13 @@ def retry_reason(result) # # @param argv [Array] # @return [Result] + sig { params(argv: T::Array[String]).returns(Result) } def run_once(argv) free_container_name last_output = now captured = +"" - Open3.popen2e(*argv) do |stdin, out, wait_thr| + Open3.popen2e(*T.unsafe(argv)) do |stdin, out, wait_thr| stdin.close reader = Thread.new do out.each_line do |line| @@ -165,7 +185,8 @@ def run_once(argv) # @param wait_thr [Process::Waiter] # @yieldreturn [Numeric] seconds since last output # @return [Boolean] true if killed due to stall - def wait_or_kill(wait_thr) + sig { params(wait_thr: Process::Waiter, blk: T.proc.returns(Float)).returns(T::Boolean) } + def wait_or_kill(wait_thr, &blk) while wait_thr.alive? sleep @poll next unless wait_thr.alive? @@ -182,6 +203,7 @@ def wait_or_kill(wait_thr) # value reports as idle so a truly silent container can still be reclaimed. # # @return [Float] + sig { returns(Float) } def container_cpu out, _err, status = Open3.capture3( "docker", "stats", "--no-stream", "--format", "{{.CPUPerc}}", @container_name @@ -194,6 +216,7 @@ def container_cpu end # @return [void] + sig { void } def kill_container @out.puts ">>> build-watcher: killing hung container #{@container_name}" system("docker", "kill", @container_name, out: File::NULL, err: File::NULL) @@ -206,6 +229,7 @@ def kill_container # Silent no-op on the first attempt, when nothing by this name exists yet. # # @return [void] + sig { void } def free_container_name system("docker", "rm", "-f", @container_name, out: File::NULL, err: File::NULL) end @@ -213,7 +237,10 @@ def free_container_name # Monotonic clock so wall-clock changes never skew stall timing. # # @return [Float] + sig { returns(Float) } def now - Process.clock_gettime(Process::CLOCK_MONOTONIC) + # CLOCK_MONOTONIC with the default :float_second unit always yields a + # Float; the cast narrows Sorbet's T.any(Float, Integer) stdlib signature. + T.cast(Process.clock_gettime(Process::CLOCK_MONOTONIC), Float) end end diff --git a/lib/dev/cd.rb b/lib/dev/cd.rb index 33123a9..71ed111 100644 --- a/lib/dev/cd.rb +++ b/lib/dev/cd.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "dev/cd/repo" diff --git a/lib/dev/cd/accessor.rb b/lib/dev/cd/accessor.rb index bb56df3..2fe3d80 100644 --- a/lib/dev/cd/accessor.rb +++ b/lib/dev/cd/accessor.rb @@ -1,6 +1,9 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" +require "stringio" require "dev/cd/repo_discovery" require "dev/cd/matcher" require "dev/cd/hook_installer" @@ -21,6 +24,8 @@ module Cd # so instead of printing a path that does nothing it self-heals the hook # and explains how to activate it. class Accessor + extend T::Sig + # `dev cd` ran without the shell wrapper being active. class ShellHookInactiveError < RuntimeError; end @@ -29,21 +34,29 @@ class UsageError < RuntimeError; end # @param root [String, Pathname] search root (default: $DEV_CD_ROOT, else ~/src) # @param hook_installer [Dev::Cd::HookInstaller] + sig { params(root: T.any(String, Pathname), hook_installer: T.untyped).void } def initialize(root: ENV["DEV_CD_ROOT"] || (Pathname(Dir.home) / "src"), hook_installer: HookInstaller.new) - @discovery = RepoDiscovery.new(root: root) + @discovery = T.let(RepoDiscovery.new(root: root), RepoDiscovery) @hook_installer = hook_installer end # Dispatch a `dev cd …` invocation. # # @param args [Array] argv after the "cd" command - # @param out [IO] stdout (the machine-readable payload only) - # @param err [IO] stderr (diagnostics and hints) + # @param out [IO, StringIO] stdout (the machine-readable payload only) + # @param err [IO, StringIO] stderr (diagnostics and hints) # @raise [UsageError] on malformed plumbing invocations # @raise [ShellHookInactiveError] for bare invocations without the hook # @raise [Matcher::RepoNotFoundError] when --resolve matches nothing # @raise [Matcher::AmbiguousRepoError] when --resolve matches several repos + sig do + params( + args: T::Array[String], + out: T.any(IO, StringIO), + err: T.any(IO, StringIO), + ).void + end def run(args, out: $stdout, err: $stderr) flag, *rest = args case flag @@ -60,10 +73,17 @@ def run(args, out: $stdout, err: $stderr) # still ends up with a working wrapper for their next shell. # # @param args [Array] - # @param out [IO] - # @param err [IO] + # @param out [IO, StringIO] + # @param err [IO, StringIO] # @return [void] # @raise [UsageError] unless exactly one query argument is given + sig do + params( + args: T::Array[String], + out: T.any(IO, StringIO), + err: T.any(IO, StringIO), + ).void + end def resolve(args, out:, err:) raise UsageError, "usage: dev cd " unless args.size == 1 @@ -78,9 +98,10 @@ def resolve(args, out:, err:) # ambiguity is the point here, not an error. # # @param args [Array] - # @param out [IO] + # @param out [IO, StringIO] # @return [void] # @raise [UsageError] when more than one query argument is given + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def candidates(args, out:) raise UsageError, "usage: dev cd --candidates []" if args.size > 1 @@ -90,9 +111,10 @@ def candidates(args, out:) # Bare `dev cd` reached the Ruby process: install the hook and tell the # user how to activate it, then fail (this process cannot cd for them). # - # @param err [IO] + # @param err [IO, StringIO] # @return [void] # @raise [ShellHookInactiveError] always + sig { params(err: T.any(IO, StringIO)).void } def explain_missing_hook(err:) case @hook_installer.ensure_installed when :added @@ -110,6 +132,7 @@ def explain_missing_hook(err:) # must see the current state of the checkout tree. # # @return [Dev::Cd::Matcher] + sig { returns(Matcher) } def matcher Matcher.new(repos: @discovery.repos) end diff --git a/lib/dev/cd/hook_installer.rb b/lib/dev/cd/hook_installer.rb index 0f3dcb4..d117442 100644 --- a/lib/dev/cd/hook_installer.rb +++ b/lib/dev/cd/hook_installer.rb @@ -1,5 +1,7 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require "dev/shell_rc_hook" module Dev @@ -23,6 +25,8 @@ module Cd # registers a `complete -c dev` source (fish applies its own filtering, # so fuzzy tokens may complete only literally there). class HookInstaller + extend T::Sig + # The marker names the snippet generation: RCs carrying only an older # marker get the current snippet appended on the next ensure (dev up or # any `dev cd`), and the later function definition wins in every @@ -104,6 +108,7 @@ class HookInstaller SNIPPET # @param rc_hook [Dev::ShellRcHook] the shared RC-snippet installer + sig { params(rc_hook: ShellRcHook).void } def initialize(rc_hook: ShellRcHook.new) @rc_hook = rc_hook end @@ -111,6 +116,7 @@ def initialize(rc_hook: ShellRcHook.new) # Ensure the wrapper function + completer are in the user's shell RC. # # @return [Symbol, false] :added, :already_present, or false (unsupported shell) + sig { returns(T.any(Symbol, FalseClass)) } def ensure_installed @rc_hook.ensure_snippet( marker: MARKER, diff --git a/lib/dev/cd/matcher.rb b/lib/dev/cd/matcher.rb index 3fd329a..bad251d 100644 --- a/lib/dev/cd/matcher.rb +++ b/lib/dev/cd/matcher.rb @@ -1,5 +1,7 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require "dev/cd/repo" module Dev @@ -13,9 +15,14 @@ module Cd # finds `d3mlabs/dev`. Ties sort by path, keeping ambiguous candidate # lists reproducible regardless of filesystem walk order. class Matcher + extend T::Sig + # No repo matched the query. class RepoNotFoundError < StandardError + extend T::Sig + # @param query [String] + sig { params(query: String).void } def initialize(query) super("no repo matching '#{query}' found") end @@ -24,11 +31,15 @@ def initialize(query) # Multiple repos matched the query equally well; carries the rendered # candidates so callers can print them and hint at a deeper suffix. class AmbiguousRepoError < StandardError + extend T::Sig + # @return [Array] candidates at their shortest-unique depth + sig { returns(T::Array[String]) } attr_reader :candidates # @param query [String] # @param candidates [Array] + sig { params(query: String, candidates: T::Array[String]).void } def initialize(query, candidates) @candidates = candidates super("'#{query}' is ambiguous (#{candidates.size} matches)") @@ -40,8 +51,9 @@ def initialize(query, candidates) SUBSTRING_SCORE = 1 # @param repos [Array] the discovered candidate set + sig { params(repos: T::Array[Repo]).void } def initialize(repos:) - @repos = repos.sort_by { |repo| repo.path.to_s } + @repos = T.let(repos.sort_by { |repo| repo.path.to_s }, T::Array[Repo]) end # Resolve a query to exactly one repo. @@ -53,6 +65,7 @@ def initialize(repos:) # @return [Dev::Cd::Repo] # @raise [RepoNotFoundError] when nothing matches # @raise [AmbiguousRepoError] when the best matches tie + sig { params(query: String).returns(Repo) } def resolve(query) scored = scored_matches(query) raise RepoNotFoundError, query if scored.empty? @@ -69,6 +82,7 @@ def resolve(query) # # @param query [String] # @return [Array] + sig { params(query: String).returns(T::Array[String]) } def candidates(query) scored_matches(query).map { |repo, _score| render(repo) } end @@ -80,6 +94,7 @@ def candidates(query) # # @param repo [Dev::Cd::Repo] # @return [String] + sig { params(repo: Repo).returns(String) } def render(repo) (1..repo.segments.size).each do |depth| suffix = repo.suffix(depth) @@ -95,6 +110,7 @@ def render(repo) # # @param query [String] # @return [Array] + sig { params(query: String).returns(T::Array[[Repo, Integer]]) } def scored_matches(query) query_segments = query.split("/").reject(&:empty?) @repos @@ -112,6 +128,7 @@ def scored_matches(query) # @param query_segments [Array] # @param repo [Dev::Cd::Repo] # @return [Integer, nil] + sig { params(query_segments: T::Array[String], repo: Repo).returns(T.nilable(Integer)) } def score(query_segments, repo) return 0 if query_segments.empty? return nil if query_segments.size > repo.segments.size @@ -129,6 +146,7 @@ def score(query_segments, repo) # @param wanted [String] the query segment # @param actual [String] the repo path segment # @return [Integer, nil] + sig { params(wanted: String, actual: String).returns(T.nilable(Integer)) } def segment_score(wanted, actual) wanted = wanted.downcase actual = actual.downcase diff --git a/lib/dev/cd/repo.rb b/lib/dev/cd/repo.rb index 75bcc5b..b3d6733 100644 --- a/lib/dev/cd/repo.rb +++ b/lib/dev/cd/repo.rb @@ -1,6 +1,8 @@ +# typed: true # frozen_string_literal: true require "pathname" +require "sorbet-runtime" module Dev module Cd @@ -11,6 +13,9 @@ module Cd # (e.g. ["github.com", "d3mlabs", "dev"]); queries are matched # right-anchored against them, so the last segment is the repo name. Repo = Data.define(:path, :segments) do + extend T::Sig + + sig { params(path: T.any(Pathname, String), segments: T::Array[String]).void } def initialize(path:, segments:) super(path: Pathname(path), segments: segments.map(&:to_s).freeze) end @@ -18,6 +23,7 @@ def initialize(path:, segments:) # The repo's leaf name (last path segment). # # @return [String] + sig { returns(String) } def name segments.fetch(-1) end @@ -26,6 +32,7 @@ def name # # @param depth [Integer] how many trailing segments to include # @return [String] e.g. "d3mlabs/dev" for depth 2 + sig { params(depth: Integer).returns(String) } def suffix(depth) segments.last(depth).join("/") end diff --git a/lib/dev/cd/repo_discovery.rb b/lib/dev/cd/repo_discovery.rb index 0ae92ee..cbb931b 100644 --- a/lib/dev/cd/repo_discovery.rb +++ b/lib/dev/cd/repo_discovery.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" require "dev/cd/repo" module Dev @@ -12,21 +14,25 @@ module Cd # descends into a repo) and bounds depth, so it stays fast by construction # on the conventional `root/github.com//` layout. class RepoDiscovery + extend T::Sig + # Deep enough for host/org/repo plus one nesting level of grouping dirs. MAX_DEPTH = 4 # @param root [String, Pathname] the search root (e.g. $DEV_CD_ROOT) + sig { params(root: T.any(String, Pathname)).void } def initialize(root:) - @root = Pathname(root).expand_path + @root = T.let(Pathname(root).expand_path, Pathname) end # All git repos under the root, sorted by path for deterministic output. # # @return [Array] + sig { returns(T::Array[Repo]) } def repos return [] unless @root.directory? - found = [] + found = T.let([], T::Array[Repo]) walk(@root, [], found) found.sort_by { |repo| repo.path.to_s } end @@ -39,6 +45,7 @@ def repos # @param segments [Array] path segments from the root to dir # @param found [Array] accumulator # @return [void] + sig { params(dir: Pathname, segments: T::Array[String], found: T::Array[Repo]).void } def walk(dir, segments, found) if !segments.empty? && (dir / ".git").exist? found << Repo.new(path: dir, segments: segments) diff --git a/lib/dev/clone.rb b/lib/dev/clone.rb index c0b7e73..640a263 100644 --- a/lib/dev/clone.rb +++ b/lib/dev/clone.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "dev/clone/repo_spec" diff --git a/lib/dev/clone/accessor.rb b/lib/dev/clone/accessor.rb index f72f43a..a189912 100644 --- a/lib/dev/clone/accessor.rb +++ b/lib/dev/clone/accessor.rb @@ -1,6 +1,9 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" +require "stringio" require "dev/clone/repo_spec" require "dev/clone/gh_cloner" require "dev/cd/hook_installer" @@ -26,6 +29,8 @@ module Clone # any hook exists — then the hook self-heals and the destination is # explained instead of landed in. class Accessor + extend T::Sig + # `dev clone` was invoked with the wrong arguments. class UsageError < RuntimeError; end @@ -35,9 +40,16 @@ class DestinationExistsError < RuntimeError; end # @param root [String, Pathname] checkout root (default: $DEV_CD_ROOT, else ~/src) # @param cloner [Dev::Clone::GhCloner] # @param hook_installer [Dev::Cd::HookInstaller] + sig do + params( + root: T.any(String, Pathname), + cloner: T.untyped, + hook_installer: T.untyped, + ).void + end def initialize(root: ENV["DEV_CD_ROOT"] || (Pathname(Dir.home) / "src"), cloner: GhCloner.new, hook_installer: Dev::Cd::HookInstaller.new) - @root = Pathname(root).expand_path + @root = T.let(Pathname(root).expand_path, Pathname) @cloner = cloner @hook_installer = hook_installer end @@ -45,13 +57,20 @@ def initialize(root: ENV["DEV_CD_ROOT"] || (Pathname(Dir.home) / "src"), # Dispatch a `dev clone …` invocation. # # @param args [Array] argv after the "clone" command - # @param out [IO] stdout (the machine-readable payload only) - # @param err [IO] stderr (progress, diagnostics and hints) + # @param out [IO, StringIO] stdout (the machine-readable payload only) + # @param err [IO, StringIO] stderr (progress, diagnostics and hints) # @return [void] # @raise [UsageError] unless exactly one clone target is given # @raise [RepoSpec::MalformedRepoError] when the target isn't "" or "/" # @raise [DestinationExistsError] when the canonical path already exists # @raise [GhCloner::CloneFailedError] when the clone itself fails + sig do + params( + args: T::Array[String], + out: T.any(IO, StringIO), + err: T.any(IO, StringIO), + ).void + end def run(args, out: $stdout, err: $stderr) plumbing = args.first == "--path" query = plumbing ? args.drop(1) : args @@ -75,8 +94,9 @@ def run(args, out: $stdout, err: $stderr) # # @param spec [Dev::Clone::RepoSpec] # @param destination [Pathname] - # @param err [IO] + # @param err [IO, StringIO] # @return [void] + sig { params(spec: RepoSpec, destination: Pathname, err: T.any(IO, StringIO)).void } def announce(spec, destination, err:) err.puts "dev: cloned #{spec.full_name} to #{destination}" case @hook_installer.ensure_installed diff --git a/lib/dev/clone/gh_cloner.rb b/lib/dev/clone/gh_cloner.rb index 327622d..e2acc70 100644 --- a/lib/dev/clone/gh_cloner.rb +++ b/lib/dev/clone/gh_cloner.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" +require "sorbet-runtime" module Dev module Clone @@ -9,11 +11,15 @@ module Clone # (dev is public and carries no credentials of its own — the same doctrine # as the learnings cache). class GhCloner + extend T::Sig + # `gh repo clone` failed (gh missing, unauthenticated, or a git error). class CloneFailedError < RuntimeError; end # Thin wrapper over the gh CLI boundary. Tests inject a fake. class Executor + extend T::Sig + # Run argv streaming its output, with the child's stdout redirected # to stderr: clone progress belongs with diagnostics, and `dev clone`'s # stdout is reserved for the machine payload (the destination path the @@ -21,12 +27,14 @@ class Executor # # @param argv [Array] # @return [Boolean] whether the command exited 0 + sig { params(argv: String).returns(T::Boolean) } def system(*argv) - Kernel.system(*argv, out: $stderr) ? true : false + T.unsafe(Kernel).system(*argv, out: $stderr) ? true : false end end # @param executor [Executor] CLI boundary (injectable for tests) + sig { params(executor: T.untyped).void } def initialize(executor: Executor.new) @executor = executor end @@ -38,6 +46,7 @@ def initialize(executor: Executor.new) # @param destination [Pathname] the target checkout directory # @return [void] # @raise [CloneFailedError] when the clone exits non-zero + sig { params(full_name: String, destination: Pathname).void } def clone(full_name, destination) FileUtils.mkdir_p(destination.dirname) return if @executor.system("gh", "repo", "clone", full_name, destination.to_s) diff --git a/lib/dev/clone/repo_spec.rb b/lib/dev/clone/repo_spec.rb index 1950509..48cb3bd 100644 --- a/lib/dev/clone/repo_spec.rb +++ b/lib/dev/clone/repo_spec.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" module Dev module Clone @@ -14,6 +16,8 @@ module Clone # a define block land on the enclosing module (breaking the nested typed # error), and Sorbet rejects the `class X < Data.define` form. class RepoSpec + extend T::Sig + # The argument is not a "" or "/" clone target. class MalformedRepoError < RuntimeError; end @@ -24,15 +28,19 @@ class MalformedRepoError < RuntimeError; end SEGMENT_PATTERN = /\A[\w.-]+\z/ # @return [String] + sig { returns(String) } attr_reader :org, :name class << self + extend T::Sig + # Parse a clone target argument into a spec. # # @param arg [String] "" or "/" # @return [Dev::Clone::RepoSpec] # @raise [MalformedRepoError] when the argument is not one or two # valid path segments + sig { params(arg: String).returns(RepoSpec) } def parse(arg) # -1 keeps trailing empty segments, so "repo/" fails validation # instead of silently collapsing to "repo". @@ -42,12 +50,13 @@ def parse(arg) end org, name = segments.size == 2 ? segments : [DEFAULT_ORG, segments.fetch(0)] - new(org:, name:) + new(org: T.must(org), name: T.must(name)) end end # @param org [String] the GitHub owner # @param name [String] the repo name + sig { params(org: String, name: String).void } def initialize(org:, name:) @org = org @name = name @@ -56,6 +65,7 @@ def initialize(org:, name:) # The gh clone target. # # @return [String] "org/repo" + sig { returns(String) } def full_name "#{org}/#{name}" end @@ -63,6 +73,7 @@ def full_name # The canonical checkout location relative to the search root. # # @return [Pathname] "github.com//" + sig { returns(Pathname) } def relative_path Pathname(HOST) / org / name end diff --git a/lib/dev/credential_accessor.rb b/lib/dev/credential_accessor.rb index ad1e9a6..6abc744 100644 --- a/lib/dev/credential_accessor.rb +++ b/lib/dev/credential_accessor.rb @@ -1,5 +1,9 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" +require "stringio" + module Dev # Read accessor over the Credentials provider, surfaced as `dev cred get`. # @@ -12,24 +16,28 @@ module Dev # Credentials is injected (defaulting to the real provider) so tests can # exercise dispatch without loading io/console. class CredentialAccessor + extend T::Sig + class UsageError < StandardError; end USAGE = "usage: dev cred get " # @param credentials [#resolve] credential provider (default: Dev::Credentials) + sig { params(credentials: T.untyped).void } def initialize(credentials: nil) - @credentials = credentials || Dev::Credentials + @credentials = T.let(credentials || Dev::Credentials, T.untyped) end # Dispatch a `dev cred …` invocation and print the resolved value. # # @param args [Array] argv after the "cred" command - # @param out [IO] output stream + # @param out [IO, StringIO] output stream # @raise [UsageError] on an unrecognized invocation + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def run(args, out: $stdout) subcommand, *rest = args case subcommand - when "get" then out.puts(get(*rest)) + when "get" then out.puts(get(*T.unsafe(rest))) else raise UsageError, USAGE end end @@ -40,6 +48,7 @@ def run(args, out: $stdout) # @param key [String] # @return [String] resolved credential value # @raise [UsageError] for a missing namespace/key + sig { params(namespace: T.nilable(String), key: T.nilable(String)).returns(String) } def get(namespace = nil, key = nil) raise UsageError, USAGE unless namespace && key @@ -57,6 +66,7 @@ def get(namespace = nil, key = nil) # @param namespace [String] # @param key [String] # @return [String] + sig { params(namespace: String, key: String).returns(String) } def default_env_var(namespace, key) "#{namespace}_#{key}".upcase.gsub(/[^A-Z0-9]+/, "_") end diff --git a/lib/dev/credentials.rb b/lib/dev/credentials.rb index a51d870..60bfad7 100644 --- a/lib/dev/credentials.rb +++ b/lib/dev/credentials.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "io/console" require "open3" +require "sorbet-runtime" require "yaml" module Dev @@ -12,6 +14,8 @@ module Dev # Storage backend is macOS Keychain when available, plain text fallback # (~/.config/dev/credentials.yml with 0600 permissions) on Linux. module Credentials + extend T::Sig + class MissingCredentialError < StandardError; end KEYCHAIN_ACCOUNT = "d3mlabs/dev" @@ -28,6 +32,15 @@ class MissingCredentialError < StandardError; end # (omitted from prompts when nil) # @return [String] the credential value # @raise [MissingCredentialError] if non-interactive and no credential found + sig do + params( + namespace: String, + key: String, + env_var: String, + prompt_label: String, + create_url: T.nilable(String), + ).returns(String) + end def resolve(namespace:, key:, env_var:, prompt_label:, create_url: nil) ENV[env_var] || load(namespace, key) || @@ -41,12 +54,13 @@ def resolve(namespace:, key:, env_var:, prompt_label:, create_url: nil) # # @param build_args [Hash{String => String}] arg name => credential reference # @return [Hash{String => String}] arg name => resolved value + sig { params(build_args: T::Hash[String, String]).returns(T::Hash[String, String]) } def resolve_build_args(build_args) build_args.to_h do |arg_name, credential_ref| namespace, key = credential_ref.split("/", 2) value = resolve( - namespace: namespace, - key: key, + namespace: T.must(namespace), + key: T.must(key), env_var: arg_name, prompt_label: "#{namespace} #{key} (docker build arg #{arg_name})", ) @@ -61,6 +75,7 @@ def resolve_build_args(build_args) # @param namespace [String] # @param key [String] # @return [String, nil] the credential value, or nil if not found + sig { params(namespace: String, key: String).returns(T.nilable(String)) } def load(namespace, key) value = load_from_keychain(namespace, key) if keychain_available? value || load_from_file(namespace, key) @@ -74,6 +89,7 @@ def load(namespace, key) # @param key [String] # @param value [String] # @return [void] + sig { params(namespace: String, key: String, value: String).void } def store(namespace, key, value) if keychain_available? store_to_keychain(namespace, key, value) @@ -85,6 +101,7 @@ def store(namespace, key, value) # Whether the macOS Keychain is available. # # @return [Boolean] + sig { returns(T::Boolean) } def keychain_available? RUBY_PLATFORM.include?("darwin") end @@ -94,6 +111,7 @@ def keychain_available? # @param namespace [String] # @param key [String] # @return [String, nil] the credential value, or nil if not found + sig { params(namespace: String, key: String).returns(T.nilable(String)) } def load_from_keychain(namespace, key) service = keychain_service(namespace, key) stdout, _stderr, status = Open3.capture3( @@ -116,6 +134,7 @@ def load_from_keychain(namespace, key) # @param key [String] # @param value [String] # @return [void] + sig { params(namespace: String, key: String, value: String).void } def store_to_keychain(namespace, key, value) service = keychain_service(namespace, key) Kernel.system( @@ -133,6 +152,7 @@ def store_to_keychain(namespace, key, value) # @param namespace [String] # @param key [String] # @return [String, nil] the credential value, or nil if not found + sig { params(namespace: String, key: String).returns(T.nilable(String)) } def load_from_file(namespace, key) path = credentials_path return nil unless File.exist?(path) @@ -154,6 +174,7 @@ def load_from_file(namespace, key) # @param key [String] # @param value [String] # @return [void] + sig { params(namespace: String, key: String, value: String).void } def store_to_file(namespace, key, value) path = credentials_path dir = File.dirname(path) @@ -197,12 +218,21 @@ def store_to_file(namespace, key, value) # @param create_url [String, nil] # @return [String] the credential value # @raise [MissingCredentialError] if non-interactive or empty input + sig do + params( + namespace: String, + key: String, + env_var: String, + prompt_label: String, + create_url: T.nilable(String), + ).returns(String) + end def prompt_and_store(namespace, key, env_var, prompt_label, create_url) unless $stdin.tty? message = "#{prompt_label} required.\n" message += "Create one at: #{create_url}\n" if create_url message += "Then set: gh secret set #{env_var}" - raise MissingCredentialError, message + Kernel.raise MissingCredentialError, message end $stdout.puts "\n#{prompt_label} required." @@ -217,7 +247,7 @@ def prompt_and_store(namespace, key, env_var, prompt_label, create_url) $stdout.print "\nPaste your #{key}: " value = $stdin.noecho { $stdin.gets.chomp } $stdout.puts - raise MissingCredentialError, "No #{key} provided" if value.empty? + Kernel.raise MissingCredentialError, "No #{key} provided" if value.empty? store(namespace, key, value) $stdout.puts "Credential stored.\n\n" @@ -229,6 +259,7 @@ def prompt_and_store(namespace, key, env_var, prompt_label, create_url) # Respects XDG_CONFIG_HOME (defaults to ~/.config). # # @return [String] + sig { returns(String) } def credentials_path config_home = ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")) File.join(config_home, "dev", "credentials.yml") @@ -239,6 +270,7 @@ def credentials_path # @param namespace [String] # @param key [String] # @return [String] e.g. "curseforge/api_key" + sig { params(namespace: String, key: String).returns(String) } def keychain_service(namespace, key) "#{namespace}/#{key}" end diff --git a/lib/dev/deps.rb b/lib/dev/deps.rb index 6c11083..5bcea30 100644 --- a/lib/dev/deps.rb +++ b/lib/dev/deps.rb @@ -1,3 +1,4 @@ +# typed: true # frozen_string_literal: true require_relative "deps/config" diff --git a/lib/dev/deps/accessor.rb b/lib/dev/deps/accessor.rb index 0ed736f..b74d67d 100644 --- a/lib/dev/deps/accessor.rb +++ b/lib/dev/deps/accessor.rb @@ -1,5 +1,9 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" +require "stringio" +require_relative "dependency" require_relative "lockfile" require_relative "cache" require_relative "ficsit_integration" @@ -13,6 +17,8 @@ module Deps # pinned Xcode — so consumers (deploy, build scripts, CI) resolve paths # from the lockfile instead of reconstructing dev's layout conventions. class Accessor + extend T::Sig + class UsageError < StandardError; end class NotLockedError < StandardError; end class PlatformNotLockedError < StandardError; end @@ -24,6 +30,7 @@ class NotInstalledError < StandardError; end # @param lockfile [Lockfile] # @param cache [Cache] # @param xcode_install_root [String] where Xcode bundles live (tests use a tmpdir) + sig { params(lockfile: Lockfile, cache: Cache, xcode_install_root: String).void } def initialize(lockfile:, cache:, xcode_install_root: XcodeIntegration::INSTALL_ROOT) @lockfile = lockfile @cache = cache @@ -33,12 +40,15 @@ def initialize(lockfile:, cache:, xcode_install_root: XcodeIntegration::INSTALL_ # Dispatch a `dev deps …` invocation and print the result. # # @param args [Array] argv after the "deps" command - # @param out [IO] output stream + # @param out [IO, StringIO] output stream # @raise [UsageError] on an unrecognized invocation + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def run(args, out: $stdout) subcommand, *rest = args case subcommand - when "path" then out.puts(path(*rest).to_s) + # T.unsafe: forwarding a runtime-sized argv splat; #path's own sig + # still validates the arguments at runtime. + when "path" then out.puts(T.unsafe(self).path(*rest).to_s) else raise UsageError, USAGE end end @@ -54,6 +64,13 @@ def run(args, out: $stdout) # @raise [PlatformNotLockedError] if the platform isn't locked for the dep # @raise [NotCachedError] if the zip isn't in the cache (run dev up) # @raise [NotInstalledError] if the pinned Xcode isn't installed (run dev up) + sig do + params( + integration: T.nilable(String), + name: T.nilable(String), + platform: T.nilable(String), + ).returns(Pathname) + end def path(integration = nil, name = nil, platform = nil) case integration when "ficsit" then ficsit_path(name, platform) @@ -67,6 +84,7 @@ def path(integration = nil, name = nil, platform = nil) # @param name [String, nil] # @param platform [String, nil] # @return [Pathname] + sig { params(name: T.nilable(String), platform: T.nilable(String)).returns(Pathname) } def ficsit_path(name, platform) raise UsageError, USAGE unless name && platform @@ -87,6 +105,7 @@ def ficsit_path(name, platform) # so xcodebuild rides the pin (e.g. unreal-engine's Mac release job). # # @return [Pathname] + sig { returns(Pathname) } def xcode_developer_dir dep = find_dep(:xcode, "xcode") developer_dir = Pathname(XcodeIntegration.developer_dir(dep.version, root: @xcode_install_root)) @@ -102,6 +121,7 @@ def xcode_developer_dir # @param integration [Symbol] # @param name [String] # @return [Dependency] + sig { params(integration: Symbol, name: String).returns(Dependency) } def find_dep(integration, name) dep = @lockfile.read.find { |d| d.integration == integration && d.name == name } raise NotLockedError, "#{name} (#{integration}) is not in the lockfile — run dev update-deps" unless dep @@ -112,6 +132,7 @@ def find_dep(integration, name) # @param dep [Dependency] # @param platform [String] # @return [Hash] the locked { "hash", "link" } for the platform + sig { params(dep: Dependency, platform: String).returns(T::Hash[String, T.untyped]) } def locked_platform(dep, platform) platforms = dep.metadata["platforms"] || {} target = platforms[platform] diff --git a/lib/dev/deps/brew_integration.rb b/lib/dev/deps/brew_integration.rb index 1d2b13f..27d1779 100644 --- a/lib/dev/deps/brew_integration.rb +++ b/lib/dev/deps/brew_integration.rb @@ -1,7 +1,10 @@ +# typed: strict # frozen_string_literal: true require "open3" require "pathname" +require "sorbet-runtime" +require "uri" require_relative "integration" require_relative "dependency" require_relative "tap" @@ -16,23 +19,34 @@ module Deps # Env filtering (install vs skip based on ci/dev) is the caller's # responsibility — only pass deps that should be installed. class BrewIntegration < Integration + extend T::Sig + class InstallError < StandardError; end class TapRegistrationError < StandardError; end - # @param repository [Repository] source adapter - # @param cache [Cache] shared download cache + # @param repository [Repository, nil] source adapter + # @param cache [Cache, nil] shared download cache # @param taps [Array] Homebrew taps to register before installing - # @param project_dir [Pathname, nil] project root for resolving file:// tap URLs + # @param project_dir [String, Pathname, nil] project root for resolving file:// tap URLs + sig do + params( + repository: T.nilable(Repository), + cache: T.nilable(Cache), + taps: T::Array[Tap], + project_dir: T.nilable(T.any(String, Pathname)), + ).void + end def initialize(repository:, cache:, taps: [], project_dir: nil) super(repository:, cache:) @taps = taps - @project_dir = project_dir ? Pathname(project_dir) : nil - @taps_registered = false + @project_dir = T.let(project_dir ? Pathname(project_dir) : nil, T.nilable(Pathname)) + @taps_registered = T.let(false, T::Boolean) end # Install all brew dependencies. Registers taps on first call. # # @param dependencies [Array] brew deps to install + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) ensure_taps_registered dependencies.each do |dep| @@ -47,6 +61,7 @@ def install_all(dependencies) private # Register all configured taps (idempotent — runs once). + sig { void } def ensure_taps_registered return if @taps_registered @@ -59,6 +74,7 @@ def ensure_taps_registered # # @param tap [Tap] tap to register # @raise [TapRegistrationError] if `brew tap` fails + sig { params(tap: Tap).void } def register_tap(tap) if tap.local? && @project_dir path = resolve_file_url(tap.url) @@ -75,6 +91,7 @@ def register_tap(tap) end # Set TAP_NAME and LOCAL_TAP_DIR env vars for the first local tap. + sig { void } def setup_tap_env return unless @project_dir @@ -87,11 +104,12 @@ def setup_tap_env # Resolve a file:// URI to an absolute path relative to project_dir. # - # @param uri [URI] file:// URI + # @param uri [URI::Generic] file:// URI # @return [String] absolute path + sig { params(uri: URI::Generic).returns(String) } def resolve_file_url(uri) path = uri.path.to_s - path = (@project_dir / path[2..]).to_s if path.start_with?("./") + path = (T.must(@project_dir) / T.must(path[2..])).to_s if path.start_with?("./") File.expand_path(path) end @@ -104,6 +122,7 @@ def resolve_file_url(uri) # # @param dep [Dependency] # @raise [InstallError] if brew install fails + sig { params(dep: Dependency).void } def install_formula(dep) suffix = dep.metadata["version_suffix"] formula = suffix ? "#{dep.name}@#{suffix}" : dep.name @@ -117,6 +136,7 @@ def install_formula(dep) # # @param dep [Dependency] # @raise [InstallError] if brew install --cask fails + sig { params(dep: Dependency).void } def install_cask(dep) return if brew_installed?(dep.name) run_brew_install(dep.name, "--cask #{dep.name}") @@ -125,7 +145,8 @@ def install_cask(dep) # Check if a formula/cask is already installed. # # @param name [String] formula or cask name - # @return [Boolean] + # @return [Boolean, nil] nil when the brew command itself cannot run + sig { params(name: String).returns(T.nilable(T::Boolean)) } def brew_installed?(name) system("brew list #{name} >/dev/null 2>&1") end @@ -135,8 +156,9 @@ def brew_installed?(name) # @param name [String] dependency name (for error messages) # @param spec [String] full install spec (e.g. "cmake@3.31.4") # @raise [InstallError] if brew exits non-zero + sig { params(name: String, spec: String).void } def run_brew_install(name, spec) - _out, err, status = Open3.capture3("brew", "install", *spec.split) + _out, err, status = T.unsafe(Open3).capture3("brew", "install", *spec.split) raise InstallError, "brew install #{spec} failed: #{err}" unless status.success? end end diff --git a/lib/dev/deps/brew_repository.rb b/lib/dev/deps/brew_repository.rb index a3ac012..614e132 100644 --- a/lib/dev/deps/brew_repository.rb +++ b/lib/dev/deps/brew_repository.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "json" require "open3" +require "sorbet-runtime" require_relative "repository" require_relative "dependency" @@ -12,6 +14,8 @@ module Deps # 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). class BrewRepository < Repository + extend T::Sig + class BrewInfoError < StandardError; end # Resolve a brew dependency identifier to a pinned Dependency. @@ -24,6 +28,7 @@ class BrewInfoError < StandardError; end # 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" @@ -76,6 +81,13 @@ def fetch(id) # @param tap [String, nil] tap slug # @param version_suffix [String, nil] brew version suffix (e.g. "18") # @return [String] + sig do + params( + name: String, + tap: T.nilable(String), + version_suffix: T.nilable(String), + ).returns(String) + end def build_formula_spec(name, tap, version_suffix) base = tap ? "#{tap}/#{name}" : name version_suffix ? "#{base}@#{version_suffix}" : base @@ -89,6 +101,7 @@ def build_formula_spec(name, tap, version_suffix) # @param tap [String, nil] tap slug from the declaration # @return [Hash] parsed JSON info for the formula # @raise [BrewInfoError] if the command fails + sig { params(formula: String, tap: T.nilable(String)).returns(T::Hash[String, T.untyped]) } def brew_info_with_tap(formula, tap) brew_info(formula) rescue BrewInfoError @@ -102,6 +115,7 @@ def brew_info_with_tap(formula, tap) # @param formula [String] formula spec (e.g. "cmake" or "d3mlabs/d3mlabs/powershell") # @return [Hash] parsed JSON info for the formula # @raise [BrewInfoError] if the command fails + sig { params(formula: String).returns(T::Hash[String, T.untyped]) } def brew_info(formula) out, _err, status = Open3.capture3("brew", "info", "--json=v1", formula) raise BrewInfoError, "brew info --json=v1 #{formula} failed" unless status.success? @@ -111,15 +125,17 @@ def brew_info(formula) # @param tap [String] tap slug (e.g. "xcodesorg/made") # @return [Boolean] whether `brew tap` succeeded + sig { params(tap: String).returns(T::Boolean) } def register_tap(tap) _out, _err, status = Open3.capture3("brew", "tap", tap) - status.success? + status.success? || false end # Extract the bottle SHA256 for the current platform. # # @param info [Hash] parsed brew info JSON # @return [String, nil] hex SHA256, or nil if no bottle found + sig { params(info: T::Hash[String, T.untyped]).returns(T.nilable(String)) } def extract_bottle_hash(info) bottles = info.dig("bottle", "stable", "files") || {} current_arch = RUBY_PLATFORM.include?("arm") ? "arm64_sonoma" : "sonoma" diff --git a/lib/dev/deps/bundler_integration.rb b/lib/dev/deps/bundler_integration.rb index e65b6b3..8226438 100644 --- a/lib/dev/deps/bundler_integration.rb +++ b/lib/dev/deps/bundler_integration.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "open3" require "pathname" +require "sorbet-runtime" require_relative "integration" require_relative "dependency" @@ -18,23 +20,33 @@ module Deps # full graph from the Gemfile.lock — so install_all only needs to know there # is at least one gem to install. class BundlerIntegration < Integration + extend T::Sig + class InstallError < StandardError; end class BundlerMissingError < StandardError; end GEMFILE = "Gemfile" - # @param repository [Repository] source adapter for bundler deps - # @param cache [Cache] shared download cache (unused; bundler caches) - # @param project_root [Pathname] root the generated Gemfile lives in + # @param repository [Repository, nil] source adapter for bundler deps + # @param cache [Cache, nil] shared download cache (unused; bundler caches) + # @param project_root [String, Pathname] root the generated Gemfile lives in + sig do + params( + repository: T.nilable(Repository), + cache: T.nilable(Cache), + project_root: T.any(String, Pathname), + ).void + end def initialize(repository:, cache:, project_root:) super(repository:, cache:) - @project_root = Pathname(project_root) + @project_root = T.let(Pathname(project_root), Pathname) end # Install all gems via `bundle install` against the generated Gemfile. # # @param dependencies [Array] bundler deps (presence-only) # @return [void] + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) return if dependencies.empty? @@ -56,6 +68,7 @@ def install_all(dependencies) # # @raise [BundlerMissingError] if bundler cannot be made available # @return [void] + sig { void } def ensure_bundler! _out, _err, status = Open3.capture3( "shadowenv", "exec", "--", "bundle", "--version", @@ -74,6 +87,7 @@ def ensure_bundler! # # @raise [InstallError] if bundle install fails # @return [void] + sig { void } def run_bundle_install _out, err, status = Open3.capture3( { "BUNDLE_GEMFILE" => gemfile_path.to_s, "BUNDLE_FROZEN" => "true" }, @@ -84,6 +98,7 @@ def run_bundle_install end # @return [Pathname] + sig { returns(Pathname) } def gemfile_path @project_root / GEMFILE end diff --git a/lib/dev/deps/bundler_repository.rb b/lib/dev/deps/bundler_repository.rb index f2d644e..7a54a85 100644 --- a/lib/dev/deps/bundler_repository.rb +++ b/lib/dev/deps/bundler_repository.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "open3" require "pathname" +require "sorbet-runtime" require_relative "repository" require_relative "dependency" @@ -19,6 +21,8 @@ module Deps # 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). class BundlerRepository < Repository + extend T::Sig + class LockError < StandardError; end class MissingGemError < StandardError; end @@ -34,10 +38,16 @@ class MissingGemError < StandardError; end # @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) - @project_root = Pathname(project_root) + @project_root = T.let(Pathname(project_root), Pathname) @ruby_version_requirement = ruby_version_requirement - @pins = nil + @pins = T.let(nil, T.nilable(T::Hash[String, T::Hash[Symbol, T.nilable(String)]])) end # Batch hook: generate the Gemfile from all gem declarations, lock it, and @@ -45,6 +55,7 @@ def initialize(project_root:, ruby_version_requirement: nil) # # @param declarations [Array] :bundler declarations # @return [void] + sig { params(declarations: T::Array[DependencyDeclaration]).void } def prepare(declarations) return if declarations.empty? @@ -58,6 +69,7 @@ def prepare(declarations) # @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 @@ -81,6 +93,7 @@ def fetch(id) # full resolve; this guards direct fetch calls in tests). # # @return [Hash{String => Hash}] + sig { returns(T::Hash[String, T::Hash[Symbol, T.nilable(String)]]) } def pins @pins ||= parse_lockfile end @@ -91,6 +104,7 @@ def pins # # @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 @@ -113,6 +127,7 @@ def write_gemfile(declarations) # # @param decl [DependencyDeclaration] # @return [String] + sig { params(decl: DependencyDeclaration).returns(String) } def gem_line(decl) parts = [%(gem "#{decl.name}")] constraint = decl.constraint @@ -129,6 +144,7 @@ def gem_line(decl) # # @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", @@ -146,6 +162,7 @@ def run_bundle_lock # gives the integrity hash. # # @return [Hash{String => Hash}] + sig { returns(T::Hash[String, T::Hash[Symbol, T.nilable(String)]]) } def parse_lockfile return {} unless lockfile_path.exist? @@ -162,9 +179,10 @@ def parse_lockfile # # @param contents [String] raw Gemfile.lock contents # @return [Hash{String => String}] gem name => locked version + sig { params(contents: String).returns(T::Hash[String, String]) } def parse_spec_versions(contents) versions = {} - in_specs = false + in_specs = T.let(false, T::Boolean) contents.each_line do |line| if line.match?(/^\s+specs:\s*$/) in_specs = true @@ -185,6 +203,7 @@ def parse_spec_versions(contents) # # @param contents [String] raw Gemfile.lock contents # @return [Hash{String => String}] + sig { params(contents: String).returns(T::Hash[String, String]) } def parse_checksums(contents) section = contents[/^CHECKSUMS\n(.*?)(?:\n\n|\z)/m, 1] return {} unless section @@ -197,11 +216,13 @@ def parse_checksums(contents) end # @return [Pathname] + sig { returns(Pathname) } def gemfile_path @project_root / GEMFILE end # @return [Pathname] + sig { returns(Pathname) } def lockfile_path @project_root / LOCKFILE end diff --git a/lib/dev/deps/cache.rb b/lib/dev/deps/cache.rb index 9355df2..ceff6ef 100644 --- a/lib/dev/deps/cache.rb +++ b/lib/dev/deps/cache.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" +require "sorbet-runtime" module Dev module Deps @@ -17,20 +19,24 @@ module Deps # accelerator; project-local install directories are managed by # each Integration. class Cache + extend T::Sig + # Raised when a requested key is not in the cache. class CacheMissError < StandardError; end - DEFAULT_DIR = Pathname.new(File.expand_path("~/.dev/cache")) + DEFAULT_DIR = T.let(Pathname.new(File.expand_path("~/.dev/cache")), Pathname) - # @param cache_dir [Pathname] root directory for cached artifacts + # @param cache_dir [Pathname, String] root directory for cached artifacts + sig { params(cache_dir: T.any(Pathname, String)).void } def initialize(cache_dir: DEFAULT_DIR) - @cache_dir = Pathname(cache_dir) + @cache_dir = T.let(Pathname(cache_dir), Pathname) end # Store an artifact in the cache. Takes ownership (moves the file). # # @param key [String] cache key (e.g. "cmake/boost-1.90.0-a1b2c3.tar.gz") # @param file [File] open handle to the source artifact + sig { params(key: String, file: File).void } def store(key, file) dest = path_for(key) FileUtils.mkdir_p(dest.dirname) @@ -42,6 +48,7 @@ def store(key, file) # @param key [String] cache key # @return [File] read-only handle to the cached artifact # @raise [Cache::CacheMissError] if the key is not in the cache + sig { params(key: String).returns(File) } def fetch(key) path = path_for(key) raise CacheMissError, "Cache miss: #{key}" unless path.exist? @@ -53,6 +60,7 @@ def fetch(key) # # @param key [String] cache key # @return [Boolean] + sig { params(key: String).returns(T::Boolean) } def exists?(key) path_for(key).exist? end @@ -63,12 +71,14 @@ def exists?(key) # # @param key [String] cache key # @return [Pathname] + sig { params(key: String).returns(Pathname) } def path(key) path_for(key) end private + sig { params(key: String).returns(Pathname) } def path_for(key) @cache_dir / key end diff --git a/lib/dev/deps/cache_gc.rb b/lib/dev/deps/cache_gc.rb index fcf1da7..68d70cc 100644 --- a/lib/dev/deps/cache_gc.rb +++ b/lib/dev/deps/cache_gc.rb @@ -1,9 +1,12 @@ +# typed: strict # frozen_string_literal: true require "set" require "fileutils" require "open3" require "pathname" +require "sorbet-runtime" +require "stringio" require_relative "lockfile" module Dev @@ -29,11 +32,14 @@ module Deps # - IN-USE versions (mounted by a running container) are never evicted — # removing a directory a job has mounted would corrupt that job. class CacheGc + extend T::Sig + DEFAULT_KEEP = 2 STAGING_GLOB = ".staging-*" # @param lockfile [Lockfile] source of locked deps (install_dir + version) - # @param out [IO] progress stream + # @param out [IO, StringIO] progress stream + sig { params(lockfile: Lockfile, out: T.any(IO, StringIO)).void } def initialize(lockfile:, out: $stdout) @lockfile = lockfile @out = out @@ -46,6 +52,7 @@ def initialize(lockfile:, out: $stdout) # @param image_ref [String, nil] "registry/image" to prune content tags for # @param live_tag [String, nil] the current content tag to never prune # @return [void] + sig { params(keep: Integer, image_ref: T.nilable(String), live_tag: T.nilable(String)).void } def gc(keep: DEFAULT_KEEP, image_ref: nil, live_tag: nil) in_use = running_mount_sources gc_install_dirs(keep: keep, in_use: in_use) @@ -60,6 +67,7 @@ def gc(keep: DEFAULT_KEEP, image_ref: nil, live_tag: nil) # @param keep [Integer] # @param in_use [Set] absolute host paths mounted by live containers # @return [void] + sig { params(keep: Integer, in_use: T::Set[String]).void } def gc_install_dirs(keep:, in_use:) locked_versions_by_base.each do |base, locked| next unless Dir.exist?(base) @@ -70,6 +78,7 @@ def gc_install_dirs(keep:, in_use:) end # @return [Hash{String => Set}] expanded install_dir => locked versions + sig { returns(T::Hash[String, T::Set[String]]) } def locked_versions_by_base @lockfile.read.each_with_object({}) do |dep, acc| dir = dep.metadata && dep.metadata["install_dir"] @@ -83,6 +92,7 @@ def locked_versions_by_base # @param locked [Set] # @param keep [Integer] # @param in_use [Set] + sig { params(base: String, locked: T::Set[String], keep: Integer, in_use: T::Set[String]).void } def prune_versions(base, locked:, keep:, in_use:) # Newest first, so the retained "others" are the most recently used. versions = version_dirs(base).sort_by { |v| -File.mtime(File.join(base, v)).to_f } @@ -103,6 +113,7 @@ def prune_versions(base, locked:, keep:, in_use:) # # @param base [String] # @return [Array] version directory basenames + sig { params(base: String).returns(T::Array[String]) } def version_dirs(base) Dir.children(base).select do |child| File.directory?(File.join(base, child)) && !child.start_with?(".staging-") @@ -110,6 +121,7 @@ def version_dirs(base) end # @param base [String] + sig { params(base: String).void } def remove_orphan_staging(base) Dir.glob(File.join(base, STAGING_GLOB)).each do |staging| @out.puts ">>> gc: removing orphan staging #{staging}" @@ -122,6 +134,7 @@ def remove_orphan_staging(base) # @param path [String] # @param in_use [Set] # @return [Boolean] + sig { params(path: String, in_use: T::Set[String]).returns(T::Boolean) } def mounted?(path, in_use) in_use.any? { |source| source == path || source.start_with?("#{path}/") || path.start_with?("#{source}/") } end @@ -131,11 +144,16 @@ def mounted?(path, in_use) # empty set rather than blocking GC (the locked-version guard still holds). # # @return [Set] + sig { returns(T::Set[String]) } def running_mount_sources ids = capture("docker", "ps", "-q").split("\n").map(&:strip).reject(&:empty?) return Set.new if ids.empty? - sources = capture("docker", "inspect", "--format", "{{range .Mounts}}{{.Source}}\n{{end}}", *ids) + # T.unsafe: Sorbet cannot check a runtime-sized argv splat; #capture's + # own sig validates at runtime. The call stays receiverless because + # #capture is private. + argv = ["docker", "inspect", "--format", "{{range .Mounts}}{{.Source}}\n{{end}}", *ids] + sources = capture(*T.unsafe(argv)) Set.new(sources.split("\n").map(&:strip).reject(&:empty?)) end @@ -144,6 +162,7 @@ def running_mount_sources # # @param image_ref [String] # @param live_tag [String, nil] + sig { params(image_ref: String, live_tag: T.nilable(String)).void } def gc_docker(image_ref:, live_tag:) tags = capture("docker", "images", image_ref, "--format", "{{.Repository}}:{{.Tag}}") .split("\n").map(&:strip).reject(&:empty?) @@ -159,6 +178,7 @@ def gc_docker(image_ref:, live_tag:) end # @return [Set] image refs of running containers + sig { returns(T::Set[String]) } def running_image_refs Set.new(capture("docker", "ps", "--format", "{{.Image}}").split("\n").map(&:strip).reject(&:empty?)) end @@ -166,8 +186,9 @@ def running_image_refs # Run a command and capture stdout, returning "" on failure. # # @return [String] + sig { params(argv: String).returns(String) } def capture(*argv) - out, _err, status = Open3.capture3(*argv) + out, _err, status = Open3.capture3(*T.unsafe(argv)) status.success? ? out : "" rescue StandardError "" diff --git a/lib/dev/deps/cli_ui.rb b/lib/dev/deps/cli_ui.rb index 2d3fd6a..a3650cc 100644 --- a/lib/dev/deps/cli_ui.rb +++ b/lib/dev/deps/cli_ui.rb @@ -1,3 +1,4 @@ +# typed: true # frozen_string_literal: true module Dev diff --git a/lib/dev/deps/cmake_integration.rb b/lib/dev/deps/cmake_integration.rb index b73f7e4..2141a18 100644 --- a/lib/dev/deps/cmake_integration.rb +++ b/lib/dev/deps/cmake_integration.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" +require "sorbet-runtime" require "tmpdir" require_relative "integration" require_relative "dependency" @@ -15,6 +17,8 @@ module Deps # 2. Generates deps.cmake (set variables for each dep: repo+sha or url+hash, dep lists) # 3. Generates deps.targets.cmake (cmake_targets, cmake_namespace per dep) class CmakeIntegration < Integration + extend T::Sig + class GitCloneError < StandardError; end class GitCheckoutError < StandardError; end class DownloadError < StandardError; end @@ -37,18 +41,26 @@ class ExtractError < StandardError; end TEXT - # @param repository [Repository] source adapter for cmake deps - # @param cache [Cache] shared download cache - # @param project_root [Pathname] project root directory + # @param repository [Repository, nil] source adapter for cmake deps + # @param cache [Cache, nil] shared download cache + # @param project_root [String, Pathname] project root directory + sig do + params( + repository: T.nilable(Repository), + cache: T.nilable(Cache), + project_root: T.any(String, Pathname), + ).void + end def initialize(repository:, cache:, project_root:) super(repository:, cache:) - @project_root = Pathname(project_root) + @project_root = T.let(Pathname(project_root), Pathname) end # Install all cmake dependencies: fetch sources, run post_install hooks, # generate deps.cmake and deps.targets.cmake. # # @param dependencies [Array] cmake deps to install + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) dependencies.each do |dep| fetch_dep(dep) @@ -64,6 +76,7 @@ def install_all(dependencies) # Each callable receives the dependency and the project root. # # @param dep [Dependency] + sig { params(dep: Dependency).void } def run_post_install(dep) return unless dep.post_install @@ -75,6 +88,7 @@ def run_post_install(dep) # Skips if the destination is already populated. # # @param dep [Dependency] + sig { params(dep: Dependency).void } def fetch_dep(dep) deps_dir = @project_root / "build" / "_deps" dest = deps_dir / "#{dep.name}-src" @@ -95,6 +109,7 @@ def fetch_dep(dep) # @param dest [Pathname] destination directory # @raise [GitCloneError] if git clone fails # @raise [GitCheckoutError] if git checkout fails + sig { params(dep: Dependency, dest: Pathname).void } def fetch_git(dep, dest) FileUtils.rm_rf(dest) system("git", "clone", "--no-checkout", "-q", dep.metadata["repo"], dest.to_s) || @@ -109,8 +124,9 @@ def fetch_git(dep, dest) # @param dep [Dependency] # @param dest [Pathname] destination directory # @raise [DownloadError] if curl download fails + sig { params(dep: Dependency, dest: Pathname).void } def fetch_tarball(dep, dest) - cached = cache.fetch(dep.hash) if dep.hash + cached = T.must(cache).fetch(dep.hash) if dep.hash if cached extract_tarball(cached, dest) else @@ -125,9 +141,10 @@ def fetch_tarball(dep, dest) # Extract a tarball into the destination, expecting a single top-level directory. # - # @param tarball_path [String, Pathname] path to the .tar.gz - # @param dest [Pathname] extraction destination + # @param tarball_path [String, Pathname, File] path to (or cached handle on) the .tar.gz + # @param dest [Pathname] extraction destination # @raise [ExtractError] if tar extraction fails or tarball has no top-level directory + sig { params(tarball_path: T.any(String, Pathname, File), dest: Pathname).void } def extract_tarball(tarball_path, dest) FileUtils.rm_rf(dest) Dir.mktmpdir("dev-cmake-extract-") do |tmpdir| @@ -143,7 +160,8 @@ def extract_tarball(tarball_path, dest) # # @param dest [Pathname] source directory to check # @param dep [Dependency] - # @return [Boolean] + # @return [Boolean, nil] nil when a url dep's dir exists but the url check short-circuits + sig { params(dest: Pathname, dep: Dependency).returns(T.nilable(T::Boolean)) } def populated?(dest, dep) return false unless dest.directory? @@ -155,6 +173,7 @@ def populated?(dest, dep) # Generate deps.cmake with set() variables for each dependency. # # @param dependencies [Array] + sig { params(dependencies: T::Array[Dependency]).void } def write_deps_cmake(dependencies) path = @project_root / DEPS_CMAKE_FILE app_deps = [] @@ -188,6 +207,7 @@ def write_deps_cmake(dependencies) # includes per dependency. # # @param dependencies [Array] + sig { params(dependencies: T::Array[Dependency]).void } def write_targets_cmake(dependencies) path = @project_root / TARGETS_CMAKE_FILE lines = [TARGETS_CMAKE_HEADER] diff --git a/lib/dev/deps/config.rb b/lib/dev/deps/config.rb index 9e2df5b..c3beae3 100644 --- a/lib/dev/deps/config.rb +++ b/lib/dev/deps/config.rb @@ -1,3 +1,4 @@ +# typed: true # frozen_string_literal: true require_relative "dsl" diff --git a/lib/dev/deps/dependency.rb b/lib/dev/deps/dependency.rb index 0aa3725..aa3718c 100644 --- a/lib/dev/deps/dependency.rb +++ b/lib/dev/deps/dependency.rb @@ -1,3 +1,4 @@ +# typed: false # frozen_string_literal: true module Dev diff --git a/lib/dev/deps/dependency_declaration.rb b/lib/dev/deps/dependency_declaration.rb index 01addcd..b3ede21 100644 --- a/lib/dev/deps/dependency_declaration.rb +++ b/lib/dev/deps/dependency_declaration.rb @@ -1,3 +1,4 @@ +# typed: false # frozen_string_literal: true module Dev diff --git a/lib/dev/deps/dependency_installer.rb b/lib/dev/deps/dependency_installer.rb index 21fa5da..472961a 100644 --- a/lib/dev/deps/dependency_installer.rb +++ b/lib/dev/deps/dependency_installer.rb @@ -1,3 +1,4 @@ +# typed: true # frozen_string_literal: true module Dev diff --git a/lib/dev/deps/dsl.rb b/lib/dev/deps/dsl.rb index d30589d..1bd3994 100644 --- a/lib/dev/deps/dsl.rb +++ b/lib/dev/deps/dsl.rb @@ -1,3 +1,4 @@ +# typed: false # frozen_string_literal: true require_relative "dependency_declaration" diff --git a/lib/dev/deps/fetcher.rb b/lib/dev/deps/fetcher.rb index 910b616..3bdf54a 100644 --- a/lib/dev/deps/fetcher.rb +++ b/lib/dev/deps/fetcher.rb @@ -1,3 +1,4 @@ +# typed: false # frozen_string_literal: true require "digest" diff --git a/lib/dev/deps/ficsit_integration.rb b/lib/dev/deps/ficsit_integration.rb index 9862b39..61fea20 100644 --- a/lib/dev/deps/ficsit_integration.rb +++ b/lib/dev/deps/ficsit_integration.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "digest" require "fileutils" +require "sorbet-runtime" require "tmpdir" require_relative "integration" @@ -20,19 +22,31 @@ module Deps # home-agnostic cache. Consumers pull the right platform's zip out via the # `dev deps path ficsit ` accessor. class FicsitIntegration < Integration + extend T::Sig + class MissingPlatformsError < StandardError; end class DownloadError < StandardError; end class IntegrityError < StandardError; end class << self + extend T::Sig + # Build the content-cache key for a locked mod platform. Shared with the # `dev deps path` accessor so consumers never reconstruct the key by hand. # # @param name [String] mod reference (e.g. "SML") # @param version [String] locked version (e.g. "3.12.0") # @param platform [String] ficsit target name (e.g. "LinuxServer") - # @param hash [String] locked integrity hash ("SHA256=…") + # @param hash [String, nil] locked integrity hash ("SHA256=…") # @return [String] cache key, e.g. "ficsit/SML-3.12.0-LinuxServer-.zip" + sig do + params( + name: String, + version: String, + platform: String, + hash: T.nilable(String), + ).returns(String) + end def cache_key(name:, version:, platform:, hash:) "ficsit/#{name}-#{version}-#{platform}-#{strip_algo(hash)}.zip" end @@ -41,6 +55,7 @@ def cache_key(name:, version:, platform:, hash:) # # @param hash [String, nil] # @return [String] + sig { params(hash: T.nilable(String)).returns(String) } def strip_algo(hash) hash.to_s.sub(/\ASHA256=/, "") end @@ -49,6 +64,7 @@ def strip_algo(hash) # Download and cache every locked platform zip for each ficsit dep. # # @param dependencies [Array] ficsit deps to install + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) dependencies.each { |dep| install(dep) } end @@ -57,6 +73,7 @@ def install_all(dependencies) # @param dep [Dependency] # @raise [MissingPlatformsError] if the dep was resolved without platforms + sig { params(dep: Dependency).void } def install(dep) platforms = dep.metadata["platforms"] if platforms.nil? || platforms.empty? @@ -71,9 +88,10 @@ def install(dep) # @param dep [Dependency] # @param platform [String] ficsit target name # @param target [Hash] { "hash" => …, "link" => … } + sig { params(dep: Dependency, platform: String, target: T::Hash[String, T.untyped]).void } def install_platform(dep, platform, target) key = self.class.cache_key(name: dep.name, version: dep.version, platform:, hash: target["hash"]) - if cache.exists?(key) + if T.must(cache).exists?(key) puts ">>> #{dep.name}@#{dep.version} (#{platform}) already cached" return end @@ -83,7 +101,7 @@ def install_platform(dep, platform, target) puts ">>> Downloading #{dep.name}@#{dep.version} (#{platform})" download(target["link"], zip) verify(zip, target["hash"], dep, platform) - File.open(zip, "rb") { |file| cache.store(key, file) } + File.open(zip, "rb") { |file| T.must(cache).store(key, file) } puts ">>> Cached #{dep.name}@#{dep.version} (#{platform})" end end @@ -91,6 +109,7 @@ def install_platform(dep, platform, target) # @param link [String] absolute download URL # @param dest [String] destination path # @raise [DownloadError] if curl fails + sig { params(link: String, dest: String).void } def download(link, dest) system("curl", "-fsSL", "-o", dest, link) || raise(DownloadError, "download failed for #{link}") @@ -101,6 +120,7 @@ def download(link, dest) # @param dep [Dependency] for error messages # @param platform [String] for error messages # @raise [IntegrityError] if the digest does not match + sig { params(path: String, expected: T.nilable(String), dep: Dependency, platform: String).void } def verify(path, expected, dep, platform) sha = self.class.strip_algo(expected) return if sha.empty? diff --git a/lib/dev/deps/ficsit_repository.rb b/lib/dev/deps/ficsit_repository.rb index 640d750..8924659 100644 --- a/lib/dev/deps/ficsit_repository.rb +++ b/lib/dev/deps/ficsit_repository.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "json" require "net/http" +require "sorbet-runtime" require "uri" require_relative "repository" require_relative "dependency" @@ -13,13 +15,15 @@ module Deps # Uses the GraphQL API at api.ficsit.app/v2/query to resolve a mod_reference # to an exact version, integrity hash, and transitive mod dependencies. class FicsitRepository < Repository + extend T::Sig + class ApiError < StandardError; end class ModNotFoundError < StandardError; end class NoVersionError < StandardError; end class TargetNotFoundError < StandardError; end API_HOST = "https://api.ficsit.app" - GRAPHQL_ENDPOINT = URI("#{API_HOST}/v2/query") + GRAPHQL_ENDPOINT = T.let(URI("#{API_HOST}/v2/query"), URI::Generic) DEFAULT_TARGET = "Windows" VERSIONS_QUERY = <<~GRAPHQL @@ -66,6 +70,7 @@ class TargetNotFoundError < StandardError; end # @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) @@ -116,6 +121,13 @@ def fetch(id) # @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 @@ -142,6 +154,12 @@ def resolve_platforms(mod_reference, version_data, requested) # @param version_data [Hash] # @param target_data [Hash] # @return [String] absolute https URL + sig do + params( + version_data: T::Hash[String, T.untyped], + target_data: T::Hash[String, T.untyped], + ).returns(String) + end def download_url(version_data, target_data) link = target_data["link"] return "#{API_HOST}#{link}" if link && !link.empty? && link.start_with?("/") @@ -156,10 +174,11 @@ def download_url(version_data, target_data) # @return [Hash] parsed mod data from the API response # @raise [ModNotFoundError] if the mod is not found # @raise [ApiError] if the HTTP request fails or returns errors + sig { params(mod_reference: String).returns(T::Hash[String, T.untyped]) } def query_mod(mod_reference) body = { query: VERSIONS_QUERY, variables: { modReference: mod_reference } } response = post_graphql(body) - parsed = JSON.parse(response.body) + parsed = JSON.parse(T.must(response.body)) if parsed.key?("errors") messages = parsed["errors"].map { |e| e["message"] }.join("; ") @@ -177,8 +196,9 @@ def query_mod(mod_reference) # @param body [Hash] request body with query and variables # @return [Net::HTTPResponse] # @raise [ApiError] if the HTTP response is not 2xx + sig { params(body: T::Hash[Symbol, T.untyped]).returns(Net::HTTPResponse) } def post_graphql(body) - http = Net::HTTP.new(GRAPHQL_ENDPOINT.host, GRAPHQL_ENDPOINT.port) + http = Net::HTTP.new(T.must(GRAPHQL_ENDPOINT.host), GRAPHQL_ENDPOINT.port) http.use_ssl = true request = Net::HTTP::Post.new(GRAPHQL_ENDPOINT.path) @@ -198,6 +218,12 @@ def post_graphql(body) # @param targets [Array, nil] target objects from the version # @param target_name [String] platform name (e.g. "Windows") # @return [Hash, nil] matching target or nil + sig do + params( + targets: T.nilable(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) return nil if targets.nil? || targets.empty? diff --git a/lib/dev/deps/gem_skill_linker.rb b/lib/dev/deps/gem_skill_linker.rb index ae43c48..f6f0092 100644 --- a/lib/dev/deps/gem_skill_linker.rb +++ b/lib/dev/deps/gem_skill_linker.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "open3" require "pathname" +require "sorbet-runtime" require_relative "../skill_installer" require_relative "bundler_repository" @@ -19,6 +21,8 @@ module Deps # staleness story as any dependency change: the lock digest changes, the # `dev up` nag fires, and the install refreshes the links. class GemSkillLinker + extend T::Sig + LINK_PREFIX = "gem-" SKILLS_SUBDIR = "skills" AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze @@ -29,15 +33,18 @@ class GemSkillLinker # unset so paths resolve from the project's canonical bundler config — # dev never runs under bundler itself, so these unsets are its # equivalent of Bundler.original_env (dev#89). - HARNESS_ENV_SCRUB = [ - "BUNDLE_PATH", - "BUNDLE_APP_CONFIG", - "BUNDLE_BIN", - "GEM_HOME", - "GEM_PATH", - "RUBYOPT", - "RUBYLIB", - ].to_h { |name| [name, nil] }.freeze + HARNESS_ENV_SCRUB = T.let( + [ + "BUNDLE_PATH", + "BUNDLE_APP_CONFIG", + "BUNDLE_BIN", + "GEM_HOME", + "GEM_PATH", + "RUBYOPT", + "RUBYLIB", + ].to_h { |name| [name, nil] }.freeze, + T::Hash[String, T.nilable(String)], + ) # @param project_root [Pathname, String] repo root (Gemfile + link target) # @param skills_dir [Pathname, String, nil] override for tests; defaults @@ -45,10 +52,17 @@ class GemSkillLinker # @param tmpdir [Pathname, String] ephemeral temp root that links must # never target; defaults to Dir.tmpdir (override for tests, whose # fixture gem trees themselves live under the real temp dir) + sig do + params( + project_root: T.any(Pathname, String), + skills_dir: T.nilable(T.any(Pathname, String)), + tmpdir: T.any(Pathname, String), + ).void + end def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir) - @project_root = Pathname(project_root) - @skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS)) - @skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir) + @project_root = T.let(Pathname(project_root), Pathname) + @skills_dir = T.let(Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS)), Pathname) + @skill_installer = T.let(SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir), SkillInstaller) end # Scan the locked gem set for shipped skills and refresh the project's @@ -61,6 +75,7 @@ def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir) # block correctness (failures are reported on stderr). # # @return [void] + sig { void } def link_all return unless gemfile_path.exist? @@ -77,6 +92,7 @@ def link_all # skills/*/SKILL.md found in a locked gem's installed tree. # # @return [Hash{String => Pathname}] link name → skill directory + sig { returns(T::Hash[String, Pathname]) } def expected_links gem_roots.each_with_object({}) do |(gem_name, gem_root), links| (gem_root / SKILLS_SUBDIR).glob("*/#{SkillInstaller::SKILL_FILE}").sort.each do |skill_file| @@ -95,6 +111,7 @@ def expected_links # with minitest-reporters, not minitest. # # @return [Array] + sig { returns(T::Array[[String, Pathname]]) } def gem_roots names = locked_gem_names bundled_gem_paths.filter_map do |path| @@ -112,6 +129,7 @@ def gem_roots # resolution into its ephemeral cache. # # @return [Array] install paths of every gem in the bundle + sig { returns(T::Array[Pathname]) } def bundled_gem_paths out, err, status = Open3.capture3( HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s), @@ -136,11 +154,12 @@ def bundled_gem_paths # constraints and are skipped. # # @return [Array] + sig { returns(T::Array[String]) } def locked_gem_names return [] unless lockfile_path.exist? names = [] - in_specs = false + in_specs = T.let(false, T::Boolean) lockfile_path.read.each_line do |line| if line.match?(/^\s+specs:\s*$/) in_specs = true @@ -160,6 +179,7 @@ def locked_gem_names # # @param expected_names [Array] # @return [void] + sig { params(expected_names: T::Array[String]).void } def prune_stale_links(expected_names) return unless @skills_dir.directory? @@ -172,11 +192,13 @@ def prune_stale_links(expected_names) end # @return [Pathname] + sig { returns(Pathname) } def gemfile_path @project_root / BundlerRepository::GEMFILE end # @return [Pathname] + sig { returns(Pathname) } def lockfile_path @project_root / BundlerRepository::LOCKFILE end diff --git a/lib/dev/deps/gh_integration.rb b/lib/dev/deps/gh_integration.rb index 22e045a..a7758a8 100644 --- a/lib/dev/deps/gh_integration.rb +++ b/lib/dev/deps/gh_integration.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "digest" @@ -5,6 +6,7 @@ require "pathname" require "securerandom" require "shellwords" +require "sorbet-runtime" require_relative "integration" module Dev @@ -38,6 +40,8 @@ module Deps # parking a second copy in ~/.dev/cache would double disk usage for no benefit. # The version-keyed install dir plus its marker file is the cache. class GhIntegration < Integration + extend T::Sig + class DownloadError < StandardError; end class IntegrityError < StandardError; end class ExtractionError < StandardError; end @@ -46,32 +50,43 @@ class BuildError < StandardError; end MARKER_FILE = ".dev-gh-release" - # @param repository [Repository] - # @param cache [Cache] + # @param repository [Repository, nil] + # @param cache [Cache, nil] # @param project_root [String, Pathname, nil] repo root, used to resolve a # project-relative build: script path (e.g. "bin/build-ue.sh") + sig do + params( + repository: T.nilable(Repository), + cache: T.nilable(Cache), + project_root: T.nilable(T.any(String, Pathname)), + ).void + end def initialize(repository:, cache:, project_root: nil) super(repository: repository, cache: cache) - @project_root = project_root && Pathname(project_root) + @project_root = T.let(project_root && Pathname(project_root), T.nilable(Pathname)) end # Install all gh dependencies. # # @param dependencies [Array] gh deps to install + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) dependencies.each { |dep| install(dep) } end private + sig { returns(T.nilable(Pathname)) } attr_reader :project_root # @param dep [Dependency] + sig { params(dep: Dependency).void } def install(dep) dep.metadata["assets"] ? install_prebuilt(dep) : install_from_source(dep) end # @param dep [Dependency] + sig { params(dep: Dependency).void } def install_prebuilt(dep) base_dir = Pathname(File.expand_path(dep.metadata["install_dir"])) target_dir = versioned_dir(base_dir, dep.version) @@ -110,6 +125,7 @@ def install_prebuilt(dep) end # @param dep [Dependency] + sig { params(dep: Dependency).void } def install_from_source(dep) base_dir = Pathname(File.expand_path(dep.metadata["install_dir"])) target_dir = versioned_dir(base_dir, dep.version) @@ -153,6 +169,7 @@ def install_from_source(dep) # # @param base_dir [Pathname] declared install_dir # @param target_dir [Pathname] the published version dir + sig { params(base_dir: Pathname, target_dir: Pathname).void } def publish_current(base_dir, target_dir) link = base_dir / "current" tmp = base_dir / ".current-#{Process.pid}-#{SecureRandom.hex(4)}" @@ -171,6 +188,7 @@ def publish_current(base_dir, target_dir) # @param dep [Dependency] # @param archive_path [Pathname] destination .tar.gz # @raise [DownloadError] if the fetch fails + sig { params(dep: Dependency, archive_path: Pathname).void } def download_source(dep, archive_path) success = system( "gh", "api", "repos/#{dep.metadata["repo"]}/tarball/#{dep.version}", @@ -189,6 +207,7 @@ def download_source(dep, archive_path) # @param archive_path [Pathname] # @param source_dir [Pathname] # @raise [ExtractionError] if tar fails + sig { params(archive_path: Pathname, source_dir: Pathname).void } def extract_source(archive_path, source_dir) success = system( "tar", "-xzf", archive_path.to_s, "-C", source_dir.to_s, "--strip-components=1" @@ -205,6 +224,7 @@ def extract_source(archive_path, source_dir) # @param source_dir [Pathname] extracted source ($DEV_SOURCE_DIR) # @param install_dir [Pathname] empty output dir ($DEV_INSTALL_DIR) # @return [Pathname] the staging dir to publish as the version dir + sig { params(dep: Dependency, source_dir: Pathname, install_dir: Pathname).returns(Pathname) } def build_source(dep, source_dir, install_dir) if dep.metadata["build"] == "none" puts ">>> #{dep.name}@#{dep.version}: header-only, publishing source as-is" @@ -223,13 +243,18 @@ def build_source(dep, source_dir, install_dir) # @param source_dir [Pathname] # @param install_dir [Pathname] # @raise [BuildError] if the recipe exits non-zero + sig { params(dep: Dependency, source_dir: Pathname, install_dir: Pathname).void } def run_build(dep, source_dir, install_dir) env = { "DEV_SOURCE_DIR" => source_dir.to_s, "DEV_INSTALL_DIR" => install_dir.to_s, "DEV_VERSION" => dep.version, } - success = system(env, *build_command(dep.metadata["build"]), chdir: source_dir.to_s) + # T.unsafe: Sorbet cannot check a runtime-sized argv splat. The call + # stays receiverless because Kernel#system is private (and tests stub + # it on the instance). + argv = [env, *build_command(dep.metadata["build"])] + success = system(*T.unsafe(argv), chdir: source_dir.to_s) return if success raise BuildError, @@ -242,6 +267,7 @@ def run_build(dep, source_dir, install_dir) # # @param build [String] script path or inline shell # @return [Array] argv for system + sig { params(build: String).returns(T::Array[String]) } def build_command(build) script = project_root&.join(build) return ["bash", script.to_s] if script&.file? @@ -255,6 +281,7 @@ def build_command(build) # @param dep [Dependency] # @param archives_dir [Pathname] destination for downloaded assets # @raise [DownloadError] if gh release download fails + sig { params(dep: Dependency, archives_dir: Pathname).void } def download_assets(dep, archives_dir) success = system( "gh", "release", "download", dep.version, @@ -275,6 +302,7 @@ def download_assets(dep, archives_dir) # @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| path = archives_dir / asset["name"] @@ -297,6 +325,7 @@ def verify_assets(dep, archives_dir) # # @param archives_dir [Pathname] # @param extracted_dir [Pathname] + sig { params(archives_dir: Pathname, extracted_dir: Pathname).void } def extract_archives(archives_dir, extracted_dir) groups = archives_dir.children.select(&:file?).group_by { |path| archive_base_name(path) } groups.each do |base_name, parts| @@ -308,6 +337,7 @@ def extract_archives(archives_dir, extracted_dir) # # @param path [Pathname] # @return [String] + sig { params(path: Pathname).returns(String) } def archive_base_name(path) path.basename.to_s.sub(/\.\d+\z/, "") end @@ -321,6 +351,7 @@ def archive_base_name(path) # @param dest_dir [Pathname] extraction destination # @raise [UnsupportedArchiveError] for non-zstd archives # @raise [ExtractionError] if the pipeline fails + sig { params(base_name: String, parts: T::Array[Pathname], dest_dir: Pathname).void } def extract_zstd_tarball(base_name, parts, dest_dir) unless base_name.end_with?(".tar.zst") raise UnsupportedArchiveError, diff --git a/lib/dev/deps/gh_repository.rb b/lib/dev/deps/gh_repository.rb index 07a359e..d272123 100644 --- a/lib/dev/deps/gh_repository.rb +++ b/lib/dev/deps/gh_repository.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "json" require "open3" +require "sorbet-runtime" require_relative "repository" require_relative "dependency" @@ -19,6 +21,8 @@ module Deps # assets: "UnrealEngine-CSS-Editor-Linux.tar.zst.*", # install_dir: "~/.dev/engines/unreal-engine-css" class GhRepository < Repository + extend T::Sig + class GhMissingError < StandardError; end class AuthenticationError < StandardError; end class RepoAccessError < StandardError; end @@ -39,6 +43,7 @@ class ApiError < StandardError; end # @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 @@ -49,6 +54,7 @@ def fetch(id) # # @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"] @@ -83,6 +89,7 @@ def fetch_prebuilt(id) # # @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"] @@ -108,6 +115,7 @@ def fetch_source(id) # @param repo_slug [String] "owner/repo" # @param tag [String] tag/ref # @return [String] commit SHA + sig { params(repo_slug: String, tag: String).returns(String) } def resolve_commit_sha(repo_slug, tag) out, err, status = run_gh_api("repos/#{repo_slug}/commits/#{tag}") return JSON.parse(out)["sha"] if status.success? @@ -122,6 +130,7 @@ def resolve_commit_sha(repo_slug, tag) # @param repo_slug [String] "owner/repo" # @param tag [String] release tag # @return [Hash] parsed release JSON + sig { params(repo_slug: String, tag: String).returns(T::Hash[String, T.untyped]) } def fetch_release(repo_slug, tag) out, err, status = run_gh_api("repos/#{repo_slug}/releases/tags/#{tag}") return JSON.parse(out) if status.success? @@ -137,6 +146,7 @@ def fetch_release(repo_slug, tag) # # @param repo_slug [String] "owner/repo" # @param tag [String] release tag + sig { params(repo_slug: String, tag: String).void } def raise_not_found_error!(repo_slug, tag) _out, _err, status = run_gh_api("repos/#{repo_slug}") if status.success? @@ -153,6 +163,7 @@ def raise_not_found_error!(repo_slug, tag) end # @param err [String] gh stderr output + sig { params(err: String).void } def raise_auth_error!(err) return unless err.include?("gh auth login") @@ -161,6 +172,7 @@ def raise_auth_error!(err) # @param err [String] gh stderr output # @return [Boolean] + sig { params(err: String).returns(T::Boolean) } def not_found?(err) err.include?("HTTP 404") end @@ -169,6 +181,7 @@ def not_found?(err) # # @param path [String] API path (e.g. "repos/owner/repo/releases/tags/v1") # @return [Array(String, String, Process::Status)] stdout, stderr, status + sig { params(path: String).returns([String, String, Process::Status]) } def run_gh_api(path) Open3.capture3("gh", "api", path) rescue Errno::ENOENT @@ -180,6 +193,12 @@ def run_gh_api(path) # @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"]) } @@ -191,6 +210,7 @@ def matching_assets(release, pattern) # # @param asset [Hash] API asset object # @return [Hash] { "name", "size", "sha256"? } + sig { params(asset: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) } def asset_metadata(asset) metadata = { "name" => asset["name"], "size" => asset["size"] } digest = asset["digest"] diff --git a/lib/dev/deps/git_repository.rb b/lib/dev/deps/git_repository.rb index d13c037..f6e1469 100644 --- a/lib/dev/deps/git_repository.rb +++ b/lib/dev/deps/git_repository.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "open3" +require "sorbet-runtime" require_relative "repository" require_relative "dependency" @@ -12,6 +14,8 @@ module Deps # 40-char hex commit SHAs pass through without network calls. # Git SHAs are identifiers, not integrity hashes — hash field is nil. class GitRepository < Repository + extend T::Sig + class RefResolutionError < StandardError; end # Resolve a git dependency identifier to a pinned Dependency. @@ -20,6 +24,7 @@ class RefResolutionError < StandardError; end # 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"] @@ -48,14 +53,15 @@ def fetch(id) # @param ref [String] tag name, branch name, or commit SHA # @return [String] full 40-char SHA # @raise [RefResolutionError] if no match found + sig { params(repo: String, ref: String).returns(String) } def resolve_ref(repo, ref) return ref if ref.to_s.length == 40 && ref.to_s.match?(/\A[0-9a-f]+\z/) out, _err, status = Open3.capture3("git", "ls-remote", "--tags", repo, ref.to_s) - return out.lines.first.split.first if status.success? && !out.strip.empty? + return T.must(out.lines.first&.split&.first) if status.success? && !out.strip.empty? out, _err, status = Open3.capture3("git", "ls-remote", repo, "refs/heads/#{ref}") - return out.lines.first.split.first if status.success? && !out.strip.empty? + return T.must(out.lines.first&.split&.first) if status.success? && !out.strip.empty? raise RefResolutionError, "Could not resolve ref '#{ref}' for #{repo}" end diff --git a/lib/dev/deps/hooks/unreal_module.rb b/lib/dev/deps/hooks/unreal_module.rb index c1ea0cd..c9da800 100644 --- a/lib/dev/deps/hooks/unreal_module.rb +++ b/lib/dev/deps/hooks/unreal_module.rb @@ -1,5 +1,10 @@ +# typed: strict # frozen_string_literal: true +require "pathname" +require "sorbet-runtime" +require_relative "../dependency" + module Dev module Deps module Hooks @@ -12,8 +17,11 @@ module Hooks # post_install: Dev::Deps::Hooks::UnrealModule module UnrealModule class << self + extend T::Sig + # @param dep [Dependency] the resolved dependency # @param root [Pathname] project root + sig { params(dep: Dependency, root: Pathname).void } def call(dep, root) src_dir = root / "build" / "_deps" / "#{dep.name}-src" return unless src_dir.directory? @@ -29,6 +37,7 @@ def call(dep, root) # # @param name [String] # @return [String] + sig { params(name: String).returns(String) } def to_module_name(name) name.gsub(/[^a-zA-Z0-9]/, " ").split.map(&:capitalize).join end @@ -38,6 +47,7 @@ def to_module_name(name) # @param module_name [String] PascalCase module name # @param dep [Dependency] # @return [String] + sig { params(module_name: String, dep: Dependency).returns(String) } def generate_build_cs(module_name, dep) public_includes = dep.metadata["public_includes"] || ["."] includes_lines = public_includes.map { |inc| " \"#{inc}\"" }.join(",\n") diff --git a/lib/dev/deps/hooks/wwise_download.rb b/lib/dev/deps/hooks/wwise_download.rb index 3b1da3e..e057866 100644 --- a/lib/dev/deps/hooks/wwise_download.rb +++ b/lib/dev/deps/hooks/wwise_download.rb @@ -1,5 +1,8 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" + module Dev module Deps module Hooks @@ -13,11 +16,14 @@ module Hooks # platforms: ["Windows_vc160", "Windows_vc170", "Linux"], # ) class WwiseDownload + extend T::Sig + class MissingVersionError < StandardError; end # @param version [String] SDK version (e.g. "2023.1.14.8770") # @param packages [Array] Wwise packages (e.g. ["SDK", "Authoring"]) # @param platforms [Array] deployment platforms (e.g. ["Windows_vc160", "Linux"]) + sig { params(version: String, packages: T::Array[String], platforms: T::Array[String]).void } def initialize(version:, packages: [], platforms: []) @version = version @packages = packages @@ -26,12 +32,13 @@ def initialize(version:, packages: [], platforms: []) # @param _name [String] brew entry name (e.g. "wwise-cli") # @param _opts [Hash] brew entry options (unused — config is on the instance) + sig { params(_name: String, _opts: T::Hash[T.untyped, T.untyped]).void } def call(_name, _opts) - argv = ["wwise-cli", "download", "--sdk-version", @version] + argv = T.let(["wwise-cli", "download", "--sdk-version", @version], T::Array[String]) @packages.each { |pkg| argv += ["--filter", "Packages=#{pkg}"] } @platforms.each { |plat| argv += ["--filter", "DeploymentPlatforms=#{plat}"] } - system(*argv) || abort("wwise-cli download failed for SDK #{@version}") + system(*T.unsafe(argv)) || abort("wwise-cli download failed for SDK #{@version}") end end end diff --git a/lib/dev/deps/integration.rb b/lib/dev/deps/integration.rb index 5317aee..17775fb 100644 --- a/lib/dev/deps/integration.rb +++ b/lib/dev/deps/integration.rb @@ -1,8 +1,13 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" require "securerandom" +require "sorbet-runtime" +require_relative "cache" +require_relative "dependency" +require_relative "repository" module Dev module Deps @@ -12,8 +17,11 @@ module Deps # dependencies for its type at once via install_all — handles per-dep # install plus any batch artifacts (e.g. deps.cmake). class Integration - # @param repository [Repository] source adapter for this integration type - # @param cache [Cache] shared download cache + extend T::Sig + + # @param repository [Repository, nil] source adapter for this integration type + # @param cache [Cache, nil] shared download cache + sig { params(repository: T.nilable(Repository), cache: T.nilable(Cache)).void } def initialize(repository:, cache:) @repository = repository @cache = cache @@ -22,13 +30,18 @@ def initialize(repository:, cache:) # Install all dependencies of this integration type. # # @param dependencies [Array] all deps for this integration type + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) raise NotImplementedError, "#{self.class}#install_all must be implemented" end private - attr_reader :repository, :cache + sig { returns(T.nilable(Repository)) } + attr_reader :repository + + sig { returns(T.nilable(Cache)) } + attr_reader :cache # --- version-keyed, content-addressed install layout (gh, steam) ------- # @@ -50,6 +63,7 @@ def install_all(dependencies) # @param base_dir [Pathname] declared install_dir # @param version [String] locked version (gh tag / steam buildid) # @return [Pathname] + sig { params(base_dir: Pathname, version: String).returns(Pathname) } def versioned_dir(base_dir, version) Pathname(base_dir) / version end @@ -60,6 +74,7 @@ def versioned_dir(base_dir, version) # # @param base_dir [Pathname] # @return [Pathname] + sig { params(base_dir: Pathname).returns(Pathname) } def new_staging_dir(base_dir) Pathname("#{base_dir}/.staging-#{Process.pid}-#{SecureRandom.hex(4)}") end @@ -73,6 +88,7 @@ def new_staging_dir(base_dir) # @param marker_file [String] marker basename # @param version [String] expected version # @return [Boolean] + sig { params(dir: Pathname, marker_file: String, version: String).returns(T::Boolean) } def version_published?(dir, marker_file, version) marker = dir / marker_file marker.file? && marker.read.strip == version @@ -92,6 +108,7 @@ def version_published?(dir, marker_file, version) # @param staging [Pathname] fully-built, marker-stamped staging dir # @param versioned [Pathname] destination version dir # @return [Boolean] true if this call published, false if another won + sig { params(staging: Pathname, versioned: Pathname).returns(T::Boolean) } def publish_version(staging, versioned) FileUtils.mkdir_p(versioned.dirname) File.rename(staging.to_s, versioned.to_s) diff --git a/lib/dev/deps/lockfile.rb b/lib/dev/deps/lockfile.rb index 0b3e9ef..7a1c177 100644 --- a/lib/dev/deps/lockfile.rb +++ b/lib/dev/deps/lockfile.rb @@ -1,3 +1,4 @@ +# typed: true # frozen_string_literal: true require "yaml" diff --git a/lib/dev/deps/luarocks_integration.rb b/lib/dev/deps/luarocks_integration.rb index e59c20a..1bae028 100644 --- a/lib/dev/deps/luarocks_integration.rb +++ b/lib/dev/deps/luarocks_integration.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "open3" require "pathname" +require "sorbet-runtime" require_relative "integration" require_relative "dependency" @@ -12,21 +14,31 @@ module Deps # Installs each dep via `luarocks install --tree /lua_modules`. # Project-local install directory (`lua_modules/`) keeps deps isolated. class LuaRocksIntegration < Integration + extend T::Sig + class InstallError < StandardError; end INSTALL_DIR = "lua_modules" - # @param repository [Repository] source adapter for luarocks deps - # @param cache [Cache] shared download cache - # @param project_root [Pathname] project root directory + # @param repository [Repository, nil] source adapter for luarocks deps + # @param cache [Cache, nil] shared download cache + # @param project_root [String, Pathname] project root directory + sig do + params( + repository: T.nilable(Repository), + cache: T.nilable(Cache), + project_root: T.any(String, Pathname), + ).void + end def initialize(repository:, cache:, project_root:) super(repository:, cache:) - @project_root = Pathname(project_root) + @project_root = T.let(Pathname(project_root), Pathname) end # Install all LuaRocks dependencies into the project-local tree. # # @param dependencies [Array] luarocks deps to install + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) tree = @project_root / INSTALL_DIR dependencies.each do |dep| @@ -39,6 +51,7 @@ def install_all(dependencies) # @param dep [Dependency] dependency to install # @param tree [Pathname] luarocks --tree path # @raise [InstallError] if luarocks install command fails + sig { params(dep: Dependency, tree: Pathname).void } def run_luarocks_install(dep, tree) _out, err, status = Open3.capture3( "luarocks", "install", dep.name, dep.version, "--tree", tree.to_s, diff --git a/lib/dev/deps/luarocks_repository.rb b/lib/dev/deps/luarocks_repository.rb index fca240f..66ebff9 100644 --- a/lib/dev/deps/luarocks_repository.rb +++ b/lib/dev/deps/luarocks_repository.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "digest" require "open3" +require "sorbet-runtime" require "tempfile" require_relative "repository" require_relative "dependency" @@ -14,6 +16,8 @@ module Deps # picks the best match for the constraint, downloads the rock to compute # SHA256. Callers are responsible for caching. class LuaRocksRepository < Repository + extend T::Sig + class SearchError < StandardError; end class NoVersionError < StandardError; end class DownloadError < StandardError; end @@ -25,6 +29,7 @@ class DownloadError < StandardError; end # @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"]) @@ -51,14 +56,17 @@ def fetch(id) # @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? - versions = out.scan(/^\s+(\S+)\s+\(/).map(&:first) + # 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? - versions.first + T.must(versions.first) end # Download a source rock to a temp file. @@ -67,14 +75,15 @@ def find_best_version(name, _constraint) # @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(tmp.path)}", + "luarocks", "download", name, version, "--source", "--to=#{File.dirname(T.must(tmp.path))}", ) raise DownloadError, "luarocks download #{name} #{version} failed: #{err}" unless status.success? - tmp.path + T.must(tmp.path) end end end diff --git a/lib/dev/deps/pip_integration.rb b/lib/dev/deps/pip_integration.rb index 3303604..527f341 100644 --- a/lib/dev/deps/pip_integration.rb +++ b/lib/dev/deps/pip_integration.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true require "open3" require "pathname" require "shadowenv_python" +require "sorbet-runtime" require_relative "integration" require_relative "dependency" @@ -16,17 +18,27 @@ module Deps # `dev install-deps` works on a fresh clone, before any command has run # ShadowenvPython.setup!. pip resolves the transitive tree at install. class PipIntegration < Integration + extend T::Sig + class InstallError < StandardError; end class MissingVersionError < StandardError; end - # @param repository [Repository] source adapter for pip deps - # @param cache [Cache] shared download cache (unused; pip caches) - # @param project_root [Pathname] project root (holds the .venv) + # @param repository [Repository, nil] source adapter for pip deps + # @param cache [Cache, nil] shared download cache (unused; pip caches) + # @param project_root [String, Pathname] project root (holds the .venv) # @param python_version [String, nil] the `python` toolchain version to build # the venv with; required whenever there are pip deps to install + sig do + params( + repository: T.nilable(Repository), + cache: T.nilable(Cache), + project_root: T.any(String, Pathname), + python_version: T.nilable(String), + ).void + end def initialize(repository:, cache:, project_root:, python_version: nil) super(repository:, cache:) - @project_root = Pathname(project_root) + @project_root = T.let(Pathname(project_root), Pathname) @python_version = python_version end @@ -35,6 +47,7 @@ def initialize(repository:, cache:, project_root:, python_version: nil) # @param dependencies [Array] pip deps to install # @raise [MissingVersionError] if pip deps exist but no `python` version is set # @raise [InstallError] if a pip install fails + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) return if dependencies.empty? @@ -55,6 +68,7 @@ def install_all(dependencies) # @param python [Pathname] the venv's python interpreter # @param dep [Dependency] dependency to install (exact version when pinned) # @raise [InstallError] if pip install fails + sig { params(python: Pathname, dep: Dependency).void } def run_pip_install(python, dep) spec = dep.version ? "#{dep.name}==#{dep.version}" : dep.name _out, err, status = Open3.capture3(python.to_s, "-m", "pip", "install", spec) diff --git a/lib/dev/deps/pip_repository.rb b/lib/dev/deps/pip_repository.rb index 745c87a..f53d140 100644 --- a/lib/dev/deps/pip_repository.rb +++ b/lib/dev/deps/pip_repository.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "digest" require "open3" +require "sorbet-runtime" require "tmpdir" require_relative "repository" require_relative "dependency" @@ -17,6 +19,8 @@ module Deps # install. Resolution uses whatever python3 is on PATH — update-deps runs on # the author's host, before the project venv necessarily exists. class PipRepository < Repository + extend T::Sig + class DownloadError < StandardError; end class NoVersionError < StandardError; end @@ -29,6 +33,7 @@ class NoVersionError < StandardError; end # @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"])}" @@ -53,6 +58,7 @@ def fetch(id) # # @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? @@ -65,6 +71,7 @@ def normalize_constraint(constraint) # # @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) @@ -85,6 +92,7 @@ def download_artifact(spec) # @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/) } diff --git a/lib/dev/deps/registry.rb b/lib/dev/deps/registry.rb index f0c89d6..f6a35c9 100644 --- a/lib/dev/deps/registry.rb +++ b/lib/dev/deps/registry.rb @@ -1,5 +1,9 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" +require_relative "cache" +require_relative "tap" require_relative "brew_repository" require_relative "brew_integration" require_relative "git_repository" @@ -43,7 +47,7 @@ module Registry CONTAINER = :container BOTH = :both - HOST_SCOPES = [HOST, BOTH].freeze + 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 @@ -56,88 +60,134 @@ module Registry Entry = Data.define( :symbol, :repository, :repository_needs, :integration, :integration_needs, :scope, ) do + extend T::Sig + + # Sorbet's Data.define rewriter can't attach sigs to the generated + # member readers (sorbet/sorbet#7272), so strict mode needs explicit + # typed readers. Data#to_h reads members at the C level (it does not + # call these readers), so the delegation is safe and non-recursive. + sig { returns(Symbol) } + def symbol = to_h.fetch(:symbol) + + sig { returns(T.class_of(Repository)) } + def repository = to_h.fetch(:repository) + + sig { returns(T::Array[Symbol]) } + def repository_needs = to_h.fetch(:repository_needs) + + sig { returns(T.nilable(T.class_of(Integration))) } + def integration = to_h.fetch(:integration) + + sig { returns(T::Array[Symbol]) } + def integration_needs = to_h.fetch(:integration_needs) + + sig { returns(Symbol) } + def scope = to_h.fetch(:scope) + + sig do + params( + symbol: Symbol, + repository: T.class_of(Repository), + integration: T.nilable(T.class_of(Integration)), + scope: Symbol, + repository_needs: T::Array[Symbol], + integration_needs: T::Array[Symbol], + ).void + end def initialize(symbol:, repository:, integration:, scope:, repository_needs: [], integration_needs: []) super end # @return [Boolean] whether this type installs on the host + sig { returns(T::Boolean) } def host? HOST_SCOPES.include?(scope) && !integration.nil? end end - INTEGRATIONS = [ - Entry.new( - symbol: :bundler, - repository: BundlerRepository, - repository_needs: %i[project_root ruby_version_requirement], - integration: BundlerIntegration, - integration_needs: %i[project_root], - scope: HOST, - ), - Entry.new( - symbol: :brew, - repository: BrewRepository, - integration: BrewIntegration, - integration_needs: %i[taps project_dir], - scope: BOTH, - ), - Entry.new( - symbol: :cmake, - repository: GitRepository, - integration: CmakeIntegration, - integration_needs: %i[project_root], - scope: HOST, - ), - Entry.new( - symbol: :luarocks, - repository: LuaRocksRepository, - integration: LuaRocksIntegration, - integration_needs: %i[project_root], - scope: HOST, - ), - Entry.new( - symbol: :ficsit, - repository: FicsitRepository, - integration: FicsitIntegration, - scope: HOST, - ), - Entry.new( - symbol: :gh, - repository: GhRepository, - integration: GhIntegration, - integration_needs: %i[project_root], - scope: HOST, - ), - Entry.new( - symbol: :steam, - repository: SteamRepository, - integration: SteamIntegration, - scope: HOST, - ), - Entry.new( - symbol: :xcode, - repository: XcodeRepository, - integration: XcodeIntegration, - integration_needs: %i[project_root], - scope: HOST, - ), - Entry.new( - symbol: :pip, - repository: PipRepository, - integration: PipIntegration, - integration_needs: %i[project_root python_version], - scope: HOST, - ), - ].freeze + INTEGRATIONS = T.let( + [ + Entry.new( + symbol: :bundler, + repository: BundlerRepository, + repository_needs: %i[project_root ruby_version_requirement], + integration: BundlerIntegration, + integration_needs: %i[project_root], + scope: HOST, + ), + Entry.new( + symbol: :brew, + repository: BrewRepository, + integration: BrewIntegration, + integration_needs: %i[taps project_dir], + scope: BOTH, + ), + Entry.new( + symbol: :cmake, + repository: GitRepository, + integration: CmakeIntegration, + integration_needs: %i[project_root], + scope: HOST, + ), + Entry.new( + symbol: :luarocks, + repository: LuaRocksRepository, + integration: LuaRocksIntegration, + integration_needs: %i[project_root], + scope: HOST, + ), + Entry.new( + symbol: :ficsit, + repository: FicsitRepository, + integration: FicsitIntegration, + scope: HOST, + ), + Entry.new( + symbol: :gh, + repository: GhRepository, + integration: GhIntegration, + integration_needs: %i[project_root], + scope: HOST, + ), + Entry.new( + symbol: :steam, + repository: SteamRepository, + integration: SteamIntegration, + scope: HOST, + ), + Entry.new( + symbol: :xcode, + repository: XcodeRepository, + integration: XcodeIntegration, + integration_needs: %i[project_root], + scope: HOST, + ), + Entry.new( + symbol: :pip, + repository: PipRepository, + integration: PipIntegration, + integration_needs: %i[project_root python_version], + scope: HOST, + ), + ].freeze, + T::Array[Entry], + ) class << self + extend T::Sig + # 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 do + params( + project_root: Pathname, + ruby_version_requirement: T.nilable(String), + ).returns(T::Hash[Symbol, Repository]) + end def repositories(project_root:, ruby_version_requirement: nil) context = { project_root:, ruby_version_requirement: } INTEGRATIONS.to_h { |entry| [entry.symbol, build_repository(entry, context)] } @@ -151,6 +201,15 @@ def repositories(project_root:, ruby_version_requirement: nil) # @param ruby_version_requirement [String, nil] for the bundler repository # @param python_version [String, nil] for the pip integration's venv # @return [Hash{Symbol => Integration}] + sig do + params( + project_root: Pathname, + cache: Cache, + taps: T::Array[Tap], + ruby_version_requirement: T.nilable(String), + python_version: T.nilable(String), + ).returns(T::Hash[Symbol, Integration]) + end def host_integrations(project_root:, cache:, taps: [], ruby_version_requirement: nil, python_version: nil) context = { project_root:, @@ -162,10 +221,13 @@ def host_integrations(project_root:, cache:, taps: [], ruby_version_requirement: INTEGRATIONS.each_with_object({}) do |entry, integrations| next unless entry.host? - integrations[entry.symbol] = entry.integration.new( + # T.unsafe: each entry's constructor takes a runtime-selected + # keyword set (integration_needs), which Sorbet cannot check + # statically; the constructors' own sigs validate at runtime. + integrations[entry.symbol] = T.unsafe(T.must(entry.integration)).new( repository: build_repository(entry, context), cache:, - **context.slice(*entry.integration_needs), + **T.unsafe(context).slice(*entry.integration_needs), ) end end @@ -173,8 +235,11 @@ def host_integrations(project_root:, cache:, taps: [], ruby_version_requirement: # @param entry [Entry] # @param context [Hash{Symbol => Object}] available constructor arguments # @return [Repository] + sig { params(entry: Entry, context: T::Hash[Symbol, T.untyped]).returns(Repository) } def build_repository(entry, context) - entry.repository.new(**context.slice(*entry.repository_needs)) + # 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)) end end end diff --git a/lib/dev/deps/repository.rb b/lib/dev/deps/repository.rb index c4fda6e..46508a9 100644 --- a/lib/dev/deps/repository.rb +++ b/lib/dev/deps/repository.rb @@ -1,5 +1,10 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" +require_relative "dependency" +require_relative "dependency_declaration" + module Dev module Deps # Source adapter that fetches a dependency by its unique identifier. @@ -7,10 +12,13 @@ module Deps # Returns a Dependency domain object with all fields populated # (including transitive dependencies when the source supports it). class Repository + extend T::Sig + # Fetch a dependency by its unique identifier. # - # @param id [String] unique resource identifier within this repository + # @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 @@ -22,6 +30,7 @@ def fetch(id) # # @param declarations [Array] this type's declarations # @return [void] + sig { params(declarations: T::Array[DependencyDeclaration]).void } def prepare(declarations); end end end diff --git a/lib/dev/deps/resolver.rb b/lib/dev/deps/resolver.rb index acbbaad..0975bed 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -1,5 +1,8 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" +require_relative "repository" require_relative "dependency" require_relative "dependency_declaration" @@ -12,9 +15,12 @@ module Deps # Registries that support dependency metadata (LuaRocks, CurseForge, Brew) # get full transitive resolution. Source-based repos (Git, URL) return []. class Resolver + extend T::Sig + class UnknownIntegrationError < StandardError; end # @param repositories [Hash{Symbol => Repository}] integration type → repository + sig { params(repositories: T::Hash[Symbol, Repository]).void } def initialize(repositories:) @repositories = repositories end @@ -27,6 +33,7 @@ def initialize(repositories:) # @param declarations [Array] declared dependencies to resolve # @return [Array] # @raise [UnknownIntegrationError] if no repository is registered for a declaration's integration type + sig { params(declarations: T::Array[DependencyDeclaration]).returns(T::Array[Dependency]) } def resolve(declarations) prepare_repositories(declarations) @@ -89,6 +96,7 @@ def resolve(declarations) # @param dependency [Dependency] freshly fetched # @param decl [DependencyDeclaration] the declaration it came from # @return [Dependency] + sig { params(dependency: Dependency, decl: DependencyDeclaration).returns(Dependency) } def attach_install_scoping(dependency, decl) extra = {} extra["host"] = decl.host.to_s if decl.host @@ -104,6 +112,7 @@ def attach_install_scoping(dependency, decl) # # @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) @@ -117,6 +126,11 @@ def prepare_repositories(declarations) # # @param declarations [Array] # @return [Hash{String => Array}] name → de-duped platform list + sig do + params( + declarations: T::Array[DependencyDeclaration], + ).returns(T::Hash[String, T::Array[T.nilable(String)]]) + end def platforms_by_name(declarations) result = Hash.new { |h, k| h[k] = [] } declarations.each { |decl| result[decl.name] << decl.platform } @@ -131,6 +145,11 @@ def platforms_by_name(declarations) # # @param constraint [Hash, String, nil] raw constraint from Dependency#dependencies # @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 diff --git a/lib/dev/deps/staleness.rb b/lib/dev/deps/staleness.rb index ff6b90d..41db739 100644 --- a/lib/dev/deps/staleness.rb +++ b/lib/dev/deps/staleness.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true require "digest" require "fileutils" require "pathname" +require "sorbet-runtime" require_relative "lockfile" module Dev @@ -30,29 +32,41 @@ module Deps # dev up), not out-of-band mutation of installed artifacts — that's a # deferred doctor-style per-integration sweep. class Staleness + extend T::Sig + STAMP_FILE = "installed-digest" # Lockfiles whose contents constitute "what an install consumed", in # fixed order for a deterministic digest. Gemfile.lock is included # because dev generates it from the gem declarations — it is a lockfile # of this system in everything but name. - LOCK_FILES = [ - Lockfile::DEPS_LOCK_FILE, - Lockfile::BUILD_DEPS_LOCK_FILE, - "Gemfile.lock", - ].freeze - - # @param project_root [Pathname] repo root (holds dependencies.rb + lockfiles) - # @param state_dir [Pathname] per-machine state root (default ~/.dev/state) + LOCK_FILES = T.let( + [ + Lockfile::DEPS_LOCK_FILE, + Lockfile::BUILD_DEPS_LOCK_FILE, + "Gemfile.lock", + ].freeze, + T::Array[String], + ) + + # @param project_root [Pathname, String] repo root (holds dependencies.rb + lockfiles) + # @param state_dir [Pathname, String] per-machine state root (default ~/.dev/state) + sig do + params( + project_root: T.any(Pathname, String), + state_dir: T.any(Pathname, String), + ).void + end def initialize(project_root:, state_dir: Pathname(File.expand_path("~/.dev/state"))) - @project_root = Pathname(project_root) - @state_dir = Pathname(state_dir) + @project_root = T.let(Pathname(project_root), Pathname) + @state_dir = T.let(Pathname(state_dir), Pathname) end # All current staleness messages, oldest layer first (a stale manifest # implies a stale install; fixing them in order is the happy path). # # @return [Array] empty when everything is in sync + sig { returns(T::Array[String]) } def messages [manifest_message, install_message].compact end @@ -60,6 +74,7 @@ def messages # Layer 1: has dependencies.rb changed since the lockfiles were generated? # # @return [String, nil] + sig { returns(T.nilable(String)) } def manifest_message manifest = @project_root / "dependencies.rb" return nil unless manifest.exist? @@ -80,6 +95,7 @@ def manifest_message # on this machine? # # @return [String, nil] + sig { returns(T.nilable(String)) } def install_message current = lockfile_digest return nil unless current # no lockfiles: nothing declared, nothing to install @@ -99,6 +115,7 @@ def install_message # none) and the nag persists. # # @return [void] + sig { void } def stamp_installed! digest = lockfile_digest return unless digest @@ -111,6 +128,7 @@ def stamp_installed! # files marked absent so adding a lockfile changes the digest). # # @return [String, nil] hex digest, or nil when no lockfile exists + sig { returns(T.nilable(String)) } def lockfile_digest paths = LOCK_FILES.map { |name| @project_root / name } return nil if paths.none?(&:exist?) @@ -123,6 +141,7 @@ def lockfile_digest end # @return [Pathname] + sig { returns(Pathname) } def stamp_path @state_dir / project_key / STAMP_FILE end @@ -133,6 +152,7 @@ def stamp_path # checkouts of the same project on one machine get independent stamps. # # @return [String] + sig { returns(String) } def project_key expanded = File.expand_path(@project_root.to_s) "#{File.basename(expanded)}-#{Digest::SHA256.hexdigest(expanded)[0, 8]}" diff --git a/lib/dev/deps/steam_cmd.rb b/lib/dev/deps/steam_cmd.rb index 39bb7fb..c697f17 100644 --- a/lib/dev/deps/steam_cmd.rb +++ b/lib/dev/deps/steam_cmd.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "open3" require "shellwords" +require "sorbet-runtime" module Dev module Deps @@ -16,10 +18,12 @@ module Deps # The SteamCMD *binary* always matches the host OS; the *depot* platform is a # separate axis the caller forces with +@sSteamCmdForcePlatformType. module SteamCmd + extend T::Sig + class BootstrapError < StandardError; end class SteamCmdError < StandardError; end - DEFAULT_DIR = File.expand_path("~/.dev/steamcmd") + DEFAULT_DIR = T.let(File.expand_path("~/.dev/steamcmd"), String) LINUX_URL = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" MACOS_URL = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_osx.tar.gz" @@ -32,6 +36,7 @@ class SteamCmdError < StandardError; end # @param dir [String] install dir for the SteamCMD binary # @return [String] path to steamcmd.sh # @raise [BootstrapError] if the download/extract fails + sig { params(dir: String).returns(String) } def ensure!(dir = DEFAULT_DIR) script = File.join(dir, "steamcmd.sh") return script if File.executable?(script) @@ -39,13 +44,14 @@ def ensure!(dir = DEFAULT_DIR) FileUtils.mkdir_p(dir) url = download_url pipeline = "curl -fsSL #{url.shellescape} | tar -xz -C #{dir.shellescape}" - system("sh", "-c", pipeline) || raise(BootstrapError, "failed to bootstrap SteamCMD from #{url}") - raise BootstrapError, "SteamCMD bootstrap did not produce #{script}" unless File.executable?(script) + Kernel.system("sh", "-c", pipeline) || Kernel.raise(BootstrapError, "failed to bootstrap SteamCMD from #{url}") + Kernel.raise(BootstrapError, "SteamCMD bootstrap did not produce #{script}") unless File.executable?(script) script end # @return [String] the SteamCMD tarball URL for the host OS + sig { returns(String) } def download_url RUBY_PLATFORM.include?("darwin") ? MACOS_URL : LINUX_URL end @@ -55,9 +61,10 @@ def download_url # @param commands [Array] steamcmd +commands (e.g. "+login", "anonymous") # @param dir [String] SteamCMD install dir # @return [Array(String, String, Process::Status)] stdout, stderr, status + sig { params(commands: String, dir: String).returns([String, String, Process::Status]) } def run(*commands, dir: DEFAULT_DIR) script = ensure!(dir) - Open3.capture3(script, *commands) + T.unsafe(Open3).capture3(script, *commands) end # Resolve the buildid published on a branch via +app_info_print. @@ -67,12 +74,13 @@ def run(*commands, dir: DEFAULT_DIR) # @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) out, err, status = run("+login", "anonymous", "+app_info_print", app.to_s, "+quit", dir:) - raise SteamCmdError, "steamcmd app_info_print #{app} failed: #{err.strip}" unless status.success? + Kernel.raise(SteamCmdError, "steamcmd app_info_print #{app} failed: #{err.strip}") unless status.success? build_id = parse_build_id(out, branch) - raise SteamCmdError, "no buildid for app #{app} branch #{branch} in app_info_print output" unless build_id + Kernel.raise(SteamCmdError, "no buildid for app #{app} branch #{branch} in app_info_print output") unless build_id build_id end @@ -84,6 +92,7 @@ def resolve_build_id(app:, branch: "public", dir: DEFAULT_DIR) # @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] diff --git a/lib/dev/deps/steam_integration.rb b/lib/dev/deps/steam_integration.rb index ded97b7..49aa783 100644 --- a/lib/dev/deps/steam_integration.rb +++ b/lib/dev/deps/steam_integration.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" +require "sorbet-runtime" require_relative "integration" require_relative "steam_cmd" @@ -21,6 +23,8 @@ module Deps # depot platform (e.g. "linux") with +@sSteamCmdForcePlatformType, so a macOS # host can still provision the Linux server build. class SteamIntegration < Integration + extend T::Sig + class ProvisionError < StandardError; end class BuildMismatchError < StandardError; end @@ -30,6 +34,7 @@ class BuildMismatchError < StandardError; end # Provision all steam dependencies. # # @param dependencies [Array] steam deps to install + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) dependencies.each { |dep| install(dep) } end @@ -37,6 +42,7 @@ def install_all(dependencies) private # @param dep [Dependency] + sig { params(dep: Dependency).void } def install(dep) base_dir = Pathname(File.expand_path(dep.metadata["install_dir"])) target_dir = versioned_dir(base_dir, dep.version) @@ -72,6 +78,7 @@ def install(dep) # @param dep [Dependency] # @param server_dir [Pathname] depot install dir # @raise [ProvisionError] if SteamCMD fails + sig { params(dep: Dependency, server_dir: Pathname).void } def provision(dep, server_dir) _out, err, status = SteamCmd.run( "+@sSteamCmdForcePlatformType", dep.metadata["platform"], @@ -93,6 +100,7 @@ def provision(dep, server_dir) # @param server_dir [Pathname] # @raise [ProvisionError] if the appmanifest is missing # @raise [BuildMismatchError] if the installed buildid differs from the lock + sig { params(dep: Dependency, server_dir: Pathname).void } def verify_build_id(dep, server_dir) manifest = server_dir / "steamapps" / "appmanifest_#{dep.metadata["app"]}.acf" raise ProvisionError, "appmanifest not found at #{manifest}" unless manifest.file? diff --git a/lib/dev/deps/steam_repository.rb b/lib/dev/deps/steam_repository.rb index e43e372..1b6df53 100644 --- a/lib/dev/deps/steam_repository.rb +++ b/lib/dev/deps/steam_repository.rb @@ -1,5 +1,7 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require_relative "repository" require_relative "dependency" require_relative "steam_cmd" @@ -21,6 +23,8 @@ module Deps # app: 1690800, # install_dir: "~/.dev/satisfactory-server" class SteamRepository < Repository + extend T::Sig + # Resolve a Steam app dependency to a pinned Dependency. # # @param id [Hash] must include "name", "app", "install_dir", "integration", @@ -28,6 +32,7 @@ class SteamRepository < Repository # 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" @@ -55,6 +60,7 @@ def fetch(id) # @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 @@ -65,6 +71,7 @@ def resolve_build_id(app:, branch:) # # @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 diff --git a/lib/dev/deps/tap.rb b/lib/dev/deps/tap.rb index 01f90f2..5509f2d 100644 --- a/lib/dev/deps/tap.rb +++ b/lib/dev/deps/tap.rb @@ -1,3 +1,4 @@ +# typed: true # frozen_string_literal: true require "uri" diff --git a/lib/dev/deps/url_repository.rb b/lib/dev/deps/url_repository.rb index 9e76c3e..95e6b3e 100644 --- a/lib/dev/deps/url_repository.rb +++ b/lib/dev/deps/url_repository.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "digest" require "open3" +require "sorbet-runtime" require "tempfile" require_relative "repository" require_relative "dependency" @@ -13,6 +15,8 @@ module Deps # The artifact is downloaded to a temp file and hashed. # Callers (e.g. Integration) are responsible for caching the result. class UrlRepository < Repository + extend T::Sig + class DownloadError < StandardError; end # Download a URL dependency and compute its SHA256 integrity hash. @@ -22,6 +26,7 @@ class DownloadError < StandardError; end # @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"] @@ -48,15 +53,16 @@ def fetch(id) # @param name [String] dependency name (used in temp file naming) # @return [String] path to the downloaded temp file # @raise [DownloadError] if curl exits non-zero + sig { params(url: String, name: String).returns(String) } def download_to_tempfile(url, name) tmp = Tempfile.new(["dev_deps_#{name}", ".bin"]) tmp.binmode tmp.close - _out, err, status = Open3.capture3("curl", "-fsSL", "-o", tmp.path, url) + _out, err, status = Open3.capture3("curl", "-fsSL", "-o", T.must(tmp.path), url) raise DownloadError, "Download failed for #{url}: #{err}" unless status.success? - tmp.path + T.must(tmp.path) end end end diff --git a/lib/dev/deps/xcode_integration.rb b/lib/dev/deps/xcode_integration.rb index 661fa1a..00b6b9a 100644 --- a/lib/dev/deps/xcode_integration.rb +++ b/lib/dev/deps/xcode_integration.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "pathname" require "shadowenv_xcode" +require "sorbet-runtime" require_relative "integration" module Dev @@ -36,18 +38,23 @@ module Deps # 4. Publish DEVELOPER_DIR via shadowenv so every project command rides # the pin. class XcodeIntegration < Integration + extend T::Sig + class XcodesMissingError < StandardError; end class InstallError < StandardError; end INSTALL_ROOT = "/Applications" class << self + extend T::Sig + # The version-named app bundle the pin installs to. xcodes' default # naming, chosen precisely because nothing auto-updates it in place. # # @param version [String] pinned Xcode version # @param root [String] install root (tests point this at a tmpdir) # @return [String] + sig { params(version: String, root: String).returns(String) } def app_path(version, root: INSTALL_ROOT) File.join(root, "Xcode-#{version}.app") end @@ -57,24 +64,34 @@ def app_path(version, root: INSTALL_ROOT) # @param version [String] pinned Xcode version # @param root [String] install root (tests point this at a tmpdir) # @return [String] + sig { params(version: String, root: String).returns(String) } def developer_dir(version, root: INSTALL_ROOT) File.join(app_path(version, root:), "Contents", "Developer") end end - # @param repository [Repository] - # @param cache [Cache] + # @param repository [Repository, nil] + # @param cache [Cache, nil] # @param project_root [String, Pathname, nil] repo root (shadowenv lives there) # @param install_root [String] where Xcode bundles live (tests use a tmpdir) + sig do + params( + repository: T.nilable(Repository), + cache: T.nilable(Cache), + project_root: T.nilable(T.any(String, Pathname)), + install_root: String, + ).void + end def initialize(repository:, cache:, project_root: nil, install_root: INSTALL_ROOT) super(repository:, cache:) - @project_root = project_root && Pathname(project_root) + @project_root = T.let(project_root && Pathname(project_root), T.nilable(Pathname)) @install_root = install_root end # Install all xcode pins (in practice: one per project). # # @param dependencies [Array] xcode deps to install + sig { params(dependencies: T::Array[Dependency]).void } def install_all(dependencies) unless darwin? puts ">>> xcode: not a macOS host, skipping" if dependencies.any? @@ -86,9 +103,14 @@ def install_all(dependencies) private - attr_reader :project_root, :install_root + sig { returns(T.nilable(Pathname)) } + attr_reader :project_root + + sig { returns(String) } + attr_reader :install_root # @param dep [Dependency] + sig { params(dep: Dependency).void } def install(dep) app = self.class.app_path(dep.version, root: install_root) if Dir.exist?(app) @@ -105,6 +127,7 @@ def install(dep) # # @param version [String] # @raise [InstallError] when the component download fails + sig { params(version: String).void } def ensure_metal_toolchain(version) return if metal_toolchain_present?(version) @@ -120,6 +143,7 @@ def ensure_metal_toolchain(version) # @param version [String] # @raise [XcodesMissingError] when the xcodes CLI is absent # @raise [InstallError] when the install fails (with the headless remediation menu) + sig { params(version: String).void } def install_via_xcodes(version) unless xcodes_available? raise XcodesMissingError, @@ -138,7 +162,8 @@ def install_via_xcodes(version) # prompt and fail immediately instead of hanging the job. # # @param version [String] - # @return [Boolean] whether xcodes exited 0 + # @return [Boolean, nil] whether xcodes exited 0 (nil when it cannot run) + sig { params(version: String).returns(T.nilable(T::Boolean)) } def run_xcodes_install(version) argv = ["xcodes", "install", version, "--directory", install_root] if interactive? @@ -150,6 +175,7 @@ def run_xcodes_install(version) # @param version [String] # @return [String] + sig { params(version: String).returns(String) } def install_failure_message(version) if interactive? "xcodes install #{version} failed — see its output above." @@ -165,13 +191,15 @@ def install_failure_message(version) # pin. Skipped when dev has no project context (nothing to publish into). # # @param version [String] + sig { params(version: String).void } def publish_developer_dir(version) - return unless project_root + root = project_root + return unless root developer_dir = self.class.developer_dir(version, root: install_root) - return if ShadowenvXcode.provisioned?(developer_dir, project_root: project_root) + return if ShadowenvXcode.provisioned?(developer_dir, project_root: root) - ShadowenvXcode.setup!(project_root: project_root, version: version, developer_dir: developer_dir) + ShadowenvXcode.setup!(project_root: root, version: version, developer_dir: developer_dir) puts ">>> xcode #{version}: DEVELOPER_DIR published via shadowenv (#{developer_dir})" end @@ -180,30 +208,35 @@ def publish_developer_dir(version) # the MetalToolchain component has been downloaded. # # @param version [String] - # @return [Boolean] + # @return [Boolean, nil] nil when xcrun cannot run at all + sig { params(version: String).returns(T.nilable(T::Boolean)) } def metal_toolchain_present?(version) env = { "DEVELOPER_DIR" => self.class.developer_dir(version, root: install_root) } system(env, "xcrun", "-sdk", "macosx", "-f", "metal", out: File::NULL, err: File::NULL) end # @param version [String] - # @return [Boolean] whether the download exited 0 + # @return [Boolean, nil] whether the download exited 0 (nil when it cannot run) + sig { params(version: String).returns(T.nilable(T::Boolean)) } def run_metal_toolchain_download(version) env = { "DEVELOPER_DIR" => self.class.developer_dir(version, root: install_root) } system(env, "xcodebuild", "-downloadComponent", "MetalToolchain") end # @return [Boolean] + sig { returns(T::Boolean) } def darwin? RUBY_PLATFORM.include?("darwin") end # @return [Boolean] + sig { returns(T::Boolean) } def interactive? $stdin.tty? end - # @return [Boolean] + # @return [Boolean, nil] nil when the probe command cannot run + sig { returns(T.nilable(T::Boolean)) } def xcodes_available? system("command -v xcodes >/dev/null 2>&1") end diff --git a/lib/dev/deps/xcode_repository.rb b/lib/dev/deps/xcode_repository.rb index 30e3b7f..568a08c 100644 --- a/lib/dev/deps/xcode_repository.rb +++ b/lib/dev/deps/xcode_repository.rb @@ -1,5 +1,7 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require_relative "repository" require_relative "dependency" @@ -13,11 +15,14 @@ module Deps # resolver -> lockfile pipeline so the pin lands in deps.lock like every # other dependency and the installer/accessor can find it there. class XcodeRepository < Repository + extend T::Sig + class MissingVersionError < StandardError; 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? diff --git a/lib/dev/learnings.rb b/lib/dev/learnings.rb index 14886dd..fe10f20 100644 --- a/lib/dev/learnings.rb +++ b/lib/dev/learnings.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "dev/learnings/layout" diff --git a/lib/dev/learnings/accessor.rb b/lib/dev/learnings/accessor.rb index 44e0c3b..2e7ce4b 100644 --- a/lib/dev/learnings/accessor.rb +++ b/lib/dev/learnings/accessor.rb @@ -1,6 +1,9 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" +require "stringio" require_relative "../deps/gem_skill_linker" require_relative "../settings" require_relative "../skill_installer" @@ -36,6 +39,8 @@ module Learnings # RuntimeError subclasses throughout so the CLI boundary prints clean # `dev:` messages instead of backtraces. class Accessor + extend T::Sig + class UsageError < RuntimeError; end # `dev learnings invariants` cannot produce the block: no knowledge repo @@ -46,7 +51,7 @@ class InvariantsUnavailableError < RuntimeError; end # scaffold into. class NoEnclosingProjectError < RuntimeError; end - USAGE = <<~USAGE.strip + USAGE = T.let(<<~USAGE.strip, String) usage: dev learnings dev learnings sync refresh the whole read path now (blocking): knowledge repo cache, skill links, invariants render dev learnings status configured knowledge repo, cache location/age, what's rendered and linked @@ -68,20 +73,38 @@ class NoEnclosingProjectError < RuntimeError; end # tests; defaults to the project's linker (nil outside a project) # @param renderer [Dev::Learnings::InvariantsRenderer] # @param scaffolder [Dev::Learnings::Scaffolder] + sig do + params( + project_root: T.nilable(T.any(Pathname, String)), + settings: Dev::Settings, + cache: T.nilable(Cache), + synchronizer: T.untyped, + skill_installer: Dev::SkillInstaller, + gem_skill_linker: T.untyped, + renderer: InvariantsRenderer, + scaffolder: Scaffolder, + ).void + end def initialize(project_root:, settings: Dev::Settings.new, cache: nil, synchronizer: nil, skill_installer: Dev::SkillInstaller.new, gem_skill_linker: nil, renderer: InvariantsRenderer.new, scaffolder: Scaffolder.new) - @project_root = project_root && Pathname(project_root) + @project_root = T.let(project_root && Pathname(project_root), T.nilable(Pathname)) @settings = settings repo = settings.knowledge_repo - @cache = cache || (repo && Cache.new(repo: repo)) + @cache = T.let(cache || (repo && Cache.new(repo: repo)), T.nilable(Cache)) # The synchronizer shares the accessor's cache (status/invariants # read it too); an unconfigured machine gets the null synchronizer. - @synchronizer = synchronizer || - (@cache ? Synchronizer.new(settings: settings, cache: @cache) : UnconfiguredSynchronizer.new(settings: settings)) + @synchronizer = T.let( + synchronizer || + (@cache ? Synchronizer.new(settings: settings, cache: @cache) : UnconfiguredSynchronizer.new(settings: settings)), + T.untyped, + ) @skill_installer = skill_installer - @gem_skill_linker = gem_skill_linker || - (@project_root && Dev::Deps::GemSkillLinker.new(project_root: @project_root)) + @gem_skill_linker = T.let( + gem_skill_linker || + (@project_root && Dev::Deps::GemSkillLinker.new(project_root: @project_root)), + T.untyped, + ) @renderer = renderer @scaffolder = scaffolder end @@ -89,9 +112,10 @@ def initialize(project_root:, settings: Dev::Settings.new, cache: nil, synchroni # Dispatch a `dev learnings …` invocation. # # @param args [Array] argv after the "learnings" command - # @param out [IO] output stream + # @param out [IO, StringIO] output stream # @return [void] # @raise [UsageError] on an unrecognized invocation + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def run(args, out: $stdout) case args when ["sync"] then sync(out:) @@ -109,18 +133,20 @@ def run(args, out: $stdout) # the org tier (cache pull, org skill links, invariants render + project # link), and the project's gem skill relinks. # - # @param out [IO] + # @param out [IO, StringIO] # @return [void] + sig { params(out: T.any(IO, StringIO)).void } def sync(out:) @skill_installer.install_all(Dev::SkillInstaller::SHIPPED_SKILLS_DIR) @synchronizer.sync!(project_root: @project_root) @gem_skill_linker&.link_all - out.puts "dev: learnings synced from #{@settings.knowledge_repo} (#{@cache.dir})." + out.puts "dev: learnings synced from #{@settings.knowledge_repo} (#{T.must(@cache).dir})." out.puts "dev: no enclosing project — skipped the invariants link and gem skill links." if @project_root.nil? end - # @param out [IO] + # @param out [IO, StringIO] # @return [void] + sig { params(out: T.any(IO, StringIO)).void } def status(out:) repo = @settings.knowledge_repo if repo.nil? @@ -130,32 +156,35 @@ def status(out:) end out.puts "dev: knowledge repo: #{repo}" - unless @cache.present? - out.puts "dev: cache: #{@cache.dir} (not cloned yet — run `dev learnings sync`)." + cache = T.must(@cache) + unless cache.present? + out.puts "dev: cache: #{cache.dir} (not cloned yet — run `dev learnings sync`)." return end - out.puts "dev: cache: #{@cache.dir} (refreshed #{format_age(Time.now - @cache.synced_at)} ago)." + out.puts "dev: cache: #{cache.dir} (refreshed #{format_age(Time.now - T.must(cache.synced_at))} ago)." status_org_tier(out) status_project_tier(out) end - # @param out [IO] + # @param out [IO, StringIO] # @return [void] # @raise [InvariantsUnavailableError] when the block cannot be produced + sig { params(out: T.any(IO, StringIO)).void } def invariants(out:) - if @cache.nil? + cache = @cache + if cache.nil? raise InvariantsUnavailableError, "no knowledge repo configured — add `knowledge_repo: /` " \ "to #{@settings.config_path} (or set DEV_KNOWLEDGE_REPO)." end - unless @cache.present? + unless cache.present? raise InvariantsUnavailableError, "the knowledge repo cache has not been cloned yet — run `dev learnings sync`." end - block = @renderer.prompt_block(@cache.index_file) - raise InvariantsUnavailableError, "#{@cache.index_file} has no `## Invariants` section." if block.nil? + block = @renderer.prompt_block(cache.index_file) + raise InvariantsUnavailableError, "#{cache.index_file} has no `## Invariants` section." if block.nil? out.puts block end @@ -166,24 +195,26 @@ def invariants(out:) # index makes this a reported no-op (exit 0), never an overwrite — so # consumers can call init unconditionally before capturing. # - # @param out [IO] + # @param out [IO, StringIO] # @param org [Boolean] scaffold the org knowledge-repo layout instead # of the repo tier # @return [void] # @raise [NoEnclosingProjectError] when run outside any project + sig { params(out: T.any(IO, StringIO), org: T::Boolean).void } def init(out:, org: false) - if @project_root.nil? + project_root = @project_root + if project_root.nil? raise NoEnclosingProjectError, "no enclosing project — run `dev learnings init` inside the repo to scaffold." end if org - @scaffolder.scaffold_org(@project_root) - out.puts "dev: scaffolded #{Layout.org_index_file(@project_root)} and " \ - "#{Layout.org_skills_dir(@project_root)}/ (the org knowledge-repo layout) — commit them." + @scaffolder.scaffold_org(project_root) + out.puts "dev: scaffolded #{Layout.org_index_file(project_root)} and " \ + "#{Layout.org_skills_dir(project_root)}/ (the org knowledge-repo layout) — commit them." else - @scaffolder.scaffold_repo(@project_root) - out.puts "dev: scaffolded #{Layout.repo_index_file(@project_root)} " \ + @scaffolder.scaffold_repo(project_root) + out.puts "dev: scaffolded #{Layout.repo_index_file(project_root)} " \ "(this repo's empty always-on learnings index) — commit it." end rescue Scaffolder::IndexAlreadyExistsError => e @@ -193,8 +224,9 @@ def init(out:, org: false) # The org tier's rendered/linked state: the machine-side invariants # render and the org skill links. # - # @param out [IO] + # @param out [IO, StringIO] # @return [void] + sig { params(out: T.any(IO, StringIO)).void } def status_org_tier(out) rendered = @synchronizer.rendered_invariants_file out.puts(if rendered.file? @@ -208,21 +240,24 @@ def status_org_tier(out) # The project tier's linked state: the invariants link and the gem skill # links, or a pointer when there is no enclosing project. # - # @param out [IO] + # @param out [IO, StringIO] # @return [void] + sig { params(out: T.any(IO, StringIO)).void } def status_project_tier(out) - if @project_root.nil? + project_root = @project_root + if project_root.nil? out.puts "dev: project: none — run inside a repo to see its invariants link and gem skills." return end - rules_file = @synchronizer.project_rules_file(@project_root) + rules_file = @synchronizer.project_rules_file(project_root) out.puts "dev: project invariants link: #{rules_file} (#{invariants_link_state(rules_file)})." out.puts "dev: gem skills: #{gem_skill_link_count} linked under #{gem_skills_dir}." end # @param rules_file [Pathname] # @return [String] + sig { params(rules_file: Pathname).returns(String) } def invariants_link_state(rules_file) if rules_file.symlink? && rules_file.readlink == @synchronizer.rendered_invariants_file "linked" @@ -237,20 +272,23 @@ def invariants_link_state(rules_file) # cache's skills corpus. # # @return [Integer] + sig { returns(Integer) } def org_skill_link_count dir = @skill_installer.skills_dir return 0 unless dir.directory? - corpus_prefix = "#{@cache.skills_dir}#{File::SEPARATOR}" + corpus_prefix = "#{T.must(@cache).skills_dir}#{File::SEPARATOR}" dir.children.count { |link| link.symlink? && link.readlink.to_s.start_with?(corpus_prefix) } end # @return [Pathname] + sig { returns(Pathname) } def gem_skills_dir - @project_root.join(*Dev::Deps::GemSkillLinker::AGENT_SKILLS_SUBDIRS) + T.must(@project_root).join(*Dev::Deps::GemSkillLinker::AGENT_SKILLS_SUBDIRS) end # @return [Integer] + sig { returns(Integer) } def gem_skill_link_count dir = gem_skills_dir return 0 unless dir.directory? @@ -260,8 +298,9 @@ def gem_skill_link_count end end - # @param seconds [Numeric] + # @param seconds [Float] # @return [String] a compact human age, e.g. "42s", "7m", "3h", "2d" + sig { params(seconds: Float).returns(String) } def format_age(seconds) case seconds when 0...60 then "#{seconds.to_i}s" diff --git a/lib/dev/learnings/cache.rb b/lib/dev/learnings/cache.rb index 5d48953..7d0ff98 100644 --- a/lib/dev/learnings/cache.rb +++ b/lib/dev/learnings/cache.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "open3" require "pathname" +require "sorbet-runtime" require_relative "layout" module Dev @@ -23,6 +25,8 @@ module Learnings # gh auth (dev is public and carries no credentials of its own); any other # source (URL, local path) clones through git directly. class Cache + extend T::Sig + # `git clone` (via gh or git) of the knowledge repo failed. class KnowledgeCloneError < RuntimeError; end @@ -44,24 +48,34 @@ class KnowledgeFetchError < RuntimeError; end REFRESH_POLL_SECONDS = 0.05 # @return [Pathname] the clone's location + sig { returns(Pathname) } attr_reader :dir # @param repo [String] "owner/repo" (cloned via gh, the user's auth) or # any git-clonable URL or local path # @param dir [Pathname, String, nil] override for tests; defaults to the # XDG data location - # @param refresh_timeout [Numeric] override for tests; how long a + # @param refresh_timeout [Integer, Float] override for tests; how long a # bounded refresh blocks before detaching - # @param refresh_floor [Numeric] override for tests; minimum age before - # a bounded refresh pulls again + # @param refresh_floor [Integer, Float] override for tests; minimum age + # before a bounded refresh pulls again + sig do + params( + repo: String, + dir: T.nilable(T.any(Pathname, String)), + refresh_timeout: T.any(Integer, Float), + refresh_floor: T.any(Integer, Float), + ).void + end def initialize(repo:, dir: nil, refresh_timeout: REFRESH_TIMEOUT_SECONDS, refresh_floor: REFRESH_FLOOR_SECONDS) @repo = repo - @dir = Pathname(dir || default_dir) + @dir = T.let(Pathname(dir || default_dir), Pathname) @refresh_timeout = refresh_timeout @refresh_floor = refresh_floor end # @return [Boolean] whether the cache has been cloned + sig { returns(T::Boolean) } def present? (@dir / ".git").exist? end @@ -71,11 +85,13 @@ def present? # beside it). # @return [Pathname] the on-demand skills corpus inside the cache + sig { returns(Pathname) } def skills_dir Layout.org_skills_dir(@dir) end # @return [Pathname] the org learnings index inside the cache + sig { returns(Pathname) } def index_file Layout.org_index_file(@dir) end @@ -87,6 +103,7 @@ def index_file # @return [void] # @raise [KnowledgeCloneError] when the initial clone fails # @raise [KnowledgeFetchError] when the pull fails + sig { void } def refresh if present? run_or_raise(pull_command, KnowledgeFetchError) @@ -105,11 +122,12 @@ def refresh # reports errors properly. # # @return [void] + sig { void } def refresh_bounded return if refreshed_within_floor? FileUtils.mkdir_p(@dir.dirname) - pid = Process.spawn(*(present? ? pull_command : clone_command), out: File::NULL, err: File::NULL) + pid = T.unsafe(Process).spawn(*(present? ? pull_command : clone_command), out: File::NULL, err: File::NULL) wait_or_detach(pid) rescue SystemCallError => e $stderr.puts "dev: warning: could not start the knowledge repo cache refresh (#{e.message})." @@ -119,6 +137,7 @@ def refresh_bounded # falling back to HEAD's (a fresh clone has no FETCH_HEAD yet). # # @return [Time, nil] nil when the cache has never been cloned + sig { returns(T.nilable(Time)) } def synced_at marker = [@dir / ".git" / "FETCH_HEAD", @dir / ".git" / "HEAD"].find(&:exist?) marker&.mtime @@ -128,6 +147,7 @@ def synced_at # @return [Boolean] whether the last successful refresh is inside the # courtesy floor + sig { returns(T::Boolean) } def refreshed_within_floor? at = synced_at !at.nil? && (Time.now - at) <= @refresh_floor @@ -138,6 +158,7 @@ def refreshed_within_floor? # # @param pid [Integer] # @return [void] + sig { params(pid: Integer).void } def wait_or_detach(pid) deadline = Time.now + @refresh_timeout until Process.waitpid(pid, Process::WNOHANG) @@ -154,12 +175,14 @@ def wait_or_detach(pid) # @param error_class [Class] # @return [void] # @raise [RuntimeError] error_class when the command fails + sig { params(command: T::Array[String], error_class: T.class_of(RuntimeError)).void } def run_or_raise(command, error_class) - _out, err, status = Open3.capture3(*command) + _out, err, status = T.unsafe(Open3).capture3(*command) raise error_class, "#{command.first} failed for #{@repo}: #{err.strip}" unless status.success? end # @return [Array] + sig { returns(T::Array[String]) } def clone_command if @repo.match?(OWNER_REPO_PATTERN) ["gh", "repo", "clone", @repo, @dir.to_s, "--", "--quiet"] @@ -169,11 +192,13 @@ def clone_command end # @return [Array] + sig { returns(T::Array[String]) } def pull_command ["git", "-C", @dir.to_s, "pull", "--ff-only", "--quiet"] end # @return [String] + sig { returns(String) } def default_dir data_home = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")) File.join(data_home, "dev", "knowledge") diff --git a/lib/dev/learnings/invariants_renderer.rb b/lib/dev/learnings/invariants_renderer.rb index 365aea6..2cdc17f 100644 --- a/lib/dev/learnings/invariants_renderer.rb +++ b/lib/dev/learnings/invariants_renderer.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" +require "sorbet-runtime" module Dev module Learnings @@ -24,6 +26,8 @@ module Learnings # # Stateless: one reusable instance renders any index into any target. class InvariantsRenderer + extend T::Sig + # The heading the invariant lines live under in the knowledge index, # and where the next section cuts them off. INVARIANTS_HEADING = /^## Invariants\b/ @@ -46,6 +50,7 @@ class InvariantsRenderer # # @param index_file [Pathname] the cached knowledge index (index.md) # @return [String, nil] nil when the index (or the section) doesn't exist + sig { params(index_file: Pathname).returns(T.nilable(String)) } def prompt_block(index_file) lines = invariant_lines(index_file) return nil if lines.nil? @@ -60,6 +65,7 @@ def prompt_block(index_file) # (beside the cache; never inside a project) # @param repo [String] knowledge repo label for the generated header # @return [void] + sig { params(index_file: Pathname, rendered_file: Pathname, repo: String).void } def render(index_file:, rendered_file:, repo:) block = prompt_block(index_file) if block.nil? @@ -81,6 +87,7 @@ def render(index_file:, rendered_file:, repo:) # @param rendered_file [Pathname] the machine-local render # @param rules_file [Pathname] the target .mdc inside a project # @return [void] + sig { params(rendered_file: Pathname, rules_file: Pathname).void } def link(rendered_file:, rules_file:) return unlink(rules_file) unless rendered_file.file? return if rules_file.symlink? && rules_file.readlink == rendered_file @@ -99,11 +106,12 @@ def link(rendered_file:, rules_file:) # # @param index_file [Pathname] # @return [String, nil] + sig { params(index_file: Pathname).returns(T.nilable(String)) } def invariant_lines(index_file) return nil unless index_file.file? - section = [] - in_section = false + section = T.let([], T::Array[String]) + in_section = T.let(false, T::Boolean) index_file.read.each_line do |line| if line.match?(INVARIANTS_HEADING) in_section = true @@ -121,6 +129,7 @@ def invariant_lines(index_file) # @param block [String] the Tier-0 prompt block # @param repo [String] # @return [String] the full .mdc content + sig { params(block: String, repo: String).returns(String) } def rule_content(block, repo) <<~CONTENT --- @@ -140,6 +149,7 @@ def rule_content(block, repo) # # @param rules_file [Pathname] # @return [Boolean] + sig { params(rules_file: Pathname).returns(T::Boolean) } def user_owned?(rules_file) return false if rules_file.symlink? || !rules_file.file? @@ -151,6 +161,7 @@ def user_owned?(rules_file) # # @param rules_file [Pathname] # @return [void] + sig { params(rules_file: Pathname).void } def unlink(rules_file) return unless rules_file.symlink? || rules_file.file? return warn_user_owned(rules_file) if user_owned?(rules_file) @@ -160,6 +171,7 @@ def unlink(rules_file) # @param rules_file [Pathname] # @return [void] + sig { params(rules_file: Pathname).void } def warn_user_owned(rules_file) $stderr.puts "dev: warning: #{rules_file} exists and is not dev-generated — leaving it in place." end diff --git a/lib/dev/learnings/layout.rb b/lib/dev/learnings/layout.rb index 8b2dab8..94a07e5 100644 --- a/lib/dev/learnings/layout.rb +++ b/lib/dev/learnings/layout.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" require_relative "../skill_installer" module Dev @@ -18,6 +20,8 @@ module Learnings # headings InvariantsRenderer parses — plus the on-demand skills # corpus beside it. module Layout + extend T::Sig + REPO_INDEX_SUBDIRS = [".cursor", "rules", "learnings-index.mdc"].freeze REPO_SKILLS_SUBDIRS = [".cursor", "skills", "learnings"].freeze @@ -84,32 +88,37 @@ module Layout # @param repo_root [Pathname, String] a participating repo's root # @return [Pathname] the repo tier's always-on index rule + sig { params(repo_root: T.any(Pathname, String)).returns(Pathname) } def repo_index_file(repo_root) - Pathname(repo_root).join(*REPO_INDEX_SUBDIRS) + Pathname.new(repo_root).join(*REPO_INDEX_SUBDIRS) end # @param repo_root [Pathname, String] a participating repo's root # @param slug [String] the learning's slug # @return [Pathname] the repo-tier detail skill for the slug + sig { params(repo_root: T.any(Pathname, String), slug: String).returns(Pathname) } def repo_skill_file(repo_root, slug) - Pathname(repo_root).join(*REPO_SKILLS_SUBDIRS, slug, SkillInstaller::SKILL_FILE) + Pathname.new(repo_root).join(*REPO_SKILLS_SUBDIRS, slug, SkillInstaller::SKILL_FILE) end # @param org_root [Pathname, String] a knowledge repo checkout (or cache) # @return [Pathname] the org tier's index + sig { params(org_root: T.any(Pathname, String)).returns(Pathname) } def org_index_file(org_root) - Pathname(org_root) / ORG_INDEX_FILENAME + Pathname.new(org_root) / ORG_INDEX_FILENAME end # @param org_root [Pathname, String] a knowledge repo checkout (or cache) # @return [Pathname] the org tier's on-demand skills corpus + sig { params(org_root: T.any(Pathname, String)).returns(Pathname) } def org_skills_dir(org_root) - Pathname(org_root) / ORG_SKILLS_DIRNAME + Pathname.new(org_root) / ORG_SKILLS_DIRNAME end # @param org_root [Pathname, String] a knowledge repo checkout (or cache) # @param slug [String] the skill's slug # @return [Pathname] the org-tier skill for the slug + sig { params(org_root: T.any(Pathname, String), slug: String).returns(Pathname) } def org_skill_file(org_root, slug) org_skills_dir(org_root) / slug / SkillInstaller::SKILL_FILE end diff --git a/lib/dev/learnings/scaffolder.rb b/lib/dev/learnings/scaffolder.rb index 67bc508..3f5e869 100644 --- a/lib/dev/learnings/scaffolder.rb +++ b/lib/dev/learnings/scaffolder.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" +require "sorbet-runtime" require_relative "layout" module Dev @@ -14,6 +16,8 @@ module Learnings # # Stateless: one reusable instance scaffolds any root. class Scaffolder + extend T::Sig + # The tier's index already exists at the target root — the scaffold is # write-once and never overwrites a committed index. class IndexAlreadyExistsError < RuntimeError; end @@ -27,6 +31,7 @@ class IndexAlreadyExistsError < RuntimeError; end # @param repo_root [Pathname, String] the repo's root # @return [void] # @raise [IndexAlreadyExistsError] when the repo already has an index + sig { params(repo_root: T.any(Pathname, String)).void } def scaffold_repo(repo_root) write_index(Layout.repo_index_file(repo_root), Layout::REPO_INDEX_SCAFFOLD) end @@ -37,6 +42,7 @@ def scaffold_repo(repo_root) # @param org_root [Pathname, String] the knowledge repo's root # @return [void] # @raise [IndexAlreadyExistsError] when the checkout already has an index + sig { params(org_root: T.any(Pathname, String)).void } def scaffold_org(org_root) write_index(Layout.org_index_file(org_root), Layout::ORG_INDEX_SCAFFOLD) skills_dir = Layout.org_skills_dir(org_root) @@ -50,6 +56,7 @@ def scaffold_org(org_root) # @param scaffold [String] the tier's template # @return [void] # @raise [IndexAlreadyExistsError] when the index already exists + sig { params(index_file: Pathname, scaffold: String).void } def write_index(index_file, scaffold) if index_file.exist? raise IndexAlreadyExistsError, diff --git a/lib/dev/learnings/synchronizer.rb b/lib/dev/learnings/synchronizer.rb index a735890..0ffcfd0 100644 --- a/lib/dev/learnings/synchronizer.rb +++ b/lib/dev/learnings/synchronizer.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" require_relative "../settings" require_relative "../skill_installer" require_relative "cache" @@ -21,6 +23,8 @@ module Learnings # private knowledge repo. The constructor itself requires a cache, so a # real Synchronizer is never in a half-configured state. class Synchronizer + extend T::Sig + # `dev learnings sync` was asked to sync with no knowledge repo configured. class KnowledgeRepoNotConfiguredError < RuntimeError; end @@ -33,6 +37,8 @@ class KnowledgeRepoNotConfiguredError < RuntimeError; end RENDERED_INVARIANTS_FILENAME = "org-invariants.mdc" class << self + extend T::Sig + # The one construction path callers use: the real synchronizer over # the configured knowledge repo's cache, or the unconfigured null # object when no repo is set. @@ -41,6 +47,13 @@ class << self # @param skill_installer [Dev::SkillInstaller] # @param renderer [Dev::Learnings::InvariantsRenderer] # @return [Synchronizer, UnconfiguredSynchronizer] + sig do + params( + settings: Dev::Settings, + skill_installer: Dev::SkillInstaller, + renderer: InvariantsRenderer, + ).returns(T.any(Synchronizer, UnconfiguredSynchronizer)) + end def for(settings: Dev::Settings.new, skill_installer: Dev::SkillInstaller.new, renderer: InvariantsRenderer.new) repo = settings.knowledge_repo @@ -56,6 +69,14 @@ def for(settings: Dev::Settings.new, skill_installer: Dev::SkillInstaller.new, # @param skill_installer [Dev::SkillInstaller] target for the org skill # links; defaults to the user-global ~/.cursor/skills # @param renderer [Dev::Learnings::InvariantsRenderer] + sig do + params( + cache: Cache, + settings: Dev::Settings, + skill_installer: Dev::SkillInstaller, + renderer: InvariantsRenderer, + ).void + end def initialize(cache:, settings: Dev::Settings.new, skill_installer: Dev::SkillInstaller.new, renderer: InvariantsRenderer.new) @settings = settings @@ -65,12 +86,14 @@ def initialize(cache:, settings: Dev::Settings.new, skill_installer: Dev::SkillI end # @return [Pathname] the machine-side invariants render (beside the cache) + sig { returns(Pathname) } def rendered_invariants_file @cache.dir.dirname / RENDERED_INVARIANTS_FILENAME end # @param project_root [Pathname, String] the enclosing project # @return [Pathname] the project's link to the invariants render + sig { params(project_root: T.any(Pathname, String)).returns(Pathname) } def project_rules_file(project_root) Pathname(project_root).join(*ORG_INVARIANTS_RULE_SUBDIRS) end @@ -84,6 +107,7 @@ def project_rules_file(project_root) # @param project_root [Pathname, String, nil] project to link the # invariants render into; nil skips the link (no project context) # @return [void] + sig { params(project_root: T.nilable(T.any(Pathname, String))).void } def sync(project_root: nil) @cache.refresh_bounded distribute(project_root) @@ -99,6 +123,7 @@ def sync(project_root: nil) # @return [void] # @raise [Cache::KnowledgeCloneError] when the initial clone fails # @raise [Cache::KnowledgeFetchError] when the refresh fails + sig { params(project_root: T.nilable(T.any(Pathname, String))).void } def sync!(project_root: nil) @cache.refresh distribute(project_root) @@ -112,6 +137,7 @@ def sync!(project_root: nil) # # @param project_root [Pathname, String, nil] # @return [void] + sig { params(project_root: T.nilable(T.any(Pathname, String))).void } def distribute(project_root) return unless @cache.present? @@ -119,7 +145,7 @@ def distribute(project_root) @renderer.render( index_file: @cache.index_file, rendered_file: rendered_invariants_file, - repo: @settings.knowledge_repo, + repo: T.must(@settings.knowledge_repo), ) return unless project_root @@ -132,8 +158,11 @@ def distribute(project_root) # no org sync is a supported state, not an error — while the explicit # `dev learnings sync` raises with configuration instructions. class UnconfiguredSynchronizer + extend T::Sig + # @param settings [Dev::Settings] used only to point the error message # at the right config file + sig { params(settings: Dev::Settings).void } def initialize(settings: Dev::Settings.new) @settings = settings end @@ -143,6 +172,7 @@ def initialize(settings: Dev::Settings.new) # @param project_root [Pathname, String, nil] unused; matches # Synchronizer#sync # @return [void] + sig { params(project_root: T.nilable(T.any(Pathname, String))).void } def sync(project_root: nil); end # The explicit entry: the user asked for a sync that cannot happen. @@ -151,6 +181,7 @@ def sync(project_root: nil); end # Synchronizer#sync! # @return [void] # @raise [Synchronizer::KnowledgeRepoNotConfiguredError] always + sig { params(project_root: T.nilable(T.any(Pathname, String))).void } def sync!(project_root: nil) raise Synchronizer::KnowledgeRepoNotConfiguredError, "no knowledge repo configured — add `knowledge_repo: /` " \ diff --git a/lib/dev/plan.rb b/lib/dev/plan.rb index 56791e0..577bc8e 100644 --- a/lib/dev/plan.rb +++ b/lib/dev/plan.rb @@ -1,5 +1,8 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" + require "dev/settings" require "dev/skill_installer" require "dev/learnings" diff --git a/lib/dev/plan/accessor.rb b/lib/dev/plan/accessor.rb index d155625..a2d94e5 100644 --- a/lib/dev/plan/accessor.rb +++ b/lib/dev/plan/accessor.rb @@ -1,8 +1,11 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "json" require "pathname" +require "sorbet-runtime" +require "stringio" module Dev module Plan @@ -12,11 +15,13 @@ module Plan # remote body changed since the recorded merge base, so the local copy can # never clobber newer remote edits. class Accessor + extend T::Sig + # RuntimeError so Dev::Runner's clean-error rescue prints the usage # instead of a backtrace (same for the other plan error classes). class UsageError < RuntimeError; end - USAGE = <<~USAGE.strip + USAGE = T.let(<<~USAGE.strip, String) usage: dev plan dev plan new "" [--blank] [--org] create a templated issue + linked local plan dev plan link <n> [<file>] [--org] attach a plan file to issue #n @@ -36,16 +41,28 @@ class UsageError < RuntimeError; end # shipped skill links (defaults to the user-global ~/.cursor/skills) # @param learnings [Dev::Learnings::Synchronizer, Dev::Learnings::UnconfiguredSynchronizer, nil] # @param executor [Dev::Plan::Executor] CLI boundary (injectable for tests) + sig do + params( + project_root: Pathname, + executor: Executor, + workspace: T.nilable(Workspace), + issues: T.untyped, + settings: T.untyped, + merge_base: T.nilable(MergeBase), + skill_installer: T.nilable(Dev::SkillInstaller), + learnings: T.untyped, + ).void + end def initialize(project_root:, executor: Executor.new, workspace: nil, issues: nil, settings: nil, merge_base: nil, skill_installer: nil, learnings: nil) @project_root = project_root @executor = executor - @workspace = workspace || Workspace.new(project_root: project_root, executor: executor) - @issues = issues || GithubIssues.new(executor: executor) - @settings = settings || Dev::Settings.new - @merge_base = merge_base || MergeBase.new - @skill_installer = skill_installer || Dev::SkillInstaller.new - @learnings = learnings || Learnings::Synchronizer.for(settings: @settings) + @workspace = T.let(workspace || Workspace.new(project_root: project_root, executor: executor), Workspace) + @issues = T.let(issues || GithubIssues.new(executor: executor), T.untyped) + @settings = T.let(settings || Dev::Settings.new, T.untyped) + @merge_base = T.let(merge_base || MergeBase.new, MergeBase) + @skill_installer = T.let(skill_installer || Dev::SkillInstaller.new, Dev::SkillInstaller) + @learnings = T.let(learnings || Learnings::Synchronizer.for(settings: @settings), T.untyped) end # Dispatch a `dev plan …` invocation. @@ -54,6 +71,13 @@ def initialize(project_root:, executor: Executor.new, workspace: nil, issues: ni # @param out [IO] output stream # @param input [IO] input stream (the Cursor hook payload for hook-after-edit) # @raise [UsageError] on an unrecognized invocation + sig do + params( + args: T::Array[String], + out: T.any(IO, StringIO), + input: T.any(IO, StringIO), + ).void + end def run(args, out: $stdout, input: $stdin) # Hook point: refresh dev's shipped skill links and the org learnings # artifacts. Cheap and idempotent (content-compared, the network pull @@ -84,6 +108,7 @@ def run(args, out: $stdout, input: $stdin) # with a `Target repos:` line: they usually span repos, and the line # narrows /split's routing menu (see ai-flow's docs/plan-lifecycle.md). # Left blank it is inert — the menu falls back to every org repo. + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def new_plan(args, out:) org = args.delete("--org") ? true : false blank = args.delete("--blank") ? true : false @@ -111,6 +136,7 @@ def new_plan(args, out:) # @param org [Boolean] true when targeting the org plans repo # @param out [IO] # @return [String] the markdown template body + sig { params(owner_repo: String, org: T::Boolean, out: T.any(IO, StringIO)).returns(String) } def template_body(owner_repo, org:, out:) content = if org @@ -133,6 +159,7 @@ def template_body(owner_repo, org:, out:) # with `git diff` and commit — human merge stays the gate). Only # marker-carrying mirrors are ever overwritten: a repo that edited the # file and dropped the marker owns its template. + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def init(args, out:) raise UsageError, "usage: dev plan init" unless args.empty? @@ -155,6 +182,7 @@ def init(args, out:) # `dev plan link <n> [<file>] [--org]` attaches a plan file to an # existing issue (local content stays; `push` publishes it), while # `dev plan link <file> [--org]` creates the issue from the file. + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def link(args, out:) org = args.delete("--org") ? true : false first, second = args @@ -167,10 +195,18 @@ def link(args, out:) end end + sig do + params( + number: Integer, + file: T.nilable(String), + org: T::Boolean, + out: T.any(IO, StringIO), + ).void + end def link_to_existing(number, file, org:, out:) path = file ? Pathname.new(file) : sole_unlinked_plan plan = Content.parse(path.read) - raise UsageError, "#{path} is already linked to #{plan.header.issue_ref}" if plan.header + raise UsageError, "#{path} is already linked to #{T.must(plan.header).issue_ref}" if plan.header owner_repo = target_repo(org:) issue = @issues.get(owner_repo, number) @@ -184,12 +220,13 @@ def link_to_existing(number, file, org:, out:) out.puts "dev: local content kept — run `dev plan push` to publish it." end + sig { params(file: String, org: T::Boolean, out: T.any(IO, StringIO)).void } def create_from_file(file, org:, out:) path = Pathname.new(file) raise UsageError, "no such plan file: #{path}" unless path.exist? plan = Content.parse(path.read) - raise UsageError, "#{path} is already linked to #{plan.header.issue_ref}" if plan.header + raise UsageError, "#{path} is already linked to #{T.must(plan.header).issue_ref}" if plan.header owner_repo = target_repo(org:) title = extract_title(plan.body) || path.basename(".plan.md").to_s @@ -203,6 +240,7 @@ def create_from_file(file, org:, out:) # `dev plan pull <n> [--merge] [--org]` — fetch the issue into the local # plan. A clean local copy is overwritten; a diverged one needs --merge # (3-way against the recorded base) so local work is never discarded. + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def pull(args, out:) org = args.delete("--org") ? true : false merge = args.delete("--merge") ? true : false @@ -239,6 +277,18 @@ def pull(args, out:) end end + sig do + params( + path: Pathname, + issue: GithubIssues::Issue, + owner_repo: String, + number: Integer, + plan: Content, + base: String, + remote_body: String, + out: T.any(IO, StringIO), + ).void + end def merge_pull(path, issue, owner_repo, number, plan, base, remote_body, out:) result = Merge.three_way(local: plan.body, base: base, remote: remote_body, executor: @executor) # The remote becomes the new base either way: the merged local copy is @@ -260,6 +310,7 @@ def merge_pull(path, issue, owner_repo, number, plan, base, remote_body, out:) # `pull` uses (`--org` picks the org plans repo, symmetric with pull); # a file path never needs `--org` — the target repo comes from the # file's header, so org-wide plans push transparently. + sig { params(args: T::Array[String], out: T.any(IO, StringIO)).void } def push(args, out:) org = args.delete("--org") ? true : false target = args.shift @@ -270,7 +321,7 @@ def push(args, out:) raise UsageError, "#{path} has no ai-flow header — link it first with `dev plan link`." unless plan.header raise "#{path} contains unresolved merge conflict markers — resolve them before pushing." if plan.body.include?("<<<<<<<") - header = plan.header + header = T.must(plan.header) issue = @issues.get(header.owner_repo, header.number) remote_body = Plan.from_issue_body(issue.body) base = @merge_base.read(header.owner_repo, header.number) @@ -305,8 +356,9 @@ def push(args, out:) # file is a linked plan in this workspace; a linked plan auto-pushes # through the same guarded sync (a guard refusal raises, surfacing in # Cursor's Hooks channel — exactly when the user must pull --merge). + sig { params(input: T.any(IO, StringIO), out: T.any(IO, StringIO)).void } def hook_after_edit(input, out:) - payload = JSON.parse(input.read) + payload = JSON.parse(T.must(input.read)) edited = payload["file_path"] || payload["filePath"] return if edited.nil? || edited.empty? @@ -322,6 +374,7 @@ def hook_after_edit(input, out:) # `dev plan status` — sync state of every linked plan in the workspace: # clean / ahead (local edits) / behind (remote edits) / diverged (both). + sig { params(out: T.any(IO, StringIO)).void } def status(out:) files = @workspace.linked_plan_files if files.empty? @@ -331,12 +384,14 @@ def status(out:) files.each do |path| plan = Content.parse(path.read) - issue = @issues.get(plan.header.owner_repo, plan.header.number) - state = sync_state(plan.header, plan.body, Plan.from_issue_body(issue.body)) - out.puts "#{state.ljust(10)} #{plan.header.issue_ref.ljust(30)} #{path}" + header = T.must(plan.header) + issue = @issues.get(header.owner_repo, header.number) + state = sync_state(header, plan.body, Plan.from_issue_body(issue.body)) + out.puts "#{state.ljust(10)} #{header.issue_ref.ljust(30)} #{path}" end end + sig { params(header: Header, local_body: String, remote_body: String).returns(String) } def sync_state(header, local_body, remote_body) base = @merge_base.read(header.owner_repo, header.number) return "unknown" if base.nil? @@ -352,6 +407,7 @@ def sync_state(header, local_body, remote_body) # @param org [Boolean] true targets the configured org plans repo # @return [String] "owner/repo" + sig { params(org: T::Boolean).returns(String) } def target_repo(org:) org ? @settings.plans_repo : @workspace.origin_repo end @@ -366,6 +422,15 @@ def target_repo(org:) # @param path [Pathname, nil] # @param frontmatter [String, nil] Cursor YAML block to preserve locally # @return [Pathname] the written path + sig do + params( + owner_repo: String, + issue: GithubIssues::Issue, + body: String, + path: T.nilable(Pathname), + frontmatter: T.nilable(String), + ).returns(Pathname) + end def write_linked_plan(owner_repo, issue, body, path: nil, frontmatter: nil) path ||= @workspace.plan_path(owner_repo, issue.number, issue.title) header = Header.new(owner_repo: owner_repo, number: issue.number, synced_at: issue.updated_at) @@ -378,15 +443,18 @@ def write_linked_plan(owner_repo, issue, body, path: nil, frontmatter: nil) # @param path [Pathname] # @param plan [Dev::Plan::Content] # @param issue [Dev::Plan::GithubIssues::Issue] + sig { params(path: Pathname, plan: Content, issue: GithubIssues::Issue).void } def record_sync(path, plan, issue) + header = T.must(plan.header) path.write(plan.with_synced_at(issue.updated_at).render) - @merge_base.write(plan.header.owner_repo, plan.header.number, plan.body) + @merge_base.write(header.owner_repo, header.number, plan.body) end # Move a freshly linked file to the `gh-<n>-<slug>.plan.md` convention # inside the workspace plans dir (no-op when it's already there). # # @return [Pathname] the conventional path + sig { params(path: Pathname, owner_repo: String, issue: GithubIssues::Issue).returns(Pathname) } def move_into_convention(path, owner_repo, issue) target = @workspace.plan_path(owner_repo, issue.number, issue.title) return path if path.expand_path == target.expand_path @@ -398,6 +466,7 @@ def move_into_convention(path, owner_repo, issue) # @param body [String] # @return [String, nil] the first H1 heading, which doubles as the title + sig { params(body: String).returns(T.nilable(String)) } def extract_title(body) body[/^# (.+)$/, 1]&.strip end @@ -408,6 +477,7 @@ def extract_title(body) # @param target [String, nil] # @param org [Boolean] # @return [Pathname] + sig { params(target: T.nilable(String), org: T::Boolean).returns(Pathname) } def push_path(target, org:) return sole_linked_plan if target.nil? return Pathname.new(target) unless target.match?(/\A\d+\z/) @@ -418,14 +488,16 @@ def push_path(target, org:) raise(UsageError, "no linked plan for #{owner_repo}##{number} — run `dev plan pull #{number}` first.") end - # @return [Pathname] + # @return [Pathname, nil] + sig { params(owner_repo: String, number: Integer).returns(T.nilable(Pathname)) } def find_linked_plan(owner_repo, number) @workspace.linked_plan_files.find do |path| - plan = Content.parse(path.read) - plan.header.owner_repo == owner_repo && plan.header.number == number + header = T.must(Content.parse(path.read).header) + header.owner_repo == owner_repo && header.number == number end end + sig { returns(Pathname) } def sole_linked_plan files = @workspace.linked_plan_files raise UsageError, "no linked plans in #{@workspace.plans_dir} — link one first." if files.empty? @@ -434,6 +506,7 @@ def sole_linked_plan raise UsageError, "multiple linked plans — specify one: dev plan push <file>\n #{files.join("\n ")}" end + sig { returns(Pathname) } def sole_unlinked_plan files = if @workspace.plans_dir.directory? diff --git a/lib/dev/plan/content.rb b/lib/dev/plan/content.rb index 024a0a6..51cacd4 100644 --- a/lib/dev/plan/content.rb +++ b/lib/dev/plan/content.rb @@ -1,5 +1,8 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" + require "dev/plan/header" require "dev/plan/frontmatter" @@ -9,16 +12,23 @@ module Plan # Cursor YAML frontmatter, and the markdown body. Sync compares and ships # the markdown body only; the header and frontmatter stay local. class Content + extend T::Sig + # @return [Dev::Plan::Header, nil] + sig { returns(T.nilable(Header)) } attr_reader :header # @return [String, nil] raw frontmatter block including `---` fences + sig { returns(T.nilable(String)) } attr_reader :frontmatter # @return [String] markdown body (canonical plan prose) + sig { returns(String) } attr_reader :body class << self + extend T::Sig + # Parse a plan file into its layers. Canonical on-disk order is header, # then optional frontmatter, then body. When frontmatter sits above the # ai-flow header (Cursor's plan tool writes that layout, with a blank @@ -29,6 +39,7 @@ class << self # # @param content [String] # @return [Content] + sig { params(content: String).returns(Content) } def parse(content) header, remainder = Header.split(without_leading_blank_lines(content)) if header @@ -60,6 +71,7 @@ def parse(content) # @param content [String] # @return [Array(String | nil, String)] the surviving frontmatter block # (or nil) and the remainder + sig { params(content: String).returns([T.nilable(String), String]) } def split_stacked_frontmatter(content) frontmatter, remainder = Frontmatter.split(content) return [nil, content] if frontmatter.nil? @@ -80,6 +92,7 @@ def split_stacked_frontmatter(content) # # @param content [String] # @return [String] + sig { params(content: String).returns(String) } def without_leading_blank_lines(content) content.sub(/\A(?:[ \t]*\n)+/, "") end @@ -88,6 +101,7 @@ def without_leading_blank_lines(content) # @param header [Dev::Plan::Header, nil] # @param frontmatter [String, nil] # @param body [String] + sig { params(header: T.nilable(Header), frontmatter: T.nilable(String), body: String).void } def initialize(header:, frontmatter:, body:) @header = header @frontmatter = frontmatter @@ -98,26 +112,30 @@ def initialize(header:, frontmatter:, body:) # markdown body. # # @return [String] + sig { returns(String) } def render "#{header&.render}#{frontmatter}#{body}" end # @param header [Dev::Plan::Header, nil] # @return [Content] + sig { params(header: T.nilable(Header)).returns(Content) } def with_header(header) self.class.new(header: header, frontmatter: frontmatter, body: body) end # @param body [String] # @return [Content] + sig { params(body: String).returns(Content) } def with_body(body) self.class.new(header: header, frontmatter: frontmatter, body: body) end # @param synced_at [String] # @return [Content] + sig { params(synced_at: String).returns(Content) } def with_synced_at(synced_at) - with_header(header.with_synced_at(synced_at)) + with_header(T.must(header).with_synced_at(synced_at)) end end end diff --git a/lib/dev/plan/executor.rb b/lib/dev/plan/executor.rb index bf18dac..7351154 100644 --- a/lib/dev/plan/executor.rb +++ b/lib/dev/plan/executor.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "open3" +require "sorbet-runtime" module Dev module Plan @@ -8,16 +10,19 @@ module Plan # Dev::RunnerSetup::Executor: the one injectable boundary so orchestration # is testable without real subprocesses. class Executor + extend T::Sig + # @param argv [Array<String>] command and arguments # @param stdin [String, nil] data piped to the subprocess (e.g. a JSON # payload for `gh api --input -`) # @return [Array(String, String, Boolean)] stdout, stderr, success? + sig { params(argv: String, stdin: T.nilable(String)).returns([String, String, T::Boolean]) } def capture(*argv, stdin: nil) out, err, status = if stdin - Open3.capture3(*argv, stdin_data: stdin) + Open3.capture3(*T.unsafe(argv), stdin_data: stdin) else - Open3.capture3(*argv) + Open3.capture3(*T.unsafe(argv)) end [out, err, status.success?] rescue Errno::ENOENT => e diff --git a/lib/dev/plan/frontmatter.rb b/lib/dev/plan/frontmatter.rb index 2078533..fe8bdbb 100644 --- a/lib/dev/plan/frontmatter.rb +++ b/lib/dev/plan/frontmatter.rb @@ -1,5 +1,7 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require "yaml" module Dev @@ -11,6 +13,8 @@ class Frontmatter FENCE_LINE = /\A---\n?\z/ class << self + extend T::Sig + # Peel a Cursor-like YAML frontmatter block from the start of +content+. # Only a leading `---` … `---` fence whose interior is a YAML mapping is # removed; ordinary markdown horizontal rules deeper in the body, or a @@ -20,6 +24,7 @@ class << self # @return [Array(String | nil, String)] frontmatter block (including # fences and a trailing newline after the closing fence) or nil, and # the remainder + sig { params(content: String).returns([T.nilable(String), String]) } def split(content) lines = content.lines return [nil, content] if lines.empty? || !fence?(lines.fetch(0)) @@ -27,12 +32,12 @@ def split(content) close_index = (1...lines.length).find { |index| fence?(lines.fetch(index)) } return [nil, content] unless close_index - yaml_text = lines[1...close_index].join + yaml_text = T.must(lines[1...close_index]).join return [nil, content] unless mapping?(yaml_text) - frontmatter = lines[0..close_index].join + frontmatter = T.must(lines[0..close_index]).join frontmatter = "#{frontmatter}\n" unless frontmatter.end_with?("\n") - body = lines[(close_index + 1)..].join + body = T.must(lines[(close_index + 1)..]).join [frontmatter, body] end @@ -42,8 +47,9 @@ def split(content) # # @param frontmatter [String] a block produced by {.split}, fences included # @return [Boolean] + sig { params(frontmatter: String).returns(T::Boolean) } def empty?(frontmatter) - interior = frontmatter.lines[1..-2].join + interior = T.must(frontmatter.lines[1..-2]).join YAML.safe_load(interior).values.all? { |value| blank_value?(value) } end @@ -51,6 +57,7 @@ def empty?(frontmatter) # @param value [Object] a value from the frontmatter's YAML mapping # @return [Boolean] + sig { params(value: T.untyped).returns(T::Boolean) } def blank_value?(value) return true if value.nil? || value == false @@ -59,12 +66,14 @@ def blank_value?(value) # @param line [String] # @return [Boolean] + sig { params(line: String).returns(T::Boolean) } def fence?(line) line.match?(FENCE_LINE) end # @param yaml_text [String] interior between fences # @return [Boolean] true when the interior parses as a YAML mapping + sig { params(yaml_text: String).returns(T::Boolean) } def mapping?(yaml_text) parsed = YAML.safe_load(yaml_text) parsed.is_a?(Hash) diff --git a/lib/dev/plan/github_issues.rb b/lib/dev/plan/github_issues.rb index d8531f3..f559a3d 100644 --- a/lib/dev/plan/github_issues.rb +++ b/lib/dev/plan/github_issues.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "json" +require "sorbet-runtime" module Dev module Plan @@ -8,11 +10,14 @@ module Plan # (the same boundary the rest of dev uses — no extra token management). # JSON payloads go through `--input -` so bodies never hit argv. class GithubIssues + extend T::Sig + class Error < RuntimeError; end Issue = Struct.new(:number, :title, :body, :updated_at, :html_url, keyword_init: true) # @param executor [Dev::Plan::Executor] CLI boundary (injectable for tests) + sig { params(executor: T.untyped).void } def initialize(executor: Executor.new) @executor = executor end @@ -21,6 +26,7 @@ def initialize(executor: Executor.new) # @param number [Integer] # @return [Issue] # @raise [Error] when the issue can't be fetched + sig { params(owner_repo: String, number: Integer).returns(Issue) } def get(owner_repo, number) out = gh_api("repos/#{owner_repo}/issues/#{number}") parse_issue(out) @@ -31,6 +37,7 @@ def get(owner_repo, number) # @param body [String] # @return [Issue] the created issue # @raise [Error] when creation fails + sig { params(owner_repo: String, title: String, body: String).returns(Issue) } def create(owner_repo, title:, body:) payload = JSON.generate({ title: title, body: body }) out = gh_api("repos/#{owner_repo}/issues", method: "POST", input: payload) @@ -46,6 +53,14 @@ def create(owner_repo, title:, body:) # @param title [String, nil] new title, or nil to leave unchanged # @return [Issue] # @raise [Error] when the update fails + sig do + params( + owner_repo: String, + number: Integer, + body: String, + title: T.nilable(String), + ).returns(Issue) + end def update(owner_repo, number, body:, title: nil) fields = { body: body } fields[:title] = title if title @@ -61,6 +76,7 @@ def update(owner_repo, number, body:, title: nil) # @param owner_repo [String] "owner/repo" # @param path [String] file path inside the repo # @return [String, nil] the file content, or nil when unavailable + sig { params(owner_repo: String, path: String).returns(T.nilable(String)) } def repo_file(owner_repo, path) out, _err, ok = @executor.capture( "gh", "api", "-H", "Accept: application/vnd.github.raw", "repos/#{owner_repo}/contents/#{path}" @@ -75,6 +91,7 @@ def repo_file(owner_repo, path) # @param input [String, nil] JSON payload piped to stdin # @return [String] response body # @raise [Error] on any gh failure, with an actionable message + sig { params(path: String, method: T.nilable(String), input: T.nilable(String)).returns(String) } def gh_api(path, method: nil, input: nil) argv = ["gh", "api"] argv += ["-X", method] if method @@ -91,6 +108,7 @@ def gh_api(path, method: nil, input: nil) # @param json [String] # @return [Issue] + sig { params(json: String).returns(Issue) } def parse_issue(json) data = JSON.parse(json) Issue.new( diff --git a/lib/dev/plan/header.rb b/lib/dev/plan/header.rb index d29d150..4c4b1ba 100644 --- a/lib/dev/plan/header.rb +++ b/lib/dev/plan/header.rb @@ -1,7 +1,12 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" + module Dev module Plan + extend T::Sig + module_function # Normalize a markdown plan body for the issue: LF, single trailing @@ -10,6 +15,7 @@ module Plan # # @param plan_body [String] # @return [String] + sig { params(plan_body: String).returns(String) } def to_issue_body(plan_body) "#{plan_body.rstrip}\n" end @@ -21,6 +27,7 @@ def to_issue_body(plan_body) # # @param issue_body [String, nil] # @return [String] + sig { params(issue_body: T.nilable(String)).returns(String) } def from_issue_body(issue_body) "#{(issue_body || "").gsub("\r\n", "\n").rstrip}\n" end @@ -29,20 +36,26 @@ def from_issue_body(issue_body) # (invisible in both GitHub and Cursor plan rendering) carrying the issue # cross-reference and the remote `updated_at` recorded at last sync. class Header + extend T::Sig + PATTERN = /\A<!-- ai-flow\nissue: (?<owner_repo>[^#\s]+)#(?<number>\d+)\nsynced_at: (?<synced_at>\S+)\n-->\n/ # @return [String] "owner/repo" + sig { returns(String) } attr_reader :owner_repo # @return [Integer] issue number + sig { returns(Integer) } attr_reader :number # @return [String] remote `updated_at` recorded at last sync (ISO 8601) + sig { returns(String) } attr_reader :synced_at # @param owner_repo [String] "owner/repo" # @param number [Integer] # @param synced_at [String] + sig { params(owner_repo: String, number: Integer, synced_at: String).void } def initialize(owner_repo:, number:, synced_at:) @owner_repo = owner_repo @number = number @@ -50,35 +63,41 @@ def initialize(owner_repo:, number:, synced_at:) end class << self + extend T::Sig + # Split a plan file's content into its header and body. # # @param content [String] # @return [Array(Header | nil, String)] header (nil when unlinked) and body + sig { params(content: String).returns([T.nilable(Header), String]) } def split(content) match = PATTERN.match(content) return [nil, content] unless match header = new( - owner_repo: match[:owner_repo], - number: Integer(match[:number]), - synced_at: match[:synced_at], + owner_repo: T.must(match[:owner_repo]), + number: Integer(T.must(match[:number])), + synced_at: T.must(match[:synced_at]), ) [header, match.post_match] end end # @return [String] "owner/repo#number" + sig { returns(String) } def issue_ref "#{owner_repo}##{number}" end # @param synced_at [String] new sync timestamp # @return [Header] + sig { params(synced_at: String).returns(Header) } def with_synced_at(synced_at) self.class.new(owner_repo: owner_repo, number: number, synced_at: synced_at) end # @return [String] the serialized header block (trailing newline included) + sig { returns(String) } def render "<!-- ai-flow\nissue: #{issue_ref}\nsynced_at: #{synced_at}\n-->\n" end diff --git a/lib/dev/plan/merge.rb b/lib/dev/plan/merge.rb index f75eacc..f4f53bf 100644 --- a/lib/dev/plan/merge.rb +++ b/lib/dev/plan/merge.rb @@ -1,5 +1,7 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require "tmpdir" module Dev @@ -8,7 +10,12 @@ module Plan # copy recorded at last sync. Pure function over strings; the temp files # exist only because git merge-file works on paths. module Merge + extend T::Sig + Result = Struct.new(:content, :conflicts, keyword_init: true) do + extend T::Sig + + sig { returns(T::Boolean) } def conflicts? = conflicts end @@ -20,6 +27,7 @@ def conflicts? = conflicts # @param executor [Dev::Plan::Executor] # @return [Result] merged content, with conflict markers when both sides # changed the same lines + sig { params(local: String, base: String, remote: String, executor: Executor).returns(Result) } def three_way(local:, base:, remote:, executor: Executor.new) Dir.mktmpdir("ai-flow-merge-") do |dir| local_path = File.join(dir, "local") @@ -37,7 +45,7 @@ def three_way(local:, base:, remote:, executor: Executor.new) local_path, base_path, remote_path ) conflicts = out.include?("<<<<<<<") - raise Workspace::Error, "git merge-file failed: #{err.strip}" if !ok && !conflicts + Kernel.raise Workspace::Error, "git merge-file failed: #{err.strip}" if !ok && !conflicts Result.new(content: out, conflicts: conflicts) end diff --git a/lib/dev/plan/merge_base.rb b/lib/dev/plan/merge_base.rb index b926445..683cbd3 100644 --- a/lib/dev/plan/merge_base.rb +++ b/lib/dev/plan/merge_base.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "fileutils" +require "sorbet-runtime" module Dev module Plan @@ -9,15 +11,19 @@ module Plan # detects divergence; this common-ancestor text is what makes a 3-way merge # possible when both sides changed (see the plan's data-model rationale). class MergeBase + extend T::Sig + # @param state_dir [String, nil] override for tests; defaults to # $XDG_STATE_HOME/ai-flow (~/.local/state/ai-flow) + sig { params(state_dir: T.nilable(String)).void } def initialize(state_dir: nil) - @state_dir = state_dir || default_state_dir + @state_dir = T.let(state_dir || default_state_dir, String) end # @param owner_repo [String] "owner/repo" # @param number [Integer] # @return [String, nil] the base body, or nil when no sync recorded + sig { params(owner_repo: String, number: Integer).returns(T.nilable(String)) } def read(owner_repo, number) path = path_for(owner_repo, number) File.exist?(path) ? File.read(path) : nil @@ -27,6 +33,7 @@ def read(owner_repo, number) # @param number [Integer] # @param body [String] # @return [void] + sig { params(owner_repo: String, number: Integer, body: String).void } def write(owner_repo, number, body) FileUtils.mkdir_p(@state_dir) File.write(path_for(owner_repo, number), body) @@ -35,6 +42,7 @@ def write(owner_repo, number, body) # @param owner_repo [String] "owner/repo" # @param number [Integer] # @return [String] + sig { params(owner_repo: String, number: Integer).returns(String) } def path_for(owner_repo, number) File.join(@state_dir, "#{owner_repo.tr("/", "-")}-#{number}.md") end @@ -42,6 +50,7 @@ def path_for(owner_repo, number) private # @return [String] + sig { returns(String) } def default_state_dir state_home = ENV.fetch("XDG_STATE_HOME", File.join(Dir.home, ".local", "state")) File.join(state_home, "ai-flow") diff --git a/lib/dev/plan/templates.rb b/lib/dev/plan/templates.rb index 01ac764..490a2eb 100644 --- a/lib/dev/plan/templates.rb +++ b/lib/dev/plan/templates.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" module Dev module Plan @@ -12,10 +14,15 @@ module Plan # `dev plan init` materializes the mirror; repos customize by editing the # mirror and dropping the marker. module Templates + extend T::Sig + # The bundled template body, relative to this file (lib/dev/plan/ → # repo or libexec root) — the installed location under brew, same # resolution as SkillInstaller::SHIPPED_SKILLS_DIR. - BUNDLE_FILE = Pathname(File.expand_path(File.join(__dir__, "..", "..", "..", "share", "plan-templates", "tech-design.md"))) + BUNDLE_FILE = T.let( + Pathname.new(File.expand_path(File.join(__dir__, "..", "..", "..", "share", "plan-templates", "tech-design.md"))), + Pathname, + ) # Where the mirror lives inside a repo — GitHub's issue-template # location, so the web UI's "New issue" chooser serves the same @@ -23,7 +30,7 @@ module Templates MIRROR_SUBDIRS = [".github", "ISSUE_TEMPLATE", "plan.md"].freeze # The mirror path relative to a repo root (for API content fetches). - MIRROR_RELATIVE_PATH = File.join(*MIRROR_SUBDIRS) + MIRROR_RELATIVE_PATH = T.let(File.join(*MIRROR_SUBDIRS), String) # Ownership marker: present = dev-managed mirror (init may overwrite, # new warns on staleness); absent = repo-owned template (left alone). @@ -43,20 +50,23 @@ module Templates module_function # @return [String] the bundled template body (markdown sections only) + sig { returns(String) } def bundle_body BUNDLE_FILE.read end # @param repo_root [Pathname, String] a repo checkout root # @return [Pathname] the repo's plan template mirror + sig { params(repo_root: T.any(Pathname, String)).returns(Pathname) } def mirror_path(repo_root) - Pathname(repo_root).join(*MIRROR_SUBDIRS) + Pathname.new(repo_root).join(*MIRROR_SUBDIRS) end # The mirror file content: GitHub front matter, the ownership marker, # then the bundled body verbatim. # # @return [String] + sig { returns(String) } def render_mirror "#{GITHUB_FRONT_MATTER}#{MARKER}\n\n#{bundle_body}" end @@ -66,6 +76,7 @@ def render_mirror # # @param content [String] a plan template file's content # @return [String] the markdown body to scaffold into a new plan + sig { params(content: String).returns(String) } def body_of(content) content .sub(FRONT_MATTER_PATTERN, "") @@ -75,6 +86,7 @@ def body_of(content) # @param content [String] a plan template file's content # @return [Boolean] whether the content is a dev-managed mirror + sig { params(content: String).returns(T::Boolean) } def mirrored?(content) content.include?(MARKER) end @@ -84,6 +96,7 @@ def mirrored?(content) # # @param content [String] a plan template file's content # @return [Boolean] + sig { params(content: String).returns(T::Boolean) } def stale?(content) mirrored?(content) && content != render_mirror end diff --git a/lib/dev/plan/workspace.rb b/lib/dev/plan/workspace.rb index 27442e8..16d8fc9 100644 --- a/lib/dev/plan/workspace.rb +++ b/lib/dev/plan/workspace.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" module Dev module Plan @@ -9,18 +11,22 @@ module Plan # convention, and resolution of the repo's `owner/repo` from its origin # remote (repo-scoped plans target the repo you're standing in). class Workspace + extend T::Sig + class Error < RuntimeError; end PLAN_GLOB = "*.plan.md" # @return [Pathname] + sig { returns(Pathname) } attr_reader :plans_dir # @param project_root [Pathname] # @param executor [Dev::Plan::Executor] CLI boundary (injectable for tests) + sig { params(project_root: Pathname, executor: Executor).void } def initialize(project_root:, executor: Executor.new) @project_root = project_root - @plans_dir = project_root / ".cursor" / "plans" + @plans_dir = T.let(project_root / ".cursor" / "plans", Pathname) @executor = executor end @@ -28,6 +34,7 @@ def initialize(project_root:, executor: Executor.new) # # @return [String] # @raise [Error] when there is no origin remote or it isn't a GitHub URL + sig { returns(String) } def origin_repo out, err, ok = @executor.capture("git", "-C", @project_root.to_s, "remote", "get-url", "origin") raise Error, "could not resolve the origin remote: #{err.strip}" unless ok @@ -41,6 +48,7 @@ def origin_repo # @param number [Integer] # @param title [String] issue title, slugified into the filename # @return [Pathname] + sig { params(owner_repo: String, number: Integer, title: String).returns(Pathname) } def plan_path(owner_repo, number, title) prefix = (owner_repo == origin_repo_or_nil) ? "" : "#{owner_repo.split("/").fetch(1)}-" @plans_dir / "gh-#{prefix}#{number}-#{self.class.slugify(title)}.plan.md" @@ -49,6 +57,7 @@ def plan_path(owner_repo, number, title) # All plan files in the workspace carrying an ai-flow header. # # @return [Array<Pathname>] + sig { returns(T::Array[Pathname]) } def linked_plan_files return [] unless @plans_dir.directory? @@ -58,8 +67,11 @@ def linked_plan_files end class << self + extend T::Sig + # @param title [String] # @return [String] filesystem-safe slug (bounded length) + sig { params(title: String).returns(String) } def slugify(title) slug = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") slug = slug[0, 40].to_s.sub(/-\z/, "") @@ -73,6 +85,7 @@ def slugify(title) # (org-wide plans still need a filename, so this must not raise). # # @return [String, nil] + sig { returns(T.nilable(String)) } def origin_repo_or_nil origin_repo rescue Error @@ -82,6 +95,7 @@ def origin_repo_or_nil # @param url [String] ssh or https remote URL # @return [String] "owner/repo" # @raise [Error] for non-GitHub remotes + sig { params(url: String).returns(String) } def parse_github_remote(url) match = url.match(%r{github\.com[:/](?<owner>[^/]+)/(?<repo>[^/\s]+?)(?:\.git)?\z}) raise Error, "origin remote is not a GitHub URL: #{url}" unless match diff --git a/lib/dev/runner_setup.rb b/lib/dev/runner_setup.rb index ae105b6..6fc424c 100644 --- a/lib/dev/runner_setup.rb +++ b/lib/dev/runner_setup.rb @@ -1,9 +1,12 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "json" require "open3" require "socket" +require "sorbet-runtime" +require "stringio" module Dev # Registers the current host as a self-hosted GitHub Actions runner — scoped @@ -26,6 +29,8 @@ module Dev # injectable Executor so the orchestration can be exercised in tests without # real side effects. class RunnerSetup + extend T::Sig + class Error < StandardError; end # Pinned runner version; override per-repo via dev.yml `runner.version`. @@ -33,18 +38,23 @@ class Error < StandardError; end # Thin wrapper over the external CLIs RunnerSetup drives. Tests inject a fake. class Executor + extend T::Sig + # @return [Array(String, String, Boolean)] stdout, stderr, success? + sig { params(argv: String).returns([String, String, T::Boolean]) } def capture(*argv) - out, err, status = Open3.capture3(*argv) + out, err, status = Open3.capture3(*T.unsafe(argv)) [out, err, status.success?] rescue Errno::ENOENT => e ["", e.message, false] end - # @return [Boolean] whether the command exited 0 + # @return [Boolean, nil] whether the command exited 0 (nil when it + # could not be spawned) + sig { params(argv: String, chdir: T.nilable(String)).returns(T.nilable(T::Boolean)) } def system(*argv, chdir: nil) opts = chdir ? { chdir: chdir } : {} - Kernel.system(*argv, **opts) + Kernel.system(*T.unsafe(argv), **opts) end end @@ -52,11 +62,22 @@ def system(*argv, chdir: nil) # @param repo [String, nil] "owner/repo" override; defaults to `gh repo view` # @param org [Boolean] register at the org scope (the repo's owner) instead # of the repo scope, so the runner serves every repo in the org - # @param executor [Executor] CLI boundary (injectable for tests) - # @param out [IO] progress stream + # @param executor [#capture, #system] CLI boundary (default: Executor; + # injectable for tests) + # @param out [IO, StringIO] progress stream # @param host_platform [String] actions-runner release platform slug for this # host (e.g. "linux-x64", "osx-arm64"); defaults to detection. Drives both # the tarball choice and the service-install shape (systemd vs LaunchAgent). + sig do + params( + config: Dev::RunnerSetupConfig, + repo: T.nilable(String), + org: T::Boolean, + executor: T.untyped, + out: T.any(IO, StringIO), + host_platform: String, + ).void + end def initialize(config:, repo: nil, org: false, executor: Executor.new, out: $stdout, host_platform: self.class.detect_host_platform) @config = config @@ -68,10 +89,13 @@ def initialize(config:, repo: nil, org: false, executor: Executor.new, out: $std end class << self + extend T::Sig + # The actions-runner release platform slug for the current host (GitHub # names macOS "osx"). # # @return [String] + sig { returns(String) } def detect_host_platform os = RUBY_PLATFORM.include?("darwin") ? "osx" : "linux" arch = RUBY_PLATFORM.match?(/arm64|aarch64/) ? "arm64" : "x64" @@ -83,6 +107,7 @@ def detect_host_platform # # @return [void] # @raise [Error] on any preflight or step failure + sig { void } def run dir = resolve_dir guard_ext4!(dir) @@ -108,16 +133,19 @@ def run # repos can register distinct runners on the same box without colliding. # # @return [String] + sig { returns(String) } def resolve_dir File.expand_path(@config.dir || "~/actions-runner-#{default_dir_suffix}") end # @return [String] + sig { returns(String) } def resolve_name @config.name || Socket.gethostname end # @return [String] + sig { returns(String) } def resolve_version @config.version || DEFAULT_VERSION end @@ -126,6 +154,7 @@ def resolve_version # the registration contract is testable without touching the system. # # @return [Array<String>] + sig { params(url: String, token: String, name: String).returns(T::Array[String]) } def config_argv(url:, token:, name:) [ "./config.sh", @@ -145,6 +174,7 @@ def config_argv(url:, token:, name:) # # @param dir [String] resolved install dir # @raise [Error] when dir is on a Windows mount + sig { params(dir: String).void } def guard_ext4!(dir) return unless dir.start_with?("/mnt/") @@ -153,6 +183,7 @@ def guard_ext4!(dir) end # @raise [Error] when gh is missing or unauthenticated + sig { void } def ensure_gh_authenticated! _out, _err, ok = @exec.capture("gh", "auth", "status") return if ok @@ -165,6 +196,7 @@ def ensure_gh_authenticated! # # @return [String] # @raise [Error] when the repo can't be resolved + sig { returns(String) } def resolve_scope repo = resolve_repo @org ? repo.split("/").fetch(0) : repo @@ -172,6 +204,7 @@ def resolve_scope # @return [String] "owner/repo" # @raise [Error] when the repo can't be resolved + sig { returns(String) } def resolve_repo return @repo_override if @repo_override @@ -187,6 +220,7 @@ def resolve_repo # @param dir [String] install dir # @param version [String] runner version # @raise [Error] on download/extract failure + sig { params(dir: String, version: String).void } def download_runner(dir, version) FileUtils.mkdir_p(dir) if File.executable?(File.join(dir, "config.sh")) @@ -215,6 +249,7 @@ def download_runner(dir, version) # @param scope [String] the target scope ("owner/repo" or "owner"), used as # a fallback when the existing registration's scope can't be read # @raise [Error] when the stale config can't be removed + sig { params(dir: String, scope: String).void } def remove_existing_config(dir, scope) return unless File.exist?(File.join(dir, ".runner")) @@ -233,6 +268,7 @@ def remove_existing_config(dir, scope) # whose service was never installed, or already removed, has nothing to undo. # # @param dir [String] install dir + sig { params(dir: String).void } def uninstall_existing_service(dir) return unless File.exist?(File.join(dir, ".service")) @@ -249,6 +285,7 @@ def uninstall_existing_service(dir) # # @param dir [String] install dir # @return [String, nil] "owner/repo" or "owner" + sig { params(dir: String).returns(T.nilable(String)) } def existing_registration_scope(dir) raw = File.read(File.join(dir, ".runner"), encoding: "bom|utf-8") url = JSON.parse(raw)["gitHubUrl"].to_s @@ -261,6 +298,7 @@ def existing_registration_scope(dir) # @param scope [String] "owner/repo" or "owner" # @return [String] a fresh registration token # @raise [Error] when the token can't be minted + sig { params(scope: String).returns(String) } def mint_registration_token(scope) @out.puts ">>> Minting a registration token ..." mint_token(scope, "registration-token") @@ -274,6 +312,7 @@ def mint_registration_token(scope) # @param kind [String] # @return [String] # @raise [Error] when the token can't be minted + sig { params(scope: String, kind: String).returns(String) } def mint_token(scope, kind) base = scope.include?("/") ? "repos/#{scope}" : "orgs/#{scope}" out, err, ok = @exec.capture( @@ -288,6 +327,7 @@ def mint_token(scope, kind) end # @raise [Error] when config.sh fails + sig { params(dir: String, url: String, token: String, name: String).void } def configure_runner(dir:, url:, token:, name:) @out.puts ">>> Configuring the runner (--replace) ..." return if @exec.system(*config_argv(url: url, token: token, name: name), chdir: dir) @@ -304,6 +344,7 @@ def configure_runner(dir:, url:, token:, name:) # # @param dir [String] install dir # @raise [Error] when the service can't be installed or started + sig { params(dir: String).void } def install_service(dir) @out.puts ">>> Installing + starting the runner service ..." raise Error, "svc.sh install failed" unless @exec.system(*service_argv("install"), chdir: dir) @@ -312,11 +353,13 @@ def install_service(dir) # @param action [String] svc.sh subcommand # @return [Array<String>] + sig { params(action: String).returns(T::Array[String]) } def service_argv(action) darwin? ? ["./svc.sh", action] : ["sudo", "./svc.sh", action] end # @return [Boolean] + sig { returns(T::Boolean) } def darwin? @host_platform.start_with?("osx") end @@ -324,6 +367,7 @@ def darwin? # First label, sanitized for use in a directory name. # # @return [String] + sig { returns(String) } def default_dir_suffix first = @config.labels.split(",").first.to_s sanitized = first.gsub(/[^A-Za-z0-9_.-]/, "-") diff --git a/lib/dev/settings.rb b/lib/dev/settings.rb index 343d7e8..cd345ea 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -1,5 +1,7 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" require "yaml" module Dev @@ -17,19 +19,24 @@ module Dev # overrides: DEV_PLANS_REPO and DEV_KNOWLEDGE_REPO (matching the # credentials ENV-first convention). class Settings + extend T::Sig + class MissingSettingError < RuntimeError; end # @return [String] path of the config file settings are read from + sig { returns(String) } attr_reader :config_path # @param config_path [String, nil] override for tests; defaults to the # XDG config location + sig { params(config_path: T.nilable(String)).void } def initialize(config_path: nil) - @config_path = config_path || default_config_path + @config_path = T.let(config_path || default_config_path, String) end # @return [String] "owner/repo" of the org-wide plans repo # @raise [MissingSettingError] when unset + sig { returns(String) } def plans_repo from_env = ENV["DEV_PLANS_REPO"] return from_env if from_env && !from_env.empty? @@ -47,6 +54,7 @@ def plans_repo # org learnings sync. # # @return [String, nil] "owner/repo" (or any git-clonable URL), or nil + sig { returns(T.nilable(String)) } def knowledge_repo from_env = ENV["DEV_KNOWLEDGE_REPO"] return from_env if from_env && !from_env.empty? @@ -58,12 +66,14 @@ def knowledge_repo private # @return [String] + sig { returns(String) } def default_config_path config_home = ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")) File.join(config_home, "dev", "config.yml") end # @return [Hash] + sig { returns(T::Hash[String, T.untyped]) } def load_config return {} unless File.exist?(@config_path) diff --git a/lib/dev/shell_rc_hook.rb b/lib/dev/shell_rc_hook.rb index aae5cb3..9ce290d 100644 --- a/lib/dev/shell_rc_hook.rb +++ b/lib/dev/shell_rc_hook.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "sorbet-runtime" module Dev # Idempotent installer of snippets into the user's shell RC file. @@ -13,18 +15,22 @@ module Dev # # Supported shells: zsh, bash, fish — the set every dev RC hook targets. class ShellRcHook - SUPPORTED_SHELLS = %i[zsh bash fish].freeze + extend T::Sig + + SUPPORTED_SHELLS = T.let(%i[zsh bash fish].freeze, T::Array[Symbol]) # @param shell [String] the user's login shell (default: $SHELL) # @param home [String, Pathname] the user's home directory (default: $HOME) + sig { params(shell: String, home: T.any(String, Pathname)).void } def initialize(shell: ENV["SHELL"] || "/bin/sh", home: ENV["HOME"] || Dir.home) @shell = shell - @home = Pathname(home) + @home = T.let(Pathname(home), Pathname) end # The supported shell this user runs, or nil for unsupported shells. # # @return [Symbol, nil] :zsh, :bash, :fish, or nil + sig { returns(T.nilable(Symbol)) } def shell_kind SUPPORTED_SHELLS.find { |kind| @shell.include?(kind.to_s) } end @@ -40,6 +46,13 @@ def shell_kind # @param present_markers [Array<String>] extra strings whose presence counts as installed # @return [Symbol, false] :added, :already_present, or false when the # shell is unsupported or has no snippet + sig do + params( + marker: String, + snippets: T::Hash[Symbol, String], + present_markers: T::Array[String], + ).returns(T.any(Symbol, FalseClass)) + end def ensure_snippet(marker:, snippets:, present_markers: []) kind = shell_kind snippet = kind && snippets[kind] @@ -64,6 +77,7 @@ def ensure_snippet(marker:, snippets:, present_markers: []) # @param kind [Symbol] :zsh, :bash, or :fish # @return [Pathname] # @raise [ArgumentError] for an unsupported shell kind + sig { params(kind: Symbol).returns(Pathname) } def rc_file(kind) case kind when :zsh then @home / ".zshrc" @@ -79,6 +93,7 @@ def rc_file(kind) # is no `.bash_profile` yet (matching the historical shadowenv behavior). # # @return [Pathname] + sig { returns(Pathname) } def bash_rc_file profile = @home / ".bash_profile" bashrc = @home / ".bashrc" diff --git a/lib/dev/skill_installer.rb b/lib/dev/skill_installer.rb index ff3dd6f..309a020 100644 --- a/lib/dev/skill_installer.rb +++ b/lib/dev/skill_installer.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" +require "sorbet-runtime" module Dev # Symlinks skill directories (each carrying a SKILL.md) into a skills dir. @@ -17,13 +19,19 @@ module Dev # refreshes shipped skills automatically (symlinks resolve through the # installed tree, wherever brew put it). class SkillInstaller + extend T::Sig + SKILL_FILE = "SKILL.md" # Skills shipped inside dev's own package, relative to this file # (lib/dev/ → repo or libexec root) — the installed location under brew. - SHIPPED_SKILLS_DIR = Pathname(File.expand_path(File.join(__dir__, "..", "..", "share", "cursor-skills"))) + SHIPPED_SKILLS_DIR = T.let( + Pathname(File.expand_path(File.join(T.must(__dir__), "..", "..", "share", "cursor-skills"))), + Pathname, + ) # @return [Pathname] target dir the symlinks live in + sig { returns(Pathname) } attr_reader :skills_dir # @param skills_dir [Pathname, String] target dir the symlinks live in; @@ -31,9 +39,10 @@ class SkillInstaller # @param tmpdir [Pathname, String] ephemeral temp root that links must # never target; defaults to Dir.tmpdir (override for tests, whose # fixture skill trees themselves live under the real temp dir) + sig { params(skills_dir: T.any(Pathname, String), tmpdir: T.any(Pathname, String)).void } def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir) - @skills_dir = Pathname(skills_dir) - @tmpdir_roots = tmpdir_roots(Pathname(tmpdir)) + @skills_dir = T.let(Pathname(skills_dir), Pathname) + @tmpdir_roots = T.let(tmpdir_roots(Pathname(tmpdir)), T::Array[Pathname]) end # Install or refresh one skill symlink. A source that resolves under the @@ -47,6 +56,7 @@ def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Di # @param name [String] link name inside the skills dir # @param source_dir [Pathname, String] skill directory the link points at # @return [void] + sig { params(name: String, source_dir: T.any(Pathname, String)).void } def install(name, source_dir) source = Pathname(source_dir) return unless source.directory? @@ -80,6 +90,7 @@ def install(name, source_dir) # @param source_root [Pathname, String] directory of skill directories # @param prefix [String] prepended to each link name (e.g. "gem-rspock--") # @return [void] + sig { params(source_root: T.any(Pathname, String), prefix: String).void } def install_all(source_root, prefix: "") root = Pathname(source_root) return unless root.directory? @@ -99,6 +110,7 @@ def install_all(source_root, prefix: "") # # @param name [String] link name inside the skills dir # @return [void] + sig { params(name: String).void } def remove(name) link = @skills_dir / name FileUtils.rm_f(link) if link.symlink? @@ -112,6 +124,7 @@ def remove(name) # # @param tmpdir [Pathname] # @return [Array<Pathname>] + sig { params(tmpdir: Pathname).returns(T::Array[Pathname]) } def tmpdir_roots(tmpdir) expanded = tmpdir.expand_path roots = [expanded] @@ -124,6 +137,7 @@ def tmpdir_roots(tmpdir) # # @param path [Pathname] # @return [Boolean] + sig { params(path: Pathname).returns(T::Boolean) } def ephemeral?(path) resolved = path.exist? ? path.realpath : path.expand_path @tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") } @@ -135,6 +149,7 @@ def ephemeral?(path) # # @param source_root [Pathname] # @return [void] + sig { params(source_root: Pathname).void } def prune_broken_links(source_root) return unless @skills_dir.directory? diff --git a/lib/ensure_bundler.rb b/lib/ensure_bundler.rb index fb26c19..6718901 100644 --- a/lib/ensure_bundler.rb +++ b/lib/ensure_bundler.rb @@ -1,3 +1,4 @@ +# typed: true # frozen_string_literal: true require "open3" diff --git a/lib/rake_test_argv.rb b/lib/rake_test_argv.rb index 7fb9c4c..883eb27 100644 --- a/lib/rake_test_argv.rb +++ b/lib/rake_test_argv.rb @@ -1,5 +1,10 @@ +# typed: strict # frozen_string_literal: true +require "sorbet-runtime" + +extend T::Sig + # Builds the `bundle exec rake test` argv for bin/test.rb (dev test). # # Rake's TestTask (rake 13.3) reads ENV["TEST"] as a single glob pattern, @@ -9,6 +14,7 @@ # @param test_files [Array<String>] test file paths relative to the repo # root; empty means the full suite # @return [Array<String>] argv for the child rake process +sig { params(test_files: T::Array[String]).returns(T::Array[String]) } def rake_test_argv(test_files) argv = ["bundle", "exec", "rake", "test"] return argv if test_files.empty? diff --git a/lib/shadowenv_llvm.rb b/lib/shadowenv_llvm.rb index e825272..4c6991d 100644 --- a/lib/shadowenv_llvm.rb +++ b/lib/shadowenv_llvm.rb @@ -1,6 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" +require "pathname" +require "sorbet-runtime" # Shadowenv LLVM provisioning: resolves the Homebrew LLVM prefix, generates # .shadowenv.d/520_llvm.lisp so clang, clang-format, clang-tidy, and lld are @@ -8,12 +11,16 @@ # # Skipped on Linux / CI where Linuxbrew tools are already in PATH. module ShadowenvLlvm + extend T::Sig + include Kernel + LISP_FILENAME = "520_llvm.lisp" FORMULA_NAMES = ["llvm@22", "llvm"].freeze module_function # Returns the Homebrew prefix for the LLVM formula, or nil if not installed. + sig { returns(T.nilable(String)) } def detect_llvm_prefix FORMULA_NAMES.each do |name| prefix = brew_prefix_for(name) @@ -24,6 +31,7 @@ def detect_llvm_prefix # Returns true when .shadowenv.d/520_llvm.lisp exists and provisions from # the given prefix. This is the fast-path check run before every dev command. + sig { params(llvm_prefix: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def provisioned?(llvm_prefix, project_root:) lisp_path = File.join(project_root.to_s, ".shadowenv.d", LISP_FILENAME) return false unless File.exist?(lisp_path) @@ -33,6 +41,7 @@ def provisioned?(llvm_prefix, project_root:) # Full provisioning: write .shadowenv.d/520_llvm.lisp, trust shadowenv. # Idempotent. Returns true on success, false if LLVM prefix is nil. + sig { params(project_root: T.any(String, Pathname), llvm_prefix: T.nilable(String)).returns(T::Boolean) } def setup!(project_root:, llvm_prefix: nil) prefix = llvm_prefix || detect_llvm_prefix unless prefix @@ -55,11 +64,13 @@ def setup!(project_root:, llvm_prefix: nil) # Returns true on Linux or when CI env is set -- environments where # Linuxbrew puts LLVM tools in PATH and shadowenv provisioning is # unnecessary. + sig { returns(T::Boolean) } def ci_or_linux? !!(ENV["CI"].to_s =~ /\A(true|1)\z/i) || RUBY_PLATFORM.to_s.include?("linux") end # Generate the shadowenv lisp that puts LLVM in PATH and sets CC/CXX. + sig { params(llvm_prefix: String).returns(String) } def generate_llvm_lisp(llvm_prefix) bin = File.join(llvm_prefix, "bin") lib_cxx = File.join(llvm_prefix, "lib", "c++") @@ -76,6 +87,7 @@ def generate_llvm_lisp(llvm_prefix) # Returns true if the project's build-deps.lock references LLVM. # Supports both YAML format (top-level "llvm:" key) and the legacy # plain-text format ("brew llvm"). + sig { params(project_root: T.any(String, Pathname)).returns(T::Boolean) } def project_needs_llvm?(project_root) lockfile = Pathname(project_root) / "build-deps.lock" return false unless lockfile.exist? @@ -86,6 +98,7 @@ def project_needs_llvm?(project_root) # --- internal helpers ------------------------------------------------ + sig { params(formula: String).returns(T.nilable(String)) } def brew_prefix_for(formula) return nil unless system("command -v brew >/dev/null 2>&1") out = IO.popen(["brew", "--prefix", formula], err: File::NULL, &:read) diff --git a/lib/shadowenv_lua.rb b/lib/shadowenv_lua.rb index c194aa5..b51df9b 100644 --- a/lib/shadowenv_lua.rb +++ b/lib/shadowenv_lua.rb @@ -1,12 +1,18 @@ +# typed: strict # frozen_string_literal: true require "fileutils" +require "pathname" +require "sorbet-runtime" # Shadowenv Lua provisioning: generates .shadowenv.d/510_lua.lisp so that # lua, luarocks, and project-local lua_modules are in PATH / LUA_PATH / LUA_CPATH. # # Mirrors ShadowenvRuby. Triggered when dev.yml declares `lua: "5.1"`. module ShadowenvLua + extend T::Sig + include Kernel + class BrewInstallError < StandardError; end LISP_FILENAME = "510_lua.lisp" @@ -19,6 +25,7 @@ class BrewInstallError < StandardError; end # @param lua_version [String] e.g. "5.1" # @param project_root [Pathname, String] project root directory # @return [Boolean] + sig { params(lua_version: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def provisioned?(lua_version, project_root:) lisp_path = File.join(project_root.to_s, ".shadowenv.d", LISP_FILENAME) return false unless File.exist?(lisp_path) @@ -33,6 +40,7 @@ def provisioned?(lua_version, project_root:) # @param project_root [Pathname, String] project root directory # @return [true] # @raise [BrewInstallError] if Homebrew lua or luarocks cannot be installed + sig { params(lua_version: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def setup!(lua_version:, project_root:) ensure_homebrew_lua!(lua_version) @@ -52,6 +60,7 @@ def setup!(lua_version:, project_root:) # # @param lua_version [String] e.g. "5.1" # @return [String] lisp source + sig { params(lua_version: String).returns(String) } def generate_lua_lisp(lua_version) lua_formula = "lua@#{lua_version}" <<~LISP @@ -80,6 +89,7 @@ def generate_lua_lisp(lua_version) # # @param lua_version [String] e.g. "5.1" # @raise [BrewInstallError] if brew install fails + sig { params(lua_version: String).void } def ensure_homebrew_lua!(lua_version) formula = "lua@#{lua_version}" unless Kernel.system("brew", "list", formula, out: File::NULL, err: File::NULL) diff --git a/lib/shadowenv_python.rb b/lib/shadowenv_python.rb index ea29552..ad059ac 100644 --- a/lib/shadowenv_python.rb +++ b/lib/shadowenv_python.rb @@ -1,6 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" +require "pathname" +require "sorbet-runtime" # Shadowenv Python provisioning: installs the interpreter via Homebrew # (python@<version>), creates a project-local .venv with it, and generates @@ -12,6 +15,9 @@ # the package set is installed into that venv by Dev::Deps::PipIntegration on # `dev install-deps`, exactly as LuaRocks fills lua_modules/. module ShadowenvPython + extend T::Sig + include Kernel + class BrewInstallError < StandardError; end LISP_FILENAME = "540_python.lisp" @@ -26,6 +32,7 @@ class BrewInstallError < StandardError; end # @param python_version [String] e.g. "3.12" # @param project_root [Pathname, String] # @return [Boolean] + sig { params(python_version: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def provisioned?(python_version, project_root:) lisp_path = File.join(project_root.to_s, ".shadowenv.d", LISP_FILENAME) return false unless File.exist?(lisp_path) @@ -40,6 +47,7 @@ def provisioned?(python_version, project_root:) # @param python_version [String] e.g. "3.12" # @param project_root [Pathname, String] # @return [true] + sig { params(python_version: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def setup!(python_version:, project_root:) venv_path = ensure_venv!(python_version:, project_root:) @@ -62,6 +70,7 @@ def setup!(python_version:, project_root:) # @param project_root [Pathname, String] # @return [String] absolute path to the venv # @raise [BrewInstallError] if the interpreter or venv cannot be created + sig { params(python_version: String, project_root: T.any(String, Pathname)).returns(String) } def ensure_venv!(python_version:, project_root:) python_bin = ensure_homebrew_python!(python_version) venv_path = File.join(project_root.to_s, VENV_DIR) @@ -81,6 +90,7 @@ def ensure_venv!(python_version:, project_root:) # # @param venv_python [String] path to the venv's python # @raise [BrewInstallError] if pip cannot be made available + sig { params(venv_python: String).void } def ensure_pip!(venv_python) return if system(venv_python, "-m", "pip", "--version", out: File::NULL, err: File::NULL) @@ -99,6 +109,7 @@ def ensure_pip!(venv_python) # @param python_version [String] e.g. "3.12" # @param venv_path [String] absolute path to the project venv # @return [String] lisp source + sig { params(python_version: String, venv_path: String).returns(String) } def generate_python_lisp(python_version, venv_path) venv = File.expand_path(venv_path) <<~LISP @@ -119,6 +130,7 @@ def generate_python_lisp(python_version, venv_path) # @param python_version [String] e.g. "3.12" # @return [String] absolute path to the python interpreter # @raise [BrewInstallError] if brew install fails or no interpreter is found + sig { params(python_version: String).returns(String) } def ensure_homebrew_python!(python_version) formula = "python@#{python_version}" unless Kernel.system("brew", "list", formula, out: File::NULL, err: File::NULL) @@ -140,6 +152,7 @@ def ensure_homebrew_python!(python_version) # @param formula [String] Homebrew formula name # @return [String, nil] brew --prefix for the formula, or nil when unavailable + sig { params(formula: String).returns(T.nilable(String)) } def brew_prefix_for(formula) return nil unless system("command -v brew >/dev/null 2>&1") diff --git a/lib/shadowenv_ruby.rb b/lib/shadowenv_ruby.rb index 8cf36a4..577d799 100644 --- a/lib/shadowenv_ruby.rb +++ b/lib/shadowenv_ruby.rb @@ -1,12 +1,18 @@ +# typed: strict # frozen_string_literal: true require "fileutils" +require "pathname" +require "sorbet-runtime" require "dev/shell_rc_hook" # Shadowenv Ruby provisioning: installs Ruby via rbenv, generates # .shadowenv.d/510_ruby.lisp, trusts, and ensures the shell hook. # Used by the dev CLI core as a pre-dispatch step for every command. module ShadowenvRuby + extend T::Sig + include Kernel + MIN_RUBY = Gem::Requirement.new(">= 2.7.0") LISP_FILENAME = "510_ruby.lisp" @@ -20,31 +26,42 @@ module ShadowenvRuby # Homebrew formulae that supply the headers/libs ruby-build links the required # extensions against, mapped to the `--with-<flag>-dir` configure flag that points # Ruby's build at the brew copy. Works on macOS and Linuxbrew alike (the box). - RUBY_BUILD_BREW_DEPS = { - "openssl@3" => "openssl", - "readline" => "readline", - "libyaml" => "libyaml", - "zlib" => "zlib", - }.freeze + RUBY_BUILD_BREW_DEPS = T.let( + { + "openssl@3" => "openssl", + "readline" => "readline", + "libyaml" => "libyaml", + "zlib" => "zlib", + }.freeze, + T::Hash[String, String], + ) module_function # Resolve the Ruby version to provision. Explicit pin wins; falls back to # the Homebrew Ruby version; aborts if neither is available or too old. + # + # The aborts go through T.unsafe (a runtime no-op) because tests stub + # Kernel.abort, making the `return` after each one live — Sorbet would + # otherwise flag them as dead code after abort's T.noreturn. The return is + # T.untyped for the same reason: String in production (abort exits the + # process), nil only when abort is stubbed. + sig { params(explicit_version: T.nilable(String)).returns(T.untyped) } def resolve_ruby_version(explicit_version) version = explicit_version || detect_homebrew_ruby_version unless version - Kernel.abort("dev: No Ruby declared in dependencies.rb and Homebrew Ruby not found. Run: brew install ruby") + T.unsafe(Kernel).abort("dev: No Ruby declared in dependencies.rb and Homebrew Ruby not found. Run: brew install ruby") return end unless MIN_RUBY.satisfied_by?(Gem::Version.new(version)) - Kernel.abort("dev: Resolved Ruby #{version} is below dev's minimum (#{MIN_RUBY}). Pin a newer version in dependencies.rb or run: brew upgrade ruby") + T.unsafe(Kernel).abort("dev: Resolved Ruby #{version} is below dev's minimum (#{MIN_RUBY}). Pin a newer version in dependencies.rb or run: brew upgrade ruby") return end version end # Returns the version string of the Homebrew-installed Ruby, or nil. + sig { returns(T.nilable(String)) } def detect_homebrew_ruby_version prefix = brew_prefix_for("ruby") return nil unless prefix @@ -67,6 +84,7 @@ def detect_homebrew_ruby_version # Guarded provisioning: the O(1) provisioned? check first, so callers on # every-command paths (CommandRunner, the up/install-deps builtins) pay # nothing after the first run. + sig { params(ruby_version: String, project_root: T.any(String, Pathname)).void } def ensure!(ruby_version:, project_root:) return if provisioned?(ruby_version, project_root: project_root) @@ -75,6 +93,7 @@ def ensure!(ruby_version:, project_root:) # Returns true when .shadowenv.d/510_ruby.lisp exists and already # provisions the requested version. This is the fast-path check. + sig { params(ruby_version: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def provisioned?(ruby_version, project_root:) lisp_path = File.join(project_root.to_s, ".shadowenv.d", LISP_FILENAME) return false unless File.exist?(lisp_path) @@ -85,6 +104,7 @@ def provisioned?(ruby_version, project_root:) # Full provisioning: install Ruby via rbenv if needed (with the build deps that # guarantee the required extensions compile), verify it is not crippled, write the # lisp, trust shadowenv, ensure shell hook. Idempotent. + sig { params(ruby_version: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def setup!(ruby_version:, project_root:) root = project_root.to_s ruby_root = ensure_ruby_installed!(ruby_version) @@ -108,6 +128,7 @@ def setup!(ruby_version:, project_root:) # --- internal helpers ------------------------------------------------ + sig { params(version: String).returns(T.nilable(String)) } def find_ruby_root(version) rbenv_path = rbenv_version_prefix(version) return rbenv_path if File.directory?(rbenv_path) @@ -117,11 +138,13 @@ def find_ruby_root(version) # The prefix rbenv/ruby-build installs (or will install) the version under. # Shared by find_ruby_root and ruby_build_env so the rpath baked at build # time always names the same directory the install lands in. + sig { params(version: String).returns(String) } def rbenv_version_prefix(version) rbenv_root = ENV["RBENV_ROOT"] || File.join(ENV["HOME"] || Dir.home, ".rbenv") File.expand_path(File.join(rbenv_root, "versions", version)) end + sig { params(formula: String).returns(T.nilable(String)) } def brew_prefix_for(formula) return nil unless system("command -v brew >/dev/null 2>&1") out = IO.popen(["brew", "--prefix", formula], err: File::NULL, &:read) @@ -129,6 +152,7 @@ def brew_prefix_for(formula) (prefix && !prefix.empty? && File.directory?(prefix)) ? prefix : nil end + sig { returns(T.nilable(String)) } def path_with_brew_bin prefix = ENV["HOMEBREW_PREFIX"] prefix ||= begin @@ -143,6 +167,7 @@ def path_with_brew_bin # pre-existing install is crippled (missing a required extension), and abort with # actionable steps if it still can't be made whole. The repair path matters on a # long-lived box where a Ruby was first built before its dev libs were present. + sig { params(version: String).returns(T.nilable(String)) } def ensure_ruby_installed!(version) ruby_root = find_ruby_root(version) @@ -174,6 +199,7 @@ def ensure_ruby_installed!(version) # Install (or force-reinstall) the Ruby via rbenv, first ensuring the build-time # libraries are present and pointing ruby-build at them, so the required extensions # are compiled rather than silently skipped. + sig { params(version: String, force: T::Boolean).returns(T.nilable(T::Boolean)) } def install_ruby_with_version_manager(version, force: false) env = { "PATH" => path_with_brew_bin } return false unless system(env, "which", "rbenv", out: File::NULL, err: File::NULL) @@ -187,6 +213,7 @@ def install_ruby_with_version_manager(version, force: false) # Abort (loudly, with a fix) if the provisioned Ruby is missing a required # extension. A crippled Ruby must never pass silently to surface as a cryptic # bundler error later. + sig { params(ruby_root: String, version: String).void } def verify_extensions!(ruby_root, version) missing = missing_extensions(ruby_root) return if missing.empty? @@ -202,6 +229,7 @@ def verify_extensions!(ruby_root, version) # The subset of REQUIRED_EXTENSIONS the given Ruby cannot `require`. A Ruby whose # binary is missing entirely counts as missing all of them. + sig { params(ruby_root: String).returns(T::Array[String]) } def missing_extensions(ruby_root) ruby_bin = File.join(ruby_root, "bin", "ruby") return REQUIRED_EXTENSIONS.dup unless File.executable?(ruby_bin) @@ -211,6 +239,7 @@ def missing_extensions(ruby_root) end end + sig { params(ruby_root: String).returns(T::Boolean) } def extensions_ok?(ruby_root) missing_extensions(ruby_root).empty? end @@ -222,6 +251,7 @@ def extensions_ok?(ruby_root) # binary silently *run as* that other version. Bundler then fails the # Gemfile's ruby pin with a baffling version mismatch; this guard names the # real culprit instead. + sig { params(ruby_root: String, version: String).void } def verify_reported_version!(ruby_root, version) reported = reported_ruby_version(ruby_root) return if reported == version @@ -229,6 +259,7 @@ def verify_reported_version!(ruby_root, version) Kernel.abort(version_hijack_message(ruby_root, version, reported)) end + sig { params(ruby_root: String, version: String, reported: T.nilable(String)).returns(String) } def version_hijack_message(ruby_root, version, reported) <<~MSG dev: Ruby #{version} at #{ruby_root} runs as RUBY_VERSION #{reported.inspect}. @@ -237,6 +268,7 @@ def version_hijack_message(ruby_root, version, reported) MSG end + sig { params(ruby_root: String, version: String).returns(T::Boolean) } def reported_version_ok?(ruby_root, version) reported_ruby_version(ruby_root) == version end @@ -245,6 +277,7 @@ def reported_version_ok?(ruby_root, version) # nil when the binary is absent or fails to run. This is deliberately the # *runtime* answer, not the directory name: the two disagree exactly when # the libruby hijack described above is in effect. + sig { params(ruby_root: String).returns(T.nilable(String)) } def reported_ruby_version(ruby_root) ruby_bin = File.join(ruby_root, "bin", "ruby") return nil unless File.executable?(ruby_bin) @@ -256,6 +289,7 @@ def reported_ruby_version(ruby_root) # Best-effort install of the build-time libraries via Homebrew. A no-op when brew # is absent (e.g. an apt-only host) — verify_extensions! still guards the result. + sig { params(env: T::Hash[String, T.nilable(String)]).void } def ensure_ruby_build_deps!(env) return unless system(env, "command -v brew >/dev/null 2>&1") @@ -269,6 +303,12 @@ def ensure_ruby_build_deps!(env) # `--with-<lib>-dir` for each available formula plus the brew prefix's include/lib/ # pkgconfig, which is the documented fix for ruby-build on Linuxbrew. Returns the # env unchanged when brew isn't present. + sig do + params( + env: T::Hash[String, T.nilable(String)], + version: String, + ).returns(T::Hash[String, T.nilable(String)]) + end def ruby_build_env(env, version) prefix = homebrew_prefix return env unless prefix @@ -302,6 +342,7 @@ def ruby_build_env(env, version) # The Homebrew prefix (HOMEBREW_PREFIX, else `brew --prefix`), or nil when brew is # unavailable. + sig { returns(T.nilable(String)) } def homebrew_prefix prefix = ENV["HOMEBREW_PREFIX"] prefix ||= begin @@ -313,12 +354,14 @@ def homebrew_prefix (prefix && !prefix.empty? && File.directory?(prefix)) ? prefix : nil end + sig { params(ruby_version: String).returns(String) } def gem_api_version(ruby_version) parts = ruby_version.split(".").map(&:to_i) return "#{parts[0]}.#{parts[1]}.0" if parts.size >= 2 "#{ruby_version}.0" end + sig { params(ruby_root: String, ruby_version: String).returns(String) } def generate_ruby_lisp(ruby_root, ruby_version) gem_root = File.join(ruby_root, "lib", "ruby", "gems", gem_api_version(ruby_version)) gem_root = File.join(ruby_root, "lib", "ruby", ruby_version) unless File.directory?(gem_root) @@ -364,6 +407,7 @@ def generate_ruby_lisp(ruby_root, ruby_version) # hook lines (pre-dating the marker) are never duplicated. # # @return [Symbol, false] :added, :already_present, or false (unsupported shell) + sig { returns(T.any(Symbol, FalseClass)) } def ensure_shadowenv_shell_hook! Dev::ShellRcHook.new.ensure_snippet( marker: "# Shadowenv (added by dev)", diff --git a/lib/shadowenv_unreal.rb b/lib/shadowenv_unreal.rb index 41544e1..e369ff6 100644 --- a/lib/shadowenv_unreal.rb +++ b/lib/shadowenv_unreal.rb @@ -1,6 +1,9 @@ +# typed: strict # frozen_string_literal: true require "fileutils" +require "pathname" +require "sorbet-runtime" # Shadowenv Unreal Engine provisioning: resolves the UE engine root, generates # .shadowenv.d/530_unreal.lisp so UE_ROOT, UE_PROJECT, and engine binaries @@ -12,19 +15,26 @@ # # Skipped on Linux / CI where builds happen inside a container with UE baked in. module ShadowenvUnreal + extend T::Sig + include Kernel + LISP_FILENAME = "530_unreal.lisp" # Well-known Unreal Engine locations on macOS, checked in order. - SEARCH_PATHS = [ - File.join(Dir.home, "UnrealEngine"), - "/Users/Shared/UnrealEngine", - "/opt/unreal-engine", - ].freeze + SEARCH_PATHS = T.let( + [ + File.join(Dir.home, "UnrealEngine"), + "/Users/Shared/UnrealEngine", + "/opt/unreal-engine", + ].freeze, + T::Array[String], + ) module_function # Returns the Unreal Engine root directory, or nil if not found. # Checks: explicit env var, then well-known paths. + sig { returns(T.nilable(String)) } def detect_ue_root from_env = ENV["UE_ROOT"] return from_env if from_env && valid_ue_root?(from_env) @@ -38,6 +48,7 @@ def detect_ue_root # Returns true when .shadowenv.d/530_unreal.lisp exists and provisions # from the given UE root. + sig { params(ue_root: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def provisioned?(ue_root, project_root:) lisp_path = File.join(project_root.to_s, ".shadowenv.d", LISP_FILENAME) return false unless File.exist?(lisp_path) @@ -51,6 +62,13 @@ def provisioned?(ue_root, project_root:) # @param project_root [String, Pathname] project root directory # @param ue_root [String, nil] explicit UE engine root (falls back to detect) # @param ue_project [String, nil] path to .uproject file (optional) + sig do + params( + project_root: T.any(String, Pathname), + ue_root: T.nilable(String), + ue_project: T.nilable(String), + ).returns(T::Boolean) + end def setup!(project_root:, ue_root: nil, ue_project: nil) root = ue_root || detect_ue_root unless root @@ -73,6 +91,7 @@ def setup!(project_root:, ue_root: nil, ue_project: nil) # Returns true on Linux or when CI env is set -- environments where # UE is baked into the build container and shadowenv provisioning is # unnecessary. + sig { returns(T::Boolean) } def ci_or_linux? !!(ENV["CI"].to_s =~ /\A(true|1)\z/i) || RUBY_PLATFORM.to_s.include?("linux") end @@ -83,6 +102,7 @@ def ci_or_linux? # @param ue_root [String] Unreal Engine root directory # @param ue_project [String, nil] optional path to .uproject file # @return [String] + sig { params(ue_root: String, ue_project: T.nilable(String)).returns(String) } def generate_unreal_lisp(ue_root, ue_project: nil) bin = File.join(ue_root, "Engine", "Binaries", platform_subdir) lisp = <<~LISP @@ -101,11 +121,13 @@ def generate_unreal_lisp(ue_root, ue_project: nil) # Validates that a directory looks like a UE engine root by checking # for Engine/Build/Build.version. + sig { params(path: String).returns(T::Boolean) } def valid_ue_root?(path) File.directory?(path) && File.exist?(File.join(path, "Engine", "Build", "Build.version")) end # Returns the platform-specific binaries subdirectory. + sig { returns(String) } def platform_subdir if RUBY_PLATFORM.include?("darwin") "Mac" diff --git a/lib/shadowenv_xcode.rb b/lib/shadowenv_xcode.rb index 1ad2ae7..500190c 100644 --- a/lib/shadowenv_xcode.rb +++ b/lib/shadowenv_xcode.rb @@ -1,18 +1,25 @@ +# typed: strict # frozen_string_literal: true require "fileutils" +require "pathname" +require "sorbet-runtime" # Shadowenv Xcode provisioning: generates .shadowenv.d/520_xcode.lisp so # DEVELOPER_DIR points at the dev-pinned Xcode (see Dev::Deps::XcodeIntegration) # inside the project — xcodebuild/xcrun/UBT then ride the pin instead of # whatever xcode-select or the App Store last touched. module ShadowenvXcode + extend T::Sig + include Kernel + LISP_FILENAME = "520_xcode.lisp" module_function # Returns true when .shadowenv.d/520_xcode.lisp exists and already # provisions the given developer dir. + sig { params(developer_dir: String, project_root: T.any(String, Pathname)).returns(T::Boolean) } def provisioned?(developer_dir, project_root:) lisp_path = File.join(project_root.to_s, ".shadowenv.d", LISP_FILENAME) return false unless File.exist?(lisp_path) @@ -25,6 +32,13 @@ def provisioned?(developer_dir, project_root:) # @param project_root [String, Pathname] project root directory # @param version [String] pinned Xcode version (for the provide record) # @param developer_dir [String] .../Xcode-<ver>.app/Contents/Developer + sig do + params( + project_root: T.any(String, Pathname), + version: String, + developer_dir: String, + ).returns(T::Boolean) + end def setup!(project_root:, version:, developer_dir:) shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") FileUtils.mkdir_p(shadowenv_d) @@ -40,6 +54,7 @@ def setup!(project_root:, version:, developer_dir:) # @param version [String] # @param developer_dir [String] # @return [String] + sig { params(version: String, developer_dir: String).returns(String) } def generate_xcode_lisp(version, developer_dir) <<~LISP (provide "xcode" "#{version}") diff --git a/sorbet/tapioca/config.yml b/sorbet/tapioca/config.yml index 1aebbec..ccec220 100644 --- a/sorbet/tapioca/config.yml +++ b/sorbet/tapioca/config.yml @@ -11,6 +11,7 @@ gem: - rubocop - rubocop-ast - rubocop-shopify + - rubocop-sorbet dsl: # Add your `dsl` command parameters here: # diff --git a/src/dev/build_container_config.rb b/src/dev/build_container_config.rb index 2ce7c73..e7d6fe0 100644 --- a/src/dev/build_container_config.rb +++ b/src/dev/build_container_config.rb @@ -116,16 +116,16 @@ class BuildContainerConfig end def initialize(image:, registry:, volumes: [], build_args: {}, build_secrets: {}, run_env: {}, content_globs: [], structure_globs: [], prewarm: nil, persist: false) - @image = T.let(image, String) - @registry = T.let(registry, String) - @volumes = T.let(volumes, T::Array[String]) - @build_args = T.let(build_args, T::Hash[String, String]) - @build_secrets = T.let(build_secrets, T::Hash[String, String]) - @run_env = T.let(run_env, T::Hash[String, String]) - @content_globs = T.let(content_globs, T::Array[String]) - @structure_globs = T.let(structure_globs, T::Array[String]) - @prewarm = T.let(prewarm, T.nilable(String)) - @persist = T.let(persist, T::Boolean) + @image = image + @registry = registry + @volumes = volumes + @build_args = build_args + @build_secrets = build_secrets + @run_env = run_env + @content_globs = content_globs + @structure_globs = structure_globs + @prewarm = prewarm + @persist = persist end # Full image reference without tag (e.g. "jpduchesne89/snappy-linux"). diff --git a/src/dev/builtins/cache_command.rb b/src/dev/builtins/cache_command.rb index 553b0fa..f14aad6 100644 --- a/src/dev/builtins/cache_command.rb +++ b/src/dev/builtins/cache_command.rb @@ -25,8 +25,8 @@ def initialize( flag_parser: Cli::FlagParser.new ) super() - @cache_gc_factory = T.let(cache_gc_factory, CacheGcFactory) - @flag_parser = T.let(flag_parser, Cli::FlagParser) + @cache_gc_factory = cache_gc_factory + @flag_parser = flag_parser end sig { override.returns(String) } diff --git a/src/dev/builtins/cd_command.rb b/src/dev/builtins/cd_command.rb index af41a17..cbe4fbd 100644 --- a/src/dev/builtins/cd_command.rb +++ b/src/dev/builtins/cd_command.rb @@ -19,7 +19,7 @@ class CdCommand < BuiltinCommand sig { params(accessor: Dev::Cd::Accessor).void } def initialize(accessor: Dev::Cd::Accessor.new) super() - @accessor = T.let(accessor, Dev::Cd::Accessor) + @accessor = accessor end sig { override.returns(String) } diff --git a/src/dev/builtins/check_command.rb b/src/dev/builtins/check_command.rb index 0f43abe..44204c6 100644 --- a/src/dev/builtins/check_command.rb +++ b/src/dev/builtins/check_command.rb @@ -14,7 +14,7 @@ class CheckCommand < BuiltinCommand sig { params(dependency_service: DependencyService).void } def initialize(dependency_service:) super() - @dependency_service = T.let(dependency_service, DependencyService) + @dependency_service = dependency_service end sig { override.returns(String) } diff --git a/src/dev/builtins/clone_command.rb b/src/dev/builtins/clone_command.rb index be6f2bc..d87ea92 100644 --- a/src/dev/builtins/clone_command.rb +++ b/src/dev/builtins/clone_command.rb @@ -19,7 +19,7 @@ class CloneCommand < BuiltinCommand sig { params(accessor: Dev::Clone::Accessor).void } def initialize(accessor: Dev::Clone::Accessor.new) super() - @accessor = T.let(accessor, Dev::Clone::Accessor) + @accessor = accessor end sig { override.returns(String) } diff --git a/src/dev/builtins/cred_command.rb b/src/dev/builtins/cred_command.rb index 6ccc891..2636f2c 100644 --- a/src/dev/builtins/cred_command.rb +++ b/src/dev/builtins/cred_command.rb @@ -19,7 +19,7 @@ class CredCommand < BuiltinCommand sig { params(accessor: Dev::CredentialAccessor).void } def initialize(accessor: Dev::CredentialAccessor.new) super() - @accessor = T.let(accessor, Dev::CredentialAccessor) + @accessor = accessor end sig { override.returns(String) } diff --git a/src/dev/builtins/deps_command.rb b/src/dev/builtins/deps_command.rb index 84a1ca4..61c8f74 100644 --- a/src/dev/builtins/deps_command.rb +++ b/src/dev/builtins/deps_command.rb @@ -30,7 +30,7 @@ def initialize( } ) super() - @accessor_factory = T.let(accessor_factory, AccessorFactory) + @accessor_factory = accessor_factory end sig { override.returns(String) } diff --git a/src/dev/builtins/help_command.rb b/src/dev/builtins/help_command.rb index 52058df..0cdae38 100644 --- a/src/dev/builtins/help_command.rb +++ b/src/dev/builtins/help_command.rb @@ -27,10 +27,10 @@ class HelpCommand < BuiltinCommand end def initialize(project_name:, usage_printer:, out:, commands_provider:) super() - @project_name = T.let(project_name, String) - @usage_printer = T.let(usage_printer, Cli::UsagePrinter) - @out = T.let(out, T.any(IO, StringIO)) - @commands_provider = T.let(commands_provider, CommandsProvider) + @project_name = project_name + @usage_printer = usage_printer + @out = out + @commands_provider = commands_provider end sig { override.returns(String) } diff --git a/src/dev/builtins/install_deps_command.rb b/src/dev/builtins/install_deps_command.rb index 34143bc..b76c56b 100644 --- a/src/dev/builtins/install_deps_command.rb +++ b/src/dev/builtins/install_deps_command.rb @@ -51,9 +51,9 @@ def initialize( synchronizer: Dev::Learnings::Synchronizer.for ) super() - @installer_factory = T.let(installer_factory, InstallerFactory) - @gem_skill_linker_factory = T.let(gem_skill_linker_factory, GemSkillLinkerFactory) - @synchronizer = T.let(synchronizer, T.untyped) + @installer_factory = installer_factory + @gem_skill_linker_factory = gem_skill_linker_factory + @synchronizer = synchronizer end sig { override.returns(String) } diff --git a/src/dev/builtins/learnings_command.rb b/src/dev/builtins/learnings_command.rb index cb9733f..1b106bc 100644 --- a/src/dev/builtins/learnings_command.rb +++ b/src/dev/builtins/learnings_command.rb @@ -26,7 +26,7 @@ class LearningsCommand < BuiltinCommand sig { params(accessor_factory: AccessorFactory).void } def initialize(accessor_factory: ->(project_root) { Dev::Learnings::Accessor.new(project_root:) }) super() - @accessor_factory = T.let(accessor_factory, AccessorFactory) + @accessor_factory = accessor_factory end sig { override.returns(String) } diff --git a/src/dev/builtins/plan_command.rb b/src/dev/builtins/plan_command.rb index b62101e..56f5316 100644 --- a/src/dev/builtins/plan_command.rb +++ b/src/dev/builtins/plan_command.rb @@ -25,7 +25,7 @@ class PlanCommand < BuiltinCommand sig { params(accessor_factory: AccessorFactory).void } def initialize(accessor_factory: ->(project_root) { Dev::Plan::Accessor.new(project_root:) }) super() - @accessor_factory = T.let(accessor_factory, AccessorFactory) + @accessor_factory = accessor_factory end sig { override.returns(String) } diff --git a/src/dev/builtins/runner_setup_command.rb b/src/dev/builtins/runner_setup_command.rb index 6db1fbe..bc653c4 100644 --- a/src/dev/builtins/runner_setup_command.rb +++ b/src/dev/builtins/runner_setup_command.rb @@ -37,8 +37,8 @@ def initialize( flag_parser: Cli::FlagParser.new ) super() - @runner_setup_factory = T.let(runner_setup_factory, RunnerSetupFactory) - @flag_parser = T.let(flag_parser, Cli::FlagParser) + @runner_setup_factory = runner_setup_factory + @flag_parser = flag_parser end sig { override.returns(String) } diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index dd6a1ab..dc339e9 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -24,8 +24,8 @@ class UpCommand < BuiltinCommand end def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.new) super() - @install_deps_command = T.let(install_deps_command, InstallDepsCommand) - @hook_installer = T.let(hook_installer, Dev::Cd::HookInstaller) + @install_deps_command = install_deps_command + @hook_installer = hook_installer end sig { override.returns(String) } diff --git a/src/dev/cli/ui.rb b/src/dev/cli/ui.rb index 3552a22..a92dc78 100644 --- a/src/dev/cli/ui.rb +++ b/src/dev/cli/ui.rb @@ -26,7 +26,7 @@ class UiImpl sig { params(cli_ui: T.class_of(CLI::UI)).void } def initialize(cli_ui:) - @cli_ui = T.let(cli_ui, T.class_of(CLI::UI)) + @cli_ui = cli_ui @cli_ui.enable_color = true end diff --git a/src/dev/command.rb b/src/dev/command.rb index d4523c2..1651e09 100644 --- a/src/dev/command.rb +++ b/src/dev/command.rb @@ -125,11 +125,11 @@ class ProjectCommand end def initialize(run:, desc: "(no description)", repl: false, container: true, hidden: false) super() - @run = T.let(run, String) - @desc = T.let(desc, String) - @repl = T.let(repl, T::Boolean) - @container = T.let(container, T::Boolean) - @hidden = T.let(hidden, T::Boolean) + @run = run + @desc = desc + @repl = repl + @container = container + @hidden = hidden end sig(:final) { override.returns(T::Boolean) } @@ -176,8 +176,8 @@ class OverriddenCommand sig(:final) { params(builtin: BuiltinCommand, project: ProjectCommand).void } def initialize(builtin:, project:) super() - @builtin = T.let(builtin, BuiltinCommand) - @project = T.let(project, ProjectCommand) + @builtin = builtin + @project = project end # The override owns the slot, so its description wins — a project `up:` diff --git a/src/dev/command_executor.rb b/src/dev/command_executor.rb index 84e6efc..ec12d2c 100644 --- a/src/dev/command_executor.rb +++ b/src/dev/command_executor.rb @@ -27,9 +27,9 @@ class CommandExecutor ).void end def initialize(builtin_executor:, project_executor:, overridden_executor:) - @builtin_executor = T.let(builtin_executor, BuiltinExecutor) - @project_executor = T.let(project_executor, ProjectExecutor) - @overridden_executor = T.let(overridden_executor, OverriddenExecutor) + @builtin_executor = builtin_executor + @project_executor = project_executor + @overridden_executor = overridden_executor end # Dispatch one command to its strategy. diff --git a/src/dev/command_runner.rb b/src/dev/command_runner.rb index 826f7e3..4559eeb 100644 --- a/src/dev/command_runner.rb +++ b/src/dev/command_runner.rb @@ -54,7 +54,7 @@ class CommandFailedError < StandardError sig { params(exit_status: Integer).void } def initialize(exit_status:) - @exit_status = T.let(exit_status, Integer) + @exit_status = exit_status super("command failed with exit status #{exit_status}") end end @@ -69,7 +69,7 @@ class CommandKilledError < StandardError sig { params(signal: Integer).void } def initialize(signal:) - @signal = T.let(signal, Integer) + @signal = signal super("command killed by signal #{signal}") end end @@ -95,11 +95,11 @@ def initialize ).void end def initialize(ui:, ruby_version:, project_root:, python_version: nil, build_container: nil) - @ui = T.let(ui, Dev::Cli::Ui) - @ruby_version = T.let(ruby_version, String) - @python_version = T.let(python_version, T.nilable(String)) - @build_container = T.let(build_container, T.nilable(Dev::BuildContainerConfig)) - @project_root = T.let(project_root, Pathname) + @ui = ui + @ruby_version = ruby_version + @python_version = python_version + @build_container = build_container + @project_root = project_root end # Hand the process over to the command: exec-replace, the right shape diff --git a/src/dev/command_service.rb b/src/dev/command_service.rb index 025126c..3381bfb 100644 --- a/src/dev/command_service.rb +++ b/src/dev/command_service.rb @@ -23,9 +23,9 @@ class CommandService ).void end def initialize(repository:, executor:, dependency_service:) - @repository = T.let(repository, CommandRepository) - @executor = T.let(executor, CommandExecutor) - @dependency_service = T.let(dependency_service, DependencyService) + @repository = repository + @executor = executor + @dependency_service = dependency_service end # Run one command end to end. diff --git a/src/dev/dependency_service.rb b/src/dev/dependency_service.rb index d015065..eb63627 100644 --- a/src/dev/dependency_service.rb +++ b/src/dev/dependency_service.rb @@ -19,7 +19,7 @@ class StaleDependencyStateError < RuntimeError; end sig { params(staleness: Dev::Deps::Staleness).void } def initialize(staleness:) - @staleness = T.let(staleness, Dev::Deps::Staleness) + @staleness = staleness end # All current staleness messages (see Dev::Deps::Staleness#messages). diff --git a/src/dev/global_dispatch.rb b/src/dev/global_dispatch.rb index 4841b37..afec3aa 100644 --- a/src/dev/global_dispatch.rb +++ b/src/dev/global_dispatch.rb @@ -72,10 +72,10 @@ class GlobalDispatch def initialize(cd_accessor: Dev::Cd::Accessor.new, clone_accessor: Dev::Clone::Accessor.new, cred_accessor: Dev::CredentialAccessor.new, usage_printer: Dev::Cli::GlobalUsagePrinter.new) - @cd_accessor = T.let(cd_accessor, Dev::Cd::Accessor) - @clone_accessor = T.let(clone_accessor, Dev::Clone::Accessor) - @cred_accessor = T.let(cred_accessor, Dev::CredentialAccessor) - @usage_printer = T.let(usage_printer, Dev::Cli::GlobalUsagePrinter) + @cd_accessor = cd_accessor + @clone_accessor = clone_accessor + @cred_accessor = cred_accessor + @usage_printer = usage_printer end # Whether the argv is dispatched here, before any dev.yml lookup: a diff --git a/src/dev/overridden_executor.rb b/src/dev/overridden_executor.rb index 0ed45ca..4e337ec 100644 --- a/src/dev/overridden_executor.rb +++ b/src/dev/overridden_executor.rb @@ -18,8 +18,8 @@ class OverriddenExecutor # @param project_executor [ProjectExecutor] sig { params(builtin_executor: BuiltinExecutor, project_executor: ProjectExecutor).void } def initialize(builtin_executor:, project_executor:) - @builtin_executor = T.let(builtin_executor, BuiltinExecutor) - @project_executor = T.let(project_executor, ProjectExecutor) + @builtin_executor = builtin_executor + @project_executor = project_executor end # Run the builtin stage, then the project tail. The tail's message diff --git a/src/dev/project_executor.rb b/src/dev/project_executor.rb index cb8499b..faa94d3 100644 --- a/src/dev/project_executor.rb +++ b/src/dev/project_executor.rb @@ -23,7 +23,7 @@ class ExecReturnedError < StandardError; end # root from the run's ExecutionContext sig { params(command_runner: CommandRunner).void } def initialize(command_runner:) - @command_runner = T.let(command_runner, CommandRunner) + @command_runner = command_runner end # Hand the process over to the project command: exec-replace, the right diff --git a/src/dev/project_manifest_loader.rb b/src/dev/project_manifest_loader.rb index a8aaab2..d24b037 100644 --- a/src/dev/project_manifest_loader.rb +++ b/src/dev/project_manifest_loader.rb @@ -35,7 +35,7 @@ class UnsupportedDevYamlRubyError < RuntimeError; end sig { params(command_parser: CommandParser).void } def initialize(command_parser: CommandParser.new) - @command_parser = T.let(command_parser, CommandParser) + @command_parser = command_parser end # Parse dev.yml into a manifest (toolchain fields left nil — see diff --git a/src/dev/runner.rb b/src/dev/runner.rb index a641e94..10e6130 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -45,11 +45,11 @@ def initialize( manifest_loader: ProjectManifestLoader.new, command_service: nil ) - @ui = T.let(ui, Dev::Cli::Ui) - @out = T.let(out, T.any(IO, StringIO)) - @manifest_loader = T.let(manifest_loader, ProjectManifestLoader) + @ui = ui + @out = out + @manifest_loader = manifest_loader @manifest = T.let(manifest_loader.load(dev_yaml_path), ProjectManifest) - @command_service = T.let(command_service, T.nilable(CommandService)) + @command_service = command_service end # Runs the dev command specified by the given argv. diff --git a/src/dev/runner_setup_config.rb b/src/dev/runner_setup_config.rb index 9f0f7d4..5b42afd 100644 --- a/src/dev/runner_setup_config.rb +++ b/src/dev/runner_setup_config.rb @@ -43,10 +43,10 @@ class RunnerSetupConfig ).void end def initialize(labels:, dir: nil, name: nil, version: nil) - @labels = T.let(labels, String) - @dir = T.let(dir, T.nilable(String)) - @name = T.let(name, T.nilable(String)) - @version = T.let(version, T.nilable(String)) + @labels = labels + @dir = dir + @name = name + @version = version end sig { params(other: Object).returns(T::Boolean) } diff --git a/test/dev/deps/pip_integration_test.rb b/test/dev/deps/pip_integration_test.rb index c1c824d..ce1a61d 100644 --- a/test/dev/deps/pip_integration_test.rb +++ b/test/dev/deps/pip_integration_test.rb @@ -20,8 +20,11 @@ def dep(name, version) tmpdir = Dir.mktmpdir("pip-integration-") integration = Dev::Deps::PipIntegration.new(repository: nil, cache: nil, project_root: tmpdir, python_version: "3.12") - Expect "nothing is installed and no venv is required" - integration.install_all([]).nil? + When "installing an empty dep list" + integration.install_all([]) + + Then "nothing is installed and no venv is created" + Dir.children(tmpdir).empty? Cleanup FileUtils.rm_rf(tmpdir) diff --git a/test/dev/deps/repository_test.rb b/test/dev/deps/repository_test.rb index bbdd925..7b73c9f 100644 --- a/test/dev/deps/repository_test.rb +++ b/test/dev/deps/repository_test.rb @@ -11,7 +11,7 @@ class Dev::Deps::RepositoryTest < Minitest::Test repo = Dev::Deps::Repository.new When "fetching a dependency" - repo.fetch("boost>=1.0") + repo.fetch({ "name" => "boost", "constraint" => ">=1.0" }) Then raises NotImplementedError diff --git a/test/test_helper.rb b/test/test_helper.rb index e162810..1b9b205 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,4 @@ +# typed: false # frozen_string_literal: true DEV_ROOT = File.expand_path("..", __dir__) unless defined?(DEV_ROOT) diff --git a/test/test_loader.rb b/test/test_loader.rb index 6af5f5a..d590cd5 100644 --- a/test/test_loader.rb +++ b/test/test_loader.rb @@ -1,3 +1,4 @@ +# typed: false # frozen_string_literal: true # Entry point when running tests (-r test_loader). Follows RSpock convention: load path, rspock, pry, then ASTTransform.