diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fe602b..fc7c54a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,4 +70,5 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage/coverage.xml + disable_search: true fail_ci_if_error: false diff --git a/.rubocop.yml b/.rubocop.yml index 2fd7261..4e44765 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,43 @@ 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 + # ...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..2bcbaaf 100644 --- a/Gemfile +++ b/Gemfile @@ -13,11 +13,15 @@ gem "rspock", "~> 3.0" gem "minitest" gem "minitest-reporters" gem "rake" -gem "simplecov", "~> 0.22" -# Codecov can't process SimpleCov's JSON once a `# :nocov:` line appears +gem "simplecov", "~> 1.0" +# Codecov can't process SimpleCov's JSON once skipped lines appear # (the "ignored" value breaks its parser — codecov/engineering-team#3592), # so CI uploads the cobertura report instead. -gem "simplecov-cobertura", "~> 3.0" +gem "simplecov-cobertura", "~> 4.0" +# Skips type-level Sorbet constructs (sig blocks, T.type_alias, T.absurd) +# so they never read as coverage misses — Sorbet already checks them +# statically; line coverage on them measures nothing. +gem "simplecov-sorbet", "~> 0.2", require: false # bin/console gem "pry", "~> 0.14" @@ -25,6 +29,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..91eadfa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,7 +21,6 @@ GEM cli-ui (2.7.0) coderay (1.1.3) diff-lcs (2.0.0) - docile (1.4.1) erubi (1.13.1) io-console (0.8.2) json (2.21.1) @@ -86,17 +85,19 @@ 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) - docile (~> 1.1) - simplecov-html (~> 0.11) - simplecov_json_formatter (~> 0.1) - simplecov-cobertura (3.2.0) + simplecov (1.1.1) + simplecov-cobertura (4.0.0) rexml - simplecov (~> 0.19) - simplecov-html (0.13.2) - simplecov_json_formatter (0.1.4) + simplecov (~> 1.0) + simplecov-sorbet (0.2.0) + ast_transform (~> 3.1) + simplecov (~> 1.0) + sorbet-runtime sorbet (0.6.12971) sorbet-static (= 0.6.12971) sorbet-runtime (0.6.12971) @@ -154,8 +155,10 @@ DEPENDENCIES rbs (~> 4.0.0.dev.5) rspock (~> 3.0) rubocop-shopify (~> 3.0) - simplecov (~> 0.22) - simplecov-cobertura (~> 3.0) + rubocop-sorbet (~> 0.10) + simplecov (~> 1.0) + simplecov-cobertura (~> 4.0) + simplecov-sorbet (~> 0.2) sorbet tapioca @@ -170,7 +173,6 @@ CHECKSUMS coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b dev (0.2.79) diff-lcs (2.0.0) sha256=708a5d52ec2945b50f8f53a181174aa1ef2c496edf81c05957fe956dabb363d5 - docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 @@ -200,12 +202,12 @@ 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 - simplecov-cobertura (3.2.0) sha256=70d702658677fcb20e5aceb6915ccf8bc62ff2ccd38b62b3ad5c9db5c0888740 - simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 - simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + simplecov (1.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78 + simplecov-cobertura (4.0.0) sha256=e4fb3159b1ecea545b44f5452a8611305323e78ad23eae8aed35924d072e01ea + simplecov-sorbet (0.2.0) sha256=dd1bc785f28289641dc9a021402104dac5c683ea19ca4fa30d60a95be4fd77c6 sorbet (0.6.12971) sha256=2b22b9dc976ac4f3d5af27e0365c9b0235fca0244ec644f4f52f9e7bb6b67acc sorbet-runtime (0.6.12971) sha256=1c2c75a262f88c4fbdb36b5617b0b11bfc7c69b11a500b3334bd67d075288a45 sorbet-static (0.6.12971-aarch64-linux) sha256=f247bb625cd50238fea2ee7c4328f079869bbef8cdba17da72fc0e21db528bfb 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/docs/cli-ui-generalization-plan.md b/docs/cli-ui-generalization-plan.md index c74d685..1b9f435 100644 --- a/docs/cli-ui-generalization-plan.md +++ b/docs/cli-ui-generalization-plan.md @@ -8,7 +8,7 @@ While investigating, two latent bugs surfaced and are already fixed: - **`content_tag` Errno::EISDIR** — a recursive `content_globs` (`bin/image/**/*`) matched the `bin/image/lib` directory; `content_tag` now skips non-files - before reading. (`lib/build_container.rb`, test added.) + before reading. (`lib/dev/build_container.rb`, test added.) - **`release.rb` clobbered resource sha256s** — a `gsub!` over every `sha256 "..."` overwrote vendored-gem resource checksums with the tarball sha; now anchored to the package url+sha pair. (`bin/release.rb`.) diff --git a/lib/build_container.rb b/lib/build_container.rb deleted file mode 100644 index 7126fda..0000000 --- a/lib/build_container.rb +++ /dev/null @@ -1,615 +0,0 @@ -# frozen_string_literal: true - -require "digest" -require "pathname" -require "securerandom" -require "tmpdir" -require "yaml" - -require "build_watcher" - -# Content-addressed Docker image management for build containers. -# -# Computes a tag from the hash of Dockerfile + .dockerignore + lockfiles -# (deps.lock, build-deps.lock) plus any project-declared content globs. Any -# change to those inputs produces a new tag, guaranteeing a rebuild. -# -# Usage: -# BuildContainer.ensure_image!(config, project_root: Pathname("...")) -# # pulls or builds the image, returns the full image:tag string -# -# BuildContainer.content_tag(project_root: Pathname("...")) -# # returns the content-addressed tag without side effects -module BuildContainer - # 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). - CONTENT_FILES = ["Dockerfile", ".dockerignore", "deps.lock", "build-deps.lock"].freeze - TAG_PREFIX = "content-" - BUILD_DEPS_LOCK = "build-deps.lock" - # Both lockfiles are scanned for version-keyed install_dir resolution: gh - # build deps (e.g. the engine) land in build-deps.lock, while integration - # deps (e.g. the Satisfactory server) land in deps.lock. - LOCKFILES = ["deps.lock", "build-deps.lock"].freeze - - module_function - - # Compute the content-addressed tag from Dockerfile + lockfiles + globs. - # - # @param project_root [Pathname] project root containing Dockerfile etc. - # @param extra_globs [Array] additional project-relative globs whose - # matched files contribute to the hash (path + content), e.g. a mod's - # *.Build.cs. Sorted for determinism; missing matches contribute nothing. - # @param structure_globs [Array] project-relative globs whose matched - # *paths* (not contents) contribute to the hash. Use for inputs where the set - # of matching files is structural but their contents are not, e.g. one - # *.Build.cs per build module: adding/removing a module changes the path set - # (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" - def content_tag(project_root:, extra_globs: [], structure_globs: []) - root = Pathname(project_root) - file_content = CONTENT_FILES - .map { |f| root / f } - .select(&:exist?) - .map(&:read) - .join - - # A recursive glob (e.g. "bin/image/**/*") also matches directories; hash - # only files. The files under a matched dir are matched in their own right, - # so skipping the dir entry loses nothing — and avoids Errno::EISDIR on read. - glob_content = extra_globs - .flat_map { |pattern| Dir.glob(pattern, base: root.to_s) } - .uniq - .select { |rel| (root / rel).file? } - .sort - .map { |rel| "#{rel}\n#{(root / rel).read}" } - .join - - # Paths only: the *existence* of these files matters, not their contents. - structure_content = structure_globs - .flat_map { |pattern| Dir.glob(pattern, base: root.to_s) } - .uniq - .sort - .join("\n") - - hash = Digest::SHA256.hexdigest(file_content + glob_content + structure_content)[0, 12] - "#{TAG_PREFIX}#{hash}" - end - - # Full image reference with content-addressed tag. - # - # @param config [Dev::BuildContainerConfig] - # @param project_root [Pathname] - # @return [String] e.g. "jpduchesne89/snappy-linux:content-a1b2c3d4e5f6" - 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 : [] - "#{config.image_ref}:#{content_tag(project_root:, extra_globs: globs, structure_globs:)}" - end - - # Ensure the build container image exists: use a local image if present, - # pull from registry if available, otherwise build. Returns the full - # image:tag string. - # - # The local check comes first so images built manually are honored. - # - # build_args_provider / secrets_provider are lazy sources of docker - # --build-arg and BuildKit --secret values (e.g. credentials). They are only - # called on a cache miss so cache hits never trigger credential resolution or - # prompts. - # - # On a cache miss, every `group: build` dependency in build-deps.lock that - # declares an install_dir is passed as a BuildKit named build-context (keyed - # by dependency name), so the Dockerfile can bind-mount large host artifacts - # (e.g. the engine) without baking them into the image. - # - # Publishing (`publish: true`) is the *provisioning* guarantee: after the - # image is resolved by any path — built OR found locally — it is published to - # the shared registry so other machines can pull it. The local-hit case is - # the one that matters: the machine that originally built the image (e.g. the - # CI runner) keeps hitting its own local copy on every run, so without - # publish-on-hit the registry it is meant to populate stays empty forever and - # no other machine can ever pull. `push:` (legacy) only pushes a freshly built - # image; `publish:` subsumes it and additionally covers the local hit, so the - # provisioning step sets `publish: true` while build/run steps leave both off. - # - # @param config [Dev::BuildContainerConfig] - # @param project_root [Pathname] - # @param push [Boolean] whether to push a freshly built image (default: true) - # @param publish [Boolean] whether to publish the resolved image to the - # registry, even on a local hit (default: false — only the provisioning step opts in) - # @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 - def ensure_image!(config, project_root:, push: true, publish: false, - build_args_provider: nil, secrets_provider: nil) - tag = image_with_tag(config, project_root:) - - if local_image?(tag) - $stderr.puts "dev: Container image found locally — #{tag}" - publish!(tag) if publish - return tag - end - - if pull(tag) - $stderr.puts "dev: Container image cache hit — #{tag}" - return tag - end - - $stderr.puts "dev: Container image cache miss — building #{tag}" - build_args = build_args_provider ? build_args_provider.call : {} - secrets = secrets_provider ? secrets_provider.call : {} - - prewarm = config.respond_to?(:prewarm) ? config.prewarm : nil - if prewarm - build_and_prewarm!(tag, config:, project_root:, build_args:, secrets:, prewarm:) - else - build_contexts = build_contexts_from_lockfile(project_root) - build!(tag, project_root:, build_args:, build_contexts:, secrets:) - end - - push!(tag) if push - publish!(tag) if publish - tag - end - - # Two-phase image creation for prewarmed images: build a cheap base from the - # Dockerfile, then run the prewarm command in a container with the build-dep - # volumes mounted and secrets delivered as files, and commit the result to the - # content-addressed tag. - # - # Why not a single `docker build` with the dependency as a BuildKit - # build-context? BuildKit *streams* a build-context from the client on demand; - # for a large, randomly-read dependency (e.g. a ~30GB engine read during - # compilation) that transport stalls/deadlocks, especially under emulation. A - # plain `-v` volume (virtiofs on Docker Desktop) is the robust path the runtime - # already uses, so the prewarm reuses it. - # - # @param tag [String] final content-addressed tag to commit - # @param config [Dev::BuildContainerConfig] - # @param project_root [Pathname] - # @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 - 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 - # secrets. Those are supplied to the prewarm run, not the Dockerfile. - build!(base_tag, project_root:, build_args:, build_contexts: {}, secrets: {}) - volumes = resolve_versioned_volumes(config.volumes, project_root:) - 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) - end - - # Named build-contexts derived from build-deps.lock: every build-group - # dependency with an install_dir becomes "=". - # The context name is lowercased because Docker rejects uppercase build-context - # names ("invalid reference format"); the Dockerfile references the lowercased - # name (e.g. `--mount=from=unrealengine`). Returns {} when the lockfile is - # absent or has no such deps. - # - # @param project_root [Pathname] - # @return [Hash{String => String}] context name => absolute host path - def build_contexts_from_lockfile(project_root) - path = Pathname(project_root) / BUILD_DEPS_LOCK - return {} unless path.exist? - - yaml = YAML.safe_load(path.read, permitted_classes: [Symbol]) || {} - - contexts = {} - yaml.each do |name, attrs| - next if name == "env" # env-scoped deps are not whole-image build inputs - next unless attrs.is_a?(Hash) - next unless attrs["group"] == "build" && attrs["install_dir"] - - base = File.expand_path(attrs["install_dir"]) - # Point at the version-keyed subdir the integration publishes to, so the - # build context tracks the locked version (see resolve_versioned_volumes). - contexts[name.downcase] = attrs["version"] ? File.join(base, attrs["version"].to_s) : base - end - contexts - end - - # Rewrite each "host:container[:opts]" volume whose host path is a locked - # dependency's install_dir to its version-keyed subdir (install_dir/, - # the immutable directory the integration publishes). This is how a command - # mounts the exact locked version while the integration keeps every version - # side by side. Volumes that don't match a locked install_dir (e.g. the shared - # cache mount) pass through unchanged. - # - # @param volumes [Array] configured "host:container[:opts]" specs - # @param project_root [Pathname] - # @return [Array] specs with matching host paths version-resolved - 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 ? "#{host}/#{version}:#{container}" : spec - end - end - - # Map every locked dependency install_dir (expanded) to its locked version, - # scanning both lockfiles (including env-nested build deps). Only entries with - # BOTH an install_dir and a version contribute. - # - # @param project_root [Pathname] - # @return [Hash{String => String}] expanded install_dir => version - def install_dir_versions(project_root) - root = Pathname(project_root) - LOCKFILES.each_with_object({}) do |file, acc| - path = root / file - next unless path.exist? - - yaml = YAML.safe_load(path.read, permitted_classes: [Symbol]) || {} - collect_install_dir_versions(yaml, acc) - end - end - - # Recursively collect {expanded install_dir => version} from a lockfile hash, - # descending into the nested env: section of build-deps.lock. - # - # @param yaml [Hash] - # @param acc [Hash{String => String}] accumulator (mutated) - # @return [void] - def collect_install_dir_versions(yaml, acc) - yaml.each do |name, attrs| - next unless attrs.is_a?(Hash) - - if name == "env" - attrs.each_value { |env_deps| collect_install_dir_versions(env_deps, acc) } - elsif attrs["install_dir"] && attrs["version"] - acc[File.expand_path(attrs["install_dir"])] = attrs["version"].to_s - end - end - end - - # Build a docker run command for executing a shell command inside the container. - # - # @param image_tag [String] full image:tag reference - # @param project_root [Pathname] project root to mount - # @param shell_cmd [String] command to run inside the container - # @param volumes [Array] extra "host:container" mounts; host - # 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 - def docker_run_command(image_tag, project_root:, shell_cmd:, volumes: [], env: {}) - env_flags = env.flat_map { |name, value| ["-e", "#{name}=#{value}"] } - - [ - "docker", "run", "--rm", - "-v", "#{project_root}:/project", - *volume_flags(volumes), - *env_flags, - "-w", "/project", - image_tag, - "sh", "-c", shell_cmd, - ] - end - - # "host:container" volume specs -> docker `-v` flags, expanding ~ in the host - # path (e.g. "~/.dev/engines/...:/ue"). - # - # @param volumes [Array] - # @return [Array] - def volume_flags(volumes) - volumes.flat_map do |spec| - host, container = spec.split(":", 2) - ["-v", "#{File.expand_path(host)}:#{container}"] - end - end - - # --- persistent service container (build.container.persist) ---------- - - # Ensure the long-lived service container for image_tag exists and is running, - # reaping any container left from a previous image tag of the same project. - # Idempotent: a no-op when the right container is already up. - # - # The container idles on `sleep infinity` so commands run against it via - # `docker exec` (see docker_exec_command). Its writable layer — and thus an - # incremental build tool's state written on top of the image — survives - # between commands, which a fresh `docker run --rm` would discard. - # - # @param image_tag [String] full image:tag the container runs - # @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 - def ensure_service!(image_tag, project_root:, volumes: []) - name = service_container_name(image_tag, project_root) - reap_stale_services!(image_tag, project_root) - - if container_exists?(name) - start_container(name) unless container_running?(name) - else - create_service_container(name, image_tag, project_root:, volumes:) - end - name - end - - # Build a `docker exec` command running shell_cmd inside the service container, - # mirroring docker_run_command's working dir (/project) and `-e` env handling. - # - # @param container [String] running container name - # @param shell_cmd [String] - # @param env [Hash{String => String}] env vars to inject via `-e` - # @return [Array] docker exec command array - 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] - end - - # Remove every service container for this checkout — the current tag's and any - # stale one — backing `dev reset-container`. Keyed by the image + workspace - # prefix (not the exact tag) so a container from a now-superseded Dockerfile/dep - # is still matched, while OTHER checkouts' containers are left untouched. - # - # @param image_tag [String] - # @param project_root [Pathname] the checkout whose containers to remove - # @return [Array] names of the removed containers - def reset_service!(image_tag, project_root) - names = service_containers(service_name_prefix(image_tag, project_root)) - names.each { |name| remove_container(name) } - names - end - - # Run the prewarm command in a container off the base image and commit the - # result to final_tag. Build-dep volumes (e.g. the engine) are mounted with - # `-v`; secrets are written to host temp files and bind-mounted at - # /run/secrets/ (a bind mount, so the value is never captured by - # `docker commit`, which only persists the container's writable layer). - # Secrets are deliberately NOT passed via `-e`, since `docker commit` would - # bake run-time env into the committed image config. - # - # @param base_tag [String] - # @param final_tag [String] - # @param volumes [Array] resolved "host:container" build-dep mounts - # (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 - def prewarm_commit!(base_tag, final_tag, volumes:, prewarm:, secrets:) - container = prewarm_container_name - secret_files = write_secret_files(secrets) - secret_mounts = secret_files.flat_map { |id, path| ["-v", "#{path}:/run/secrets/#{id}:ro"] } - - run_argv = [ - "docker", "run", "--name", container, - *volume_flags(volumes), - *secret_mounts, - base_tag, - "sh", "-c", prewarm, - ] - - 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) - secret_files&.each_value { |path| File.delete(path) if File.exist?(path) } - end - - # Run the prewarm docker command under the hung-build watcher, which detects - # the Rosetta clang deadlock (silent, idle container) and retries transient - # crashes while failing fast on real compile errors. Isolated here so callers - # (and tests) treat it as a single boundary. - # - # @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 - def run_watched(argv, container:) - BuildWatcher.new(container_name: container).run(argv) - end - - # Write each secret value to a private host temp file for bind-mounting into - # the prewarm container. Returns {id => path}; caller deletes the files. - # - # @param secrets [Hash{String => String}] - # @return [Hash{String => String}] secret id => temp file path - def write_secret_files(secrets) - secrets.each_with_object({}) do |(id, value), files| - path = File.join(Dir.tmpdir, "dev-secret-#{SecureRandom.hex(8)}") - File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |f| f.write(value) } - files[id] = path - end - end - - # --- internal helpers ------------------------------------------------ - - # Unique name for the throwaway prewarm container; pid + random suffix so - # concurrent dev invocations never collide. - def prewarm_container_name - "dev-prewarm-#{Process.pid}-#{rand(1_000_000)}" - end - - def remove_image(image_tag) - system("docker", "image", "rm", "-f", image_tag, out: File::NULL, err: File::NULL) - end - - # Container name for image_tag + workspace: "dev---", - # registry dropped and any char Docker forbids in a name (notably ':') replaced - # with '-'. E.g. "reg/snappy-linux:content-abc" in /work/snappy -> - # "dev-snappy-linux-9f86d08-content-abc". - # - # The segment is what keys the persistent container to the checkout - # it is bind-mounted to. Without it the container is keyed by image tag ALONE, - # so a SECOND checkout of the same project (e.g. a CI runner's actions/checkout - # vs. a manual clone elsewhere on the same machine) finds the first checkout's - # container by name and reuses it — still bind-mounted to the FIRST checkout — - # 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. - def service_container_name(image_tag, project_root) - image = image_basename(image_tag) - tag = 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-". - 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". - def image_basename(image_tag) - 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. - 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] - end - - def sanitize_container_name(str) - str.gsub(/[^a-zA-Z0-9_.-]/, "-") - end - - # Remove service containers for this checkout that don't match the current - # 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. - 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| - remove_container(name) unless name == keep - end - end - - # Names of existing containers (running or stopped) whose name matches the - # project prefix. `^` anchors the regex name filter to the start. - def service_containers(prefix) - out = `docker ps -a --filter name=^#{prefix} --format {{.Names}}` - out.split("\n").map(&:strip).reject(&:empty?) - end - - def container_exists?(name) - system("docker", "container", "inspect", name, out: File::NULL, err: File::NULL) - end - - def container_running?(name) - `docker container inspect -f {{.State.Running}} #{name} 2>/dev/null`.strip == "true" - end - - def start_container(name) - system("docker", "start", name, out: File::NULL, err: File::NULL) - end - - # 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`. - def create_service_container(name, image_tag, project_root:, volumes: []) - success = system( - "docker", "run", "-d", "--name", name, - "-v", "#{project_root}:/project", - *volume_flags(volumes), - "-w", "/project", - image_tag, - "sleep", "infinity", - out: File::NULL, err: File::NULL, - ) - raise "Failed to create service container #{name}" unless success - end - - def remove_container(name) - system("docker", "rm", "-f", name, out: File::NULL, err: File::NULL) - end - - def local_image?(image_tag) - system("docker", "image", "inspect", image_tag, out: File::NULL, err: File::NULL) - end - - # Stream pull progress to stdout so a multi-GB pull (e.g. a 20GB build image - # 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. - def pull(image_tag) - system("docker", "pull", image_tag, err: File::NULL) - end - - # Build the image with BuildKit. build_contexts are passed as - # `--build-context name=path` (host artifacts bind-mounted at build time, - # never stored in the image). secrets are passed as `--secret id=NAME,env=NAME` - # with the value exported into docker's environment for that invocation, so the - # value is mounted only for the requesting RUN and never persists in a layer. - # - # @param image_tag [String] - # @param project_root [Pathname] - # @param build_args [Hash{String => String}] - # @param build_contexts [Hash{String => String}] context name => host path - # @param secrets [Hash{String => String}] secret id => value - 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}"] } - secret_flags = secrets.keys.flat_map { |id| ["--secret", "id=#{id},env=#{id}"] } - - # BuildKit is required for --build-context and --secret; enable it explicitly - # so the build behaves the same regardless of the host Docker default. Secret - # values travel via the environment (referenced by env=), never on argv. - env = { "DOCKER_BUILDKIT" => "1" }.merge(secrets) - - # --progress=plain: BuildKit's auto renderer detects non-TTY stdout (CI log - # 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, - "docker", "build", "--progress=plain", "-t", image_tag, - *arg_flags, *context_flags, *secret_flags, - project_root.to_s, - ) - 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. - def push!(image_tag) - system("docker", "push", image_tag) - end - - # Guarantee the shared registry advertises this content tag, so other - # machines pull it instead of rebuilding. A no-op when the registry already - # has the tag (checked via a remote manifest lookup that transfers only - # metadata, never the multi-GB layers), otherwise pushes the local image. - # - # Best-effort: a push failure is logged, not raised, so a transient registry - # hiccup never fails an otherwise-green build — the next provisioning run - # retries, since the registry still lacks the tag. - # - # @param image_tag [String] - # @return [Boolean] whether the registry has the tag after this call - def publish!(image_tag) - if registry_has?(image_tag) - $stderr.puts "dev: Container image already published — #{image_tag}" - return true - end - - $stderr.puts "dev: Publishing container image to registry — #{image_tag}" - pushed = push!(image_tag) - $stderr.puts "dev: WARNING — could not publish #{image_tag} to the registry" unless pushed - pushed - end - - # Whether the registry already advertises image_tag. `docker manifest inspect` - # queries the remote registry for the tag's manifest only (no layer - # download), so this is a cheap existence check. - # - # @param image_tag [String] - # @return [Boolean] - def registry_has?(image_tag) - system("docker", "manifest", "inspect", image_tag, out: File::NULL, err: File::NULL) - end -end diff --git a/lib/build_watcher.rb b/lib/build_watcher.rb deleted file mode 100644 index adf65c2..0000000 --- a/lib/build_watcher.rb +++ /dev/null @@ -1,219 +0,0 @@ -# frozen_string_literal: true - -require "open3" - -# Runs a long containerized build with hung-build detection and bounded retries. -# -# The base-game compile deadlocks intermittently under Rosetta: a clang++ worker -# crashes, leaves a zombie, and UnrealBuildTool waits forever on a child that -# will never report — the container sits at ~0% CPU producing no output. A plain -# `system(docker run …)` would block indefinitely. -# -# This watcher distinguishes three terminal situations and reacts to each, so a -# transient emulation flake self-heals while a genuine compile error still fails -# fast (no infinite retry loop): -# -# - exit 0 -> success -# - alive, stalled (no output for -# stall_after AND container CPU -# ~0%) -> a hang: kill the container and retry -# - non-zero exit: -# * Rosetta/clang crash sig -> a transient crash: retry -# * real compile-error sig -> fail fast (don't retry) -# * neither -> fail fast (surface the unknown failure) -# -# Retries are capped (max_attempts) and rely on the build tool's atomic -# intermediate writes, so a retry resumes incrementally rather than from scratch. -# -# 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 - # 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). - DEFAULT_STALL_AFTER = 300 - # Container CPU percent at or below which it counts as "doing nothing". - DEFAULT_CPU_FLOOR = 5.0 - # How often to probe liveness/CPU while the build runs. - DEFAULT_POLL = 15 - # Total attempts before giving up (fail fast on a genuinely broken build). - DEFAULT_MAX_ATTEMPTS = 5 - - # Crash signatures worth retrying (flaky Rosetta/clang emulation failures). - CRASH_SIGNATURES = [ - /rosetta error/i, - /segmentation fault/i, - /caught signal/i, - /clang\+\+.*(?:crashed|terminated|killed|signal)/i, - /llvm error/i, - /internal compiler error|\bICE\b/i, - /unable to spawn process|posix_spawn failed/i, - ].freeze - - # Genuine compile/link errors — fail fast, retrying won't help. - COMPILE_ERROR_SIGNATURES = [ - /(?:^|\s)error:\s/i, - /fatal error:/i, - /undefined reference to/i, - /ld(?:\.lld)?:\s*error/i, - /\bUnrealBuildTool\b.*\bERROR\b/, - ].freeze - - Result = Struct.new(:outcome, :output) # outcome: :success | :stalled | :failed - - # @param container_name [String] the `docker run --name` of the watched build, - # so a stall can be killed by name - # @param stall_after [Integer] seconds of no output before stall-eligible - # @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 - 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 - @stall_after = stall_after - @cpu_floor = cpu_floor - @poll = poll - @max_attempts = max_attempts - @out = out - end - - # Run argv with stall detection and bounded retries. - # - # @param argv [Array] the docker run command to execute - # @return [Boolean] true if a run succeeded within the attempt budget - def run(argv) - @max_attempts.times do |attempt| - result = run_once(argv) - return true if result.outcome == :success - - reason = retry_reason(result) - return false unless reason - - @out.puts ">>> build-watcher: #{reason} (attempt #{attempt + 1}/#{@max_attempts}); retrying" - end - @out.puts ">>> build-watcher: giving up after #{@max_attempts} attempts" - false - end - - # Whether a still-running build looks hung: silent long enough AND idle CPU. - # Both are required so a legitimately slow (but working) compile action — which - # keeps the CPU busy — is never killed. - # - # @param idle_seconds [Numeric] seconds since the last build output - # @param cpu_percent [Numeric] current container CPU percent - # @return [Boolean] - def stalled?(idle_seconds:, cpu_percent:) - idle_seconds >= @stall_after && cpu_percent <= @cpu_floor - end - - # Classify a failed (non-zero) run's output. Crash signatures take precedence - # over compile-error signatures: a Rosetta crash often *also* prints a cascade - # "error:", but it's the transient cause we should retry; a real compile error - # has no crash signature. - # - # @param output [String] captured combined output - # @return [Symbol] :retry (transient) or :fail (genuine) - def classify_failure(output) - return :retry if CRASH_SIGNATURES.any? { |re| output.match?(re) } - - :fail # compile error or unknown: don't loop on a broken build - end - - private - - # @param result [Result] - # @return [String, nil] human reason to retry, or nil to stop - 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 - - nil - end - - # Spawn the build, stream + capture its output, and watch for a stall. - # Isolated as the single OS-touching seam so the retry/classify policy can be - # tested without real processes. - # - # @param argv [Array] - # @return [Result] - def run_once(argv) - free_container_name - last_output = now - captured = +"" - - Open3.popen2e(*argv) do |stdin, out, wait_thr| - stdin.close - reader = Thread.new do - out.each_line do |line| - @out.print(line) - captured << line - last_output = now - end - end - - stalled = wait_or_kill(wait_thr) { now - last_output } - reader.join - next Result.new(:stalled, captured) if stalled - - Result.new(wait_thr.value.success? ? :success : :failed, captured) - end - end - - # Poll until the process exits; if it goes silent and idle, kill it. - # - # @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) - while wait_thr.alive? - sleep @poll - next unless wait_thr.alive? - next unless stalled?(idle_seconds: yield, cpu_percent: container_cpu) - - kill_container - wait_thr.join - return true - end - false - end - - # Current container CPU percent via `docker stats`. Best-effort: an unreadable - # value reports as idle so a truly silent container can still be reclaimed. - # - # @return [Float] - def container_cpu - out, _err, status = Open3.capture3( - "docker", "stats", "--no-stream", "--format", "{{.CPUPerc}}", @container_name - ) - return 0.0 unless status.success? - - out.strip.delete("%").to_f - rescue StandardError - 0.0 - end - - # @return [void] - def kill_container - @out.puts ">>> build-watcher: killing hung container #{@container_name}" - system("docker", "kill", @container_name, out: File::NULL, err: File::NULL) - end - - # Remove any container left by a previous attempt so this attempt's - # `docker run --name` can't collide. A stalled container we killed, or one - # that exited non-zero, persists until removed — and the prewarm run can't use - # `--rm` because the container must survive for the subsequent `docker commit`. - # Silent no-op on the first attempt, when nothing by this name exists yet. - # - # @return [void] - def free_container_name - system("docker", "rm", "-f", @container_name, out: File::NULL, err: File::NULL) - end - - # Monotonic clock so wall-clock changes never skew stall timing. - # - # @return [Float] - def now - Process.clock_gettime(Process::CLOCK_MONOTONIC) - end -end diff --git a/lib/dev/build_container.rb b/lib/dev/build_container.rb new file mode 100644 index 0000000..1298276 --- /dev/null +++ b/lib/dev/build_container.rb @@ -0,0 +1,718 @@ +# typed: strict +# frozen_string_literal: true + +require "digest" +require "pathname" +require "securerandom" +require "tmpdir" +require "yaml" + +require "dev/build_watcher" + +module Dev + # Content-addressed Docker image management for build containers. + # + # Computes a tag from the hash of Dockerfile + .dockerignore + lockfiles + # (deps.lock, build-deps.lock) plus any project-declared content globs. Any + # change to those inputs produces a new tag, guaranteeing a rebuild. + # + # Usage: + # BuildContainer.ensure_image!(config, project_root: Pathname("...")) + # # pulls or builds the image, returns the full image:tag string + # + # 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). + CONTENT_FILES = ["Dockerfile", ".dockerignore", "deps.lock", "build-deps.lock"].freeze + TAG_PREFIX = "content-" + BUILD_DEPS_LOCK = "build-deps.lock" + # Both lockfiles are scanned for version-keyed install_dir resolution: gh + # build deps (e.g. the engine) land in build-deps.lock, while integration + # deps (e.g. the Satisfactory server) land in deps.lock. + LOCKFILES = ["deps.lock", "build-deps.lock"].freeze + + module_function + + # Compute the content-addressed tag from Dockerfile + lockfiles + globs. + # + # @param project_root [Pathname] project root containing Dockerfile etc. + # @param extra_globs [Array] additional project-relative globs whose + # matched files contribute to the hash (path + content), e.g. a mod's + # *.Build.cs. Sorted for determinism; missing matches contribute nothing. + # @param structure_globs [Array] project-relative globs whose matched + # *paths* (not contents) contribute to the hash. Use for inputs where the set + # of matching files is structural but their contents are not, e.g. one + # *.Build.cs per build module: adding/removing a module changes the path set + # (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 + .map { |f| root / f } + .select(&:exist?) + .map(&:read) + .join + + # A recursive glob (e.g. "bin/image/**/*") also matches directories; hash + # only files. The files under a matched dir are matched in their own right, + # so skipping the dir entry loses nothing — and avoids Errno::EISDIR on read. + glob_content = extra_globs + .flat_map { |pattern| Dir.glob(pattern, base: root.to_s) } + .uniq + .select { |rel| (root / rel).file? } + .sort + .map { |rel| "#{rel}\n#{(root / rel).read}" } + .join + + # Paths only: the *existence* of these files matters, not their contents. + structure_content = structure_globs + .flat_map { |pattern| Dir.glob(pattern, base: root.to_s) } + .uniq + .sort + .join("\n") + + hash = Digest::SHA256.hexdigest(file_content + glob_content + structure_content)[0, 12] + "#{TAG_PREFIX}#{hash}" + end + + # Full image reference with content-addressed tag. + # + # @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 : [] + "#{config.image_ref}:#{content_tag(project_root:, extra_globs: globs, structure_globs:)}" + end + + # Ensure the build container image exists: use a local image if present, + # pull from registry if available, otherwise build. Returns the full + # image:tag string. + # + # The local check comes first so images built manually are honored. + # + # build_args_provider / secrets_provider are lazy sources of docker + # --build-arg and BuildKit --secret values (e.g. credentials). They are only + # called on a cache miss so cache hits never trigger credential resolution or + # prompts. + # + # On a cache miss, every `group: build` dependency in build-deps.lock that + # declares an install_dir is passed as a BuildKit named build-context (keyed + # by dependency name), so the Dockerfile can bind-mount large host artifacts + # (e.g. the engine) without baking them into the image. + # + # Publishing (`publish: true`) is the *provisioning* guarantee: after the + # image is resolved by any path — built OR found locally — it is published to + # the shared registry so other machines can pull it. The local-hit case is + # the one that matters: the machine that originally built the image (e.g. the + # CI runner) keeps hitting its own local copy on every run, so without + # publish-on-hit the registry it is meant to populate stays empty forever and + # no other machine can ever pull. `push:` (legacy) only pushes a freshly built + # image; `publish:` subsumes it and additionally covers the local hit, so the + # provisioning step sets `publish: true` while build/run steps leave both off. + # + # @param config [Dev::BuildContainerConfig] + # @param project_root [Pathname] + # @param push [Boolean] whether to push a freshly built image (default: true) + # @param publish [Boolean] whether to publish the resolved image to the + # registry, even on a local hit (default: false — only the provisioning step opts in) + # @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:) + + if local_image?(tag) + $stderr.puts "dev: Container image found locally — #{tag}" + publish!(tag) if publish + return tag + end + + if pull(tag) + $stderr.puts "dev: Container image cache hit — #{tag}" + return tag + end + + $stderr.puts "dev: Container image cache miss — building #{tag}" + build_args = build_args_provider ? build_args_provider.call : {} + secrets = secrets_provider ? secrets_provider.call : {} + + prewarm = config.respond_to?(:prewarm) ? config.prewarm : nil + if prewarm + build_and_prewarm!(tag, config:, project_root:, build_args:, secrets:, prewarm:) + else + build_contexts = build_contexts_from_lockfile(project_root) + build!(tag, project_root:, build_args:, build_contexts:, secrets:) + end + + push!(tag) if push + publish!(tag) if publish + tag + end + + # Two-phase image creation for prewarmed images: build a cheap base from the + # Dockerfile, then run the prewarm command in a container with the build-dep + # volumes mounted and secrets delivered as files, and commit the result to the + # content-addressed tag. + # + # Why not a single `docker build` with the dependency as a BuildKit + # build-context? BuildKit *streams* a build-context from the client on demand; + # for a large, randomly-read dependency (e.g. a ~30GB engine read during + # compilation) that transport stalls/deadlocks, especially under emulation. A + # plain `-v` volume (virtiofs on Docker Desktop) is the robust path the runtime + # already uses, so the prewarm reuses it. + # + # @param tag [String] final content-addressed tag to commit + # @param config [Dev::BuildContainerConfig] + # @param project_root [Pathname] + # @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 + # secrets. Those are supplied to the prewarm run, not the Dockerfile. + build!(base_tag, project_root:, build_args:, build_contexts: {}, secrets: {}) + volumes = resolve_versioned_volumes(config.volumes, project_root:) + 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. 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 + # dependency with an install_dir becomes "=". + # The context name is lowercased because Docker rejects uppercase build-context + # names ("invalid reference format"); the Dockerfile references the lowercased + # name (e.g. `--mount=from=unrealengine`). Returns {} when the lockfile is + # absent or has no such deps. + # + # @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? + + yaml = YAML.safe_load(path.read, permitted_classes: [Symbol]) || {} + + contexts = {} + yaml.each do |name, attrs| + next if name == "env" # env-scoped deps are not whole-image build inputs + next unless attrs.is_a?(Hash) + next unless attrs["group"] == "build" && attrs["install_dir"] + + base = File.expand_path(attrs["install_dir"]) + # Point at the version-keyed subdir the integration publishes to, so the + # build context tracks the locked version (see resolve_versioned_volumes). + contexts[name.downcase] = attrs["version"] ? File.join(base, attrs["version"].to_s) : base + end + contexts + end + + # Rewrite each "host:container[:opts]" volume whose host path is a locked + # dependency's install_dir to its version-keyed subdir (install_dir/, + # the immutable directory the integration publishes). This is how a command + # mounts the exact locked version while the integration keeps every version + # side by side. Volumes that don't match a locked install_dir (e.g. the shared + # cache mount) pass through unchanged. + # + # @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(T.must(host))] + version ? "#{host}/#{version}:#{container}" : spec + end + end + + # Map every locked dependency install_dir (expanded) to its locked version, + # scanning both lockfiles (including env-nested build deps). Only entries with + # BOTH an install_dir and a version contribute. + # + # @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| + path = root / file + next unless path.exist? + + yaml = YAML.safe_load(path.read, permitted_classes: [Symbol]) || {} + collect_install_dir_versions(yaml, acc) + end + end + + # Recursively collect {expanded install_dir => version} from a lockfile hash, + # descending into the nested env: section of build-deps.lock. + # + # @param yaml [Hash] + # @param acc [Hash{String => String}] accumulator (mutated) + # @return [void] + sig { params(yaml: T::Hash[T.untyped, T.untyped], acc: T::Hash[String, String]).void } + def collect_install_dir_versions(yaml, acc) + yaml.each do |name, attrs| + next unless attrs.is_a?(Hash) + + if name == "env" + attrs.each_value { |env_deps| collect_install_dir_versions(env_deps, acc) } + elsif attrs["install_dir"] && attrs["version"] + acc[File.expand_path(attrs["install_dir"])] = attrs["version"].to_s + end + end + end + + # Build a docker run command for executing a shell command inside the container. + # + # @param image_tag [String] full image:tag reference + # @param project_root [Pathname] project root to mount + # @param shell_cmd [String] command to run inside the container + # @param volumes [Array] extra "host:container" mounts; host + # 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}"] } + + [ + "docker", "run", "--rm", + "-v", "#{project_root}:/project", + *volume_flags(volumes), + *env_flags, + "-w", "/project", + image_tag, + "sh", "-c", shell_cmd, + ] + end + + # "host:container" volume specs -> docker `-v` flags, expanding ~ in the host + # path (e.g. "~/.dev/engines/...:/ue"). + # + # @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(T.must(host))}:#{container}"] + end + end + + # --- persistent service container (build.container.persist) ---------- + + # Ensure the long-lived service container for image_tag exists and is running, + # reaping any container left from a previous image tag of the same project. + # Idempotent: a no-op when the right container is already up. + # + # The container idles on `sleep infinity` so commands run against it via + # `docker exec` (see docker_exec_command). Its writable layer — and thus an + # incremental build tool's state written on top of the image — survives + # between commands, which a fresh `docker run --rm` would discard. + # + # @param image_tag [String] full image:tag the container runs + # @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) + + if container_exists?(name) + start_container(name) unless container_running?(name) + else + create_service_container(name, image_tag, project_root:, volumes:) + end + name + end + + # Build a `docker exec` command running shell_cmd inside the service container, + # mirroring docker_run_command's working dir (/project) and `-e` env handling. + # + # @param container [String] running container name + # @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] + end + + # Remove every service container for this checkout — the current tag's and any + # stale one — backing `dev reset-container`. Keyed by the image + workspace + # prefix (not the exact tag) so a container from a now-superseded Dockerfile/dep + # is still matched, while OTHER checkouts' containers are left untouched. + # + # @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) } + names + end + + # Run the prewarm command in a container off the base image and commit the + # result to final_tag. Build-dep volumes (e.g. the engine) are mounted with + # `-v`; secrets are written to host temp files and bind-mounted at + # /run/secrets/ (a bind mount, so the value is never captured by + # `docker commit`, which only persists the container's writable layer). + # Secrets are deliberately NOT passed via `-e`, since `docker commit` would + # bake run-time env into the committed image config. + # + # @param base_tag [String] + # @param final_tag [String] + # @param volumes [Array] resolved "host:container" build-dep mounts + # (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) + secret_mounts = secret_files.flat_map { |id, path| ["-v", "#{path}:/run/secrets/#{id}:ro"] } + + run_argv = [ + "docker", "run", "--name", container, + *volume_flags(volumes), + *secret_mounts, + base_tag, + "sh", "-c", prewarm, + ] + + 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 + # 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 + + # Run the prewarm docker command under the hung-build watcher, which detects + # the Rosetta clang deadlock (silent, idle container) and retries transient + # crashes while failing fast on real compile errors. Isolated here so callers + # (and tests) treat it as a single boundary. + # + # @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 + + # Write each secret value to a private host temp file for bind-mounting into + # the prewarm container. Returns {id => path}; caller deletes the files. + # + # @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)}") + File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |f| f.write(value) } + files[id] = path + end + end + + # --- internal helpers ------------------------------------------------ + + # 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 + + # Container name for image_tag + workspace: "dev---", + # registry dropped and any char Docker forbids in a name (notably ':') replaced + # with '-'. E.g. "reg/snappy-linux:content-abc" in /work/snappy -> + # "dev-snappy-linux-9f86d08-content-abc". + # + # The segment is what keys the persistent container to the checkout + # it is bind-mounted to. Without it the container is keyed by image tag ALONE, + # so a SECOND checkout of the same project (e.g. a CI runner's actions/checkout + # vs. a manual clone elsewhere on the same machine) finds the first checkout's + # container by name and reuses it — still bind-mounted to the FIRST checkout — + # 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 = 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) + 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 + 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 + + # Remove service containers for this checkout that don't match the current + # 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| + remove_container(name) unless name == keep + end + end + + # 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 + + # 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: []) + argv = [ + "docker", "run", "-d", "--name", name, + "-v", "#{project_root}:/project", + *volume_flags(volumes), + "-w", "/project", + image_tag, + "sleep", "infinity", + ] + 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 + + # Stream pull progress to stdout so a multi-GB pull (e.g. a 20GB build image + # 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 + + # Build the image with BuildKit. build_contexts are passed as + # `--build-context name=path` (host artifacts bind-mounted at build time, + # never stored in the image). secrets are passed as `--secret id=NAME,env=NAME` + # with the value exported into docker's environment for that invocation, so the + # value is mounted only for the requesting RUN and never persists in a layer. + # + # @param image_tag [String] + # @param project_root [Pathname] + # @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}"] } + secret_flags = secrets.keys.flat_map { |id| ["--secret", "id=#{id},env=#{id}"] } + + # BuildKit is required for --build-context and --secret; enable it explicitly + # so the build behaves the same regardless of the host Docker default. Secret + # values travel via the environment (referenced by env=), never on argv. + env = { "DOCKER_BUILDKIT" => "1" }.merge(secrets) + + # --progress=plain: BuildKit's auto renderer detects non-TTY stdout (CI log + # 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. + 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 + + # Guarantee the shared registry advertises this content tag, so other + # machines pull it instead of rebuilding. A no-op when the registry already + # has the tag (checked via a remote manifest lookup that transfers only + # metadata, never the multi-GB layers), otherwise pushes the local image. + # + # Best-effort: a push failure is logged, not raised, so a transient registry + # hiccup never fails an otherwise-green build — the next provisioning run + # retries, since the registry still lacks the tag. + # + # @param image_tag [String] + # @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}" + return true + end + + $stderr.puts "dev: Publishing container image to registry — #{image_tag}" + pushed = push!(image_tag) + $stderr.puts "dev: WARNING — could not publish #{image_tag} to the registry" unless pushed + pushed + end + + # Whether the registry already advertises image_tag. `docker manifest inspect` + # queries the remote registry for the tag's manifest only (no layer + # download), so this is a cheap existence check. + # + # @param image_tag [String] + # @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 + end +end diff --git a/lib/dev/build_watcher.rb b/lib/dev/build_watcher.rb new file mode 100644 index 0000000..6b32862 --- /dev/null +++ b/lib/dev/build_watcher.rb @@ -0,0 +1,247 @@ +# typed: strict +# frozen_string_literal: true + +require "open3" +require "stringio" + +module Dev + # Runs a long containerized build with hung-build detection and bounded retries. + # + # The base-game compile deadlocks intermittently under Rosetta: a clang++ worker + # crashes, leaves a zombie, and UnrealBuildTool waits forever on a child that + # will never report — the container sits at ~0% CPU producing no output. A plain + # `system(docker run …)` would block indefinitely. + # + # This watcher distinguishes three terminal situations and reacts to each, so a + # transient emulation flake self-heals while a genuine compile error still fails + # fast (no infinite retry loop): + # + # - exit 0 -> success + # - alive, stalled (no output for + # stall_after AND container CPU + # ~0%) -> a hang: kill the container and retry + # - non-zero exit: + # * Rosetta/clang crash sig -> a transient crash: retry + # * real compile-error sig -> fail fast (don't retry) + # * neither -> fail fast (surface the unknown failure) + # + # Retries are capped (max_attempts) and rely on the build tool's atomic + # intermediate writes, so a retry resumes incrementally rather than from scratch. + # + # 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). + DEFAULT_STALL_AFTER = 300 + # Container CPU percent at or below which it counts as "doing nothing". + DEFAULT_CPU_FLOOR = 5.0 + # How often to probe liveness/CPU while the build runs. + DEFAULT_POLL = 15 + # Total attempts before giving up (fail fast on a genuinely broken build). + DEFAULT_MAX_ATTEMPTS = 5 + + # Crash signatures worth retrying (flaky Rosetta/clang emulation failures). + CRASH_SIGNATURES = T.let([ + /rosetta error/i, + /segmentation fault/i, + /caught signal/i, + /clang\+\+.*(?:crashed|terminated|killed|signal)/i, + /llvm error/i, + /internal compiler error|\bICE\b/i, + /unable to spawn process|posix_spawn failed/i, + ].freeze, T::Array[Regexp]) + + # Genuine compile/link errors — fail fast, retrying won't help. + 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, T::Array[Regexp]) + + Result = Struct.new(:outcome, :output) # outcome: :success | :stalled | :failed + + # @param container_name [String] the `docker run --name` of the watched build, + # so a stall can be killed by name + # @param stall_after [Integer] seconds of no output before stall-eligible + # @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, 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 + @stall_after = stall_after + @cpu_floor = cpu_floor + @poll = poll + @max_attempts = max_attempts + @out = out + end + + # Run argv with stall detection and bounded retries. + # + # @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) + return true if result.outcome == :success + + reason = retry_reason(result) + return false unless reason + + @out.puts ">>> build-watcher: #{reason} (attempt #{attempt + 1}/#{@max_attempts}); retrying" + end + @out.puts ">>> build-watcher: giving up after #{@max_attempts} attempts" + false + end + + # Whether a still-running build looks hung: silent long enough AND idle CPU. + # Both are required so a legitimately slow (but working) compile action — which + # keeps the CPU busy — is never killed. + # + # @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 + + # Classify a failed (non-zero) run's output. Crash signatures take precedence + # over compile-error signatures: a Rosetta crash often *also* prints a cascade + # "error:", but it's the transient cause we should retry; a real compile error + # has no crash signature. + # + # @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) } + + :fail # compile error or unknown: don't loop on a broken build + end + + private + + # @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 + + nil + end + + # Spawn the build, stream + capture its output, and watch for a stall. + # Isolated as the single OS-touching seam so the retry/classify policy can be + # tested without real processes. + # + # @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(*T.unsafe(argv)) do |stdin, out, wait_thr| + stdin.close + reader = Thread.new do + out.each_line do |line| + @out.print(line) + captured << line + last_output = now + end + end + + stalled = wait_or_kill(wait_thr) { now - last_output } + reader.join + next Result.new(:stalled, captured) if stalled + + Result.new(wait_thr.value.success? ? :success : :failed, captured) + end + end + + # Poll until the process exits; if it goes silent and idle, kill it. + # + # @param wait_thr [Process::Waiter] + # @yieldreturn [Numeric] seconds since last output + # @return [Boolean] true if killed due to stall + 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? + next unless stalled?(idle_seconds: yield, cpu_percent: container_cpu) + + kill_container + wait_thr.join + return true + end + false + end + + # Current container CPU percent via `docker stats`. Best-effort: an unreadable + # 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 + ) + return 0.0 unless status.success? + + out.strip.delete("%").to_f + rescue StandardError + 0.0 + 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) + end + + # Remove any container left by a previous attempt so this attempt's + # `docker run --name` can't collide. A stalled container we killed, or one + # that exited non-zero, persists until removed — and the prewarm run can't use + # `--rm` because the container must survive for the subsequent `docker commit`. + # 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 + + # Monotonic clock so wall-clock changes never skew stall timing. + # + # @return [Float] + sig { returns(Float) } + def now + # 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 +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..4033fa5 100644 --- a/lib/dev/cd/accessor.rb +++ b/lib/dev/cd/accessor.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "stringio" require "dev/cd/repo_discovery" require "dev/cd/matcher" require "dev/cd/hook_installer" @@ -21,6 +23,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 +33,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: HookInstaller).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 +72,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 +97,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 +110,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 +131,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..9d9b91f 100644 --- a/lib/dev/cd/hook_installer.rb +++ b/lib/dev/cd/hook_installer.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "dev/shell_rc_hook" @@ -23,6 +24,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 +107,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 +115,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..ddc6258 100644 --- a/lib/dev/cd/matcher.rb +++ b/lib/dev/cd/matcher.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "dev/cd/repo" @@ -13,9 +14,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 +30,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 +50,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 +64,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 +81,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 +93,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 +109,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,12 +127,15 @@ 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 tail = repo.segments.last(query_segments.size) - scores = query_segments.zip(tail).map { |wanted, actual| segment_score(wanted, actual) } + # T.must: the size guard above ensures tail has as many segments as + # the query, so zip never pads with nil. + scores = query_segments.zip(tail).map { |wanted, actual| segment_score(wanted, T.must(actual)) } return nil if scores.any?(&:nil?) scores.sum @@ -129,6 +147,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..e8b7b07 100644 --- a/lib/dev/cd/repo.rb +++ b/lib/dev/cd/repo.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "pathname" @@ -6,18 +7,37 @@ module Dev module Cd # A discovered git checkout under the search root. # - # - path: absolute Pathname of the repo root - # - segments: path segments relative to the search root - # (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 + # A plain value class rather than Data.define: Sorbet at strict demands + # sigs on the readers Data.define synthesizes, and re-declaring them in + # the define block would destroy the originals (the same conversion + # Clone::RepoSpec already made). + class Repo + extend T::Sig + + # @return [Pathname] absolute Pathname of the repo root + sig { returns(Pathname) } + attr_reader :path + + # Path segments relative to the search root + # (e.g. ["github.com", "d3mlabs", "dev"]); queries are matched + # right-anchored against them, so the last segment is the repo name. + # + # @return [Array] + sig { returns(T::Array[String]) } + attr_reader :segments + + # @param path [Pathname, String] absolute path of the repo root + # @param segments [Array] path segments relative to the search root + 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) + @path = T.let(Pathname(path), Pathname) + @segments = T.let(segments.map(&:to_s).freeze, T::Array[String]) end # The repo's leaf name (last path segment). # # @return [String] + sig { returns(String) } def name segments.fetch(-1) end @@ -26,6 +46,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..2e7e7ce 100644 --- a/lib/dev/cd/repo_discovery.rb +++ b/lib/dev/cd/repo_discovery.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "pathname" @@ -12,21 +13,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 +44,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..fe359dc 100644 --- a/lib/dev/clone/accessor.rb +++ b/lib/dev/clone/accessor.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "stringio" require "dev/clone/repo_spec" require "dev/clone/gh_cloner" require "dev/cd/hook_installer" @@ -26,6 +28,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 +39,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: GhCloner, + hook_installer: Dev::Cd::HookInstaller, + ).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 +56,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 +93,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..96ef3e4 100644 --- a/lib/dev/clone/gh_cloner.rb +++ b/lib/dev/clone/gh_cloner.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -9,11 +10,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 +26,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: Executor).void } def initialize(executor: Executor.new) @executor = executor end @@ -38,6 +45,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..181844e 100644 --- a/lib/dev/clone/repo_spec.rb +++ b/lib/dev/clone/repo_spec.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "pathname" @@ -14,6 +15,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 +27,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 +49,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 +64,7 @@ def initialize(org:, name:) # The gh clone target. # # @return [String] "org/repo" + sig { returns(String) } def full_name "#{org}/#{name}" end @@ -63,6 +72,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..56cf7f2 100644 --- a/lib/dev/credential_accessor.rb +++ b/lib/dev/credential_accessor.rb @@ -1,5 +1,8 @@ +# typed: strict # frozen_string_literal: true +require "stringio" + module Dev # Read accessor over the Credentials provider, surfaced as `dev cred get`. # @@ -12,24 +15,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 +47,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 +65,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..fdea2d1 100644 --- a/lib/dev/credentials.rb +++ b/lib/dev/credentials.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -12,6 +13,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 +31,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 +53,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 +74,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 +88,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 +100,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 +110,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 +133,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 +151,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 +173,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 +217,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 +246,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 +258,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 +269,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..908e00c 100644 --- a/lib/dev/deps/accessor.rb +++ b/lib/dev/deps/accessor.rb @@ -1,5 +1,8 @@ +# typed: strict # frozen_string_literal: true +require "stringio" +require_relative "dependency" require_relative "lockfile" require_relative "cache" require_relative "ficsit_integration" @@ -13,6 +16,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 +29,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 +39,17 @@ 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) + when "path" + raise UsageError, USAGE if rest.size > 3 + + integration, name, platform = rest + out.puts(path(integration, name, platform).to_s) else raise UsageError, USAGE end end @@ -54,6 +65,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 +85,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 +106,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 +122,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 +133,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..3cd2f59 100644 --- a/lib/dev/deps/brew_integration.rb +++ b/lib/dev/deps/brew_integration.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true require "open3" require "pathname" +require "uri" require_relative "integration" require_relative "dependency" require_relative "tap" @@ -16,23 +18,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 +60,7 @@ def install_all(dependencies) private # Register all configured taps (idempotent — runs once). + sig { void } def ensure_taps_registered return if @taps_registered @@ -59,9 +73,11 @@ 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) + project_dir = @project_dir + if tap.local? && project_dir + path = resolve_file_url(tap.url, project_dir) success = system("brew", "tap", tap.name, path) raise TapRegistrationError, "brew tap #{tap.name} #{path} failed" unless success elsif tap.url @@ -75,23 +91,28 @@ 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 + project_dir = @project_dir + return unless project_dir local_tap = @taps.find(&:local?) return unless local_tap ENV["TAP_NAME"] = local_tap.name - ENV["LOCAL_TAP_DIR"] = resolve_file_url(local_tap.url) if local_tap.url + ENV["LOCAL_TAP_DIR"] = resolve_file_url(local_tap.url, project_dir) if local_tap.url end # Resolve a file:// URI to an absolute path relative to project_dir. # - # @param uri [URI] file:// URI + # @param uri [URI::Generic] file:// URI + # @param project_dir [Pathname] project root ./ paths resolve against # @return [String] absolute path - def resolve_file_url(uri) + sig { params(uri: URI::Generic, project_dir: Pathname).returns(String) } + def resolve_file_url(uri, project_dir) path = uri.path.to_s - path = (@project_dir / path[2..]).to_s if path.start_with?("./") + # T.must: start_with?("./") guarantees at least two leading chars. + path = (project_dir / T.must(path[2..])).to_s if path.start_with?("./") File.expand_path(path) end @@ -104,6 +125,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 +139,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 +148,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 +159,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..b1a720c 100644 --- a/lib/dev/deps/brew_repository.rb +++ b/lib/dev/deps/brew_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "json" @@ -12,6 +13,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 +27,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 +80,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 +100,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 +114,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 +124,19 @@ 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? + # success? is nil (not false) when the process didn't exit normally, + # e.g. it was killed by a signal — coerce that to a failure. + 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..16c9389 100644 --- a/lib/dev/deps/bundler_integration.rb +++ b/lib/dev/deps/bundler_integration.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "open3" @@ -18,23 +19,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 +67,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 +86,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 +97,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..e7b7b94 100644 --- a/lib/dev/deps/bundler_repository.rb +++ b/lib/dev/deps/bundler_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "open3" @@ -19,6 +20,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 +37,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 +54,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 +68,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 +92,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 +103,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 +126,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 +143,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 +161,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 +178,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 +202,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 +215,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..23f2dd2 100644 --- a/lib/dev/deps/cache.rb +++ b/lib/dev/deps/cache.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -17,20 +18,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 +47,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 +59,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 +70,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..a069814 100644 --- a/lib/dev/deps/cache_gc.rb +++ b/lib/dev/deps/cache_gc.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "set" require "fileutils" require "open3" require "pathname" +require "stringio" require_relative "lockfile" module Dev @@ -29,11 +31,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 +51,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 +66,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 +77,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 +91,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 +112,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 +120,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 +133,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 +143,12 @@ 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?) + 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) + sources = capture(["docker", "inspect", "--format", "{{range .Mounts}}{{.Source}}\n{{end}}", *ids]) Set.new(sources.split("\n").map(&:strip).reject(&:empty?)) end @@ -144,8 +157,9 @@ 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}}") + tags = capture(["docker", "images", image_ref, "--format", "{{.Repository}}:{{.Tag}}"]) .split("\n").map(&:strip).reject(&:empty?) in_use_images = running_image_refs @@ -159,15 +173,24 @@ 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?)) + Set.new(capture(["docker", "ps", "--format", "{{.Image}}"]).split("\n").map(&:strip).reject(&:empty?)) end # Run a command and capture stdout, returning "" on failure. # + # Takes the argv as an array (not rest args) so call sites can build it + # dynamically: Sorbet rejects splats of runtime-sized arrays (error 7019), + # which would force a T.unsafe at every caller. + # + # @param argv [Array] # @return [String] - def capture(*argv) - out, _err, status = Open3.capture3(*argv) + sig { params(argv: T::Array[String]).returns(String) } + def capture(argv) + # T.unsafe: capture3's fixed first parameter (env-or-command) can't be + # matched against an array of statically-unknown size (error 7019). + 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..ed6d96d 100644 --- a/lib/dev/deps/cmake_integration.rb +++ b/lib/dev/deps/cmake_integration.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -15,6 +16,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 +40,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 +75,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 +87,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 +108,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 +123,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 +140,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| @@ -144,17 +160,18 @@ def extract_tarball(tarball_path, dest) # @param dest [Pathname] source directory to check # @param dep [Dependency] # @return [Boolean] + sig { params(dest: Pathname, dep: Dependency).returns(T::Boolean) } def populated?(dest, dep) return false unless dest.directory? + return true if dest.join(".git").exist? || dest.join("CMakeLists.txt").exist? - dest.join(".git").exist? || - dest.join("CMakeLists.txt").exist? || - (dep.metadata["url"] && dest.children.any?) + dep.metadata["url"] ? dest.children.any? : false end # 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 +205,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..61b5474 100644 --- a/lib/dev/deps/ficsit_integration.rb +++ b/lib/dev/deps/ficsit_integration.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "digest" @@ -20,19 +21,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 +54,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 +63,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 +72,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 +87,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 +100,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 +108,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 +119,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..bdc13a7 100644 --- a/lib/dev/deps/ficsit_repository.rb +++ b/lib/dev/deps/ficsit_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "json" @@ -13,13 +14,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 +69,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) @@ -84,7 +88,7 @@ def fetch(id) hash = nil else target = id.fetch("target", DEFAULT_TARGET) - target_data = find_target(version_data["targets"], target) + target_data = find_target(version_data["targets"] || [], target) hash = target_data ? "SHA256=#{target_data["hash"]}" : nil metadata["target"] = target end @@ -116,6 +120,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 +153,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 +173,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 +195,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) @@ -195,12 +214,16 @@ def post_graphql(body) # Find the target matching the requested platform. # - # @param targets [Array, nil] target objects from the version + # @param targets [Array] target objects from the version # @param target_name [String] platform name (e.g. "Windows") - # @return [Hash, nil] matching target or nil + # @return [Hash, nil] matching target, or nil when targets is empty + sig do + params( + targets: T::Array[T::Hash[String, T.untyped]], + target_name: String, + ).returns(T.nilable(T::Hash[String, T.untyped])) + end def find_target(targets, target_name) - return nil if targets.nil? || targets.empty? - targets.find { |t| t["targetName"] == target_name } || targets.first end end diff --git a/lib/dev/deps/gem_skill_linker.rb b/lib/dev/deps/gem_skill_linker.rb index ae43c48..301f898 100644 --- a/lib/dev/deps/gem_skill_linker.rb +++ b/lib/dev/deps/gem_skill_linker.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "open3" @@ -19,6 +20,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 +32,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 +51,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 +74,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 +91,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 +110,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 +128,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 +153,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 +178,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 +191,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..6fa4308 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" @@ -38,6 +39,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 +49,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 +124,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 +168,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 +187,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 +206,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 +223,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 +242,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 +266,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 +280,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 +301,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 +324,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 +336,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 +350,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..78b381b 100644 --- a/lib/dev/deps/gh_repository.rb +++ b/lib/dev/deps/gh_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "json" @@ -19,6 +20,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 +42,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 +53,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 +88,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 +114,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 +129,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 +145,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 +162,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 +171,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 +180,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 +192,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 +209,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..179791c 100644 --- a/lib/dev/deps/git_repository.rb +++ b/lib/dev/deps/git_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "open3" @@ -12,6 +13,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 +23,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 +52,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..21f56bb 100644 --- a/lib/dev/deps/hooks/unreal_module.rb +++ b/lib/dev/deps/hooks/unreal_module.rb @@ -1,5 +1,13 @@ +# typed: strict # frozen_string_literal: true +require "pathname" +# Required here (not just in src/dev.rb) because consumer dependencies.rb +# files load this hook standalone via bin/install-build-deps.rb, where the +# Dev module root never loads. +require "sorbet-runtime" +require_relative "../dependency" + module Dev module Deps module Hooks @@ -12,8 +20,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 +40,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 +50,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..216ed60 100644 --- a/lib/dev/deps/hooks/wwise_download.rb +++ b/lib/dev/deps/hooks/wwise_download.rb @@ -1,5 +1,11 @@ +# typed: strict # frozen_string_literal: true +# Required here (not just in src/dev.rb) because consumer dependencies.rb +# files load this hook standalone via bin/install-build-deps.rb, where the +# Dev module root never loads. +require "sorbet-runtime" + module Dev module Deps module Hooks @@ -13,11 +19,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 +35,16 @@ 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}") + # T.unsafe: Sorbet rejects splats of runtime-sized arrays (error + # 7019) — Kernel#system's fixed first parameter (env-or-command) + # can't be matched against an array of statically-unknown size. + 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..b9385bb 100644 --- a/lib/dev/deps/integration.rb +++ b/lib/dev/deps/integration.rb @@ -1,8 +1,12 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "pathname" require "securerandom" +require_relative "cache" +require_relative "dependency" +require_relative "repository" module Dev module Deps @@ -12,8 +16,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 +29,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 +62,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 +73,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 +87,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 +107,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..33f4751 100644 --- a/lib/dev/deps/luarocks_integration.rb +++ b/lib/dev/deps/luarocks_integration.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "open3" @@ -12,21 +13,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 +50,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..238d877 100644 --- a/lib/dev/deps/luarocks_repository.rb +++ b/lib/dev/deps/luarocks_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "digest" @@ -14,6 +15,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 +28,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 +55,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 +74,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..cc1b693 100644 --- a/lib/dev/deps/pip_integration.rb +++ b/lib/dev/deps/pip_integration.rb @@ -1,8 +1,9 @@ +# typed: strict # frozen_string_literal: true require "open3" require "pathname" -require "shadowenv_python" +require "dev/shadowenv_python" require_relative "integration" require_relative "dependency" @@ -16,17 +17,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 +46,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 +67,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..386a0f2 100644 --- a/lib/dev/deps/pip_repository.rb +++ b/lib/dev/deps/pip_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "digest" @@ -17,6 +18,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 +32,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 +57,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 +70,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 +91,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..7723ae5 100644 --- a/lib/dev/deps/registry.rb +++ b/lib/dev/deps/registry.rb @@ -1,5 +1,8 @@ +# typed: strict # frozen_string_literal: true +require_relative "cache" +require_relative "tap" require_relative "brew_repository" require_relative "brew_integration" require_relative "git_repository" @@ -43,7 +46,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 +59,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 +200,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 +220,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 +234,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..552e633 100644 --- a/lib/dev/deps/repository.rb +++ b/lib/dev/deps/repository.rb @@ -1,5 +1,9 @@ +# typed: strict # frozen_string_literal: true +require_relative "dependency" +require_relative "dependency_declaration" + module Dev module Deps # Source adapter that fetches a dependency by its unique identifier. @@ -7,10 +11,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 +29,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..0593a30 100644 --- a/lib/dev/deps/resolver.rb +++ b/lib/dev/deps/resolver.rb @@ -1,5 +1,7 @@ +# typed: strict # frozen_string_literal: true +require_relative "repository" require_relative "dependency" require_relative "dependency_declaration" @@ -12,9 +14,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 +32,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 +95,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 +111,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 +125,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 +144,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..ccec5af 100644 --- a/lib/dev/deps/staleness.rb +++ b/lib/dev/deps/staleness.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "digest" @@ -30,29 +31,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 +73,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 +94,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 +114,7 @@ def install_message # none) and the nag persists. # # @return [void] + sig { void } def stamp_installed! digest = lockfile_digest return unless digest @@ -111,6 +127,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 +140,7 @@ def lockfile_digest end # @return [Pathname] + sig { returns(Pathname) } def stamp_path @state_dir / project_key / STAMP_FILE end @@ -133,6 +151,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..2f7df3b 100644 --- a/lib/dev/deps/steam_cmd.rb +++ b/lib/dev/deps/steam_cmd.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -16,10 +17,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 +35,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 +43,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 +60,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 +73,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 +91,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..c69bc50 100644 --- a/lib/dev/deps/steam_integration.rb +++ b/lib/dev/deps/steam_integration.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -21,6 +22,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 +33,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 +41,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 +77,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 +99,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..666a850 100644 --- a/lib/dev/deps/steam_repository.rb +++ b/lib/dev/deps/steam_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require_relative "repository" @@ -21,6 +22,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 +31,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 +59,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 +70,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..66223e6 100644 --- a/lib/dev/deps/url_repository.rb +++ b/lib/dev/deps/url_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "digest" @@ -13,6 +14,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 +25,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 +52,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..7de1459 100644 --- a/lib/dev/deps/xcode_integration.rb +++ b/lib/dev/deps/xcode_integration.rb @@ -1,7 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" -require "shadowenv_xcode" +require "dev/shadowenv_xcode" require_relative "integration" module Dev @@ -36,18 +37,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 +63,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 +102,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 +126,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 +142,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 +161,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 +174,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 +190,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 +207,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..dfbdf14 100644 --- a/lib/dev/deps/xcode_repository.rb +++ b/lib/dev/deps/xcode_repository.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require_relative "repository" @@ -13,11 +14,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..ac29e12 100644 --- a/lib/dev/learnings/accessor.rb +++ b/lib/dev/learnings/accessor.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true require "pathname" +require "stringio" require_relative "../deps/gem_skill_linker" require_relative "../settings" require_relative "../skill_installer" @@ -36,6 +38,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 +50,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 +72,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 +111,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 +132,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 +155,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 +194,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 +223,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 +239,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 +271,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 +297,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..643b263 100644 --- a/lib/dev/learnings/cache.rb +++ b/lib/dev/learnings/cache.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -23,6 +24,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 +47,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 +84,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 +102,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 +121,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 +136,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 +146,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 +157,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 +174,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 +191,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..f9a6ab9 100644 --- a/lib/dev/learnings/invariants_renderer.rb +++ b/lib/dev/learnings/invariants_renderer.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -24,6 +25,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 +49,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 +64,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 +86,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 +105,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 +128,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 +148,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 +160,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 +170,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..a6d03c0 100644 --- a/lib/dev/learnings/layout.rb +++ b/lib/dev/learnings/layout.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "pathname" @@ -18,6 +19,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 +87,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..2b195df 100644 --- a/lib/dev/learnings/scaffolder.rb +++ b/lib/dev/learnings/scaffolder.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -14,6 +15,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 +30,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 +41,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 +55,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..6813483 100644 --- a/lib/dev/learnings/synchronizer.rb +++ b/lib/dev/learnings/synchronizer.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "pathname" @@ -21,6 +22,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 +36,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 +46,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 +68,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 +85,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 +106,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 +122,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 +136,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 +144,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 +157,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 +171,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 +180,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..26eb52b 100644 --- a/lib/dev/plan.rb +++ b/lib/dev/plan.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "dev/settings" diff --git a/lib/dev/plan/accessor.rb b/lib/dev/plan/accessor.rb index d155625..17eb4c6 100644 --- a/lib/dev/plan/accessor.rb +++ b/lib/dev/plan/accessor.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "json" require "pathname" +require "stringio" module Dev module Plan @@ -12,11 +14,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 +40,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 +70,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 +107,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 +135,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 +158,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 +181,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 +194,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 +219,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 +239,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 +276,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 +309,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 +320,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 +355,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 +373,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 +383,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 +406,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 +421,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 +442,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 +465,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 +476,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 +487,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 +505,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..59272fd 100644 --- a/lib/dev/plan/content.rb +++ b/lib/dev/plan/content.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "dev/plan/header" @@ -9,16 +10,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 +37,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 +69,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 +90,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 +99,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 +110,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..a30c827 100644 --- a/lib/dev/plan/executor.rb +++ b/lib/dev/plan/executor.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "open3" @@ -8,16 +9,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..16ef1dc 100644 --- a/lib/dev/plan/frontmatter.rb +++ b/lib/dev/plan/frontmatter.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "yaml" @@ -11,6 +12,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 +23,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 +31,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 +46,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 +56,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 +65,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..802bed1 100644 --- a/lib/dev/plan/github_issues.rb +++ b/lib/dev/plan/github_issues.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "json" @@ -8,11 +9,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 +25,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 +36,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 +52,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 +75,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 +90,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 +107,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..77d3dda 100644 --- a/lib/dev/plan/header.rb +++ b/lib/dev/plan/header.rb @@ -1,7 +1,10 @@ +# typed: strict # frozen_string_literal: true module Dev module Plan + extend T::Sig + module_function # Normalize a markdown plan body for the issue: LF, single trailing @@ -10,6 +13,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 +25,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 +34,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 +61,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..dccb1b8 100644 --- a/lib/dev/plan/merge.rb +++ b/lib/dev/plan/merge.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "tmpdir" @@ -8,7 +9,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 +26,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 +44,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..8d88fd2 100644 --- a/lib/dev/plan/merge_base.rb +++ b/lib/dev/plan/merge_base.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -9,15 +10,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 +32,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 +41,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 +49,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..636ddeb 100644 --- a/lib/dev/plan/templates.rb +++ b/lib/dev/plan/templates.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "pathname" @@ -12,10 +13,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 +29,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 +49,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 +75,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 +85,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 +95,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..8ce13c1 100644 --- a/lib/dev/plan/workspace.rb +++ b/lib/dev/plan/workspace.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "pathname" @@ -9,18 +10,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 +33,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 +47,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 +56,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 +66,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 +84,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 +94,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..b358e25 100644 --- a/lib/dev/runner_setup.rb +++ b/lib/dev/runner_setup.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true require "fileutils" require "json" require "open3" require "socket" +require "stringio" module Dev # Registers the current host as a self-hosted GitHub Actions runner — scoped @@ -26,6 +28,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 +37,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 +61,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 +88,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 +106,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 +132,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 +153,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 +173,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 +182,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 +195,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 +203,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 +219,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 +248,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 +267,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 +284,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 +297,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 +311,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 +326,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 +343,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 +352,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 +366,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..4bbebad 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "yaml" @@ -17,19 +18,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 +53,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 +65,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/shadowenv_llvm.rb b/lib/dev/shadowenv_llvm.rb new file mode 100644 index 0000000..78204c2 --- /dev/null +++ b/lib/dev/shadowenv_llvm.rb @@ -0,0 +1,109 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "pathname" + +module Dev + # Shadowenv LLVM provisioning: resolves the Homebrew LLVM prefix, generates + # .shadowenv.d/520_llvm.lisp so clang, clang-format, clang-tidy, and lld are + # available in PATH, and CC/CXX point to the Homebrew compiler. + # + # 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) + return prefix if prefix + end + nil + end + + # 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) + content = File.read(lisp_path) + content.include?(%(provide "llvm")) && content.include?(llvm_prefix) + end + + # 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 + $stderr.puts "dev: LLVM not found via Homebrew. Run: brew install llvm" + return false + end + + shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") + FileUtils.mkdir_p(shadowenv_d) + lisp_path = File.join(shadowenv_d, LISP_FILENAME) + File.write(lisp_path, generate_llvm_lisp(prefix)) + + Dir.chdir(project_root.to_s) do + system("shadowenv", "trust", out: File::NULL, err: File::NULL) + end + + true + end + + # 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++") + <<~LISP + (provide "llvm" "#{llvm_prefix}") + + (env/prepend-to-pathlist "PATH" "#{bin}") + (env/set "CC" "#{File.join(bin, "clang")}") + (env/set "CXX" "#{File.join(bin, "clang++")}") + (env/set "LDFLAGS" "-L#{lib_cxx} -Wl,-rpath,#{lib_cxx}") + LISP + end + + # 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? + + content = lockfile.read + content.match?(/^llvm:\s*$/) || content.match?(/^brew llvm\b/) + end + + # --- 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) + prefix = out&.strip + (prefix && !prefix.empty? && File.directory?(prefix)) ? prefix : nil + end + end +end diff --git a/lib/dev/shadowenv_lua.rb b/lib/dev/shadowenv_lua.rb new file mode 100644 index 0000000..d68ad67 --- /dev/null +++ b/lib/dev/shadowenv_lua.rb @@ -0,0 +1,109 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "pathname" + +module Dev + # 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" + + module_function + + # Fast-path check: does .shadowenv.d/510_lua.lisp exist and provision + # the correct Lua version? + # + # @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) + + content = File.read(lisp_path) + content.include?(%(provide "lua" "#{lua_version}")) + end + + # Full provisioning: write .shadowenv.d/510_lua.lisp, trust shadowenv. + # + # @param lua_version [String] e.g. "5.1" + # @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) + + shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") + FileUtils.mkdir_p(shadowenv_d) + lisp_path = File.join(shadowenv_d, LISP_FILENAME) + File.write(lisp_path, generate_lua_lisp(lua_version)) + + Dir.chdir(project_root.to_s) do + Kernel.system("shadowenv", "trust", out: File::NULL, err: File::NULL) + end + + true + end + + # Generate the shadowenv lisp for Lua environment isolation. + # + # @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 + (provide "lua" "#{lua_version}") + + (when-let ((lua-root (env/get "LUA_ROOT"))) + (env/remove-from-pathlist "PATH" (path-concat lua-root "bin"))) + + (env/set "LUA_ROOT" "/opt/homebrew/opt/#{lua_formula}") + (env/prepend-to-pathlist "PATH" "/opt/homebrew/opt/#{lua_formula}/bin") + (env/prepend-to-pathlist "PATH" "/opt/homebrew/opt/luarocks/bin") + + (let ((modules (path-concat (env/get "SHADOWENV_PROJECT_DIR") "lua_modules"))) + (env/set "LUA_PATH" + (string-append + (path-concat modules "share/lua/#{lua_version}/?.lua") ";" + (path-concat modules "share/lua/#{lua_version}/?/init.lua") ";" + "./?.lua;./?/init.lua;;")) + (env/set "LUA_CPATH" + (string-append + (path-concat modules "lib/lua/#{lua_version}/?.so") ";;"))) + LISP + end + + # Ensure Homebrew lua and luarocks are installed. + # + # @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) + $stderr.puts "dev: Installing #{formula} via Homebrew..." + unless Kernel.system("brew", "install", formula) + raise BrewInstallError, "brew install #{formula} failed" + end + end + unless Kernel.system("brew", "list", "luarocks", out: File::NULL, err: File::NULL) + $stderr.puts "dev: Installing luarocks via Homebrew..." + unless Kernel.system("brew", "install", "luarocks") + raise BrewInstallError, "brew install luarocks failed" + end + end + end + end +end diff --git a/lib/dev/shadowenv_python.rb b/lib/dev/shadowenv_python.rb new file mode 100644 index 0000000..0d245d2 --- /dev/null +++ b/lib/dev/shadowenv_python.rb @@ -0,0 +1,164 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "pathname" + +module Dev + # Shadowenv Python provisioning: installs the interpreter via Homebrew + # (python@<version>), creates a project-local .venv with it, and generates + # .shadowenv.d/540_python.lisp so the venv's bin is first on PATH and + # VIRTUAL_ENV points at it for every dev command. + # + # Mirrors ShadowenvRuby/ShadowenvLua. Triggered when dependencies.rb declares + # `python "3.12"`. This module owns only the interpreter + the (empty) venv; + # 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" + VENV_DIR = ".venv" + + module_function + + # Fast-path check: does .shadowenv.d/540_python.lisp provision this version + # AND does the project venv still exist? The venv check means a deleted .venv + # (or a fresh clone) re-triggers setup! rather than leaving a dangling PATH. + # + # @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) + return false unless File.directory?(File.join(project_root.to_s, VENV_DIR)) + + File.read(lisp_path).include?(%(provide "python" "#{python_version}")) + end + + # Full provisioning: ensure the interpreter + venv exist, write the lisp, + # trust shadowenv. Idempotent. + # + # @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:) + + shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") + FileUtils.mkdir_p(shadowenv_d) + File.write(File.join(shadowenv_d, LISP_FILENAME), generate_python_lisp(python_version, venv_path)) + + Dir.chdir(project_root.to_s) do + Kernel.system("shadowenv", "trust", out: File::NULL, err: File::NULL) + end + true + end + + # Ensure Homebrew python@<version> is installed and a project-local .venv + # exists, built with that exact interpreter. Idempotent and safe to call from + # both setup! (per command) and PipIntegration (install-deps), so the venv is + # guaranteed present before packages install into it. + # + # @param python_version [String] e.g. "3.12" + # @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) + venv_python = File.join(venv_path, "bin", "python") + + unless File.executable?(venv_python) + raise BrewInstallError, "python -m venv #{venv_path} failed" unless Kernel.system(python_bin, "-m", "venv", venv_path) + end + + ensure_pip!(venv_python) + venv_path + end + + # Guarantee pip is importable inside the venv. `python -m venv` normally seeds + # pip via ensurepip, but some Homebrew interpreters produce a venv without it; + # bootstrap it so package installs (PipIntegration) don't fail on a bare venv. + # + # @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) + + raise BrewInstallError, "could not bootstrap pip in the venv" unless Kernel.system(venv_python, "-m", "ensurepip", "--upgrade") + end + + # Generate the shadowenv lisp that activates the project venv: VIRTUAL_ENV set, + # its bin prepended to PATH, and PYTHONHOME cleared (a stray PYTHONHOME makes a + # venv resolve the wrong stdlib). Mirrors what a venv's activate script does. + # + # The venv path is baked in as an absolute literal (shadowenv exposes no + # reliable project-dir variable — SHADOWENV_PROJECT_DIR reads empty, and an + # empty path-concat panics the loader), matching how ShadowenvRuby/Lua bake + # their absolute Homebrew/rbenv roots. + # + # @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 + (provide "python" "#{python_version}") + + (when-let ((old (env/get "VIRTUAL_ENV"))) + (env/remove-from-pathlist "PATH" (path-concat old "bin"))) + + (env/set "PYTHONHOME" ()) + (env/set "VIRTUAL_ENV" "#{venv}") + (env/prepend-to-pathlist "PATH" "#{File.join(venv, "bin")}") + LISP + end + + # Ensure brew python@<version> is present; return the path to its versioned + # interpreter (e.g. .../bin/python3.12), falling back to the formula's python3. + # + # @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) + $stderr.puts "dev: Installing #{formula} via Homebrew..." + raise BrewInstallError, "brew install #{formula} failed" unless Kernel.system("brew", "install", formula) + end + + prefix = brew_prefix_for(formula) + raise BrewInstallError, "could not resolve Homebrew prefix for #{formula}" unless prefix + + versioned = File.join(prefix, "bin", "python#{python_version}") + return versioned if File.executable?(versioned) + + fallback = File.join(prefix, "bin", "python3") + return fallback if File.executable?(fallback) + + raise BrewInstallError, "no python interpreter found under #{prefix}" + end + + # @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") + + out = IO.popen(["brew", "--prefix", formula], err: File::NULL, &:read) + prefix = out&.strip + (prefix && !prefix.empty? && File.directory?(prefix)) ? prefix : nil + end + end +end diff --git a/lib/dev/shadowenv_ruby.rb b/lib/dev/shadowenv_ruby.rb new file mode 100644 index 0000000..3f096c1 --- /dev/null +++ b/lib/dev/shadowenv_ruby.rb @@ -0,0 +1,435 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "pathname" +require "dev/shell_rc_hook" + +module Dev + # 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" + + # Stdlib C-extensions every dev workflow depends on. rbenv/ruby-build silently + # *skips* an extension when its dev library is missing at compile time, so a Ruby + # can install "successfully" yet blow up on the first `require` deep inside bundler + # ("cannot load such file -- zlib"). We make that failure mode impossible: provision + # the libraries below before building, and hard-verify these after. + REQUIRED_EXTENSIONS = %w[zlib openssl psych].freeze + + # 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 = 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 + 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)) + 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 + # /opt/homebrew/Cellar/ruby/4.0.1 -> "4.0.1" + # /opt/homebrew/Cellar/ruby/4.0.6_1 -> "4.0.6" + # The keg dirname carries brew's PKG version, whose "_N" formula-revision + # suffix (a rebuild of the same upstream Ruby) is not part of RUBY_VERSION + # and is rejected by Gem::Version - so only the numeric prefix may leak out. + realpath = File.realpath(prefix) rescue prefix + version = File.basename(realpath)[/\A\d+(?:\.\d+)+/] + return version if version + # Fallback: ask the Homebrew ruby binary directly + ruby_bin = File.join(prefix, "bin", "ruby") + return nil unless File.executable?(ruby_bin) + out = IO.popen([ruby_bin, "-e", "puts RUBY_VERSION"], err: File::NULL, &:read) + v = out&.strip + (v && !v.empty?) ? v : nil + end + + # 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) + + setup!(ruby_version: ruby_version, project_root: project_root) + end + + # 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) + content = File.read(lisp_path) + content.include?(%(provide "ruby" "#{ruby_version}")) + end + + # 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) + return false unless ruby_root + + shadowenv_d = File.join(root, ".shadowenv.d") + FileUtils.mkdir_p(shadowenv_d) + lisp_path = File.join(shadowenv_d, LISP_FILENAME) + File.write(lisp_path, generate_ruby_lisp(ruby_root, ruby_version)) + + ruby_version_path = File.join(root, ".ruby-version") + File.write(ruby_version_path, "#{ruby_version}\n") + + Dir.chdir(root) do + system("shadowenv", "trust", out: File::NULL, err: File::NULL) + end + + ensure_shadowenv_shell_hook! + true + end + + # --- 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) + nil + end + + # 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) + prefix = out&.strip + (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 + out = IO.popen(["brew", "prefix"], err: File::NULL, &:read) + out&.strip + end + return ENV["PATH"] unless prefix && File.directory?(prefix) + "#{File.join(prefix, "bin")}:#{ENV["PATH"]}" + end + + # Resolve a usable Ruby for the version: install it if absent, repair it if a + # 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) + + if ruby_root && extensions_ok?(ruby_root) && reported_version_ok?(ruby_root, version) + return ruby_root + elsif ruby_root + missing = missing_extensions(ruby_root) + if missing.any? + $stderr.puts "dev: Ruby #{version} is missing required extension(s): #{missing.join(', ')}. Rebuilding with the right deps..." + else + $stderr.puts "dev: Ruby #{version} runs as #{reported_ruby_version(ruby_root)} (its libruby is shadowed by another install). Rebuilding..." + end + install_ruby_with_version_manager(version, force: true) + else + install_ruby_with_version_manager(version) + end + + ruby_root = find_ruby_root(version) + unless ruby_root + $stderr.puts "dev: Ruby #{version} not found. Install: brew install rbenv ruby-build && rbenv install #{version}" + return nil + end + + verify_extensions!(ruby_root, version) + verify_reported_version!(ruby_root, version) + ruby_root + end + + # 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) + + ensure_ruby_build_deps!(env) + system(env, "rbenv", "uninstall", "--force", version, out: File::NULL, err: File::NULL) if force + $stderr.puts "dev: Installing Ruby #{version} with rbenv (one-time)..." + system(ruby_build_env(env, version), "rbenv", "install", "--skip-existing", version) + end + + # 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? + + Kernel.abort(<<~MSG) + dev: Ruby #{version} is built without required extension(s): #{missing.join(', ')}. + ruby-build skips an extension when its dev library is missing at build time. + Install the libraries and reinstall: + brew install #{RUBY_BUILD_BREW_DEPS.keys.join(' ')} # or apt: zlib1g-dev libssl-dev libyaml-dev libreadline-dev + rbenv uninstall -f #{version} && rbenv install #{version} + MSG + end + + # 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) + + REQUIRED_EXTENSIONS.reject do |ext| + system(ruby_bin, "-e", "require #{ext.inspect}", out: File::NULL, err: File::NULL) + end + end + + sig { params(ruby_root: String).returns(T::Boolean) } + def extensions_ok?(ruby_root) + missing_extensions(ruby_root).empty? + end + + # Abort if the provisioned ruby runs as a different version than requested. + # With --enable-shared, rubies differing only in teeny share a libruby soname + # (libruby.so.X.Y), so a wrong runtime search path (e.g. Homebrew's lib dir + # rpathed ahead of the ruby's own — carrying a same-minor Ruby) makes the + # 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 + + 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}. + Its binary is loading another Ruby's shared libruby (same-soname runtime search path collision). + Reinstall it: rbenv uninstall -f #{version} && dev up + 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 + + # The RUBY_VERSION the ruby at ruby_root actually reports when executed, or + # 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) + + out = IO.popen([ruby_bin, "-e", "print RUBY_VERSION"], err: File::NULL, &:read) + version = out&.strip + (version && !version.empty?) ? version : nil + end + + # 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") + + RUBY_BUILD_BREW_DEPS.each_key do |formula| + next if system(env, "brew", "list", "--versions", formula, out: File::NULL, err: File::NULL) + system(env, "brew", "install", formula) + end + end + + # Augment the install env so ruby-build links the brew-provided libraries. Adds a + # `--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 + + configure_opts = RUBY_BUILD_BREW_DEPS.filter_map do |formula, flag| + dir = brew_prefix_for(formula) + "--with-#{flag}-dir=#{dir}" if dir + end + + # -rpath alongside -L: on Linuxbrew, ruby-build links miniruby against brew's + # libs (e.g. libcrypt.so.2 from libxcrypt) but bakes no runtime search path, so + # the just-built miniruby dies with "libcrypt.so.2: cannot open shared object" + # mid-build. Baking the brew lib dir into the rpath makes the built ruby find + # its brew libs at runtime. Harmless on macOS (rpath to an already-found dir). + lib = File.join(prefix, "lib") + + # rpath ORDER is load-bearing: brew's lib dir can carry its own Ruby, and + # rubies differing only in teeny share a libruby soname (libruby.so.X.Y). + # If brew's dir is searched first, the freshly built ruby silently loads — + # and runs as — brew's version (e.g. a 4.0.5 build reporting 4.0.6). Rpath + # the build's own lib dir ahead of brew's so its libruby always wins. + own_lib = File.join(rbenv_version_prefix(version), "lib") + + env.merge( + "RUBY_CONFIGURE_OPTS" => [env["RUBY_CONFIGURE_OPTS"], *configure_opts].compact.reject(&:empty?).join(" "), + "PKG_CONFIG_PATH" => [File.join(prefix, "lib", "pkgconfig"), ENV["PKG_CONFIG_PATH"]].compact.reject(&:empty?).join(":"), + "CPPFLAGS" => [ENV["CPPFLAGS"], "-I#{File.join(prefix, "include")}"].compact.reject(&:empty?).join(" "), + "LDFLAGS" => [ENV["LDFLAGS"], "-L#{lib}", "-Wl,-rpath,#{own_lib}", "-Wl,-rpath,#{lib}"].compact.reject(&:empty?).join(" "), + ) + end + + # 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 + out = IO.popen(["brew", "--prefix"], err: File::NULL, &:read) + out&.strip + rescue Errno::ENOENT + nil + end + (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) + <<~LISP + (provide "ruby" "#{ruby_version}") + + (when-let ((ruby-root (env/get "RUBY_ROOT"))) + (env/remove-from-pathlist "PATH" (path-concat ruby-root "bin")) + (when-let ((gem-root (env/get "GEM_ROOT"))) + (env/remove-from-pathlist "PATH" (path-concat gem-root "bin"))) + (when-let ((gem-home (env/get "GEM_HOME"))) + (env/remove-from-pathlist "PATH" (path-concat gem-home "bin")) + (env/remove-from-pathlist "GEM_PATH" gem-home))) + + (env/set "GEM_PATH" ()) + (env/set "GEM_HOME" ()) + (env/set "RUBYOPT" ()) + + (env/set "RUBY_ROOT" "#{ruby_root}") + (env/prepend-to-pathlist "PATH" "#{File.join(ruby_root, "bin")}") + (env/set "RUBY_ENGINE" "ruby") + (env/set "RUBY_VERSION" "#{ruby_version}") + (env/set "GEM_ROOT" "#{gem_root}") + + (when-let ((gem-root (env/get "GEM_ROOT"))) + (env/prepend-to-pathlist "GEM_PATH" gem-root) + (env/prepend-to-pathlist "PATH" (path-concat gem-root "bin"))) + + (let ((gem-home + (path-concat (env/get "HOME") ".gem" (env/get "RUBY_ENGINE") (env/get "RUBY_VERSION")))) + (do + (env/set "GEM_HOME" gem-home) + (env/prepend-to-pathlist "GEM_PATH" gem-home) + (env/prepend-to-pathlist "PATH" (path-concat gem-home "bin")))) + LISP + end + + # Ensure the shadowenv activation hook is in the user's shell RC. + # + # Delegates the shell → RC-file mapping and the idempotent append to the + # shared Dev::ShellRcHook installer; this module only declares its snippet + # per shell. "shadowenv init" counts as already installed so hand-added + # 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)", + present_markers: ["shadowenv init"], + snippets: { + zsh: <<~SNIPPET.chomp, + eval "$(shadowenv init zsh)" + # Optional: show active shadowenv in prompt: setopt PROMPT_SUBST && PROMPT='$(shadowenv prompt-widget)'"$PROMPT" + SNIPPET + bash: <<~SNIPPET.chomp, + eval "$(shadowenv init bash)" + # Optional: show active shadowenv in prompt: PS1='$(shadowenv prompt-widget)'"$PS1" + SNIPPET + fish: <<~SNIPPET.chomp, + shadowenv init fish | source + # Optional: see https://shopify.github.io/shadowenv/best-practices/#prompt-widget for fish + SNIPPET + }, + ) + rescue => e + $stderr.puts "dev: Could not add shadowenv hook to shell profile: #{e.message}" + false + end + end +end diff --git a/lib/dev/shadowenv_unreal.rb b/lib/dev/shadowenv_unreal.rb new file mode 100644 index 0000000..16f2d7e --- /dev/null +++ b/lib/dev/shadowenv_unreal.rb @@ -0,0 +1,144 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "pathname" + +module Dev + # Shadowenv Unreal Engine provisioning: resolves the UE engine root, generates + # .shadowenv.d/530_unreal.lisp so UE_ROOT, UE_PROJECT, and engine binaries + # are available for IDE code intelligence (CLion, Rider). + # + # Unlike LLVM, Unreal Engine installs vary widely. The engine root must be + # specified explicitly via ue_root parameter or UE_ROOT env var. Common + # detection paths are checked as a fallback. + # + # 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 = 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) + + SEARCH_PATHS.each do |path| + expanded = File.expand_path(path) + return expanded if valid_ue_root?(expanded) + end + nil + end + + # 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) + content = File.read(lisp_path) + content.include?(%(provide "unreal")) && content.include?(ue_root) + end + + # Full provisioning: write .shadowenv.d/530_unreal.lisp, trust shadowenv. + # Idempotent. Returns true on success, false if UE root is nil. + # + # @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 + $stderr.puts "dev: Unreal Engine not found. Set UE_ROOT or install to a known location." + return false + end + + shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") + FileUtils.mkdir_p(shadowenv_d) + lisp_path = File.join(shadowenv_d, LISP_FILENAME) + File.write(lisp_path, generate_unreal_lisp(root, ue_project:)) + + Dir.chdir(project_root.to_s) do + system("shadowenv", "trust", out: File::NULL, err: File::NULL) + end + + true + end + + # 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 + + # Generate the shadowenv lisp that sets UE_ROOT, UE_PROJECT, and + # prepends engine binaries to PATH. + # + # @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 + (provide "unreal" "#{ue_root}") + + (env/set "UE_ROOT" "#{ue_root}") + (env/prepend-to-pathlist "PATH" "#{bin}") + LISP + lisp += <<~LISP if ue_project + (env/set "UE_PROJECT" "#{ue_project}") + LISP + lisp + end + + # --- internal helpers ------------------------------------------------ + + # 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. + # + # @param ruby_platform [String] the platform triple to map (defaults to + # the running interpreter's) + sig { params(ruby_platform: String).returns(String) } + def platform_subdir(ruby_platform = RUBY_PLATFORM) + if ruby_platform.include?("darwin") + "Mac" + elsif ruby_platform.include?("linux") + "Linux" + else + "Win64" + end + end + end +end diff --git a/lib/dev/shadowenv_xcode.rb b/lib/dev/shadowenv_xcode.rb new file mode 100644 index 0000000..0bf64db --- /dev/null +++ b/lib/dev/shadowenv_xcode.rb @@ -0,0 +1,66 @@ +# typed: strict +# frozen_string_literal: true + +require "fileutils" +require "pathname" + +module Dev + # 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) + + File.read(lisp_path).include?(%((env/set "DEVELOPER_DIR" "#{developer_dir}"))) + end + + # Write the lisp and trust shadowenv. Idempotent. + # + # @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) + File.write(File.join(shadowenv_d, LISP_FILENAME), generate_xcode_lisp(version, developer_dir)) + + Dir.chdir(project_root.to_s) do + system("shadowenv", "trust", out: File::NULL, err: File::NULL) + end + + true + end + + # @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}") + + (env/set "DEVELOPER_DIR" "#{developer_dir}") + LISP + end + end +end diff --git a/lib/dev/shell_rc_hook.rb b/lib/dev/shell_rc_hook.rb index aae5cb3..141e28f 100644 --- a/lib/dev/shell_rc_hook.rb +++ b/lib/dev/shell_rc_hook.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "pathname" @@ -13,18 +14,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 +45,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 +76,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 +92,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..b5f9fa4 100644 --- a/lib/dev/skill_installer.rb +++ b/lib/dev/skill_installer.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require "fileutils" @@ -17,13 +18,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 +38,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 +55,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 +89,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 +109,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 +123,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 +136,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 +148,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..fa31c78 100644 --- a/lib/rake_test_argv.rb +++ b/lib/rake_test_argv.rb @@ -1,5 +1,13 @@ +# typed: strict # frozen_string_literal: true +# Required here (not just in src/dev.rb) because bin/test.rb loads this +# helper standalone — it never requires "dev", where sorbet-runtime is +# centralized for the Dev module tree. +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 +17,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 deleted file mode 100644 index e825272..0000000 --- a/lib/shadowenv_llvm.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -require "fileutils" - -# Shadowenv LLVM provisioning: resolves the Homebrew LLVM prefix, generates -# .shadowenv.d/520_llvm.lisp so clang, clang-format, clang-tidy, and lld are -# available in PATH, and CC/CXX point to the Homebrew compiler. -# -# Skipped on Linux / CI where Linuxbrew tools are already in PATH. -module ShadowenvLlvm - 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. - def detect_llvm_prefix - FORMULA_NAMES.each do |name| - prefix = brew_prefix_for(name) - return prefix if prefix - end - nil - end - - # 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. - 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) - content = File.read(lisp_path) - content.include?(%(provide "llvm")) && content.include?(llvm_prefix) - end - - # Full provisioning: write .shadowenv.d/520_llvm.lisp, trust shadowenv. - # Idempotent. Returns true on success, false if LLVM prefix is nil. - def setup!(project_root:, llvm_prefix: nil) - prefix = llvm_prefix || detect_llvm_prefix - unless prefix - $stderr.puts "dev: LLVM not found via Homebrew. Run: brew install llvm" - return false - end - - shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - lisp_path = File.join(shadowenv_d, LISP_FILENAME) - File.write(lisp_path, generate_llvm_lisp(prefix)) - - Dir.chdir(project_root.to_s) do - system("shadowenv", "trust", out: File::NULL, err: File::NULL) - end - - true - end - - # Returns true on Linux or when CI env is set -- environments where - # Linuxbrew puts LLVM tools in PATH and shadowenv provisioning is - # unnecessary. - 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. - def generate_llvm_lisp(llvm_prefix) - bin = File.join(llvm_prefix, "bin") - lib_cxx = File.join(llvm_prefix, "lib", "c++") - <<~LISP - (provide "llvm" "#{llvm_prefix}") - - (env/prepend-to-pathlist "PATH" "#{bin}") - (env/set "CC" "#{File.join(bin, "clang")}") - (env/set "CXX" "#{File.join(bin, "clang++")}") - (env/set "LDFLAGS" "-L#{lib_cxx} -Wl,-rpath,#{lib_cxx}") - LISP - end - - # 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"). - def project_needs_llvm?(project_root) - lockfile = Pathname(project_root) / "build-deps.lock" - return false unless lockfile.exist? - - content = lockfile.read - content.match?(/^llvm:\s*$/) || content.match?(/^brew llvm\b/) - end - - # --- internal helpers ------------------------------------------------ - - 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) - prefix = out&.strip - (prefix && !prefix.empty? && File.directory?(prefix)) ? prefix : nil - end -end diff --git a/lib/shadowenv_lua.rb b/lib/shadowenv_lua.rb deleted file mode 100644 index c194aa5..0000000 --- a/lib/shadowenv_lua.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -require "fileutils" - -# 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 - class BrewInstallError < StandardError; end - - LISP_FILENAME = "510_lua.lisp" - - module_function - - # Fast-path check: does .shadowenv.d/510_lua.lisp exist and provision - # the correct Lua version? - # - # @param lua_version [String] e.g. "5.1" - # @param project_root [Pathname, String] project root directory - # @return [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) - - content = File.read(lisp_path) - content.include?(%(provide "lua" "#{lua_version}")) - end - - # Full provisioning: write .shadowenv.d/510_lua.lisp, trust shadowenv. - # - # @param lua_version [String] e.g. "5.1" - # @param project_root [Pathname, String] project root directory - # @return [true] - # @raise [BrewInstallError] if Homebrew lua or luarocks cannot be installed - def setup!(lua_version:, project_root:) - ensure_homebrew_lua!(lua_version) - - shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - lisp_path = File.join(shadowenv_d, LISP_FILENAME) - File.write(lisp_path, generate_lua_lisp(lua_version)) - - Dir.chdir(project_root.to_s) do - Kernel.system("shadowenv", "trust", out: File::NULL, err: File::NULL) - end - - true - end - - # Generate the shadowenv lisp for Lua environment isolation. - # - # @param lua_version [String] e.g. "5.1" - # @return [String] lisp source - def generate_lua_lisp(lua_version) - lua_formula = "lua@#{lua_version}" - <<~LISP - (provide "lua" "#{lua_version}") - - (when-let ((lua-root (env/get "LUA_ROOT"))) - (env/remove-from-pathlist "PATH" (path-concat lua-root "bin"))) - - (env/set "LUA_ROOT" "/opt/homebrew/opt/#{lua_formula}") - (env/prepend-to-pathlist "PATH" "/opt/homebrew/opt/#{lua_formula}/bin") - (env/prepend-to-pathlist "PATH" "/opt/homebrew/opt/luarocks/bin") - - (let ((modules (path-concat (env/get "SHADOWENV_PROJECT_DIR") "lua_modules"))) - (env/set "LUA_PATH" - (string-append - (path-concat modules "share/lua/#{lua_version}/?.lua") ";" - (path-concat modules "share/lua/#{lua_version}/?/init.lua") ";" - "./?.lua;./?/init.lua;;")) - (env/set "LUA_CPATH" - (string-append - (path-concat modules "lib/lua/#{lua_version}/?.so") ";;"))) - LISP - end - - # Ensure Homebrew lua and luarocks are installed. - # - # @param lua_version [String] e.g. "5.1" - # @raise [BrewInstallError] if brew install fails - def ensure_homebrew_lua!(lua_version) - formula = "lua@#{lua_version}" - unless Kernel.system("brew", "list", formula, out: File::NULL, err: File::NULL) - $stderr.puts "dev: Installing #{formula} via Homebrew..." - unless Kernel.system("brew", "install", formula) - raise BrewInstallError, "brew install #{formula} failed" - end - end - unless Kernel.system("brew", "list", "luarocks", out: File::NULL, err: File::NULL) - $stderr.puts "dev: Installing luarocks via Homebrew..." - unless Kernel.system("brew", "install", "luarocks") - raise BrewInstallError, "brew install luarocks failed" - end - end - end -end diff --git a/lib/shadowenv_python.rb b/lib/shadowenv_python.rb deleted file mode 100644 index ea29552..0000000 --- a/lib/shadowenv_python.rb +++ /dev/null @@ -1,150 +0,0 @@ -# frozen_string_literal: true - -require "fileutils" - -# Shadowenv Python provisioning: installs the interpreter via Homebrew -# (python@<version>), creates a project-local .venv with it, and generates -# .shadowenv.d/540_python.lisp so the venv's bin is first on PATH and -# VIRTUAL_ENV points at it for every dev command. -# -# Mirrors ShadowenvRuby/ShadowenvLua. Triggered when dependencies.rb declares -# `python "3.12"`. This module owns only the interpreter + the (empty) venv; -# the package set is installed into that venv by Dev::Deps::PipIntegration on -# `dev install-deps`, exactly as LuaRocks fills lua_modules/. -module ShadowenvPython - class BrewInstallError < StandardError; end - - LISP_FILENAME = "540_python.lisp" - VENV_DIR = ".venv" - - module_function - - # Fast-path check: does .shadowenv.d/540_python.lisp provision this version - # AND does the project venv still exist? The venv check means a deleted .venv - # (or a fresh clone) re-triggers setup! rather than leaving a dangling PATH. - # - # @param python_version [String] e.g. "3.12" - # @param project_root [Pathname, String] - # @return [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) - return false unless File.directory?(File.join(project_root.to_s, VENV_DIR)) - - File.read(lisp_path).include?(%(provide "python" "#{python_version}")) - end - - # Full provisioning: ensure the interpreter + venv exist, write the lisp, - # trust shadowenv. Idempotent. - # - # @param python_version [String] e.g. "3.12" - # @param project_root [Pathname, String] - # @return [true] - def setup!(python_version:, project_root:) - venv_path = ensure_venv!(python_version:, project_root:) - - shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - File.write(File.join(shadowenv_d, LISP_FILENAME), generate_python_lisp(python_version, venv_path)) - - Dir.chdir(project_root.to_s) do - Kernel.system("shadowenv", "trust", out: File::NULL, err: File::NULL) - end - true - end - - # Ensure Homebrew python@<version> is installed and a project-local .venv - # exists, built with that exact interpreter. Idempotent and safe to call from - # both setup! (per command) and PipIntegration (install-deps), so the venv is - # guaranteed present before packages install into it. - # - # @param python_version [String] e.g. "3.12" - # @param project_root [Pathname, String] - # @return [String] absolute path to the venv - # @raise [BrewInstallError] if the interpreter or venv cannot be created - def ensure_venv!(python_version:, project_root:) - python_bin = ensure_homebrew_python!(python_version) - venv_path = File.join(project_root.to_s, VENV_DIR) - venv_python = File.join(venv_path, "bin", "python") - - unless File.executable?(venv_python) - raise BrewInstallError, "python -m venv #{venv_path} failed" unless Kernel.system(python_bin, "-m", "venv", venv_path) - end - - ensure_pip!(venv_python) - venv_path - end - - # Guarantee pip is importable inside the venv. `python -m venv` normally seeds - # pip via ensurepip, but some Homebrew interpreters produce a venv without it; - # bootstrap it so package installs (PipIntegration) don't fail on a bare venv. - # - # @param venv_python [String] path to the venv's python - # @raise [BrewInstallError] if pip cannot be made available - def ensure_pip!(venv_python) - return if system(venv_python, "-m", "pip", "--version", out: File::NULL, err: File::NULL) - - raise BrewInstallError, "could not bootstrap pip in the venv" unless Kernel.system(venv_python, "-m", "ensurepip", "--upgrade") - end - - # Generate the shadowenv lisp that activates the project venv: VIRTUAL_ENV set, - # its bin prepended to PATH, and PYTHONHOME cleared (a stray PYTHONHOME makes a - # venv resolve the wrong stdlib). Mirrors what a venv's activate script does. - # - # The venv path is baked in as an absolute literal (shadowenv exposes no - # reliable project-dir variable — SHADOWENV_PROJECT_DIR reads empty, and an - # empty path-concat panics the loader), matching how ShadowenvRuby/Lua bake - # their absolute Homebrew/rbenv roots. - # - # @param python_version [String] e.g. "3.12" - # @param venv_path [String] absolute path to the project venv - # @return [String] lisp source - def generate_python_lisp(python_version, venv_path) - venv = File.expand_path(venv_path) - <<~LISP - (provide "python" "#{python_version}") - - (when-let ((old (env/get "VIRTUAL_ENV"))) - (env/remove-from-pathlist "PATH" (path-concat old "bin"))) - - (env/set "PYTHONHOME" ()) - (env/set "VIRTUAL_ENV" "#{venv}") - (env/prepend-to-pathlist "PATH" "#{File.join(venv, "bin")}") - LISP - end - - # Ensure brew python@<version> is present; return the path to its versioned - # interpreter (e.g. .../bin/python3.12), falling back to the formula's python3. - # - # @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 - def ensure_homebrew_python!(python_version) - formula = "python@#{python_version}" - unless Kernel.system("brew", "list", formula, out: File::NULL, err: File::NULL) - $stderr.puts "dev: Installing #{formula} via Homebrew..." - raise BrewInstallError, "brew install #{formula} failed" unless Kernel.system("brew", "install", formula) - end - - prefix = brew_prefix_for(formula) - raise BrewInstallError, "could not resolve Homebrew prefix for #{formula}" unless prefix - - versioned = File.join(prefix, "bin", "python#{python_version}") - return versioned if File.executable?(versioned) - - fallback = File.join(prefix, "bin", "python3") - return fallback if File.executable?(fallback) - - raise BrewInstallError, "no python interpreter found under #{prefix}" - end - - # @param formula [String] Homebrew formula name - # @return [String, nil] brew --prefix for the formula, or nil when unavailable - 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) - prefix = out&.strip - (prefix && !prefix.empty? && File.directory?(prefix)) ? prefix : nil - end -end diff --git a/lib/shadowenv_ruby.rb b/lib/shadowenv_ruby.rb deleted file mode 100644 index 8cf36a4..0000000 --- a/lib/shadowenv_ruby.rb +++ /dev/null @@ -1,390 +0,0 @@ -# frozen_string_literal: true - -require "fileutils" -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 - MIN_RUBY = Gem::Requirement.new(">= 2.7.0") - LISP_FILENAME = "510_ruby.lisp" - - # Stdlib C-extensions every dev workflow depends on. rbenv/ruby-build silently - # *skips* an extension when its dev library is missing at compile time, so a Ruby - # can install "successfully" yet blow up on the first `require` deep inside bundler - # ("cannot load such file -- zlib"). We make that failure mode impossible: provision - # the libraries below before building, and hard-verify these after. - REQUIRED_EXTENSIONS = %w[zlib openssl psych].freeze - - # 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 - - 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. - 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") - 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") - return - end - version - end - - # Returns the version string of the Homebrew-installed Ruby, or nil. - def detect_homebrew_ruby_version - prefix = brew_prefix_for("ruby") - return nil unless prefix - # /opt/homebrew/Cellar/ruby/4.0.1 -> "4.0.1" - # /opt/homebrew/Cellar/ruby/4.0.6_1 -> "4.0.6" - # The keg dirname carries brew's PKG version, whose "_N" formula-revision - # suffix (a rebuild of the same upstream Ruby) is not part of RUBY_VERSION - # and is rejected by Gem::Version - so only the numeric prefix may leak out. - realpath = File.realpath(prefix) rescue prefix - version = File.basename(realpath)[/\A\d+(?:\.\d+)+/] - return version if version - # Fallback: ask the Homebrew ruby binary directly - ruby_bin = File.join(prefix, "bin", "ruby") - return nil unless File.executable?(ruby_bin) - out = IO.popen([ruby_bin, "-e", "puts RUBY_VERSION"], err: File::NULL, &:read) - v = out&.strip - (v && !v.empty?) ? v : nil - end - - # 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. - def ensure!(ruby_version:, project_root:) - return if provisioned?(ruby_version, project_root: project_root) - - setup!(ruby_version: ruby_version, project_root: project_root) - end - - # Returns true when .shadowenv.d/510_ruby.lisp exists and already - # provisions the requested version. This is the fast-path check. - 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) - content = File.read(lisp_path) - content.include?(%(provide "ruby" "#{ruby_version}")) - end - - # 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. - def setup!(ruby_version:, project_root:) - root = project_root.to_s - ruby_root = ensure_ruby_installed!(ruby_version) - return false unless ruby_root - - shadowenv_d = File.join(root, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - lisp_path = File.join(shadowenv_d, LISP_FILENAME) - File.write(lisp_path, generate_ruby_lisp(ruby_root, ruby_version)) - - ruby_version_path = File.join(root, ".ruby-version") - File.write(ruby_version_path, "#{ruby_version}\n") - - Dir.chdir(root) do - system("shadowenv", "trust", out: File::NULL, err: File::NULL) - end - - ensure_shadowenv_shell_hook! - true - end - - # --- internal helpers ------------------------------------------------ - - def find_ruby_root(version) - rbenv_path = rbenv_version_prefix(version) - return rbenv_path if File.directory?(rbenv_path) - nil - end - - # 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. - 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 - - 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) - prefix = out&.strip - (prefix && !prefix.empty? && File.directory?(prefix)) ? prefix : nil - end - - def path_with_brew_bin - prefix = ENV["HOMEBREW_PREFIX"] - prefix ||= begin - out = IO.popen(["brew", "prefix"], err: File::NULL, &:read) - out&.strip - end - return ENV["PATH"] unless prefix && File.directory?(prefix) - "#{File.join(prefix, "bin")}:#{ENV["PATH"]}" - end - - # Resolve a usable Ruby for the version: install it if absent, repair it if a - # 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. - def ensure_ruby_installed!(version) - ruby_root = find_ruby_root(version) - - if ruby_root && extensions_ok?(ruby_root) && reported_version_ok?(ruby_root, version) - return ruby_root - elsif ruby_root - missing = missing_extensions(ruby_root) - if missing.any? - $stderr.puts "dev: Ruby #{version} is missing required extension(s): #{missing.join(', ')}. Rebuilding with the right deps..." - else - $stderr.puts "dev: Ruby #{version} runs as #{reported_ruby_version(ruby_root)} (its libruby is shadowed by another install). Rebuilding..." - end - install_ruby_with_version_manager(version, force: true) - else - install_ruby_with_version_manager(version) - end - - ruby_root = find_ruby_root(version) - unless ruby_root - $stderr.puts "dev: Ruby #{version} not found. Install: brew install rbenv ruby-build && rbenv install #{version}" - return nil - end - - verify_extensions!(ruby_root, version) - verify_reported_version!(ruby_root, version) - ruby_root - end - - # 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. - 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) - - ensure_ruby_build_deps!(env) - system(env, "rbenv", "uninstall", "--force", version, out: File::NULL, err: File::NULL) if force - $stderr.puts "dev: Installing Ruby #{version} with rbenv (one-time)..." - system(ruby_build_env(env, version), "rbenv", "install", "--skip-existing", version) - end - - # 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. - def verify_extensions!(ruby_root, version) - missing = missing_extensions(ruby_root) - return if missing.empty? - - Kernel.abort(<<~MSG) - dev: Ruby #{version} is built without required extension(s): #{missing.join(', ')}. - ruby-build skips an extension when its dev library is missing at build time. - Install the libraries and reinstall: - brew install #{RUBY_BUILD_BREW_DEPS.keys.join(' ')} # or apt: zlib1g-dev libssl-dev libyaml-dev libreadline-dev - rbenv uninstall -f #{version} && rbenv install #{version} - MSG - end - - # The subset of REQUIRED_EXTENSIONS the given Ruby cannot `require`. A Ruby whose - # binary is missing entirely counts as missing all of them. - def missing_extensions(ruby_root) - ruby_bin = File.join(ruby_root, "bin", "ruby") - return REQUIRED_EXTENSIONS.dup unless File.executable?(ruby_bin) - - REQUIRED_EXTENSIONS.reject do |ext| - system(ruby_bin, "-e", "require #{ext.inspect}", out: File::NULL, err: File::NULL) - end - end - - def extensions_ok?(ruby_root) - missing_extensions(ruby_root).empty? - end - - # Abort if the provisioned ruby runs as a different version than requested. - # With --enable-shared, rubies differing only in teeny share a libruby soname - # (libruby.so.X.Y), so a wrong runtime search path (e.g. Homebrew's lib dir - # rpathed ahead of the ruby's own — carrying a same-minor Ruby) makes the - # 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. - def verify_reported_version!(ruby_root, version) - reported = reported_ruby_version(ruby_root) - return if reported == version - - Kernel.abort(version_hijack_message(ruby_root, version, reported)) - end - - def version_hijack_message(ruby_root, version, reported) - <<~MSG - dev: Ruby #{version} at #{ruby_root} runs as RUBY_VERSION #{reported.inspect}. - Its binary is loading another Ruby's shared libruby (same-soname runtime search path collision). - Reinstall it: rbenv uninstall -f #{version} && dev up - MSG - end - - def reported_version_ok?(ruby_root, version) - reported_ruby_version(ruby_root) == version - end - - # The RUBY_VERSION the ruby at ruby_root actually reports when executed, or - # 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. - def reported_ruby_version(ruby_root) - ruby_bin = File.join(ruby_root, "bin", "ruby") - return nil unless File.executable?(ruby_bin) - - out = IO.popen([ruby_bin, "-e", "print RUBY_VERSION"], err: File::NULL, &:read) - version = out&.strip - (version && !version.empty?) ? version : nil - end - - # 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. - def ensure_ruby_build_deps!(env) - return unless system(env, "command -v brew >/dev/null 2>&1") - - RUBY_BUILD_BREW_DEPS.each_key do |formula| - next if system(env, "brew", "list", "--versions", formula, out: File::NULL, err: File::NULL) - system(env, "brew", "install", formula) - end - end - - # Augment the install env so ruby-build links the brew-provided libraries. Adds a - # `--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. - def ruby_build_env(env, version) - prefix = homebrew_prefix - return env unless prefix - - configure_opts = RUBY_BUILD_BREW_DEPS.filter_map do |formula, flag| - dir = brew_prefix_for(formula) - "--with-#{flag}-dir=#{dir}" if dir - end - - # -rpath alongside -L: on Linuxbrew, ruby-build links miniruby against brew's - # libs (e.g. libcrypt.so.2 from libxcrypt) but bakes no runtime search path, so - # the just-built miniruby dies with "libcrypt.so.2: cannot open shared object" - # mid-build. Baking the brew lib dir into the rpath makes the built ruby find - # its brew libs at runtime. Harmless on macOS (rpath to an already-found dir). - lib = File.join(prefix, "lib") - - # rpath ORDER is load-bearing: brew's lib dir can carry its own Ruby, and - # rubies differing only in teeny share a libruby soname (libruby.so.X.Y). - # If brew's dir is searched first, the freshly built ruby silently loads — - # and runs as — brew's version (e.g. a 4.0.5 build reporting 4.0.6). Rpath - # the build's own lib dir ahead of brew's so its libruby always wins. - own_lib = File.join(rbenv_version_prefix(version), "lib") - - env.merge( - "RUBY_CONFIGURE_OPTS" => [env["RUBY_CONFIGURE_OPTS"], *configure_opts].compact.reject(&:empty?).join(" "), - "PKG_CONFIG_PATH" => [File.join(prefix, "lib", "pkgconfig"), ENV["PKG_CONFIG_PATH"]].compact.reject(&:empty?).join(":"), - "CPPFLAGS" => [ENV["CPPFLAGS"], "-I#{File.join(prefix, "include")}"].compact.reject(&:empty?).join(" "), - "LDFLAGS" => [ENV["LDFLAGS"], "-L#{lib}", "-Wl,-rpath,#{own_lib}", "-Wl,-rpath,#{lib}"].compact.reject(&:empty?).join(" "), - ) - end - - # The Homebrew prefix (HOMEBREW_PREFIX, else `brew --prefix`), or nil when brew is - # unavailable. - def homebrew_prefix - prefix = ENV["HOMEBREW_PREFIX"] - prefix ||= begin - out = IO.popen(["brew", "--prefix"], err: File::NULL, &:read) - out&.strip - rescue Errno::ENOENT - nil - end - (prefix && !prefix.empty? && File.directory?(prefix)) ? prefix : nil - end - - 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 - - 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) - <<~LISP - (provide "ruby" "#{ruby_version}") - - (when-let ((ruby-root (env/get "RUBY_ROOT"))) - (env/remove-from-pathlist "PATH" (path-concat ruby-root "bin")) - (when-let ((gem-root (env/get "GEM_ROOT"))) - (env/remove-from-pathlist "PATH" (path-concat gem-root "bin"))) - (when-let ((gem-home (env/get "GEM_HOME"))) - (env/remove-from-pathlist "PATH" (path-concat gem-home "bin")) - (env/remove-from-pathlist "GEM_PATH" gem-home))) - - (env/set "GEM_PATH" ()) - (env/set "GEM_HOME" ()) - (env/set "RUBYOPT" ()) - - (env/set "RUBY_ROOT" "#{ruby_root}") - (env/prepend-to-pathlist "PATH" "#{File.join(ruby_root, "bin")}") - (env/set "RUBY_ENGINE" "ruby") - (env/set "RUBY_VERSION" "#{ruby_version}") - (env/set "GEM_ROOT" "#{gem_root}") - - (when-let ((gem-root (env/get "GEM_ROOT"))) - (env/prepend-to-pathlist "GEM_PATH" gem-root) - (env/prepend-to-pathlist "PATH" (path-concat gem-root "bin"))) - - (let ((gem-home - (path-concat (env/get "HOME") ".gem" (env/get "RUBY_ENGINE") (env/get "RUBY_VERSION")))) - (do - (env/set "GEM_HOME" gem-home) - (env/prepend-to-pathlist "GEM_PATH" gem-home) - (env/prepend-to-pathlist "PATH" (path-concat gem-home "bin")))) - LISP - end - - # Ensure the shadowenv activation hook is in the user's shell RC. - # - # Delegates the shell → RC-file mapping and the idempotent append to the - # shared Dev::ShellRcHook installer; this module only declares its snippet - # per shell. "shadowenv init" counts as already installed so hand-added - # hook lines (pre-dating the marker) are never duplicated. - # - # @return [Symbol, false] :added, :already_present, or false (unsupported shell) - def ensure_shadowenv_shell_hook! - Dev::ShellRcHook.new.ensure_snippet( - marker: "# Shadowenv (added by dev)", - present_markers: ["shadowenv init"], - snippets: { - zsh: <<~SNIPPET.chomp, - eval "$(shadowenv init zsh)" - # Optional: show active shadowenv in prompt: setopt PROMPT_SUBST && PROMPT='$(shadowenv prompt-widget)'"$PROMPT" - SNIPPET - bash: <<~SNIPPET.chomp, - eval "$(shadowenv init bash)" - # Optional: show active shadowenv in prompt: PS1='$(shadowenv prompt-widget)'"$PS1" - SNIPPET - fish: <<~SNIPPET.chomp, - shadowenv init fish | source - # Optional: see https://shopify.github.io/shadowenv/best-practices/#prompt-widget for fish - SNIPPET - }, - ) - rescue => e - $stderr.puts "dev: Could not add shadowenv hook to shell profile: #{e.message}" - false - end -end diff --git a/lib/shadowenv_unreal.rb b/lib/shadowenv_unreal.rb deleted file mode 100644 index 41544e1..0000000 --- a/lib/shadowenv_unreal.rb +++ /dev/null @@ -1,118 +0,0 @@ -# frozen_string_literal: true - -require "fileutils" - -# Shadowenv Unreal Engine provisioning: resolves the UE engine root, generates -# .shadowenv.d/530_unreal.lisp so UE_ROOT, UE_PROJECT, and engine binaries -# are available for IDE code intelligence (CLion, Rider). -# -# Unlike LLVM, Unreal Engine installs vary widely. The engine root must be -# specified explicitly via ue_root parameter or UE_ROOT env var. Common -# detection paths are checked as a fallback. -# -# Skipped on Linux / CI where builds happen inside a container with UE baked in. -module ShadowenvUnreal - 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 - - module_function - - # Returns the Unreal Engine root directory, or nil if not found. - # Checks: explicit env var, then well-known paths. - def detect_ue_root - from_env = ENV["UE_ROOT"] - return from_env if from_env && valid_ue_root?(from_env) - - SEARCH_PATHS.each do |path| - expanded = File.expand_path(path) - return expanded if valid_ue_root?(expanded) - end - nil - end - - # Returns true when .shadowenv.d/530_unreal.lisp exists and provisions - # from the given UE root. - 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) - content = File.read(lisp_path) - content.include?(%(provide "unreal")) && content.include?(ue_root) - end - - # Full provisioning: write .shadowenv.d/530_unreal.lisp, trust shadowenv. - # Idempotent. Returns true on success, false if UE root is nil. - # - # @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) - def setup!(project_root:, ue_root: nil, ue_project: nil) - root = ue_root || detect_ue_root - unless root - $stderr.puts "dev: Unreal Engine not found. Set UE_ROOT or install to a known location." - return false - end - - shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - lisp_path = File.join(shadowenv_d, LISP_FILENAME) - File.write(lisp_path, generate_unreal_lisp(root, ue_project:)) - - Dir.chdir(project_root.to_s) do - system("shadowenv", "trust", out: File::NULL, err: File::NULL) - end - - true - end - - # Returns true on Linux or when CI env is set -- environments where - # UE is baked into the build container and shadowenv provisioning is - # unnecessary. - 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 sets UE_ROOT, UE_PROJECT, and - # prepends engine binaries to PATH. - # - # @param ue_root [String] Unreal Engine root directory - # @param ue_project [String, nil] optional path to .uproject file - # @return [String] - def generate_unreal_lisp(ue_root, ue_project: nil) - bin = File.join(ue_root, "Engine", "Binaries", platform_subdir) - lisp = <<~LISP - (provide "unreal" "#{ue_root}") - - (env/set "UE_ROOT" "#{ue_root}") - (env/prepend-to-pathlist "PATH" "#{bin}") - LISP - lisp += <<~LISP if ue_project - (env/set "UE_PROJECT" "#{ue_project}") - LISP - lisp - end - - # --- internal helpers ------------------------------------------------ - - # Validates that a directory looks like a UE engine root by checking - # for Engine/Build/Build.version. - def valid_ue_root?(path) - File.directory?(path) && File.exist?(File.join(path, "Engine", "Build", "Build.version")) - end - - # Returns the platform-specific binaries subdirectory. - def platform_subdir - if RUBY_PLATFORM.include?("darwin") - "Mac" - elsif RUBY_PLATFORM.include?("linux") - "Linux" - else - "Win64" - end - end -end diff --git a/lib/shadowenv_xcode.rb b/lib/shadowenv_xcode.rb deleted file mode 100644 index 1ad2ae7..0000000 --- a/lib/shadowenv_xcode.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -require "fileutils" - -# 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 - LISP_FILENAME = "520_xcode.lisp" - - module_function - - # Returns true when .shadowenv.d/520_xcode.lisp exists and already - # provisions the given developer dir. - 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) - - File.read(lisp_path).include?(%((env/set "DEVELOPER_DIR" "#{developer_dir}"))) - end - - # Write the lisp and trust shadowenv. Idempotent. - # - # @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 - def setup!(project_root:, version:, developer_dir:) - shadowenv_d = File.join(project_root.to_s, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - File.write(File.join(shadowenv_d, LISP_FILENAME), generate_xcode_lisp(version, developer_dir)) - - Dir.chdir(project_root.to_s) do - system("shadowenv", "trust", out: File::NULL, err: File::NULL) - end - - true - end - - # @param version [String] - # @param developer_dir [String] - # @return [String] - def generate_xcode_lisp(version, developer_dir) - <<~LISP - (provide "xcode" "#{version}") - - (env/set "DEVELOPER_DIR" "#{developer_dir}") - LISP - end -end diff --git a/sorbet/config b/sorbet/config index 48e47e6..2567062 100644 --- a/sorbet/config +++ b/sorbet/config @@ -3,3 +3,5 @@ --ignore=tmp/ --ignore=vendor/ --ignore=bin/ +# ai-flow worktrees carry a nested .ai-flow checkout whose RBIs collide with ours. +--ignore=/.ai-flow diff --git a/sorbet/rbi/gems/ast_transform@3.1.1.rbi b/sorbet/rbi/gems/ast_transform@3.1.1.rbi index 332965b..813a740 100644 --- a/sorbet/rbi/gems/ast_transform@3.1.1.rbi +++ b/sorbet/rbi/gems/ast_transform@3.1.1.rbi @@ -29,6 +29,39 @@ module ASTTransform end end +# Base class for read-only analysis passes: subclass, harvest state in +on_*+ handlers (always call +super+ so +# traversal continues), and expose results through readers. The walk still functionally rebuilds the tree — +# Parser::AST::Processor has no read-only mode — but +run+ discards the rebuilt tree, so handlers never need to +# care what they return. +# +# class SendCounter < ASTTransform::AbstractAnalysis +# attr_reader :count +# +# def initialize +# @count = 0 +# super +# end +# +# def on_send(node) +# @count += 1 +# super +# end +# end +# +# SendCounter.new.run(SourceParser.new.parse(source)).count +# +# source://ast_transform//lib/ast_transform/abstract_analysis.rb#26 +class ASTTransform::AbstractAnalysis < ::ASTTransform::AbstractProcessor + # Runs this analysis on +node+, discarding the rebuilt tree. + # Note: If you want to add one-time setup or result finalization, override this, then call super. + # + # @param node [Parser::AST::Node] The node to be analyzed. + # @return [ASTTransform::AbstractAnalysis] self, so callers can chain result readers off the run. + # + # source://ast_transform//lib/ast_transform/abstract_analysis.rb#33 + def run(node); end +end + # Shared traversal core for tree passes. Parser::AST::Processor is a rewriting walker — every visit functionally # rebuilds the tree — so transformation and analysis share one engine and differ only in what they keep: # AbstractTransformation's +run+ returns the rebuilt tree, AbstractAnalysis's +run+ discards it and returns the diff --git a/sorbet/rbi/gems/docile@1.4.1.rbi b/sorbet/rbi/gems/docile@1.4.1.rbi deleted file mode 100644 index 3113067..0000000 --- a/sorbet/rbi/gems/docile@1.4.1.rbi +++ /dev/null @@ -1,377 +0,0 @@ -# typed: true - -# DO NOT EDIT MANUALLY -# This is an autogenerated file for types exported from the `docile` gem. -# Please instead update this file by running `bin/tapioca gem docile`. - - -# Docile keeps your Ruby DSLs tame and well-behaved. -# -# source://docile//lib/docile/version.rb#3 -module Docile - extend ::Docile::Execution - - private - - # Execute a block in the context of an object whose methods represent the - # commands in a DSL. - # - # Use this method to execute an *imperative* DSL, which means that: - # - # 1. Each command mutates the state of the DSL context object - # 2. The return value of each command is ignored - # 3. The final return value is the original context object - # - # @example Use a String as a DSL - # Docile.dsl_eval("Hello, world!") do - # reverse! - # upcase! - # end - # #=> "!DLROW ,OLLEH" - # @example Use an Array as a DSL - # Docile.dsl_eval([]) do - # push 1 - # push 2 - # pop - # push 3 - # end - # #=> [1, 3] - # @note Use with an *imperative* DSL (commands modify the context object) - # @param args [Array] arguments to be passed to the block - # @param block [Proc] the block of DSL commands to be executed against the - # `dsl` context object - # @param dsl [Object] context object whose methods make up the DSL - # @return [Object] the `dsl` context object after executing the block - # - # source://docile//lib/docile.rb#45 - def dsl_eval(dsl, *args, **_arg2, &block); end - - # Execute a block in the context of an immutable object whose methods, - # and the methods of their return values, represent the commands in a DSL. - # - # Use this method to execute a *functional* DSL, which means that: - # - # 1. The original DSL context object is never mutated - # 2. Each command returns the next DSL context object - # 3. The final return value is the value returned by the last command - # - # @example Use a Float as a DSL - # Docile.dsl_eval_immutable(84.5) do - # fdiv(2) - # floor - # end - # #=> 42 - # @example Use a frozen String as a DSL - # Docile.dsl_eval_immutable("I'm immutable!".freeze) do - # reverse - # upcase - # end - # #=> "!ELBATUMMI M'I" - # @note Use with a *functional* DSL (commands return successor - # context objects) - # @param args [Array] arguments to be passed to the block - # @param block [Proc] the block of DSL commands to be executed against the - # `dsl` context object and successor return values - # @param dsl [Object] immutable context object whose methods make up the - # initial DSL - # @return [Object] the return value of the final command in the block - # - # source://docile//lib/docile.rb#128 - def dsl_eval_immutable(dsl, *args, **_arg2, &block); end - - # Execute a block in the context of an object whose methods represent the - # commands in a DSL, and return *the block's return value*. - # - # Use this method to execute an *imperative* DSL, which means that: - # - # 1. Each command mutates the state of the DSL context object - # 2. The return value of each command is ignored - # 3. The final return value is the original context object - # - # @example Use a String as a DSL - # Docile.dsl_eval_with_block_return("Hello, world!") do - # reverse! - # upcase! - # first - # end - # #=> "!" - # @example Use an Array as a DSL - # Docile.dsl_eval_with_block_return([]) do - # push "a" - # push "b" - # pop - # push "c" - # length - # end - # #=> 2 - # @note Use with an *imperative* DSL (commands modify the context object) - # @param args [Array] arguments to be passed to the block - # @param block [Proc] the block of DSL commands to be executed against the - # `dsl` context object - # @param dsl [Object] context object whose methods make up the DSL - # @return [Object] the return value from executing the block - # - # source://docile//lib/docile.rb#87 - def dsl_eval_with_block_return(dsl, *args, **_arg2, &block); end - - class << self - # Execute a block in the context of an object whose methods represent the - # commands in a DSL. - # - # Use this method to execute an *imperative* DSL, which means that: - # - # 1. Each command mutates the state of the DSL context object - # 2. The return value of each command is ignored - # 3. The final return value is the original context object - # - # @example Use a String as a DSL - # Docile.dsl_eval("Hello, world!") do - # reverse! - # upcase! - # end - # #=> "!DLROW ,OLLEH" - # @example Use an Array as a DSL - # Docile.dsl_eval([]) do - # push 1 - # push 2 - # pop - # push 3 - # end - # #=> [1, 3] - # @note Use with an *imperative* DSL (commands modify the context object) - # @param args [Array] arguments to be passed to the block - # @param block [Proc] the block of DSL commands to be executed against the - # `dsl` context object - # @param dsl [Object] context object whose methods make up the DSL - # @return [Object] the `dsl` context object after executing the block - # - # source://docile//lib/docile.rb#51 - def dsl_eval(dsl, *args, **_arg2, &block); end - - # Execute a block in the context of an immutable object whose methods, - # and the methods of their return values, represent the commands in a DSL. - # - # Use this method to execute a *functional* DSL, which means that: - # - # 1. The original DSL context object is never mutated - # 2. Each command returns the next DSL context object - # 3. The final return value is the value returned by the last command - # - # @example Use a Float as a DSL - # Docile.dsl_eval_immutable(84.5) do - # fdiv(2) - # floor - # end - # #=> 42 - # @example Use a frozen String as a DSL - # Docile.dsl_eval_immutable("I'm immutable!".freeze) do - # reverse - # upcase - # end - # #=> "!ELBATUMMI M'I" - # @note Use with a *functional* DSL (commands return successor - # context objects) - # @param args [Array] arguments to be passed to the block - # @param block [Proc] the block of DSL commands to be executed against the - # `dsl` context object and successor return values - # @param dsl [Object] immutable context object whose methods make up the - # initial DSL - # @return [Object] the return value of the final command in the block - # - # source://docile//lib/docile.rb#133 - def dsl_eval_immutable(dsl, *args, **_arg2, &block); end - - # Execute a block in the context of an object whose methods represent the - # commands in a DSL, and return *the block's return value*. - # - # Use this method to execute an *imperative* DSL, which means that: - # - # 1. Each command mutates the state of the DSL context object - # 2. The return value of each command is ignored - # 3. The final return value is the original context object - # - # @example Use a String as a DSL - # Docile.dsl_eval_with_block_return("Hello, world!") do - # reverse! - # upcase! - # first - # end - # #=> "!" - # @example Use an Array as a DSL - # Docile.dsl_eval_with_block_return([]) do - # push "a" - # push "b" - # pop - # push "c" - # length - # end - # #=> 2 - # @note Use with an *imperative* DSL (commands modify the context object) - # @param args [Array] arguments to be passed to the block - # @param block [Proc] the block of DSL commands to be executed against the - # `dsl` context object - # @param dsl [Object] context object whose methods make up the DSL - # @return [Object] the return value from executing the block - # - # source://docile//lib/docile.rb#94 - def dsl_eval_with_block_return(dsl, *args, **_arg2, &block); end - end -end - -# This is used to remove entries pointing to Docile's source files -# from {Exception#backtrace} and {Exception#backtrace_locations}. -# -# If {NoMethodError} is caught then the exception object will be extended -# by this module to add filter functionalities. -# -# @api private -# -# source://docile//lib/docile/backtrace_filter.rb#11 -module Docile::BacktraceFilter - # @api private - # - # source://docile//lib/docile/backtrace_filter.rb#14 - def backtrace; end - - # @api private - # - # source://docile//lib/docile/backtrace_filter.rb#19 - def backtrace_locations; end -end - -# @api private -# -# source://docile//lib/docile/backtrace_filter.rb#12 -Docile::BacktraceFilter::FILTER_PATTERN = T.let(T.unsafe(nil), Regexp) - -# Operates in the same manner as {FallbackContextProxy}, but replacing -# the primary `receiver` object with the result of each proxied method. -# -# This is useful for implementing DSL evaluation for immutable context -# objects. -# -# -# @api private -# @see Docile.dsl_eval_immutable -# -# source://docile//lib/docile/chaining_fallback_context_proxy.rb#17 -class Docile::ChainingFallbackContextProxy < ::Docile::FallbackContextProxy - # Proxy methods as in {FallbackContextProxy#method_missing}, replacing - # `receiver` with the returned value. - # - # @api private - # - # source://docile//lib/docile/chaining_fallback_context_proxy.rb#20 - def method_missing(method, *args, **_arg2, &block); end -end - -# A namespace for functions relating to the execution of a block against a -# proxy object. -# -# @api private -# -# source://docile//lib/docile/execution.rb#8 -module Docile::Execution - private - - # Execute a block in the context of an object whose methods represent the - # commands in a DSL, using a specific proxy class. - # - # @api private - # @param args [Array] arguments to be passed to the block - # @param block [Proc] the block of DSL commands to be executed - # @param dsl [Object] context object whose methods make up the - # (initial) DSL - # @param proxy_type [FallbackContextProxy, ChainingFallbackContextProxy] which class to instantiate as proxy context - # @return [Object] the return value of the block - # - # source://docile//lib/docile/execution.rb#19 - def exec_in_proxy_context(dsl, proxy_type, *args, **_arg3, &block); end - - class << self - # Execute a block in the context of an object whose methods represent the - # commands in a DSL, using a specific proxy class. - # - # @api private - # @param args [Array] arguments to be passed to the block - # @param block [Proc] the block of DSL commands to be executed - # @param dsl [Object] context object whose methods make up the - # (initial) DSL - # @param proxy_type [FallbackContextProxy, ChainingFallbackContextProxy] which class to instantiate as proxy context - # @return [Object] the return value of the block - # - # source://docile//lib/docile/execution.rb#51 - def exec_in_proxy_context(dsl, proxy_type, *args, **_arg3, &block); end - end -end - -# A proxy object with a primary receiver as well as a secondary -# fallback receiver. -# -# Will attempt to forward all method calls first to the primary receiver, -# and then to the fallback receiver if the primary does not handle that -# method. -# -# This is useful for implementing DSL evaluation in the context of an object. -# -# -# @api private -# @see Docile.dsl_eval -# -# source://docile//lib/docile/fallback_context_proxy.rb#20 -class Docile::FallbackContextProxy - # @api private - # @param fallback [Object] the fallback proxy target to which any methods - # not handled by `receiver` will be forwarded - # @param receiver [Object] the primary proxy target to which all methods - # initially will be forwarded - # @return [FallbackContextProxy] a new instance of FallbackContextProxy - # - # source://docile//lib/docile/fallback_context_proxy.rb#46 - def initialize(receiver, fallback); end - - # @api private - # @return [Array<Symbol>] Instance variable names, excluding - # {NON_PROXIED_INSTANCE_VARIABLES} - # - # source://docile//lib/docile/fallback_context_proxy.rb#85 - def instance_variables; end - - # Proxy all methods, excluding {NON_PROXIED_METHODS}, first to `receiver` - # and then to `fallback` if not found. - # - # @api private - # - # source://docile//lib/docile/fallback_context_proxy.rb#91 - def method_missing(method, *args, **_arg2, &block); end -end - -# The set of methods which will **not** fallback from the block's context -# to the dsl object. -# -# @api private -# -# source://docile//lib/docile/fallback_context_proxy.rb#30 -Docile::FallbackContextProxy::NON_FALLBACK_METHODS = T.let(T.unsafe(nil), Set) - -# The set of instance variables which are local to this object and hidden. -# All other instance variables will be copied in and out of this object -# from the scope in which this proxy was created. -# -# @api private -# -# source://docile//lib/docile/fallback_context_proxy.rb#35 -Docile::FallbackContextProxy::NON_PROXIED_INSTANCE_VARIABLES = T.let(T.unsafe(nil), Set) - -# The set of methods which will **not** be proxied, but instead answered -# by this object directly. -# -# @api private -# -# source://docile//lib/docile/fallback_context_proxy.rb#23 -Docile::FallbackContextProxy::NON_PROXIED_METHODS = T.let(T.unsafe(nil), Set) - -# The current version of this library -# -# source://docile//lib/docile/version.rb#5 -Docile::VERSION = T.let(T.unsafe(nil), String) diff --git a/sorbet/rbi/gems/simplecov-cobertura@3.2.0.rbi b/sorbet/rbi/gems/simplecov-cobertura@4.0.0.rbi similarity index 100% rename from sorbet/rbi/gems/simplecov-cobertura@3.2.0.rbi rename to sorbet/rbi/gems/simplecov-cobertura@4.0.0.rbi diff --git a/sorbet/rbi/gems/simplecov-html@0.13.2.rbi b/sorbet/rbi/gems/simplecov-html@0.13.2.rbi deleted file mode 100644 index c4eb02a..0000000 --- a/sorbet/rbi/gems/simplecov-html@0.13.2.rbi +++ /dev/null @@ -1,96 +0,0 @@ -# typed: true - -# DO NOT EDIT MANUALLY -# This is an autogenerated file for types exported from the `simplecov-html` gem. -# Please instead update this file by running `bin/tapioca gem simplecov-html`. - - -# source://simplecov-html//lib/simplecov-html.rb#15 -module SimpleCov; end - -# source://simplecov-html//lib/simplecov-html.rb#16 -module SimpleCov::Formatter; end - -# source://simplecov-html//lib/simplecov-html.rb#17 -class SimpleCov::Formatter::HTMLFormatter - # @return [HTMLFormatter] a new instance of HTMLFormatter - # - # source://simplecov-html//lib/simplecov-html.rb#26 - def initialize; end - - # source://simplecov-html//lib/simplecov-html.rb#33 - def format(result); end - - private - - # source://simplecov-html//lib/simplecov-html.rb#93 - def asset_inline(name); end - - # source://simplecov-html//lib/simplecov-html.rb#79 - def asset_output_path; end - - # source://simplecov-html//lib/simplecov-html.rb#87 - def assets_path(name); end - - # @return [Boolean] - # - # source://simplecov-html//lib/simplecov-html.rb#48 - def branchable_result?; end - - # source://simplecov-html//lib/simplecov-html.rb#124 - def coverage_css_class(covered_percent); end - - # source://simplecov-html//lib/simplecov-html.rb#120 - def covered_percent(percent); end - - # Returns a table containing the given source files - # - # source://simplecov-html//lib/simplecov-html.rb#111 - def formatted_file_list(title, source_files); end - - # Returns the html for the given source_file - # - # source://simplecov-html//lib/simplecov-html.rb#104 - def formatted_source_file(source_file); end - - # Return a (kind of) unique id for the source file given. Uses SHA1 on path for the id - # - # source://simplecov-html//lib/simplecov-html.rb#145 - def id(source_file); end - - # @return [Boolean] - # - # source://simplecov-html//lib/simplecov-html.rb#55 - def line_status?(source_file, line); end - - # source://simplecov-html//lib/simplecov-html.rb#157 - def link_to_source_file(source_file); end - - # source://simplecov-html//lib/simplecov-html.rb#63 - def output_message(result); end - - # source://simplecov-html//lib/simplecov-html.rb#75 - def output_path; end - - # source://simplecov-html//lib/simplecov-html.rb#153 - def shortened_filename(source_file); end - - # source://simplecov-html//lib/simplecov-html.rb#134 - def strength_css_class(covered_strength); end - - # Returns the an erb instance for the template of given name - # - # source://simplecov-html//lib/simplecov-html.rb#71 - def template(name); end - - # source://simplecov-html//lib/simplecov-html.rb#149 - def timeago(time); end -end - -# Only have a few content types, just hardcode them -# -# source://simplecov-html//lib/simplecov-html.rb#19 -SimpleCov::Formatter::HTMLFormatter::CONTENT_TYPES = T.let(T.unsafe(nil), Hash) - -# source://simplecov-html//lib/simplecov-html/version.rb#6 -SimpleCov::Formatter::HTMLFormatter::VERSION = T.let(T.unsafe(nil), String) diff --git a/sorbet/rbi/gems/simplecov-sorbet@0.2.0.rbi b/sorbet/rbi/gems/simplecov-sorbet@0.2.0.rbi new file mode 100644 index 0000000..e55154a --- /dev/null +++ b/sorbet/rbi/gems/simplecov-sorbet@0.2.0.rbi @@ -0,0 +1,102 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for types exported from the `simplecov-sorbet` gem. +# Please instead update this file by running `bin/tapioca gem simplecov-sorbet`. + + +# source://simplecov-sorbet//lib/simplecov/sorbet/version.rb#4 +module SimpleCov; end + +class SimpleCov::Directive + extend ::SimpleCov::Sorbet::DirectiveExtension +end + +# SimpleCov extension for Sorbet codebases: skips type-level Sorbet constructs coverage should not measure — +# multi-line +T.type_alias+ blocks, +sig+ blocks, and +T.absurd+ sends. Requiring this file installs the +# extension; Module#prepend is idempotent, so requiring it more than once is harmless. +# +# source://simplecov-sorbet//lib/simplecov/sorbet/version.rb#5 +module SimpleCov::Sorbet; end + +# Prepended onto SimpleCov::Directive's singleton class. Directive.disabled_ranges is the one choke point both +# SourceFile (loaded files) and LinesClassifier (tracked-but-unloaded files) consult for skip ranges, so +# extending its result covers every path SimpleCov classifies lines through. Ignored ranges join all three +# categories: type-level constructs are not behavior, so any line, branch, or method inside them is skippable. +# +# source://simplecov-sorbet//lib/simplecov/sorbet/directive_extension.rb#10 +module SimpleCov::Sorbet::DirectiveExtension + # source://simplecov-sorbet//lib/simplecov/sorbet/directive_extension.rb#14 + sig { params(src_lines: T::Array[::String]).returns(T::Hash[::Symbol, T::Array[T::Range[::Integer]]]) } + def disabled_ranges(src_lines); end + + private + + # Scans +source+ for type-level Sorbet constructs (see IgnoredRanges). Unparseable source yields no ranges: + # coverage annotation must never take down a suite's reporting, and a file Ruby executed but this parser + # rejects has no constructs we could trust anyway. The rescue is deliberately wide — Prism parses most broken + # source tolerantly, and what escapes is not Parser::SyntaxError but arbitrary errors from the whitequark + # builder choking on error-recovered trees (e.g. NoMethodError in join_exprs). + # + # source://simplecov-sorbet//lib/simplecov/sorbet/directive_extension.rb#35 + sig { params(source: ::String).returns(T::Array[T::Range[::Integer]]) } + def ignored_ranges(source); end + + # The parser is fixed for the extension's lifetime. Prepended onto SimpleCov::Directive's singleton class, + # this module has no constructor to own it, so the singleton's ivar plays that role. + # + # source://simplecov-sorbet//lib/simplecov/sorbet/directive_extension.rb#46 + sig { returns(::ASTTransform::SourceParser) } + def source_parser; end +end + +# Read-only analysis pass collecting the line ranges of type-level Sorbet constructs coverage should ignore. +# Detection is purely syntactic — this gem never loads Sorbet's type system. Three constructs are collected: +# +# - +T.type_alias+ blocks: sorbet-runtime resolves aliases lazily and collection checks are shallow, so a +# multi-line alias body never executes and reads as a permanent coverage miss. +# - +sig+ blocks (bare or with a receiver, e.g. +T::Sig::WithoutRuntime.sig+): sigs are type metadata whose +# correctness +srb tc+ owns; coverage should measure behavior. Only multi-line blocks are collected — a +# single-line +sig { ... }+ occupies the send's own line, which executes at load. +# - +T.absurd+ sends: unreachable by definition when exhaustiveness holds, so in correct code the line is a +# permanent coverage miss. +# +# source://simplecov-sorbet//lib/simplecov/sorbet/ignored_ranges.rb#16 +class SimpleCov::Sorbet::IgnoredRanges < ::ASTTransform::AbstractAnalysis + # source://simplecov-sorbet//lib/simplecov/sorbet/ignored_ranges.rb#34 + sig { void } + def initialize; end + + # Collects the block's full expression range when the block's send is a +T.type_alias+ or a multi-line +sig+. + # + # source://simplecov-sorbet//lib/simplecov/sorbet/ignored_ranges.rb#58 + sig { params(node: ::Parser::AST::Node).returns(::Parser::AST::Node) } + def on_block(node); end + + # Collects the send's full expression range when it is a +T.absurd+ call. + # + # source://simplecov-sorbet//lib/simplecov/sorbet/ignored_ranges.rb#45 + sig { params(node: ::Parser::AST::Node).returns(::Parser::AST::Node) } + def on_send(node); end + + # One line range per ignored construct, in source order. + # + # source://simplecov-sorbet//lib/simplecov/sorbet/ignored_ranges.rb#31 + sig { returns(T::Array[T::Range[::Integer]]) } + def ranges; end + + private + + # source://simplecov-sorbet//lib/simplecov/sorbet/ignored_ranges.rb#80 + sig { params(node: ::Parser::AST::Node).returns(T::Range[::Integer]) } + def line_range(node); end +end + +# Structural patterns for the +T+ receiver (node equality ignores source locations): +T+ and its explicit +# top-level form +::T+. +# +# source://simplecov-sorbet//lib/simplecov/sorbet/ignored_ranges.rb#21 +SimpleCov::Sorbet::IgnoredRanges::T_RECEIVERS = T.let(T.unsafe(nil), Array) + +# source://simplecov-sorbet//lib/simplecov/sorbet/version.rb#6 +SimpleCov::Sorbet::VERSION = T.let(T.unsafe(nil), String) diff --git a/sorbet/rbi/gems/simplecov@0.22.0.rbi b/sorbet/rbi/gems/simplecov@0.22.0.rbi deleted file mode 100644 index cab96d3..0000000 --- a/sorbet/rbi/gems/simplecov@0.22.0.rbi +++ /dev/null @@ -1,2149 +0,0 @@ -# typed: true - -# DO NOT EDIT MANUALLY -# This is an autogenerated file for types exported from the `simplecov` gem. -# Please instead update this file by running `bin/tapioca gem simplecov`. - - -# Code coverage for ruby. Please check out README for a full introduction. -# -# source://simplecov//lib/simplecov.rb#22 -module SimpleCov - extend ::SimpleCov::Configuration - - class << self - # source://simplecov//lib/simplecov.rb#174 - def at_exit_behavior; end - - # Clear out the previously cached .result. Primarily useful in testing - # - # source://simplecov//lib/simplecov.rb#170 - def clear_result; end - - # Collate a series of SimpleCov result files into a single SimpleCov output. - # - # You can optionally specify configuration with a block: - # SimpleCov.collate Dir["simplecov-resultset-*/.resultset.json"] - # OR - # SimpleCov.collate Dir["simplecov-resultset-*/.resultset.json"], 'rails' # using rails profile - # OR - # SimpleCov.collate Dir["simplecov-resultset-*/.resultset.json"] do - # add_filter 'test' - # end - # OR - # SimpleCov.collate Dir["simplecov-resultset-*/.resultset.json"], 'rails' do - # add_filter 'test' - # end - # - # Please check out the RDoc for SimpleCov::Configuration to find about - # available config options, or checkout the README for more in-depth - # information about coverage collation - # - # By default `collate` ignores the merge_timeout so all results of all files specified will be - # merged together. If you want to honor the merge_timeout then provide the keyword argument - # `ignore_timeout: false`. - # - # source://simplecov//lib/simplecov.rb#86 - def collate(result_filenames, profile = T.unsafe(nil), ignore_timeout: T.unsafe(nil), &block); end - - # Thinking: Move this behavior earlier so if there was an error we do nothing? - # - # @api private - # - # source://simplecov//lib/simplecov.rb#223 - def exit_and_report_previous_error(exit_status); end - - # Returns the exit status from the exit exception - # - # @api private - # - # source://simplecov//lib/simplecov.rb#200 - def exit_status_from_exception; end - - # Basically, should we take care of at_exit behavior or something else? - # Used by the minitest plugin. See lib/minitest/simplecov_plugin.rb - # - # source://simplecov//lib/simplecov.rb#28 - def external_at_exit; end - - # Basically, should we take care of at_exit behavior or something else? - # Used by the minitest plugin. See lib/minitest/simplecov_plugin.rb - # - # source://simplecov//lib/simplecov.rb#28 - def external_at_exit=(_arg0); end - - # Basically, should we take care of at_exit behavior or something else? - # Used by the minitest plugin. See lib/minitest/simplecov_plugin.rb - # - # source://simplecov//lib/simplecov.rb#29 - def external_at_exit?; end - - # Applies the configured filters to the given array of SimpleCov::SourceFile items - # - # source://simplecov//lib/simplecov.rb#131 - def filtered(files); end - - # @api private - # @return [Boolean] - # - # source://simplecov//lib/simplecov.rb#268 - def final_result_process?; end - - # Applies the configured groups to the given array of SimpleCov::SourceFile items - # - # source://simplecov//lib/simplecov.rb#142 - def grouped(files); end - - # source://simplecov//lib/simplecov.rb#162 - def load_adapter(name); end - - # Applies the profile of given name on SimpleCov configuration - # - # source://simplecov//lib/simplecov.rb#158 - def load_profile(name); end - - # Returns the value of attribute pid. - # - # source://simplecov//lib/simplecov.rb#24 - def pid; end - - # Sets the attribute pid - # - # @param value the value to set the attribute pid to. - # - # source://simplecov//lib/simplecov.rb#24 - def pid=(_arg0); end - - # @api private - # @return [Boolean] - # - # source://simplecov//lib/simplecov.rb#213 - def previous_error?(error_exit_status); end - - # Usage: - # exit_status = SimpleCov.process_result(SimpleCov.result, exit_status) - # - # @api private - # - # source://simplecov//lib/simplecov.rb#248 - def process_result(result); end - - # source://simplecov//lib/simplecov.rb#233 - def process_results_and_report_error; end - - # @api private - # @return [Boolean] - # - # source://simplecov//lib/simplecov.rb#229 - def ready_to_process_results?; end - - # Returns the result for the current coverage run, merging it across test suites - # from cache using SimpleCov::ResultMerger if use_merging is activated (default) - # - # source://simplecov//lib/simplecov.rb#101 - def result; end - - # Returns nil if the result has not been computed - # Otherwise, returns the result - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov.rb#124 - def result?; end - - # source://simplecov//lib/simplecov.rb#256 - def result_exit_status(result); end - - # Rounding down to be extra strict, see #679 - # - # @api private - # - # source://simplecov//lib/simplecov.rb#296 - def round_coverage(coverage); end - - # Called from at_exit block - # - # @api private - # - # source://simplecov//lib/simplecov.rb#186 - def run_exit_tasks!; end - - # Returns the value of attribute running. - # - # source://simplecov//lib/simplecov.rb#24 - def running; end - - # Sets the attribute running - # - # @param value the value to set the attribute running to. - # - # source://simplecov//lib/simplecov.rb#24 - def running=(_arg0); end - - # Sets up SimpleCov to run against your project. - # You can optionally specify a profile to use as well as configuration with a block: - # SimpleCov.start - # OR - # SimpleCov.start 'rails' # using rails profile - # OR - # SimpleCov.start do - # add_filter 'test' - # end - # OR - # SimpleCov.start 'rails' do - # add_filter 'test' - # end - # - # Please check out the RDoc for SimpleCov::Configuration to find about available config options - # - # source://simplecov//lib/simplecov.rb#48 - def start(profile = T.unsafe(nil), &block); end - - # @api private - # - # source://simplecov//lib/simplecov.rb#276 - def wait_for_other_processes; end - - # @api private - # - # source://simplecov//lib/simplecov.rb#285 - def write_last_run(result); end - - private - - # Unite the result so it wouldn't matter what coverage type was called - # - # @return [Hash] - # - # source://simplecov//lib/simplecov.rb#399 - def adapt_coverage_result; end - - # Finds files that were to be tracked but were not loaded and initializes - # the line-by-line coverage to zero (if relevant) or nil (comments / whitespace etc). - # - # source://simplecov//lib/simplecov.rb#371 - def add_not_loaded_files(result); end - - # source://simplecov//lib/simplecov.rb#302 - def initial_setup(profile, &block); end - - # source://simplecov//lib/simplecov.rb#363 - def lookup_corresponding_ruby_coverage_name(criterion); end - - # parallel_tests isn't always available, see: https://github.com/grosser/parallel_tests/issues/772 - # - # source://simplecov//lib/simplecov.rb#425 - def make_parallel_tests_available; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov.rb#434 - def probably_running_parallel_tests?; end - - # Call steps that handle process coverage result - # - # @return [Hash] - # - # source://simplecov//lib/simplecov.rb#388 - def process_coverage_result; end - - # Filter coverage result - # The result before filter also has result of coverage for files - # are not related to the project like loaded gems coverage. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov.rb#410 - def remove_useless_results; end - - # Initialize result with files that are not included by coverage - # and added inside the config block - # - # @return [Hash] - # - # source://simplecov//lib/simplecov.rb#420 - def result_with_not_loaded_files; end - - # Trigger Coverage.start depends on given config coverage_criterion - # - # With Positive branch it supports all coverage measurement types - # With Negative branch it supports only line coverage measurement type - # - # source://simplecov//lib/simplecov.rb#314 - def start_coverage_measurement; end - - # source://simplecov//lib/simplecov.rb#349 - def start_coverage_with_criteria; end - end -end - -# source://simplecov//lib/simplecov/filter.rb#77 -class SimpleCov::ArrayFilter < ::SimpleCov::Filter - # @return [ArrayFilter] a new instance of ArrayFilter - # - # source://simplecov//lib/simplecov/filter.rb#78 - def initialize(filter_argument); end - - # Returns true if any of the filters in the array match the given source file. - # Configure this Filter like StringFilter.new(['some/path', /^some_regex/, Proc.new {|src_file| ... }]) - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/filter.rb#88 - def matches?(source_files_list); end -end - -# source://simplecov//lib/simplecov/filter.rb#69 -class SimpleCov::BlockFilter < ::SimpleCov::Filter - # Returns true if the block given when initializing this filter with BlockFilter.new {|src_file| ... } - # returns true for the given source file. - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/filter.rb#72 - def matches?(source_file); end -end - -# Functionally for combining coverage results -# -# source://simplecov//lib/simplecov/combine.rb#6 -module SimpleCov::Combine - private - - # Combine two coverage based on the given combiner_module. - # - # Combiners should always be called through this interface, - # as it takes care of short-circuiting of one of the coverages is nil. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine.rb#16 - def combine(combiner_module, coverage_a, coverage_b); end - - # source://simplecov//lib/simplecov/combine.rb#22 - def empty_coverage?(coverage_a, coverage_b); end - - # source://simplecov//lib/simplecov/combine.rb#26 - def existing_coverage(coverage_a, coverage_b); end - - class << self - # Combine two coverage based on the given combiner_module. - # - # Combiners should always be called through this interface, - # as it takes care of short-circuiting of one of the coverages is nil. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine.rb#16 - def combine(combiner_module, coverage_a, coverage_b); end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/combine.rb#22 - def empty_coverage?(coverage_a, coverage_b); end - - # source://simplecov//lib/simplecov/combine.rb#26 - def existing_coverage(coverage_a, coverage_b); end - end -end - -# Combine different branch coverage results on single file. -# -# Should be called through `SimpleCov.combine`. -# -# source://simplecov//lib/simplecov/combine/branches_combiner.rb#9 -module SimpleCov::Combine::BranchesCombiner - private - - # Return merged branches or the existed brach if other is missing. - # - # Branches inside files are always same if they exist, the difference only in coverage count. - # Branch coverage report for any conditional case is built from hash, it's key is a condition and - # it's body is a hash << keys from condition and value is coverage rate >>. - # ex: branches =>{ [:if, 3, 8, 6, 8, 36] => {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36]=>2}, other conditions...} - # We create copy of result and update it values depending on the combined branches coverage values. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/branches_combiner.rb#23 - def combine(coverage_a, coverage_b); end - - class << self - # Return merged branches or the existed brach if other is missing. - # - # Branches inside files are always same if they exist, the difference only in coverage count. - # Branch coverage report for any conditional case is built from hash, it's key is a condition and - # it's body is a hash << keys from condition and value is coverage rate >>. - # ex: branches =>{ [:if, 3, 8, 6, 8, 36] => {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36]=>2}, other conditions...} - # We create copy of result and update it values depending on the combined branches coverage values. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/branches_combiner.rb#23 - def combine(coverage_a, coverage_b); end - end -end - -# Handle combining two coverage results for same file -# -# Should be called through `SimpleCov.combine`. -# -# source://simplecov//lib/simplecov/combine/files_combiner.rb#9 -module SimpleCov::Combine::FilesCombiner - private - - # Combines the results for 2 coverages of a file. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/files_combiner.rb#17 - def combine(coverage_a, coverage_b); end - - class << self - # Combines the results for 2 coverages of a file. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/files_combiner.rb#17 - def combine(coverage_a, coverage_b); end - end -end - -# Combine two different lines coverage results on same file -# -# Should be called through `SimpleCov.combine`. -# -# source://simplecov//lib/simplecov/combine/lines_combiner.rb#9 -module SimpleCov::Combine::LinesCombiner - private - - # source://simplecov//lib/simplecov/combine/lines_combiner.rb#12 - def combine(coverage_a, coverage_b); end - - # Return depends on coverage in a specific line - # - # Logic: - # - # => nil + 0 = nil - # => nil + nil = nil - # => int + int = int - # - # @param first_val [Integer || nil] - # @param second_val [Integer || nil] - # @return [Integer || nil] - # - # source://simplecov//lib/simplecov/combine/lines_combiner.rb#32 - def merge_line_coverage(first_val, second_val); end - - class << self - # source://simplecov//lib/simplecov/combine/lines_combiner.rb#12 - def combine(coverage_a, coverage_b); end - - # Return depends on coverage in a specific line - # - # Logic: - # - # => nil + 0 = nil - # => nil + nil = nil - # => int + int = int - # - # @param first_val [Integer || nil] - # @param second_val [Integer || nil] - # @return [Integer || nil] - # - # source://simplecov//lib/simplecov/combine/lines_combiner.rb#32 - def merge_line_coverage(first_val, second_val); end - end -end - -# There might be reports from different kinds of tests, -# e.g. RSpec and Cucumber. We need to combine their results -# into unified one. This class does that. -# To unite the results on file basis, it leverages -# the combine of lines and branches inside each file within given results. -# -# source://simplecov//lib/simplecov/combine/results_combiner.rb#10 -module SimpleCov::Combine::ResultsCombiner - private - - # Combine process explanation - # => ResultCombiner: define all present files between results and start combine on file level. - # ==> FileCombiner: collect result of next combine levels lines and branches. - # ===> LinesCombiner: combine lines results. - # ===> BranchesCombiner: combine branches results. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/results_combiner.rb#22 - def combine(*results); end - - # Combine two files coverage results - # - # @param coverage_a [Hash] - # @param coverage_b [Hash] - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/results_combiner.rb#55 - def combine_file_coverage(coverage_a, coverage_b); end - - # Manage combining results on files level - # - # @param combined_results [Hash] - # @param result [Hash] - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/results_combiner.rb#36 - def combine_result_sets(combined_results, result); end - - class << self - # Combine process explanation - # => ResultCombiner: define all present files between results and start combine on file level. - # ==> FileCombiner: collect result of next combine levels lines and branches. - # ===> LinesCombiner: combine lines results. - # ===> BranchesCombiner: combine branches results. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/results_combiner.rb#22 - def combine(*results); end - - # Combine two files coverage results - # - # @param coverage_a [Hash] - # @param coverage_b [Hash] - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/results_combiner.rb#55 - def combine_file_coverage(coverage_a, coverage_b); end - - # Manage combining results on files level - # - # @param combined_results [Hash] - # @param result [Hash] - # @return [Hash] - # - # source://simplecov//lib/simplecov/combine/results_combiner.rb#36 - def combine_result_sets(combined_results, result); end - end -end - -# Helper that tries to find out what test suite is running (for SimpleCov.command_name) -# -# source://simplecov//lib/simplecov/command_guesser.rb#7 -module SimpleCov::CommandGuesser - class << self - # source://simplecov//lib/simplecov/command_guesser.rb#16 - def guess; end - - # Storage for the original command line call that invoked the test suite. - # This has got to be stored as early as possible because i.e. rake and test/unit 2 - # have a habit of tampering with ARGV, which makes i.e. the automatic distinction - # between rails unit/functional/integration tests impossible without this cached - # item. - # - # source://simplecov//lib/simplecov/command_guesser.rb#14 - def original_run_command; end - - # Storage for the original command line call that invoked the test suite. - # This has got to be stored as early as possible because i.e. rake and test/unit 2 - # have a habit of tampering with ARGV, which makes i.e. the automatic distinction - # between rails unit/functional/integration tests impossible without this cached - # item. - # - # source://simplecov//lib/simplecov/command_guesser.rb#14 - def original_run_command=(_arg0); end - - private - - # source://simplecov//lib/simplecov/command_guesser.rb#31 - def from_command_line_options; end - - # source://simplecov//lib/simplecov/command_guesser.rb#46 - def from_defined_constants; end - - # source://simplecov//lib/simplecov/command_guesser.rb#22 - def from_env; end - end -end - -# Bundles the configuration options used for SimpleCov. All methods -# defined here are usable from SimpleCov directly. Please check out -# SimpleCov documentation for further info. -# -# source://simplecov//lib/simplecov/configuration.rb#13 -module SimpleCov::Configuration - # source://simplecov//lib/simplecov/configuration.rb#161 - def adapters; end - - # Add a filter to the processing chain. - # There are four ways to define a filter: - # - # * as a String that will then be matched against all source files' file paths, - # SimpleCov.add_filter 'app/models' # will reject all your models - # * as a block which will be passed the source file in question and should either - # return a true or false value, depending on whether the file should be removed - # SimpleCov.add_filter do |src_file| - # File.basename(src_file.filename) == 'environment.rb' - # end # Will exclude environment.rb files from the results - # * as an array of strings that are matched against all sorce files' file - # paths and then ignored (basically string filter multiple times) - # SimpleCov.add_filter ['app/models', 'app/helpers'] # ignores both dirs - # * as an instance of a subclass of SimpleCov::Filter. See the documentation there - # on how to define your own filter classes - # - # source://simplecov//lib/simplecov/configuration.rb#367 - def add_filter(filter_argument = T.unsafe(nil), &filter_proc); end - - # Define a group for files. Works similar to add_filter, only that the first - # argument is the desired group name and files PASSING the filter end up in the group - # (while filters exclude when the filter is applicable). - # - # source://simplecov//lib/simplecov/configuration.rb#376 - def add_group(group_name, filter_argument = T.unsafe(nil), &filter_proc); end - - # Gets or sets the behavior to process coverage results. - # - # By default, it will call SimpleCov.result.format! - # - # Configure with: - # - # SimpleCov.at_exit do - # puts "Coverage done" - # SimpleCov.result.format! - # end - # - # source://simplecov//lib/simplecov/configuration.rb#193 - def at_exit(&block); end - - # Gets or sets the behavior to start a new forked Process. - # - # By default, it will add " (Process #{pid})" to the command_name, and start SimpleCov in quiet mode - # - # Configure with: - # - # SimpleCov.at_fork do |pid| - # SimpleCov.start do - # # This needs a unique name so it won't be ovewritten - # SimpleCov.command_name "#{SimpleCov.command_name} (subprocess: #{pid})" - # # be quiet, the parent process will be in charge of using the regular formatter and checking coverage totals - # SimpleCov.print_error_status = false - # SimpleCov.formatter SimpleCov::Formatter::SimpleFormatter - # SimpleCov.minimum_coverage 0 - # # start - # SimpleCov.start - # end - # end - # - # source://simplecov//lib/simplecov/configuration.rb#233 - def at_fork(&block); end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/configuration.rb#428 - def branch_coverage?; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/configuration.rb#443 - def branch_coverage_supported?; end - - # source://simplecov//lib/simplecov/configuration.rb#424 - def clear_coverage_criteria; end - - # The name of the command (a.k.a. Test Suite) currently running. Used for result - # merging and caching. It first tries to make a guess based upon the command line - # arguments the current test suite is running on and should automatically detect - # unit tests, functional tests, integration tests, rpsec and cucumber and label - # them properly. If it fails to recognize the current command, the command name - # is set to the shell command that the current suite is running on. - # - # You can specify it manually with SimpleCov.command_name("test:units") - please - # also check out the corresponding section in README.rdoc - # - # source://simplecov//lib/simplecov/configuration.rb#87 - def command_name(name = T.unsafe(nil)); end - - # Allows you to configure simplecov in a block instead of prepending SimpleCov to all config methods - # you're calling. - # - # SimpleCov.configure do - # add_filter 'foobar' - # end - # - # This is equivalent to SimpleCov.add_filter 'foobar' and thus makes it easier to set a bunch of configure - # options at once. - # - # source://simplecov//lib/simplecov/configuration.rb#177 - def configure(&block); end - - # source://simplecov//lib/simplecov/configuration.rb#416 - def coverage_criteria; end - - # Define which coverage criterion should be evaluated. - # - # Possible coverage criteria: - # * :line - coverage based on lines aka has this line been executed? - # * :branch - coverage based on branches aka has this branch (think conditions) been executed? - # - # If not set the default is `:line` - # - # @param criterion [Symbol] - # - # source://simplecov//lib/simplecov/configuration.rb#393 - def coverage_criterion(criterion = T.unsafe(nil)); end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/configuration.rb#420 - def coverage_criterion_enabled?(criterion); end - - # The name of the output and cache directory. Defaults to 'coverage' - # - # Configure with SimpleCov.coverage_dir('cov') - # - # source://simplecov//lib/simplecov/configuration.rb#34 - def coverage_dir(dir = T.unsafe(nil)); end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/configuration.rb#452 - def coverage_for_eval_enabled?; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/configuration.rb#447 - def coverage_for_eval_supported?; end - - # Returns the full path to the output directory using SimpleCov.root - # and SimpleCov.coverage_dir, so you can adjust this by configuring those - # values. Will create the directory if it's missing - # - # source://simplecov//lib/simplecov/configuration.rb#46 - def coverage_path; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/configuration.rb#432 - def coverage_start_arguments_supported?; end - - # source://simplecov//lib/simplecov/configuration.rb#401 - def enable_coverage(criterion); end - - # source://simplecov//lib/simplecov/configuration.rb#456 - def enable_coverage_for_eval; end - - # gets or sets the enabled_for_subprocess configuration - # when true, this will inject SimpleCov code into Process.fork - # - # source://simplecov//lib/simplecov/configuration.rb#202 - def enable_for_subprocesses(value = T.unsafe(nil)); end - - # gets the enabled_for_subprocess configuration - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/configuration.rb#209 - def enabled_for_subprocesses?; end - - # Returns the list of configured filters. Add filters using SimpleCov.add_filter. - # - # source://simplecov//lib/simplecov/configuration.rb#74 - def filters; end - - # Sets the attribute filters - # - # @param value the value to set the attribute filters to. - # - # source://simplecov//lib/simplecov/configuration.rb#14 - def filters=(_arg0); end - - # Gets or sets the configured formatter. - # - # Configure with: SimpleCov.formatter(SimpleCov::Formatter::SimpleFormatter) - # - # source://simplecov//lib/simplecov/configuration.rb#98 - def formatter(formatter = T.unsafe(nil)); end - - # Sets the attribute formatter - # - # @param value the value to set the attribute formatter to. - # - # source://simplecov//lib/simplecov/configuration.rb#14 - def formatter=(_arg0); end - - # Gets the configured formatters. - # - # source://simplecov//lib/simplecov/configuration.rb#117 - def formatters; end - - # Sets the configured formatters. - # - # source://simplecov//lib/simplecov/configuration.rb#110 - def formatters=(formatters); end - - # Returns the configured groups. Add groups using SimpleCov.add_group - # - # source://simplecov//lib/simplecov/configuration.rb#150 - def groups; end - - # Sets the attribute groups - # - # @param value the value to set the attribute groups to. - # - # source://simplecov//lib/simplecov/configuration.rb#14 - def groups=(_arg0); end - - # Defines the maximum coverage drop at once allowed for the testsuite to pass. - # SimpleCov will return non-zero if the coverage decreases by more than this threshold. - # - # Default is 100% (disabled) - # - # source://simplecov//lib/simplecov/configuration.rb#313 - def maximum_coverage_drop(coverage_drop = T.unsafe(nil)); end - - # Defines the maximum age (in seconds) of a resultset to still be included in merged results. - # i.e. If you run cucumber features, then later rake test, if the stored cucumber resultset is - # more seconds ago than specified here, it won't be taken into account when merging (and is also - # purged from the resultset cache) - # - # Of course, this only applies when merging is active (e.g. SimpleCov.use_merging is not false!) - # - # Default is 600 seconds (10 minutes) - # - # Configure with SimpleCov.merge_timeout(3600) # 1hr - # - # source://simplecov//lib/simplecov/configuration.rb#279 - def merge_timeout(seconds = T.unsafe(nil)); end - - # Defines the minimum overall coverage required for the testsuite to pass. - # SimpleCov will return non-zero if the current coverage is below this threshold. - # - # Default is 0% (disabled) - # - # source://simplecov//lib/simplecov/configuration.rb#290 - def minimum_coverage(coverage = T.unsafe(nil)); end - - # Defines the minimum coverage per file required for the testsuite to pass. - # SimpleCov will return non-zero if the current coverage of the least covered file - # is below this threshold. - # - # Default is 0% (disabled) - # - # source://simplecov//lib/simplecov/configuration.rb#330 - def minimum_coverage_by_file(coverage = T.unsafe(nil)); end - - # Certain code blocks (i.e. Ruby-implementation specific code) can be excluded from - # the coverage metrics by wrapping it inside # :nocov: comment blocks. The nocov token - # can be configured to be any other string using this. - # - # Configure with SimpleCov.nocov_token('skip') or it's alias SimpleCov.skip_token('skip') - # - # source://simplecov//lib/simplecov/configuration.rb#140 - def nocov_token(nocov_token = T.unsafe(nil)); end - - # source://simplecov//lib/simplecov/configuration.rb#407 - def primary_coverage(criterion = T.unsafe(nil)); end - - # Whether we should print non-success status codes. This can be - # configured with the #print_error_status= method. - # - # source://simplecov//lib/simplecov/configuration.rb#129 - def print_error_status; end - - # Sets the attribute print_error_status - # - # @param value the value to set the attribute print_error_status to. - # - # source://simplecov//lib/simplecov/configuration.rb#14 - def print_error_status=(_arg0); end - - # Returns the hash of available profiles - # - # source://simplecov//lib/simplecov/configuration.rb#157 - def profiles; end - - # Returns the project name - currently assuming the last dirname in - # the SimpleCov.root is this. - # - # source://simplecov//lib/simplecov/configuration.rb#251 - def project_name(new_name = T.unsafe(nil)); end - - # source://simplecov//lib/simplecov/configuration.rb#300 - def raise_on_invalid_coverage(coverage, coverage_setting); end - - # Refuses any coverage drop. That is, coverage is only allowed to increase. - # SimpleCov will return non-zero if the coverage decreases. - # - # source://simplecov//lib/simplecov/configuration.rb#344 - def refuse_coverage_drop(*criteria); end - - # The root for the project. This defaults to the - # current working directory. - # - # Configure with SimpleCov.root('/my/project/path') - # - # source://simplecov//lib/simplecov/configuration.rb#22 - def root(root = T.unsafe(nil)); end - - # Certain code blocks (i.e. Ruby-implementation specific code) can be excluded from - # the coverage metrics by wrapping it inside # :nocov: comment blocks. The nocov token - # can be configured to be any other string using this. - # - # Configure with SimpleCov.nocov_token('skip') or it's alias SimpleCov.skip_token('skip') - # - # source://simplecov//lib/simplecov/configuration.rb#145 - def skip_token(nocov_token = T.unsafe(nil)); end - - # Coverage results will always include files matched by this glob, whether - # or not they were explicitly required. Without this, un-required files - # will not be present in the final report. - # - # source://simplecov//lib/simplecov/configuration.rb#59 - def track_files(glob); end - - # Returns the glob that will be used to include files that were not - # explicitly required. - # - # source://simplecov//lib/simplecov/configuration.rb#67 - def tracked_files; end - - # Defines whether to use result merging so all your test suites (test:units, test:functionals, cucumber, ...) - # are joined and combined into a single coverage report - # - # source://simplecov//lib/simplecov/configuration.rb#262 - def use_merging(use = T.unsafe(nil)); end - - private - - # source://simplecov//lib/simplecov/configuration.rb#483 - def minimum_possible_coverage_exceeded(coverage_option); end - - # The actual filter processor. Not meant for direct use - # - # source://simplecov//lib/simplecov/configuration.rb#490 - def parse_filter(filter_argument = T.unsafe(nil), &filter_proc); end - - # source://simplecov//lib/simplecov/configuration.rb#466 - def raise_if_criterion_disabled(criterion); end - - # source://simplecov//lib/simplecov/configuration.rb#475 - def raise_if_criterion_unsupported(criterion); end -end - -# source://simplecov//lib/simplecov/configuration.rb#381 -SimpleCov::Configuration::DEFAULT_COVERAGE_CRITERION = T.let(T.unsafe(nil), Symbol) - -# source://simplecov//lib/simplecov/configuration.rb#380 -SimpleCov::Configuration::SUPPORTED_COVERAGE_CRITERIA = T.let(T.unsafe(nil), Array) - -# Holds the individual data of a coverage result. -# -# This is uniform across coverage criteria as they all have: -# -# * total - how many things to cover there are (total relevant loc/branches) -# * covered - how many of the coverables are hit -# * missed - how many of the coverables are missed -# * percent - percentage as covered/missed -# * strength - average hits per/coverable (will not exist for one shot lines format) -# -# source://simplecov//lib/simplecov/coverage_statistics.rb#13 -class SimpleCov::CoverageStatistics - # Requires only covered, missed and strength to be initialized. - # - # Other values are computed by this class. - # - # @return [CoverageStatistics] a new instance of CoverageStatistics - # - # source://simplecov//lib/simplecov/coverage_statistics.rb#34 - def initialize(covered:, missed:, total_strength: T.unsafe(nil)); end - - # Returns the value of attribute covered. - # - # source://simplecov//lib/simplecov/coverage_statistics.rb#14 - def covered; end - - # Returns the value of attribute missed. - # - # source://simplecov//lib/simplecov/coverage_statistics.rb#14 - def missed; end - - # Returns the value of attribute percent. - # - # source://simplecov//lib/simplecov/coverage_statistics.rb#14 - def percent; end - - # Returns the value of attribute strength. - # - # source://simplecov//lib/simplecov/coverage_statistics.rb#14 - def strength; end - - # Returns the value of attribute total. - # - # source://simplecov//lib/simplecov/coverage_statistics.rb#14 - def total; end - - private - - # source://simplecov//lib/simplecov/coverage_statistics.rb#44 - def compute_percent(covered, missed, total); end - - # source://simplecov//lib/simplecov/coverage_statistics.rb#50 - def compute_strength(total_strength, total); end - - class << self - # source://simplecov//lib/simplecov/coverage_statistics.rb#16 - def from(coverage_statistics); end - end -end - -# source://simplecov//lib/simplecov/exit_codes.rb#4 -module SimpleCov::ExitCodes; end - -# source://simplecov//lib/simplecov/exit_codes.rb#6 -SimpleCov::ExitCodes::EXCEPTION = T.let(T.unsafe(nil), Integer) - -# source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#5 -module SimpleCov::ExitCodes::ExitCodeHandling - private - - # source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#8 - def call(result, coverage_limits:); end - - # source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#20 - def coverage_checks(result, coverage_limits); end - - class << self - # source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#8 - def call(result, coverage_limits:); end - - # source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#20 - def coverage_checks(result, coverage_limits); end - end -end - -# source://simplecov//lib/simplecov/exit_codes.rb#8 -SimpleCov::ExitCodes::MAXIMUM_COVERAGE_DROP = T.let(T.unsafe(nil), Integer) - -# source://simplecov//lib/simplecov/exit_codes.rb#7 -SimpleCov::ExitCodes::MINIMUM_COVERAGE = T.let(T.unsafe(nil), Integer) - -# source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#5 -class SimpleCov::ExitCodes::MaximumCoverageDropCheck - # @return [MaximumCoverageDropCheck] a new instance of MaximumCoverageDropCheck - # - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#6 - def initialize(result, maximum_coverage_drop); end - - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#28 - def exit_code; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#11 - def failing?; end - - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#17 - def report; end - - private - - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#49 - def compute_coverage_drop_data; end - - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#42 - def coverage_drop_violations; end - - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#61 - def drop_percent(criterion); end - - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#73 - def last_coverage(criterion); end - - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#36 - def last_run; end - - # Returns the value of attribute maximum_coverage_drop. - # - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#34 - def maximum_coverage_drop; end - - # Returns the value of attribute result. - # - # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#34 - def result; end -end - -# if anyone says "max_coverage_drop 0.000000000000000001" I appologize. Please don't. -# -# source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#60 -SimpleCov::ExitCodes::MaximumCoverageDropCheck::MAX_DROP_ACCURACY = T.let(T.unsafe(nil), Integer) - -# source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#5 -class SimpleCov::ExitCodes::MinimumCoverageByFileCheck - # @return [MinimumCoverageByFileCheck] a new instance of MinimumCoverageByFileCheck - # - # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#6 - def initialize(result, minimum_coverage_by_file); end - - # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#26 - def exit_code; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#11 - def failing?; end - - # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#15 - def report; end - - private - - # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#41 - def compute_minimum_coverage_data; end - - # Returns the value of attribute minimum_coverage_by_file. - # - # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#32 - def minimum_coverage_by_file; end - - # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#34 - def minimum_violations; end - - # Returns the value of attribute result. - # - # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#32 - def result; end -end - -# source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#5 -class SimpleCov::ExitCodes::MinimumOverallCoverageCheck - # @return [MinimumOverallCoverageCheck] a new instance of MinimumOverallCoverageCheck - # - # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#6 - def initialize(result, minimum_coverage); end - - # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#26 - def exit_code; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#11 - def failing?; end - - # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#15 - def report; end - - private - - # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#38 - def calculate_minimum_violations; end - - # Returns the value of attribute minimum_coverage. - # - # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#32 - def minimum_coverage; end - - # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#34 - def minimum_violations; end - - # Returns the value of attribute result. - # - # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#32 - def result; end -end - -# source://simplecov//lib/simplecov/exit_codes.rb#5 -SimpleCov::ExitCodes::SUCCESS = T.let(T.unsafe(nil), Integer) - -# An array of SimpleCov SourceFile instances with additional collection helper -# methods for calculating coverage across them etc. -# -# source://simplecov//lib/simplecov/file_list.rb#6 -class SimpleCov::FileList - include ::Enumerable - extend ::Forwardable - - # @return [FileList] a new instance of FileList - # - # source://simplecov//lib/simplecov/file_list.rb#22 - def initialize(files); end - - # source://simplecov//lib/simplecov/file_list.rb#101 - def branch_covered_percent; end - - # source://simplecov//lib/simplecov/file_list.rb#10 - def count(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/file_list.rb#26 - def coverage_statistics; end - - # source://simplecov//lib/simplecov/file_list.rb#30 - def coverage_statistics_by_file; end - - # Return total count of covered branches - # - # source://simplecov//lib/simplecov/file_list.rb#92 - def covered_branches; end - - # Returns the count of lines that have coverage - # - # source://simplecov//lib/simplecov/file_list.rb#35 - def covered_lines; end - - # Computes the coverage based upon lines covered and lines missed - # - # @return [Float] - # - # source://simplecov//lib/simplecov/file_list.rb#76 - def covered_percent; end - - # Computes the coverage based upon lines covered and lines missed for each file - # Returns an array with all coverage percentages - # - # source://simplecov//lib/simplecov/file_list.rb#60 - def covered_percentages; end - - # Computes the strength (hits / line) based upon lines covered and lines missed - # - # @return [Float] - # - # source://simplecov//lib/simplecov/file_list.rb#82 - def covered_strength; end - - # source://simplecov//lib/simplecov/file_list.rb#10 - def each(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/file_list.rb#10 - def empty?(*_arg0, **_arg1, &_arg2); end - - # Finds the least covered file and returns that file's name - # - # source://simplecov//lib/simplecov/file_list.rb#65 - def least_covered_file; end - - # source://simplecov//lib/simplecov/file_list.rb#10 - def length(*_arg0, **_arg1, &_arg2); end - - # Returns the overall amount of relevant lines of code across all files in this list - # - # source://simplecov//lib/simplecov/file_list.rb#70 - def lines_of_code; end - - # source://simplecov//lib/simplecov/file_list.rb#10 - def map(*_arg0, **_arg1, &_arg2); end - - # Return total count of covered branches - # - # source://simplecov//lib/simplecov/file_list.rb#97 - def missed_branches; end - - # Returns the count of lines that have been missed - # - # source://simplecov//lib/simplecov/file_list.rb#40 - def missed_lines; end - - # Returns the count of lines that are not relevant for coverage - # - # source://simplecov//lib/simplecov/file_list.rb#45 - def never_lines; end - - # source://simplecov//lib/simplecov/file_list.rb#10 - def size(*_arg0, **_arg1, &_arg2); end - - # Returns the count of skipped lines - # - # source://simplecov//lib/simplecov/file_list.rb#52 - def skipped_lines; end - - # source://simplecov//lib/simplecov/file_list.rb#10 - def to_a(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/file_list.rb#10 - def to_ary(*_arg0, **_arg1, &_arg2); end - - # Return total count of branches in all files - # - # source://simplecov//lib/simplecov/file_list.rb#87 - def total_branches; end - - private - - # source://simplecov//lib/simplecov/file_list.rb#114 - def compute_coverage_statistics; end - - # source://simplecov//lib/simplecov/file_list.rb#107 - def compute_coverage_statistics_by_file; end -end - -# Base filter class. Inherit from this to create custom filters, -# and overwrite the passes?(source_file) instance method -# -# # A sample class that rejects all source files. -# class StupidFilter < SimpleCov::Filter -# def passes?(source_file) -# false -# end -# end -# -# source://simplecov//lib/simplecov/filter.rb#15 -class SimpleCov::Filter - # @return [Filter] a new instance of Filter - # - # source://simplecov//lib/simplecov/filter.rb#18 - def initialize(filter_argument); end - - # Returns the value of attribute filter_argument. - # - # source://simplecov//lib/simplecov/filter.rb#16 - def filter_argument; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/filter.rb#22 - def matches?(_source_file); end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/filter.rb#26 - def passes?(source_file); end - - class << self - # source://simplecov//lib/simplecov/filter.rb#31 - def build_filter(filter_argument); end - - # source://simplecov//lib/simplecov/filter.rb#37 - def class_for_argument(filter_argument); end - end -end - -# TODO: Documentation on how to build your own formatters -# -# source://simplecov//lib/simplecov/formatter/multi_formatter.rb#4 -module SimpleCov::Formatter - class << self - # source://simplecov//lib/simplecov/default_formatter.rb#7 - def from_env(env); end - end -end - -# source://simplecov//lib/simplecov/formatter/multi_formatter.rb#5 -class SimpleCov::Formatter::MultiFormatter - class << self - # source://simplecov//lib/simplecov/formatter/multi_formatter.rb#26 - def [](*args); end - - # source://simplecov//lib/simplecov/formatter/multi_formatter.rb#17 - def new(formatters = T.unsafe(nil)); end - end -end - -# source://simplecov//lib/simplecov/formatter/multi_formatter.rb#6 -module SimpleCov::Formatter::MultiFormatter::InstanceMethods - # source://simplecov//lib/simplecov/formatter/multi_formatter.rb#7 - def format(result); end -end - -# A ridiculously simple formatter for SimpleCov results. -# -# source://simplecov//lib/simplecov/formatter/simple_formatter.rb#8 -class SimpleCov::Formatter::SimpleFormatter - # Takes a SimpleCov::Result and generates a string out of it - # - # source://simplecov//lib/simplecov/formatter/simple_formatter.rb#10 - def format(result); end -end - -# source://simplecov//lib/simplecov/last_run.rb#6 -module SimpleCov::LastRun - class << self - # source://simplecov//lib/simplecov/last_run.rb#8 - def last_run_path; end - - # source://simplecov//lib/simplecov/last_run.rb#12 - def read; end - - # source://simplecov//lib/simplecov/last_run.rb#21 - def write(json); end - end -end - -# Classifies whether lines are relevant for code coverage analysis. -# Comments & whitespace lines, and :nocov: token blocks, are considered not relevant. -# -# source://simplecov//lib/simplecov/lines_classifier.rb#7 -class SimpleCov::LinesClassifier - # source://simplecov//lib/simplecov/lines_classifier.rb#33 - def classify(lines); end - - class << self - # source://simplecov//lib/simplecov/lines_classifier.rb#15 - def no_cov_line; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/lines_classifier.rb#19 - def no_cov_line?(line); end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/lines_classifier.rb#26 - def whitespace_line?(line); end - end -end - -# source://simplecov//lib/simplecov/lines_classifier.rb#12 -SimpleCov::LinesClassifier::COMMENT_LINE = T.let(T.unsafe(nil), Regexp) - -# source://simplecov//lib/simplecov/lines_classifier.rb#9 -SimpleCov::LinesClassifier::NOT_RELEVANT = T.let(T.unsafe(nil), T.untyped) - -# source://simplecov//lib/simplecov/lines_classifier.rb#8 -SimpleCov::LinesClassifier::RELEVANT = T.let(T.unsafe(nil), Integer) - -# source://simplecov//lib/simplecov/lines_classifier.rb#11 -SimpleCov::LinesClassifier::WHITESPACE_LINE = T.let(T.unsafe(nil), Regexp) - -# source://simplecov//lib/simplecov/lines_classifier.rb#13 -SimpleCov::LinesClassifier::WHITESPACE_OR_COMMENT_LINE = T.let(T.unsafe(nil), Regexp) - -# Profiles are SimpleCov configuration procs that can be easily -# loaded using SimpleCov.start :rails and defined using -# SimpleCov.profiles.define :foo do -# # SimpleCov configuration here, same as in SimpleCov.configure -# end -# -# source://simplecov//lib/simplecov/profiles.rb#11 -class SimpleCov::Profiles < ::Hash - # Define a SimpleCov profile: - # SimpleCov.profiles.define 'rails' do - # # Same as SimpleCov.configure do .. here - # end - # - # source://simplecov//lib/simplecov/profiles.rb#18 - def define(name, &blk); end - - # Applies the profile of given name on SimpleCov.configure - # - # source://simplecov//lib/simplecov/profiles.rb#28 - def load(name); end -end - -# source://simplecov//lib/simplecov/filter.rb#61 -class SimpleCov::RegexFilter < ::SimpleCov::Filter - # Returns true when the given source file's filename matches the - # regex configured when initializing this Filter with RegexFilter.new(/someregex/) - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/filter.rb#64 - def matches?(source_file); end -end - -# A simplecov code coverage result, initialized from the Hash Ruby's built-in coverage -# library generates (Coverage.result). -# -# source://simplecov//lib/simplecov/result.rb#11 -class SimpleCov::Result - extend ::Forwardable - - # Initialize a new SimpleCov::Result from given Coverage.result (a Hash of filenames each containing an array of - # coverage data) - # - # @return [Result] a new instance of Result - # - # source://simplecov//lib/simplecov/result.rb#28 - def initialize(original_result, command_name: T.unsafe(nil), created_at: T.unsafe(nil)); end - - # The command name that launched this result. - # Delegated to SimpleCov.command_name if not set manually - # - # source://simplecov//lib/simplecov/result.rb#61 - def command_name; end - - # Explicitly set the command name that was used for this coverage result. Defaults to SimpleCov.command_name - # - # source://simplecov//lib/simplecov/result.rb#21 - def command_name=(_arg0); end - - # source://simplecov//lib/simplecov/result.rb#23 - def coverage_statistics(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#23 - def coverage_statistics_by_file(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#23 - def covered_branches(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#23 - def covered_lines(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#23 - def covered_percent(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#23 - def covered_percentages(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#23 - def covered_strength(*_arg0, **_arg1, &_arg2); end - - # Defines when this result has been created. Defaults to Time.now - # - # source://simplecov//lib/simplecov/result.rb#55 - def created_at; end - - # Explicitly set the Time this result has been created - # - # source://simplecov//lib/simplecov/result.rb#19 - def created_at=(_arg0); end - - # Returns all filenames for source files contained in this result - # - # source://simplecov//lib/simplecov/result.rb#40 - def filenames; end - - # Returns all files that are applicable to this result (sans filters!) as instances of SimpleCov::SourceFile. Aliased as :source_files - # - # source://simplecov//lib/simplecov/result.rb#16 - def files; end - - # Applies the configured SimpleCov.formatter on this result - # - # source://simplecov//lib/simplecov/result.rb#50 - def format!; end - - # Returns a Hash of groups for this result. Define groups using SimpleCov.add_group 'Models', 'app/models' - # - # source://simplecov//lib/simplecov/result.rb#45 - def groups; end - - # source://simplecov//lib/simplecov/result.rb#23 - def least_covered_file(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#23 - def missed_branches(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#23 - def missed_lines(*_arg0, **_arg1, &_arg2); end - - # Returns the original Coverage.result used for this instance of SimpleCov::Result - # - # source://simplecov//lib/simplecov/result.rb#14 - def original_result; end - - # Returns all files that are applicable to this result (sans filters!) as instances of SimpleCov::SourceFile. Aliased as :source_files - # - # source://simplecov//lib/simplecov/result.rb#17 - def source_files; end - - # Returns a hash representation of this Result that can be used for marshalling it into JSON - # - # source://simplecov//lib/simplecov/result.rb#66 - def to_hash; end - - # source://simplecov//lib/simplecov/result.rb#23 - def total_branches(*_arg0, **_arg1, &_arg2); end - - # source://simplecov//lib/simplecov/result.rb#24 - def total_lines(*_arg0, **_arg1, &_arg2); end - - private - - # source://simplecov//lib/simplecov/result.rb#84 - def coverage; end - - # Applies all configured SimpleCov filters on this result's source files - # - # source://simplecov//lib/simplecov/result.rb#90 - def filter!; end - - class << self - # Loads a SimpleCov::Result#to_hash dump - # - # source://simplecov//lib/simplecov/result.rb#76 - def from_hash(hash); end - end -end - -# Responsible for adapting the format of the coverage result whether it's default or with statistics -# -# source://simplecov//lib/simplecov/result_adapter.rb#7 -class SimpleCov::ResultAdapter - # @return [ResultAdapter] a new instance of ResultAdapter - # - # source://simplecov//lib/simplecov/result_adapter.rb#10 - def initialize(result); end - - # source://simplecov//lib/simplecov/result_adapter.rb#18 - def adapt; end - - # Returns the value of attribute result. - # - # source://simplecov//lib/simplecov/result_adapter.rb#8 - def result; end - - class << self - # source://simplecov//lib/simplecov/result_adapter.rb#14 - def call(*args); end - end -end - -# Singleton that is responsible for caching, loading and merging -# SimpleCov::Results into a single result for coverage analysis based -# upon multiple test suites. -# -# source://simplecov//lib/simplecov/result_merger.rb#11 -module SimpleCov::ResultMerger - class << self - # source://simplecov//lib/simplecov/result_merger.rb#187 - def adapt_pre_simplecov_0_18_result(result); end - - # We changed the format of the raw result data in simplecov, as people are likely - # to have "old" resultsets lying around (but not too old so that they're still - # considered we can adapt them). - # See https://github.com/simplecov-ruby/simplecov/pull/824#issuecomment-576049747 - # - # source://simplecov//lib/simplecov/result_merger.rb#172 - def adapt_result(result); end - - # source://simplecov//lib/simplecov/result_merger.rb#93 - def create_result(command_names, coverage); end - - # source://simplecov//lib/simplecov/result_merger.rb#22 - def merge_and_store(*file_paths, ignore_timeout: T.unsafe(nil)); end - - # source://simplecov//lib/simplecov/result_merger.rb#100 - def merge_coverage(*results); end - - # source://simplecov//lib/simplecov/result_merger.rb#28 - def merge_results(*file_paths, ignore_timeout: T.unsafe(nil)); end - - # source://simplecov//lib/simplecov/result_merger.rb#74 - def merge_valid_results(results, ignore_timeout: T.unsafe(nil)); end - - # Gets all SimpleCov::Results stored in resultset, merges them and produces a new - # SimpleCov::Result with merged coverage data and the command_name - # for the result consisting of a join on all source result's names - # - # source://simplecov//lib/simplecov/result_merger.rb#117 - def merged_result; end - - # source://simplecov//lib/simplecov/result_merger.rb#51 - def parse_file(path); end - - # source://simplecov//lib/simplecov/result_merger.rb#65 - def parse_json(content); end - - # pre 0.18 coverage data pointed from file directly to an array of line coverage - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/result_merger.rb#181 - def pre_simplecov_0_18_result?(result); end - - # source://simplecov//lib/simplecov/result_merger.rb#56 - def read_file(path); end - - # source://simplecov//lib/simplecov/result_merger.rb#126 - def read_resultset; end - - # The path to the .resultset.json cache file - # - # source://simplecov//lib/simplecov/result_merger.rb#14 - def resultset_path; end - - # source://simplecov//lib/simplecov/result_merger.rb#18 - def resultset_writelock; end - - # Saves the given SimpleCov::Result in the resultset cache - # - # source://simplecov//lib/simplecov/result_merger.rb#136 - def store_result(result); end - - # Ensure only one process is reading or writing the resultset at any - # given time - # - # source://simplecov//lib/simplecov/result_merger.rb#153 - def synchronize_resultset; end - - # source://simplecov//lib/simplecov/result_merger.rb#89 - def time_since_result_creation(data); end - - # source://simplecov//lib/simplecov/result_merger.rb#46 - def valid_results(file_path, ignore_timeout: T.unsafe(nil)); end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/result_merger.rb#85 - def within_merge_timeout?(data); end - end -end - -# Responsible for producing file coverage metrics. -# -# source://simplecov//lib/simplecov/simulate_coverage.rb#7 -module SimpleCov::SimulateCoverage - private - - # Simulate normal file coverage report on - # ruby 2.5 and return similar hash with lines and branches keys - # - # Happens when a file wasn't required but still tracked. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/simulate_coverage.rb#18 - def call(absolute_path); end - - class << self - # Simulate normal file coverage report on - # ruby 2.5 and return similar hash with lines and branches keys - # - # Happens when a file wasn't required but still tracked. - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/simulate_coverage.rb#18 - def call(absolute_path); end - end -end - -# Representation of a source file including it's coverage data, source code, -# source lines and featuring helpers to interpret that data. -# -# source://simplecov//lib/simplecov/source_file/line.rb#4 -class SimpleCov::SourceFile - # @return [SourceFile] a new instance of SourceFile - # - # source://simplecov//lib/simplecov/source_file.rb#14 - def initialize(filename, coverage_data); end - - # Return all the branches inside current source file - # - # source://simplecov//lib/simplecov/source_file.rb#98 - def branches; end - - # source://simplecov//lib/simplecov/source_file.rb#106 - def branches_coverage_percent; end - - # source://simplecov//lib/simplecov/source_file.rb#142 - def branches_for_line(line_number); end - - # Return hash with key of line number and branch coverage count as value - # - # source://simplecov//lib/simplecov/source_file.rb#118 - def branches_report; end - - # The array of coverage data received from the Coverage.result - # - # source://simplecov//lib/simplecov/source_file.rb#12 - def coverage_data; end - - # source://simplecov//lib/simplecov/source_file.rb#32 - def coverage_statistics; end - - # Select the covered branches - # Here we user tree schema because some conditions like case may have additional - # else that is not in declared inside the code but given by default by coverage report - # - # @return [Array] - # - # source://simplecov//lib/simplecov/source_file.rb#129 - def covered_branches; end - - # Returns all covered lines as SimpleCov::SourceFile::Line - # - # source://simplecov//lib/simplecov/source_file.rb#48 - def covered_lines; end - - # The coverage for this file in percent. 0 if the file has no coverage lines - # - # source://simplecov//lib/simplecov/source_file.rb#80 - def covered_percent; end - - # source://simplecov//lib/simplecov/source_file.rb#84 - def covered_strength; end - - # The full path to this source file (e.g. /User/colszowka/projects/simplecov/lib/simplecov/source_file.rb) - # - # source://simplecov//lib/simplecov/source_file.rb#10 - def filename; end - - # Access SimpleCov::SourceFile::Line source lines by line number - # - # source://simplecov//lib/simplecov/source_file.rb#75 - def line(number); end - - # Check if any branches missing on given line number - # - # @param line_number [Integer] - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file.rb#153 - def line_with_missed_branch?(line_number); end - - # Returns all source lines for this file as instances of SimpleCov::SourceFile::Line, - # and thus including coverage data. Aliased as :source_lines - # - # source://simplecov//lib/simplecov/source_file.rb#42 - def lines; end - - # Returns the number of relevant lines (covered + missed) - # - # source://simplecov//lib/simplecov/source_file.rb#70 - def lines_of_code; end - - # Select the missed branches with coverage equal to zero - # - # @return [Array] - # - # source://simplecov//lib/simplecov/source_file.rb#138 - def missed_branches; end - - # Returns all lines that should have been, but were not covered - # as instances of SimpleCov::SourceFile::Line - # - # source://simplecov//lib/simplecov/source_file.rb#54 - def missed_lines; end - - # Returns all lines that are not relevant for coverage as - # SimpleCov::SourceFile::Line instances - # - # source://simplecov//lib/simplecov/source_file.rb#60 - def never_lines; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file.rb#102 - def no_branches?; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file.rb#88 - def no_lines?; end - - # The path to this source file relative to the projects directory - # - # source://simplecov//lib/simplecov/source_file.rb#20 - def project_filename; end - - # source://simplecov//lib/simplecov/source_file.rb#92 - def relevant_lines; end - - # Returns all lines that were skipped as SimpleCov::SourceFile::Line instances - # - # source://simplecov//lib/simplecov/source_file.rb#65 - def skipped_lines; end - - # The source code for this file. Aliased as :source - # - # source://simplecov//lib/simplecov/source_file.rb#30 - def source; end - - # Returns all source lines for this file as instances of SimpleCov::SourceFile::Line, - # and thus including coverage data. Aliased as :source_lines - # - # source://simplecov//lib/simplecov/source_file.rb#45 - def source_lines; end - - # The source code for this file. Aliased as :source - # - # source://simplecov//lib/simplecov/source_file.rb#25 - def src; end - - # Return the relevant branches to source file - # - # source://simplecov//lib/simplecov/source_file.rb#112 - def total_branches; end - - private - - # source://simplecov//lib/simplecov/source_file.rb#346 - def branch_coverage_statistics; end - - # source://simplecov//lib/simplecov/source_file.rb#324 - def build_branch(branch_data, hit_count, condition_start_line); end - - # Call recursive method that transform our static hash to array of objects - # - # @return [Array] - # - # source://simplecov//lib/simplecov/source_file.rb#273 - def build_branches; end - - # source://simplecov//lib/simplecov/source_file.rb#310 - def build_branches_from(condition, branches); end - - # Build full branches report - # Root branches represent the wrapper of all condition state that - # have inside the branches - # - # @return [Hash] - # - # source://simplecov//lib/simplecov/source_file.rb#262 - def build_branches_report; end - - # source://simplecov//lib/simplecov/source_file.rb#229 - def build_lines; end - - # source://simplecov//lib/simplecov/source_file.rb#164 - def build_no_cov_chunks; end - - # Warning to identify condition from Issue #56 - # - # source://simplecov//lib/simplecov/source_file.rb#251 - def coverage_exceeding_source_warn; end - - # source://simplecov//lib/simplecov/source_file.rb#214 - def ensure_remove_undefs(file_lines); end - - # source://simplecov//lib/simplecov/source_file.rb#336 - def line_coverage_statistics; end - - # source://simplecov//lib/simplecov/source_file.rb#246 - def lines_strength; end - - # source://simplecov//lib/simplecov/source_file.rb#178 - def load_source; end - - # no_cov_chunks is zero indexed to work directly with the array holding the lines - # - # source://simplecov//lib/simplecov/source_file.rb#160 - def no_cov_chunks; end - - # source://simplecov//lib/simplecov/source_file.rb#282 - def process_skipped_branches(branches); end - - # source://simplecov//lib/simplecov/source_file.rb#237 - def process_skipped_lines(lines); end - - # source://simplecov//lib/simplecov/source_file.rb#198 - def read_lines(file, lines, current_line); end - - # Since we are dumping to and loading from JSON, and we have arrays as keys those - # don't make their way back to us intact e.g. just as a string - # - # We should probably do something different here, but as it stands these are - # our data structures that we write so eval isn't _too_ bad. - # - # See #801 - # - # source://simplecov//lib/simplecov/source_file.rb#300 - def restore_ruby_data_structure(structure); end - - # source://simplecov//lib/simplecov/source_file.rb#206 - def set_encoding_based_on_magic_comment(file, line); end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file.rb#194 - def shebang?(line); end -end - -# Representing single branch that has been detected in coverage report. -# Give us support methods that handle needed calculations. -# -# source://simplecov//lib/simplecov/source_file/branch.rb#8 -class SimpleCov::SourceFile::Branch - # @return [Branch] a new instance of Branch - # - # source://simplecov//lib/simplecov/source_file/branch.rb#12 - def initialize(start_line:, end_line:, coverage:, inline:, type:); end - - # Returns the value of attribute coverage. - # - # source://simplecov//lib/simplecov/source_file/branch.rb#9 - def coverage; end - - # Return true if there is relevant count defined > 0 - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/branch.rb#31 - def covered?; end - - # Returns the value of attribute end_line. - # - # source://simplecov//lib/simplecov/source_file/branch.rb#9 - def end_line; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/branch.rb#22 - def inline?; end - - # Check if branche missed or not - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/branch.rb#40 - def missed?; end - - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/branch.rb#70 - def overlaps_with?(line_range); end - - # Return array with coverage count and badge - # - # @return [Array] - # - # source://simplecov//lib/simplecov/source_file/branch.rb#79 - def report; end - - # The line on which we want to report the coverage - # - # Usually we choose the line above the start of the branch (so that it shows up - # at if/else) because that - # * highlights the condition - # * makes it distinguishable if the first line of the branch is an inline branch - # (see the nested_branches fixture) - # - # source://simplecov//lib/simplecov/source_file/branch.rb#52 - def report_line; end - - # Flags the branch as skipped - # - # source://simplecov//lib/simplecov/source_file/branch.rb#61 - def skipped!; end - - # Returns true if the branch was marked skipped by virtue of nocov comments. - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/branch.rb#66 - def skipped?; end - - # Returns the value of attribute start_line. - # - # source://simplecov//lib/simplecov/source_file/branch.rb#9 - def start_line; end - - # Returns the value of attribute type. - # - # source://simplecov//lib/simplecov/source_file/branch.rb#9 - def type; end -end - -# Representation of a single line in a source file including -# this specific line's source code, line_number and code coverage, -# with the coverage being either nil (coverage not applicable, e.g. comment -# line), 0 (line not covered) or >1 (the amount of times the line was -# executed) -# -# source://simplecov//lib/simplecov/source_file/line.rb#10 -class SimpleCov::SourceFile::Line - # @raise [ArgumentError] - # @return [Line] a new instance of Line - # - # source://simplecov//lib/simplecov/source_file/line.rb#25 - def initialize(src, line_number, coverage); end - - # The coverage data for this line: either nil (never), 0 (missed) or >=1 (times covered) - # - # source://simplecov//lib/simplecov/source_file/line.rb#16 - def coverage; end - - # Returns true if this is a line that has been covered - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/line.rb#42 - def covered?; end - - # The line number in the source file. Aliased as :line, :number - # - # source://simplecov//lib/simplecov/source_file/line.rb#22 - def line; end - - # The line number in the source file. Aliased as :line, :number - # - # source://simplecov//lib/simplecov/source_file/line.rb#14 - def line_number; end - - # Returns true if this is a line that should have been covered, but was not - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/line.rb#37 - def missed?; end - - # Returns true if this line is not relevant for coverage - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/line.rb#47 - def never?; end - - # The line number in the source file. Aliased as :line, :number - # - # source://simplecov//lib/simplecov/source_file/line.rb#23 - def number; end - - # Whether this line was skipped - # - # source://simplecov//lib/simplecov/source_file/line.rb#18 - def skipped; end - - # Flags this line as skipped - # - # source://simplecov//lib/simplecov/source_file/line.rb#52 - def skipped!; end - - # Returns true if this line was skipped, false otherwise. Lines are skipped if they are wrapped with - # # :nocov: comment lines. - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/source_file/line.rb#58 - def skipped?; end - - # The source code for this line. Aliased as :source - # Lets grab some fancy aliases, shall we? - # - # source://simplecov//lib/simplecov/source_file/line.rb#21 - def source; end - - # The source code for this line. Aliased as :source - # - # source://simplecov//lib/simplecov/source_file/line.rb#12 - def src; end - - # The status of this line - either covered, missed, skipped or never. Useful i.e. for direct use - # as a css class in report generation - # - # source://simplecov//lib/simplecov/source_file/line.rb#64 - def status; end -end - -# source://simplecov//lib/simplecov/source_file.rb#205 -SimpleCov::SourceFile::RUBY_FILE_ENCODING_MAGIC_COMMENT_REGEX = T.let(T.unsafe(nil), Regexp) - -# source://simplecov//lib/simplecov/source_file.rb#193 -SimpleCov::SourceFile::SHEBANG_REGEX = T.let(T.unsafe(nil), Regexp) - -# source://simplecov//lib/simplecov/filter.rb#53 -class SimpleCov::StringFilter < ::SimpleCov::Filter - # Returns true when the given source file's filename matches the - # string configured when initializing this Filter with StringFilter.new('somestring') - # - # @return [Boolean] - # - # source://simplecov//lib/simplecov/filter.rb#56 - def matches?(source_file); end -end - -# Select the files that related to working scope directory of SimpleCov -# -# source://simplecov//lib/simplecov/useless_results_remover.rb#7 -module SimpleCov::UselessResultsRemover - class << self - # source://simplecov//lib/simplecov/useless_results_remover.rb#8 - def call(coverage_result); end - - # source://simplecov//lib/simplecov/useless_results_remover.rb#14 - def root_regx; end - end -end - -# source://simplecov//lib/simplecov/version.rb#4 -SimpleCov::VERSION = T.let(T.unsafe(nil), String) diff --git a/sorbet/rbi/gems/simplecov@1.1.1.rbi b/sorbet/rbi/gems/simplecov@1.1.1.rbi new file mode 100644 index 0000000..f13ed26 --- /dev/null +++ b/sorbet/rbi/gems/simplecov@1.1.1.rbi @@ -0,0 +1,7254 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for types exported from the `simplecov` gem. +# Please instead update this file by running `bin/tapioca gem simplecov`. + + +# Code coverage for ruby. Please check out README for a full introduction. +# +# source://simplecov//lib/simplecov.rb#6 +module SimpleCov + extend ::SimpleCov::Configuration + extend ::SimpleCov::RunIdentity::Accessors + + class << self + # source://simplecov//lib/simplecov/exit_handling.rb#20 + def at_exit_behavior; end + + # Clear out the previously cached .result. Primarily useful in testing. + # + # source://simplecov//lib/simplecov/result_processing.rb#123 + def clear_result; end + + # Collate a series of SimpleCov result files into a single SimpleCov output. + # + # See README for usage. By default `collate` ignores the merge_timeout + # so all results in all files specified will be merged. Pass + # `ignore_timeout: false` to honor it. + # + # `processes:` above 1 fans the merge out across that many forked worker + # processes, for a collate big enough that reading and parsing the + # resultsets dominates it. The report is identical either way, not merely + # equivalent — the workers visit the resultsets in the order the + # single-process merge visits them — and one process never forks at all. + # The count is deliberately not clamped to the machine's core count nor + # gated on some minimum number of resultsets: how many processes a collate + # job can afford is the caller's call, not SimpleCov's. Anything below 1 is + # taken as 1, so a count computed from arithmetic that can reach zero needs + # no guarding. Defaults to `SIMPLECOV_CONCURRENCY`, or 1 when that is + # unset. See `SimpleCov::ParallelResultMerger`. + # + # source://simplecov//lib/simplecov/result_processing.rb#28 + def collate(result_filenames, profile = T.unsafe(nil), processes: T.unsafe(nil), ignore_timeout: T.unsafe(nil), &_arg4); end + + # @api private — true while `SimpleCov.collate` is running its finalizer. + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_processing.rb#85 + def collating_result?; end + + # Build this process's slice of the coverage result. `standalone` is true + # when no merge step follows, which makes this the final result: it is the + # one that reports dropped source files, and the one that injects unloaded + # files. When a merge does follow, both jobs belong to the merged result. + # + # source://simplecov//lib/simplecov/result_processing.rb#73 + def collect_own_coverage(standalone:); end + + # `:oneshot_line` data is folded into the `:line` bucket of + # `coverage_statistics` by `ResultAdapter`, so use `:line` to look + # up stats for either criterion. + # + # source://simplecov//lib/simplecov.rb#66 + def coverage_statistics_key(criterion); end + + # source://simplecov//lib/simplecov/parallel_coordination.rb#89 + def current_parallel_worker_count; end + + # Returns true when our process has no coverage data to contribute + # (after the resultset merge) and a newer report already exists on + # disk. Typically fires when `SimpleCov.start` ran in a parent + # process that shelled out to the test runner. See issue #581. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/report_deferral.rb#13 + def defer_to_existing_report?; end + + # `.last_run.json` only exists after fully successful runs, so alone + # it left the backstop inert when the child run failed — the case + # where clobbering its report hurts most. The report stamp is + # touched by every formatting process regardless of exit status. The + # rescue covers a file vanishing mid-at_exit (`rm -rf coverage`). + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/report_deferral.rb#27 + def existing_report_newer_than_us?; end + + # @api private + # + # source://simplecov//lib/simplecov/exit_handling.rb#68 + def exit_and_report_previous_error(exit_status); end + + # @api private — returns the exit status from the exit exception. + # + # source://simplecov//lib/simplecov/exit_handling.rb#49 + def exit_status_from_exception; end + + # Should we take care of at_exit behavior or something else? Used by the + # minitest plugin. See lib/minitest/simplecov_plugin.rb. + # + # source://simplecov//lib/simplecov.rb#61 + def external_at_exit; end + + # Should we take care of at_exit behavior or something else? Used by the + # minitest plugin. See lib/minitest/simplecov_plugin.rb. + # + # source://simplecov//lib/simplecov.rb#61 + def external_at_exit=(_arg0); end + + # Coerce to a proper boolean so rspec-mocks 4's predicate matcher + # (`expect(...).not_to be_external_at_exit`) accepts the result. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov.rb#72 + def external_at_exit?; end + + # Applies the configured filters to the given array of SimpleCov::SourceFile items + # + # source://simplecov//lib/simplecov/result_processing.rb#90 + def filtered(files); end + + # @api private + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_coordination.rb#12 + def final_result_process?; end + + # running (set by SimpleCov::ProcessForkHook in the child). Such a child + # stores its own slice but must not act as the final-result process: the + # process that forked it merges every slice and produces the report. Only + # consulted when no parallel-test adapter is active, since adapters answer + # `first_worker?` themselves. See issue #1171. + # + # @api private — true in a process that was forked while coverage was + # @return [Boolean] + # + # source://simplecov//lib/simplecov.rb#51 + def forked_subprocess?; end + + # Bin the given source files by group filter. `groups:` defaults to + # `SimpleCov.groups`; pass a Hash explicitly to bin against a + # different group config (e.g., the snapshot a Result captured at + # construction). Files matched by no group fall into the implicit + # "Ungrouped" bucket. + # + # source://simplecov//lib/simplecov/result_processing.rb#103 + def grouped(files, groups: T.unsafe(nil)); end + + # Add simulated coverage for each of `candidate_paths` the result doesn't + # already carry, and return it with the set of paths added. + # + # only because the merge runs in another object, on behalf of processes + # whose configuration it may not share, so it supplies the paths itself. + # + # @api private — the seam `SimpleCov::ResultMerger` injects through. Public + # + # source://simplecov//lib/simplecov/result_processing.rb#146 + def inject_unloaded_files(result, candidate_paths, synthesize: T.unsafe(nil), lines: T.unsafe(nil)); end + + # Install the at_exit hook that formats results and runs exit-code + # checks. `SimpleCov.start` calls this automatically. Idempotent — + # safe to call multiple times. Callers that drive the formatting + # pipeline themselves (e.g., dogfood test setups) can skip it by + # using `start_tracking` directly instead of `start`. + # + # source://simplecov//lib/simplecov.rb#129 + def install_at_exit_hook; end + + # Applies the profile of given name on SimpleCov configuration + # + # source://simplecov//lib/simplecov/result_processing.rb#118 + def load_profile(name); end + + # @api private — marked in the child immediately after a fork. + # + # source://simplecov//lib/simplecov.rb#56 + def mark_forked_subprocess!; end + + # source://simplecov//lib/simplecov/parallel_coordination.rb#107 + def monotonic_time; end + + # child inherits its own ordinal via copy-on-write. + # + # @api private — bump the serial in the parent before a fork so the + # + # source://simplecov//lib/simplecov.rb#41 + def next_subprocess_serial!; end + + # before the wait deadline. Defaults to true outside a parallel + # run (when `wait_for_other_processes` is a no-op). + # + # @api private — true when every sibling reported its resultset + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_coordination.rb#49 + def parallel_results_complete?; end + + # the first timeout so the user knows the merged total is partial. + # + # @api private — true once the wait deadline has passed; warns on + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_coordination.rb#113 + def parallel_wait_timed_out?(deadline, expected, seen); end + + # Returns the value of attribute pid. + # + # source://simplecov//lib/simplecov.rb#23 + def pid; end + + # Sets the attribute pid + # + # @param value the value to set the attribute pid to. + # + # source://simplecov//lib/simplecov.rb#23 + def pid=(_arg0); end + + # accepts it. test_unit sets status 0 on success, so SUCCESS must + # also be treated as "not a previous error". + # + # @api private — strict boolean so rspec-mocks 4's predicate matcher + # @return [Boolean] + # + # source://simplecov//lib/simplecov/exit_handling.rb#63 + def previous_error?(error_exit_status); end + + # @api private — `exit_status = SimpleCov.process_result(SimpleCov.result)`. + # + # source://simplecov//lib/simplecov/exit_handling.rb#105 + def process_result(result); end + + # source://simplecov//lib/simplecov/exit_handling.rb#90 + def process_results_and_report_error; end + + # When this process started tracking coverage. Captured by SimpleCov.start + # so JSONFormatter can detect when an existing coverage.json was written + # by a sibling process running concurrently. + # + # source://simplecov//lib/simplecov.rb#27 + def process_start_time; end + + # When this process started tracking coverage. Captured by SimpleCov.start + # so JSONFormatter can detect when an existing coverage.json was written + # by a sibling process running concurrently. + # + # source://simplecov//lib/simplecov.rb#27 + def process_start_time=(_arg0); end + + # only one that reports against thresholds, and only when its + # `wait_for_other_processes` confirmed every sibling reported. + # When the wait times out, the merged total is partial and + # comparing it against `minimum_coverage` / `maximum_coverage` + # would surface a spurious "below minimum" violation about the + # missing slice rather than a real shortfall. + # + # @api private — the process that owns final merge processing is the + # @return [Boolean] + # + # source://simplecov//lib/simplecov/exit_handling.rb#85 + def ready_to_process_results?; end + + # Returns the result for the current coverage run. With merging enabled, + # every process stores its own slice, but only the finalization owner reads + # and caches the merged result. + # + # source://simplecov//lib/simplecov/result_processing.rb#49 + def result; end + + # Returns nil if the result has not been computed, otherwise the result. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_processing.rb#80 + def result?; end + + # source://simplecov//lib/simplecov/exit_handling.rb#111 + def result_exit_status(result); end + + # Track whether the resultset count has held steady (and positive) for + # `PARALLEL_RESULTS_SETTLE` seconds. `tracker` carries the last count and + # the time it last changed across poll iterations. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_coordination.rb#97 + def resultset_count_settled?(tracker, count); end + + # @api private — round down to two decimals to be extra strict. + # + # source://simplecov//lib/simplecov/result_processing.rb#136 + def round_coverage(coverage); end + + # the status, see there) and by `collate` (the default argument). + # + # @api private — called from the at_exit block (which pre-captures + # + # source://simplecov//lib/simplecov/exit_handling.rb#41 + def run_exit_tasks!(error_exit_status = T.unsafe(nil)); end + + # Sets up SimpleCov to run against your project. See README for + # the full DSL, or: + # + # SimpleCov.start + # SimpleCov.start 'rails' # using a profile + # SimpleCov.start { add_filter 'test' } # with a config block + # + # source://simplecov//lib/simplecov.rb#84 + def start(profile = T.unsafe(nil), &_arg1); end + + # Begin coverage tracking without applying configuration. Pairs with + # `SimpleCov.configure { ... }` for callers that want to separate + # the two — for example a dogfood test that has already started + # `Coverage` itself before requiring simplecov, but still wants the + # process_start_time / pid / fork-hook bookkeeping. + # + # source://simplecov//lib/simplecov.rb#152 + def start_tracking; end + + # A monotonically increasing serial the parent assigns to each forked + # subprocess (see SimpleCov::ProcessForkHook). The default `at_fork` + # builds the worker's command_name from this rather than the OS pid: + # the serial sequence is the same from one run to the next, so a re-run + # overwrites the previous run's resultset entries instead of writing + # uniquely-named ones that pile up until merge_timeout. See issue #1171. + # + # source://simplecov//lib/simplecov.rb#35 + def subprocess_serial; end + + # @api private + # + # source://simplecov//lib/simplecov/parallel_coordination.rb#26 + def wait_for_other_processes; end + + # resultset it's going to get, false on timeout. Single-process runs + # (expected <= 1) short-circuit to true with no waiting. + # + # Normally we poll until `expected` (= PARALLEL_TEST_GROUPS) workers have + # reported or `SimpleCov.parallel_wait_timeout` elapses; raise that setting + # when a slow worker routinely finishes well after the others. + # + # When a native wait already confirmed every sibling PROCESS exited + # (`native_wait`), no further resultset will appear, so a count below + # `expected` just means some workers produced none — e.g. parallel_test + # groups that got no spec file on a machine with more cores than files. + # Once the count then holds steady for `PARALLEL_RESULTS_SETTLE` we accept + # it as final rather than blocking for the whole timeout. Without a native + # wait (GenericAdapter) we can't tell an idle worker from a slow one, so we + # keep waiting the full timeout. + # + # @api private — returns true when the reporting worker has every + # + # source://simplecov//lib/simplecov/parallel_coordination.rb#74 + def wait_for_parallel_results(expected, native_wait: T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/report_deferral.rb#37 + def warn_about_deferred_report; end + + # @api private + # + # source://simplecov//lib/simplecov/parallel_coordination.rb#121 + def warn_about_incomplete_parallel_results(expected, seen); end + + # source://simplecov//lib/simplecov.rb#110 + def warn_about_start_in_dot_simplecov; end + + # Mark the duration of a `.simplecov` auto-load so any `SimpleCov.start` + # call inside the file can warn about the impending migration to a + # config-only file. Tracking still begins for backward compatibility; + # the warning is the cue to move `SimpleCov.start` into a test helper. + # See #581. + # + # @api private + # + # source://simplecov//lib/simplecov.rb#99 + def with_dot_simplecov_autoload; end + + # rounded down (see #679) so the next run can compute drift. + # + # @api private — persist the per-criterion coverage percentages + # + # source://simplecov//lib/simplecov/result_processing.rb#129 + def write_last_run(result); end + + private + + # source://simplecov//lib/simplecov/exit_handling.rb#117 + def build_coverage_limits; end + + # source://simplecov//lib/simplecov.rb#202 + def defer_to_minitest_after_run; end + + # source://simplecov//lib/simplecov/result_processing.rb#170 + def grouped_file_set(grouped); end + + # source://simplecov//lib/simplecov/result_processing.rb#165 + def initial_setup(profile, &block); end + + # `Rake::TestTask` runs `ruby -e 'require "minitest/autorun"; ...'`, + # which means Minitest's at_exit registers before SimpleCov's. Since + # at_exit fires LIFO, SimpleCov's hook would otherwise run *before* + # Minitest gets a chance to invoke the tests — and format an empty + # resultset. When we can see that Minitest is loaded and its autorun + # is armed, route the report through `Minitest.after_run` instead, + # which fires after the suite completes. See issues #1099 and #1112. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov.rb#195 + def minitest_autorun_pending?; end + + # Run all the steps that handle processing the raw coverage result. + # `report:` is true only when this slice is the final result (merging + # off); with merging on the merged result reports dropped source files, + # so the per-process slice stays quiet to avoid one warning per worker. + # + # `inject_unloaded:` is likewise false when a merge step follows. Only the + # union of every process's loaded files says what was really never loaded, + # so injecting here means each worker simulates nearly the whole project + # and all but one of those passes is merged away. See #1250. + # + # source://simplecov//lib/simplecov/result_processing.rb#202 + def process_coverage_result(report:, inject_unloaded: T.unsafe(nil)); end + + # Trigger Coverage.start with the configured criteria. Every supported + # runtime (CRuby >= 3.2, JRuby >= 10, TruffleRuby >= 22) accepts the + # criteria-hash form, so no compatibility fallback is needed. + # + # source://simplecov//lib/simplecov.rb#178 + def start_coverage_measurement; end + + # Every path this process was told to track, whether or not it loaded them. + # Recorded on the result (and from there into the resultset) so that a merge + # in another process can inject the ones nobody loaded without needing this + # process's `cover` / `track_files` configuration. A standalone `collate` + # never ran `SimpleCov.start` and so has none of its own. See #1250. + # + # source://simplecov//lib/simplecov/result_processing.rb#179 + def tracked_file_paths; end + + # Globs to expand on disk when injecting unloaded files into the + # result. Combines the legacy `track_files` glob (additive only) + # with every string glob declared via `cover` (also restrictive, + # but the restriction lives in `Result#apply_cover_filters!`). + # + # source://simplecov//lib/simplecov/result_processing.rb#189 + def unloaded_file_discovery_globs; end + + # JRuby coverage data is unreliable unless full-trace mode is enabled. + # + # @see https://github.com/jruby/jruby/issues/1196 + # @see https://github.com/simplecov-ruby/simplecov/issues/420 + # @see https://github.com/simplecov-ruby/simplecov/issues/86 + # + # source://simplecov//lib/simplecov.rb#211 + def warn_if_jruby_full_trace_disabled; end + end +end + +# Filter that matches when any of its component filters (built from the +# array's elements) match the source file. +# +# source://simplecov//lib/simplecov/filter.rb#151 +class SimpleCov::ArrayFilter < ::SimpleCov::Filter + # @return [ArrayFilter] a new instance of ArrayFilter + # + # source://simplecov//lib/simplecov/filter.rb#152 + def initialize(filter_argument); end + + # Returns true if any of the filters in the array match the given source file. + # Configure this Filter like StringFilter.new(['some/path', /^some_regex/, Proc.new {|src_file| ... }]) + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#167 + def matches?(source_files_list); end + + # Path-decidable only when every component filter is. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#161 + def path_only?; end +end + +# Replaces an artifact through a collision-safe temporary file in the +# destination directory, so readers see either the old or complete new file. +# +# source://simplecov//lib/simplecov/atomic_file.rb#9 +module SimpleCov::AtomicFile + class << self + # source://simplecov//lib/simplecov/atomic_file.rb#13 + def write(path, content, binary: T.unsafe(nil)); end + + private + + # source://simplecov//lib/simplecov/atomic_file.rb#61 + def destination_mode(path); end + + # On POSIX systems rename replaces the destination atomically. On + # Windows it fails with EACCES when the destination is open in a + # reader or mid-replacement by a concurrent writer, so retry briefly + # there before giving up. + # + # source://simplecov//lib/simplecov/atomic_file.rb#41 + def rename_over(temp_path, path); end + + # source://simplecov//lib/simplecov/atomic_file.rb#25 + def replace(temp, path, content, mode, binary:); end + end +end + +# source://simplecov//lib/simplecov/atomic_file.rb#10 +SimpleCov::AtomicFile::DEFAULT_MODE = T.let(T.unsafe(nil), Integer) + +# Filter that matches when the configured block returns truthy for the +# source file. +# +# source://simplecov//lib/simplecov/filter.rb#127 +class SimpleCov::BlockFilter < ::SimpleCov::Filter + # Returns true if the block given when initializing this filter with BlockFilter.new {|src_file| ... } + # returns true for the given source file. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#130 + def matches?(source_file); end +end + +# Maps SimpleCov's criterion names to the keys `Coverage.start` expects. +# Lives at module scope (not inside `class << self`) so it can be +# declared in the RBS signatures; lexical scoping keeps every existing +# reference inside the singleton class working. +# +# source://simplecov//lib/simplecov.rb#15 +SimpleCov::CRITERION_TO_RUBY_COVERAGE = T.let(T.unsafe(nil), Hash) + +# ANSI colorization for stderr diagnostics. Thresholds mirror the +# HTML formatter (>= 90 green, >= 75 yellow, otherwise red) so a +# team's mental model of "what's the cutoff" is the same whether +# they're reading the terminal output or the HTML report. +# +# Color precedence, highest first: +# +# - `SimpleCov.color = true` / `false` (programmatic override, wins +# over everything; default is `:auto` which falls through) +# - `NO_COLOR` env var (any non-empty value) → off (see no-color.org) +# - `FORCE_COLOR` env var (any non-empty value) → on +# - `stream.tty?` fallback +# +# `NO_COLOR` wins over `FORCE_COLOR` if both env vars are set. +# +# source://simplecov//lib/simplecov/color.rb#18 +module SimpleCov::Color + private + + # Wrap `text` in the ANSI sequence for `color` (a key of ANSI). + # Returns the bare text if color is disabled. The `enabled:` + # keyword lets callers (e.g., CLI subcommands honoring `--no-color`) + # override the auto-detection without touching env vars. + # + # source://simplecov//lib/simplecov/color.rb#65 + def colorize(text, color, enabled: T.unsafe(nil)); end + + # Render `percent` as a fixed "NN.NN%" string colored by which + # threshold band it falls into. Callers that want a different + # rendering of the number can pass the pre-rendered `text`. + # + # source://simplecov//lib/simplecov/color.rb#74 + def colorize_percent(percent, text = T.unsafe(nil), enabled: T.unsafe(nil)); end + + # `stream` is the IO that the colorized text is destined for. The + # formatter writes to stderr, so that's the default. CLI subcommands + # that print to stdout should pass `$stdout` so a redirected pipe + # doesn't get ANSI sequences. See the module-level comment for + # precedence. + # + # source://simplecov//lib/simplecov/color.rb#36 + def enabled?(stream = T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/color.rb#78 + def env_set?(name); end + + # source://simplecov//lib/simplecov/color.rb#54 + def for_percent(percent); end + + class << self + # Wrap `text` in the ANSI sequence for `color` (a key of ANSI). + # Returns the bare text if color is disabled. The `enabled:` + # keyword lets callers (e.g., CLI subcommands honoring `--no-color`) + # override the auto-detection without touching env vars. + # + # source://simplecov//lib/simplecov/color.rb#65 + def colorize(text, color, enabled: T.unsafe(nil)); end + + # Render `percent` as a fixed "NN.NN%" string colored by which + # threshold band it falls into. Callers that want a different + # rendering of the number can pass the pre-rendered `text`. + # + # source://simplecov//lib/simplecov/color.rb#74 + def colorize_percent(percent, text = T.unsafe(nil), enabled: T.unsafe(nil)); end + + # `stream` is the IO that the colorized text is destined for. The + # formatter writes to stderr, so that's the default. CLI subcommands + # that print to stdout should pass `$stdout` so a redirected pipe + # doesn't get ANSI sequences. See the module-level comment for + # precedence. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/color.rb#36 + def enabled?(stream = T.unsafe(nil)); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/color.rb#78 + def env_set?(name); end + + # source://simplecov//lib/simplecov/color.rb#54 + def for_percent(percent); end + end +end + +# source://simplecov//lib/simplecov/color.rb#22 +SimpleCov::Color::ANSI = T.let(T.unsafe(nil), Hash) + +# source://simplecov//lib/simplecov/color.rb#19 +SimpleCov::Color::GREEN_THRESHOLD = T.let(T.unsafe(nil), Integer) + +# source://simplecov//lib/simplecov/color.rb#20 +SimpleCov::Color::YELLOW_THRESHOLD = T.let(T.unsafe(nil), Integer) + +# Namespace for the pieces that combine coverage results. +# +# `CoverageAccumulator` is the entry point: it folds any number of +# resultsets together, delegating one file's lines, branches and methods to +# the combiner modules alongside it. +# +# source://simplecov//lib/simplecov/combine.rb#10 +module SimpleCov::Combine; end + +# Combine different branch coverage results on single file. +# +# Should be called through `CoverageAccumulator`. +# +# source://simplecov//lib/simplecov/combine/branches_combiner.rb#13 +module SimpleCov::Combine::BranchesCombiner + private + + # Folds `coverage` into `target`, an interned table keyed by condition + # identity. Kept interned (rather than turned back into tuple keys per + # merge) so a fold over N resultsets interns each condition once + # instead of re-interning the whole accumulated table N times. + # + # @return [Hash] `target` + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#39 + def absorb(target, coverage); end + + # Return merged branches or the existed branch if other is missing. + # + # Branches inside files are always same if they exist, the difference only in coverage count. + # Branch coverage report for any conditional case is built from hash, it's key is a condition and + # it's body is a hash << keys from condition and value is coverage rate >>. + # ex: branches => { [:if, 3, 8, 6, 8, 36] => + # {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36] => 2}, ... } + # We create copy of result and update it values depending on the combined branches coverage values. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#28 + def combine(coverage_a, coverage_b); end + + # Bounded by the project's branch count, like `RubyDataParser`'s parse + # cache. + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#67 + def identities; end + + # Turns an interned table back into the tuple-keyed hash the rest of + # SimpleCov reads. Done once, at the end of a fold. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#61 + def materialize(target); end + + # Split out so the empty arm table has somewhere to carry its type + # annotation. Only allocated the first time a condition is seen. + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#52 + def new_condition(condition); end + + # Branches match on source span, whatever ids the recording processes + # handed them (issue #1233). + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#73 + def tuple_identity(tuple); end + + class << self + # Folds `coverage` into `target`, an interned table keyed by condition + # identity. Kept interned (rather than turned back into tuple keys per + # merge) so a fold over N resultsets interns each condition once + # instead of re-interning the whole accumulated table N times. + # + # @return [Hash] `target` + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#39 + def absorb(target, coverage); end + + # Return merged branches or the existed branch if other is missing. + # + # Branches inside files are always same if they exist, the difference only in coverage count. + # Branch coverage report for any conditional case is built from hash, it's key is a condition and + # it's body is a hash << keys from condition and value is coverage rate >>. + # ex: branches => { [:if, 3, 8, 6, 8, 36] => + # {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36] => 2}, ... } + # We create copy of result and update it values depending on the combined branches coverage values. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#28 + def combine(coverage_a, coverage_b); end + + # Bounded by the project's branch count, like `RubyDataParser`'s parse + # cache. + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#67 + def identities; end + + # Turns an interned table back into the tuple-keyed hash the rest of + # SimpleCov reads. Done once, at the end of a fold. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#61 + def materialize(target); end + + # Split out so the empty arm table has somewhere to carry its type + # annotation. Only allocated the first time a condition is seen. + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#52 + def new_condition(condition); end + + # Branches match on source span, whatever ids the recording processes + # handed them (issue #1233). + # + # source://simplecov//lib/simplecov/combine/branches_combiner.rb#73 + def tuple_identity(tuple); end + end +end + +# Folds any number of resultsets' coverage into one, absorbing them one +# at a time. +# +# This replaces a pairwise `reduce` over the resultsets, which rebuilt +# the entire accumulated structure on every one of its N-1 steps: a +# fresh outer file hash, a fresh lines array for every file, and a fresh +# branch / method table for every file whose keys were re-interned from +# their tuples each time. On a 160-worker run over ~1,800 files that is +# ~290,000 whole-file rebuilds to produce ~1,800 files of output. +# +# The accumulated side is private to the fold, so it can be updated in +# place instead, and the interned tables only have to be turned back into +# tuple-keyed hashes once, at the end. +# +# Resultsets are absorbed one at a time rather than taken as a list, so +# `ResultMerger.merge_results` can keep reading and discarding them one +# file at a time — reading 100s of CI jobs' worth of coverage into memory +# at once is what that method is careful not to do. +# +# source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#29 +class SimpleCov::Combine::CoverageAccumulator + # @return [CoverageAccumulator] a new instance of CoverageAccumulator + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#69 + def initialize; end + + # Folds one resultset's coverage (filename => per-file coverage) into + # the accumulator. A `nil` coverage is ignored — a resultset that + # carried nothing contributes nothing. + # + # @return [CoverageAccumulator] self, so absorbs can be chained + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#87 + def absorb(coverage); end + + # The merged coverage, or `nil` when nothing was absorbed at all — the + # caller needs to tell "no results" apart from "results that cover + # nothing", and only the former means there is no report to build. + # + # A file only one resultset carried is returned exactly as it came in, + # untouched: with nothing to merge it into, copying it would only cost + # memory. + # + # @return [Hash, nil] + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#109 + def result; end + + private + + # The first sighting of a file is kept as-is; the second promotes it to + # a `MergedFile` that owns its state, and every later one folds into + # that same object. + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#122 + def merge_file(existing, file_coverage); end + + class << self + # A file some process actually loaded has at least one executed line; + # a simulated (never-loaded) file's lines are all `nil` or `0`. This is + # the signal the merge reconciles synthesized tuples on, and the one + # `ResultMerger` re-derives a merged result's not-loaded set from, so it + # lives here rather than being spelled out at each site. + # + # `Array()` plus the `Numeric` test rather than a bare + # `any?(&:positive?)` because this reads straight off a parsed + # resultset, which is external input: a file written by another + # SimpleCov version, or hand-edited, can carry anything under + # "lines" — a Hash coerces to pairs, a String to itself. Keeping a + # malformed entry to a wrong answer instead of a NoMethodError out + # of the middle of a merge is the point. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#43 + def executed?(lines); end + + # Folds `[command_names, coverage]` pairs into one merged coverage. + # + # `pairs` is only ever iterated, so a caller that reads resultsets off + # disk can hand in a lazy enumerable and never hold more than one in + # memory — which is what `ResultMerger.merge_results` is careful about. + # + # @return [Array] the concatenated command names and the merged coverage + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#57 + def fold(pairs); end + end +end + +# One file's accumulated coverage, against state this object owns +# outright rather than rebuilt for each merged pair. +# +# source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#134 +class SimpleCov::Combine::CoverageAccumulator::MergedFile + # A criterion is carried for this file when the data carries it, not + # when this process happens to measure it. A merge runs on behalf of + # the processes that produced the resultsets and does not necessarily + # share their configuration: `simplecov merge` never ran SimpleCov.start + # at all, and dropping a table it did not ask for would lose branch data + # the producers did measure. A nil table means nobody measured that + # criterion; an empty one means it was measured and the file has none. + # + # @return [MergedFile] a new instance of MergedFile + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#142 + def initialize(coverage, branches:, methods:); end + + # Folds one more resultset's coverage for this file in. + # + # @return [MergedFile] self + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#159 + def absorb(coverage); end + + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#165 + def to_h; end + + private + + # Both sides agree on whether they ran: union their tuples. A criterion + # only the incoming side carries comes into play here, because its + # tuples survive. It deliberately does not come into play on the path + # that drops them: promoting there would advertise an empty table + # sourced from data this merge just discarded, which claims the file + # has no branches when what is true is that nobody measured them. + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#213 + def absorb_tuples(coverage); end + + # The executed side's tuples when it has them. Otherwise `keep_empty` + # decides whether the criterion survives as an empty table, which is + # what keeps `SourceFile` able to tell "no branches here" from + # "branches were not measured". + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#247 + def authoritative_table(combiner, table, keep_empty); end + + # Only the accumulated side ran, so the incoming tuples are synthesized + # and contribute nothing. When the discarded side carried a methods + # table, a measuring process still gets one — the empty table stands in + # for the blanked side, which is what a pair against a blank one used + # to produce for a file that has no methods yet. When no side has + # carried methods at all, the table stays nil (the rule `initialize` + # documents), the same answer the union path gives. + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#226 + def drop_incoming_tuples(coverage); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#262 + def executed?(lines); end + + # Whether a side can be judged at all. Absent lines do not mean the side + # never ran: a branch-only or method-only run omits them even for the + # files it loaded, so treating that as synthesized would discard real + # tuples. Unknown means union, which is already what happens when + # neither side carries lines. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#258 + def judgeable?(lines); end + + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#174 + def new_table; end + + # When exactly one side of the merge was actually executed, its branch + # and method tuples are authoritative and the other side's are + # dropped. A simulated entry (SimulateCoverage backfills + # tracked-but-unloaded files) synthesizes those tuples statically, so + # a location that drifts from what Coverage emits would otherwise be + # unioned in by position and survive as a phantom, permanently-missed + # branch (see #1233). This contains any such drift to denominator + # inflation for files no process loaded, rather than a false miss on a + # covered file. Lines are never dropped: a simulated file's line shape + # is correct and carries the unloaded-file denominator (#1059). + # + # The accumulated side's executed-ness is re-read on every absorb + # because folding in an executed resultset can flip it, exactly as it + # would have flipped between two steps of the pairwise fold. + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#192 + def reconcile_synthesized(coverage); end + + # Only the incoming side ran, so what's accumulated is synthesized: + # start its tuples over from the executed side's. The authoritative + # side decides which criteria the file carries, not just their tuples. + # Keeping a table in play that only the dropped side carried would make + # the answer depend on which side was seen first. + # + # source://simplecov//lib/simplecov/combine/coverage_accumulator.rb#235 + def replace_tuples(coverage); end +end + +# Builds the raw-key => interned-identity caches the tuple combiners keep. +# +# The cache maps a raw key to its interned identity on first sight and +# keeps it: the pairwise fold would otherwise re-derive the accumulator's +# identities on every one of its merges, always to the same answer. +# +# Identities exist only to be `combine`'s hash keys, and `Array#hash` is +# not memoized, so keying on the identity tuple itself would rehash its +# elements for every key of both sides of every merge. An Integer hashes +# as an immediate. Two keys share an id exactly when their identities are +# equal, so the string and array forms of one key still merge together. +# +# source://simplecov//lib/simplecov/combine/identity_interner.rb#18 +module SimpleCov::Combine::IdentityInterner + private + + # source://simplecov//lib/simplecov/combine/identity_interner.rb#21 + def build; end + + class << self + # source://simplecov//lib/simplecov/combine/identity_interner.rb#21 + def build; end + end +end + +# The inner merge loop the branches and methods combiners share: +# fold `source`'s tuple => count pairs into `target`, an interned +# table keyed by each tuple's merge identity and holding +# `[raw_key, count]` pairs. The first-seen raw key is kept for +# display; counts sum. `target` and its pairs are always built by +# this loop, so updating them in place can't reach data a caller +# still holds. +# +# source://simplecov//lib/simplecov/combine/interned_counts.rb#12 +module SimpleCov::Combine::InternedCounts + private + + # @return [Hash] `target` + # + # source://simplecov//lib/simplecov/combine/interned_counts.rb#16 + def absorb_counts(target, source, identities); end + + class << self + # @return [Hash] `target` + # + # source://simplecov//lib/simplecov/combine/interned_counts.rb#16 + def absorb_counts(target, source, identities); end + end +end + +# Combine two different lines coverage results on same file +# +# Should be called through `CoverageAccumulator`. +# +# source://simplecov//lib/simplecov/combine/lines_combiner.rb#9 +module SimpleCov::Combine::LinesCombiner + private + + # The rare arm: `existing` is nil (line not yet relevant in the + # target) or malformed external input. + # + # source://simplecov//lib/simplecov/combine/lines_combiner.rb#68 + def coerce_add(existing, value); end + + # Folds `source` into `target` rather than building a third array. + # Only for a `target` the caller owns outright: a caller holding a + # reference to it (e.g. the parsed `coverage` key of a resultset + # hash being passed into a second merge) would see it change. + # `source` is never touched. + # + # Two runs of the same source file should agree on which lines + # are coverage-relevant (`nil` for comments / whitespace, `0`+ + # for executable). When they don't, treat "relevant on either + # side" as relevant rather than masking a real `0` as `nil`, + # which would silently drop an uncovered line from the + # denominator and inflate the percentage: + # + # => nil + nil = nil + # => nil + int = int (preserves a relevant-but-uncovered 0) + # => int + int = int (sum) + # + # The loop is written out rather than dispatching a block per + # element because this is the innermost loop of a merge: a + # 160-worker run over ~1,800 files folds tens of millions of line + # counts through it, and a call per count is a measurable share of + # that. + # + # @return [Array] the array to keep — `target` itself once there is one + # + # source://simplecov//lib/simplecov/combine/lines_combiner.rb#36 + def merge_into(target, source); end + + # Split out only to keep `merge_into` short; it is the same loop. + # + # The `Integer` tests coerce malformed counts (a "3" written by a + # hand-edited or foreign resultset, a JSON float) with `to_i`, so + # external input merges to a wrong answer instead of raising out of + # the middle of a merge. An `is_a?` per count is far cheaper than + # the block dispatch this loop exists to avoid, and the well-formed + # fast path stays branch-for-branch what it was. + # + # source://simplecov//lib/simplecov/combine/lines_combiner.rb#53 + def sum_into(target, source, size); end + + class << self + # The rare arm: `existing` is nil (line not yet relevant in the + # target) or malformed external input. + # + # source://simplecov//lib/simplecov/combine/lines_combiner.rb#68 + def coerce_add(existing, value); end + + # Folds `source` into `target` rather than building a third array. + # Only for a `target` the caller owns outright: a caller holding a + # reference to it (e.g. the parsed `coverage` key of a resultset + # hash being passed into a second merge) would see it change. + # `source` is never touched. + # + # Two runs of the same source file should agree on which lines + # are coverage-relevant (`nil` for comments / whitespace, `0`+ + # for executable). When they don't, treat "relevant on either + # side" as relevant rather than masking a real `0` as `nil`, + # which would silently drop an uncovered line from the + # denominator and inflate the percentage: + # + # => nil + nil = nil + # => nil + int = int (preserves a relevant-but-uncovered 0) + # => int + int = int (sum) + # + # The loop is written out rather than dispatching a block per + # element because this is the innermost loop of a merge: a + # 160-worker run over ~1,800 files folds tens of millions of line + # counts through it, and a call per count is a measurable share of + # that. + # + # @return [Array] the array to keep — `target` itself once there is one + # + # source://simplecov//lib/simplecov/combine/lines_combiner.rb#36 + def merge_into(target, source); end + + # Split out only to keep `merge_into` short; it is the same loop. + # + # The `Integer` tests coerce malformed counts (a "3" written by a + # hand-edited or foreign resultset, a JSON float) with `to_i`, so + # external input merges to a wrong answer instead of raising out of + # the middle of a merge. An `is_a?` per count is far cheaper than + # the block dispatch this loop exists to avoid, and the well-formed + # fast path stays branch-for-branch what it was. + # + # source://simplecov//lib/simplecov/combine/lines_combiner.rb#53 + def sum_into(target, source, size); end + end +end + +# Combine different method coverage results on a single file. +# +# Should be called through `CoverageAccumulator`. +# +# source://simplecov//lib/simplecov/combine/methods_combiner.rb#13 +module SimpleCov::Combine::MethodsCombiner + private + + # Folds `coverage` into `target`, an interned table keyed by method + # source identity. See `BranchesCombiner.absorb` for why a fold keeps + # its accumulator interned. + # + # A `nil` `target` stays `nil` until some resultset actually carries + # methods, so a merge can tell "no method data anywhere" apart from + # "method data that covers nothing". + # + # @return [Hash, nil] `target`, created on the first coverage seen + # + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#47 + def absorb(target, coverage); end + + # Return merged methods or the existing methods if other is missing. + # + # Method coverage maps `[class, name, start_line, start_col, end_line, + # end_col]` keys to hit counts. Keys are matched on their SOURCE + # identity — the location, ignoring the class and name elements — + # because Ruby records one entry per defined method: the same + # `define_method` block defined onto different classes, or under + # different names, in different processes arrives with different + # receivers or names for the same source method, and matching on the + # full key would keep both, letting a never-called copy's 0 shadow a + # covered method after merge (issue #1234). Combining sums the hit + # counts for matching methods and preserves methods that only appear + # in one result. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#33 + def combine(coverage_a, coverage_b); end + + # Bounded by the project's method count, like `RubyDataParser`'s parse + # cache. + # + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#64 + def identities; end + + # Turns an interned table back into the tuple-keyed hash the rest of + # SimpleCov reads. Done once, at the end of a fold. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#58 + def materialize(target); end + + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#68 + def source_identity(key); end + + class << self + # Folds `coverage` into `target`, an interned table keyed by method + # source identity. See `BranchesCombiner.absorb` for why a fold keeps + # its accumulator interned. + # + # A `nil` `target` stays `nil` until some resultset actually carries + # methods, so a merge can tell "no method data anywhere" apart from + # "method data that covers nothing". + # + # @return [Hash, nil] `target`, created on the first coverage seen + # + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#47 + def absorb(target, coverage); end + + # Return merged methods or the existing methods if other is missing. + # + # Method coverage maps `[class, name, start_line, start_col, end_line, + # end_col]` keys to hit counts. Keys are matched on their SOURCE + # identity — the location, ignoring the class and name elements — + # because Ruby records one entry per defined method: the same + # `define_method` block defined onto different classes, or under + # different names, in different processes arrives with different + # receivers or names for the same source method, and matching on the + # full key would keep both, letting a never-called copy's 0 shadow a + # covered method after merge (issue #1234). Combining sums the hit + # counts for matching methods and preserves methods that only appear + # in one result. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#33 + def combine(coverage_a, coverage_b); end + + # Bounded by the project's method count, like `RubyDataParser`'s parse + # cache. + # + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#64 + def identities; end + + # Turns an interned table back into the tuple-keyed hash the rest of + # SimpleCov reads. Done once, at the end of a fold. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#58 + def materialize(target); end + + # source://simplecov//lib/simplecov/combine/methods_combiner.rb#68 + def source_identity(key); end + end +end + +# There might be reports from different kinds of tests, +# e.g. RSpec and Cucumber. We need to combine their results +# into unified one. This class does that. +# To unite the results on file basis, it leverages +# the combine of lines and branches inside each file within given results. +# +# source://simplecov//lib/simplecov/combine/results_combiner.rb#12 +module SimpleCov::Combine::ResultsCombiner + private + + # Combine process explanation + # => ResultsCombiner: hand every result to one accumulator. + # ==> CoverageAccumulator::MergedFile: hold one file's merged coverage. + # ===> LinesCombiner: combine lines results. + # ===> BranchesCombiner: combine branches results. + # ===> MethodsCombiner: combine methods results. + # + # Callers that read their results one at a time (`ResultMerger`, to + # keep a big CI run's resultsets out of memory all at once) should + # drive a `CoverageAccumulator` directly instead of collecting the + # results to pass here. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/results_combiner.rb#30 + def combine(*results); end + + class << self + # Combine process explanation + # => ResultsCombiner: hand every result to one accumulator. + # ==> CoverageAccumulator::MergedFile: hold one file's merged coverage. + # ===> LinesCombiner: combine lines results. + # ===> BranchesCombiner: combine branches results. + # ===> MethodsCombiner: combine methods results. + # + # Callers that read their results one at a time (`ResultMerger`, to + # keep a big CI run's resultsets out of memory all at once) should + # drive a `CoverageAccumulator` directly instead of collecting the + # results to pass here. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/combine/results_combiner.rb#30 + def combine(*results); end + end +end + +# Helper that tries to find out what test suite is running (for SimpleCov.command_name) +# +# source://simplecov//lib/simplecov/command_guesser.rb#7 +module SimpleCov::CommandGuesser + class << self + # source://simplecov//lib/simplecov/command_guesser.rb#35 + def guess; end + + # source://simplecov//lib/simplecov/command_guesser.rb#31 + def original_program_name; end + + # The invoked program on its own. `original_run_command` joins + # `$PROGRAM_NAME` and `ARGV` with a space, so a program path containing + # one (`/opt/My Ruby/bin/rspec`) cannot be recovered from it afterwards. + # Falls back to the first token for callers that set only the flattened + # command, which is every caller outside `defaults.rb`. + # + # source://simplecov//lib/simplecov/command_guesser.rb#29 + def original_program_name=(_arg0); end + + # Storage for the original command line call that invoked the test suite. + # This has got to be stored as early as possible because i.e. rake and test/unit 2 + # have a habit of tampering with ARGV, which makes i.e. the automatic distinction + # between rails unit/functional/integration tests impossible without this cached + # item. + # + # source://simplecov//lib/simplecov/command_guesser.rb#14 + def original_run_command; end + + # Assigning the flattened command clears any separately recorded program + # name so the two can never describe different runs. Callers that know the + # real one assign it immediately afterwards, as `defaults.rb` does. + # + # source://simplecov//lib/simplecov/command_guesser.rb#19 + def original_run_command=(command); end + + private + + # source://simplecov//lib/simplecov/command_guesser.rb#82 + def from_command_line_options; end + + # If the command regexps fail, let's try checking defined constants. + # + # source://simplecov//lib/simplecov/command_guesser.rb#118 + def from_defined_constants; end + + # source://simplecov//lib/simplecov/command_guesser.rb#98 + def from_executable_name; end + + # When parallel_tests (or a compatible runner) is driving the suite, + # tag the command name with this worker's position in the pool. + # + # source://simplecov//lib/simplecov/command_guesser.rb#44 + def parallel_data; end + end +end + +# Bundles the configuration options used for SimpleCov. All methods +# defined here are usable from SimpleCov directly. Please check out +# SimpleCov documentation for further info. +# +# source://simplecov//lib/simplecov/configuration.rb#11 +module SimpleCov::Configuration + # Whether SimpleCov has anything to do at exit: the Coverage module + # is actively tracking, or a `@result` has already been assembled + # (e.g. by `SimpleCov.collate`, which never starts Coverage). + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration.rb#114 + def active_session?; end + + # DEPRECATED: alias for `skip`. Same matcher grammar, identical behavior. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#101 + def add_filter(filter_argument = T.unsafe(nil), &block); end + + # DEPRECATED: alias for `group`. Same arguments, same behavior. + # + # source://simplecov//lib/simplecov/configuration/groups.rb#29 + def add_group(group_name, filter_argument = T.unsafe(nil), &block); end + + # Gets or sets the behavior to process coverage results. + # By default, it stores/merges the current result and formats only + # from the final reporting process. + # + # source://simplecov//lib/simplecov/configuration.rb#99 + def at_exit(&block); end + + # Gets or sets the behavior to start a new forked Process. + # Defaults to adding " (subprocess: #{serial})" to command_name and + # starting SimpleCov in quiet mode. + # + # source://simplecov//lib/simplecov/configuration.rb#123 + def at_fork(&block); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#76 + def branch_coverage?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#80 + def branch_coverage_supported?; end + + # Reset the criteria back to the lazy default (`Set[:line]`). + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#52 + def clear_coverage_criteria; end + + # Remove every filter from the chain, including the defaults installed + # by `SimpleCov.start`. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#118 + def clear_filters; end + + # Get or set whether SimpleCov colorizes its stderr diagnostics. Accepts + # `true` (always on), `false` (always off), or `:auto` (default: defer + # to `SimpleCov::Color`, which checks `$stderr.tty?` with `NO_COLOR` + # and `FORCE_COLOR` overrides). An explicit `true`/`false` wins over + # both auto-detection and the env vars, which is the right escape + # hatch when stderr is being piped through a wrapper that still + # renders ANSI in its own terminal (parallel_tests with + # `--combine-stderr`, log multiplexers, some CI runners). See #1157. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#68 + def color(value = T.unsafe(nil)); end + + # The name of the command (a.k.a. Test Suite) currently running. + # Used for result merging and caching. Auto-detected; set explicitly + # with SimpleCov.command_name("test:units"). + # + # source://simplecov//lib/simplecov/configuration.rb#68 + def command_name(name = T.unsafe(nil)); end + + # Allows you to configure simplecov in a block instead of + # prepending SimpleCov to each config method. Parameterized blocks retain + # their caller context and receive this configuration target explicitly. + # + # @raise [ArgumentError] + # + # source://simplecov//lib/simplecov/configuration.rb#83 + def configure(&block); end + + # Restrict the universe of files in the coverage report to those matching + # one or more globs, regexps, or block predicates. Multiple calls union; + # when any `cover` matcher is configured the report drops every file that + # doesn't match at least one of them. + # + # Strings are interpreted as shell globs (e.g. "lib/**/*.rb"), not + # substring matches — this is a deliberate departure from the legacy + # `add_filter` semantics and matches the way `track_files` already + # interprets its argument. + # + # When the matcher is a string-glob, `cover` also expands the glob on + # disk so files that exist but were never required during the run still + # appear in the report (at 0% coverage). This is the "include unloaded + # files" half of the legacy `track_files` behavior, rolled into the + # same call. + # + # SimpleCov.start do + # cover "lib/**/*.rb", "app/**/*.rb" + # cover(/_helper\.rb\z/) + # cover { |sf| sf.lines.count > 5 } + # end + # + # source://simplecov//lib/simplecov/configuration/filters.rb#32 + def cover(*args, &block); end + + # Returns the list of configured inclusion filters added via `cover`. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#39 + def cover_filters; end + + # Returns the list of string globs passed to `cover` — used by the + # disk-discovery pass in `SimpleCov.tracked_file_paths` so files + # matching a `cover` glob appear in the report even when they were + # never required during the suite. + # + # Walks into `ArrayFilter` entries (built when a caller passes an + # array to `cover`) so a glob nested inside `cover(["lib/**/*.rb", + # /helper\.rb\z/])` still drives unloaded-file discovery. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#51 + def cover_globs; end + + # Configure (and, unless `enabled: false`, enable) a coverage criterion. + # + # Threshold options mirror the block verbs for one-liner use: + # coverage :branch, minimum: 80, maximum_drop: 5 + # + # `primary: true` makes this the report's leading criterion (and the one a + # bare `minimum_coverage 90` targets). `oneshot: true` (valid only for + # `:line`) selects the faster oneshot-lines mode. + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#43 + def coverage(criterion, primary: T.unsafe(nil), enabled: T.unsafe(nil), oneshot: T.unsafe(nil), **thresholds, &block); end + + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#43 + def coverage_criteria; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#47 + def coverage_criterion_enabled?(criterion); end + + # Ask the Coverage runtime itself whether a criterion is supported + # (Ruby >= 3.2). Older Rubies don't expose `Coverage.supported?`, so + # fall back to the historical engine check that line/branch/method + # were unavailable on JRuby. `:eval` was added later, so on older + # Rubies its fallback is "always unsupported" rather than the + # JRuby-only one above. The fallback arm is unreachable from the + # dogfood report, which runs on a newer Ruby. + # simplecov:disable + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#100 + def coverage_criterion_supported?(criterion); end + + # The name of the output and cache directory. Defaults to 'coverage' + # + # Configure with SimpleCov.coverage_dir('cov') + # + # source://simplecov//lib/simplecov/configuration.rb#30 + def coverage_dir(dir = T.unsafe(nil)); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/eval_coverage.rb#13 + def coverage_for_eval_enabled?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/eval_coverage.rb#9 + def coverage_for_eval_supported?; end + + # Returns the full path to the output directory. By default + # constructed from `SimpleCov.root` + `SimpleCov.coverage_dir`, but + # callers can override with an arbitrary absolute path — handy for + # out-of-tree build directories. See #716. + # + # Reading is pure: the directory is only created when a path is + # explicitly assigned (the user has signaled they intend to write + # there). The codepaths that actually write into the directory + # (formatters, `LastRun`, `ResultsetStore`) ensure existence + # themselves, so read-only CLI subcommands that interpolate the + # path into status text don't materialize a stray `coverage/` + # directory. + # + # source://simplecov//lib/simplecov/configuration.rb#52 + def coverage_path(path = T.unsafe(nil)); end + + # Internal accessor used by SimpleCov to recognise `# :nocov:` + # markers without emitting the public-API deprecation warning. Will + # be removed alongside the deprecated `nocov_token` setter. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#132 + def current_nocov_token(value = T.unsafe(nil)); end + + # Remove `criterion` from the set of enabled coverage criteria. + # `:eval` turns the standalone eval-coverage toggle back off, + # mirroring `enable_coverage`. Disabling every criterion raises at + # `start_tracking` (not here), so config files that toggle criteria + # in arbitrary order don't have to worry about transient empty states. + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#26 + def disable_coverage(criterion); end + + # Enable one or more coverage criteria. `:eval` is accepted as a + # shorthand for the standalone eval-coverage toggle. + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#17 + def enable_coverage(*criteria); end + + # DEPRECATED: prefer `enable_coverage :eval`. + # + # source://simplecov//lib/simplecov/configuration/eval_coverage.rb#18 + def enable_coverage_for_eval; end + + # DEPRECATED: alias for `merge_subprocesses`. Same value/behavior. + # + # source://simplecov//lib/simplecov/configuration/merging.rb#38 + def enable_for_subprocesses(value = T.unsafe(nil)); end + + # whether to install the fork hook. + # + # @api private — predicate used by `start_tracking` to decide + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/merging.rb#21 + def enabled_for_subprocesses?; end + + # Pins the suite to an exact coverage figure by setting both + # `minimum_coverage` and `maximum_coverage`. See #187. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#43 + def expected_coverage(coverage = T.unsafe(nil)); end + + # Returns the list of configured exclusion filters added via `skip` + # (or the deprecated `add_filter`). + # + # source://simplecov//lib/simplecov/configuration/filters.rb#86 + def filters; end + + # Sets the attribute filters + # + # @param value the value to set the attribute filters to. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#7 + def filters=(_arg0); end + + # Get or set whether SimpleCov's selected final process owns merge processing: + # waiting for sibling workers, building the merged result, formatting, + # enforcing thresholds, and writing `.last_run.json`. + # + # Defaults to true, except for recognized multi-worker parallel runs + # that explicitly write to a custom coverage destination while merging + # is enabled. Those runs are likely using an external `SimpleCov.collate` + # step to finalize the merge. + # + # source://simplecov//lib/simplecov/configuration/merging.rb#65 + def finalize_merge(value = T.unsafe(nil)); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/merging.rb#78 + def finalize_merge?; end + + # Gets or sets the configured formatter. Accepts a formatter class + # (instantiated fresh for every report) or a ready-built instance, + # which is how constructor options are passed — e.g. + # `formatter SimpleCov::Formatter::HTMLFormatter.new(silent: true)` + # to suppress the "Coverage report generated" status line (see + # #1240). Pass `false` (or `nil`) to opt out of formatting + # entirely — worker processes in big parallel CI setups (see #964) + # only need their `.resultset.json` on disk so a final + # `SimpleCov.collate` job can produce the report; running them + # without a formatter saves the per-job HTML/multi-formatter + # overhead. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#24 + def formatter(formatter = T.unsafe(nil)); end + + # Sets the attribute formatter + # + # @param value the value to set the attribute formatter to. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#9 + def formatter=(_arg0); end + + # Sets the configured formatters. Pass `[]` to opt out of + # formatting entirely; see `formatter` for the rationale. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#35 + def formatters(formatters = T.unsafe(nil)); end + + # Sets the configured formatters. Equivalent to `formatters [...]`. + # Accepts a single formatter as well as an Array, matching the pre-1.0 behavior + # where `MultiFormatter.new` normalized its input. Elements may be + # formatter classes or ready-built instances; see `formatter`. + # `nil`, `false`, and `[]` all opt out of formatting entirely — + # `false` normalized like `formatter false` does, since `Array(false)` + # would otherwise smuggle it in as a "formatter" that can only fail. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#53 + def formatters=(formatters); end + + # Define a display group for files. Same matcher grammar as `skip`, + # but instead of dropping the matching files it bins them under + # `group_name` for the formatter. Files matched by no group fall + # into the implicit "Ungrouped" bucket. + # + # source://simplecov//lib/simplecov/configuration/groups.rb#22 + def group(group_name, filter_argument = T.unsafe(nil), &_arg2); end + + # Returns the configured groups. Add groups using SimpleCov.group. + # + # source://simplecov//lib/simplecov/configuration/groups.rb#8 + def groups; end + + # source://simplecov//lib/simplecov/configuration/groups.rb#12 + def groups=(new_groups); end + + # Variadic; multiple calls union. Setting is recorded regardless + # of whether branch coverage is enabled at call time. See #1033, #1046. + # + # source://simplecov//lib/simplecov/configuration/ignored_entries.rb#15 + def ignore_branches(*types); end + + # See `ignore_branches`. The only supported method-type token today + # is `:eval_generated`; see #1046 for the rationale. + # + # source://simplecov//lib/simplecov/configuration/ignored_entries.rb#31 + def ignore_methods(*types); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/ignored_entries.rb#25 + def ignored_branch?(type); end + + # source://simplecov//lib/simplecov/configuration/ignored_entries.rb#21 + def ignored_branches; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/ignored_entries.rb#41 + def ignored_method?(type); end + + # source://simplecov//lib/simplecov/configuration/ignored_entries.rb#37 + def ignored_methods; end + + # Whether this run produces line data at all. The oneshot variant counts: + # `ResultAdapter` turns its executed-line list back into a line array. A + # branch-only or method-only run produces none, and `Coverage.result` + # entries for the files it loaded carry no `"lines"` key. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#71 + def line_coverage?; end + + # Defines the maximum overall coverage allowed for the testsuite to + # pass. Useful paired with `minimum_coverage` (or via + # `expected_coverage`) to pin coverage to an exact value, so an + # unexpected jump up fails the build. See #187. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#33 + def maximum_coverage(coverage = T.unsafe(nil)); end + + # Defines the maximum coverage drop at once allowed for the + # testsuite to pass. Default is 100% (disabled). + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#54 + def maximum_coverage_drop(coverage_drop = T.unsafe(nil)); end + + # @api private + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/merging.rb#83 + def merge_finalization_owner?; end + + # Get or set whether SimpleCov should hook `Process._fork` to + # attach itself to subprocesses. Required when the suite uses + # parallel test workers (e.g. Rails' `parallelize(workers:)`). + # Defaults to false. + # + # source://simplecov//lib/simplecov/configuration/merging.rb#13 + def merge_subprocesses(value = T.unsafe(nil)); end + + # Defines the maximum age (in seconds) of a resultset to still be + # included in merged results. Default is 600 seconds (10 minutes). + # + # source://simplecov//lib/simplecov/configuration/merging.rb#102 + def merge_timeout(seconds = T.unsafe(nil)); end + + # Get or set whether to merge results from multiple test suites + # (test:units, test:functionals, cucumber, ...) into a single + # coverage report. Defaults to true. + # + # source://simplecov//lib/simplecov/configuration/merging.rb#49 + def merging(use = T.unsafe(nil)); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#84 + def method_coverage?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#88 + def method_coverage_supported?; end + + # Defines the minimum overall coverage required for the testsuite to pass. + # Returns non-zero if the current coverage is below this threshold. + # Default is 0% (disabled). + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#14 + def minimum_coverage(coverage = T.unsafe(nil)); end + + # Defines the minimum coverage per file required for the testsuite + # to pass. Accepts a Numeric (global threshold on the primary + # criterion), a Symbol-keyed Hash (per-criterion globals), or a + # Hash mixing Symbol keys with String / Regexp keys to declare + # per-path overrides. See README and #575. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#67 + def minimum_coverage_by_file(coverage = T.unsafe(nil)); end + + # Returns the per-path overrides set via `minimum_coverage_by_file`. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#84 + def minimum_coverage_by_file_overrides; end + + # Defines the minimum coverage per group required for the testsuite + # to pass. Default is 0% (disabled). + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#92 + def minimum_coverage_by_group(coverage = T.unsafe(nil)); end + + # Drop every filter previously installed (defaults plus anything + # earlier in this block) so subsequent `skip` calls start from a + # clean slate. Order matters — call this before your own `skip` + # invocations. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#126 + def no_default_skips; end + + # DEPRECATED: configure `# :nocov:` token override. Prefer + # `# simplecov:disable` / `# simplecov:enable` block comments (see + # SimpleCov::Directive). The `# :nocov:` toggle and this hook will + # be removed in a future release. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#122 + def nocov_token(nocov_token = T.unsafe(nil)); end + + # Get or set whether SimpleCov should auto-require the + # `parallel_tests` gem when it sees `TEST_ENV_NUMBER` / + # `PARALLEL_TEST_GROUPS` in the environment. Defaults to auto-detect + # (nil). See #1018. + # + # source://simplecov//lib/simplecov/configuration/merging.rb#31 + def parallel_tests(value = T.unsafe(nil)); end + + # Defines how long (in seconds) the reporting process waits for the + # remaining parallel-test workers to write their resultsets before it + # proceeds with a partial merge. Default is 60 seconds. Raise it when a + # slow worker routinely finishes well after the others, so its coverage + # is still included and the minimum / maximum coverage checks aren't + # skipped against a partial total. + # + # source://simplecov//lib/simplecov/configuration/merging.rb#115 + def parallel_wait_timeout(seconds = T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#34 + def primary_coverage(criterion = T.unsafe(nil)); end + + # DEPRECATED: alias for `print_errors`. Same value, same behavior. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#110 + def print_error_status; end + + # Sets the attribute print_error_status + # + # @param value the value to set the attribute print_error_status to. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#9 + def print_error_status=(_arg0); end + + # Get or set whether SimpleCov prints its own diagnostic warnings to + # stderr. Covers per-check threshold violations, the trailing + # "SimpleCov failed with exit ..." summary, and the deferred-report / + # previous-error notices. Defaults to true. Set to false to silence + # SimpleCov entirely when parsing tooling output (see issue #1155). + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#81 + def print_errors(value = T.unsafe(nil)); end + + # Returns the hash of available profiles + # + # source://simplecov//lib/simplecov/configuration.rb#74 + def profiles; end + + # Returns the project name — defaults to the last dirname in + # SimpleCov.root, capitalized with underscores → spaces. + # + # source://simplecov//lib/simplecov/configuration.rb#145 + def project_name(new_name = T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/configuration/thresholds.rb#20 + def raise_on_invalid_coverage(coverage, coverage_setting); end + + # Refuses any coverage drop. Coverage is only allowed to increase. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#105 + def refuse_coverage_drop(*criteria); end + + # Remove any filters whose `filter_argument` equals the given value. + # Returns true when at least one filter was removed, false otherwise. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#110 + def remove_filter(filter_argument); end + + # The root for the project. This defaults to the + # current working directory. + # + # Configure with SimpleCov.root('/my/project/path') + # + # source://simplecov//lib/simplecov/configuration.rb#18 + def root(root = T.unsafe(nil)); end + + # Drop matching files from the coverage report. The inverse of `cover`. + # + # See README for the full grammar; `skip` accepts a String (path-segment + # substring), Regexp, block predicate, or Array of any of those. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#96 + def skip(filter_argument = T.unsafe(nil), &_arg1); end + + # DEPRECATED: configure `# :nocov:` token override. Prefer + # `# simplecov:disable` / `# simplecov:enable` block comments (see + # SimpleCov::Directive). The `# :nocov:` toggle and this hook will + # be removed in a future release. + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#127 + def skip_token(nocov_token = T.unsafe(nil)); end + + # Get or set whether `coverage.json` includes the full source-text + # array for every file. Defaults to true. Set to false when a + # downstream tool reads the project's source files directly and + # only needs the coverage metrics, so `coverage.json` doesn't carry + # a copy of the source tree (which dominates the payload on larger + # projects). + # + # The HTML report's embedded data always includes source — the + # client-side renderer needs it. Only `coverage.json` honors + # this setting. + # + # SimpleCov.start do + # source_in_json false + # end + # + # source://simplecov//lib/simplecov/configuration/formatting.rb#103 + def source_in_json(value = T.unsafe(nil)); end + + # DEPRECATED: prefer `cover`, which both includes unloaded files (the + # historical `track_files` behavior) and restricts the report to the + # matching set. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#58 + def track_files(glob); end + + # `track_files(nil)` is the documented way to clear a previously-set + # glob, but `cover(nil)` raises `ConfigurationError`, so don't point + # users at it. The `cover` API has no direct equivalent for "reset + # the inclusion list" — point users at the `@cover_filters` reset. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#68 + def track_files_replacement_hint(glob); end + + # Returns the glob used to include files that were not explicitly required. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#80 + def tracked_files; end + + # DEPRECATED: alias for `merging`. Same value, same behavior. + # Delegating (rather than duplicating the body) also fixes the + # return value: this used to return nil where `merging` returns + # false, because the final expression was the skipped guard + # assignment rather than the stored value. + # + # source://simplecov//lib/simplecov/configuration/merging.rb#92 + def use_merging(use = T.unsafe(nil)); end + + # fast when the user has disabled every coverage criterion. + # + # @api private — called from `SimpleCov.start_tracking` to fail + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#59 + def validate_coverage_criteria!; end + + private + + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#110 + def add_coverage_criterion(criterion); end + + # Forward the one-liner threshold keywords (`coverage :branch, minimum: 80`) + # to the matching `CoverageCriterion` verbs, rejecting anything that isn't a + # recognized threshold option. + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#61 + def apply_threshold_options(configurator, options); end + + # Build a filter for a `cover` argument. Strings are treated as + # globs (not substrings — that's `skip`/`add_filter`'s semantics); + # everything else dispatches exactly like `add_filter`. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#144 + def build_cover_filter(arg); end + + # Walk a list of cover filters and return the string globs they hold, + # descending into `ArrayFilter` wrappers built by `cover(["a", "b"])`. + # + # source://simplecov//lib/simplecov/configuration/filters.rb#154 + def collect_cover_globs(filter_list); end + + # If `:line` is enabled, it's the default primary; otherwise fall + # back to whichever criterion the user actually enabled (in + # insertion order). Returning `:line` even when disabled would + # propagate broken state into `minimum_coverage 90`. + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#121 + def default_primary_coverage; end + + # No support check needed: off is a safe state on every Ruby. + # + # source://simplecov//lib/simplecov/configuration/eval_coverage.rb#37 + def disable_eval_coverage; end + + # Enable or disable the criterion (or its oneshot / eval variant) and + # return the criterion symbol that thresholds should be stored under. + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#75 + def enable_coverage_criterion(criterion, enabled:, oneshot:); end + + # Shared implementation backing both `enable_coverage :eval` and + # the deprecated `enable_coverage_for_eval`. + # + # source://simplecov//lib/simplecov/configuration/eval_coverage.rb#28 + def enable_eval_coverage; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/merging.rb#144 + def explicit_coverage_destination?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/merging.rb#138 + def explicit_custom_coverage_destination?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/merging.rb#122 + def inferred_finalize_merge?; end + + # source://simplecov//lib/simplecov/configuration/merging.rb#157 + def inferred_finalize_merge_warning; end + + # source://simplecov//lib/simplecov/configuration/thresholds.rb#143 + def minimum_possible_coverage_exceeded(coverage_option); end + + # Shared normalize-and-validate step behind every threshold setter: + # a bare Numeric targets the primary criterion, and the resulting + # per-criterion hash is validated before it is stored. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#115 + def normalized_threshold(coverage, setting); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/configuration/merging.rb#134 + def parallel_worker_environment?; end + + # The actual filter processor. Not meant for direct use. + # + # @raise [ArgumentError] + # + # source://simplecov//lib/simplecov/configuration/filters.rb#133 + def parse_filter(filter_argument = T.unsafe(nil), &filter_proc); end + + # Split a `minimum_coverage_by_file` argument into Symbol-keyed + # criterion defaults and String/Regexp-keyed per-path overrides; + # normalize Numeric override values to `{primary_coverage => N}` + # so downstream code only has one shape to handle. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#125 + def partition_per_file_thresholds(coverage); end + + # Render the `coverage` configuration equivalent to a (deprecated) + # `minimum_coverage_by_file` argument so the deprecation warning can be + # copy-pasted verbatim into the user's config. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#150 + def per_file_coverage_replacement(defaults, overrides); end + + # Same, for a (deprecated) `minimum_coverage_by_group` argument. + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#162 + def per_group_coverage_replacement(coverage); end + + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/configuration/ignored_entries.rb#47 + def raise_if_branch_type_unsupported(type); end + + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#129 + def raise_if_criterion_disabled(criterion); end + + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#147 + def raise_if_criterion_unsupported(criterion); end + + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/configuration/ignored_entries.rb#55 + def raise_if_method_type_unsupported(type); end + + # source://simplecov//lib/simplecov/configuration/thresholds.rb#173 + def render_coverage_blocks(by_criterion); end + + # source://simplecov//lib/simplecov/configuration/coverage.rb#81 + def resolve_criterion_variant(criterion, oneshot); end + + # source://simplecov//lib/simplecov/configuration/coverage.rb#99 + def store_minimum_per_file(criterion, percent, target); end + + # source://simplecov//lib/simplecov/configuration/coverage.rb#110 + def store_minimum_per_group(criterion, percent, group_name); end + + # write the same `@minimum_coverage` / `@maximum_coverage` / ... hashes the + # flat threshold methods populate, so the exit-code checks are unchanged. + # + # @api private — threshold-store writers used by CoverageCriterion. They + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#94 + def store_overall_threshold(setting, criterion, percent); end + + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/configuration/thresholds.rb#136 + def validate_per_file_key(key); end + + # source://simplecov//lib/simplecov/configuration/merging.rb#149 + def warn_about_inferred_finalize_merge; end +end + +# One-liner keyword options `coverage` accepts, each forwarding to the +# `CoverageCriterion` verb of the same name. `minimum_per_group` is omitted +# because it needs an `only:` target, so it's block-only. +# +# source://simplecov//lib/simplecov/configuration/coverage.rb#31 +SimpleCov::Configuration::COVERAGE_THRESHOLD_OPTIONS = T.let(T.unsafe(nil), Array) + +# Receiver for a `coverage <criterion> do ... end` block. Each verb writes a +# threshold for the single criterion the block configures, so the value is +# always a plain percentage (`minimum_per_file 100` is unambiguous) and the +# syntax is identical across line, branch, and method coverage. +# +# source://simplecov//lib/simplecov/configuration/coverage.rb#124 +class SimpleCov::Configuration::CoverageCriterion + # @return [CoverageCriterion] a new instance of CoverageCriterion + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#125 + def initialize(config, criterion); end + + # Pin coverage to an exact figure (sets both `minimum` and `maximum`). + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#142 + def exact(percent); end + + # Overall maximum: fails the build if coverage rises above it. Paired with + # `minimum` (or via `exact`) this pins coverage so an unexpected jump fails. + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#137 + def maximum(percent); end + + # Maximum allowed drop between runs (`maximum_drop 0` refuses any drop). + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#148 + def maximum_drop(percent); end + + # Overall (suite-wide) minimum for this criterion. + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#131 + def minimum(percent); end + + # Per-file minimum. With no `only:`, sets the default applied to every + # file; with `only:` (a String path or Regexp), overrides that default + # for the matching files. + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#155 + def minimum_per_file(percent, only: T.unsafe(nil)); end + + # Per-group minimum for the named group (defined via `group`). + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#160 + def minimum_per_group(percent, only:); end + + # Make this criterion the report's primary (leading) criterion. + # + # source://simplecov//lib/simplecov/configuration/coverage.rb#165 + def primary; end +end + +# source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#10 +SimpleCov::Configuration::DEFAULT_COVERAGE_CRITERION = T.let(T.unsafe(nil), Symbol) + +# Branch types accepted by `ignore_branches`. +# +# source://simplecov//lib/simplecov/configuration/ignored_entries.rb#9 +SimpleCov::Configuration::IGNORABLE_BRANCH_TYPES = T.let(T.unsafe(nil), Array) + +# Method types accepted by `ignore_methods`. +# +# source://simplecov//lib/simplecov/configuration/ignored_entries.rb#11 +SimpleCov::Configuration::IGNORABLE_METHOD_TYPES = T.let(T.unsafe(nil), Array) + +# source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#12 +SimpleCov::Configuration::LINE_COVERAGE_ALTERNATIVES = T.let(T.unsafe(nil), Hash) + +# source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#11 +SimpleCov::Configuration::ONESHOT_LINE_COVERAGE_CRITERION = T.let(T.unsafe(nil), Symbol) + +# source://simplecov//lib/simplecov/configuration/coverage_criteria.rb#9 +SimpleCov::Configuration::SUPPORTED_COVERAGE_CRITERIA = T.let(T.unsafe(nil), Array) + +# Raised when a user's configuration is internally inconsistent — e.g. +# every coverage criterion has been disabled. +# +# source://simplecov//lib/simplecov.rb#9 +class SimpleCov::ConfigurationError < ::StandardError; end + +# Shared parser for coverage.json consumers. It enforces the encoding and +# stable outermost shape while leaving command- or viewer-specific fields to +# their respective callers. +# +# source://simplecov//lib/simplecov/coverage_json.rb#9 +module SimpleCov::CoverageJSON + class << self + # source://simplecov//lib/simplecov/coverage_json.rb#12 + def load(path); end + end +end + +# source://simplecov//lib/simplecov/coverage_json.rb#10 +class SimpleCov::CoverageJSON::Error < ::StandardError; end + +# Holds the individual data of a coverage result. +# +# This is uniform across coverage criteria as they all have: +# +# * total - how many things to cover there are (total relevant loc/branches) +# * covered - how many of the coverables are hit +# * missed - how many of the coverables are missed +# * omitted - how many lines cannot be covered (blank lines/comments); only meaningful for line coverage +# * percent - percentage as covered/missed +# * strength - average hits per coverable unit (will not exist for one shot lines format) +# +# source://simplecov//lib/simplecov/coverage_statistics.rb#14 +class SimpleCov::CoverageStatistics + # Requires only covered, missed and strength to be initialized. + # + # Other values are computed by this class. + # + # @return [CoverageStatistics] a new instance of CoverageStatistics + # + # source://simplecov//lib/simplecov/coverage_statistics.rb#39 + def initialize(covered:, missed:, omitted: T.unsafe(nil), total_strength: T.unsafe(nil), percent: T.unsafe(nil)); end + + # Returns the value of attribute covered. + # + # source://simplecov//lib/simplecov/coverage_statistics.rb#15 + def covered; end + + # Returns the value of attribute missed. + # + # source://simplecov//lib/simplecov/coverage_statistics.rb#15 + def missed; end + + # Returns the value of attribute omitted. + # + # source://simplecov//lib/simplecov/coverage_statistics.rb#15 + def omitted; end + + # Returns the value of attribute percent. + # + # source://simplecov//lib/simplecov/coverage_statistics.rb#15 + def percent; end + + # Returns the value of attribute strength. + # + # source://simplecov//lib/simplecov/coverage_statistics.rb#15 + def strength; end + + # Returns the value of attribute total. + # + # source://simplecov//lib/simplecov/coverage_statistics.rb#15 + def total; end + + private + + # source://simplecov//lib/simplecov/coverage_statistics.rb#50 + def compute_percent(covered, missed, total); end + + # source://simplecov//lib/simplecov/coverage_statistics.rb#56 + def compute_strength(total_strength, total); end + + class << self + # source://simplecov//lib/simplecov/coverage_statistics.rb#20 + def from(coverage_statistics); end + end +end + +# Seed for the reduce in `.from`: covered, missed, omitted, strength. +# +# source://simplecov//lib/simplecov/coverage_statistics.rb#18 +SimpleCov::CoverageStatistics::ZERO_STATS = T.let(T.unsafe(nil), Array) + +# Computes coverage threshold violations for a given result. Shared by +# the exit-code checks and the JSON formatter's `errors` section. +# +# Each method returns an array of violation hashes. All percents are +# rounded via `SimpleCov.round_coverage` so downstream consumers don't +# need to round again. +# +# source://simplecov//lib/simplecov/coverage_violations.rb#10 +module SimpleCov::CoverageViolations + class << self + # @return [Array<Hash>] {:criterion, :maximum, :actual} where `actual` + # is the observed drop (in percentage points) vs. the last run. + # + # source://simplecov//lib/simplecov/coverage_violations.rb#56 + def maximum_drop(result, thresholds, last_run: T.unsafe(nil)); end + + # Tolerance: `percent_for` floors the actual percent to two decimal + # places (matching the existing minimum-coverage behavior), so an + # actual of e.g. 95.4287 is treated as 95.42 — meaning a maximum of + # 95.42 still passes. See issue #187 for the rationale. + # + # @return [Array<Hash>] {:criterion, :expected, :actual} + # + # source://simplecov//lib/simplecov/coverage_violations.rb#25 + def maximum_overall(result, thresholds); end + + # `defaults` is the criterion-keyed Hash applied to every file. + # `overrides` is an ordered Hash<pattern, criterion_thresholds> of per-path + # overrides; for each file, defaults are merged with every matching override + # (later wins per criterion, overrides win over defaults). + # + # @return [Array<Hash>] {:criterion, :expected, :actual, :filename, :project_filename} + # + # source://simplecov//lib/simplecov/coverage_violations.rb#38 + def minimum_by_file(result, defaults, overrides = T.unsafe(nil)); end + + # @return [Array<Hash>] {:group_name, :criterion, :expected, :actual} + # + # source://simplecov//lib/simplecov/coverage_violations.rb#46 + def minimum_by_group(result, thresholds); end + + # @return [Array<Hash>] {:criterion, :expected, :actual} + # + # source://simplecov//lib/simplecov/coverage_violations.rb#13 + def minimum_overall(result, thresholds); end + + private + + # source://simplecov//lib/simplecov/coverage_violations.rb#138 + def compute_drop(criterion, result, last_run); end + + # Walk the overrides in declaration order, merging each one that matches + # the file's project path into the running effective threshold (so the + # most-specific or latest-declared override wins per criterion). Returns + # the defaults Hash unchanged when nothing matches. + # + # source://simplecov//lib/simplecov/coverage_violations.rb#84 + def effective_per_file_thresholds(file, defaults, overrides); end + + # source://simplecov//lib/simplecov/coverage_violations.rb#105 + def file_minimum_violation(file, criterion, expected); end + + # source://simplecov//lib/simplecov/coverage_violations.rb#118 + def group_minimum_violations(group_name, group, minimums); end + + # The misconfiguration notice is enforcement output, not a Ruby + # warning: like every other enforcement message it must survive + # `-W0` and `Warning.warn` hooks (see ExitCodes.print_error) and + # honor the `print_errors` opt-out. + # + # source://simplecov//lib/simplecov/coverage_violations.rb#129 + def lookup_group(result, group_name); end + + # Per-path matching for `minimum_coverage_by_file` overrides. Strings + # ending in `/` are treated as directory prefixes; otherwise they must + # match `project_filename` exactly. Regexps are tested via `match?`. + # The configuration setter rejects anything other than String/Regexp, + # so no dead `else` branch is needed here. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/coverage_violations.rb#98 + def path_matches?(project_filename, pattern); end + + # Look up a criterion's percent on any coverage_statistics-bearing + # object (Result, SourceFile, FileList). Returns nil — and the + # caller silently skips — when the criterion was configured but not + # actually measured by the runtime (e.g. `minimum_coverage branch: + # 100` under the "strict" profile on JRuby, where the Coverage + # module doesn't emit branch data). The config-time + # `raise_if_criterion_disabled` check still catches the genuine + # "forgot to enable the criterion" mistake before we ever get here. + # + # source://simplecov//lib/simplecov/coverage_violations.rb#75 + def percent_for(stats_source, criterion); end + + # source://simplecov//lib/simplecov/coverage_violations.rb#154 + def round(percent); end + end +end + +# Emits legacy-API deprecation warnings, deduplicated by the source +# location that triggered them. A deprecated method called in a loop — +# or a config block re-evaluated once per parallel worker / spec file — +# otherwise repeats the same notice until stderr is unreadable. Keying +# on the caller location collapses those repeats to a single line while +# still warning separately about each distinct call site the user needs +# to fix. See issue #1204. +# +# source://simplecov//lib/simplecov/deprecation.rb#11 +module SimpleCov::Deprecation + private + + # Already-emitted dedup keys for this process. Parallel workers are + # separate processes with their own set, so each warns at most once. + # + # source://simplecov//lib/simplecov/deprecation.rb#36 + def emitted; end + + # @api private — reset emitted state between tests. + # + # source://simplecov//lib/simplecov/deprecation.rb#41 + def reset!; end + + # Warn about a deprecated API. `message` is the notice without the + # `[DEPRECATION]` tag or location prefix (both are added here). + # + # `location` defaults to the caller of the deprecated method that + # called us — every shipped call site is a one-level alias such as + # `track_files`, so the frame two up is the user code. Pass `location:` + # explicitly when the relevant site isn't that frame (e.g. a source + # file and line discovered while parsing). + # `Array(...)` coerces a missing backtrace (nil) to `[]` so `.first` + # yields nil rather than raising — and, unlike `&.`, adds no branch for + # the unreachable no-caller case to the project's 100% coverage target. + # + # source://simplecov//lib/simplecov/deprecation.rb#25 + def warn(message, location: T.unsafe(nil)); end + + class << self + # Already-emitted dedup keys for this process. Parallel workers are + # separate processes with their own set, so each warns at most once. + # + # source://simplecov//lib/simplecov/deprecation.rb#36 + def emitted; end + + # @api private — reset emitted state between tests. + # + # source://simplecov//lib/simplecov/deprecation.rb#41 + def reset!; end + + # Warn about a deprecated API. `message` is the notice without the + # `[DEPRECATION]` tag or location prefix (both are added here). + # + # `location` defaults to the caller of the deprecated method that + # called us — every shipped call site is a one-level alias such as + # `track_files`, so the frame two up is the user code. Pass `location:` + # explicitly when the relevant site isn't that frame (e.g. a source + # file and line discovered while parsing). + # `Array(...)` coerces a missing backtrace (nil) to `[]` so `.first` + # yields nil rather than raising — and, unlike `&.`, adds no branch for + # the unreachable no-caller case to the project's 100% coverage target. + # + # source://simplecov//lib/simplecov/deprecation.rb#25 + def warn(message, location: T.unsafe(nil)); end + end +end + +# Parses `# simplecov:disable` / `# simplecov:enable` directive comments. +# +# Two forms are supported: +# +# Block form (the directive is the entire comment on its own line) opens a +# region that runs until the matching `# simplecov:enable`: +# +# # simplecov:disable line +# ... +# # simplecov:enable line +# +# Inline form (the directive trails real code on the same line) only affects +# that single line and does not need to be re-enabled: +# +# raise "absurd" # simplecov:disable +# +# Categories are `:line`, `:branch`, and `:method`. They may be combined +# with commas. Omitting categories targets all three. +# +# Any text after the directive (and the optional category list) is treated +# as a free-form reason and discarded: +# +# # simplecov:disable line not worth testing this glue +# +# As a consequence, an unrecognised category name silently falls into the +# reason bucket. `# simplecov:disable cyclomatic` is parsed as the bare +# form (disable everything) with reason "cyclomatic" — a deliberate +# over-disable so the typo is visible in the report rather than silently +# disabling nothing. +# +# Comment extraction goes through `Ripper.lex` so directive markers inside +# string literals or heredocs are correctly ignored. +# +# source://simplecov//lib/simplecov/directive.rb#38 +class SimpleCov::Directive + extend ::SimpleCov::Sorbet::DirectiveExtension + + # @return [Directive] a new instance of Directive + # + # source://simplecov//lib/simplecov/directive.rb#137 + def initialize(line_number:, mode:, categories:, inline:); end + + # Apply this directive's effect to the in-flight per-category state. + # Inline directives mark just their line; block disables open a region; + # block enables close one. Re-opening an already-open block is a no-op. + # + # source://simplecov//lib/simplecov/directive.rb#155 + def apply(ranges, open_starts); end + + # Returns the value of attribute categories. + # + # source://simplecov//lib/simplecov/directive.rb#51 + def categories; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/directive.rb#144 + def disabled?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/directive.rb#148 + def inline?; end + + # Returns the value of attribute line_number. + # + # source://simplecov//lib/simplecov/directive.rb#51 + def line_number; end + + # Returns the value of attribute mode. + # + # source://simplecov//lib/simplecov/directive.rb#51 + def mode; end + + class << self + # Walk an array of source lines and return the disabled line ranges per + # category as `{ line: [Range, ...], branch: [...], method: [...] }`. + # An unclosed `disable` block extends to the end of the file. + # + # source://simplecov//lib/simplecov/directive.rb#56 + sig { params(src_lines: T::Array[::String]).returns(T::Hash[::Symbol, T::Array[T::Range[::Integer]]]) } + def disabled_ranges(src_lines); end + + private + + # source://simplecov//lib/simplecov/directive.rb#125 + def comments_in(lines); end + + # Extract every directive in the file, in source order. Comments inside + # string literals or heredocs are skipped because Ripper.lex doesn't tag + # them as :on_comment tokens. + # + # source://simplecov//lib/simplecov/directive.rb#73 + def directives_in(lines); end + + # Whether the directive sits after non-whitespace content on its line. + # `column` is the byte column of the directive's `#` in the source line, + # adjusted for any prefix that may precede it within the comment token + # (e.g., `# prefix # simplecov:disable line`). + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/directive.rb#118 + def inline?(lines, line_number, column); end + + # source://simplecov//lib/simplecov/directive.rb#108 + def parse_categories(text); end + + # source://simplecov//lib/simplecov/directive.rb#91 + def parse_comment(lines, line_number, column, text); end + + # Cheap pre-check so we don't tokenize files that obviously can't contain + # a directive. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/directive.rb#83 + def source_might_contain_directive?(lines); end + end +end + +# source://simplecov//lib/simplecov/directive.rb#39 +SimpleCov::Directive::CATEGORIES = T.let(T.unsafe(nil), Array) + +# source://simplecov//lib/simplecov/directive.rb#42 +SimpleCov::Directive::CATEGORIES_PATTERN = T.let(T.unsafe(nil), String) + +# source://simplecov//lib/simplecov/directive.rb#41 +SimpleCov::Directive::CATEGORY_PATTERN = T.let(T.unsafe(nil), String) + +# source://simplecov//lib/simplecov/directive.rb#43 +SimpleCov::Directive::PATTERN = T.let(T.unsafe(nil), Regexp) + +# Exit statuses SimpleCov sets when coverage checks fail, and the output +# helper the enforcement machinery reports through. +# +# source://simplecov//lib/simplecov/exit_codes.rb#6 +module SimpleCov::ExitCodes + class << self + # Threshold-violation reports and exit-status notices are the output of + # the enforcement feature, not Ruby warnings: routing them through + # `Kernel#warn` made `-W0` swallow the explanation for a failing exit + # code, let `Warning.warn` hooks (warning trackers, raise-on-warning + # test setups) intercept them mid-`at_exit`, and fed colorized text to + # warning logs. `print_errors false` remains the intended opt-out. + # + # source://simplecov//lib/simplecov/exit_codes.rb#19 + def print_error(message); end + end +end + +# Shared skeleton for the threshold checks: stash the result and the +# threshold configuration, fail when any violation exists, and +# report one message per violation. Subclasses supply `exit_code`, +# `compute_violations` (memoized here — `failing?` and `report` both +# consult it), and `report_violation`. +# +# source://simplecov//lib/simplecov/exit_codes/check.rb#10 +class SimpleCov::ExitCodes::Check + # @return [Check] a new instance of Check + # + # source://simplecov//lib/simplecov/exit_codes/check.rb#11 + def initialize(result, thresholds); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/exit_codes/check.rb#16 + def failing?; end + + # source://simplecov//lib/simplecov/exit_codes/check.rb#20 + def report; end + + private + + # Returns the value of attribute result. + # + # source://simplecov//lib/simplecov/exit_codes/check.rb#26 + def result; end + + # Returns the value of attribute thresholds. + # + # source://simplecov//lib/simplecov/exit_codes/check.rb#26 + def thresholds; end + + # source://simplecov//lib/simplecov/exit_codes/check.rb#28 + def violations; end +end + +# source://simplecov//lib/simplecov/exit_codes.rb#8 +SimpleCov::ExitCodes::EXCEPTION = T.let(T.unsafe(nil), Integer) + +# Runs every coverage check against the result and returns the exit +# code from the first failing one (or SUCCESS if all pass). +# +# source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#7 +module SimpleCov::ExitCodes::ExitCodeHandling + private + + # source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#10 + def call(result, coverage_limits:); end + + # source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#22 + def coverage_checks(result, coverage_limits); end + + class << self + # source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#10 + def call(result, coverage_limits:); end + + # source://simplecov//lib/simplecov/exit_codes/exit_code_handling.rb#22 + def coverage_checks(result, coverage_limits); end + end +end + +# source://simplecov//lib/simplecov/exit_codes.rb#11 +SimpleCov::ExitCodes::MAXIMUM_COVERAGE = T.let(T.unsafe(nil), Integer) + +# source://simplecov//lib/simplecov/exit_codes.rb#10 +SimpleCov::ExitCodes::MAXIMUM_COVERAGE_DROP = T.let(T.unsafe(nil), Integer) + +# source://simplecov//lib/simplecov/exit_codes.rb#9 +SimpleCov::ExitCodes::MINIMUM_COVERAGE = T.let(T.unsafe(nil), Integer) + +# Fails when any coverage criterion has dropped by more than the +# configured maximum since the last recorded run. +# +# source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#7 +class SimpleCov::ExitCodes::MaximumCoverageDropCheck < ::SimpleCov::ExitCodes::Check + # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#8 + def exit_code; end + + private + + # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#14 + def compute_violations; end + + # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#25 + def message_for(violation); end + + # The "drop percent" is a delta, not a coverage level — it has no + # natural green/yellow/red mapping, so the whole line goes red to + # keep the failure visible at a glance. + # + # source://simplecov//lib/simplecov/exit_codes/maximum_coverage_drop_check.rb#21 + def report_violation(violation); end +end + +# Fails when the overall (project-wide) coverage for any criterion is +# above the configured maximum. Pair with +# `SimpleCov::ExitCodes::MinimumOverallCoverageCheck` (or use +# `SimpleCov.expected_coverage`) to pin coverage to an exact value +# and surface unexpected increases instead of silently absorbing them. +# +# source://simplecov//lib/simplecov/exit_codes/maximum_overall_coverage_check.rb#10 +class SimpleCov::ExitCodes::MaximumOverallCoverageCheck < ::SimpleCov::ExitCodes::Check + # source://simplecov//lib/simplecov/exit_codes/maximum_overall_coverage_check.rb#11 + def exit_code; end + + private + + # source://simplecov//lib/simplecov/exit_codes/maximum_overall_coverage_check.rb#17 + def compute_violations; end + + # source://simplecov//lib/simplecov/exit_codes/maximum_overall_coverage_check.rb#21 + def report_violation(violation); end +end + +# Fails when any individual file falls below the configured minimum +# coverage for any criterion. +# +# source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#7 +class SimpleCov::ExitCodes::MinimumCoverageByFileCheck < ::SimpleCov::ExitCodes::Check + # @return [MinimumCoverageByFileCheck] a new instance of MinimumCoverageByFileCheck + # + # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#8 + def initialize(result, minimum_coverage_by_file, overrides = T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#13 + def exit_code; end + + private + + # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#19 + def compute_violations; end + + # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb#23 + def report_violation(violation); end +end + +# Fails when any configured group falls below its minimum coverage +# threshold for any criterion. +# +# source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb#7 +class SimpleCov::ExitCodes::MinimumCoverageByGroupCheck < ::SimpleCov::ExitCodes::Check + # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb#8 + def exit_code; end + + private + + # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb#14 + def compute_violations; end + + # source://simplecov//lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb#18 + def report_violation(violation); end +end + +# Fails when the overall (project-wide) coverage for any criterion is +# below the configured minimum. +# +# source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#7 +class SimpleCov::ExitCodes::MinimumOverallCoverageCheck < ::SimpleCov::ExitCodes::Check + # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#8 + def exit_code; end + + private + + # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#17 + def compute_violations; end + + # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#21 + def report_violation(violation); end + + # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#33 + def report_worst_files(criterion); end + + # source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#47 + def worst_files_for(criterion); end +end + +# source://simplecov//lib/simplecov/exit_codes/minimum_overall_coverage_check.rb#14 +SimpleCov::ExitCodes::MinimumOverallCoverageCheck::WORST_FILES_LIMIT = T.let(T.unsafe(nil), Integer) + +# source://simplecov//lib/simplecov/exit_codes.rb#7 +SimpleCov::ExitCodes::SUCCESS = T.let(T.unsafe(nil), Integer) + +# An array of SimpleCov SourceFile instances with additional collection helper +# methods for calculating coverage across them etc. +# +# source://simplecov//lib/simplecov/file_list.rb#6 +class SimpleCov::FileList + include ::Enumerable + extend ::Forwardable + + # @return [FileList] a new instance of FileList + # + # source://simplecov//lib/simplecov/file_list.rb#22 + def initialize(files); end + + # source://simplecov//lib/simplecov/file_list.rb#107 + def branch_covered_percent; end + + # source://simplecov//lib/simplecov/file_list.rb#10 + def count(*_arg0, **_arg1, &_arg2); end + + # The per-criterion coverage statistics across all files. With no argument + # returns the `{line:, branch:, method:}` Hash; pass a criterion symbol + # (`:line` / `:branch` / `:method`) to get that one CoverageStatistics. + # + # source://simplecov//lib/simplecov/file_list.rb#29 + def coverage_statistics(criterion = T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/file_list.rb#34 + def coverage_statistics_by_file; end + + # Return total count of covered branches + # + # source://simplecov//lib/simplecov/file_list.rb#98 + def covered_branches; end + + # Returns the count of lines that have coverage + # + # source://simplecov//lib/simplecov/file_list.rb#39 + def covered_lines; end + + # Return total count of covered methods + # + # source://simplecov//lib/simplecov/file_list.rb#117 + def covered_methods; end + + # The coverage across all files in percent, for the given criterion (line + # by default). Returns nil if the criterion was not measured. + # + # @return [Float, nil] + # + # source://simplecov//lib/simplecov/file_list.rb#81 + def covered_percent(criterion = T.unsafe(nil)); end + + # Computes the coverage based upon lines covered and lines missed for each file + # Returns an array with all coverage percentages + # + # source://simplecov//lib/simplecov/file_list.rb#60 + def covered_percentages; end + + # The strength (average hits per relevant unit) for the given criterion + # (line by default). + # + # @return [Float, nil] + # + # source://simplecov//lib/simplecov/file_list.rb#88 + def covered_strength(criterion = T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/file_list.rb#10 + def each(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/file_list.rb#10 + def empty?(*_arg0, **_arg1, &_arg2); end + + # Finds the least covered file and returns that file's name, or nil + # for an empty list (e.g. a fully filtered result). + # + # source://simplecov//lib/simplecov/file_list.rb#66 + def least_covered_file; end + + # source://simplecov//lib/simplecov/file_list.rb#10 + def length(*_arg0, **_arg1, &_arg2); end + + # Returns the overall amount of relevant lines of code across all files in this list + # + # source://simplecov//lib/simplecov/file_list.rb#74 + def lines_of_code; end + + # source://simplecov//lib/simplecov/file_list.rb#10 + def map(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/file_list.rb#126 + def method_covered_percent; end + + # Return total count of covered branches + # + # source://simplecov//lib/simplecov/file_list.rb#103 + def missed_branches; end + + # Returns the count of lines that have been missed + # + # source://simplecov//lib/simplecov/file_list.rb#44 + def missed_lines; end + + # Return total count of missed methods + # + # source://simplecov//lib/simplecov/file_list.rb#122 + def missed_methods; end + + # Returns the count of lines that are not relevant for coverage + # + # source://simplecov//lib/simplecov/file_list.rb#49 + def never_lines; end + + # source://simplecov//lib/simplecov/file_list.rb#10 + def size(*_arg0, **_arg1, &_arg2); end + + # Returns the count of skipped lines + # + # source://simplecov//lib/simplecov/file_list.rb#54 + def skipped_lines; end + + # source://simplecov//lib/simplecov/file_list.rb#10 + def to_a(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/file_list.rb#10 + def to_ary(*_arg0, **_arg1, &_arg2); end + + # Return total count of branches in all files + # + # source://simplecov//lib/simplecov/file_list.rb#93 + def total_branches; end + + # Return total count of methods in all files + # + # source://simplecov//lib/simplecov/file_list.rb#112 + def total_methods; end + + private + + # source://simplecov//lib/simplecov/file_list.rb#151 + def compute_coverage_statistics; end + + # Seed the result hash with one entry per criterion the user + # enabled — so an empty FileList (e.g. a group with no files) still + # yields the right shape — then fold each file's stats into the + # matching bucket. `SourceFile#coverage_statistics` always reports + # all three criteria; FileList is the layer that filters to the + # enabled set so disabled criteria don't surface in totals, JSON, + # or the HTML report. + # + # source://simplecov//lib/simplecov/file_list.rb#139 + def compute_coverage_statistics_by_file; end + + # `:line` (or its `:oneshot_line` synonym) is reported when either + # criterion is enabled; the JRuby-gated branch/method criteria are + # reported when they pass their own engine-support check. + # + # source://simplecov//lib/simplecov/file_list.rb#158 + def enabled_criteria_for_reporting; end +end + +# Base filter class. Inherit from this to create custom filters, +# and overwrite the matches?(source_file) instance method +# +# # A sample class that rejects all source files. +# class StupidFilter < SimpleCov::Filter +# def matches?(source_file) +# false +# end +# end +# +# source://simplecov//lib/simplecov/filter.rb#15 +class SimpleCov::Filter + # @return [Filter] a new instance of Filter + # + # source://simplecov//lib/simplecov/filter.rb#18 + def initialize(filter_argument); end + + # Returns the value of attribute filter_argument. + # + # source://simplecov//lib/simplecov/filter.rb#16 + def filter_argument; end + + # @raise [NotImplementedError] + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#22 + def matches?(_source_file); end + + # Whether this filter's verdict depends only on the file's path, so it can + # be decided before that file has any coverage. Used when recording which + # tracked files a process did not load, where no coverage exists yet. + # Defaults to false so a custom filter is never guessed at. See #1250. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#30 + def path_only?; end + + class << self + # `string_filter` selects the semantics of bare String arguments — + # StringFilter's segment-substring match for `add_filter`/`skip`, + # GlobFilter for `cover` — and threads through Array elements so a + # list gets the same treatment as its members. + # + # source://simplecov//lib/simplecov/filter.rb#38 + def build_filter(filter_argument, string_filter: T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/filter.rb#48 + def class_for_argument(filter_argument); end + + private + + # source://simplecov//lib/simplecov/filter.rb#53 + def filter_classes_by_argument_type; end + end +end + +# Namespace for SimpleCov result formatters. Built-in formatters live +# below this module; custom formatters should respond to `#format(result)` +# and can be wired up via `SimpleCov.formatter=`. +# TODO: Documentation on how to build your own formatters +# +# source://simplecov//lib/simplecov/formatter/multi_formatter.rb#4 +module SimpleCov::Formatter + class << self + # Normalize a class or instance, then dispatch the result to it. + # + # source://simplecov//lib/simplecov/formatter.rb#18 + def format(formatter, result); end + + # Formatters can be configured either as classes (instantiated + # fresh for every report) or as ready-built instances — the only + # way to reach constructor options like + # `HTMLFormatter.new(silent: true)`. See #1240. + # + # source://simplecov//lib/simplecov/formatter.rb#13 + def instance_for(formatter); end + end +end + +# Shared scaffolding for formatters that write a coverage report to +# an output directory and emit a "Coverage report generated for X +# to Y" summary on stderr (it's a status message, not data). +# Subclasses override `format` to do their actual writing, and may +# override `message_prefix` (e.g. JSON prepends "JSON "). +# +# @api private +# +# source://simplecov//lib/simplecov/formatter/base.rb#14 +class SimpleCov::Formatter::Base + # `output_dir` defaults to `SimpleCov.coverage_path` so the at_exit + # pipeline keeps working unchanged. Pass it explicitly to write + # somewhere else (handy for tests that don't want to clobber the + # project's `coverage/` directory). + # + # @api private + # @return [Base] a new instance of Base + # + # source://simplecov//lib/simplecov/formatter/base.rb#19 + def initialize(silent: T.unsafe(nil), output_dir: T.unsafe(nil)); end + + private + + # The path shown in the "Coverage report generated for X to Y" + # status line. Renders relative to cwd when `output_path` lives + # inside cwd (e.g. `coverage` instead of `/Users/me/proj/coverage`) + # and appends the formatter's `entry_point_filename` so the line + # points at a concrete file the user (or a terminal that + # hyperlinks paths) can act on — e.g. `coverage/index.html` + # instead of the bare directory `coverage`. Paths outside cwd + # stay absolute; a `../../../tmp/cov` display would be more + # confusing than the absolute form. See issue #197. + # + # @api private + # + # source://simplecov//lib/simplecov/formatter/base.rb#62 + def displayable_output_path; end + + # The one home of the "status lines go to stderr, not through warn" + # decision (see #1225). stderr rather than stdout because this is a + # status message, not the program's output, so it stays out of + # pipelines like `rspec -f json`. And `$stderr.puts` rather than + # `warn` so the line neither reaches `Warning.warn` hooks (warning + # trackers, raise-on-warning test setups) nor vanishes under `-W0`. + # Subclasses call this at the end of their `format`. + # + # @api private + # + # source://simplecov//lib/simplecov/formatter/base.rb#33 + def emit_status(result); end + + # Subclasses override to name the report's entry-point file + # (e.g. `index.html` for HTML, `coverage.json` for JSON), which + # gets appended to the directory in the status line. Default nil + # leaves the bare directory in place for any third-party formatter + # that has no single canonical entry point. + # + # @api private + # + # source://simplecov//lib/simplecov/formatter/base.rb#84 + def entry_point_filename; end + + # Subclasses override to prepend a marker (e.g. "JSON ") to the + # summary line. Default empty for the HTML formatter, which has + # historically been the unmarked default. + # + # @api private + # + # source://simplecov//lib/simplecov/formatter/base.rb#45 + def message_prefix; end + + # Emit one summary line per criterion that the run actually + # measured. The header line ("Coverage report generated for X + # to Y") is always first; per-criterion lines follow in the + # order of `result.coverage_statistics` (which is the same + # insertion order as `SourceFile#coverage_statistics`, which in + # turn reflects what the user enabled). + # + # @api private + # + # source://simplecov//lib/simplecov/formatter/base.rb#94 + def output_message(result); end + + # @api private + # + # source://simplecov//lib/simplecov/formatter/base.rb#49 + def output_path; end + + # @api private + # + # source://simplecov//lib/simplecov/formatter/base.rb#68 + def relative_or_absolute_output_path; end + + # Returns nil for branch/method criteria that have nothing to + # measure (e.g. a file with no branches under branch coverage). + # Showing "Branch coverage: 0 / 0 (100.00%)" is noise; the older + # output specifically suppressed it. + # + # @api private + # + # source://simplecov//lib/simplecov/formatter/base.rb#104 + def stats_line(criterion, stat); end +end + +# Shared writer for the coverage.json artifact, used by JSONFormatter +# (as its report) and HTMLFormatter (as the side file feeding +# `simplecov serve` and external tools). Centralizing the write keeps +# the two copies byte-identical (same pretty printing, same binary +# mode, so no CRLF translation on Windows depending on which +# formatter wrote last) and gives both formatters the +# concurrent-overwrite warning of issue #1171. +# +# source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#16 +module SimpleCov::Formatter::CoverageJSONWriter + private + + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#57 + def existing_meta(path); end + + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#69 + def parse_meta(path); end + + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#89 + def parse_meta_full(path); end + + # The meta object is flat and sits at the head of every file this + # module writes, so the common case parses just that slice instead + # of a multi-megabyte report. A miss (foreign key order, a brace + # inside a meta string) falls back to the full parse. + # + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#77 + def parse_meta_head(path); end + + # Warns when the existing coverage.json has a timestamp newer than this + # process's start time — a strong signal that a sibling test process + # (e.g., parallel_tests) wrote it while we were running, and that our + # write is about to clobber their data. + # + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#38 + def warn_if_concurrent_overwrite(path, result); end + + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#27 + def write(output_path, hash, result); end + + class << self + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#57 + def existing_meta(path); end + + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#69 + def parse_meta(path); end + + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#89 + def parse_meta_full(path); end + + # The meta object is flat and sits at the head of every file this + # module writes, so the common case parses just that slice instead + # of a multi-megabyte report. A miss (foreign key order, a brace + # inside a meta string) falls back to the full parse. + # + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#77 + def parse_meta_head(path); end + + # Warns when the existing coverage.json has a timestamp newer than this + # process's start time — a strong signal that a sibling test process + # (e.g., parallel_tests) wrote it while we were running, and that our + # write is about to clobber their data. + # + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#38 + def warn_if_concurrent_overwrite(path, result); end + + # source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#27 + def write(output_path, hash, result); end + end +end + +# source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#17 +SimpleCov::Formatter::CoverageJSONWriter::FILENAME = T.let(T.unsafe(nil), String) + +# The previous report can embed the project's entire source text, +# so the overwrite check bounds how much of it is read looking for +# the meta object before falling back to a full parse. +# +# source://simplecov//lib/simplecov/formatter/coverage_json_writer.rb#22 +SimpleCov::Formatter::CoverageJSONWriter::META_SCAN_BYTES = T.let(T.unsafe(nil), Integer) + +# Generates a single self-contained HTML coverage report. The compiled +# template (public/index.html) already carries the viewer app's JS and +# CSS inline; format substitutes the coverage JSON at the data marker +# and writes one index.html, so the report can be mailed, uploaded as +# a single CI artifact, or opened from anywhere without sibling files. +# A coverage.json is written alongside from the same in-memory hash. +# +# source://simplecov//lib/simplecov/formatter/html_formatter.rb#18 +class SimpleCov::Formatter::HTMLFormatter < ::SimpleCov::Formatter::Base + # source://simplecov//lib/simplecov/formatter/html_formatter.rb#31 + def format(result); end + + # Generate HTML from a pre-existing coverage.json file without + # needing a live SimpleCov::Result or even a running test suite. + # The round-trip through parse/generate compacts the (typically + # pretty-printed) input and rejects invalid JSON here rather than + # embedding it and failing at view time. + # + # source://simplecov//lib/simplecov/formatter/html_formatter.rb#52 + def format_from_json(json_path, output_dir); end + + private + + # source://simplecov//lib/simplecov/formatter/html_formatter.rb#60 + def entry_point_filename; end + + # source://simplecov//lib/simplecov/formatter/html_formatter.rb#96 + def public_dir; end + + # Substitute the coverage JSON into the compiled template. `<` is + # escaped as \u003c — valid JSON, since `<` can only occur inside + # strings — so embedded source text containing "</script>" or + # "<!--" cannot terminate the surrounding <script> element. Block + # forms keep gsub/sub from interpreting backslashes in the JSON as + # replacement-string back-references. + # + # source://simplecov//lib/simplecov/formatter/html_formatter.rb#81 + def render_report(json); end + + # source://simplecov//lib/simplecov/formatter/html_formatter.rb#64 + def source_less_hash(hash); end + + # source://simplecov//lib/simplecov/formatter/html_formatter.rb#69 + def write_report_files(json_hash, viewer_hash, result); end +end + +# Placeholder in the compiled template where the report's data goes. +# +# source://simplecov//lib/simplecov/formatter/html_formatter.rb#20 +SimpleCov::Formatter::HTMLFormatter::DATA_MARKER = T.let(T.unsafe(nil), String) + +# The sibling files a 1.0.0 through 1.0.3 report wrote next to +# index.html. They are stale the moment a single-file report lands +# on top of them — `simplecov serve` would happily keep serving the +# old coverage_data.js — so formatting removes the known names. +# +# source://simplecov//lib/simplecov/formatter/html_formatter.rb#26 +SimpleCov::Formatter::HTMLFormatter::LEGACY_REPORT_FILES = T.let(T.unsafe(nil), Array) + +# Validates the subset of coverage.json that the browser viewer +# dereferences without defensive fallbacks. +# +# source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#10 +module SimpleCov::Formatter::HTMLFormatter::ViewerDataValidator + class << self + # source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#21 + def call(data); end + + private + + # @raise [SimpleCov::CoverageJSON::Error] + # + # source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#87 + def validate_boolean!(meta, key); end + + # @raise [SimpleCov::CoverageJSON::Error] + # + # source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#39 + def validate_file!(filename, file); end + + # @raise [SimpleCov::CoverageJSON::Error] + # + # source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#69 + def validate_group!(name, group, meta); end + + # source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#52 + def validate_meta!(meta); end + + # @raise [SimpleCov::CoverageJSON::Error] + # + # source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#33 + def validate_section!(data, key); end + + # source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#60 + def validate_statistics!(statistics, meta, location); end + + # @raise [SimpleCov::CoverageJSON::Error] + # + # source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#80 + def validate_type!(object, key, type, location); end + end +end + +# source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#12 +SimpleCov::Formatter::HTMLFormatter::ViewerDataValidator::COVERAGE_FLAGS = T.let(T.unsafe(nil), Hash) + +# source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#11 +SimpleCov::Formatter::HTMLFormatter::ViewerDataValidator::META_STRINGS = T.let(T.unsafe(nil), Array) + +# source://simplecov//lib/simplecov/formatter/html_formatter/viewer_data_validator.rb#17 +SimpleCov::Formatter::HTMLFormatter::ViewerDataValidator::STAT_FIELDS = T.let(T.unsafe(nil), Array) + +# Writes coverage results as JSON to coverage/coverage.json. Used +# standalone, alongside the HTML formatter, or by external tools that +# consume SimpleCov output. +# +# source://simplecov//lib/simplecov/formatter/json_formatter.rb#12 +class SimpleCov::Formatter::JSONFormatter < ::SimpleCov::Formatter::Base + # source://simplecov//lib/simplecov/formatter/json_formatter.rb#24 + def format(result); end + + private + + # source://simplecov//lib/simplecov/formatter/json_formatter.rb#35 + def entry_point_filename; end + + # source://simplecov//lib/simplecov/formatter/json_formatter.rb#31 + def message_prefix; end + + class << self + # `include_source:` defaults to `SimpleCov.source_in_json` (true + # by default) so the historical payload shape is unchanged. + # Callers that need the source array regardless of the global + # setting (the HTML formatter, which feeds the client-side + # viewer) pass `include_source: true` explicitly. + # + # source://simplecov//lib/simplecov/formatter/json_formatter.rb#20 + def build_hash(result, include_source: T.unsafe(nil)); end + end +end + +# Translates the threshold violations reported by +# `SimpleCov::CoverageViolations` into the `:errors` section of +# coverage.json. Each violation is keyed by criterion +# (`:lines` / `:branches` / `:methods`) so consumers can render +# per-criterion messages without re-deriving them. +# +# source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#11 +class SimpleCov::Formatter::JSONFormatter::ErrorsFormatter + class << self + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#16 + def call(result); end + + private + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#73 + def bucket(errors, name); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#81 + def expected_actual(violation); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#66 + def format_maximum_drop(result, errors); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#60 + def format_maximum_overall(result, errors); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#34 + def format_minimum_by_file(result, errors); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#47 + def format_minimum_by_group(result, errors); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#28 + def format_minimum_overall(result, errors); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#77 + def key_for(violation); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#41 + def record_by_file(violation, errors); end + end +end + +# source://simplecov//lib/simplecov/formatter/json_formatter/errors_formatter.rb#12 +SimpleCov::Formatter::JSONFormatter::ErrorsFormatter::CRITERION_KEYS = T.let(T.unsafe(nil), Hash) + +# source://simplecov//lib/simplecov/formatter/json_formatter.rb#13 +SimpleCov::Formatter::JSONFormatter::FILENAME = T.let(T.unsafe(nil), String) + +# Builds the hash that JSONFormatter serializes to coverage.json: +# meta, per-file coverage data, group totals, and aggregate stats. +# +# source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#13 +class SimpleCov::Formatter::JSONFormatter::ResultHashFormatter + class << self + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#26 + def format(result, include_source: T.unsafe(nil)); end + + private + + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#78 + def coverage_flags; end + + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#86 + def format_coverage_statistics(statistics); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#39 + def format_files(result, include_source:); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#45 + def format_groups(result); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#94 + def format_line_statistic(stat); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#52 + def format_meta(result); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#105 + def format_single_statistic(stat); end + + # Full git commit SHA of `SimpleCov.root`'s HEAD, or nil when the + # project isn't a git checkout or git isn't on PATH. Recorded so tools + # can recover the exact source a report was generated against, which + # matters most when `source_in_json false` drops the source text from + # coverage.json. stderr is captured (not forwarded) so a non-git project + # doesn't print git's diagnostics to the build. + # + # source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#71 + def git_commit; end + end +end + +# source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#22 +SimpleCov::Formatter::JSONFormatter::ResultHashFormatter::SCHEMA_URL = T.let(T.unsafe(nil), String) + +# Bump SCHEMA_VERSION (and SCHEMA_URL) when the JSON shape +# changes. Additive changes bump the minor segment, removals or +# shape changes bump the major segment. The versioned file at +# schemas/coverage-vX.Y.schema.json is the canonical artifact +# consumers should pin to, schemas/coverage.schema.json is a +# convenience alias that always tracks the latest. See the +# `coverage.json` schema section of the README for the rationale. +# +# source://simplecov//lib/simplecov/formatter/json_formatter/result_hash_formatter.rb#21 +SimpleCov::Formatter::JSONFormatter::ResultHashFormatter::SCHEMA_VERSION = T.let(T.unsafe(nil), String) + +# Renders a single `SimpleCov::SourceFile` as the per-file payload +# in coverage.json: source code plus per-enabled-criterion arrays +# and totals. +# +# source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#9 +class SimpleCov::Formatter::JSONFormatter::SourceFileFormatter + class << self + # source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#11 + def call(source_file, include_source: T.unsafe(nil)); end + + private + + # source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#43 + def branch_coverage_section(source_file); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#70 + def format_branch(branch); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#66 + def format_line(line); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#81 + def format_method(method); end + + # No per-line encoding conversion here: SourceLoader guarantees + # every line leaves it as valid UTF-8 (transcoding declared + # encodings, scrubbing invalid bytes), and the converter copy + # this used to make per line was the single largest allocation + # source in formatting a large report. + # + # source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#26 + def format_source_code(source_file); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#30 + def line_coverage_section(source_file); end + + # source://simplecov//lib/simplecov/formatter/json_formatter/source_file_formatter.rb#53 + def method_coverage_section(source_file); end + end +end + +# Wraps multiple formatters so SimpleCov.formatter can drive several +# output formats (HTML + JSON, etc.) in a single run. +# +# source://simplecov//lib/simplecov/formatter/multi_formatter.rb#7 +class SimpleCov::Formatter::MultiFormatter + class << self + # source://simplecov//lib/simplecov/formatter/multi_formatter.rb#21 + def new(formatters = T.unsafe(nil)); end + end +end + +# Shared `#format` implementation; included into individual +# MultiFormatter subclasses built by `MultiFormatter.new`. +# +# source://simplecov//lib/simplecov/formatter/multi_formatter.rb#10 +module SimpleCov::Formatter::MultiFormatter::InstanceMethods + # source://simplecov//lib/simplecov/formatter/multi_formatter.rb#11 + def format(result); end +end + +# A ridiculously simple formatter for SimpleCov results. +# +# source://simplecov//lib/simplecov/formatter/simple_formatter.rb#8 +class SimpleCov::Formatter::SimpleFormatter + # Takes a SimpleCov::Result and generates a string out of it + # + # source://simplecov//lib/simplecov/formatter/simple_formatter.rb#10 + def format(result); end + + private + + # source://simplecov//lib/simplecov/formatter/simple_formatter.rb#17 + def format_group(name, files, criterion); end +end + +# Filter that matches when the source file's project path matches the +# configured shell glob (e.g. "lib/**/*.rb"). Used by `cover` and +# `skip` when callers want glob semantics instead of the substring +# match of `StringFilter`. +# +# source://simplecov//lib/simplecov/filter.rb#139 +class SimpleCov::GlobFilter < ::SimpleCov::Filter + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#140 + def matches?(source_file); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#144 + def path_only?; end +end + +# Semantic names owned by result processing rather than user configuration. +# +# source://simplecov//lib/simplecov/group_names.rb#5 +module SimpleCov::GroupNames + private + + # Group names are Hash keys, report labels, and JSON object keys all at + # once, so they are normalized to Strings up front: a Symbol spelling + # (`group :Controllers`) means the String, and anything else has no + # sensible serialized form. Normalizing before `validate!` also keeps + # `group :Ungrouped` from slipping past the reservation below. + # + # source://simplecov//lib/simplecov/group_names.rb#15 + def normalize(group_name); end + + # source://simplecov//lib/simplecov/group_names.rb#25 + def validate!(group_names); end + + class << self + # Group names are Hash keys, report labels, and JSON object keys all at + # once, so they are normalized to Strings up front: a Symbol spelling + # (`group :Controllers`) means the String, and anything else has no + # sensible serialized form. Normalizing before `validate!` also keeps + # `group :Ungrouped` from slipping past the reservation below. + # + # source://simplecov//lib/simplecov/group_names.rb#15 + def normalize(group_name); end + + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/group_names.rb#25 + def validate!(group_names); end + end +end + +# source://simplecov//lib/simplecov/group_names.rb#6 +SimpleCov::GroupNames::UNGROUPED = T.let(T.unsafe(nil), String) + +# Reads and writes coverage/.last_run.json — the previous run's coverage +# percentages used by MaximumCoverageDropCheck. +# +# source://simplecov//lib/simplecov/last_run.rb#9 +module SimpleCov::LastRun + class << self + # source://simplecov//lib/simplecov/last_run.rb#11 + def last_run_path; end + + # source://simplecov//lib/simplecov/last_run.rb#15 + def read; end + + # source://simplecov//lib/simplecov/last_run.rb#30 + def write(json); end + + private + + # source://simplecov//lib/simplecov/last_run.rb#36 + def invalid_last_run; end + end +end + +# Classifies whether lines are relevant for code coverage analysis. +# Comments & whitespace lines, and :nocov: token blocks, are considered not relevant. +# +# source://simplecov//lib/simplecov/lines_classifier.rb#8 +class SimpleCov::LinesClassifier + # source://simplecov//lib/simplecov/lines_classifier.rb#44 + def classify(lines); end + + private + + # A `:nocov:` marker is itself a comment, so the cheap + # whitespace-or-comment test can gate the token match: a line of real + # code cannot be a marker, and no longer pays to be checked against + # one. That matters because this runs per line of every + # tracked-but-unloaded file, once in every process of a parallel run. + # + # source://simplecov//lib/simplecov/lines_classifier.rb#64 + def classify_line(line, line_number, directive_disabled, skip_state); end + + # source://simplecov//lib/simplecov/lines_classifier.rb#75 + def directive_disabled_line_set(lines); end + + class << self + # The leading `^(\s*)#` anchor is load-bearing beyond matching the marker: + # `classify_line` only reaches this for lines that already passed + # `whitespace_line?`, which is sound only while every marker is also a + # comment. Loosening this to match a trailing `x = 1 # :nocov:` would stop + # the toggle firing. `lines_classifier_spec.rb` pins the implication. + # + # The `/o` flag freezes the interpolated nocov token at this + # process's first classification; a `nocov_token` configured after + # coverage has classified a file would be silently ignored. Sound + # today because configuration always precedes classification. + # + # source://simplecov//lib/simplecov/lines_classifier.rb#26 + def no_cov_line; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/lines_classifier.rb#30 + def no_cov_line?(line); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/lines_classifier.rb#37 + def whitespace_line?(line); end + end +end + +# source://simplecov//lib/simplecov/lines_classifier.rb#13 +SimpleCov::LinesClassifier::COMMENT_LINE = T.let(T.unsafe(nil), Regexp) + +# source://simplecov//lib/simplecov/lines_classifier.rb#10 +SimpleCov::LinesClassifier::NOT_RELEVANT = T.let(T.unsafe(nil), T.untyped) + +# source://simplecov//lib/simplecov/lines_classifier.rb#9 +SimpleCov::LinesClassifier::RELEVANT = T.let(T.unsafe(nil), Integer) + +# source://simplecov//lib/simplecov/lines_classifier.rb#12 +SimpleCov::LinesClassifier::WHITESPACE_LINE = T.let(T.unsafe(nil), Regexp) + +# source://simplecov//lib/simplecov/lines_classifier.rb#14 +SimpleCov::LinesClassifier::WHITESPACE_OR_COMMENT_LINE = T.let(T.unsafe(nil), Regexp) + +# Registry + selection for parallel-test-runner adapters. An adapter +# answers a small fixed set of questions on SimpleCov's behalf: +# +# - `active?` — are WE the runner in charge for this process? +# - `first_worker?` — should this process do the final-result work? +# - `wait_for_siblings` — block until siblings finish (optional) +# - `expected_worker_count` — how many workers total +# +# `SimpleCov::ParallelAdapters::Base` provides safe no-op defaults; two +# adapters ship out of the box: +# +# - `ParallelTestsAdapter` — wraps the grosser/parallel_tests gem +# (precise sync + first-process detection via the gem's own API). +# - `GenericAdapter` — env-var-only detection for runners that follow +# the parallel_tests `TEST_ENV_NUMBER` convention but don't ship a +# Ruby API (parallel_rspec, rspec-conductor 1.0.7+, custom CI +# sharding, knapsack-style splitters). See +# https://github.com/simplecov-ruby/simplecov/issues/1065 and +# https://github.com/simplecov-ruby/simplecov/issues/1156. +# +# Users can plug in additional adapters: +# +# SimpleCov::ParallelAdapters.register MyRunnerAdapter +# +# An adapter just needs to be a class responding to the four methods +# above. Subclass `SimpleCov::ParallelAdapters::Base` to inherit the +# no-op defaults and override only what you need (the contract methods +# are defined as class methods, so plain inheritance is what carries +# them through; `extend Base` won't pick them up). +# +# source://simplecov//lib/simplecov/parallel_adapters/base.rb#4 +module SimpleCov::ParallelAdapters + private + + # Adapters in selection order. ParallelTestsAdapter first (most + # specific — uses the gem's own API when the gem is loaded); then + # GenericAdapter as the env-var fallback. User-registered adapters + # are prepended (#register puts new entries at the front) so + # downstream code can override the built-ins by registering a more + # specific match. + # + # source://simplecov//lib/simplecov/parallel_adapters.rb#46 + def adapters; end + + # The adapter SimpleCov should consult for this process — the first + # registered adapter whose `active?` returns true. Returns nil when + # no adapter is active (i.e., we're not running under any recognized + # parallel test runner), in which case the caller should treat the + # process as single-worker. + # + # source://simplecov//lib/simplecov/parallel_adapters.rb#73 + def current; end + + # Register a custom adapter. Newly registered adapters are inserted + # at the front of the selection list so a custom adapter for a + # specific runner takes precedence over the built-in ParallelTests + # and Generic adapters. + # + # class MyRunnerAdapter < SimpleCov::ParallelAdapters::Base + # def self.active? = ENV["MY_RUNNER_PID"] + # def self.first_worker? = ENV["MY_RUNNER_PID"].to_i == 1 + # def self.expected_worker_count = ENV["MY_RUNNER_WORKERS"].to_i + # end + # + # SimpleCov::ParallelAdapters.register MyRunnerAdapter + # + # source://simplecov//lib/simplecov/parallel_adapters.rb#62 + def register(adapter); end + + # Clear the memoized `current` selection. Primarily for tests that + # mutate env vars between examples; production runs are single-shot. + # + # source://simplecov//lib/simplecov/parallel_adapters.rb#81 + def reset_current!; end + + class << self + # Adapters in selection order. ParallelTestsAdapter first (most + # specific — uses the gem's own API when the gem is loaded); then + # GenericAdapter as the env-var fallback. User-registered adapters + # are prepended (#register puts new entries at the front) so + # downstream code can override the built-ins by registering a more + # specific match. + # + # source://simplecov//lib/simplecov/parallel_adapters.rb#46 + def adapters; end + + # The adapter SimpleCov should consult for this process — the first + # registered adapter whose `active?` returns true. Returns nil when + # no adapter is active (i.e., we're not running under any recognized + # parallel test runner), in which case the caller should treat the + # process as single-worker. + # + # source://simplecov//lib/simplecov/parallel_adapters.rb#73 + def current; end + + # Register a custom adapter. Newly registered adapters are inserted + # at the front of the selection list so a custom adapter for a + # specific runner takes precedence over the built-in ParallelTests + # and Generic adapters. + # + # class MyRunnerAdapter < SimpleCov::ParallelAdapters::Base + # def self.active? = ENV["MY_RUNNER_PID"] + # def self.first_worker? = ENV["MY_RUNNER_PID"].to_i == 1 + # def self.expected_worker_count = ENV["MY_RUNNER_WORKERS"].to_i + # end + # + # SimpleCov::ParallelAdapters.register MyRunnerAdapter + # + # source://simplecov//lib/simplecov/parallel_adapters.rb#62 + def register(adapter); end + + # Clear the memoized `current` selection. Primarily for tests that + # mutate env vars between examples; production runs are single-shot. + # + # source://simplecov//lib/simplecov/parallel_adapters.rb#81 + def reset_current!; end + end +end + +# Default no-op implementations for a parallel-test-runner adapter. +# Real adapters subclass and override what they need; everything else +# falls back to "behave like a single-process run." +# +# Adapters are classes (used as singletons, never instantiated) — they +# answer a small fixed set of questions about whether THIS worker +# process is the one that should do final-result work, and provide an +# optional hook for waiting on sibling workers. +# +# @see SimpleCov::ParallelAdapters for the registry and selection. +# +# source://simplecov//lib/simplecov/parallel_adapters/base.rb#15 +class SimpleCov::ParallelAdapters::Base + class << self + # Should this adapter be selected for the current process? Adapters + # are tried in registration order; the first one whose `active?` + # returns true is chosen. Inactive adapters return `false`. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/base.rb#20 + def active?; end + + # How many parallel workers are participating in this run. Used + # by the polling fallback to know how many resultset entries to + # expect. Defaults to 1 (single-process). + # + # source://simplecov//lib/simplecov/parallel_adapters/base.rb#55 + def expected_worker_count; end + + # Among the parallel workers in this run, should THIS worker do + # the final-result work (wait for siblings, merge resultsets, + # run threshold checks, format the report)? Default is `true` + # for the single-process case. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/base.rb#28 + def first_worker?; end + + # The user's explicit `SimpleCov.parallel_tests false` opt-out, + # which every adapter's `active?` honors before its own + # detection. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/base.rb#62 + def forced_off?; end + + # Does `wait_for_siblings` block until every sibling PROCESS has + # exited (so no further resultset can appear)? When true, the + # reporting worker can accept a settled resultset count below + # `expected_worker_count` as final instead of waiting out the whole + # `parallel_wait_timeout` for workers that produced no coverage. + # Defaults to false (no native wait; the poll is the only signal). + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/base.rb#48 + def native_wait?; end + + # Shared implementation of the PARALLEL_TEST_GROUPS convention + # both built-in adapters follow for `expected_worker_count`. + # Unset, empty, non-numeric, and non-positive values all mean 1: + # an unparseable value must not yield 0 workers, which would end + # the sibling wait before it started. + # + # source://simplecov//lib/simplecov/parallel_adapters/base.rb#71 + def parallel_test_groups_count; end + + # Optional: block until sibling workers have finished writing + # their resultsets. An adapter that wraps a parallel-test runner + # with a native synchronization primitive (e.g., `parallel_tests`'s + # `wait_for_other_processes_to_finish`) implements this for + # lower latency; otherwise SimpleCov polls the resultset cache + # as a fallback (see `SimpleCov.wait_for_parallel_results`). + # + # source://simplecov//lib/simplecov/parallel_adapters/base.rb#38 + def wait_for_siblings; end + end +end + +# Catch-all adapter for parallel test runners that follow the +# `TEST_ENV_NUMBER` / `PARALLEL_TEST_GROUPS` env-var convention but +# don't ship a Ruby API for SimpleCov to hook (parallel_rspec, +# rspec-conductor since 1.0.7, knapsack-style splitters, custom CI +# sharding scripts). Activates when `TEST_ENV_NUMBER` is set; +# doesn't require any specific gem to be loaded. +# +# Heuristic for `first_worker?`: the worker whose `TEST_ENV_NUMBER` +# is `""` (parallel_tests/parallel_rspec convention, rspec-conductor's +# default) or `"1"` (runners that number from 1, like rspec-conductor +# with `--first-is-1`). Any other value is treated as a non-first +# worker. +# +# `wait_for_siblings` is inherited from Base as a no-op — without a +# runner-provided API the only synchronization available is polling +# the resultset cache, which `SimpleCov.wait_for_parallel_results` +# does after the no-op returns. +# +# source://simplecov//lib/simplecov/parallel_adapters/generic.rb#24 +class SimpleCov::ParallelAdapters::GenericAdapter < ::SimpleCov::ParallelAdapters::Base + class << self + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/generic.rb#26 + def active?; end + + # source://simplecov//lib/simplecov/parallel_adapters/generic.rb#39 + def expected_worker_count; end + + # parallel_tests sets the first worker's TEST_ENV_NUMBER to ""; + # parallel_rspec inherits that. Runners that number from 1 use + # "1" for the first worker. Both shapes match. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/generic.rb#35 + def first_worker?; end + end +end + +# Adapter for [grosser/parallel_tests](https://github.com/grosser/parallel_tests). +# This is the historical default — SimpleCov has special-cased +# parallel_tests since 0.18 — and remains the most precise option for +# projects on it. Detection requires the full native coordination +# contract: the `ParallelTests` constant has been loaded, +# `TEST_ENV_NUMBER` is set, and `PARALLEL_PID_FILE` is set. The pid-file +# path is required because the native wait API reads it with `ENV.fetch`. +# When a runner only provides the env-var convention, GenericAdapter is +# the correct coordination path. +# +# source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#16 +class SimpleCov::ParallelAdapters::ParallelTestsAdapter < ::SimpleCov::ParallelAdapters::Base + class << self + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#18 + def active?; end + + # Auto-require `parallel_tests` when it's installed AND the env + # vars it sets are present, so callers can rely on + # `defined?(::ParallelTests)` downstream. parallel_tests is an + # optional dependency (see https://github.com/grosser/parallel_tests/issues/772), + # and `TEST_ENV_NUMBER` / `PARALLEL_TEST_GROUPS` are commonly set + # for other reasons (custom subprocess coordination, CI sharding, + # the parallel_rspec gem which intentionally mirrors the env-var + # convention), so a missing gem is treated as "user isn't using + # parallel_tests" — silently skip and let GenericAdapter handle + # it. Users who want to override the auto-detect can set + # `SimpleCov.parallel_tests true` (force on) or `false` (force + # off). See #1018. + # + # source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#68 + def ensure_loaded; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#83 + def env_suggests_parallel_tests?; end + + # source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#52 + def expected_worker_count; end + + # Pick the *first* started process to do the final-result work, + # not the last. The parallel_tests README recommends + # `first_process?` for "do something once after every worker + # finishes" hooks, so user code that has its own + # `wait_for_other_processes_to_finish` in an `RSpec.after(:suite)` + # overwhelmingly waits in the first process — picking the same + # side avoids the cross-process deadlock #922 reported. Also + # handles `PARALLEL_TEST_GROUPS=1` naturally (the only worker's + # `TEST_ENV_NUMBER` is "" and `first_process?` tests for that + # empty string). + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#36 + def first_worker?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#87 + def native_parallel_tests_environment?; end + + # The native wait blocks until every sibling process exits, but only + # when the pid-file contract is present (see `wait_for_siblings`). + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#48 + def native_wait?; end + + # source://simplecov//lib/simplecov/parallel_adapters/parallel_tests.rb#40 + def wait_for_siblings; end + end +end + +# Folds a list of resultset files into one merged coverage table across +# forked worker processes. Drives `SimpleCov.collate(..., processes: N)`. +# +# `ResultMerger.absorb_results` is a fold over N independent +# read-parse-combine steps, so it splits cleanly: each worker runs that +# same fold over a contiguous slice of the file list and ships the pair +# back over a pipe, and the parent combines the handful of per-worker +# pairs it gets back. Reading and parsing the shards — where a collate +# over a few hundred CI jobs spends most of its time — is what actually +# parallelises. +# +# The slices are contiguous and merged back in order, so the fold visits +# the resultsets in the order the serial fold visits them and the merged +# result is identical to `SimpleCov.collate`'s, not merely equivalent. +# +# Every failure path returns nil rather than a partial merge, so the caller +# can redo the fold serially: reporting coverage for a subset of the +# resultsets would silently understate it. +# +# source://simplecov//lib/simplecov/parallel_result_merger.rb#24 +module SimpleCov::ParallelResultMerger + private + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#128 + def abandon(workers); end + + # `ResultMerger.absorb_results` across at most `processes` forked + # workers: same arguments, same `[command_names, coverage]` return. + # + # The tracked paths a worker's slice carried come back with its payload + # rather than through a collector block, since the block a serial absorb + # takes would be mutating a Set in the wrong process. + # + # @return [Array(Array<String>, Hash), nil] the pair + # `ResultMerger.create_result` consumes, or nil when the work could not + # be fanned out and the caller should merge in this process instead. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#73 + def absorb_results(file_paths, processes:, ignore_timeout: T.unsafe(nil), tracked_files: T.unsafe(nil)); end + + # Contiguous slices whose sizes differ by at most one, so no worker is + # left folding twice its share while the others idle. There are never + # more slices than files: asking for more processes than there are + # resultsets just gives one resultset per process. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#93 + def chunk(file_paths, processes); end + + # Deserializes on a thread per worker so every pipe is drained while the + # workers are still writing. A payload larger than the pipe buffer would + # otherwise block its worker mid-write, and the parent would block + # reaping a worker that can never finish. + # + # Returns nil if any worker failed, so the caller can fall back to the + # serial fold rather than report a subset of the resultsets as the whole. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#185 + def collect(workers); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#196 + def drain(workers); end + + # A `fork` that fails here raises, and is left to. `absorb_results` has + # already excluded the runtimes that never fork, so what remains is the OS + # refusing a process we expected to get — EAGAIN at RLIMIT_NPROC, ENOMEM + # under memory pressure. That says something is wrong with the machine + # rather than with the merge, and quietly absorbing it would hide it. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#106 + def fan_out(chunks, ignore_timeout:, tracked_files: T.unsafe(nil)); end + + # `ResultMerger.merge_and_store` across `processes` forked workers. One + # worker hands straight back to `ResultMerger`, so the default `collate` + # takes exactly the path it always has and never reaches this module's + # machinery at all. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#33 + def merge_and_store(*file_paths, processes:, ignore_timeout: T.unsafe(nil)); end + + # `ResultMerger.merge_results` across `processes` forked workers, merging + # in this process instead whenever the fan-out did not produce a complete + # merge — a runtime that cannot fork, nothing worth splitting, or a worker + # that died. The result is the same either way; only the time it took to + # get there differs. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#48 + def merge_results(*file_paths, processes:, ignore_timeout: T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#200 + def read_payload(reader); end + + # Everything the child does. `exit!` rather than `exit` because it must + # never fall through to the collating process's inherited `at_exit` + # handlers — SimpleCov's own report generation included. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#155 + def run_in_child(reader, writer, chunk, ignore_timeout); end + + # The body of a worker: merge the slice, ship it back, and report the exit + # status the child should terminate with. Kept free of the exit itself so + # it can be exercised in-process. + # + # The slice's tracked paths travel with the pair because the parent needs + # the union across every worker to know what nothing loaded. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#166 + def run_worker(chunk, writer, ignore_timeout:); end + + # `fork` itself can fail (EAGAIN under a process limit). The caller + # cleans up the workers it knows about, but this pipe is ours: the + # parent's writer end always closes, and the reader closes too when + # no child was spawned to feed it. Steep cannot type body locals + # inside an ensure, hence the ignore markers; the safe navigation + # keeps the cleanup well-defined when `IO.pipe` itself raised. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#141 + def spawn_worker(chunk, ignore_timeout:); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#115 + def spawn_workers(chunks, ignore_timeout:); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#214 + def succeeded?(pid); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#223 + def warn_about_failed_workers(failed, total); end + + class << self + # source://simplecov//lib/simplecov/parallel_result_merger.rb#128 + def abandon(workers); end + + # `ResultMerger.absorb_results` across at most `processes` forked + # workers: same arguments, same `[command_names, coverage]` return. + # + # The tracked paths a worker's slice carried come back with its payload + # rather than through a collector block, since the block a serial absorb + # takes would be mutating a Set in the wrong process. + # + # @return [Array(Array<String>, Hash), nil] the pair + # `ResultMerger.create_result` consumes, or nil when the work could not + # be fanned out and the caller should merge in this process instead. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#73 + def absorb_results(file_paths, processes:, ignore_timeout: T.unsafe(nil), tracked_files: T.unsafe(nil)); end + + # Contiguous slices whose sizes differ by at most one, so no worker is + # left folding twice its share while the others idle. There are never + # more slices than files: asking for more processes than there are + # resultsets just gives one resultset per process. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#93 + def chunk(file_paths, processes); end + + # Deserializes on a thread per worker so every pipe is drained while the + # workers are still writing. A payload larger than the pipe buffer would + # otherwise block its worker mid-write, and the parent would block + # reaping a worker that can never finish. + # + # Returns nil if any worker failed, so the caller can fall back to the + # serial fold rather than report a subset of the resultsets as the whole. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#185 + def collect(workers); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#196 + def drain(workers); end + + # A `fork` that fails here raises, and is left to. `absorb_results` has + # already excluded the runtimes that never fork, so what remains is the OS + # refusing a process we expected to get — EAGAIN at RLIMIT_NPROC, ENOMEM + # under memory pressure. That says something is wrong with the machine + # rather than with the merge, and quietly absorbing it would hide it. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#106 + def fan_out(chunks, ignore_timeout:, tracked_files: T.unsafe(nil)); end + + # `ResultMerger.merge_and_store` across `processes` forked workers. One + # worker hands straight back to `ResultMerger`, so the default `collate` + # takes exactly the path it always has and never reaches this module's + # machinery at all. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#33 + def merge_and_store(*file_paths, processes:, ignore_timeout: T.unsafe(nil)); end + + # `ResultMerger.merge_results` across `processes` forked workers, merging + # in this process instead whenever the fan-out did not produce a complete + # merge — a runtime that cannot fork, nothing worth splitting, or a worker + # that died. The result is the same either way; only the time it took to + # get there differs. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#48 + def merge_results(*file_paths, processes:, ignore_timeout: T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#200 + def read_payload(reader); end + + # Everything the child does. `exit!` rather than `exit` because it must + # never fall through to the collating process's inherited `at_exit` + # handlers — SimpleCov's own report generation included. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#155 + def run_in_child(reader, writer, chunk, ignore_timeout); end + + # The body of a worker: merge the slice, ship it back, and report the exit + # status the child should terminate with. Kept free of the exit itself so + # it can be exercised in-process. + # + # The slice's tracked paths travel with the pair because the parent needs + # the union across every worker to know what nothing loaded. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#166 + def run_worker(chunk, writer, ignore_timeout:); end + + # `fork` itself can fail (EAGAIN under a process limit). The caller + # cleans up the workers it knows about, but this pipe is ours: the + # parent's writer end always closes, and the reader closes too when + # no child was spawned to feed it. Steep cannot type body locals + # inside an ensure, hence the ignore markers; the safe navigation + # keeps the cleanup well-defined when `IO.pipe` itself raised. + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#141 + def spawn_worker(chunk, ignore_timeout:); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#115 + def spawn_workers(chunks, ignore_timeout:); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/parallel_result_merger.rb#214 + def succeeded?(pid); end + + # source://simplecov//lib/simplecov/parallel_result_merger.rb#223 + def warn_about_failed_workers(failed, total); end + end +end + +# Profiles are SimpleCov configuration procs that can be easily +# loaded using SimpleCov.start :rails and defined using +# SimpleCov.profiles.define :foo do +# # SimpleCov configuration here, same as in SimpleCov.configure +# end +# +# source://simplecov//lib/simplecov/profiles.rb#11 +class SimpleCov::Profiles < ::Hash + # Define a SimpleCov profile: + # SimpleCov.profiles.define 'rails' do + # # Same as SimpleCov.configure do .. here + # end + # + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/profiles.rb#18 + def define(name, &blk); end + + # Returns the proc registered for the given profile name, autoloading + # bundled or plugin-gem profiles on first lookup. Raises if the profile + # cannot be located. + # + # Lookup order: + # 1. already registered via #define + # 2. require "simplecov/profiles/<name>" (bundled profiles) + # 3. require "simplecov-profile-<name>" (third-party plugin gems) + # + # @raise [SimpleCov::ConfigurationError] + # + # source://simplecov//lib/simplecov/profiles.rb#42 + def fetch_proc(name); end + + # Applies the profile of given name on SimpleCov.configure + # + # source://simplecov//lib/simplecov/profiles.rb#28 + def load(name); end + + private + + # source://simplecov//lib/simplecov/profiles.rb#52 + def autoload_profile(name); end +end + +# Filter that matches when the source file's project path matches the +# configured Regexp. +# +# source://simplecov//lib/simplecov/filter.rb#110 +class SimpleCov::RegexFilter < ::SimpleCov::Filter + # Returns true when the given source file's filename matches the + # regex configured when initializing this Filter with RegexFilter.new(/someregex/). + # Uses `Regexp#match?` so the predicate returns a real boolean — `=~` + # would return the match position (an Integer or nil), which trips + # rspec-mocks 4's stricter predicate-matcher type check. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#116 + def matches?(source_file); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#120 + def path_only?; end +end + +# Touches coverage/.report_stamp whenever a report is formatted, no +# matter how the run ends. The clobber-prevention backstop +# (`SimpleCov.defer_to_existing_report?`) needs an on-disk signal that +# a fresher report exists, and `.last_run.json` can't be it alone: +# that file is only written after fully successful runs, which left +# the backstop inert exactly when the child run failed and its report +# mattered most. +# +# source://simplecov//lib/simplecov/report_stamp.rb#13 +module SimpleCov::ReportStamp + class << self + # source://simplecov//lib/simplecov/report_stamp.rb#15 + def path; end + + # source://simplecov//lib/simplecov/report_stamp.rb#19 + def touch; end + end +end + +# A simplecov code coverage result, initialized from the Hash Ruby's built-in coverage +# library generates (Coverage.result). +# +# source://simplecov//lib/simplecov/result/missing_source_files_reporter.rb#4 +class SimpleCov::Result + extend ::Forwardable + + # Initialize a new SimpleCov::Result from given Coverage.result (a Hash of filenames each containing an array of + # coverage data). + # + # `filter_config` defaults to the SimpleCov singleton's filter / group + # configuration so existing call sites are unchanged. Pass a custom + # FilterConfig to opt out — useful for tests that build synthetic Results + # and don't want the project's filters or groups applied. + # + # @return [Result] a new instance of Result + # + # source://simplecov//lib/simplecov/result.rb#64 + def initialize(original_result, command_name: T.unsafe(nil), created_at: T.unsafe(nil), not_loaded_files: T.unsafe(nil), tracked_files: T.unsafe(nil), run_id: T.unsafe(nil), worker_id: T.unsafe(nil), report: T.unsafe(nil), filter_config: T.unsafe(nil)); end + + # The command name that launched this result. + # Delegated to SimpleCov.command_name if not set manually + # + # source://simplecov//lib/simplecov/result.rb#129 + def command_name; end + + # Explicitly set the command name that was used for this coverage result. Defaults to SimpleCov.command_name + # + # source://simplecov//lib/simplecov/result.rb#32 + def command_name=(_arg0); end + + # Returns the {line:/branch:/method:} coverage_statistics hash for the + # given file path, or nil if no matching source file is in this + # result. See SimpleCov::Result#source_file_for for path resolution. + # + # source://simplecov//lib/simplecov/result.rb#95 + def coverage_for(path); end + + # source://simplecov//lib/simplecov/result.rb#34 + def coverage_statistics(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def coverage_statistics_by_file(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def covered_branches(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def covered_lines(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def covered_methods(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def covered_percent(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def covered_percentages(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def covered_strength(*_arg0, **_arg1, &_arg2); end + + # Defines when this result has been created. Defaults to Time.now + # + # source://simplecov//lib/simplecov/result.rb#123 + def created_at; end + + # Explicitly set the Time this result has been created + # + # source://simplecov//lib/simplecov/result.rb#30 + def created_at=(_arg0); end + + # Returns all filenames for source files contained in this result + # + # source://simplecov//lib/simplecov/result.rb#79 + def filenames; end + + # Returns all files that are applicable to this result (sans filters!) as instances of + # SimpleCov::SourceFile. Aliased as :source_files + # + # source://simplecov//lib/simplecov/result.rb#27 + def files; end + + # Applies the configured SimpleCov.formatter on this result. Returns + # nil if formatting has been opted out of (`SimpleCov.formatter false` + # / `SimpleCov.formatters []`) — the cheap path for non-final + # processes in a parallel CI run, which only need their + # `.resultset.json` on disk. See #964. + # + # source://simplecov//lib/simplecov/result.rb#109 + def format!; end + + # Returns a Hash of groups for this result. Define groups using SimpleCov.group 'Models', 'app/models' + # + # source://simplecov//lib/simplecov/result.rb#100 + def groups; end + + # source://simplecov//lib/simplecov/result.rb#34 + def least_covered_file(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def missed_branches(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def missed_lines(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def missed_methods(*_arg0, **_arg1, &_arg2); end + + # Returns the original Coverage.result used for this instance of SimpleCov::Result + # + # source://simplecov//lib/simplecov/result.rb#17 + def original_result; end + + # Invocation and top-level worker identities used only for parallel-result + # coordination. They do not change which fresh suites are merged. + # + # source://simplecov//lib/simplecov/result.rb#24 + def run_id; end + + # Returns the SimpleCov::SourceFile for the given path, or nil if no + # matching file is in this result. The path is resolved against + # SimpleCov.root, so callers can pass either an absolute path or a + # project-relative one. + # + # source://simplecov//lib/simplecov/result.rb#87 + def source_file_for(path); end + + # Returns all files that are applicable to this result (sans filters!) as instances of + # SimpleCov::SourceFile. Aliased as :source_files + # + # source://simplecov//lib/simplecov/result.rb#28 + def source_files; end + + # Returns a hash representation of this Result that can be used for marshalling it into JSON + # + # source://simplecov//lib/simplecov/result.rb#134 + def to_hash; end + + # source://simplecov//lib/simplecov/result.rb#34 + def total_branches(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#39 + def total_lines(*_arg0, **_arg1, &_arg2); end + + # source://simplecov//lib/simplecov/result.rb#34 + def total_methods(*_arg0, **_arg1, &_arg2); end + + # Every path the producing process was told to track, loaded or not. Carried + # into the resultset so a merge elsewhere can inject the ones nobody loaded + # without needing that process's `cover` / `track_files` config. See #1250. + # + # source://simplecov//lib/simplecov/result.rb#21 + def tracked_files; end + + # Invocation and top-level worker identities used only for parallel-result + # coordination. They do not change which fresh suites are merged. + # + # source://simplecov//lib/simplecov/result.rb#24 + def worker_id; end + + private + + # When any `cover` matcher is configured, restrict `@files` to source + # files matching at least one of them. With no cover matchers configured + # this is a no-op, preserving the historical "everything required, then + # filtered" universe. + # + # source://simplecov//lib/simplecov/result.rb#205 + def apply_cover_filters!(cover_filters); end + + # Applies the given filter chain to `@files`, dropping each source + # file that any filter matches. + # + # source://simplecov//lib/simplecov/result.rb#195 + def apply_filters!(filters); end + + # A live result's criterion keys are Symbols (`:lines`, `:branches`), + # while entries parsed back from `.resultset.json` carry Strings, and + # the combiners read only String keys. Serialize with String keys so a + # live result merged against a stored entry contributes its counts + # instead of being silently dropped for every shared file. + # + # source://simplecov//lib/simplecov/result.rb#187 + def coverage; end + + # source://simplecov//lib/simplecov/result.rb#155 + def initialize_coordination_metadata(tracked_files, run_id, worker_id); end + + # source://simplecov//lib/simplecov/result.rb#161 + def warn_about_missing_source_files(missing, input_size); end + + class << self + # Loads a SimpleCov::Result#to_hash dump + # + # source://simplecov//lib/simplecov/result.rb#145 + def from_hash(hash); end + end +end + +# Bundles the filter and grouping configuration a Result applies to its +# source files after building them. Each field defaults to the SimpleCov +# singleton's configuration, so ordinary callers never construct one; +# tests pass a custom instance to opt out of (or extend) the project's +# filters or groups (e.g. `filters: []` to keep every file). Grouping the +# three together keeps Result#initialize's parameter list small. +# +# source://simplecov//lib/simplecov/result.rb#47 +class SimpleCov::Result::FilterConfig + # @return [FilterConfig] a new instance of FilterConfig + # + # source://simplecov//lib/simplecov/result.rb#50 + def initialize(filters: T.unsafe(nil), cover_filters: T.unsafe(nil), groups: T.unsafe(nil)); end + + # Returns the value of attribute cover_filters. + # + # source://simplecov//lib/simplecov/result.rb#48 + def cover_filters; end + + # Returns the value of attribute filters. + # + # source://simplecov//lib/simplecov/result.rb#48 + def filters; end + + # Returns the value of attribute groups. + # + # source://simplecov//lib/simplecov/result.rb#48 + def groups; end +end + +# When a resultset references source files that don't exist on the local +# filesystem they're silently dropped — which produces an empty `0 / 0 +# (100.00%)` report that looks like success but isn't. Emit a single +# warning summarizing the drop and, when every entry was lost, point at +# the typical cause (`SimpleCov.collate` invoked from a machine or path +# different from where the resultsets were generated). See #980. +# +# source://simplecov//lib/simplecov/result/missing_source_files_reporter.rb#11 +class SimpleCov::Result::MissingSourceFilesReporter + # @return [MissingSourceFilesReporter] a new instance of MissingSourceFilesReporter + # + # source://simplecov//lib/simplecov/result/missing_source_files_reporter.rb#12 + def initialize(missing_paths, input_size:, every_entry_dropped:); end + + # source://simplecov//lib/simplecov/result/missing_source_files_reporter.rb#22 + def message; end + + # source://simplecov//lib/simplecov/result/missing_source_files_reporter.rb#18 + def warn!; end + + private + + # source://simplecov//lib/simplecov/result/missing_source_files_reporter.rb#28 + def all_missing_warning; end + + # source://simplecov//lib/simplecov/result/missing_source_files_reporter.rb#36 + def partial_missing_warning; end + + # source://simplecov//lib/simplecov/result/missing_source_files_reporter.rb#42 + def summary; end +end + +# Constructs `SimpleCov::SourceFile` instances from a raw coverage +# hash, sorts them by filename, and surfaces filenames whose source +# is no longer present on disk so the caller can warn about the +# silent drop (see #980). +# +# source://simplecov//lib/simplecov/result/source_file_builder.rb#9 +class SimpleCov::Result::SourceFileBuilder + # @return [SourceFileBuilder] a new instance of SourceFileBuilder + # + # source://simplecov//lib/simplecov/result/source_file_builder.rb#12 + def initialize(original_result, not_loaded_files:); end + + # source://simplecov//lib/simplecov/result/source_file_builder.rb#18 + def call; end + + # Returns the value of attribute missing_source_files. + # + # source://simplecov//lib/simplecov/result/source_file_builder.rb#10 + def missing_source_files; end + + private + + # source://simplecov//lib/simplecov/result/source_file_builder.rb#28 + def build_source_file(filename, coverage); end + + # `Coverage.result` returns symbol keys (`:lines`, `:branches`, + # `:methods`); resultsets loaded from disk are already string-keyed. + # SourceFile reads with strings, and handles both Array and + # stringified-Array branch/method keys via `restore_ruby_data_structure`, + # so only the outer hash needs normalizing. + # + # source://simplecov//lib/simplecov/result/source_file_builder.rb#46 + def stringify_outer_keys(coverage); end +end + +# Responsible for adapting the format of the coverage result whether it's default or with statistics +# +# source://simplecov//lib/simplecov/result_adapter.rb#9 +class SimpleCov::ResultAdapter + # @return [ResultAdapter] a new instance of ResultAdapter + # + # source://simplecov//lib/simplecov/result_adapter.rb#12 + def initialize(result); end + + # source://simplecov//lib/simplecov/result_adapter.rb#20 + def adapt; end + + # Returns the value of attribute result. + # + # source://simplecov//lib/simplecov/result_adapter.rb#10 + def result; end + + private + + # Pre-0.18 resultsets pointed each filename straight at a line-coverage + # array; everything since uses the `{lines:, branches:, methods:}` + # shape. Newer entries also need their methods and branches tables + # massaged before downstream code reports or merges them. + # + # source://simplecov//lib/simplecov/result_adapter.rb#34 + def adapt_one(file_name, cover_statistic); end + + # source://simplecov//lib/simplecov/result_adapter.rb#146 + def adapt_oneshot_lines_if_needed(file_name, cover_statistic); end + + # Ruby's eval coverage records a fresh set of branch entries for every + # COMPILE of an eval'd string: a template rendered through multiple view + # classes (e.g. hanami-view compiles each template once per view) yields + # several `[:if, id, location]` conditions at identical coordinates in + # the same file, each counting only the renders that flowed through that + # compile. Reported as-is they inflate the branch denominator and turn a + # side covered under a different compile into a phantom miss (issue + # #1235). Aggregate them by (type, location) — combining a branches hash + # with an empty one dedups within it, since BranchesCombiner keys arms + # on location identity. Regular (non-eval) source can never produce two + # conditions at the same location, so this is a no-op outside eval. + # + # source://simplecov//lib/simplecov/result_adapter.rb#139 + def aggregate_duplicated_branches(cover_statistic); end + + # source://simplecov//lib/simplecov/result_adapter.rb#155 + def build_line_stub(file_name, oneshot_lines); end + + # Rendering a class name can execute user code: a singleton class's + # `to_s` renders its attached object via `#inspect`, which a module can + # shadow with an incompatible signature (Liquid::Utils defines + # `inspect(value, max_depth = 2)` as a module_function, so rendering + # `#<Class:Liquid::Utils>` raises ArgumentError). A coverage report + # must never crash the host suite over that, so on failure rebuild the + # singleton wrapper from `Module#name` via bound methods (which cannot + # be shadowed), falling back to the address form, which + # ADDRESS_PATTERN then normalizes. See issue #1236. + # + # source://simplecov//lib/simplecov/result_adapter.rb#109 + def class_display_name(klass); end + + # source://simplecov//lib/simplecov/result_adapter.rb#92 + def normalize_method_key(key); end + + # Ruby's method coverage records one entry per DEFINED METHOD, not per + # source location: a block handed to `define_method` / + # `define_singleton_method` from a shared code path yields a separate + # `[receiver, name, location]` entry for every class it's defined on + # (a module's `included` hook defining onto each descendant) AND for + # every name it's defined under (a builder looping `define_method key` + # over a container), all pointing at the same source. A file-based + # report can only express "was the method at this location ever + # executed", so entries are aggregated by location alone, summing + # hits — otherwise each receiver or name whose generated copy never + # ran shows as a phantom uncovered method on a line whose line + # coverage is 100%. Regular `def`s map one location to one name, so + # they are unaffected. The first entry's (normalized) key is kept for + # display. See issue #1234. + # + # source://simplecov//lib/simplecov/result_adapter.rb#79 + def normalize_method_keys(cover_statistic); end + + # source://simplecov//lib/simplecov/result_adapter.rb#115 + def singleton_wrapper_name(klass); end + + class << self + # source://simplecov//lib/simplecov/result_adapter.rb#16 + def call(*_arg0); end + end +end + +# Normalize memory addresses in method coverage keys so that results +# from different processes can be merged. Anonymous class names like +# "#<Class:0x00007ff19ab24790>" get inconsistent addresses across runs. +# Address widths vary by runtime (32-bit hosts: 8 hex chars; 64-bit +# CRuby: 16; some JVM/TruffleRuby formats may differ), so match any +# length of hex digits and collapse to a single placeholder. +# +# source://simplecov//lib/simplecov/result_adapter.rb#49 +SimpleCov::ResultAdapter::ADDRESS_PATTERN = T.let(T.unsafe(nil), Regexp) + +# source://simplecov//lib/simplecov/result_adapter.rb#52 +SimpleCov::ResultAdapter::ADDRESS_PLACEHOLDER = T.let(T.unsafe(nil), String) + +# Strip the `#<Class:Foo>` wrapper Ruby's Coverage adds to singleton-class +# method keys. `module_function` and class methods get recorded both as +# singleton (`[#<Class:Foo>, :m, …]`) and instance/module (`[Foo, :m, …]`) +# entries pointing at the same source location; only one of the two is +# ever reachable at runtime, so we merge them. Only applies to named +# constants — anonymous-class addresses like `#<Class:0x0>` are left +# alone (handled by ADDRESS_PATTERN above). +# +# source://simplecov//lib/simplecov/result_adapter.rb#62 +SimpleCov::ResultAdapter::SINGLETON_WRAPPER_PATTERN = T.let(T.unsafe(nil), Regexp) + +# Singleton that is responsible for caching, loading and merging +# SimpleCov::Results into a single result for coverage analysis based +# upon multiple test suites. +# +# source://simplecov//lib/simplecov/result_merger/legacy_format_adapter.rb#4 +module SimpleCov::ResultMerger + extend ::SimpleCov::ResultMerger::ResultsetRunIdentity + + class << self + # Reads every resultset and folds it into one merged coverage, stopping + # short of building a `SimpleCov::Result`. + # + # It is intentional here that files are only read in and parsed one at a time. + # + # In big CI setups you might deal with 100s of CI jobs and each one producing Megabytes + # of data. Reading them all in easily produces Gigabytes of memory consumption which + # we want to avoid. + # + # For similar reasons a SimpleCov::Result is only created in the end as that'd create + # even more data especially when it also reads in all source files. + # + # One accumulator absorbs the whole run, rather than folding each file + # into the merged-so-far pairwise: the pairwise form rebuilt every + # file's coverage once per resultset, which is what made merging a + # large parallel run's results the dominant cost of `collate`. + # Absorbing is still one resultset at a time, so the memory ceiling + # above is unchanged. + # + # `file_paths` is only ever iterated, so a caller that wants to observe + # the merge as it goes can hand in any Enumerable — `benchmarks/collate` + # passes an Enumerator that reports progress — rather than reimplement + # this loop and risk timing something other than what ships. + # + # @return [Array] the command names and the merged coverage + # + # source://simplecov//lib/simplecov/result_merger.rb#65 + def absorb_results(file_paths, ignore_timeout: T.unsafe(nil), &on_parse); end + + # source://simplecov//lib/simplecov/result_merger.rb#115 + def create_result(command_names, coverage, tracked_files: T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/result_merger.rb#91 + def drop_expired_results(results); end + + # source://simplecov//lib/simplecov/result_merger.rb#23 + def merge_and_store(*file_paths, ignore_timeout: T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/result_merger.rb#133 + def merge_coverage(*results); end + + # source://simplecov//lib/simplecov/result_merger.rb#29 + def merge_results(*file_paths, ignore_timeout: T.unsafe(nil)); end + + # Yields the entries that survived the merge timeout, so a caller that + # wants to observe what a resultset carried sees only what is being + # merged. An expired entry contributes nothing, tracked paths included. + # + # @yield [results] + # + # source://simplecov//lib/simplecov/result_merger.rb#79 + def merge_valid_results(results, ignore_timeout: T.unsafe(nil)); end + + # If an entry with the same command_name was written AFTER our process + # started, a sibling test runner (typically a subprocess our parent + # process shelled out to) wrote it. Combine coverage data rather than + # overwriting, so an empty parent-process result doesn't clobber the + # subprocess's real data. See https://github.com/simplecov-ruby/simplecov/issues/581. + # + # source://simplecov//lib/simplecov/result_merger.rb#175 + def merged_entry(existing, incoming); end + + # Gets all SimpleCov::Results stored in resultset, merges them and produces a new + # SimpleCov::Result with merged coverage data and the command_name + # for the result consisting of a join on all source result's names + # + # source://simplecov//lib/simplecov/result_merger.rb#144 + def merged_result; end + + # source://simplecov//lib/simplecov/result_merger.rb#150 + def read_resultset; end + + # source://simplecov//lib/simplecov/result_merger.rb#19 + def resultset_path; end + + # Saves the given SimpleCov::Result in the resultset cache + # + # source://simplecov//lib/simplecov/result_merger.rb#156 + def store_result(result); end + + # source://simplecov//lib/simplecov/result_merger.rb#184 + def synchronize_resultset(&_arg0); end + + # Yields the surviving entries before they are reduced. + # + # source://simplecov//lib/simplecov/result_merger.rb#72 + def valid_results(file_path, ignore_timeout: T.unsafe(nil), &on_parse); end + + # source://simplecov//lib/simplecov/result_merger.rb#103 + def warn_about_expired_results(expired_command_names); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_merger.rb#99 + def within_merge_timeout?(data); end + end +end + +# We changed the format of the raw result data in simplecov, as people +# are likely to have "old" resultsets lying around (but not too old so +# that they're still considered we can adapt them). See +# https://github.com/simplecov-ruby/simplecov/pull/824#issuecomment-576049747 +# +# source://simplecov//lib/simplecov/result_merger/legacy_format_adapter.rb#9 +module SimpleCov::ResultMerger::LegacyFormatAdapter + private + + # source://simplecov//lib/simplecov/result_merger/legacy_format_adapter.rb#12 + def call(result); end + + # Pre-0.18 coverage data pointed from file directly to an array of + # line coverage rather than a `{"lines" => [...]}` hash. + # + # source://simplecov//lib/simplecov/result_merger/legacy_format_adapter.rb#18 + def pre_0_18?(result); end + + # source://simplecov//lib/simplecov/result_merger/legacy_format_adapter.rb#23 + def upgrade(result); end + + class << self + # source://simplecov//lib/simplecov/result_merger/legacy_format_adapter.rb#12 + def call(result); end + + # Pre-0.18 coverage data pointed from file directly to an array of + # line coverage rather than a `{"lines" => [...]}` hash. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_merger/legacy_format_adapter.rb#18 + def pre_0_18?(result); end + + # source://simplecov//lib/simplecov/result_merger/legacy_format_adapter.rb#23 + def upgrade(result); end + end +end + +# Read + parse a `.resultset.json` file with the same tolerance the +# historical `ResultMerger` had: a missing or empty file quietly +# returns `{}`, an unparseable one warns and returns `{}`, and parse +# success returns the decoded Hash with any malformed entries +# warned about and dropped. +# +# source://simplecov//lib/simplecov/result_merger/resultset_file.rb#12 +module SimpleCov::ResultMerger::ResultsetFile + private + + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#35 + def decode(content); end + + # Each surviving entry must have the shape every consumer relies + # on: a Hash carrying a Numeric "timestamp" (the merge-timeout + # check) and a Hash "coverage" (the merge fold). A truncated or + # hand-edited entry would otherwise crash out of the middle of an + # at_exit merge, so it gets the same warn-and-move-on treatment + # as an unparseable file. + # + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#56 + def drop_malformed_entries(resultset); end + + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#68 + def invalid_resultset; end + + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#15 + def parse(path); end + + # A missing or blank file quietly means "no results yet"; anything + # else — a 1-byte truncation included — flows through decode so + # corruption warns instead of silently vanishing (the old + # `length < 2` check swallowed exactly those). Read first and + # rescue rather than check exist? (racy against a concurrent + # clean). + # + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#26 + def read(path); end + + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#64 + def well_formed_entry?(data); end + + class << self + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#35 + def decode(content); end + + # Each surviving entry must have the shape every consumer relies + # on: a Hash carrying a Numeric "timestamp" (the merge-timeout + # check) and a Hash "coverage" (the merge fold). A truncated or + # hand-edited entry would otherwise crash out of the middle of an + # at_exit merge, so it gets the same warn-and-move-on treatment + # as an unparseable file. + # + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#56 + def drop_malformed_entries(resultset); end + + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#68 + def invalid_resultset; end + + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#15 + def parse(path); end + + # A missing or blank file quietly means "no results yet"; anything + # else — a 1-byte truncation included — flows through decode so + # corruption warns instead of silently vanishing (the old + # `length < 2` check swallowed exactly those). Read first and + # rescue rather than check exist? (racy against a concurrent + # clean). + # + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#26 + def read(path); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_merger/resultset_file.rb#64 + def well_formed_entry?(data); end + end +end + +# Run/worker metadata queries used by resultset storage and parallel +# coordination. Kept separate from the coverage-folding implementation. +# +# source://simplecov//lib/simplecov/result_merger/resultset_run_identity.rb#7 +module SimpleCov::ResultMerger::ResultsetRunIdentity + # Whether `entry` was written by a runner concurrent with this + # process: either it shares our run id (`current_run_entry?`), or it + # was written strictly after our process started. A mismatched run + # id must not defeat the timestamp check — a subprocess we shelled + # out to (fork+exec) generates its own random run id, and its + # freshly written entry is exactly the data issue #581 protects + # from being clobbered. Strict id matching is reserved for worker + # counting (`worker_identities_for_run`), where admitting a stale + # entry would end a sibling wait early. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_merger/resultset_run_identity.rb#48 + def concurrent_runner_entry?(entry, incoming = T.unsafe(nil)); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_merger/resultset_run_identity.rb#23 + def current_run_entry?(entry, run_id, started_at); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_merger/resultset_run_identity.rb#34 + def fresh_entry?(entry, started_at); end + + # One namespaced identity per distinct worker that wrote to the + # current run: `[:worker, id]` for entries that carry a worker id, + # `[:legacy, command_name]` for fresh entries written without one + # (an old SimpleCov writing to the same resultset). Pairs rather + # than mangled strings, so a resultset mixing both kinds can never + # alias a real worker id to a synthesized legacy identity. + # + # source://simplecov//lib/simplecov/result_merger/resultset_run_identity.rb#14 + def worker_identities_for_run(results, run_id, started_at); end + + # Strictly after, unlike `fresh_entry?`: an entry stamped at the + # exact instant we started is treated as leftover from a previous + # run, so a same-second stale entry is still overwritten. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_merger/resultset_run_identity.rb#61 + def written_after_start?(entry, started_at); end +end + +# Reads and writes the persistent `.resultset.json` cache, including +# file-lock synchronization between processes and atomic temp-file +# renames so concurrent readers don't observe a truncated file. +# +# source://simplecov//lib/simplecov/result_merger/resultset_store.rb#13 +module SimpleCov::ResultMerger::ResultsetStore + private + + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#19 + def resultset_path; end + + # Serialize threads before taking the process-wide file lock. Nested + # calls by the owning thread bypass flock so they cannot deadlock on a + # second descriptor for the same lock file. + # + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#38 + def synchronize(&_arg0); end + + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#44 + def with_flock; end + + # Compact JSON, not pretty-printed: this is a machine-read cache + # that every parallel worker rewrites wholesale, and on a large + # project pretty printing nearly doubles the bytes written, read + # back, and parsed on each of those store-merge round trips. + # + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#31 + def write(resultset); end + + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#23 + def writelock_path; end + + class << self + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#19 + def resultset_path; end + + # Serialize threads before taking the process-wide file lock. Nested + # calls by the owning thread bypass flock so they cannot deadlock on a + # second descriptor for the same lock file. + # + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#38 + def synchronize(&_arg0); end + + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#44 + def with_flock; end + + # Compact JSON, not pretty-printed: this is a machine-read cache + # that every parallel worker rewrites wholesale, and on a large + # project pretty printing nearly doubles the bytes written, read + # back, and parsed on each of those store-merge round trips. + # + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#31 + def write(resultset); end + + # source://simplecov//lib/simplecov/result_merger/resultset_store.rb#23 + def writelock_path; end + end +end + +# source://simplecov//lib/simplecov/result_merger/resultset_store.rb#14 +SimpleCov::ResultMerger::ResultsetStore::LOCK_MONITOR = T.let(T.unsafe(nil), Monitor) + +# The merge step's half of unloaded-file handling: the policy of when +# to inject and which criteria simulated files carry. The mechanism — +# expanding globs and simulating each file — is +# `SimpleCov::UnloadedFileInjector`, an easy name to confuse with this +# one. +# +# Injection moved here from the individual processes because only the union +# of what they all loaded says what was really never loaded. Doing it per +# process meant N workers simulated the same file up to N times and the +# merge threw all but one away. See #1250. +# +# source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#15 +module SimpleCov::ResultMerger::UnloadedFiles + private + + # A simulated file should have the same shape as the files it is being + # merged alongside, so the criteria come from the merged data rather than + # from this process's configuration, which is not necessarily the + # configuration any contributing process measured under: `simplecov merge` + # never ran `SimpleCov.start` at all. Giving injected files fewer tables + # than their neighbours is what inflates the percentage #1059 fixed. + # + # Falls back to the configuration when there is nothing to be consistent + # with, which is a merge of resultsets that carried no files. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#64 + def carries?(coverage, criterion); end + + # Concurrent workers sharing a command name may have been told to track + # different sets, so keep both rather than letting the later write win. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#36 + def carry_tracked(entry, existing, incoming); end + + # The union of what every contributing process was told to track. Absent + # from resultsets written before this was recorded, in which case those + # processes injected their own unloaded files and the data is already in + # the coverage hash. + # A collector for the merge to hand `merge_valid_results`, gathering the + # tracked paths of every resultset that survives the merge timeout. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#24 + def collector(into); end + + # Simulate each tracked file the merged coverage doesn't already carry. + # The paths come from the resultsets rather than this process's own + # configuration, which a standalone `collate` would not have: it never ran + # `SimpleCov.start`, so it has no `cover` glob to expand. Idempotent, so + # resultsets from an older SimpleCov (or from a `merging false` process) + # that already carry their unloaded files pass through untouched. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#47 + def inject(coverage, tracked_files); end + + # Which files no contributing process ever loaded. Injection reports the + # ones it added, but a file can also arrive already simulated from a + # resultset this merge didn't inject into, so the merged line counts are + # still consulted, using the same signal + # `Combine::CoverageAccumulator` reconciles synthesized tuples on. + # + # A file is judged only when it has a relevant line. A branch-only or + # method-only run reports no line data at all for the files it loaded, + # and a loaded file with no executable lines (a comment-only constants + # stub, say) reports every line as `nil` — either would otherwise read + # as "never executed" and mark a genuinely loaded file not loaded, + # turning its branch and method coverage into #902's 0%. + # `SimulateCoverage` omits lines under those criteria too and gives a + # simulated file a `0` on every relevant line, so genuinely unloaded + # files still carry judgeable data, and a simulated file with none is + # already flagged by injection reporting it. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#93 + def never_executed(coverage); end + + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#28 + def tracked_in(resultset); end + + class << self + # A simulated file should have the same shape as the files it is being + # merged alongside, so the criteria come from the merged data rather than + # from this process's configuration, which is not necessarily the + # configuration any contributing process measured under: `simplecov merge` + # never ran `SimpleCov.start` at all. Giving injected files fewer tables + # than their neighbours is what inflates the percentage #1059 fixed. + # + # Falls back to the configuration when there is nothing to be consistent + # with, which is a merge of resultsets that carried no files. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#64 + def carries?(coverage, criterion); end + + # Concurrent workers sharing a command name may have been told to track + # different sets, so keep both rather than letting the later write win. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#36 + def carry_tracked(entry, existing, incoming); end + + # The union of what every contributing process was told to track. Absent + # from resultsets written before this was recorded, in which case those + # processes injected their own unloaded files and the data is already in + # the coverage hash. + # A collector for the merge to hand `merge_valid_results`, gathering the + # tracked paths of every resultset that survives the merge timeout. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#24 + def collector(into); end + + # Simulate each tracked file the merged coverage doesn't already carry. + # The paths come from the resultsets rather than this process's own + # configuration, which a standalone `collate` would not have: it never ran + # `SimpleCov.start`, so it has no `cover` glob to expand. Idempotent, so + # resultsets from an older SimpleCov (or from a `merging false` process) + # that already carry their unloaded files pass through untouched. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#47 + def inject(coverage, tracked_files); end + + # Which files no contributing process ever loaded. Injection reports the + # ones it added, but a file can also arrive already simulated from a + # resultset this merge didn't inject into, so the merged line counts are + # still consulted, using the same signal + # `Combine::CoverageAccumulator` reconciles synthesized tuples on. + # + # A file is judged only when it has a relevant line. A branch-only or + # method-only run reports no line data at all for the files it loaded, + # and a loaded file with no executable lines (a comment-only constants + # stub, say) reports every line as `nil` — either would otherwise read + # as "never executed" and mark a genuinely loaded file not loaded, + # turning its branch and method coverage into #902's 0%. + # `SimulateCoverage` omits lines under those criteria too and gives a + # simulated file a `0` on every relevant line, so genuinely unloaded + # files still carry judgeable data, and a simulated file with none is + # already flagged by injection reporting it. + # + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#93 + def never_executed(coverage); end + + # source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#28 + def tracked_in(resultset); end + end +end + +# source://simplecov//lib/simplecov/result_merger/unloaded_files.rb#70 +SimpleCov::ResultMerger::UnloadedFiles::CRITERION_PREDICATES = T.let(T.unsafe(nil), Hash) + +# Identifies one test invocation and each top-level parallel worker within +# it. Forked subprocesses inherit both values from their parent worker. +# +# source://simplecov//lib/simplecov/run_identity.rb#8 +module SimpleCov::RunIdentity + private + + # Whether the current run id alone proves same-run membership. Decided + # where the id is generated, never re-inferred from the id's shape, so + # an explicit SIMPLECOV_RUN_ID that happens to look like an inferred + # one is still trusted. + # + # source://simplecov//lib/simplecov/run_identity.rb#42 + def authoritative?; end + + # source://simplecov//lib/simplecov/run_identity.rb#47 + def current; end + + # source://simplecov//lib/simplecov/run_identity.rb#56 + def current_worker_id; end + + # Returns the run id together with its provenance: `true` when the id + # alone proves same-run membership (explicitly configured, derived from + # parallel_tests' per-invocation pid file, or freshly random), `false` + # when it was inferred from the parent pid, which consecutive runs + # launched by the same long-lived parent process share. + # + # source://simplecov//lib/simplecov/run_identity.rb#16 + def generate; end + + # source://simplecov//lib/simplecov/run_identity.rb#52 + def materialize_current; end + + # source://simplecov//lib/simplecov/run_identity.rb#60 + def prepare; end + + # source://simplecov//lib/simplecov/run_identity.rb#28 + def worker_id; end + + class << self + # Whether the current run id alone proves same-run membership. Decided + # where the id is generated, never re-inferred from the id's shape, so + # an explicit SIMPLECOV_RUN_ID that happens to look like an inferred + # one is still trusted. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/run_identity.rb#42 + def authoritative?; end + + # source://simplecov//lib/simplecov/run_identity.rb#47 + def current; end + + # source://simplecov//lib/simplecov/run_identity.rb#56 + def current_worker_id; end + + # Returns the run id together with its provenance: `true` when the id + # alone proves same-run membership (explicitly configured, derived from + # parallel_tests' per-invocation pid file, or freshly random), `false` + # when it was inferred from the parent pid, which consecutive runs + # launched by the same long-lived parent process share. + # + # source://simplecov//lib/simplecov/run_identity.rb#16 + def generate; end + + # source://simplecov//lib/simplecov/run_identity.rb#52 + def materialize_current; end + + # source://simplecov//lib/simplecov/run_identity.rb#60 + def prepare; end + + # source://simplecov//lib/simplecov/run_identity.rb#28 + def worker_id; end + end +end + +# Mixed into SimpleCov after ParallelAdapters has loaded. +# +# source://simplecov//lib/simplecov/run_identity.rb#67 +module SimpleCov::RunIdentity::Accessors + # source://simplecov//lib/simplecov/run_identity.rb#68 + def run_id; end + + # source://simplecov//lib/simplecov/run_identity.rb#72 + def worker_id; end +end + +# Responsible for producing file coverage metrics. +# +# source://simplecov//lib/simplecov/simulate_coverage.rb#10 +module SimpleCov::SimulateCoverage + private + + # Simulate a file coverage report for a file that was tracked but never + # required. Returns the same hash shape as `Coverage.result` (lines, + # branches, methods). + # + # The line classification comes from `Coverage.line_stub` — the same + # classification the runtime would have produced if the file had been + # required — overlaid with SimpleCov's `# :nocov:` toggles and + # `# simplecov:disable line` directive ranges, which `Coverage` doesn't + # know about. This keeps "relevant lines" identical whether a file was + # loaded or just tracked, fixing the multi-line statement discrepancy + # in https://github.com/simplecov-ruby/simplecov/issues/654. + # + # Branches and methods are enumerated by static analysis (via + # `StaticCoverageExtractor`, which uses Prism). Earlier behavior left + # both as empty hashes, which made unloaded files invisible to the + # branch/method denominators while their lines DID count — so a + # `track_files`/`cover` glob that picked up files without specs + # silently inflated branch% relative to line%. See + # https://github.com/simplecov-ruby/simplecov/issues/1059. When Prism + # isn't loadable (Ruby < 3.3 without the prism gem) or the file + # can't be parsed, fall back to the old empty hashes — old behavior, + # old tradeoff. + # + # Pass `synthesize: false` to skip the static analysis and return the + # empty hashes directly. Callers use it when neither branch nor method + # coverage is enabled, since nothing will read the tuples and the Prism + # parse is about half the cost of simulating a file. See #1250. + # + # Pass `lines: false` to omit the `"lines"` key entirely, mirroring what + # `Coverage.result` reports for a file loaded under a branch-only or + # method-only run. Emitting zeroed lines there would make a simulated + # file indistinguishable from one a sibling process actually loaded once + # the two are merged. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/simulate_coverage.rb#50 + def call(absolute_path, synthesize: T.unsafe(nil), lines: T.unsafe(nil)); end + + # Combine `Coverage.line_stub` (which gets multi-line statements right) + # with `LinesClassifier` (which knows about `# :nocov:` toggles and + # `# simplecov:disable line` ranges). Returns nil — and the caller + # falls back to `LinesClassifier` alone — when `Coverage` can't read + # or parse the file, or when the runtime doesn't expose `line_stub` + # (JRuby and TruffleRuby). + # + # source://simplecov//lib/simplecov/simulate_coverage.rb#86 + def coverage_stub(path, source_lines); end + + # SystemCallError, not just ENOENT: a `track_files` glob can sweep + # up an unreadable file or a directory named like a Ruby file + # (EACCES, EISDIR), and simulation must degrade to "empty file" + # rather than crash the merge or report step. + # + # source://simplecov//lib/simplecov/simulate_coverage.rb#74 + def read_lines(path); end + + # The branch and method tuples for a file, or empty hashes when the static + # analysis is skipped (nothing enabled reads them) or unavailable (no Prism, + # or the file doesn't parse). + # + # source://simplecov//lib/simplecov/simulate_coverage.rb#63 + def synthesized_tuples(source_lines, synthesize); end + + class << self + # Simulate a file coverage report for a file that was tracked but never + # required. Returns the same hash shape as `Coverage.result` (lines, + # branches, methods). + # + # The line classification comes from `Coverage.line_stub` — the same + # classification the runtime would have produced if the file had been + # required — overlaid with SimpleCov's `# :nocov:` toggles and + # `# simplecov:disable line` directive ranges, which `Coverage` doesn't + # know about. This keeps "relevant lines" identical whether a file was + # loaded or just tracked, fixing the multi-line statement discrepancy + # in https://github.com/simplecov-ruby/simplecov/issues/654. + # + # Branches and methods are enumerated by static analysis (via + # `StaticCoverageExtractor`, which uses Prism). Earlier behavior left + # both as empty hashes, which made unloaded files invisible to the + # branch/method denominators while their lines DID count — so a + # `track_files`/`cover` glob that picked up files without specs + # silently inflated branch% relative to line%. See + # https://github.com/simplecov-ruby/simplecov/issues/1059. When Prism + # isn't loadable (Ruby < 3.3 without the prism gem) or the file + # can't be parsed, fall back to the old empty hashes — old behavior, + # old tradeoff. + # + # Pass `synthesize: false` to skip the static analysis and return the + # empty hashes directly. Callers use it when neither branch nor method + # coverage is enabled, since nothing will read the tuples and the Prism + # parse is about half the cost of simulating a file. See #1250. + # + # Pass `lines: false` to omit the `"lines"` key entirely, mirroring what + # `Coverage.result` reports for a file loaded under a branch-only or + # method-only run. Emitting zeroed lines there would make a simulated + # file indistinguishable from one a sibling process actually loaded once + # the two are merged. + # + # @return [Hash] + # + # source://simplecov//lib/simplecov/simulate_coverage.rb#50 + def call(absolute_path, synthesize: T.unsafe(nil), lines: T.unsafe(nil)); end + + # Combine `Coverage.line_stub` (which gets multi-line statements right) + # with `LinesClassifier` (which knows about `# :nocov:` toggles and + # `# simplecov:disable line` ranges). Returns nil — and the caller + # falls back to `LinesClassifier` alone — when `Coverage` can't read + # or parse the file, or when the runtime doesn't expose `line_stub` + # (JRuby and TruffleRuby). + # + # source://simplecov//lib/simplecov/simulate_coverage.rb#86 + def coverage_stub(path, source_lines); end + + # SystemCallError, not just ENOENT: a `track_files` glob can sweep + # up an unreadable file or a directory named like a Ruby file + # (EACCES, EISDIR), and simulation must degrade to "empty file" + # rather than crash the merge or report step. + # + # source://simplecov//lib/simplecov/simulate_coverage.rb#74 + def read_lines(path); end + + # The branch and method tuples for a file, or empty hashes when the static + # analysis is skipped (nothing enabled reads them) or unavailable (no Prism, + # or the file doesn't parse). + # + # source://simplecov//lib/simplecov/simulate_coverage.rb#63 + def synthesized_tuples(source_lines, synthesize); end + end +end + +# Representation of a source file including it's coverage data, source code, +# source lines and featuring helpers to interpret that data. +# +# source://simplecov//lib/simplecov/source_file/line.rb#4 +class SimpleCov::SourceFile + include ::SimpleCov::SourceFile::BuilderContext + + # @return [SourceFile] a new instance of SourceFile + # + # source://simplecov//lib/simplecov/source_file.rb#27 + def initialize(filename, coverage_data, loaded: T.unsafe(nil)); end + + # Return all the branches inside current source file + # + # source://simplecov//lib/simplecov/source_file.rb#116 + def branches; end + + # DEPRECATED: use `covered_percent(:branch)`. + # + # source://simplecov//lib/simplecov/source_file.rb#125 + def branches_coverage_percent; end + + # source://simplecov//lib/simplecov/source_file.rb#155 + def branches_for_line(line_number); end + + # Return hash with key of line number and branch coverage count as value + # + # source://simplecov//lib/simplecov/source_file.rb#137 + def branches_report; end + + # The array of coverage data received from the Coverage.result + # + # source://simplecov//lib/simplecov/source_file.rb#25 + def coverage_data; end + + # Returns a hash keyed by every supported coverage criterion. Each + # value is a CoverageStatistics, even for criteria that weren't + # enabled during the run — those collapse to 0/0/0. Consumers + # (FileList, formatters) decide which keys to surface based on + # `SimpleCov.coverage_criterion_enabled?`. + # The per-criterion coverage statistics for this file. With no argument + # returns the `{line:, branch:, method:}` Hash; pass a criterion symbol + # (`:line` / `:branch` / `:method`) to get that one CoverageStatistics. + # + # source://simplecov//lib/simplecov/source_file.rb#53 + def coverage_statistics(criterion = T.unsafe(nil)); end + + # Select the covered branches. We use a tree schema here because + # some conditions like `case` may have an additional `else` that + # isn't declared in code but is given by default by the coverage + # report. + # + # source://simplecov//lib/simplecov/source_file.rb#146 + def covered_branches; end + + # Returns all covered lines as SimpleCov::SourceFile::Line + # + # source://simplecov//lib/simplecov/source_file.rb#66 + def covered_lines; end + + # source://simplecov//lib/simplecov/source_file.rb#169 + def covered_methods; end + + # The coverage for this file in percent, for the given criterion (line by + # default). Returns nil if the criterion was not measured. + # + # source://simplecov//lib/simplecov/source_file.rb#99 + def covered_percent(criterion = T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/source_file.rb#103 + def covered_strength(criterion = T.unsafe(nil)); end + + # The full path to this source file (e.g. /User/colszowka/projects/simplecov/lib/simplecov/source_file.rb) + # + # source://simplecov//lib/simplecov/source_file.rb#23 + def filename; end + + # Access SimpleCov::SourceFile::Line source lines by line number + # + # source://simplecov//lib/simplecov/source_file.rb#93 + def line(number); end + + # Check if any branches missing on given line number + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file.rb#160 + def line_with_missed_branch?(line_number); end + + # Returns all source lines for this file as instances of SimpleCov::SourceFile::Line, + # and thus including coverage data. Aliased as :source_lines + # + # source://simplecov//lib/simplecov/source_file.rb#60 + def lines; end + + # Returns the number of relevant lines (covered + missed) + # + # source://simplecov//lib/simplecov/source_file.rb#88 + def lines_of_code; end + + # Return all methods detected in this source file + # + # source://simplecov//lib/simplecov/source_file.rb#165 + def methods; end + + # DEPRECATED: use `covered_percent(:method)`. + # + # source://simplecov//lib/simplecov/source_file.rb#178 + def methods_coverage_percent; end + + # Select the missed branches with coverage equal to zero + # + # source://simplecov//lib/simplecov/source_file.rb#151 + def missed_branches; end + + # Returns all lines that should have been, but were not covered + # as instances of SimpleCov::SourceFile::Line + # + # source://simplecov//lib/simplecov/source_file.rb#72 + def missed_lines; end + + # source://simplecov//lib/simplecov/source_file.rb#173 + def missed_methods; end + + # Returns all lines that are not relevant for coverage as + # SimpleCov::SourceFile::Line instances + # + # source://simplecov//lib/simplecov/source_file.rb#78 + def never_lines; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file.rb#120 + def no_branches?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file.rb#107 + def no_lines?; end + + # Whether this file was added via track_files but never loaded/required. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file.rb#185 + def not_loaded?; end + + # The path to this source file relative to the projects directory + # + # source://simplecov//lib/simplecov/source_file.rb#34 + def project_filename; end + + # source://simplecov//lib/simplecov/source_file.rb#111 + def relevant_lines; end + + # Returns all lines that were skipped as SimpleCov::SourceFile::Line instances + # + # source://simplecov//lib/simplecov/source_file.rb#83 + def skipped_lines; end + + # The source code for this file. Aliased as :source. + # Intentionally read lazily to suppress reading unused source code. + # + # source://simplecov//lib/simplecov/source_file.rb#43 + def source; end + + # Returns all source lines for this file as instances of SimpleCov::SourceFile::Line, + # and thus including coverage data. Aliased as :source_lines + # + # source://simplecov//lib/simplecov/source_file.rb#63 + def source_lines; end + + # The source code for this file. Aliased as :source. + # Intentionally read lazily to suppress reading unused source code. + # + # source://simplecov//lib/simplecov/source_file.rb#40 + def src; end + + # Return the relevant branches to source file + # + # source://simplecov//lib/simplecov/source_file.rb#132 + def total_branches; end +end + +# Representing single branch that has been detected in coverage report. +# Give us support methods that handle needed calculations. +# +# source://simplecov//lib/simplecov/source_file/branch.rb#8 +class SimpleCov::SourceFile::Branch + # @return [Branch] a new instance of Branch + # + # source://simplecov//lib/simplecov/source_file/branch.rb#11 + def initialize(start_line:, end_line:, coverage:, inline:, type:); end + + # Returns the value of attribute coverage. + # + # source://simplecov//lib/simplecov/source_file/branch.rb#9 + def coverage; end + + # Return true if there is relevant count defined > 0 + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/branch.rb#29 + def covered?; end + + # Returns the value of attribute end_line. + # + # source://simplecov//lib/simplecov/source_file/branch.rb#9 + def end_line; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/branch.rb#20 + def inline?; end + + # Check if branch missed or not + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/branch.rb#38 + def missed?; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/branch.rb#68 + def overlaps_with?(line_range); end + + # Return array with coverage count and badge + # + # @return [Array] + # + # source://simplecov//lib/simplecov/source_file/branch.rb#77 + def report; end + + # The line on which we want to report the coverage + # + # Usually we choose the line above the start of the branch (so that it shows up + # at if/else) because that + # * highlights the condition + # * makes it distinguishable if the first line of the branch is an inline branch + # (see the nested_branches fixture) + # + # source://simplecov//lib/simplecov/source_file/branch.rb#50 + def report_line; end + + # Flags the branch as skipped + # + # source://simplecov//lib/simplecov/source_file/branch.rb#59 + def skipped!; end + + # Returns true if the branch was marked skipped by virtue of nocov comments. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/branch.rb#64 + def skipped?; end + + # Returns the value of attribute start_line. + # + # source://simplecov//lib/simplecov/source_file/branch.rb#9 + def start_line; end + + # Returns the value of attribute type. + # + # source://simplecov//lib/simplecov/source_file/branch.rb#9 + def type; end +end + +# Builds the `SourceFile::Branch` objects for a source file from +# the raw branch data Ruby's Coverage library reports. Applies the +# `ignore_branches :eval_generated` / `:implicit_else` filters and +# marks branches inside `# simplecov:disable` / `# :nocov:` chunks +# as skipped. +# +# source://simplecov//lib/simplecov/source_file/branch_builder.rb#10 +class SimpleCov::SourceFile::BranchBuilder + # @return [BranchBuilder] a new instance of BranchBuilder + # + # source://simplecov//lib/simplecov/source_file/branch_builder.rb#11 + def initialize(source_file); end + + # source://simplecov//lib/simplecov/source_file/branch_builder.rb#15 + def call; end + + private + + # source://simplecov//lib/simplecov/source_file/branch_builder.rb#63 + def build_branch(branch_data, hit_count, condition_range); end + + # source://simplecov//lib/simplecov/source_file/branch_builder.rb#50 + def build_branches_from(condition, branches); end + + # Detect a Coverage-reported branch condition that originates from + # `eval`/`module_eval`/`class_eval`/`instance_eval` rather than from + # the file's literal source. Coverage attributes such branches to the + # caller's `__FILE__`/`__LINE__`, so a Rails `delegate :foo, to: :bar` + # call surfaces inside the source file as if there were branches at + # the `delegate` line. Prism never sees those branches in the static + # source, so a condition whose start_line isn't in the real-source + # branch set must be eval-generated. Only consulted when the user has + # opted in via `SimpleCov.ignore_branches :eval_generated`. See #1046. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/branch_builder.rb#37 + def eval_generated_condition_to_ignore?(condition); end + + # Detect synthetic `:else` branches that Ruby's Coverage library reports + # for constructs with no literal `else` keyword in source (`case/in` / + # `case/when` without else, `||=`, `&&=`, `if`/`unless` without else, + # and the postfix `return if cond` shape). The signal is structural: + # a synthetic else reuses its parent condition's *full source range* + # (start_line, start_col, end_line, end_col all identical), while an + # explicit `else` arm carries a narrower range — its own keyword/body + # position rather than the whole conditional. Comparing the full range + # (not just `start_line`) is what distinguishes a ternary's explicit + # else on the same line as the condition — `arg == 42 ? :yes : :no`, + # where the else's columns differ from the parent's — from a postfix + # `return if cond` where the synthetic else inherits the full range. + # Only consulted when the user has opted in via + # `SimpleCov.ignore_branches :implicit_else`. See #1033. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/branch_builder.rb#90 + def implicit_else_to_ignore?(type, branch_range, condition_range); end + + # source://simplecov//lib/simplecov/source_file/branch_builder.rb#97 + def process_skipped(branches); end +end + +# Helpers shared by the per-criterion builders (LineBuilder / +# BranchBuilder / MethodBuilder). Mixed into SourceFile so each +# builder can ask the file for its skip-chunk ranges and Prism-derived +# real source positions without duplicating the memoization. +# +# source://simplecov//lib/simplecov/source_file/builder_context.rb#9 +module SimpleCov::SourceFile::BuilderContext + # Memoized set of real source positions (branch start lines, method + # name+line pairs) extracted via Prism. Returns nil when Prism is + # unavailable or parsing fails, signaling callers to keep every + # Coverage entry (no false drops). The `defined?` guard preserves a + # nil memoization across calls. + # + # source://simplecov//lib/simplecov/source_file/builder_context.rb#21 + def real_source_positions; end + + # Skip-chunk lookup for the named criterion (`:line`, `:branch`, + # `:method`). + # + # source://simplecov//lib/simplecov/source_file/builder_context.rb#12 + def skip_chunks_for(criterion); end +end + +# Representation of a single line in a source file including +# this specific line's source code, line_number and code coverage, +# with the coverage being either nil (coverage not applicable, e.g. comment +# line), 0 (line not covered) or >1 (the amount of times the line was +# executed) +# +# source://simplecov//lib/simplecov/source_file/line.rb#10 +class SimpleCov::SourceFile::Line + # @raise [ArgumentError] + # @return [Line] a new instance of Line + # + # source://simplecov//lib/simplecov/source_file/line.rb#25 + def initialize(src, line_number, coverage); end + + # The coverage data for this line: either nil (never), 0 (missed) or >=1 (times covered) + # + # source://simplecov//lib/simplecov/source_file/line.rb#16 + def coverage; end + + # Returns true if this is a line that has been covered + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/line.rb#44 + def covered?; end + + # The line number in the source file. Aliased as :line, :number + # + # source://simplecov//lib/simplecov/source_file/line.rb#22 + def line; end + + # The line number in the source file. Aliased as :line, :number + # + # source://simplecov//lib/simplecov/source_file/line.rb#14 + def line_number; end + + # Returns true if this is a line that should have been covered, but was not + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/line.rb#39 + def missed?; end + + # Returns true if this line is not relevant for coverage + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/line.rb#49 + def never?; end + + # The line number in the source file. Aliased as :line, :number + # + # source://simplecov//lib/simplecov/source_file/line.rb#23 + def number; end + + # Whether this line was skipped + # + # source://simplecov//lib/simplecov/source_file/line.rb#18 + def skipped; end + + # Flags this line as skipped + # + # source://simplecov//lib/simplecov/source_file/line.rb#54 + def skipped!; end + + # Returns true if this line was skipped, false otherwise. Lines are skipped if they are wrapped with + # # :nocov: comment lines. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/line.rb#60 + def skipped?; end + + # The source code for this line. Aliased as :source + # Lets grab some fancy aliases, shall we? + # + # source://simplecov//lib/simplecov/source_file/line.rb#21 + def source; end + + # The source code for this line. Aliased as :source + # + # source://simplecov//lib/simplecov/source_file/line.rb#12 + def src; end + + # The status of this line - either covered, missed, skipped or never. Useful i.e. for direct use + # as a css class in report generation + # + # source://simplecov//lib/simplecov/source_file/line.rb#66 + def status; end +end + +# Builds the `SourceFile::Line` objects for a source file from the +# raw line-coverage array. Each line carries its source text, its +# 1-based line number, and the Coverage hit count (or nil for +# never-counted lines). Applies `# simplecov:disable` / +# `# :nocov:` block ranges via `skipped!`. +# +# source://simplecov//lib/simplecov/source_file/line_builder.rb#10 +class SimpleCov::SourceFile::LineBuilder + # @return [LineBuilder] a new instance of LineBuilder + # + # source://simplecov//lib/simplecov/source_file/line_builder.rb#11 + def initialize(source_file); end + + # source://simplecov//lib/simplecov/source_file/line_builder.rb#15 + def call; end + + private + + # When `:line` coverage is disabled, the Ruby Coverage module + # doesn't emit "lines" data, so look up `nil` (never-counted) for + # every position. The source rows are still useful — e.g. for the + # HTML report's source view — even without per-line hits. + # + # source://simplecov//lib/simplecov/source_file/line_builder.rb#27 + def build_lines; end + + # The array the lines are kept in is 0-based whereas the line + # numbers in the chunks are 1-based (more understandable elsewhere), + # so each range needs to be shifted down by one to slice into the + # `lines` array. + # + # source://simplecov//lib/simplecov/source_file/line_builder.rb#38 + def mark_skipped(lines, chunks); end +end + +# Represents a single method detected in coverage data. +# Provides helpers similar to Branch for coverage status. +# +# source://simplecov//lib/simplecov/source_file/method.rb#7 +class SimpleCov::SourceFile::Method + # @return [Method] a new instance of Method + # + # source://simplecov//lib/simplecov/source_file/method.rb#11 + def initialize(source_file, info, coverage); end + + # Returns the value of attribute class_name. + # + # source://simplecov//lib/simplecov/source_file/method.rb#8 + def class_name; end + + # Returns the value of attribute coverage. + # + # source://simplecov//lib/simplecov/source_file/method.rb#8 + def coverage; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/method.rb#17 + def covered?; end + + # Returns the value of attribute end_col. + # + # source://simplecov//lib/simplecov/source_file/method.rb#8 + def end_col; end + + # Returns the value of attribute end_line. + # + # source://simplecov//lib/simplecov/source_file/method.rb#8 + def end_line; end + + # source://simplecov//lib/simplecov/source_file/method.rb#42 + def lines; end + + # Returns the value of attribute method_name. + # + # source://simplecov//lib/simplecov/source_file/method.rb#8 + def method_name; end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/method.rb#38 + def missed?; end + + # Whether this method's source range intersects the given inclusive line range. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/method.rb#47 + def overlaps_with?(line_range); end + + # Flag the method as skipped directly, without going through its lines. + # + # source://simplecov//lib/simplecov/source_file/method.rb#34 + def skipped!; end + + # Criterion-level skips (nocov chunks, `# simplecov:disable` / + # `# simplecov:disable method` regions) arrive via `skipped!` from + # MethodBuilder. Deliberately NOT derived from the lines' skip + # state: a line-only directive around a def must not remove the + # method from method totals. Without line info there is nothing to + # report against, so such a method stays skipped. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/method.rb#27 + def skipped?; end + + # Returns the value of attribute source_file. + # + # source://simplecov//lib/simplecov/source_file/method.rb#8 + def source_file; end + + # Returns the value of attribute start_col. + # + # source://simplecov//lib/simplecov/source_file/method.rb#8 + def start_col; end + + # Returns the value of attribute start_line. + # + # source://simplecov//lib/simplecov/source_file/method.rb#8 + def start_line; end + + # source://simplecov//lib/simplecov/source_file/method.rb#53 + def to_s; end +end + +# Builds the `SourceFile::Method` objects for a source file from +# the raw method-coverage data. Applies the +# `ignore_methods :eval_generated` filter and marks methods inside +# `# simplecov:disable` / `# :nocov:` chunks as skipped. +# +# source://simplecov//lib/simplecov/source_file/method_builder.rb#9 +class SimpleCov::SourceFile::MethodBuilder + # @return [MethodBuilder] a new instance of MethodBuilder + # + # source://simplecov//lib/simplecov/source_file/method_builder.rb#10 + def initialize(source_file); end + + # source://simplecov//lib/simplecov/source_file/method_builder.rb#14 + def call; end + + private + + # See `BranchBuilder#eval_generated_condition_to_ignore?` for the + # rationale. Coverage reports an eval'd `def` at the eval caller's + # line and name, so a method whose `(name, start_line)` is absent + # from the real-source `def` set is eval-generated. Only consulted + # when the user has opted in via + # `SimpleCov.ignore_methods :eval_generated`. See #1046. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/method_builder.rb#35 + def eval_generated_to_ignore?(info); end + + # source://simplecov//lib/simplecov/source_file/method_builder.rb#48 + def process_skipped(methods); end +end + +# `Coverage.result` reports condition and method keys as Ruby +# arrays. When the resultset is round-tripped through JSON those +# array keys become their stringified inspect form, so this parser +# walks the literal back into a real Array without using `eval` (see +# #801). The grammar covers symbols, strings, integers, unary minus, +# and constant paths — every shape Coverage ever emits. +# +# source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#13 +module SimpleCov::SourceFile::RubyDataParser + private + + # Tests use the real data structures (except for integration tests) + # so no need to put them through here. + # + # String parses are memoized: `Combine::BranchesCombiner` and + # `Combine::MethodsCombiner` derive a merge identity from every key of + # both sides on every pairwise merge, so collating N resultsets parses + # each key string N-1 times — and Ripper dominates the wall time of a + # large collate. + # Key strings repeat across folds and within report building, while the + # set of unique keys is bounded by the project's branch and method + # count, so a permanent cache stays small. Cached arrays are frozen + # element-wise (String class names included, not just the tuple): + # every caller destructures without mutating, and sharing one array + # across callers must stay that way. The cache key needs no such + # care — `Hash#[]=` dups and freezes String keys on its own. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#31 + def call(structure); end + + # Parse a string like '[:if, 0, 3, 4, 3, 21]' or + # '["ClassName", :method1, 2, 2, 5, 5]' back into a Ruby array. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#44 + def parse_array_string(str); end + + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#38 + def parse_cache; end + + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#58 + def parse_element(node); end + + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#72 + def parse_integer_node(node); end + + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#76 + def parse_symbol_node(node); end + + # Method coverage keys can contain inspect-format class references + # like `#<Class:Foo>` or `#<Class:0x...>`, which aren't valid Ruby + # syntax. Wrap them in quotes so Ripper can parse the surrounding + # array literal; downstream we treat them as opaque strings. The + # pattern recurses because singleton methods on instances nest one + # inspect segment inside another (`#<Class:#<Object:0x...>>`), and + # stopping at the first `>` leaves a dangling `>` that fails both + # Ripper passes and crashes the merge. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#105 + def quote_inspected_class_segments(str); end + + # Concatenate the text fragments of a `:string_content` node. Ripper + # may emit zero, one, or many `:@tstring_content` children depending + # on the literal. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#87 + def string_literal_text(string_content); end + + # Undo the same backslash-prefix escapes the previous hand-rolled + # parser undid: `\X` → `X` for any X. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#93 + def unescape_ruby(raw); end + + class << self + # Tests use the real data structures (except for integration tests) + # so no need to put them through here. + # + # String parses are memoized: `Combine::BranchesCombiner` and + # `Combine::MethodsCombiner` derive a merge identity from every key of + # both sides on every pairwise merge, so collating N resultsets parses + # each key string N-1 times — and Ripper dominates the wall time of a + # large collate. + # Key strings repeat across folds and within report building, while the + # set of unique keys is bounded by the project's branch and method + # count, so a permanent cache stays small. Cached arrays are frozen + # element-wise (String class names included, not just the tuple): + # every caller destructures without mutating, and sharing one array + # across callers must stay that way. The cache key needs no such + # care — `Hash#[]=` dups and freezes String keys on its own. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#31 + def call(structure); end + + # Parse a string like '[:if, 0, 3, 4, 3, 21]' or + # '["ClassName", :method1, 2, 2, 5, 5]' back into a Ruby array. + # + # @raise [ArgumentError] + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#44 + def parse_array_string(str); end + + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#38 + def parse_cache; end + + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#58 + def parse_element(node); end + + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#72 + def parse_integer_node(node); end + + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#76 + def parse_symbol_node(node); end + + # Method coverage keys can contain inspect-format class references + # like `#<Class:Foo>` or `#<Class:0x...>`, which aren't valid Ruby + # syntax. Wrap them in quotes so Ripper can parse the surrounding + # array literal; downstream we treat them as opaque strings. The + # pattern recurses because singleton methods on instances nest one + # inspect segment inside another (`#<Class:#<Object:0x...>>`), and + # stopping at the first `>` leaves a dangling `>` that fails both + # Ripper passes and crashes the merge. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#105 + def quote_inspected_class_segments(str); end + + # Concatenate the text fragments of a `:string_content` node. Ripper + # may emit zero, one, or many `:@tstring_content` children depending + # on the literal. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#87 + def string_literal_text(string_content); end + + # Undo the same backslash-prefix escapes the previous hand-rolled + # parser undid: `\X` → `X` for any X. + # + # source://simplecov//lib/simplecov/source_file/ruby_data_parser.rb#93 + def unescape_ruby(raw); end + end +end + +# Computes the set of line ranges that should be excluded from a +# SourceFile's coverage for each criterion. Two sources contribute: +# +# * The deprecated `# :nocov:` block toggle (lines wrapped between +# even-numbered pairs of nocov markers are excluded from line and +# branch coverage). +# * `# simplecov:disable` / `# simplecov:enable` block directives, +# which can be scoped per-criterion (`# simplecov:disable branch`, +# etc.) — see `SimpleCov::Directive`. +# +# source://simplecov//lib/simplecov/source_file/skip_chunks.rb#14 +class SimpleCov::SourceFile::SkipChunks + # @return [SkipChunks] a new instance of SkipChunks + # + # source://simplecov//lib/simplecov/source_file/skip_chunks.rb#20 + def initialize(filename, src); end + + # source://simplecov//lib/simplecov/source_file/skip_chunks.rb#39 + def directive_chunks; end + + # Every criterion honors the deprecated all-criteria `# :nocov:` + # chunks plus its own per-criterion directive ranges. Methods + # included: they carry a source range, and nocov has always meant + # "exclude everything here". + # + # source://simplecov//lib/simplecov/source_file/skip_chunks.rb#29 + def for(criterion); end + + # Ranges of 1-based line numbers (see `with_index(1)` below); + # consumers subtract 1 to index into the zero-based lines array. + # + # source://simplecov//lib/simplecov/source_file/skip_chunks.rb#35 + def nocov_chunks; end + + private + + # source://simplecov//lib/simplecov/source_file/skip_chunks.rb#45 + def build_nocov_chunks; end + + # Emit a one-time-per-file deprecation warning pointing the user + # at the `# simplecov:disable` / `# simplecov:enable` replacement. + # + # source://simplecov//lib/simplecov/source_file/skip_chunks.rb#63 + def warn_nocov_deprecation(first_line_number); end + + class << self + # Returns the value of attribute nocov_warned. + # + # source://simplecov//lib/simplecov/source_file/skip_chunks.rb#17 + def nocov_warned; end + end +end + +# Reads a source file into an array of lines, honoring the source's +# shebang and `coding:` magic comment when present. Always +# transcodes to UTF-8 with invalid/undef bytes replaced — both for +# JRuby compatibility and to keep encoding shenanigans in one place +# (see #866). +# +# source://simplecov//lib/simplecov/source_file/source_loader.rb#10 +module SimpleCov::SourceFile::SourceLoader + private + + # source://simplecov//lib/simplecov/source_file/source_loader.rb#16 + def call(filename); end + + # Guarantee every line leaves the loader as valid UTF-8, replacing + # what can't be represented: transcode non-UTF-8 lines (setting + # invalid/undef options on `file.set_encoding` doesn't work + # properly, and this also works around a JRuby incompatibility) + # and scrub UTF-8-tagged lines that carry invalid bytes, which + # would otherwise raise from every regex the classifier runs. + # + # source://simplecov//lib/simplecov/source_file/source_loader.rb#66 + def ensure_remove_undefs(file_lines); end + + # source://simplecov//lib/simplecov/source_file/source_loader.rb#45 + def read_lines(file, lines, current_line); end + + # A line read as UTF-8 can still carry invalid bytes (a Latin-1 + # source file without a magic comment, say). Replace them before + # any regex sees the line: the shebang and magic-comment checks + # would otherwise raise ArgumentError and take the report down. + # + # source://simplecov//lib/simplecov/source_file/source_loader.rb#35 + def scrub_invalid(line); end + + # Encoding magic comment must be placed at first line except for + # shebang. + # + # source://simplecov//lib/simplecov/source_file/source_loader.rb#54 + def set_encoding_based_on_magic_comment(file, line); end + + # source://simplecov//lib/simplecov/source_file/source_loader.rb#41 + def shebang?(line); end + + class << self + # source://simplecov//lib/simplecov/source_file/source_loader.rb#16 + def call(filename); end + + # Guarantee every line leaves the loader as valid UTF-8, replacing + # what can't be represented: transcode non-UTF-8 lines (setting + # invalid/undef options on `file.set_encoding` doesn't work + # properly, and this also works around a JRuby incompatibility) + # and scrub UTF-8-tagged lines that carry invalid bytes, which + # would otherwise raise from every regex the classifier runs. + # + # source://simplecov//lib/simplecov/source_file/source_loader.rb#66 + def ensure_remove_undefs(file_lines); end + + # source://simplecov//lib/simplecov/source_file/source_loader.rb#45 + def read_lines(file, lines, current_line); end + + # A line read as UTF-8 can still carry invalid bytes (a Latin-1 + # source file without a magic comment, say). Replace them before + # any regex sees the line: the shebang and magic-comment checks + # would otherwise raise ArgumentError and take the report down. + # + # source://simplecov//lib/simplecov/source_file/source_loader.rb#35 + def scrub_invalid(line); end + + # Encoding magic comment must be placed at first line except for + # shebang. + # + # source://simplecov//lib/simplecov/source_file/source_loader.rb#54 + def set_encoding_based_on_magic_comment(file, line); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/source_file/source_loader.rb#41 + def shebang?(line); end + end +end + +# source://simplecov//lib/simplecov/source_file/source_loader.rb#12 +SimpleCov::SourceFile::SourceLoader::RUBY_FILE_ENCODING_MAGIC_COMMENT_REGEX = T.let(T.unsafe(nil), Regexp) + +# source://simplecov//lib/simplecov/source_file/source_loader.rb#11 +SimpleCov::SourceFile::SourceLoader::SHEBANG_REGEX = T.let(T.unsafe(nil), Regexp) + +# Builds the `CoverageStatistics` triple (`:line`, `:branch`, `:method`) +# for a SourceFile, regardless of which criteria were actually enabled +# during the run — disabled or empty criteria collapse to 0/0/0 so +# downstream consumers don't have to special-case enable-state. +# +# source://simplecov//lib/simplecov/source_file/statistics.rb#9 +class SimpleCov::SourceFile::Statistics + # @return [Statistics] a new instance of Statistics + # + # source://simplecov//lib/simplecov/source_file/statistics.rb#10 + def initialize(source_file); end + + # source://simplecov//lib/simplecov/source_file/statistics.rb#14 + def call; end + + private + + # Files added via track_files but never loaded have no branch/method + # data. Report 0% instead of the empty-set default of 100% (see #902). + # + # source://simplecov//lib/simplecov/source_file/statistics.rb#36 + def branch_statistics; end + + # source://simplecov//lib/simplecov/source_file/statistics.rb#54 + def coverage_statistics(covered, missed, omitted: T.unsafe(nil), percent: T.unsafe(nil)); end + + # source://simplecov//lib/simplecov/source_file/statistics.rb#24 + def line_statistics; end + + # source://simplecov//lib/simplecov/source_file/statistics.rb#45 + def method_statistics; end +end + +# Static enumeration of the branches and methods Ruby's `Coverage` library +# WOULD have reported if a file had been loaded with `branches: true` / +# `methods: true`. Used by `SimulateCoverage` to backfill data for files +# added via `cover` / `track_files` that were never `require`'d during the +# run — so unloaded files contribute to the branch/method denominators +# symmetrically with their line coverage, instead of vanishing from the +# totals (see #1059). +# +# Implementation uses Prism (stdlib in Ruby 3.3+, gem on older Rubies). +# When Prism isn't available, `available?` returns false and SimulateCoverage +# falls back to the previous behavior — older Rubies keep working, just +# without the synthesized data. +# +# The emitted shape mirrors `Coverage.result[path]` for the same file: +# branches are nested as `{condition_tuple => {arm_tuple => 0, ...}}` and +# methods as `{["ClassName", :name, lines/cols] => 0}`. Position info +# comes from Prism's reported source locations; it doesn't always match +# `Coverage`'s byte-for-byte (the two parsers report slightly different +# column conventions for some constructs), but lines are reliable and +# downstream consumers that key off line numbers (the HTML formatter, +# SonarQube, etc.) see the data they expect. +# +# source://simplecov//lib/simplecov/static_coverage_extractor.rb#33 +module SimpleCov::StaticCoverageExtractor + private + + # simplecov:disable branch + # The Prism-unavailable arm of this ternary is unreachable when Prism + # itself IS loadable — i.e., on every engine that exercises the dogfood + # report. Asserted-on by callers; tested indirectly via the + # `available?`-returns-false fallback path in SimulateCoverage's spec. + # + # source://simplecov//lib/simplecov/static_coverage_extractor.rb#41 + def available?; end + + # Parse `source` (a string of Ruby) and return a hash of the form + # `{"branches" => {...}, "methods" => {...}}` matching the shape that + # `Coverage.result[path]` produces. Returns nil on parse failure or + # when Prism isn't available; callers should treat that as "couldn't + # extract — fall back to empty hashes." + # + # source://simplecov//lib/simplecov/static_coverage_extractor.rb#51 + def call(source); end + + # Summarize a source file's REAL branch and method positions, for the + # `:eval_generated` filter (SimpleCov.ignore_branches / + # SimpleCov.ignore_methods, #1046). Returns a hash: + # + # { + # branches: Set[start_line, ...], # e.g., [3, 12, 20] + # methods: Set[[name, start_line], ...] # e.g., [[:foo, 7], [:bar, 13]] + # } + # + # Branch matching is start_line-only rather than by the full tuple. + # Static extraction and Coverage can still disagree on a branch's exact + # column positions (and, for some constructs, its type), so matching on + # start_line alone is the conservative choice that tolerates those + # differences. Coincidental line-sharing between a real branch and an + # eval-generated one will keep both, which is an acceptable + # false-negative for an opt-in filter. Method matching uses + # (name, start_line) since a method name is unique at any line. + # + # Returns nil when Prism is unavailable or parsing fails, signaling + # callers to keep every Coverage entry (no false drops). + # + # source://simplecov//lib/simplecov/static_coverage_extractor.rb#93 + def real_source_positions(source); end + + class << self + # simplecov:disable branch + # The Prism-unavailable arm of this ternary is unreachable when Prism + # itself IS loadable — i.e., on every engine that exercises the dogfood + # report. Asserted-on by callers; tested indirectly via the + # `available?`-returns-false fallback path in SimulateCoverage's spec. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor.rb#41 + def available?; end + + # Parse `source` (a string of Ruby) and return a hash of the form + # `{"branches" => {...}, "methods" => {...}}` matching the shape that + # `Coverage.result[path]` produces. Returns nil on parse failure or + # when Prism isn't available; callers should treat that as "couldn't + # extract — fall back to empty hashes." + # + # source://simplecov//lib/simplecov/static_coverage_extractor.rb#51 + def call(source); end + + # Summarize a source file's REAL branch and method positions, for the + # `:eval_generated` filter (SimpleCov.ignore_branches / + # SimpleCov.ignore_methods, #1046). Returns a hash: + # + # { + # branches: Set[start_line, ...], # e.g., [3, 12, 20] + # methods: Set[[name, start_line], ...] # e.g., [[:foo, 7], [:bar, 13]] + # } + # + # Branch matching is start_line-only rather than by the full tuple. + # Static extraction and Coverage can still disagree on a branch's exact + # column positions (and, for some constructs, its type), so matching on + # start_line alone is the conservative choice that tolerates those + # differences. Coincidental line-sharing between a real branch and an + # eval-generated one will keep both, which is an acceptable + # false-negative for an opt-in filter. Method matching uses + # (name, start_line) since a method name is unique at any line. + # + # Returns nil when Prism is unavailable or parsing fails, signaling + # callers to keep every Coverage entry (no false drops). + # + # source://simplecov//lib/simplecov/static_coverage_extractor.rb#93 + def real_source_positions(source); end + end +end + +# Detects the `if` / `unless` / ternary conditions CRuby folds away. +# When a condition is a statically-known-truthy/falsy literal the +# compiler eliminates the dead arm and Coverage emits NO branch, so the +# extractor must not synthesize one either — otherwise the arm is a +# phantom that no loaded run can ever hit, the same unmergeable-tuple +# failure mode as #1226 / #1233. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#11 +module SimpleCov::StaticCoverageExtractor::ConditionFolding + private + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#232 + def container_contents_eliminable?(node); end + + # Whether the compiler compiles `node` to nothing when its value is + # discarded. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#181 + def eliminable_when_discarded?(node); end + + # A folding literal, minus the ones parentheses shield from the + # fold (`unwrapped` differing from `node` is what says parentheses + # were seen through). + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#146 + def foldable?(node, unwrapped); end + + # The compiler's verdict on a condition: `:truthy` or `:falsy` when + # it folds, nil when it doesn't. The verdict decides which arm + # survives — the compiler eliminates the other arm's entire subtree, + # so everything inside it (nested branches, methods) must go + # unvisited too, not just the folded condition's own tuple. + # + # Parentheses are transparent to the fold for most literals + # (`if (1)` folds like `if 1`) but not all — see + # PAREN_OPAQUE_TYPES. Compound forms (`!true`, `true || x`) are + # deliberately not folded: `!` never folds, and `||` / `&&` + # constant-propagation diverges across Ruby versions, so matching + # it would trade a rare, version-specific gain for real risk. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#136 + def folded_condition(node); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#217 + def static_array_literal?(node); end + + # The container dispatch, version-free: the predicate specs + # exercise it directly on every supported Ruby, 3.2 included. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#208 + def static_container?(node); end + + # A scalar literal leaf, or an Array/Hash/Range whose contents pass + # the running compiler's contents rule (see + # CONTAINER_CONTENTS_NEED_STATIC_LITERALS). Container elimination + # only exists from 3.3 on. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#194 + def static_container_literal?(node); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#221 + def static_hash_literal?(node); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#228 + def static_range_literal?(node); end + + # Parentheses are transparent to the fold, and a multi-statement + # body (`if (1; 2)`) folds by its LAST expression — but only when + # the compiler eliminates every leading statement. Stopping at + # multi-statement bodies synthesized a phantom then/else pair no + # real run can ever hit. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#164 + def unwrap_parentheses(node); end + + # simplecov:disable — 3.2-only; unreachable on the modern dogfood Ruby + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#110 + def visit_dead_arm(arm); end + + # A truthy verdict keeps the first arm live, a falsy one the + # second. `visit` is nil-safe, so a missing arm just visits + # nothing. On 3.2 the dead arm's branch table entries survive the + # fold (parse.y instrumented branches before eliminating dead + # code, even inside dead `def` bodies and lambdas) while its + # methods never register, so the dead arm is visited with method + # collection suppressed. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#101 + def visit_folded_arms(verdict, truthy_arm, falsy_arm); end +end + +# Container literals in discarded position are eliminated from 3.3 +# on, but the contents rule differs: 3.3's compile.c elides a +# container whose contents are merely effect-free (`[x]`, `[self]`), +# while the Prism compiler (3.4+) demands fully static literals +# (`[1]` goes, `[x]` stays). See `static_container_literal?`. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#28 +SimpleCov::StaticCoverageExtractor::ConditionFolding::CONTAINER_CONTENTS_NEED_STATIC_LITERALS = T.let(T.unsafe(nil), TrueClass) + +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#22 +SimpleCov::StaticCoverageExtractor::ConditionFolding::DEAD_ARM_BRANCHES_SURVIVE = T.let(T.unsafe(nil), FalseClass) + +# simplecov:disable branch — which arm runs is fixed by the running Ruby's version +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#86 +SimpleCov::StaticCoverageExtractor::ConditionFolding::ELIMINABLE_READ_TYPES = T.let(T.unsafe(nil), Array) + +# The literals whose fold eliminates the *then* side: `if false` / +# `if nil` keep only the else arm. Every other folded literal is +# truthy and keeps only the then arm. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#51 +SimpleCov::StaticCoverageExtractor::ConditionFolding::FALSY_CONDITION_TYPES = T.let(T.unsafe(nil), Array) + +# CRuby 3.4 rebuilt the fold on the Prism compiler, and the +# parse.y-based fold it replaced differed in three observable ways, +# each pinned by the runtime tuple equivalence battery on CI: +# `__FILE__` folded on 3.2/3.3 but no longer does; parentheses were +# transparent for every literal on 3.2 (opacity starts at 3.3); and +# on 3.2 the dead arm's branch table entries survive the fold — +# parse.y instrumented branches before eliminating dead code — while +# its `def`s still never register. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#20 +SimpleCov::StaticCoverageExtractor::ConditionFolding::FOLDS_SOURCE_FILE = T.let(T.unsafe(nil), FalseClass) + +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#21 +SimpleCov::StaticCoverageExtractor::ConditionFolding::PARENS_ALWAYS_TRANSPARENT = T.let(T.unsafe(nil), FalseClass) + +# The literals whose fold does NOT see through parentheses: CRuby +# folds `if nil`, `if "x"`, and `if -> {}` but keeps a real branch +# for `if (nil)`, `if ("x")`, and `if (-> {})` — verified against +# Coverage, and pinned by the runtime tuple equivalence battery — +# while every other literal folds parenthesized or not. `__FILE__` +# is opaque like other strings on the Rubies that fold it at all. +# Consulted only when PARENS_ALWAYS_TRANSPARENT is false. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#60 +SimpleCov::StaticCoverageExtractor::ConditionFolding::PAREN_OPAQUE_TYPES = T.let(T.unsafe(nil), Array) + +# Non-literal reads that are also eliminated when discarded. +# `self` is eliminated by every supported compiler; local/ivar/ +# defined? elimination arrived with the Prism-era compilers. +# Anything that can raise or run hooks (constants, globals, calls, +# writes) is never eliminated and keeps the branch real. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#81 +SimpleCov::StaticCoverageExtractor::ConditionFolding::PRISM_ERA_ELIMINABLE_READS = T.let(T.unsafe(nil), Array) + +# Prism node types for the literals that fold. `while` / `until` do +# NOT fold (`while true` is a real branch), so only the if-like +# visitors consult this. Regexp and Range literals are excluded on +# purpose: as conditions they mean `=~ $_` / flip-flop, which +# Coverage does branch on. `[]`, `{}`, and interpolated strings do +# not fold either, and `->` folds while a `lambda` call does not — +# the compiler only folds what it can prove at compile time, and a +# method named `lambda` proves nothing. +# simplecov:disable branch — which arm runs is fixed by the running Ruby's version +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#39 +SimpleCov::StaticCoverageExtractor::ConditionFolding::STATIC_CONDITION_TYPES = T.let(T.unsafe(nil), Array) + +# The scalar literals the compiler treats as fully static: a +# multi-statement paren condition (`if (1; 2)`) folds by its last +# expression only when every leading statement is eliminated when +# discarded, and these — bare or composing an Array/Hash/Range — +# always are. Pinned against real Coverage on 3.2 through 4.0. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/condition_folding.rb#69 +SimpleCov::StaticCoverageExtractor::ConditionFolding::STATIC_LITERAL_LEAF_TYPES = T.let(T.unsafe(nil), Array) + +# The source ranges Ruby's Coverage assigns to branch conditions and +# arms, resolved from Prism nodes. Simulated entries only ever merge +# with real entries produced by the running Ruby, and CRuby 3.4 +# changed several of these conventions, so every resolver here emits +# whichever shape this Ruby's Coverage uses. See issues #1226 / #1233. +# +# per-construct, per-Ruby-version Coverage location conventions; +# splitting it would scatter closely-related resolvers. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#16 +module SimpleCov::StaticCoverageExtractor::LocationConventions + private + + # `begin ... end while/until cond` (the do-while form) parses as a + # while/until whose sole statement is the BeginNode. Modern Coverage + # attributes the body to that whole `begin ... end` span (which the + # generic `node.statements.location` already yields), but 3.3 uses + # the begin's inner statements instead — or a point at the end of + # the `begin` keyword when the body is empty. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#175 + def begin_modifier_loop?(node); end + + # Arm location for a when/in clause: its body statements, or — when + # the body is empty — the clause's own range on modern Rubies, a + # point at the pattern's end for a legacy `in`, and for a legacy + # `when` a point at the clause's end in void position or the tail + # convention (keyword through the case's remaining content) in value. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#109 + def case_arm_location(case_node, when_node, when_type); end + + # Resolve the source range Coverage attributes to a synthetic-or-real + # `:else` arm of a case construct: the body of an explicit else, + # the case's full range when no else is present, and — for an + # explicit else with an empty body — the else..end span on modern + # Rubies or the case's full range on legacy ones. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#146 + def else_arm_location(node); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#42 + def elsif_node?(node); end + + # Whether an empty then arm collapses to a point at the predicate's + # end. Modern Coverage does this for every `if` (but not `unless`); + # legacy Coverage does it only in void position, for both. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#216 + def empty_arm_collapses?(node, type); end + + # Location of an empty explicit `else`: a modern `if` uses the + # else..end span; a legacy Ruby in void position collapses to a point + # at the `else` keyword's end; otherwise (legacy value position, or + # `unless`) it uses the condition's range. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#97 + def empty_else_location(node, sub, type); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#129 + def following_case_content(case_node, when_node); end + + # Resolve the source range Coverage attributes to a real-or-synthetic + # `:else` arm of an if-like construct (`PrismCompat` hides the + # per-Prism-version accessor split). When no else/elsif is present, + # the synthesized else inherits the condition's range (matches + # Coverage's convention). + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#81 + def if_like_else_location(node, type); end + + # The range Coverage assigns to an if-like node itself. Modern + # CRuby uses the node's full source range for every form; 3.2/3.3 + # end an `elsif` clause's range at its last content instead of the + # shared `end` keyword the clause doesn't own. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#36 + def if_like_location(node, type); end + + # Location of the then arm. Coverage uses the body statements' + # range; with an empty then body the arm collapses to a zero-width + # point at the predicate's end — always on a modern `if`, and on + # legacy Rubies only when the construct is in void position (a + # trailing statement discards its value). In value (tail) position, + # legacy Rubies and `unless` fall back to the node's range. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#69 + def if_like_then_location(node, type); end + + # The last body content in the case after `when_node`, falling + # back to the clause's final condition value. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#124 + def legacy_case_tail_end(case_node, when_node); end + + # Where an if/elsif chain's content ends, for the legacy range + # convention: the deepest trailing clause's statements, or that + # clause's predicate / `else` keyword when its body is empty. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#50 + def legacy_content_end(node); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#179 + def legacy_do_while_body_location(node); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#118 + def legacy_when_value_location(case_node, when_node); end + + # An empty loop body falls back to the loop's range on modern + # Rubies and collapses to a point at the predicate's end on legacy + # ones. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#161 + def loop_body_location(node); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#206 + def point_at_end(location); end + + # Coverage's safe-navigation branch spans the receiver through the + # end of the call's arguments (or just the message when there are + # none), but never includes a trailing block: `x&.foo { ... }` and + # `x&.foo(1) { ... }` both end exactly where `x&.foo` / `x&.foo(1)` + # would without the block. `node.location` includes an attached + # block, so build the end position from `closing_loc` (closing + # paren) / `arguments` (paren-less args) / `message_loc` instead. + # This convention is the same on legacy and modern Rubies. See + # issue #1233. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#194 + def safe_navigation_location(node); end + + # The range from `from`'s start through `to`'s end. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#199 + def span(from, to); end + + # Whether `node` sits in value (method-return) position, which on + # legacy Rubies keeps an empty arm's range instead of collapsing it + # to a point. `@value_positions` is computed once per parse by + # ValuePositions (only on legacy; nil elsewhere, which reads as + # "value" — the safe, pre-audit default). + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#227 + def value_position?(node); end +end + +# source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#17 +SimpleCov::StaticCoverageExtractor::LocationConventions::LEGACY_COVERAGE_LOCATIONS = T.let(T.unsafe(nil), FalseClass) + +# A zero-width stand-in for Prism locations, for the arms Coverage +# anchors to a point rather than a range. +class SimpleCov::StaticCoverageExtractor::LocationConventions::PointLocation < ::Data + # Returns the value of attribute end_column + # + # @return [Object] the current value of end_column + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#21 + def end_column; end + + # Returns the value of attribute end_line + # + # @return [Object] the current value of end_line + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#21 + def end_line; end + + # Returns the value of attribute start_column + # + # @return [Object] the current value of start_column + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#21 + def start_column; end + + # Returns the value of attribute start_line + # + # @return [Object] the current value of start_line + # + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#21 + def start_line; end + + class << self + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#21 + def [](*_arg0); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#21 + def inspect; end + + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#21 + def members; end + + # source://simplecov//lib/simplecov/static_coverage_extractor/location_conventions.rb#21 + def new(*_arg0); end + end +end + +# Visitor mixin that collects method tuples and tracks the lexical +# class / module nesting that names them, in the shape Ruby's +# `Coverage` reports methods. Mixed into `Visitor`, it shares that +# visitor's `@methods` / `@class_stack` state and keeps the +# method-collection concern separate from branch extraction. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/method_collector.rb#10 +module SimpleCov::StaticCoverageExtractor::MethodCollector + # Track class/module nesting so method tuples carry the lexical + # class name. Module + Class are both treated as namespaces here + # since `Coverage` reports both as the constant. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/method_collector.rb#14 + def visit_class_node(node); end + + # `def name(...)` and `def self.name(...)` both produce DefNode. + # The class context is the surrounding lexical class/module (or + # `Object` at the top level, matching `Coverage`'s convention). + # Suppression covers 3.2's folded dead arms, where nested branches + # stay instrumented but a `def` never registers (see + # Visitor#visit_dead_arm). + # + # source://simplecov//lib/simplecov/static_coverage_extractor/method_collector.rb#28 + def visit_def_node(node); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/method_collector.rb#18 + def visit_module_node(node); end + + private + + # Render a constant path (e.g., `Foo::Bar`) as its source-form + # string. Defensive nil / to_s fallbacks: ClassNode and ModuleNode + # always carry a constant_path in practice. + # simplecov:disable + # + # source://simplecov//lib/simplecov/static_coverage_extractor/method_collector.rb#46 + def constant_name(node); end + + # simplecov:enable + # + # source://simplecov//lib/simplecov/static_coverage_extractor/method_collector.rb#54 + def with_class(name); end +end + +# The Prism 1.3 (Dec 2024) accessor renames for the trailing clause of +# conditional nodes, resolved ONCE at load so the per-node hot paths +# stay branch-free. Ruby 3.3's stdlib Prism (0.19) predates the +# renames; 3.4+ and any installed prism gem post-dates them. Reaching +# for the modern name on 0.19 raised NoMethodError inside the +# extractor — `call` swallowed it and the whole file silently fell +# back to no simulated data (see the 1.0.2 audit). +# +# This lives in its own file, required by every consumer, because the +# constants are referenced from several extractor files and defining +# them after those files were loaded worked only while the references +# happened at call time — a load-order trap for the next editor. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/prism_compat.rb#17 +module SimpleCov::StaticCoverageExtractor::PrismCompat + private + + # source://simplecov//lib/simplecov/static_coverage_extractor/prism_compat.rb#50 + def else_clause(node); end + + # The `else`/`elsif` clause of an if-like node (an ElseNode, or a + # nested IfNode for `elsif`), or the `else` clause of anything else + # that has one, under whichever accessor this Prism exposes. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/prism_compat.rb#46 + def subsequent(node); end + + class << self + # source://simplecov//lib/simplecov/static_coverage_extractor/prism_compat.rb#50 + def else_clause(node); end + + # The `else`/`elsif` clause of an if-like node (an ElseNode, or a + # nested IfNode for `elsif`), or the `else` clause of anything else + # that has one, under whichever accessor this Prism exposes. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/prism_compat.rb#46 + def subsequent(node); end + end +end + +# The same rename hit the `else` accessor on `UnlessNode`, +# `CaseNode`, and `CaseMatchNode` (all three: `consequent` -> +# `else_clause`). All three renamed together, so one constant +# (probed off CaseNode) covers them. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/prism_compat.rb#35 +SimpleCov::StaticCoverageExtractor::PrismCompat::ELSE_CLAUSE_METHOD = T.let(T.unsafe(nil), Symbol) + +# `Prism::IfNode#subsequent` was renamed from `consequent`. The +# not-taken arm on whichever Prism version we're on can't be +# exercised by our own dogfood (we only run on one Prism at a time). +# simplecov:disable +# +# source://simplecov//lib/simplecov/static_coverage_extractor/prism_compat.rb#24 +SimpleCov::StaticCoverageExtractor::PrismCompat::IF_NODE_SUBSEQUENT_METHOD = T.let(T.unsafe(nil), Symbol) + +# Ruby 3.3 value-position analysis for the extractor's legacy branch +# conventions (see LocationConventions and the #1233 audit). +# +# On Ruby 3.3, the source range Coverage assigns to an EMPTY branch arm +# depends on whether its construct is in value position — its result is +# the method's return value — or void position, where the result is +# discarded. Value position keeps the whole-construct range; void +# collapses the arm to a point at its header's end. Ruby 3.4 dropped the +# distinction, so this pass only runs on legacy Rubies. +# +# "Value position" here is narrower than general value-use: it is +# strictly method-return (tail) position. It reaches a node only through +# statement tails and `if`/`unless`/`when` arms. Assignments, blocks, +# lambdas, method arguments, `case/in` arms, and loop bodies all discard +# it (Coverage treats their empty arms as void). So `tail_children` +# names the constructs that forward tail position and everything else +# falls through to the void default. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/value_position.rb#24 +module SimpleCov::StaticCoverageExtractor::ValuePositions + private + + # An identity set (a `compare_by_identity` Hash used as a set) of the + # Prism nodes Coverage treats as being in value position. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/value_position.rb#35 + def call(root); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/value_position.rb#42 + def mark(node, in_value, positions); end + + # The children of `node` that inherit its tail position; empty for the + # void default. A method body is a tail context even when the `def` + # itself is not (the method still returns its last expression), so it + # is included regardless of `in_value`. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/value_position.rb#56 + def tail_children(node, in_value); end + + class << self + # An identity set (a `compare_by_identity` Hash used as a set) of the + # Prism nodes Coverage treats as being in value position. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/value_position.rb#35 + def call(root); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/value_position.rb#42 + def mark(node, in_value, positions); end + + # The children of `node` that inherit its tail position; empty for the + # void default. A method body is a tail context even when the `def` + # itself is not (the method still returns its last expression), so it + # is included regardless of `in_value`. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/value_position.rb#56 + def tail_children(node, in_value); end + end +end + +# Prism visitor that accumulates branch and method tuples in the +# shape Ruby's `Coverage` reports. Tuple ids are sequential across +# the file like `Coverage`'s, but the numbering order can differ +# (e.g. `case`/`when` and chained `&.` are visited in a different +# order than Coverage numbers them). That's fine: the combiners +# intern on source span and the report output drops ids, so nothing +# downstream compares them. Only defined when Prism is loadable; +# `StaticCoverageExtractor.available?` is the runtime gate. +# +# source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#19 +class SimpleCov::StaticCoverageExtractor::Visitor < ::Prism::Visitor + include ::SimpleCov::StaticCoverageExtractor::MethodCollector + include ::SimpleCov::StaticCoverageExtractor::LocationConventions + include ::SimpleCov::StaticCoverageExtractor::ConditionFolding + + # @return [Visitor] a new instance of Visitor + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#32 + def initialize; end + + # Returns the value of attribute branches. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#30 + def branches; end + + # Returns the value of attribute methods. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#30 + def methods; end + + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#84 + def visit_call_node(node); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#97 + def visit_case_match_node(node); end + + # `case`/`when` and `case`/`in` (pattern matching) parse as CaseNode + # and CaseMatchNode respectively. When there's no explicit `else`, + # Coverage synthesizes one at the case's range. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#92 + def visit_case_node(node); end + + # `if` / `unless` / postfix-if / postfix-unless / ternary all parse + # as IfNode (or UnlessNode). Both carry a `then` arm (the + # statements body) and an optional `subsequent` (an ElseNode for + # `else`, another IfNode for `elsif`). When the subsequent is + # missing, Coverage synthesizes a `:else` arm attributed to the + # whole condition's range — we do the same. + # + # A folded condition emits no tuple, and on modern Rubies only its + # live arm is descended into: the compiler eliminates the dead + # arm's entire subtree, so a branch or method nested there would be + # a phantom no loaded run can produce. A falsy `if`'s elsif chain + # survives as a plain `if`, which is what visiting the subsequent + # IfNode emits. On 3.2 the dead arm is visited too, branches only + # (see visit_folded_arms). + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#68 + def visit_if_node(node); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#114 + def visit_match_predicate_node(node); end + + # One-line pattern matching: `x => pattern` (MatchRequiredNode) and + # `x in pattern` (MatchPredicateNode). Ruby 3.3's Coverage reports + # these as a `:case` with an `:in` and an `:else` arm; 3.4 dropped + # them entirely (no branch), so this is legacy-only. The two forms + # differ only in where Coverage anchors the synthesized `:else`: + # `=>` uses the whole expression, `in` uses just the pattern. + # simplecov:disable branch — legacy-only arms; unreachable on the modern dogfood Ruby + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#109 + def visit_match_required_node(node); end + + # Entry point for a parsed file. On legacy Rubies the location of an + # empty branch arm depends on whether its construct is in value + # (tail) position, so precompute that once for the whole tree before + # emitting anything. Modern Rubies don't need it (see + # LocationConventions), so the pass is skipped there. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#47 + def visit_program_node(node); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#76 + def visit_unless_node(node); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#127 + def visit_until_node(node); end + + # `while` / `until` loops get a single `:body` arm. No synthetic + # else (the loop either runs the body or doesn't). + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#122 + def visit_while_node(node); end + + private + + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#176 + def build_tuple(type, location); end + + # simplecov:enable + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#163 + def emit_case_like(node, when_type); end + + # IfNode and UnlessNode share a shape (predicate + then body + + # optional else/elsif) but expose the trailing arm under different + # accessors. `if_like_else_location` hides that split. + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#137 + def emit_if_like(node, type); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#171 + def emit_loop(node, type); end + + # simplecov:disable — legacy-only (3.4 emits no branch for one-line patterns) + # + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#155 + def emit_oneline_pattern(node, else_location); end + + # source://simplecov//lib/simplecov/static_coverage_extractor/visitor.rb#146 + def emit_safe_navigation(node); end +end + +# Filter that matches when the source file's project path contains the +# configured string at a path-segment boundary. +# +# source://simplecov//lib/simplecov/filter.rb#66 +class SimpleCov::StringFilter < ::SimpleCov::Filter + # Returns true when the given source file's filename matches the + # string configured when initializing this Filter with StringFilter.new('somestring'). + # Matching is path-segment-aware: the argument must appear immediately after a "/" + # and be followed by "/" or end-of-string, so "lib" matches "/lib/foo.rb" but not + # "/app/models/library.rb". + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#72 + def matches?(source_file); end + + # @return [Boolean] + # + # source://simplecov//lib/simplecov/filter.rb#76 + def path_only?; end + + private + + # source://simplecov//lib/simplecov/filter.rb#86 + def compute_segment_pattern; end + + # source://simplecov//lib/simplecov/filter.rb#82 + def segment_pattern; end +end + +# Fills in coverage for files that were tracked (via `cover` / `track_files`) +# but never loaded, so they count toward the denominators instead of being +# absent from the report entirely. +# +# This is the mechanism; the merge-time policy of *when* to inject and +# which criteria the simulated files carry lives in +# `ResultMerger::UnloadedFiles` — easy names to confuse. +# +# Everything arrives as arguments rather than being read from the SimpleCov +# singleton. The merge step performs this injection on behalf of the processes +# that contributed to it, and it does not necessarily share their +# configuration: a standalone `collate` never ran `SimpleCov.start`, so it has +# no `cover` glob of its own and takes the tracked paths from the resultsets +# instead. See #1250. +# +# source://simplecov//lib/simplecov/unloaded_file_injector.rb#20 +module SimpleCov::UnloadedFileInjector + private + + # Add simulated coverage for every path `coverage` does not already carry. + # Paths that are present are left alone, whoever put them there, which is + # what lets this run over resultsets a previous SimpleCov already injected + # into without double-counting or overwriting real data. + # + # Returns the augmented coverage and the set of paths that were added. + # + # source://simplecov//lib/simplecov/unloaded_file_injector.rb#61 + def call(coverage, paths, synthesize:, lines:); end + + # Expand `globs` into absolute paths, relative to `root` rather than + # `Dir.pwd` — test runners that chdir (or CI scripts that invoke the suite + # from a subdirectory) would otherwise silently miss files and produce a + # different set per environment. See #1106. + # `reject:` are the producing process's path-decidable filters. Paths its + # own report would exclude must not be recorded as tracked, or a merge that + # does not share the configuration would simulate them back in. Before + # injection moved to the merge the producer filtered them out of its own + # result and they never reached a resultset. A block filter is handed the + # source file and may consult coverage that does not exist yet, so those + # still fall to the merging process's filter chain. See #1250. + # + # source://simplecov//lib/simplecov/unloaded_file_injector.rb#34 + def discover(globs, root:, reject: T.unsafe(nil)); end + + # `SourceFile` reads source and builds lines lazily, so a path-only filter + # answers without anything here touching disk. + # + # source://simplecov//lib/simplecov/unloaded_file_injector.rb#46 + def rejected?(path, filters); end + + class << self + # Add simulated coverage for every path `coverage` does not already carry. + # Paths that are present are left alone, whoever put them there, which is + # what lets this run over resultsets a previous SimpleCov already injected + # into without double-counting or overwriting real data. + # + # Returns the augmented coverage and the set of paths that were added. + # + # source://simplecov//lib/simplecov/unloaded_file_injector.rb#61 + def call(coverage, paths, synthesize:, lines:); end + + # Expand `globs` into absolute paths, relative to `root` rather than + # `Dir.pwd` — test runners that chdir (or CI scripts that invoke the suite + # from a subdirectory) would otherwise silently miss files and produce a + # different set per environment. See #1106. + # `reject:` are the producing process's path-decidable filters. Paths its + # own report would exclude must not be recorded as tracked, or a merge that + # does not share the configuration would simulate them back in. Before + # injection moved to the merge the producer filtered them out of its own + # result and they never reached a resultset. A block filter is handed the + # source file and may consult coverage that does not exist yet, so those + # still fall to the merging process's filter chain. See #1250. + # + # source://simplecov//lib/simplecov/unloaded_file_injector.rb#34 + def discover(globs, root:, reject: T.unsafe(nil)); end + + # `SourceFile` reads source and builds lines lazily, so a path-only filter + # answers without anything here touching disk. + # + # @return [Boolean] + # + # source://simplecov//lib/simplecov/unloaded_file_injector.rb#46 + def rejected?(path, filters); end + end +end + +# source://simplecov//lib/simplecov/unloaded_file_injector.rb#52 +SimpleCov::UnloadedFileInjector::NO_COVERAGE_YET = T.let(T.unsafe(nil), Hash) + +# Drop coverage entries whose paths live outside `SimpleCov.root` so the +# report only reflects the project's own source. Vendored gems, stdlib +# files, and anything else that happens to have been touched during the +# run never make it into the formatted result. +# +# source://simplecov//lib/simplecov/useless_results_remover.rb#8 +module SimpleCov::UselessResultsRemover + class << self + # source://simplecov//lib/simplecov/useless_results_remover.rb#9 + def call(coverage_result); end + + # The `/i` flag covers case-insensitive matches on Windows / macOS-HFS+ + # where the on-disk path's case can differ from `SimpleCov.root`'s. + # + # source://simplecov//lib/simplecov/useless_results_remover.rb#15 + def root_regex; end + + # source://simplecov//lib/simplecov/useless_results_remover.rb#23 + def root_regx; end + end +end + +# source://simplecov//lib/simplecov/version.rb#4 +SimpleCov::VERSION = T.let(T.unsafe(nil), String) diff --git a/sorbet/rbi/gems/simplecov_json_formatter@0.1.4.rbi b/sorbet/rbi/gems/simplecov_json_formatter@0.1.4.rbi deleted file mode 100644 index 6e139a2..0000000 --- a/sorbet/rbi/gems/simplecov_json_formatter@0.1.4.rbi +++ /dev/null @@ -1,110 +0,0 @@ -# typed: true - -# DO NOT EDIT MANUALLY -# This is an autogenerated file for types exported from the `simplecov_json_formatter` gem. -# Please instead update this file by running `bin/tapioca gem simplecov_json_formatter`. - - -# source://simplecov_json_formatter//lib/simplecov_json_formatter.rb#7 -module SimpleCov; end - -# source://simplecov_json_formatter//lib/simplecov_json_formatter.rb#8 -module SimpleCov::Formatter; end - -# source://simplecov_json_formatter//lib/simplecov_json_formatter.rb#9 -class SimpleCov::Formatter::JSONFormatter - # source://simplecov_json_formatter//lib/simplecov_json_formatter.rb#10 - def format(result); end - - private - - # source://simplecov_json_formatter//lib/simplecov_json_formatter.rb#25 - def export_formatted_result(result_hash); end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter.rb#20 - def format_result(result); end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter.rb#30 - def output_message(result); end -end - -# source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#3 -module SimpleCovJSONFormatter; end - -# source://simplecov_json_formatter//lib/simplecov_json_formatter/result_exporter.rb#4 -class SimpleCovJSONFormatter::ResultExporter - # @return [ResultExporter] a new instance of ResultExporter - # - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_exporter.rb#7 - def initialize(result_hash); end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_exporter.rb#11 - def export; end - - private - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_exporter.rb#23 - def export_path; end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_exporter.rb#19 - def json_result; end -end - -# source://simplecov_json_formatter//lib/simplecov_json_formatter/result_exporter.rb#5 -SimpleCovJSONFormatter::ResultExporter::FILENAME = T.let(T.unsafe(nil), String) - -# source://simplecov_json_formatter//lib/simplecov_json_formatter/result_hash_formatter.rb#6 -class SimpleCovJSONFormatter::ResultHashFormatter - # @return [ResultHashFormatter] a new instance of ResultHashFormatter - # - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_hash_formatter.rb#7 - def initialize(result); end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_hash_formatter.rb#11 - def format; end - - private - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_hash_formatter.rb#20 - def format_files; end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_hash_formatter.rb#27 - def format_groups; end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_hash_formatter.rb#47 - def format_source_file(source_file); end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/result_hash_formatter.rb#37 - def formatted_result; end -end - -# source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#4 -class SimpleCovJSONFormatter::SourceFileFormatter - # @return [SourceFileFormatter] a new instance of SourceFileFormatter - # - # source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#5 - def initialize(source_file); end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#10 - def format; end - - private - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#26 - def branch_coverage; end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#41 - def branches; end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#20 - def line_coverage; end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#32 - def lines; end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#56 - def parse_branch(branch); end - - # source://simplecov_json_formatter//lib/simplecov_json_formatter/source_file_formatter.rb#50 - def parse_line(line); end -end 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.rb b/src/dev.rb index 2de83ee..66ce83c 100644 --- a/src/dev.rb +++ b/src/dev.rb @@ -2,6 +2,12 @@ # frozen_string_literal: true require "pathname" +# The one early require of sorbet-runtime for the whole Dev module tree: +# every in-process entry point (bin/dev, bin/console, test_helper) requires +# "dev" before any lib/dev file, so individual files don't re-require it. +# Exceptions that load outside this chain carry their own require: the +# deps hooks (loaded by consumer dependencies.rb via install-build-deps) +# and lib/rake_test_argv.rb (loaded standalone by bin/test.rb). require "sorbet-runtime" # Dev CLI: find repo with dev.yml, run declared commands (optionally in a CLI::UI Frame). 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..b2d0b46 100644 --- a/src/dev/builtins/cache_command.rb +++ b/src/dev/builtins/cache_command.rb @@ -5,7 +5,7 @@ require "dev/command" require "dev/deps/cache_gc" require "dev/deps/lockfile" -require "build_container" +require "dev/build_container" module Dev module Builtins @@ -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..b77084d 100644 --- a/src/dev/builtins/install_deps_command.rb +++ b/src/dev/builtins/install_deps_command.rb @@ -10,7 +10,7 @@ require "dev/deps/lockfile" require "dev/deps/registry" require "dev/learnings" -require "shadowenv_ruby" +require "dev/shadowenv_ruby" module Dev module Builtins @@ -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/provide_image_command.rb b/src/dev/builtins/provide_image_command.rb index 2f8f667..c9ac9af 100644 --- a/src/dev/builtins/provide_image_command.rb +++ b/src/dev/builtins/provide_image_command.rb @@ -3,7 +3,7 @@ require "dev/command" require "dev/credentials" -require "build_container" +require "dev/build_container" module Dev module Builtins diff --git a/src/dev/builtins/reset_container_command.rb b/src/dev/builtins/reset_container_command.rb index 26edd89..0144f02 100644 --- a/src/dev/builtins/reset_container_command.rb +++ b/src/dev/builtins/reset_container_command.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "dev/command" -require "build_container" +require "dev/build_container" module Dev module Builtins 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..c8af074 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. @@ -49,11 +49,11 @@ def execute(command, args:, context:) when OverriddenCommand @overridden_executor.execute(command, args:, context:) else - # :nocov: — the sealed hierarchy leaves no fourth variant to - # construct, so this arm is unreachable at runtime; T.absurd keeps + # simplecov:disable — the sealed hierarchy leaves no fourth variant + # to construct, so this arm is unreachable at runtime; T.absurd keeps # the static exhaustiveness proof. T.absurd(command) - # :nocov: + # simplecov:enable end end end diff --git a/src/dev/command_runner.rb b/src/dev/command_runner.rb index 826f7e3..7fabe65 100644 --- a/src/dev/command_runner.rb +++ b/src/dev/command_runner.rb @@ -6,10 +6,10 @@ require "dev/cli/ui" require "dev/command" require "dev/credentials" -require "build_container" -require "shadowenv_llvm" -require "shadowenv_python" -require "shadowenv_ruby" +require "dev/build_container" +require "dev/shadowenv_llvm" +require "dev/shadowenv_python" +require "dev/shadowenv_ruby" module Dev # Runs dev commands by handing the process over to the child. Dev prints a @@ -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..658a92e 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -19,7 +19,7 @@ require "dev/project_executor" require "dev/project_manifest" require "dev/project_manifest_loader" -require "shadowenv_ruby" +require "dev/shadowenv_ruby" module Dev # The application service behind bin/dev, and the composition root of the @@ -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/lib/build_container_test.rb b/test/dev/build_container_test.rb similarity index 70% rename from test/lib/build_container_test.rb rename to test/dev/build_container_test.rb index de2afe3..7c23223 100644 --- a/test/lib/build_container_test.rb +++ b/test/dev/build_container_test.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "test_helper" -require "build_container" +require "dev/build_container" require "dev/build_container_config" require "tmpdir" @@ -15,7 +15,7 @@ class BuildContainerTest < Minitest::Test File.write(File.join(dir, "build-deps.lock"), "cmake: {version: 3.31}") When "computing the tag" - tag = BuildContainer.content_tag(project_root: Pathname(dir)) + tag = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) Then tag.start_with?("content-") @@ -29,11 +29,11 @@ class BuildContainerTest < Minitest::Test Given "a project with a Dockerfile" dir = Dir.mktmpdir("build-container-test-") File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") - tag_a = BuildContainer.content_tag(project_root: Pathname(dir)) + tag_a = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) When "the Dockerfile changes" File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:25.04") - tag_b = BuildContainer.content_tag(project_root: Pathname(dir)) + tag_b = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) Then tag_a != tag_b @@ -48,8 +48,8 @@ class BuildContainerTest < Minitest::Test File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") When "computing twice" - tag_a = BuildContainer.content_tag(project_root: Pathname(dir)) - tag_b = BuildContainer.content_tag(project_root: Pathname(dir)) + tag_a = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) + tag_b = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) Then tag_a == tag_b @@ -64,7 +64,7 @@ class BuildContainerTest < Minitest::Test File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") When "computing the tag" - tag = BuildContainer.content_tag(project_root: Pathname(dir)) + tag = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) Then tag.start_with?("content-") @@ -80,7 +80,7 @@ class BuildContainerTest < Minitest::Test config = Dev::BuildContainerConfig.new(image: "snappy-linux", registry: "jpduchesne89") When "computing the full image reference" - result = BuildContainer.image_with_tag(config, project_root: Pathname(dir)) + result = Dev::BuildContainer.image_with_tag(config, project_root: Pathname(dir)) Then result.start_with?("jpduchesne89/snappy-linux:content-") @@ -91,7 +91,7 @@ class BuildContainerTest < Minitest::Test test "docker_run_command produces correct command array" do When "building a docker run command" - cmd = BuildContainer.docker_run_command( + cmd = Dev::BuildContainer.docker_run_command( "jpduchesne89/snappy:content-abc123", project_root: Pathname("/project"), shell_cmd: "./bin/build.sh", @@ -108,7 +108,7 @@ class BuildContainerTest < Minitest::Test test "docker_run_command renders extra volume mounts" do When "building a docker run command with volumes" - cmd = BuildContainer.docker_run_command( + cmd = Dev::BuildContainer.docker_run_command( "jpduchesne89/snappy:content-abc123", project_root: Pathname("/project"), shell_cmd: "./bin/build.sh", @@ -125,7 +125,7 @@ class BuildContainerTest < Minitest::Test test "docker_run_command renders env vars as -e flags" do When "building a docker run command with env" - cmd = BuildContainer.docker_run_command( + cmd = Dev::BuildContainer.docker_run_command( "jpduchesne89/snappy:content-abc123", project_root: Pathname("/project"), shell_cmd: "./bin/build.sh", @@ -140,7 +140,7 @@ class BuildContainerTest < Minitest::Test test "docker_run_command expands ~ in volume host paths" do When "building a docker run command with a ~ volume" - cmd = BuildContainer.docker_run_command( + cmd = Dev::BuildContainer.docker_run_command( "jpduchesne89/snappy:content-abc123", project_root: Pathname("/project"), shell_cmd: "./bin/build.sh", @@ -159,9 +159,9 @@ class BuildContainerTest < Minitest::Test config = Dev::BuildContainerConfig.new(image: "snappy-linux", registry: "jpduchesne89") When "ensuring the image" - BuildContainer.stubs(:local_image?).returns(false) - BuildContainer.stubs(:pull).returns(true) - result = BuildContainer.ensure_image!(config, project_root: Pathname(dir)) + Dev::BuildContainer.stubs(:local_image?).returns(false) + Dev::BuildContainer.stubs(:pull).returns(true) + result = Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir)) Then result.start_with?("jpduchesne89/snappy-linux:content-") @@ -179,13 +179,13 @@ class BuildContainerTest < Minitest::Test received_args = nil When "ensuring the image with a build args provider" - BuildContainer.stubs(:local_image?).returns(false) - BuildContainer.stubs(:pull).returns(false) - BuildContainer.stubs(:build!).with { |_tag, build_args:, **_| + Dev::BuildContainer.stubs(:local_image?).returns(false) + Dev::BuildContainer.stubs(:pull).returns(false) + Dev::BuildContainer.stubs(:build!).with { |_tag, build_args:, **_| received_args = build_args true } - BuildContainer.stubs(:push!).returns(true) - BuildContainer.ensure_image!( + Dev::BuildContainer.stubs(:push!).returns(true) + Dev::BuildContainer.ensure_image!( config, project_root: Pathname(dir), build_args_provider: -> { @@ -209,9 +209,9 @@ class BuildContainerTest < Minitest::Test provider_calls = 0 When "ensuring the image" - BuildContainer.stubs(:local_image?).returns(false) - BuildContainer.stubs(:pull).returns(true) - BuildContainer.ensure_image!( + Dev::BuildContainer.stubs(:local_image?).returns(false) + Dev::BuildContainer.stubs(:pull).returns(true) + Dev::BuildContainer.ensure_image!( config, project_root: Pathname(dir), build_args_provider: -> { @@ -232,10 +232,10 @@ class BuildContainerTest < Minitest::Test captured = nil When "building with build args" - BuildContainer.stubs(:system).with { |*argv| + Dev::BuildContainer.stubs(:system).with { |*argv| captured = argv true }.returns(true) - BuildContainer.send( + Dev::BuildContainer.send( :build!, "img:tag", project_root: Pathname(dir), @@ -258,10 +258,10 @@ class BuildContainerTest < Minitest::Test captured = nil When "building" - BuildContainer.stubs(:system).with { |*argv| + Dev::BuildContainer.stubs(:system).with { |*argv| captured = argv true }.returns(true) - BuildContainer.send(:build!, "img:tag", project_root: Pathname(dir)) + Dev::BuildContainer.send(:build!, "img:tag", project_root: Pathname(dir)) Then "the non-TTY-silent auto renderer is overridden" captured.include?("--progress=plain") @@ -278,11 +278,11 @@ class BuildContainerTest < Minitest::Test pulled = [] When "ensuring the image" - BuildContainer.stubs(:local_image?).returns(true) - BuildContainer.stubs(:pull).with { |tag| + Dev::BuildContainer.stubs(:local_image?).returns(true) + Dev::BuildContainer.stubs(:pull).with { |tag| pulled << tag true } - result = BuildContainer.ensure_image!(config, project_root: Pathname(dir)) + result = Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir)) Then result.start_with?("jpduchesne89/snappy-linux:content-") @@ -301,15 +301,15 @@ class BuildContainerTest < Minitest::Test pushed = [] When "ensuring the image" - BuildContainer.stubs(:local_image?).returns(false) - BuildContainer.stubs(:pull).returns(false) - BuildContainer.stubs(:build!).with { |tag, **_| + Dev::BuildContainer.stubs(:local_image?).returns(false) + Dev::BuildContainer.stubs(:pull).returns(false) + Dev::BuildContainer.stubs(:build!).with { |tag, **_| built << tag true } - BuildContainer.stubs(:push!).with { |tag| + Dev::BuildContainer.stubs(:push!).with { |tag| pushed << tag true } - result = BuildContainer.ensure_image!(config, project_root: Pathname(dir)) + result = Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir)) Then result.start_with?("jpduchesne89/snappy-linux:content-") @@ -329,13 +329,13 @@ class BuildContainerTest < Minitest::Test pushed = [] When "ensuring the image with push: false" - BuildContainer.stubs(:local_image?).returns(false) - BuildContainer.stubs(:pull).returns(false) - BuildContainer.stubs(:build!).returns(true) - BuildContainer.stubs(:push!).with { |tag| + Dev::BuildContainer.stubs(:local_image?).returns(false) + Dev::BuildContainer.stubs(:pull).returns(false) + Dev::BuildContainer.stubs(:build!).returns(true) + Dev::BuildContainer.stubs(:push!).with { |tag| pushed << tag true } - BuildContainer.ensure_image!(config, project_root: Pathname(dir), push: false) + Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir), push: false) Then pushed.empty? @@ -349,17 +349,17 @@ class BuildContainerTest < Minitest::Test dir = Dir.mktmpdir("build-container-test-") File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") config = Dev::BuildContainerConfig.new(image: "snappy-linux", registry: "jpduchesne89") - tag = BuildContainer.image_with_tag(config, project_root: Pathname(dir)) + tag = Dev::BuildContainer.image_with_tag(config, project_root: Pathname(dir)) When "ensuring the image with publish: true" - result = BuildContainer.ensure_image!(config, project_root: Pathname(dir), publish: true) + result = Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir), publish: true) Then "the local image is honored, then published to the registry (no pull, no build)" result == tag - 1 * BuildContainer.local_image?(tag) >> true - 0 * BuildContainer.pull(tag) - 1 * BuildContainer.registry_has?(tag) >> false - 1 * BuildContainer.push!(tag) >> true + 1 * Dev::BuildContainer.local_image?(tag) >> true + 0 * Dev::BuildContainer.pull(tag) + 1 * Dev::BuildContainer.registry_has?(tag) >> false + 1 * Dev::BuildContainer.push!(tag) >> true Cleanup FileUtils.rm_rf(dir) @@ -370,16 +370,16 @@ class BuildContainerTest < Minitest::Test dir = Dir.mktmpdir("build-container-test-") File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") config = Dev::BuildContainerConfig.new(image: "snappy-linux", registry: "jpduchesne89") - tag = BuildContainer.image_with_tag(config, project_root: Pathname(dir)) + tag = Dev::BuildContainer.image_with_tag(config, project_root: Pathname(dir)) When "ensuring the image with publish: true" - result = BuildContainer.ensure_image!(config, project_root: Pathname(dir), publish: true) + result = Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir), publish: true) Then "the registry check short-circuits the push" result == tag - 1 * BuildContainer.local_image?(tag) >> true - 1 * BuildContainer.registry_has?(tag) >> true - 0 * BuildContainer.push!(tag) + 1 * Dev::BuildContainer.local_image?(tag) >> true + 1 * Dev::BuildContainer.registry_has?(tag) >> true + 0 * Dev::BuildContainer.push!(tag) Cleanup FileUtils.rm_rf(dir) @@ -390,16 +390,16 @@ class BuildContainerTest < Minitest::Test dir = Dir.mktmpdir("build-container-test-") File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") config = Dev::BuildContainerConfig.new(image: "snappy-linux", registry: "jpduchesne89") - tag = BuildContainer.image_with_tag(config, project_root: Pathname(dir)) + tag = Dev::BuildContainer.image_with_tag(config, project_root: Pathname(dir)) When "ensuring the image" - result = BuildContainer.ensure_image!(config, project_root: Pathname(dir)) + result = Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir)) Then "no registry interaction happens — a plain local run never publishes" result == tag - 1 * BuildContainer.local_image?(tag) >> true - 0 * BuildContainer.registry_has?(tag) - 0 * BuildContainer.push!(tag) + 1 * Dev::BuildContainer.local_image?(tag) >> true + 0 * Dev::BuildContainer.registry_has?(tag) + 0 * Dev::BuildContainer.push!(tag) Cleanup FileUtils.rm_rf(dir) @@ -410,18 +410,18 @@ class BuildContainerTest < Minitest::Test dir = Dir.mktmpdir("build-container-test-") File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") config = Dev::BuildContainerConfig.new(image: "snappy-linux", registry: "jpduchesne89") - tag = BuildContainer.image_with_tag(config, project_root: Pathname(dir)) + tag = Dev::BuildContainer.image_with_tag(config, project_root: Pathname(dir)) When "ensuring the image with push: false (the only real caller) and publish: true" - result = BuildContainer.ensure_image!(config, project_root: Pathname(dir), push: false, publish: true) + result = Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir), push: false, publish: true) Then "it builds, then publishes via the registry-guarded path" result == tag - 1 * BuildContainer.local_image?(tag) >> false - 1 * BuildContainer.pull(tag) >> false - 1 * BuildContainer.build!(tag, project_root: Pathname(dir), build_args: {}, build_contexts: {}, secrets: {}) >> true - 1 * BuildContainer.registry_has?(tag) >> false - 1 * BuildContainer.push!(tag) >> true + 1 * Dev::BuildContainer.local_image?(tag) >> false + 1 * Dev::BuildContainer.pull(tag) >> false + 1 * Dev::BuildContainer.build!(tag, project_root: Pathname(dir), build_args: {}, build_contexts: {}, secrets: {}) >> true + 1 * Dev::BuildContainer.registry_has?(tag) >> false + 1 * Dev::BuildContainer.push!(tag) >> true Cleanup FileUtils.rm_rf(dir) @@ -429,32 +429,32 @@ class BuildContainerTest < Minitest::Test test "publish! is a no-op when the registry already advertises the tag" do When "publishing a tag the registry already has" - result = BuildContainer.publish!("img:tag") + result = Dev::BuildContainer.publish!("img:tag") Then "the manifest check short-circuits and nothing is pushed" result == true - 1 * BuildContainer.registry_has?("img:tag") >> true - 0 * BuildContainer.push!("img:tag") + 1 * Dev::BuildContainer.registry_has?("img:tag") >> true + 0 * Dev::BuildContainer.push!("img:tag") end test "publish! pushes when the registry lacks the tag" do When "publishing a tag the registry lacks" - result = BuildContainer.publish!("img:tag") + result = Dev::BuildContainer.publish!("img:tag") Then "it pushes the local image" result == true - 1 * BuildContainer.registry_has?("img:tag") >> false - 1 * BuildContainer.push!("img:tag") >> true + 1 * Dev::BuildContainer.registry_has?("img:tag") >> false + 1 * Dev::BuildContainer.push!("img:tag") >> true end test "publish! warns but does not raise when the push fails" do When "the registry lacks the tag and the push fails" - result = BuildContainer.publish!("img:tag") + result = Dev::BuildContainer.publish!("img:tag") Then "the failure is surfaced as a falsey return, not an exception" result == false - 1 * BuildContainer.registry_has?("img:tag") >> false - 1 * BuildContainer.push!("img:tag") >> false + 1 * Dev::BuildContainer.registry_has?("img:tag") >> false + 1 * Dev::BuildContainer.push!("img:tag") >> false end test "build! raises when docker build fails" do @@ -462,8 +462,8 @@ class BuildContainerTest < Minitest::Test dir = Dir.mktmpdir("build-container-test-") When "docker build fails" - BuildContainer.stubs(:system).returns(false) - BuildContainer.send(:build!, "bad:tag", project_root: Pathname(dir)) + Dev::BuildContainer.stubs(:system).returns(false) + Dev::BuildContainer.send(:build!, "bad:tag", project_root: Pathname(dir)) Then raises RuntimeError @@ -476,11 +476,11 @@ class BuildContainerTest < Minitest::Test Given "a project with Dockerfile and .dockerignore" dir = Dir.mktmpdir("build-container-test-") File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") - tag_without = BuildContainer.content_tag(project_root: Pathname(dir)) + tag_without = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) When "adding a .dockerignore" File.write(File.join(dir, ".dockerignore"), "node_modules") - tag_with = BuildContainer.content_tag(project_root: Pathname(dir)) + tag_with = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) Then tag_without != tag_with @@ -493,11 +493,11 @@ class BuildContainerTest < Minitest::Test Given "a project with a Dockerfile" dir = Dir.mktmpdir("build-container-test-") File.write(File.join(dir, "Dockerfile"), "FROM ubuntu:24.04") - tag_without = BuildContainer.content_tag(project_root: Pathname(dir)) + tag_without = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) When "adding a deps.lock" File.write(File.join(dir, "deps.lock"), "SML: {version: 3.12.0}") - tag_with = BuildContainer.content_tag(project_root: Pathname(dir)) + tag_with = Dev::BuildContainer.content_tag(project_root: Pathname(dir)) Then tag_without != tag_with @@ -514,11 +514,11 @@ class BuildContainerTest < Minitest::Test build_cs = File.join(dir, "Mods/Snappy/Source/Snappy/Snappy.Build.cs") File.write(build_cs, "// deps: Core") globs = ["Mods/*/Source/*/*.Build.cs"] - tag_a = BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) + tag_a = Dev::BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) When "the Build.cs changes" File.write(build_cs, "// deps: Core, SML") - tag_b = BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) + tag_b = Dev::BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) Then tag_a != tag_b @@ -538,9 +538,9 @@ class BuildContainerTest < Minitest::Test When "computing the content tag (the glob also matches bin/image/lib)" # Regression: the dir entry must be skipped, not read (Errno::EISDIR). - tag_a = BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) + tag_a = Dev::BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) File.write(File.join(dir, "bin/image/lib/env.sh"), "echo env changed") - tag_b = BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) + tag_b = Dev::BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) Then "it hashes the nested files without raising, and tracks their contents" tag_a.start_with?("content-") @@ -557,11 +557,11 @@ class BuildContainerTest < Minitest::Test FileUtils.mkdir_p(File.join(dir, "Mods/Snappy/Source/Snappy")) File.write(File.join(dir, "Mods/Snappy/Source/Snappy/Snappy.Build.cs"), "// deps") globs = ["Mods/*/Source/*/*.Build.cs"] - tag_a = BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) + tag_a = Dev::BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) When "a non-globbed source file changes" File.write(File.join(dir, "Mods/Snappy/Source/Snappy/Snappy.cpp"), "int main() {}") - tag_b = BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) + tag_b = Dev::BuildContainer.content_tag(project_root: Pathname(dir), extra_globs: globs) Then tag_a == tag_b @@ -578,11 +578,11 @@ class BuildContainerTest < Minitest::Test build_cs = File.join(dir, "Mods/Snappy/Source/Snappy/Snappy.Build.cs") File.write(build_cs, "// deps: Core") globs = ["Mods/*/Source/*/*.Build.cs"] - tag_a = BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) + tag_a = Dev::BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) When "the Build.cs contents change but the module set does not" File.write(build_cs, "// deps: Core, UMG, Slate, AssetRegistry") - tag_b = BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) + tag_b = Dev::BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) Then "the tag is unchanged: a dependency edit must not invalidate the image" tag_a == tag_b @@ -598,12 +598,12 @@ class BuildContainerTest < Minitest::Test FileUtils.mkdir_p(File.join(dir, "Mods/Snappy/Source/Snappy")) File.write(File.join(dir, "Mods/Snappy/Source/Snappy/Snappy.Build.cs"), "// deps") globs = ["Mods/*/Source/*/*.Build.cs"] - tag_a = BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) + tag_a = Dev::BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) When "a second module is added (a new Build.cs path)" FileUtils.mkdir_p(File.join(dir, "Mods/Snappy/Source/SnappyTests")) File.write(File.join(dir, "Mods/Snappy/Source/SnappyTests/SnappyTests.Build.cs"), "// deps") - tag_b = BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) + tag_b = Dev::BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) Then "the tag changes: adding/removing a module must invalidate the image" tag_a != tag_b @@ -622,11 +622,11 @@ class BuildContainerTest < Minitest::Test tests_cs = File.join(dir, "Mods/Snappy/Source/SnappyTests/SnappyTests.Build.cs") File.write(tests_cs, "// deps") globs = ["Mods/*/Source/*/*.Build.cs"] - tag_a = BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) + tag_a = Dev::BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) When "a module is removed (its Build.cs path disappears)" File.delete(tests_cs) - tag_b = BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) + tag_b = Dev::BuildContainer.content_tag(project_root: Pathname(dir), structure_globs: globs) Then tag_a != tag_b @@ -645,12 +645,12 @@ class BuildContainerTest < Minitest::Test config = Dev::BuildContainerConfig.new( image: "snappy-linux", registry: "jpduchesne89", structure_globs: structure, ) - ref_a = BuildContainer.image_with_tag(config, project_root: Pathname(dir)) + ref_a = Dev::BuildContainer.image_with_tag(config, project_root: Pathname(dir)) When "a module is added" FileUtils.mkdir_p(File.join(dir, "Mods/Snappy/Source/SnappyTests")) File.write(File.join(dir, "Mods/Snappy/Source/SnappyTests/SnappyTests.Build.cs"), "// deps") - ref_b = BuildContainer.image_with_tag(config, project_root: Pathname(dir)) + ref_b = Dev::BuildContainer.image_with_tag(config, project_root: Pathname(dir)) Then "the full image reference changes via the structural path set" ref_a != ref_b @@ -674,7 +674,7 @@ class BuildContainerTest < Minitest::Test LOCK When "computing build contexts" - contexts = BuildContainer.build_contexts_from_lockfile(Pathname(dir)) + contexts = Dev::BuildContainer.build_contexts_from_lockfile(Pathname(dir)) Then "the context name is lowercased (Docker rejects uppercase)" contexts == { "unrealengine" => File.expand_path("~/.dev/engines/unreal-engine-css") } @@ -695,7 +695,7 @@ class BuildContainerTest < Minitest::Test LOCK When "computing build contexts" - contexts = BuildContainer.build_contexts_from_lockfile(Pathname(dir)) + contexts = Dev::BuildContainer.build_contexts_from_lockfile(Pathname(dir)) Then "the host path includes the locked version" contexts == { "unrealengine" => File.join(File.expand_path("~/.dev/engines/unreal-engine-css"), "5.6.1-css-83") } @@ -723,7 +723,7 @@ class BuildContainerTest < Minitest::Test LOCK When "resolving a mix of locked and unlocked volumes" - resolved = BuildContainer.resolve_versioned_volumes( + resolved = Dev::BuildContainer.resolve_versioned_volumes( ["/opt/engines/ue:/ue", "/opt/satisfactory-server:/server", "~/.dev/cache:/cache:ro"], project_root: Pathname(dir), ) @@ -744,7 +744,7 @@ class BuildContainerTest < Minitest::Test dir = Dir.mktmpdir("build-container-test-") When "resolving volumes" - resolved = BuildContainer.resolve_versioned_volumes(["/opt/engines/ue:/ue"], project_root: Pathname(dir)) + resolved = Dev::BuildContainer.resolve_versioned_volumes(["/opt/engines/ue:/ue"], project_root: Pathname(dir)) Then resolved == ["/opt/engines/ue:/ue"] @@ -767,7 +767,7 @@ class BuildContainerTest < Minitest::Test LOCK When "collecting install_dir versions" - versions = BuildContainer.install_dir_versions(Pathname(dir)) + versions = Dev::BuildContainer.install_dir_versions(Pathname(dir)) Then versions == { "/opt/engines/ue" => "5.6.1-css-83" } @@ -781,7 +781,7 @@ class BuildContainerTest < Minitest::Test dir = Dir.mktmpdir("build-container-test-") When "computing build contexts" - contexts = BuildContainer.build_contexts_from_lockfile(Pathname(dir)) + contexts = Dev::BuildContainer.build_contexts_from_lockfile(Pathname(dir)) Then contexts == {} @@ -796,10 +796,10 @@ class BuildContainerTest < Minitest::Test captured = nil When "building with contexts and secrets" - BuildContainer.stubs(:system).with { |*argv| + Dev::BuildContainer.stubs(:system).with { |*argv| captured = argv true }.returns(true) - BuildContainer.send( + Dev::BuildContainer.send( :build!, "img:tag", project_root: Pathname(dir), @@ -824,10 +824,10 @@ class BuildContainerTest < Minitest::Test captured = nil When "building with a secret" - BuildContainer.stubs(:system).with { |*argv| + Dev::BuildContainer.stubs(:system).with { |*argv| captured = argv true }.returns(true) - BuildContainer.send( + Dev::BuildContainer.send( :build!, "img:tag", project_root: Pathname(dir), @@ -857,15 +857,15 @@ class BuildContainerTest < Minitest::Test received_contexts = nil When "ensuring the image with a secrets provider" - BuildContainer.stubs(:local_image?).returns(false) - BuildContainer.stubs(:pull).returns(false) - BuildContainer.stubs(:build!).with do |_tag, secrets:, build_contexts:, **_| + Dev::BuildContainer.stubs(:local_image?).returns(false) + Dev::BuildContainer.stubs(:pull).returns(false) + Dev::BuildContainer.stubs(:build!).with do |_tag, secrets:, build_contexts:, **_| received_secrets = secrets received_contexts = build_contexts true end - BuildContainer.stubs(:push!).returns(true) - BuildContainer.ensure_image!( + Dev::BuildContainer.stubs(:push!).returns(true) + Dev::BuildContainer.ensure_image!( config, project_root: Pathname(dir), build_args_provider: -> { {} }, @@ -891,9 +891,9 @@ class BuildContainerTest < Minitest::Test secret_calls = 0 When "ensuring the image" - BuildContainer.stubs(:local_image?).returns(false) - BuildContainer.stubs(:pull).returns(true) - BuildContainer.ensure_image!( + Dev::BuildContainer.stubs(:local_image?).returns(false) + Dev::BuildContainer.stubs(:pull).returns(true) + Dev::BuildContainer.ensure_image!( config, project_root: Pathname(dir), secrets_provider: -> { @@ -916,21 +916,21 @@ class BuildContainerTest < Minitest::Test image: "snappy-linux", registry: "jpduchesne89", volumes: ["/engines/ue:/ue"], prewarm: "bash /work/bin/prewarm.sh", ) - tag = BuildContainer.image_with_tag(config, project_root: Pathname(dir)) + tag = Dev::BuildContainer.image_with_tag(config, project_root: Pathname(dir)) When "ensuring the image" - result = BuildContainer.ensure_image!(config, project_root: Pathname(dir)) + result = Dev::BuildContainer.ensure_image!(config, project_root: Pathname(dir)) Then "the base is built engine-free, the prewarm runs against it, and the base tag is dropped" result == tag - 1 * BuildContainer.local_image?(tag) >> false - 1 * BuildContainer.pull(tag) >> false - 1 * BuildContainer.build!("#{tag}-base", project_root: Pathname(dir), build_args: {}, + 1 * Dev::BuildContainer.local_image?(tag) >> false + 1 * Dev::BuildContainer.pull(tag) >> false + 1 * Dev::BuildContainer.build!("#{tag}-base", project_root: Pathname(dir), build_args: {}, build_contexts: {}, secrets: {}) >> true - 1 * BuildContainer.prewarm_commit!("#{tag}-base", tag, volumes: ["/engines/ue:/ue"], + 1 * Dev::BuildContainer.prewarm_commit!("#{tag}-base", tag, volumes: ["/engines/ue:/ue"], prewarm: "bash /work/bin/prewarm.sh", secrets: {}) >> true - 1 * BuildContainer.remove_image("#{tag}-base") >> true - 1 * BuildContainer.push!(tag) >> true + 1 * Dev::BuildContainer.remove_image("#{tag}-base") >> true + 1 * Dev::BuildContainer.push!(tag) >> true Cleanup FileUtils.rm_rf(dir) @@ -940,20 +940,20 @@ class BuildContainerTest < Minitest::Test Given "resolved dep volumes and a secret" When "running the prewarm commit" - BuildContainer.send( + Dev::BuildContainer.send( :prewarm_commit!, "img:tag-base", "img:tag", volumes: ["/engines/ue:/ue"], prewarm: "bash /work/bin/prewarm.sh", secrets: { "WWISE_TOKEN" => "tok" } ) Then "the run mounts the engine + secret file (never -e: commit would bake env), runs under the watcher, commits, and removes it" - 1 * BuildContainer.prewarm_container_name >> "dev-prewarm-test" - 1 * BuildContainer.write_secret_files({ "WWISE_TOKEN" => "tok" }) >> { "WWISE_TOKEN" => "/tmp/dev-secret-xyz" } - 1 * BuildContainer.run_watched(["docker", "run", "--name", "dev-prewarm-test", + 1 * Dev::BuildContainer.prewarm_container_name >> "dev-prewarm-test" + 1 * Dev::BuildContainer.write_secret_files({ "WWISE_TOKEN" => "tok" }) >> { "WWISE_TOKEN" => "/tmp/dev-secret-xyz" } + 1 * Dev::BuildContainer.run_watched(["docker", "run", "--name", "dev-prewarm-test", "-v", "/engines/ue:/ue", "-v", "/tmp/dev-secret-xyz:/run/secrets/WWISE_TOKEN:ro", "img:tag-base", "sh", "-c", "bash /work/bin/prewarm.sh"], container: "dev-prewarm-test") >> true - 1 * BuildContainer.system("docker", "commit", "dev-prewarm-test", "img:tag") >> true - 1 * BuildContainer.system("docker", "rm", "-f", "dev-prewarm-test", + 1 * Dev::BuildContainer.system("docker", "commit", "dev-prewarm-test", "img:tag") >> true + 1 * Dev::BuildContainer.system("docker", "rm", "-f", "dev-prewarm-test", out: File::NULL, err: File::NULL) >> true end @@ -961,24 +961,24 @@ class BuildContainerTest < Minitest::Test Given "a prewarm command that fails" When "running the prewarm commit" - BuildContainer.send( + Dev::BuildContainer.send( :prewarm_commit!, "img:tag-base", "img:tag", volumes: [], prewarm: "false", secrets: {} ) Then "it surfaces the failure and the ensure block removes the container (no commit)" raises RuntimeError - 1 * BuildContainer.prewarm_container_name >> "dev-prewarm-test" - 1 * BuildContainer.write_secret_files({}) >> {} - 1 * BuildContainer.run_watched(["docker", "run", "--name", "dev-prewarm-test", + 1 * Dev::BuildContainer.prewarm_container_name >> "dev-prewarm-test" + 1 * Dev::BuildContainer.write_secret_files({}) >> {} + 1 * Dev::BuildContainer.run_watched(["docker", "run", "--name", "dev-prewarm-test", "img:tag-base", "sh", "-c", "false"], container: "dev-prewarm-test") >> false - 1 * BuildContainer.system("docker", "rm", "-f", "dev-prewarm-test", + 1 * Dev::BuildContainer.system("docker", "rm", "-f", "dev-prewarm-test", out: File::NULL, err: File::NULL) >> true end test "write_secret_files writes each secret to a private temp file" do When "writing secret files" - files = BuildContainer.send(:write_secret_files, { "TOK" => "s3cr3t" }) + files = Dev::BuildContainer.send(:write_secret_files, { "TOK" => "s3cr3t" }) Then "the value is on disk with owner-only permissions" File.read(files["TOK"]) == "s3cr3t" @@ -991,10 +991,10 @@ class BuildContainerTest < Minitest::Test test "service_container_name keys the name by image, workspace, and tag" do Given "a full image:tag and the checkout it runs in" root = Pathname("/work/snappy") - wid = BuildContainer.workspace_id(root) + wid = Dev::BuildContainer.workspace_id(root) When "naming the service container" - name = BuildContainer.service_container_name("jpduchesne89/snappy-linux:content-abc123", root) + name = Dev::BuildContainer.service_container_name("jpduchesne89/snappy-linux:content-abc123", root) Then "the name is registry-free, colon-free, dev-prefixed, and workspace-keyed" name == "dev-snappy-linux-#{wid}-content-abc123" @@ -1005,8 +1005,8 @@ class BuildContainerTest < Minitest::Test tag = "jpduchesne89/snappy-linux:content-abc123" When "naming the service container for each checkout" - runner = BuildContainer.service_container_name(tag, Pathname("/runner/_work/snappy/snappy")) - manual = BuildContainer.service_container_name(tag, Pathname("/home/dev/snappy")) + runner = Dev::BuildContainer.service_container_name(tag, Pathname("/runner/_work/snappy/snappy")) + manual = Dev::BuildContainer.service_container_name(tag, Pathname("/home/dev/snappy")) Then "the names differ, so neither checkout can hijack the other's container" runner != manual @@ -1015,10 +1015,10 @@ class BuildContainerTest < Minitest::Test test "service_name_prefix is the tag-independent, workspace-scoped prefix" do Given "a full image:tag and the checkout it runs in" root = Pathname("/work/snappy") - wid = BuildContainer.workspace_id(root) + wid = Dev::BuildContainer.workspace_id(root) When "computing the reap prefix for a tag" - prefix = BuildContainer.service_name_prefix("jpduchesne89/snappy-linux:content-abc123", root) + prefix = Dev::BuildContainer.service_name_prefix("jpduchesne89/snappy-linux:content-abc123", root) Then "it omits the tag (so any tag matches) but pins the workspace" prefix == "dev-snappy-linux-#{wid}-" @@ -1026,7 +1026,7 @@ class BuildContainerTest < Minitest::Test test "docker_exec_command targets the container with /project workdir" do When "building a docker exec command" - cmd = BuildContainer.docker_exec_command( + cmd = Dev::BuildContainer.docker_exec_command( "dev-snappy-linux-content-abc", shell_cmd: "./bin/build.sh", ) @@ -1041,7 +1041,7 @@ class BuildContainerTest < Minitest::Test test "docker_exec_command renders env vars as -e flags before the container" do When "building a docker exec command with env" - cmd = BuildContainer.docker_exec_command( + cmd = Dev::BuildContainer.docker_exec_command( "dev-snappy-linux-content-abc", shell_cmd: "./bin/build.sh", env: { "WWISE_TOKEN" => "tok-123" }, ) @@ -1056,35 +1056,35 @@ class BuildContainerTest < Minitest::Test Given "an image tag whose container is absent, and the checkout it runs in" tag = "jpduchesne89/snappy-linux:content-abc" root = Pathname("/proj") - name = BuildContainer.service_container_name(tag, root) + name = Dev::BuildContainer.service_container_name(tag, root) When "ensuring the service" - result = BuildContainer.ensure_service!(tag, project_root: root, volumes: ["/e:/e"]) + result = Dev::BuildContainer.ensure_service!(tag, project_root: root, volumes: ["/e:/e"]) Then "stale containers are reaped, then the container is created (never started)" result == name - 1 * BuildContainer.reap_stale_services!(tag, root) >> nil - 1 * BuildContainer.container_exists?(name) >> false - 1 * BuildContainer.create_service_container(name, tag, + 1 * Dev::BuildContainer.reap_stale_services!(tag, root) >> nil + 1 * Dev::BuildContainer.container_exists?(name) >> false + 1 * Dev::BuildContainer.create_service_container(name, tag, project_root: root, volumes: ["/e:/e"]) >> true - 0 * BuildContainer.start_container(name) + 0 * Dev::BuildContainer.start_container(name) end test "ensure_service! starts the container when it exists but is stopped" do Given "an image tag whose container exists but is stopped" tag = "jpduchesne89/snappy-linux:content-abc" root = Pathname("/proj") - name = BuildContainer.service_container_name(tag, root) + name = Dev::BuildContainer.service_container_name(tag, root) When "ensuring the service" - BuildContainer.ensure_service!(tag, project_root: root) + Dev::BuildContainer.ensure_service!(tag, project_root: root) Then "the existing container is started, not recreated" - 1 * BuildContainer.reap_stale_services!(tag, root) >> nil - 1 * BuildContainer.container_exists?(name) >> true - 1 * BuildContainer.container_running?(name) >> false - 1 * BuildContainer.start_container(name) >> true - 0 * BuildContainer.create_service_container(name, tag, + 1 * Dev::BuildContainer.reap_stale_services!(tag, root) >> nil + 1 * Dev::BuildContainer.container_exists?(name) >> true + 1 * Dev::BuildContainer.container_running?(name) >> false + 1 * Dev::BuildContainer.start_container(name) >> true + 0 * Dev::BuildContainer.create_service_container(name, tag, project_root: root, volumes: []) end @@ -1092,51 +1092,51 @@ class BuildContainerTest < Minitest::Test Given "an image tag whose container is already up" tag = "jpduchesne89/snappy-linux:content-abc" root = Pathname("/proj") - name = BuildContainer.service_container_name(tag, root) + name = Dev::BuildContainer.service_container_name(tag, root) When "ensuring the service" - BuildContainer.ensure_service!(tag, project_root: root) + Dev::BuildContainer.ensure_service!(tag, project_root: root) Then "neither start nor create is invoked" - 1 * BuildContainer.reap_stale_services!(tag, root) >> nil - 1 * BuildContainer.container_exists?(name) >> true - 1 * BuildContainer.container_running?(name) >> true - 0 * BuildContainer.start_container(name) + 1 * Dev::BuildContainer.reap_stale_services!(tag, root) >> nil + 1 * Dev::BuildContainer.container_exists?(name) >> true + 1 * Dev::BuildContainer.container_running?(name) >> true + 0 * Dev::BuildContainer.start_container(name) end test "reap_stale_services! removes other-tag containers but keeps the current tag" do Given "a current tag and a stale sibling container in the same checkout" tag = "jpduchesne89/snappy-linux:content-new" root = Pathname("/proj") - prefix = BuildContainer.service_name_prefix(tag, root) + prefix = Dev::BuildContainer.service_name_prefix(tag, root) keep = "#{prefix}content-new" stale = "#{prefix}content-old" When "reaping" - BuildContainer.send(:reap_stale_services!, tag, root) + Dev::BuildContainer.send(:reap_stale_services!, tag, root) Then "only the non-current container is removed" - 1 * BuildContainer.service_containers(prefix) >> [stale, keep] - 1 * BuildContainer.remove_container(stale) >> true - 0 * BuildContainer.remove_container(keep) + 1 * Dev::BuildContainer.service_containers(prefix) >> [stale, keep] + 1 * Dev::BuildContainer.remove_container(stale) >> true + 0 * Dev::BuildContainer.remove_container(keep) end test "reset_service! removes every container for the checkout prefix" do Given "two containers for the checkout (current and stale)" tag = "jpduchesne89/snappy-linux:content-abc" root = Pathname("/proj") - prefix = BuildContainer.service_name_prefix(tag, root) + prefix = Dev::BuildContainer.service_name_prefix(tag, root) current = "#{prefix}content-abc" stale = "#{prefix}content-old" When "resetting" - result = BuildContainer.reset_service!(tag, root) + result = Dev::BuildContainer.reset_service!(tag, root) Then "all matching containers are removed and their names returned" result == [stale, current] - 1 * BuildContainer.service_containers(prefix) >> [stale, current] - 1 * BuildContainer.remove_container(stale) >> true - 1 * BuildContainer.remove_container(current) >> true + 1 * Dev::BuildContainer.service_containers(prefix) >> [stale, current] + 1 * Dev::BuildContainer.remove_container(stale) >> true + 1 * Dev::BuildContainer.remove_container(current) >> true end test "create_service_container runs detached, mounts project + volumes, and idles" do @@ -1144,10 +1144,10 @@ class BuildContainerTest < Minitest::Test captured = nil When "creating the service container" - BuildContainer.stubs(:system).with { |*argv, **_kw| + Dev::BuildContainer.stubs(:system).with { |*argv, **_kw| captured = argv true }.returns(true) - BuildContainer.send( + Dev::BuildContainer.send( :create_service_container, "dev-x", "img:tag", project_root: Pathname("/project"), volumes: ["/engines/ue:/ue"], ) @@ -1162,12 +1162,66 @@ class BuildContainerTest < Minitest::Test test "create_service_container raises when docker run fails" do When "docker run fails" - BuildContainer.stubs(:system).returns(false) - BuildContainer.send( + Dev::BuildContainer.stubs(:system).returns(false) + Dev::BuildContainer.send( :create_service_container, "dev-x", "img:tag", project_root: Pathname("/project"), ) Then raises RuntimeError end + + test "prewarm_container_name embeds the pid and never repeats" do + When "naming two prewarm containers" + name_a = Dev::BuildContainer.send(:prewarm_container_name) + name_b = Dev::BuildContainer.send(:prewarm_container_name) + + Then "both carry the dev-prewarm-<pid>- prefix and differ in their random suffix" + name_a.start_with?("dev-prewarm-#{Process.pid}-") + name_b.start_with?("dev-prewarm-#{Process.pid}-") + name_a != name_b + end + + test "the docker CLI wrappers pass through the command's success" do + Given "a docker whose every invocation succeeds" + Dev::BuildContainer.stubs(:system).returns(true) + + Expect "each one-command wrapper reports that success" + Dev::BuildContainer.send(:remove_image, "img:tag") == true + Dev::BuildContainer.send(:container_exists?, "dev-x") == true + Dev::BuildContainer.send(:start_container, "dev-x").nil? == false + Dev::BuildContainer.send(:remove_container, "dev-x").nil? == false + Dev::BuildContainer.send(:local_image?, "img:tag") == true + Dev::BuildContainer.send(:pull, "img:tag") == true + Dev::BuildContainer.push!("img:tag") == true + Dev::BuildContainer.send(:registry_has?, "img:tag") == true + end + + test "service_containers parses the newline-separated docker ps names" do + Given "docker ps reporting two containers with surrounding noise" + Dev::BuildContainer.stubs(:`).returns("dev-snappy-abc-content-1\ndev-snappy-abc-content-2\n\n") + + Expect "the trimmed names are returned" + Dev::BuildContainer.send(:service_containers, "dev-snappy-abc-") == + ["dev-snappy-abc-content-1", "dev-snappy-abc-content-2"] + end + + test "container_running? reflects the inspected running state" do + Given "docker inspect reporting the state" + Dev::BuildContainer.stubs(:`).returns("true\n") + + Expect "the container counts as running" + Dev::BuildContainer.send(:container_running?, "dev-x") == true + end + + test "run_watched delegates the build to a watcher named for the container" do + Given "a watcher primed for the prewarm command" + argv = ["docker", "run", "--name", "dev-prewarm-1", "img:tag"] + watcher = mock + watcher.expects(:run).with(argv).returns(true) + Dev::BuildWatcher.expects(:new).with(container_name: "dev-prewarm-1").returns(watcher) + + Expect "the watcher's verdict is returned" + Dev::BuildContainer.send(:run_watched, argv, container: "dev-prewarm-1") == true + end end diff --git a/test/dev/build_watcher_test.rb b/test/dev/build_watcher_test.rb new file mode 100644 index 0000000..74d76c5 --- /dev/null +++ b/test/dev/build_watcher_test.rb @@ -0,0 +1,227 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/build_watcher" +require "fileutils" +require "open3" +require "stringio" +require "tmpdir" + +# Dev::BuildWatcher with the OS mechanism (run_once) replaced by a scripted sequence +# of results, so the retry/classify policy is tested without real processes. +class ScriptedWatcher < Dev::BuildWatcher + attr_reader :calls + + def initialize(results:, **kwargs) + super(**kwargs) + @results = results.dup + @calls = 0 + end + + def run_once(_argv) + @calls += 1 + @results.shift + end +end unless defined?(ScriptedWatcher) + +transform!(RSpock::AST::Transformation) +class BuildWatcherTest < Minitest::Test + def watcher(**kwargs) + Dev::BuildWatcher.new(container_name: "c", out: StringIO.new, stall_after: 300, cpu_floor: 5.0, **kwargs) + end + + def scripted(results, max_attempts: 5) + ScriptedWatcher.new(results: results, container_name: "c", out: StringIO.new, max_attempts: max_attempts) + end + + def result(outcome, output = "") + Dev::BuildWatcher::Result.new(outcome, output) + end + + test "stalled? is true only when both silent long enough and idle CPU" do + Given "a watcher with default thresholds" + w = watcher + + Expect "silent + idle is a stall; busy CPU or recent output is not" + w.stalled?(idle_seconds: 400, cpu_percent: 0.0) == true + w.stalled?(idle_seconds: 400, cpu_percent: 80.0) == false + w.stalled?(idle_seconds: 10, cpu_percent: 0.0) == false + end + + test "classify_failure retries on a Rosetta/clang crash signature" do + Given "output with a crash signature" + w = watcher + + Expect + w.classify_failure("rosetta error: failed to open elf") == :retry + w.classify_failure("clang++: error: unable to spawn process (posix_spawn failed)") == :retry + w.classify_failure("PLATFORM: Segmentation fault (core dumped)") == :retry + end + + test "classify_failure fails fast on a genuine compile error" do + Given "output with only a real compile error" + w = watcher + + Expect + w.classify_failure("main.cpp:3:5: error: expected ';'") == :fail + w.classify_failure("just some unrelated noise") == :fail + end + + test "run returns true on the first successful attempt" do + Given "a run that succeeds immediately" + w = scripted([result(:success)]) + + When "running" + ok = w.run(["docker", "run"]) + + Then + ok == true + w.calls == 1 + end + + test "run retries a hung build and succeeds on the next attempt" do + Given "a stall followed by a success" + w = scripted([result(:stalled, "...building..."), result(:success)]) + + When "running" + ok = w.run(["docker", "run"]) + + Then + ok == true + w.calls == 2 + end + + test "run retries a transient crash and succeeds" do + Given "a crash-signature failure followed by a success" + w = scripted([result(:failed, "rosetta error: boom"), result(:success)]) + + When "running" + ok = w.run(["docker", "run"]) + + Then + ok == true + w.calls == 2 + end + + test "run fails fast on a genuine compile error without retrying" do + Given "a failure whose output is a real compile error" + w = scripted([result(:failed, "main.cpp:3:5: error: nope"), result(:success)]) + + When "running" + ok = w.run(["docker", "run"]) + + Then "it stops after the first attempt" + ok == false + w.calls == 1 + end + + test "run gives up after the attempt cap on persistent stalls" do + Given "a build that stalls every attempt" + w = scripted([result(:stalled), result(:stalled), result(:stalled)], max_attempts: 3) + + When "running" + ok = w.run(["docker", "run"]) + + Then + ok == false + w.calls == 3 + end + + test "run_once spawns the command, streams its output, and reports success" do + Given "a watcher with a fast poll and a name no container holds" + w = Dev::BuildWatcher.new(container_name: "bw-test-#{Process.pid}", out: StringIO.new, poll: 1) + + When "running a real short-lived process" + result = w.send(:run_once, ["sh", "-c", "echo built"]) + + Then + result.outcome == :success + result.output.include?("built") == true + end + + test "run_once reports a non-zero exit as failed with the captured output" do + Given "a watcher with a fast poll and a name no container holds" + w = Dev::BuildWatcher.new(container_name: "bw-test-#{Process.pid}", out: StringIO.new, poll: 1) + + When "running a real process that fails" + result = w.send(:run_once, ["sh", "-c", "echo boom >&2; exit 3"]) + + Then "stderr rides the merged capture" + result.outcome == :failed + result.output.include?("boom") == true + end + + test "wait_or_kill kills a silent idle build and reports the stall" do + Given "a fake docker (idle stats, successful kill) and a silent long-running process" + tmpdir = Dir.mktmpdir("bw-fake-docker-") + fake_docker = File.join(tmpdir, "docker") + File.write(fake_docker, "#!/bin/sh\ncase \"$1\" in\n stats) exit 1 ;;\nesac\nexit 0\n") + FileUtils.chmod(0o755, fake_docker) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:#{original_path}" + io = StringIO.new + w = Dev::BuildWatcher.new(container_name: "bw-stall-test", out: io, poll: 0) + stdin, out, wait_thr = Open3.popen2e("sleep", "1") + + When "waiting on the silent process" + killed = w.send(:wait_or_kill, wait_thr) { 999.0 } + + Then "the stall is detected and the container kill is announced" + killed == true + assert_includes io.string, "killing hung container bw-stall-test" + + Cleanup + ENV["PATH"] = original_path + stdin.close + out.close + wait_thr.join + FileUtils.rm_rf(tmpdir) + end + + test "container_cpu parses the docker stats percentage" do + Given "a fake docker whose stats report 42.5%" + tmpdir = Dir.mktmpdir("bw-fake-docker-") + fake_docker = File.join(tmpdir, "docker") + File.write(fake_docker, "#!/bin/sh\nprintf '42.5%%\\n'\n") + FileUtils.chmod(0o755, fake_docker) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:#{original_path}" + w = watcher + + Expect "the percentage is parsed as a Float" + w.send(:container_cpu) == 42.5 + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "container_cpu reports idle when docker cannot be executed at all" do + Given "a PATH with no docker" + tmpdir = Dir.mktmpdir("bw-empty-path-") + original_path = ENV["PATH"] + ENV["PATH"] = tmpdir + w = watcher + + Expect "the unreadable value counts as idle" + w.send(:container_cpu) == 0.0 + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "now returns a monotonic Float for stall timing" do + Given "a watcher" + w = watcher + + When "sampling the clock twice" + first = w.send(:now) + second = w.send(:now) + + Then + first.is_a?(Float) == true + (second >= first) == true + end +end diff --git a/test/dev/builtins/cache_command_test.rb b/test/dev/builtins/cache_command_test.rb index 5ca76af..426bda1 100644 --- a/test/dev/builtins/cache_command_test.rb +++ b/test/dev/builtins/cache_command_test.rb @@ -60,7 +60,7 @@ class Dev::Builtins::CacheCommandTest < Minitest::Test ).once command = build_command(gc) context = build_context(build_container: config) - BuildContainer.stubs(:image_with_tag) + Dev::BuildContainer.stubs(:image_with_tag) .with(config, project_root: context.project_root) .returns("myregistry/myapp-linux:content-abc123") diff --git a/test/dev/builtins/install_deps_command_test.rb b/test/dev/builtins/install_deps_command_test.rb index be30205..133812d 100644 --- a/test/dev/builtins/install_deps_command_test.rb +++ b/test/dev/builtins/install_deps_command_test.rb @@ -5,7 +5,7 @@ require "dev/builtins/install_deps_command" require "fileutils" require "pathname" -require "shadowenv_ruby" +require "dev/shadowenv_ruby" require "tmpdir" transform!(RSpock::AST::Transformation) @@ -42,7 +42,7 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test ) # Headless boxes reach install-deps before any CommandRunner provisioning, # so the builtin provisions the toolchain itself — the true boundary. - ShadowenvRuby.expects(:ensure!).with(ruby_version: "4.0.1", project_root: root).once + Dev::ShadowenvRuby.expects(:ensure!).with(ruby_version: "4.0.1", project_root: root).once When "running install-deps" command.call(args: [], context: build_context(root)) @@ -72,7 +72,7 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test }, synchronizer: stub(sync: nil), ) - ShadowenvRuby.stubs(:ensure!) + Dev::ShadowenvRuby.stubs(:ensure!) When "running install-deps" command.call(args: [], context: build_context(root)) @@ -96,7 +96,7 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test # are faked. root = Pathname.new(Dir.mktmpdir("install-deps-default-")) command = Dev::Builtins::InstallDepsCommand.new(synchronizer: stub(sync: nil)) - ShadowenvRuby.stubs(:ensure!) + Dev::ShadowenvRuby.stubs(:ensure!) When "running install-deps" command.call(args: [], context: build_context(root)) diff --git a/test/dev/builtins/provide_image_command_test.rb b/test/dev/builtins/provide_image_command_test.rb index be16d1c..d06228e 100644 --- a/test/dev/builtins/provide_image_command_test.rb +++ b/test/dev/builtins/provide_image_command_test.rb @@ -25,7 +25,7 @@ class Dev::Builtins::ProvideImageCommandTest < Minitest::Test Given "a context with a build container and a stubbed resolution boundary" config = Dev::BuildContainerConfig.new(image: "myapp-linux", registry: "myregistry") context = build_context(config) - BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") + Dev::BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") command = Dev::Builtins::ProvideImageCommand.new old_stdout = $stdout $stdout = StringIO.new @@ -54,7 +54,7 @@ class Dev::Builtins::ProvideImageCommandTest < Minitest::Test Dev::Credentials.stubs(:resolve_build_args).with({ "GH_TOKEN" => "github/token" }) .returns({ "GH_TOKEN" => "s3cr3t" }) captured = {} - BuildContainer.stubs(:ensure_image!).with do |_cfg, **kwargs| + Dev::BuildContainer.stubs(:ensure_image!).with do |_cfg, **kwargs| captured = kwargs true end.returns("myregistry/myapp-linux:content-abc123") diff --git a/test/dev/builtins/reset_container_command_test.rb b/test/dev/builtins/reset_container_command_test.rb index b35a2e7..4854581 100644 --- a/test/dev/builtins/reset_container_command_test.rb +++ b/test/dev/builtins/reset_container_command_test.rb @@ -15,8 +15,8 @@ class Dev::Builtins::ResetContainerCommandTest < Minitest::Test Given "a context with a build container and a stubbed docker boundary" config = Dev::BuildContainerConfig.new(image: "myapp-linux", registry: "myregistry", persist: true) context = build_context(config) - BuildContainer.stubs(:image_with_tag).returns("myregistry/myapp-linux:content-abc123") - BuildContainer.stubs(:reset_service!).returns(["dev-myapp-linux-content-abc123"]) + Dev::BuildContainer.stubs(:image_with_tag).returns("myregistry/myapp-linux:content-abc123") + Dev::BuildContainer.stubs(:reset_service!).returns(["dev-myapp-linux-content-abc123"]) command = Dev::Builtins::ResetContainerCommand.new old_stdout = $stdout $stdout = StringIO.new @@ -35,8 +35,8 @@ class Dev::Builtins::ResetContainerCommandTest < Minitest::Test Given "a docker boundary with no persistent container" config = Dev::BuildContainerConfig.new(image: "myapp-linux", registry: "myregistry", persist: true) context = build_context(config) - BuildContainer.stubs(:image_with_tag).returns("myregistry/myapp-linux:content-abc123") - BuildContainer.stubs(:reset_service!).returns([]) + Dev::BuildContainer.stubs(:image_with_tag).returns("myregistry/myapp-linux:content-abc123") + Dev::BuildContainer.stubs(:reset_service!).returns([]) command = Dev::Builtins::ResetContainerCommand.new old_stdout = $stdout $stdout = StringIO.new diff --git a/test/dev/cd/accessor_test.rb b/test/dev/cd/accessor_test.rb index ebf05d7..a7b82e9 100644 --- a/test/dev/cd/accessor_test.rb +++ b/test/dev/cd/accessor_test.rb @@ -8,8 +8,9 @@ require "tmpdir" # A hook installer stand-in with a scripted ensure result, so accessor flows -# are tested without touching the user's real shell RC. -class FakeCdHookInstaller +# are tested without touching the user's real shell RC. Subclasses the real +# installer so the accessor's typed constructor accepts it. +class FakeCdHookInstaller < Dev::Cd::HookInstaller attr_reader :ensure_count def initialize(result: :already_present) diff --git a/test/dev/clone/accessor_test.rb b/test/dev/clone/accessor_test.rb index 6028d39..dff0eb1 100644 --- a/test/dev/clone/accessor_test.rb +++ b/test/dev/clone/accessor_test.rb @@ -8,8 +8,9 @@ require "tmpdir" # A cloner stand-in recording its calls, so accessor flows are tested without -# gh or the network. -class FakeGhCloner +# gh or the network. Subclasses the real cloner so the accessor's typed +# constructor accepts it. +class FakeGhCloner < Dev::Clone::GhCloner attr_reader :calls def initialize @@ -22,8 +23,9 @@ def clone(full_name, destination) end unless defined?(FakeGhCloner) # A hook installer stand-in with a scripted ensure result, so accessor flows -# are tested without touching the user's real shell RC. -class FakeCloneHookInstaller +# are tested without touching the user's real shell RC. Subclasses the real +# installer so the accessor's typed constructor accepts it. +class FakeCloneHookInstaller < Dev::Cd::HookInstaller attr_reader :ensure_count def initialize(result: :already_present) diff --git a/test/dev/clone/gh_cloner_test.rb b/test/dev/clone/gh_cloner_test.rb index 0ed6b3f..9626bc3 100644 --- a/test/dev/clone/gh_cloner_test.rb +++ b/test/dev/clone/gh_cloner_test.rb @@ -8,8 +8,9 @@ require "tmpdir" # An executor stand-in over the gh CLI boundary, recording argv and answering -# a scripted exit status — the one true boundary this module has. -class RecordingCloneExecutor +# a scripted exit status — the one true boundary this module has. Subclasses +# the real executor so the cloner's typed constructor accepts it. +class RecordingCloneExecutor < Dev::Clone::GhCloner::Executor attr_reader :argvs def initialize(success: true) diff --git a/test/dev/command_runner_test.rb b/test/dev/command_runner_test.rb index 840cbaf..bfa5cc1 100644 --- a/test/dev/command_runner_test.rb +++ b/test/dev/command_runner_test.rb @@ -5,8 +5,8 @@ require "dev/command_runner" require "dev/build_container_config" require "dev/credentials" -require "build_container" -require "shadowenv_ruby" +require "dev/build_container" +require "dev/shadowenv_ruby" transform!(RSpock::AST::Transformation) class CommandRunnerTest < Minitest::Test @@ -43,7 +43,7 @@ def teardown runner.exec_into(cmd) Then "the declared Ruby is ensured for the project root" - 1 * ShadowenvRuby.ensure!(ruby_version: "4.0.1", project_root: @project_root) + 1 * Dev::ShadowenvRuby.ensure!(ruby_version: "4.0.1", project_root: @project_root) Cleanup Dir.chdir(@original_cwd) @@ -202,8 +202,8 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/up.sh", repl: false) When "the image resolves and we run the command waiting" - BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") - BuildContainer.stubs(:docker_run_command) + Dev::BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") + Dev::BuildContainer.stubs(:docker_run_command) .returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/up.sh"]) runner.run_waiting(cmd) @@ -223,11 +223,11 @@ def teardown runner = Dev::CommandRunner.new(ui: @ui, ruby_version: "4.0.1", build_container: config, project_root: @project_root) cmd = Dev::ProjectCommand.new(run: "./bin/build.sh", repl: false) - When "BuildContainer.ensure_image! returns a tag and we run the command" - BuildContainer.expects(:ensure_image!) + When "Dev::BuildContainer.ensure_image! returns a tag and we run the command" + Dev::BuildContainer.expects(:ensure_image!) .with(config, project_root: @project_root, push: false, publish: false, build_args_provider: instance_of(Proc), secrets_provider: instance_of(Proc)) .returns("myregistry/myapp-linux:content-abc123") - BuildContainer.expects(:docker_run_command) + Dev::BuildContainer.expects(:docker_run_command) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, shell_cmd: "./bin/build.sh", volumes: [], env: {}) .returns(["docker", "run", "--rm", "-v", "#{@project_root}:/project", "-w", "/project", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) runner.exec_into(cmd) @@ -246,14 +246,14 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/build.sh", repl: false) When "the image resolves and the service container is ensured" - BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") - BuildContainer.expects(:ensure_service!) + Dev::BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") + Dev::BuildContainer.expects(:ensure_service!) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, volumes: ["/e:/e"]) .returns("dev-myapp-linux-content-abc123") - BuildContainer.expects(:docker_exec_command) + Dev::BuildContainer.expects(:docker_exec_command) .with("dev-myapp-linux-content-abc123", shell_cmd: "./bin/build.sh", env: {}) .returns(["docker", "exec", "-w", "/project", "dev-myapp-linux-content-abc123", "sh", "-c", "./bin/build.sh"]) - BuildContainer.expects(:docker_run_command).never + Dev::BuildContainer.expects(:docker_run_command).never runner.exec_into(cmd) Then "exec is called with the docker exec command, not docker run" @@ -302,11 +302,11 @@ def teardown runner = Dev::CommandRunner.new(ui: @ui, ruby_version: "4.0.1", build_container: config, project_root: @project_root) cmd = Dev::ProjectCommand.new(run: "./bin/test.sh", repl: false) - When "BuildContainer returns docker command and we run with args" - BuildContainer.expects(:ensure_image!) + When "Dev::BuildContainer returns docker command and we run with args" + Dev::BuildContainer.expects(:ensure_image!) .with(config, project_root: @project_root, push: false, publish: false, build_args_provider: instance_of(Proc), secrets_provider: instance_of(Proc)) .returns("myregistry/myapp-linux:content-abc123") - BuildContainer.expects(:docker_run_command) + Dev::BuildContainer.expects(:docker_run_command) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, shell_cmd: "./bin/test.sh --verbose", volumes: [], env: {}) .returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/test.sh --verbose"]) runner.exec_into(cmd, args: ["--verbose"]) @@ -330,8 +330,8 @@ def teardown ENV["WWISE_TOKEN"] = "tok-123" When "the image is ready and the command runs" - BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") - BuildContainer.expects(:docker_run_command) + Dev::BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") + Dev::BuildContainer.expects(:docker_run_command) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, shell_cmd: "./bin/build.sh", volumes: [], env: { "WWISE_TOKEN" => "tok-123" }) .returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) runner.exec_into(cmd) @@ -352,10 +352,10 @@ def teardown ENV["DEV_PUBLISH_IMAGE"] = "1" When "the command runs" - BuildContainer.expects(:ensure_image!) + Dev::BuildContainer.expects(:ensure_image!) .with(config, project_root: @project_root, push: false, publish: true, build_args_provider: instance_of(Proc), secrets_provider: instance_of(Proc)) .returns("myregistry/myapp-linux:content-abc123") - BuildContainer.stubs(:docker_run_command).returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) + Dev::BuildContainer.stubs(:docker_run_command).returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) runner.exec_into(cmd) Then "ensure_image! is asked to publish the resolved image" @@ -378,8 +378,8 @@ def teardown When "the credential is not stored and the command runs" Dev::Credentials.stubs(:load).with("wwise", "token").returns(nil) - BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") - BuildContainer.expects(:docker_run_command) + Dev::BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") + Dev::BuildContainer.expects(:docker_run_command) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, shell_cmd: "./bin/build.sh", volumes: [], env: {}) .returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) runner.exec_into(cmd) diff --git a/test/dev/credentials_test.rb b/test/dev/credentials_test.rb index a3fcc92..e1039f2 100644 --- a/test/dev/credentials_test.rb +++ b/test/dev/credentials_test.rb @@ -225,6 +225,21 @@ class Dev::CredentialsTest < Minitest::Test assert_includes error.message, "gh secret set CF_API_KEY" end + test "prompt_and_store raises MissingCredentialError when the pasted value is empty" do + When "prompting for a credential without a create URL" + Dev::Credentials.prompt_and_store( + "curseforge", "api_key", "CF_API_KEY", "CurseForge API key", nil + ) + + Then "an interactive stdin that pastes nothing raises" + _ * $stdin.tty? >> true + _ * $stdin.noecho >> "" + _ * $stdout.puts + _ * $stdout.print + error = raises Dev::Credentials::MissingCredentialError + assert_includes error.message, "No api_key provided" + end + test "credentials_path respects XDG_CONFIG_HOME" do Given "a custom XDG_CONFIG_HOME" ENV["XDG_CONFIG_HOME"] = "/custom/config" diff --git a/test/dev/deps/brew_integration_test.rb b/test/dev/deps/brew_integration_test.rb index 171fa3e..87fa98d 100644 --- a/test/dev/deps/brew_integration_test.rb +++ b/test/dev/deps/brew_integration_test.rb @@ -6,7 +6,10 @@ require "dev/deps/brew_repository" require "dev/deps/cache" require "dev/deps/dependency" +require "dev/deps/tap" +require "pathname" require "tmpdir" +require "uri" transform!(RSpock::AST::Transformation) class Dev::Deps::BrewIntegrationTest < Minitest::Test @@ -105,4 +108,45 @@ class Dev::Deps::BrewIntegrationTest < Minitest::Test Cleanup FileUtils.rm_rf(dir) end + + test "install_all registers a local file:// tap at its resolved path and publishes the tap env" do + Given "an integration with a project dir and a local tap" + dir = Dir.mktmpdir("dev-brew-int-test-") + cache = Dev::Deps::Cache.new(cache_dir: dir) + tap = Dev::Deps::Tap.new(name: "local/tap", url: "file://#{dir}/brew-tap") + integration = Dev::Deps::BrewIntegration.new( + repository: Dev::Deps::BrewRepository.new, cache: cache, taps: [tap], project_dir: dir, + ) + integration.expects(:system).with("brew", "tap", "local/tap", "#{dir}/brew-tap").returns(true) + + When "installing all (no deps, taps only)" + integration.install_all([]) + + Then "the local tap env vars point at the resolved tap" + ENV["TAP_NAME"] == "local/tap" + ENV["LOCAL_TAP_DIR"] == "#{dir}/brew-tap" + + Cleanup + ENV.delete("TAP_NAME") + ENV.delete("LOCAL_TAP_DIR") + FileUtils.rm_rf(dir) + end + + test "resolve_file_url resolves a ./ path against the project dir" do + Given "an integration with a project dir and a project-relative file URI" + dir = Dir.mktmpdir("dev-brew-int-test-") + integration = Dev::Deps::BrewIntegration.new( + repository: Dev::Deps::BrewRepository.new, cache: Dev::Deps::Cache.new(cache_dir: dir), project_dir: dir, + ) + relative_uri = URI::Generic.new("file", nil, nil, nil, nil, "./brew-tap", nil, nil, nil) + + When "resolving" + path = integration.send(:resolve_file_url, relative_uri, Pathname(dir)) + + Then + path == File.expand_path(File.join(dir, "brew-tap")) + + Cleanup + FileUtils.rm_rf(dir) + end end diff --git a/test/dev/deps/cache_gc_test.rb b/test/dev/deps/cache_gc_test.rb index 9c1d3ad..8548686 100644 --- a/test/dev/deps/cache_gc_test.rb +++ b/test/dev/deps/cache_gc_test.rb @@ -120,4 +120,56 @@ def lock_engine(lock_dir, base, version:) Cleanup FileUtils.rm_rf(dir) end + + test "running_mount_sources collects every running container's mount sources" do + Given "a gc whose docker capture reports two containers with mounts" + dir = Dir.mktmpdir("dev-cache-gc-test-") + gc = Dev::Deps::CacheGc.new(lockfile: Dev::Deps::Lockfile.new(dir: dir), out: StringIO.new) + gc.stubs(:capture).with(["docker", "ps", "-q"]).returns("abc\ndef\n") + gc.stubs(:capture) + .with(["docker", "inspect", "--format", "{{range .Mounts}}{{.Source}}\n{{end}}", "abc", "def"]) + .returns("/mnt/engine\n\n/mnt/cache\n") + + When "collecting mount sources" + sources = gc.send(:running_mount_sources) + + Then "both sources are present, blank lines dropped" + sources == Set.new(["/mnt/engine", "/mnt/cache"]) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "gc_docker leaves non-content tags and in-use images alone" do + Given "a gc whose docker capture reports only a live tag and a plain tag" + dir = Dir.mktmpdir("dev-cache-gc-test-") + gc = Dev::Deps::CacheGc.new(lockfile: Dev::Deps::Lockfile.new(dir: dir), out: StringIO.new) + gc.stubs(:capture) + .with(["docker", "images", "repo/img", "--format", "{{.Repository}}:{{.Tag}}"]) + .returns("repo/img:latest\nrepo/img:content-abc\n") + gc.stubs(:capture).with(["docker", "ps", "--format", "{{.Image}}"]).returns("repo/img:content-abc\n") + + When "pruning content tags" + gc.send(:gc_docker, image_ref: "repo/img", live_tag: nil) + + Then "nothing is removed: latest isn't a content tag, and the content tag is in use" + true # reaching here without a docker rmi spawn is the assertion (none is stubbed) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "capture returns stdout on success and empty string on failure or a missing binary" do + Given "a gc" + dir = Dir.mktmpdir("dev-cache-gc-test-") + gc = Dev::Deps::CacheGc.new(lockfile: Dev::Deps::Lockfile.new(dir: dir), out: StringIO.new) + + Expect "real subprocess results ride through; failures collapse to empty" + gc.send(:capture, ["sh", "-c", "echo hi"]) == "hi\n" + gc.send(:capture, ["sh", "-c", "exit 1"]) == "" + gc.send(:capture, ["dev-test-missing-binary-xyz"]) == "" + + Cleanup + FileUtils.rm_rf(dir) + end end diff --git a/test/dev/deps/cmake_integration_test.rb b/test/dev/deps/cmake_integration_test.rb index 8931d98..ba224f0 100644 --- a/test/dev/deps/cmake_integration_test.rb +++ b/test/dev/deps/cmake_integration_test.rb @@ -9,6 +9,7 @@ require "dev/deps/dependency_declaration" require "dev/deps/cache" require "dev/deps/dependency" +require "pathname" require "tmpdir" # Stub repository for end-to-end resolver tests. @@ -377,4 +378,31 @@ def prepopulate_dep(root, name) Cleanup FileUtils.rm_rf(dir) end + + test "populated? treats a url dep's non-empty extract dir as populated: #{expected}" do + Given "a source dir without .git or CMakeLists.txt" + dir = Dir.mktmpdir("dev-cmake-int-test-") + integration = Dev::Deps::CmakeIntegration.new( + repository: Dev::Deps::UrlRepository.new, cache: Dev::Deps::Cache.new(cache_dir: dir), project_root: dir, + ) + dest = Pathname(dir) / "boost-src" + FileUtils.mkdir_p(dest) + File.write(dest / "README.md", "extracted") if has_children + dep = Dev::Deps::Dependency.new( + name: "boost", integration: :cmake, group: :app, + version: "1.90.0", hash: nil, metadata: metadata, + ) + + Expect "only a url dep with extracted children counts" + integration.send(:populated?, dest, dep) == expected + + Cleanup + FileUtils.rm_rf(dir) + + Where + metadata | has_children | expected + { "url" => "https://example.com/b.tar.gz" } | true | true + { "url" => "https://example.com/b.tar.gz" } | false | false + {} | true | false + end end diff --git a/test/dev/deps/ficsit_repository_test.rb b/test/dev/deps/ficsit_repository_test.rb index f143d97..49a732b 100644 --- a/test/dev/deps/ficsit_repository_test.rb +++ b/test/dev/deps/ficsit_repository_test.rb @@ -411,4 +411,22 @@ class Dev::Deps::FicsitRepositoryTest < Minitest::Test dep.dependencies.size == 2 dep.dependencies.map { |d| d[:name] }.sort == ["RequiredLib", "SML"] end + + test "post_graphql posts the query over TLS and returns the 2xx response" do + Given "a repo and a stubbed HTTP transport" + repo = Dev::Deps::FicsitRepository.new + response = stub(body: "{}", is_a?: true) + response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + http = mock + http.expects(:use_ssl=).with(true) + http.expects(:request).with(instance_of(Net::HTTP::Post)).returns(response) + endpoint = Dev::Deps::FicsitRepository::GRAPHQL_ENDPOINT + Net::HTTP.expects(:new).with(endpoint.host, endpoint.port).returns(http) + + When "posting a query body" + result = repo.send(:post_graphql, { query: "query {}", variables: {} }) + + Then + result == response + end end diff --git a/test/dev/deps/llvm_compat_test.rb b/test/dev/deps/llvm_compat_test.rb index 05bbadd..05ab07e 100644 --- a/test/dev/deps/llvm_compat_test.rb +++ b/test/dev/deps/llvm_compat_test.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "test_helper" -require "shadowenv_llvm" +require "dev/shadowenv_llvm" require "tmpdir" require "yaml" @@ -22,7 +22,7 @@ class LlvmCompatTest < Minitest::Test File.write(File.join(dir, "build-deps.lock"), "# Generated by dev. Do not edit.\n" + YAML.dump(yaml_content)) When "checking for llvm" - result = ShadowenvLlvm.project_needs_llvm?(Pathname(dir)) + result = Dev::ShadowenvLlvm.project_needs_llvm?(Pathname(dir)) Then result == true @@ -44,7 +44,7 @@ class LlvmCompatTest < Minitest::Test File.write(File.join(dir, "build-deps.lock"), YAML.dump(yaml_content)) When "checking for llvm" - result = ShadowenvLlvm.project_needs_llvm?(Pathname(dir)) + result = Dev::ShadowenvLlvm.project_needs_llvm?(Pathname(dir)) Then result == false @@ -59,7 +59,7 @@ class LlvmCompatTest < Minitest::Test File.write(File.join(dir, "build-deps.lock"), "brew llvm\nbrew cmake\n") When "checking for llvm" - result = ShadowenvLlvm.project_needs_llvm?(Pathname(dir)) + result = Dev::ShadowenvLlvm.project_needs_llvm?(Pathname(dir)) Then result == true @@ -73,7 +73,7 @@ class LlvmCompatTest < Minitest::Test dir = Dir.mktmpdir("dev-llvm-compat-") When "checking for llvm" - result = ShadowenvLlvm.project_needs_llvm?(Pathname(dir)) + result = Dev::ShadowenvLlvm.project_needs_llvm?(Pathname(dir)) Then result == false diff --git a/test/dev/deps/pip_integration_test.rb b/test/dev/deps/pip_integration_test.rb index c1c824d..734e6fb 100644 --- a/test/dev/deps/pip_integration_test.rb +++ b/test/dev/deps/pip_integration_test.rb @@ -3,7 +3,7 @@ require "test_helper" require "dev/deps/pip_integration" -require "shadowenv_python" +require "dev/shadowenv_python" require "tmpdir" transform!(RSpock::AST::Transformation) @@ -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) @@ -48,7 +51,7 @@ def dep(name, version) Given "an integration with one pinned dep and a stubbed venv + pip" tmpdir = Dir.mktmpdir("pip-integration-") integration = Dev::Deps::PipIntegration.new(repository: nil, cache: nil, project_root: tmpdir, python_version: "3.12") - ShadowenvPython.stubs(:ensure_venv!).returns(File.join(tmpdir, ".venv")) + Dev::ShadowenvPython.stubs(:ensure_venv!).returns(File.join(tmpdir, ".venv")) ok = stub(success?: true) When "installing" 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/dev/deps/steam_cmd_test.rb b/test/dev/deps/steam_cmd_test.rb index 1b2bde8..40314bc 100644 --- a/test/dev/deps/steam_cmd_test.rb +++ b/test/dev/deps/steam_cmd_test.rb @@ -88,4 +88,69 @@ class Dev::Deps::SteamCmdTest < Minitest::Test expected = RUBY_PLATFORM.include?("darwin") ? Dev::Deps::SteamCmd::MACOS_URL : Dev::Deps::SteamCmd::LINUX_URL url == expected end + + test "ensure! returns the script path without bootstrapping when it is already executable" do + Given "a warm install dir with an executable steamcmd.sh" + dir = Dir.mktmpdir("steamcmd-test-") + script = File.join(dir, "steamcmd.sh") + File.write(script, "#!/bin/sh\n") + File.chmod(0o755, script) + + When "ensuring" + path = Dev::Deps::SteamCmd.ensure!(dir) + + Then + path == script + + Cleanup + FileUtils.rm_rf(dir) + end + + test "ensure! raises BootstrapError when the curl|tar pipeline fails" do + Given "an empty install dir and a failing download pipeline" + dir = Dir.mktmpdir("steamcmd-test-") + Kernel.expects(:system).with("sh", "-c", regexp_matches(/curl -fsSL .+ \| tar -xz -C /)).returns(false) + + When "ensuring" + Dev::Deps::SteamCmd.ensure!(dir) + + Then + raises Dev::Deps::SteamCmd::BootstrapError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "run ensures the install and executes steamcmd.sh with the +commands" do + Given "a warm install dir whose steamcmd.sh echoes its arguments" + dir = Dir.mktmpdir("steamcmd-test-") + script = File.join(dir, "steamcmd.sh") + File.write(script, "#!/bin/sh\necho \"$@\"\n") + File.chmod(0o755, script) + + When "running" + out, _err, status = Dev::Deps::SteamCmd.run("+login", "anonymous", dir: dir) + + Then + out == "+login anonymous\n" + status.success? == true + + Cleanup + FileUtils.rm_rf(dir) + end + + test "ensure! raises BootstrapError when the pipeline succeeds but produces no script" do + Given "an empty install dir and a pipeline that extracts nothing" + dir = Dir.mktmpdir("steamcmd-test-") + Kernel.expects(:system).with("sh", "-c", regexp_matches(/curl -fsSL .+ \| tar -xz -C /)).returns(true) + + When "ensuring" + Dev::Deps::SteamCmd.ensure!(dir) + + Then + raises Dev::Deps::SteamCmd::BootstrapError + + Cleanup + FileUtils.rm_rf(dir) + end end diff --git a/test/dev/deps/url_repository_test.rb b/test/dev/deps/url_repository_test.rb index 624f58e..99b57d6 100644 --- a/test/dev/deps/url_repository_test.rb +++ b/test/dev/deps/url_repository_test.rb @@ -58,4 +58,18 @@ class Dev::Deps::UrlRepositoryTest < Minitest::Test Then raises Dev::Deps::UrlRepository::DownloadError end + + test "download_to_tempfile returns the temp path when curl succeeds" do + Given "a stubbed successful curl" + repo = Dev::Deps::UrlRepository.new + Open3.stubs(:capture3) + .with("curl", "-fsSL", "-o", anything, "https://example.com/ok.tar.gz") + .returns(["", "", stub(success?: true)]) + + When "downloading" + path = repo.send(:download_to_tempfile, "https://example.com/ok.tar.gz", "ok") + + Then "the tempfile path comes back for hashing" + File.exist?(path) == true + end end diff --git a/test/dev/global_dispatch_test.rb b/test/dev/global_dispatch_test.rb index 9063091..929115a 100644 --- a/test/dev/global_dispatch_test.rb +++ b/test/dev/global_dispatch_test.rb @@ -324,10 +324,13 @@ class Dev::GlobalDispatchTest < Minitest::Test private - # A hook installer that never touches the real shell RC. + # A hook installer that never touches the real shell RC. Subclasses the + # real installer to satisfy the accessors' typed constructors. + class QuietHookInstaller < Dev::Cd::HookInstaller + def ensure_installed = :already_present + end + def quiet_hook_installer - installer = Object.new - def installer.ensure_installed = :already_present - installer + QuietHookInstaller.new end end diff --git a/test/dev/plan/executor_test.rb b/test/dev/plan/executor_test.rb new file mode 100644 index 0000000..f8e9273 --- /dev/null +++ b/test/dev/plan/executor_test.rb @@ -0,0 +1,37 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/plan/executor" + +transform!(RSpock::AST::Transformation) +class Dev::Plan::ExecutorTest < Minitest::Test + test "capture returns stdout, stderr, and success from a real process" do + When "capturing a real shell command" + out, err, ok = Dev::Plan::Executor.new.capture("sh", "-c", "echo out; echo err >&2") + + Then + out == "out\n" + err == "err\n" + ok == true + end + + test "capture pipes stdin data to the subprocess" do + When "capturing with piped stdin" + out, _err, ok = Dev::Plan::Executor.new.capture("cat", stdin: "piped payload") + + Then + out == "piped payload" + ok == true + end + + test "capture reports a missing binary as a failure instead of raising" do + When "capturing a command that does not exist" + out, err, ok = Dev::Plan::Executor.new.capture("dev-test-missing-binary-xyz") + + Then "the ENOENT message rides the stderr slot" + out == "" + err.empty? == false + ok == false + end +end diff --git a/test/dev/runner_setup_test.rb b/test/dev/runner_setup_test.rb index 6cf2f6d..0491472 100644 --- a/test/dev/runner_setup_test.rb +++ b/test/dev/runner_setup_test.rb @@ -308,4 +308,40 @@ def capturing_executor(captured) platform.start_with?("linux-") end end + + test "Executor#capture returns stdout, stderr, and success from a real process" do + When "capturing a real shell command" + out, err, ok = Dev::RunnerSetup::Executor.new.capture("sh", "-c", "echo out; echo err >&2") + + Then + out == "out\n" + err == "err\n" + ok == true + end + + test "Executor#capture reports a missing binary as a failure instead of raising" do + When "capturing a command that does not exist" + out, err, ok = Dev::RunnerSetup::Executor.new.capture("dev-test-missing-binary-xyz") + + Then "the ENOENT message rides the stderr slot" + out == "" + err.empty? == false + ok == false + end + + test "Executor#system runs a real process, honoring chdir" do + Given "a temp working directory" + dir = Dir.mktmpdir("runner-setup-exec-") + + When "running real commands" + ok = Dev::RunnerSetup::Executor.new.system("sh", "-c", "test \"$(pwd)\" = \"$1\"", "--", File.realpath(dir), chdir: dir) + failed = Dev::RunnerSetup::Executor.new.system("sh", "-c", "exit 1") + + Then + ok == true + failed == false + + Cleanup + FileUtils.rm_rf(dir) + end end diff --git a/test/dev/runner_test.rb b/test/dev/runner_test.rb index 68fd429..9671e00 100644 --- a/test/dev/runner_test.rb +++ b/test/dev/runner_test.rb @@ -5,7 +5,7 @@ require "dev" require "dev/runner" require "fileutils" -require "shadowenv_ruby" +require "dev/shadowenv_ruby" require "stringio" require "tempfile" require "tmpdir" @@ -224,7 +224,7 @@ class RunnerTest < Minitest::Test build: { "container" => { "image" => "myapp-linux", "registry" => "myregistry" } }, out: usage, ) - BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") + Dev::BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") old_stdout = $stdout $stdout = StringIO.new @@ -298,7 +298,7 @@ class RunnerTest < Minitest::Test true } ui = fake_ui runner = build_runner(commands: {}, command_service: command_service, ui: ui, root: root) - ShadowenvRuby.stubs(:resolve_ruby_version).with("9.9.9").returns("9.9.9") + Dev::ShadowenvRuby.stubs(:resolve_ruby_version).with("9.9.9").returns("9.9.9") When "we run a command with args" runner.run(["test", "--fast"]) @@ -410,7 +410,7 @@ def build_runner(name: "testproject", commands: {}, build: nil, runner: nil, com ui: fake_ui, out: StringIO.new, root: nil) root ||= (@tmp_roots ||= []).push(Pathname.new(Dir.mktmpdir("runner-test-"))).fetch(-1) Dev.stubs(:target_project_root).returns(root) - ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") + Dev::ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") yaml = { "name" => name, "commands" => commands } yaml["build"] = build if build diff --git a/test/lib/shadowenv_llvm_test.rb b/test/dev/shadowenv_llvm_test.rb similarity index 57% rename from test/lib/shadowenv_llvm_test.rb rename to test/dev/shadowenv_llvm_test.rb index e722b0f..846bb56 100644 --- a/test/lib/shadowenv_llvm_test.rb +++ b/test/dev/shadowenv_llvm_test.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "test_helper" -require "shadowenv_llvm" +require "dev/shadowenv_llvm" require "fileutils" require "tmpdir" @@ -18,11 +18,11 @@ class ShadowenvLlvmTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "520_llvm.lisp"), - ShadowenvLlvm.generate_llvm_lisp(llvm_prefix) + Dev::ShadowenvLlvm.generate_llvm_lisp(llvm_prefix) ) Expect ".provisioned? returns true" - ShadowenvLlvm.provisioned?(llvm_prefix, project_root: tmpdir) == true + Dev::ShadowenvLlvm.provisioned?(llvm_prefix, project_root: tmpdir) == true Cleanup FileUtils.rm_rf(tmpdir) @@ -35,11 +35,11 @@ class ShadowenvLlvmTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "520_llvm.lisp"), - ShadowenvLlvm.generate_llvm_lisp("/usr/local/opt/llvm") + Dev::ShadowenvLlvm.generate_llvm_lisp("/usr/local/opt/llvm") ) Expect ".provisioned? returns false for different prefix" - ShadowenvLlvm.provisioned?("/opt/homebrew/opt/llvm", project_root: tmpdir) == false + Dev::ShadowenvLlvm.provisioned?("/opt/homebrew/opt/llvm", project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -50,7 +50,7 @@ class ShadowenvLlvmTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-llvm-test-") Expect ".provisioned? returns false" - ShadowenvLlvm.provisioned?("/opt/homebrew/opt/llvm", project_root: tmpdir) == false + Dev::ShadowenvLlvm.provisioned?("/opt/homebrew/opt/llvm", project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -60,7 +60,7 @@ class ShadowenvLlvmTest < Minitest::Test test "generate_llvm_lisp contains provide directive with prefix" do When "we generate lisp" - result = ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") + result = Dev::ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") Then "the provide directive includes the prefix" assert_includes result, '(provide "llvm" "/opt/homebrew/opt/llvm")' @@ -68,7 +68,7 @@ class ShadowenvLlvmTest < Minitest::Test test "generate_llvm_lisp prepends llvm bin to PATH" do When "we generate lisp" - result = ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") + result = Dev::ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") Then "PATH includes llvm bin" assert_includes result, '(env/prepend-to-pathlist "PATH" "/opt/homebrew/opt/llvm/bin")' @@ -76,7 +76,7 @@ class ShadowenvLlvmTest < Minitest::Test test "generate_llvm_lisp sets CC" do When "we generate lisp" - result = ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") + result = Dev::ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") Then "CC is set to clang" assert_includes result, '(env/set "CC" "/opt/homebrew/opt/llvm/bin/clang")' @@ -84,7 +84,7 @@ class ShadowenvLlvmTest < Minitest::Test test "generate_llvm_lisp sets CXX" do When "we generate lisp" - result = ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") + result = Dev::ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") Then "CXX is set to clang++" assert_includes result, '(env/set "CXX" "/opt/homebrew/opt/llvm/bin/clang++")' @@ -92,7 +92,7 @@ class ShadowenvLlvmTest < Minitest::Test test "generate_llvm_lisp sets LDFLAGS with rpath" do When "we generate lisp" - result = ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") + result = Dev::ShadowenvLlvm.generate_llvm_lisp("/opt/homebrew/opt/llvm") Then "LDFLAGS includes the c++ lib path" assert_includes result, '(env/set "LDFLAGS" "-L/opt/homebrew/opt/llvm/lib/c++ -Wl,-rpath,/opt/homebrew/opt/llvm/lib/c++")' @@ -106,10 +106,10 @@ class ShadowenvLlvmTest < Minitest::Test llvm_prefix = "/opt/homebrew/opt/llvm" When "we call setup!" - result = ShadowenvLlvm.setup!(project_root: tmpdir, llvm_prefix: llvm_prefix) + result = Dev::ShadowenvLlvm.setup!(project_root: tmpdir, llvm_prefix: llvm_prefix) Then "it returns true and writes the lisp file" - _ * ShadowenvLlvm.method(:system) >> true + _ * Dev::ShadowenvLlvm.method(:system) >> true result == true lisp_path = File.join(tmpdir, ".shadowenv.d", "520_llvm.lisp") assert File.exist?(lisp_path), "Expected lisp file at #{lisp_path}" @@ -124,13 +124,59 @@ class ShadowenvLlvmTest < Minitest::Test Given "no LLVM prefix" When "we call setup! without a prefix" - result = ShadowenvLlvm.setup!(project_root: Dir.mktmpdir("shadowenv-llvm-none-"), llvm_prefix: nil) + result = Dev::ShadowenvLlvm.setup!(project_root: Dir.mktmpdir("shadowenv-llvm-none-"), llvm_prefix: nil) Then "it returns false" - _ * ShadowenvLlvm.detect_llvm_prefix >> nil + _ * Dev::ShadowenvLlvm.detect_llvm_prefix >> nil result == false end + # --- detect_llvm_prefix / brew_prefix_for --- + + test "detect_llvm_prefix returns the prefix brew reports for an installed formula" do + Given "a fake brew printing an existing keg directory" + tmpdir = Dir.mktmpdir("fake-brew-llvm-") + prefix = File.join(tmpdir, "opt", "llvm@22") + FileUtils.mkdir_p(prefix) + write_fake_brew(tmpdir, prints: prefix) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "the printed prefix is returned" + Dev::ShadowenvLlvm.detect_llvm_prefix == prefix + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "detect_llvm_prefix returns nil when no formula resolves to a real keg" do + Given "a fake brew printing a directory that does not exist" + tmpdir = Dir.mktmpdir("fake-brew-llvm-") + write_fake_brew(tmpdir, prints: File.join(tmpdir, "no-such-keg")) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "no prefix is found" + Dev::ShadowenvLlvm.detect_llvm_prefix.nil? == true + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "detect_llvm_prefix returns nil when brew is absent" do + Given "a PATH without brew" + original_path = ENV["PATH"] + ENV["PATH"] = "/usr/bin:/bin" + + Expect "no prefix is found" + Dev::ShadowenvLlvm.detect_llvm_prefix.nil? == true + + Cleanup + ENV["PATH"] = original_path + end + # --- ci_or_linux? --- test "ci_or_linux? returns true when CI env is set" do @@ -139,7 +185,7 @@ class ShadowenvLlvmTest < Minitest::Test ENV["CI"] = "true" Expect "ci_or_linux? returns true" - ShadowenvLlvm.ci_or_linux? == true + Dev::ShadowenvLlvm.ci_or_linux? == true Cleanup ENV["CI"] = original @@ -151,7 +197,7 @@ class ShadowenvLlvmTest < Minitest::Test ENV.delete("CI") When "we check ci_or_linux?" - result = ShadowenvLlvm.ci_or_linux? + result = Dev::ShadowenvLlvm.ci_or_linux? Then "it returns true on Linux, false on macOS" result == RUBY_PLATFORM.include?("linux") @@ -159,4 +205,12 @@ class ShadowenvLlvmTest < Minitest::Test Cleanup ENV["CI"] = original if original end + + private + + def write_fake_brew(dir, prints:) + fake_brew = File.join(dir, "brew") + File.write(fake_brew, "#!/bin/sh\nprintf '%s\\n' \"#{prints}\"\n") + FileUtils.chmod(0o755, fake_brew) + end end diff --git a/test/lib/shadowenv_lua_test.rb b/test/dev/shadowenv_lua_test.rb similarity index 63% rename from test/lib/shadowenv_lua_test.rb rename to test/dev/shadowenv_lua_test.rb index 49da92f..6c91384 100644 --- a/test/lib/shadowenv_lua_test.rb +++ b/test/dev/shadowenv_lua_test.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "test_helper" -require "shadowenv_lua" +require "dev/shadowenv_lua" require "fileutils" require "tmpdir" @@ -15,11 +15,11 @@ class ShadowenvLuaTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "510_lua.lisp"), - ShadowenvLua.generate_lua_lisp("5.1"), + Dev::ShadowenvLua.generate_lua_lisp("5.1"), ) Expect "provisioned? returns true" - ShadowenvLua.provisioned?("5.1", project_root: tmpdir) == true + Dev::ShadowenvLua.provisioned?("5.1", project_root: tmpdir) == true Cleanup FileUtils.rm_rf(tmpdir) @@ -30,7 +30,7 @@ class ShadowenvLuaTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-lua-test-") Expect "provisioned? returns false" - ShadowenvLua.provisioned?("5.1", project_root: tmpdir) == false + Dev::ShadowenvLua.provisioned?("5.1", project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -43,11 +43,11 @@ class ShadowenvLuaTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "510_lua.lisp"), - ShadowenvLua.generate_lua_lisp("5.4"), + Dev::ShadowenvLua.generate_lua_lisp("5.4"), ) Expect "provisioned? returns false for mismatched version" - ShadowenvLua.provisioned?("5.1", project_root: tmpdir) == false + Dev::ShadowenvLua.provisioned?("5.1", project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -55,7 +55,7 @@ class ShadowenvLuaTest < Minitest::Test test "generate_lua_lisp contains provide directive" do When "generating lisp for 5.1" - result = ShadowenvLua.generate_lua_lisp("5.1") + result = Dev::ShadowenvLua.generate_lua_lisp("5.1") Then "the lisp includes the provide directive" assert_includes result, '(provide "lua" "5.1")' @@ -63,7 +63,7 @@ class ShadowenvLuaTest < Minitest::Test test "generate_lua_lisp sets LUA_PATH for lua_modules" do When "generating lisp for 5.1" - result = ShadowenvLua.generate_lua_lisp("5.1") + result = Dev::ShadowenvLua.generate_lua_lisp("5.1") Then "LUA_PATH references lua_modules" assert_includes result, "LUA_PATH" @@ -72,7 +72,7 @@ class ShadowenvLuaTest < Minitest::Test test "generate_lua_lisp sets LUA_CPATH for lua_modules" do When "generating lisp for 5.1" - result = ShadowenvLua.generate_lua_lisp("5.1") + result = Dev::ShadowenvLua.generate_lua_lisp("5.1") Then "LUA_CPATH is configured" assert_includes result, "LUA_CPATH" @@ -80,7 +80,7 @@ class ShadowenvLuaTest < Minitest::Test test "generate_lua_lisp prepends lua and luarocks to PATH" do When "generating lisp for 5.1" - result = ShadowenvLua.generate_lua_lisp("5.1") + result = Dev::ShadowenvLua.generate_lua_lisp("5.1") Then "PATH includes lua formula and luarocks" assert_includes result, "lua@5.1" @@ -92,7 +92,7 @@ class ShadowenvLuaTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-lua-setup-") When "running setup! with all system calls stubbed" - result = ShadowenvLua.setup!(lua_version: "5.1", project_root: tmpdir) + result = Dev::ShadowenvLua.setup!(lua_version: "5.1", project_root: tmpdir) Then "it writes the lisp file and returns true" _ * Kernel.system >> true @@ -111,8 +111,8 @@ class ShadowenvLuaTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-lua-setup-") When "brew list returns false and brew install also fails" - error = assert_raises(ShadowenvLua::BrewInstallError) do - ShadowenvLua.setup!(lua_version: "5.1", project_root: tmpdir) + error = assert_raises(Dev::ShadowenvLua::BrewInstallError) do + Dev::ShadowenvLua.setup!(lua_version: "5.1", project_root: tmpdir) end Then "the error mentions the failing formula" @@ -122,4 +122,34 @@ class ShadowenvLuaTest < Minitest::Test Cleanup FileUtils.rm_rf(tmpdir) end + + test "ensure_homebrew_lua! raises BrewInstallError when the luarocks install fails" do + Given "a fake brew with lua installed but luarocks missing and uninstallable" + tmpdir = Dir.mktmpdir("fake-brew-lua-") + fake_brew = File.join(tmpdir, "brew") + File.write(fake_brew, <<~SH) + #!/bin/sh + case "$1 $2" in + "list lua@5.1") exit 0 ;; + "list luarocks") exit 1 ;; + "install luarocks") exit 1 ;; + esac + exit 0 + SH + FileUtils.chmod(0o755, fake_brew) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + When "ensuring the Homebrew lua toolchain" + error = assert_raises(Dev::ShadowenvLua::BrewInstallError) do + Dev::ShadowenvLua.ensure_homebrew_lua!("5.1") + end + + Then "the error names the failing luarocks install" + error.message.include?("luarocks") + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end end diff --git a/test/dev/shadowenv_python_test.rb b/test/dev/shadowenv_python_test.rb new file mode 100644 index 0000000..bcb7375 --- /dev/null +++ b/test/dev/shadowenv_python_test.rb @@ -0,0 +1,393 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/shadowenv_python" +require "fileutils" +require "tmpdir" + +transform!(RSpock::AST::Transformation) +class ShadowenvPythonTest < Minitest::Test + test "generate_python_lisp contains the provide directive" do + When "generating lisp for 3.12" + result = Dev::ShadowenvPython.generate_python_lisp("3.12", "/tmp/proj/.venv") + + Then "the lisp declares the provided version" + assert_includes result, '(provide "python" "3.12")' + end + + test "generate_python_lisp activates the venv on PATH and VIRTUAL_ENV by absolute path" do + When "generating lisp for 3.12" + result = Dev::ShadowenvPython.generate_python_lisp("3.12", "/tmp/proj/.venv") + + Then "it sets VIRTUAL_ENV + PYTHONHOME and prepends the venv bin as a literal path" + assert_includes result, '(env/set "VIRTUAL_ENV" "/tmp/proj/.venv")' + assert_includes result, '(env/set "PYTHONHOME" ())' + assert_includes result, '(env/prepend-to-pathlist "PATH" "/tmp/proj/.venv/bin")' + end + + test "provisioned? is true only when the lisp matches and the venv exists" do + Given "a project with a matching lisp and a .venv directory" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + shadowenv_d = File.join(tmpdir, ".shadowenv.d") + FileUtils.mkdir_p(shadowenv_d) + FileUtils.mkdir_p(File.join(tmpdir, ".venv")) + File.write(File.join(shadowenv_d, "540_python.lisp"), + Dev::ShadowenvPython.generate_python_lisp("3.12", File.join(tmpdir, ".venv"))) + + Expect "provisioned? returns true" + Dev::ShadowenvPython.provisioned?("3.12", project_root: tmpdir) == true + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "provisioned? is false when the venv is missing even if the lisp exists" do + Given "a matching lisp but no .venv directory" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + shadowenv_d = File.join(tmpdir, ".shadowenv.d") + FileUtils.mkdir_p(shadowenv_d) + File.write(File.join(shadowenv_d, "540_python.lisp"), + Dev::ShadowenvPython.generate_python_lisp("3.12", File.join(tmpdir, ".venv"))) + + Expect "provisioned? returns false" + Dev::ShadowenvPython.provisioned?("3.12", project_root: tmpdir) == false + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "setup! writes the lisp and reports success" do + Given "a project root and a provisionable venv" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + venv = File.join(tmpdir, ".venv") + + When "we run full provisioning" + result = Dev::ShadowenvPython.setup!(python_version: "3.12", project_root: tmpdir) + + Then "the lisp is written and setup reports success" + _ * Dev::ShadowenvPython.ensure_venv!(python_version: "3.12", project_root: tmpdir) >> venv + result == true + lisp = File.read(File.join(tmpdir, ".shadowenv.d", "540_python.lisp")) + assert_includes lisp, '(provide "python" "3.12")' + assert_includes lisp, %((env/set "VIRTUAL_ENV" "#{venv}")) + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "ensure_venv! creates the venv with the resolved interpreter" do + Given "a resolved interpreter that materializes a venv on demand" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + python_bin = write_venv_capable_python(tmpdir) + + When "we ensure the venv" + result = Dev::ShadowenvPython.ensure_venv!(python_version: "3.12", project_root: tmpdir) + + Then "the venv exists at the project root with a working python" + _ * Dev::ShadowenvPython.ensure_homebrew_python!("3.12") >> python_bin + result == File.join(tmpdir, ".venv") + File.executable?(File.join(tmpdir, ".venv", "bin", "python")) == true + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "ensure_venv! reuses an existing venv without recreating it" do + Given "a project with a venv already in place" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + venv_bin = File.join(tmpdir, ".venv", "bin") + FileUtils.mkdir_p(venv_bin) + write_fake_executable(File.join(venv_bin, "python"), "#!/bin/sh\nexit 0\n") + failing_python = write_fake_executable(File.join(tmpdir, "python-never-called"), "#!/bin/sh\nexit 1\n") + + When "we ensure the venv" + result = Dev::ShadowenvPython.ensure_venv!(python_version: "3.12", project_root: tmpdir) + + Then "the existing venv is returned and no interpreter call is needed" + _ * Dev::ShadowenvPython.ensure_homebrew_python!("3.12") >> failing_python + result == File.join(tmpdir, ".venv") + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "ensure_venv! raises when venv creation fails" do + Given "an installed formula whose interpreter cannot create a venv" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + prefix = File.join(tmpdir, "opt", "python@3.12") + FileUtils.mkdir_p(File.join(prefix, "bin")) + write_fake_executable(File.join(prefix, "bin", "python3.12"), "#!/bin/sh\nexit 1\n") + write_fake_brew(tmpdir, prefix: prefix) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + When "we ensure the venv" + error = assert_raises(Dev::ShadowenvPython::BrewInstallError) do + Dev::ShadowenvPython.ensure_venv!(python_version: "3.12", project_root: tmpdir) + end + + Then "the error names the failed venv creation" + assert_includes error.message, "-m venv" + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "ensure_pip! bootstraps pip via ensurepip when it is missing" do + Given "a venv python without pip whose ensurepip succeeds" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + log = File.join(tmpdir, "invocations.log") + venv_python = write_fake_executable(File.join(tmpdir, "python"), <<~SCRIPT) + #!/bin/sh + echo "$@" >> "#{log}" + case "$2" in + pip) exit 1 ;; + esac + exit 0 + SCRIPT + + When "we ensure pip" + Dev::ShadowenvPython.ensure_pip!(venv_python) + + Then "ensurepip ran" + assert_includes File.read(log), "ensurepip --upgrade" + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "ensure_pip! raises when pip cannot be bootstrapped" do + Given "a venv python where both pip and ensurepip fail" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + venv_python = write_fake_executable(File.join(tmpdir, "python"), "#!/bin/sh\nexit 1\n") + + Expect "a BrewInstallError about pip" + error = assert_raises(Dev::ShadowenvPython::BrewInstallError) do + Dev::ShadowenvPython.ensure_pip!(venv_python) + end + assert_includes error.message, "pip" + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "provisioned? is false for a different version" do + Given "a lisp provisioned for 3.11 and a venv" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + shadowenv_d = File.join(tmpdir, ".shadowenv.d") + FileUtils.mkdir_p(shadowenv_d) + FileUtils.mkdir_p(File.join(tmpdir, ".venv")) + File.write(File.join(shadowenv_d, "540_python.lisp"), + Dev::ShadowenvPython.generate_python_lisp("3.11", File.join(tmpdir, ".venv"))) + + Expect "provisioned? returns false for a mismatched version" + Dev::ShadowenvPython.provisioned?("3.12", project_root: tmpdir) == false + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + # --- ensure_homebrew_python! --- + + test "ensure_homebrew_python! returns the versioned interpreter of an installed formula" do + Given "an installed python@3.12 with a versioned interpreter" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + prefix = File.join(tmpdir, "opt", "python@3.12") + FileUtils.mkdir_p(File.join(prefix, "bin")) + write_fake_executable(File.join(prefix, "bin", "python3.12"), "#!/bin/sh\nexit 0\n") + write_fake_brew(tmpdir, prefix: prefix) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "the versioned interpreter path is returned" + Dev::ShadowenvPython.ensure_homebrew_python!("3.12") == File.join(prefix, "bin", "python3.12") + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "ensure_homebrew_python! installs the formula when it is missing" do + Given "a brew without python@3.12 whose install succeeds" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + prefix = File.join(tmpdir, "opt", "python@3.12") + FileUtils.mkdir_p(File.join(prefix, "bin")) + write_fake_executable(File.join(prefix, "bin", "python3.12"), "#!/bin/sh\nexit 0\n") + log = write_fake_brew(tmpdir, prefix: prefix, list_exit: 1) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + When "we ensure the interpreter" + result = Dev::ShadowenvPython.ensure_homebrew_python!("3.12") + + Then "brew install ran and the interpreter is returned" + assert_includes File.read(log), "install python@3.12" + result == File.join(prefix, "bin", "python3.12") + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "ensure_homebrew_python! raises when brew install fails" do + Given "a brew without python@3.12 whose install fails" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + write_fake_brew(tmpdir, prefix: tmpdir, list_exit: 1, install_exit: 1) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "a BrewInstallError naming the failed install" + error = assert_raises(Dev::ShadowenvPython::BrewInstallError) do + Dev::ShadowenvPython.ensure_homebrew_python!("3.12") + end + assert_includes error.message, "brew install python@3.12 failed" + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "ensure_homebrew_python! raises when the formula prefix cannot be resolved" do + Given "an installed formula whose prefix does not resolve" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + write_fake_brew(tmpdir, prefix: File.join(tmpdir, "no-such-prefix")) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "a BrewInstallError about the prefix" + error = assert_raises(Dev::ShadowenvPython::BrewInstallError) do + Dev::ShadowenvPython.ensure_homebrew_python!("3.12") + end + assert_includes error.message, "could not resolve Homebrew prefix" + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "ensure_homebrew_python! falls back to the formula's python3" do + Given "an installed formula with only an unversioned python3" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + prefix = File.join(tmpdir, "opt", "python@3.12") + FileUtils.mkdir_p(File.join(prefix, "bin")) + write_fake_executable(File.join(prefix, "bin", "python3"), "#!/bin/sh\nexit 0\n") + write_fake_brew(tmpdir, prefix: prefix) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "the python3 fallback is returned" + Dev::ShadowenvPython.ensure_homebrew_python!("3.12") == File.join(prefix, "bin", "python3") + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "ensure_homebrew_python! raises when the formula ships no interpreter at all" do + Given "an installed formula with an empty bin" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + prefix = File.join(tmpdir, "opt", "python@3.12") + FileUtils.mkdir_p(File.join(prefix, "bin")) + write_fake_brew(tmpdir, prefix: prefix) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "a BrewInstallError about the missing interpreter" + error = assert_raises(Dev::ShadowenvPython::BrewInstallError) do + Dev::ShadowenvPython.ensure_homebrew_python!("3.12") + end + assert_includes error.message, "no python interpreter found" + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + # --- brew_prefix_for --- + + test "brew_prefix_for returns nil when brew is absent" do + Given "a PATH without brew" + original_path = ENV["PATH"] + ENV["PATH"] = "/usr/bin:/bin" + + Expect "no prefix is found" + Dev::ShadowenvPython.brew_prefix_for("python@3.12").nil? == true + + Cleanup + ENV["PATH"] = original_path + end + + test "brew_prefix_for returns the directory brew prints" do + Given "a fake brew printing an existing directory" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + prefix = File.join(tmpdir, "opt", "python@3.12") + FileUtils.mkdir_p(prefix) + write_fake_brew(tmpdir, prefix: prefix) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "the printed prefix is returned" + Dev::ShadowenvPython.brew_prefix_for("python@3.12") == prefix + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "brew_prefix_for returns nil when brew prints a nonexistent directory" do + Given "a fake brew printing a directory that does not exist" + tmpdir = Dir.mktmpdir("shadowenv-python-test-") + write_fake_brew(tmpdir, prefix: File.join(tmpdir, "no-such-prefix")) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "nil is returned" + Dev::ShadowenvPython.brew_prefix_for("python@3.12").nil? == true + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + private + + # Writes an executable script and returns its path. + def write_fake_executable(path, contents) + File.write(path, contents) + FileUtils.chmod(0o755, path) + path + end + + # A python stand-in whose `-m venv <dir>` materializes a minimal venv with a + # working bin/python; any other invocation (the pip probes) succeeds. + def write_venv_capable_python(dir) + write_fake_executable(File.join(dir, "python"), <<~SCRIPT) + #!/bin/sh + if [ "$1" = "-m" ] && [ "$2" = "venv" ]; then + mkdir -p "$3/bin" + printf '#!/bin/sh\\nexit 0\\n' > "$3/bin/python" + chmod 755 "$3/bin/python" + fi + exit 0 + SCRIPT + end + + # A brew stand-in: `list` exits list_exit, `install` logs and exits + # install_exit, `--prefix` prints the given prefix. Returns the log path. + def write_fake_brew(dir, prefix:, list_exit: 0, install_exit: 0) + log = File.join(dir, "invocations.log") + write_fake_executable(File.join(dir, "brew"), <<~SCRIPT) + #!/bin/sh + echo "$@" >> "#{log}" + case "$1" in + list) exit #{list_exit} ;; + install) exit #{install_exit} ;; + --prefix) printf '%s\\n' "#{prefix}" ;; + esac + exit 0 + SCRIPT + log + end +end diff --git a/test/lib/shadowenv_ruby_test.rb b/test/dev/shadowenv_ruby_test.rb similarity index 54% rename from test/lib/shadowenv_ruby_test.rb rename to test/dev/shadowenv_ruby_test.rb index 117f6c7..64d1bd1 100644 --- a/test/lib/shadowenv_ruby_test.rb +++ b/test/dev/shadowenv_ruby_test.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "test_helper" -require "shadowenv_ruby" +require "dev/shadowenv_ruby" require "fileutils" require "tmpdir" @@ -15,10 +15,10 @@ class ShadowenvRubyTest < Minitest::Test ruby_version = "4.0.1" When "we resolve with an explicit version" - result = ShadowenvRuby.resolve_ruby_version(ruby_version) + result = Dev::ShadowenvRuby.resolve_ruby_version(ruby_version) Then "the explicit version is returned and homebrew version is ignored" - _ * ShadowenvRuby.detect_homebrew_ruby_version >> "3.2.0" + _ * Dev::ShadowenvRuby.detect_homebrew_ruby_version >> "3.2.0" result == ruby_version end @@ -27,10 +27,10 @@ class ShadowenvRubyTest < Minitest::Test ruby_version = nil When "we resolve with nil" - result = ShadowenvRuby.resolve_ruby_version(nil) + result = Dev::ShadowenvRuby.resolve_ruby_version(nil) Then "the Homebrew version is returned" - _ * ShadowenvRuby.detect_homebrew_ruby_version >> "3.3.0" + _ * Dev::ShadowenvRuby.detect_homebrew_ruby_version >> "3.3.0" result == "3.3.0" end @@ -39,10 +39,10 @@ class ShadowenvRubyTest < Minitest::Test ruby_version = nil When "we resolve the ruby version" - ShadowenvRuby.resolve_ruby_version(ruby_version) + Dev::ShadowenvRuby.resolve_ruby_version(ruby_version) Then "it aborts" - _ * ShadowenvRuby.detect_homebrew_ruby_version >> nil + _ * Dev::ShadowenvRuby.detect_homebrew_ruby_version >> nil 1 * Kernel.abort("dev: No Ruby declared in dependencies.rb and Homebrew Ruby not found. Run: brew install ruby") end @@ -51,7 +51,7 @@ class ShadowenvRubyTest < Minitest::Test ruby_version = "2.6.0" When "we resolve the ruby version" - ShadowenvRuby.resolve_ruby_version(ruby_version) + Dev::ShadowenvRuby.resolve_ruby_version(ruby_version) Then "it aborts for version below 2.7.0" 1 * Kernel.abort("dev: Resolved Ruby 2.6.0 is below dev's minimum (>= 2.7.0). Pin a newer version in dependencies.rb or run: brew upgrade ruby") @@ -66,16 +66,53 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.mkdir_p(keg) When "we detect the Homebrew Ruby version" - result = ShadowenvRuby.detect_homebrew_ruby_version + result = Dev::ShadowenvRuby.detect_homebrew_ruby_version Then "the keg's version is returned" - _ * ShadowenvRuby.brew_prefix_for("ruby") >> keg + _ * Dev::ShadowenvRuby.brew_prefix_for("ruby") >> keg result == "4.0.6" Cleanup FileUtils.rm_rf(tmpdir) end + test "detect_homebrew_ruby_version asks the keg's ruby when the dirname carries no version" do + Given "a keg whose dirname has no numeric version but whose bin/ruby answers" + tmpdir = Dir.mktmpdir("shadowenv-detect-test-") + keg = File.join(tmpdir, "Cellar", "ruby", "HEAD") + bin = File.join(keg, "bin") + FileUtils.mkdir_p(bin) + File.write(File.join(bin, "ruby"), "#!/bin/sh\nprintf '4.0.6\\n'\n") + FileUtils.chmod(0o755, File.join(bin, "ruby")) + + When "we detect the Homebrew Ruby version" + result = Dev::ShadowenvRuby.detect_homebrew_ruby_version + + Then "the version comes from running the binary" + _ * Dev::ShadowenvRuby.brew_prefix_for("ruby") >> keg + result == "4.0.6" + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "detect_homebrew_ruby_version returns nil when the keg has no version and no ruby binary" do + Given "a keg whose dirname has no numeric version and no bin/ruby" + tmpdir = Dir.mktmpdir("shadowenv-detect-test-") + keg = File.join(tmpdir, "Cellar", "ruby", "HEAD") + FileUtils.mkdir_p(keg) + + When "we detect the Homebrew Ruby version" + result = Dev::ShadowenvRuby.detect_homebrew_ruby_version + + Then "nil is returned" + _ * Dev::ShadowenvRuby.brew_prefix_for("ruby") >> keg + result.nil? == true + + Cleanup + FileUtils.rm_rf(tmpdir) + end + test "detect_homebrew_ruby_version strips a brew formula revision suffix" do Given "a brew ruby prefix resolving to a revision keg (a rebuild of the same upstream Ruby)" tmpdir = Dir.mktmpdir("shadowenv-detect-test-") @@ -83,10 +120,10 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.mkdir_p(keg) When "we detect the Homebrew Ruby version" - result = ShadowenvRuby.detect_homebrew_ruby_version + result = Dev::ShadowenvRuby.detect_homebrew_ruby_version Then "the upstream version is returned - RUBY_VERSION carries no _1, and Gem::Version rejects it" - _ * ShadowenvRuby.brew_prefix_for("ruby") >> keg + _ * Dev::ShadowenvRuby.brew_prefix_for("ruby") >> keg result == "4.0.6" Cleanup @@ -102,14 +139,14 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "510_ruby.lisp"), - ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") + Dev::ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") ) When "we ensure the version" - ShadowenvRuby.ensure!(ruby_version: "4.0.1", project_root: tmpdir) + Dev::ShadowenvRuby.ensure!(ruby_version: "4.0.1", project_root: tmpdir) Then "setup! is never reached (the fast path won)" - 0 * ShadowenvRuby.setup! + 0 * Dev::ShadowenvRuby.setup! Cleanup FileUtils.rm_rf(tmpdir) @@ -120,15 +157,70 @@ class ShadowenvRubyTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-ensure-test-") When "we ensure the version" - ShadowenvRuby.ensure!(ruby_version: "4.0.1", project_root: tmpdir) + Dev::ShadowenvRuby.ensure!(ruby_version: "4.0.1", project_root: tmpdir) Then "setup! runs with the same version and root" - 1 * ShadowenvRuby.setup!(ruby_version: "4.0.1", project_root: tmpdir) + 1 * Dev::ShadowenvRuby.setup!(ruby_version: "4.0.1", project_root: tmpdir) Cleanup FileUtils.rm_rf(tmpdir) end + # --- setup! --- + + test "setup! writes the lisp and .ruby-version and reports success" do + Given "a project root and a resolvable ruby root" + tmpdir = Dir.mktmpdir("shadowenv-setup-test-") + ruby_root = File.join(tmpdir, "rubies", "4.0.5") + FileUtils.mkdir_p(ruby_root) + + When "we run full provisioning" + result = Dev::ShadowenvRuby.setup!(ruby_version: "4.0.5", project_root: tmpdir) + + Then "the lisp and .ruby-version are written and setup reports success" + _ * Dev::ShadowenvRuby.ensure_ruby_installed!("4.0.5") >> ruby_root + _ * Dev::ShadowenvRuby.ensure_shadowenv_shell_hook! >> :added + result == true + File.read(File.join(tmpdir, ".shadowenv.d", "510_ruby.lisp")).include?('(provide "ruby" "4.0.5")') == true + File.read(File.join(tmpdir, ".ruby-version")) == "4.0.5\n" + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "setup! reports failure and writes nothing when the ruby cannot be provisioned" do + Given "a project root and no installable ruby" + tmpdir = Dir.mktmpdir("shadowenv-setup-test-") + + When "we run full provisioning" + result = Dev::ShadowenvRuby.setup!(ruby_version: "4.0.5", project_root: tmpdir) + + Then "setup reports failure and leaves no artifacts behind" + _ * Dev::ShadowenvRuby.ensure_ruby_installed!("4.0.5") >> nil + result == false + File.exist?(File.join(tmpdir, ".shadowenv.d", "510_ruby.lisp")) == false + File.exist?(File.join(tmpdir, ".ruby-version")) == false + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + # --- find_ruby_root --- + + test "find_ruby_root returns nil when the version is not installed" do + Given "an rbenv root with no versions" + tmp_rbenv_root = Dir.mktmpdir("rbenv-root-") + original_rbenv_root = ENV["RBENV_ROOT"] + ENV["RBENV_ROOT"] = tmp_rbenv_root + + Expect "no ruby root is found" + Dev::ShadowenvRuby.find_ruby_root("4.0.5").nil? == true + + Cleanup + ENV["RBENV_ROOT"] = original_rbenv_root + FileUtils.rm_rf(tmp_rbenv_root) + end + # --- provisioned? --- test "provisioned? returns true when lisp file matches version" do @@ -139,11 +231,11 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "510_ruby.lisp"), - ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", ruby_version) + Dev::ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", ruby_version) ) Expect ".provisioned? returns true when lisp file matches version" - ShadowenvRuby.provisioned?(ruby_version, project_root: tmpdir) == true + Dev::ShadowenvRuby.provisioned?(ruby_version, project_root: tmpdir) == true Cleanup FileUtils.rm_rf(tmpdir) @@ -156,11 +248,11 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "510_ruby.lisp"), - ShadowenvRuby.generate_ruby_lisp("/opt/ruby/3.2.0", "3.2.0") + Dev::ShadowenvRuby.generate_ruby_lisp("/opt/ruby/3.2.0", "3.2.0") ) Expect ".provisioned? returns false when lisp file has different version" - ShadowenvRuby.provisioned?("4.0.1", project_root: tmpdir) == false + Dev::ShadowenvRuby.provisioned?("4.0.1", project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -171,7 +263,7 @@ class ShadowenvRubyTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-test-") Expect ".provisioned? returns false when .shadowenv.d does not exist" - result = ShadowenvRuby.provisioned?("4.0.1", project_root: tmpdir) == false + result = Dev::ShadowenvRuby.provisioned?("4.0.1", project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -181,7 +273,7 @@ class ShadowenvRubyTest < Minitest::Test test "gem_api_version returns #{expected} for #{version}" do Given "a ruby version string" - result = ShadowenvRuby.gem_api_version(version) + result = Dev::ShadowenvRuby.gem_api_version(version) Expect "the correct format is returned" result == expected @@ -192,13 +284,185 @@ class ShadowenvRubyTest < Minitest::Test "3.2.5" | "3.2.0" "2.7.0" | "2.7.0" "3.3.10" | "3.3.0" + "4" | "4.0" + end + + # --- brew_prefix_for / path_with_brew_bin / homebrew_prefix --- + + test "brew_prefix_for returns nil when brew is absent" do + Given "a PATH without brew" + original_path = ENV["PATH"] + ENV["PATH"] = "/usr/bin:/bin" + + Expect "no prefix is found" + Dev::ShadowenvRuby.brew_prefix_for("ruby").nil? == true + + Cleanup + ENV["PATH"] = original_path + end + + test "brew_prefix_for returns the directory brew prints" do + Given "a fake brew printing an existing directory" + tmpdir = Dir.mktmpdir("fake-brew-") + prefix = File.join(tmpdir, "opt", "ruby") + FileUtils.mkdir_p(prefix) + write_fake_brew(tmpdir, prints: prefix) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "the printed prefix is returned" + Dev::ShadowenvRuby.brew_prefix_for("ruby") == prefix + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "brew_prefix_for returns nil when brew prints a nonexistent directory" do + Given "a fake brew printing a directory that does not exist" + tmpdir = Dir.mktmpdir("fake-brew-") + write_fake_brew(tmpdir, prints: File.join(tmpdir, "no-such-keg")) + original_path = ENV["PATH"] + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "nil is returned" + Dev::ShadowenvRuby.brew_prefix_for("ruby").nil? == true + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "path_with_brew_bin prepends the brew bin from HOMEBREW_PREFIX" do + Given "HOMEBREW_PREFIX pointing at an existing directory" + tmpdir = Dir.mktmpdir("brew-prefix-") + original_brew_prefix = ENV["HOMEBREW_PREFIX"] + ENV["HOMEBREW_PREFIX"] = tmpdir + + When "we compute the install PATH" + result = Dev::ShadowenvRuby.path_with_brew_bin + + Then "the brew bin dir leads the PATH" + result == "#{File.join(tmpdir, "bin")}:#{ENV["PATH"]}" + + Cleanup + ENV["HOMEBREW_PREFIX"] = original_brew_prefix + FileUtils.rm_rf(tmpdir) + end + + test "path_with_brew_bin resolves the prefix via brew when the env var is unset" do + Given "no HOMEBREW_PREFIX and a fake brew printing an existing directory" + tmpdir = Dir.mktmpdir("fake-brew-") + prefix = File.join(tmpdir, "homebrew") + FileUtils.mkdir_p(prefix) + write_fake_brew(tmpdir, prints: prefix) + original_brew_prefix = ENV["HOMEBREW_PREFIX"] + original_path = ENV["PATH"] + ENV.delete("HOMEBREW_PREFIX") + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + When "we compute the install PATH" + result = Dev::ShadowenvRuby.path_with_brew_bin + + Then "the resolved brew bin dir leads the PATH" + result == "#{File.join(prefix, "bin")}:#{ENV["PATH"]}" + + Cleanup + ENV["HOMEBREW_PREFIX"] = original_brew_prefix + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "homebrew_prefix resolves via brew when the env var is unset" do + Given "no HOMEBREW_PREFIX and a fake brew printing an existing directory" + tmpdir = Dir.mktmpdir("fake-brew-") + prefix = File.join(tmpdir, "homebrew") + FileUtils.mkdir_p(prefix) + write_fake_brew(tmpdir, prints: prefix) + original_brew_prefix = ENV["HOMEBREW_PREFIX"] + original_path = ENV["PATH"] + ENV.delete("HOMEBREW_PREFIX") + ENV["PATH"] = "#{tmpdir}:/usr/bin:/bin" + + Expect "the printed prefix is returned" + Dev::ShadowenvRuby.homebrew_prefix == prefix + + Cleanup + ENV["HOMEBREW_PREFIX"] = original_brew_prefix + ENV["PATH"] = original_path + FileUtils.rm_rf(tmpdir) + end + + test "homebrew_prefix is nil when brew is absent entirely" do + Given "no HOMEBREW_PREFIX and no brew on PATH" + original_brew_prefix = ENV["HOMEBREW_PREFIX"] + original_path = ENV["PATH"] + ENV.delete("HOMEBREW_PREFIX") + ENV["PATH"] = "/usr/bin:/bin" + + Expect "nil is returned via the ENOENT rescue" + Dev::ShadowenvRuby.homebrew_prefix.nil? == true + + Cleanup + ENV["HOMEBREW_PREFIX"] = original_brew_prefix + ENV["PATH"] = original_path + end + + # --- ensure_ruby_build_deps! --- + + test "ensure_ruby_build_deps! installs every missing build library" do + Given "a fake brew where no formula is installed yet" + tmpdir = Dir.mktmpdir("fake-brew-") + log = File.join(tmpdir, "invocations.log") + File.write(File.join(tmpdir, "brew"), <<~SCRIPT) + #!/bin/sh + echo "$@" >> "#{log}" + case "$1" in + list) exit 1 ;; + esac + exit 0 + SCRIPT + FileUtils.chmod(0o755, File.join(tmpdir, "brew")) + env = { "PATH" => "#{tmpdir}:/usr/bin:/bin" } + + When "we ensure the build deps" + Dev::ShadowenvRuby.ensure_ruby_build_deps!(env) + + Then "each formula is brew-installed" + invocations = File.read(log) + Dev::ShadowenvRuby::RUBY_BUILD_BREW_DEPS.keys.all? { |f| invocations.include?("install #{f}") } == true + + Cleanup + FileUtils.rm_rf(tmpdir) + end + + test "ensure_ruby_build_deps! skips formulae that are already installed" do + Given "a fake brew where every formula is already installed" + tmpdir = Dir.mktmpdir("fake-brew-") + log = File.join(tmpdir, "invocations.log") + File.write(File.join(tmpdir, "brew"), <<~SCRIPT) + #!/bin/sh + echo "$@" >> "#{log}" + exit 0 + SCRIPT + FileUtils.chmod(0o755, File.join(tmpdir, "brew")) + env = { "PATH" => "#{tmpdir}:/usr/bin:/bin" } + + When "we ensure the build deps" + Dev::ShadowenvRuby.ensure_ruby_build_deps!(env) + + Then "nothing is installed" + File.read(log).include?("install") == false + + Cleanup + FileUtils.rm_rf(tmpdir) end # --- generate_ruby_lisp --- test "generate_ruby_lisp contains provide directive with version" do When "we generate lisp" - result = ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") + result = Dev::ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") Then "the provide directive is present" assert_includes result, '(provide "ruby" "4.0.1")' @@ -206,7 +470,7 @@ class ShadowenvRubyTest < Minitest::Test test "generate_ruby_lisp sets RUBY_ROOT" do When "we generate lisp" - result = ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") + result = Dev::ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") Then "RUBY_ROOT is set" assert_includes result, '(env/set "RUBY_ROOT" "/opt/ruby/4.0.1")' @@ -214,7 +478,7 @@ class ShadowenvRubyTest < Minitest::Test test "generate_ruby_lisp prepends ruby bin to PATH" do When "we generate lisp" - result = ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") + result = Dev::ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") Then "PATH includes ruby bin" assert_includes result, '(env/prepend-to-pathlist "PATH" "/opt/ruby/4.0.1/bin")' @@ -222,7 +486,7 @@ class ShadowenvRubyTest < Minitest::Test test "generate_ruby_lisp sets RUBY_VERSION env var" do When "we generate lisp" - result = ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") + result = Dev::ShadowenvRuby.generate_ruby_lisp("/opt/ruby/4.0.1", "4.0.1") Then "RUBY_VERSION is set" assert_includes result, '(env/set "RUBY_VERSION" "4.0.1")' @@ -239,7 +503,7 @@ class ShadowenvRubyTest < Minitest::Test ENV["HOME"] = tmpdir When "we ensure the shell hook" - result = ShadowenvRuby.ensure_shadowenv_shell_hook! + result = Dev::ShadowenvRuby.ensure_shadowenv_shell_hook! Then "the hook is added to .zshrc" result == :added @@ -262,7 +526,7 @@ class ShadowenvRubyTest < Minitest::Test File.write(File.join(tmpdir, ".zshrc"), 'eval "$(shadowenv init zsh)"') When "we ensure the shell hook" - result = ShadowenvRuby.ensure_shadowenv_shell_hook! + result = Dev::ShadowenvRuby.ensure_shadowenv_shell_hook! Then "it reports already present" result == :already_present @@ -284,7 +548,7 @@ class ShadowenvRubyTest < Minitest::Test before = File.read(File.join(tmpdir, ".zshrc")) When "we ensure the shell hook" - result = ShadowenvRuby.ensure_shadowenv_shell_hook! + result = Dev::ShadowenvRuby.ensure_shadowenv_shell_hook! Then "the old install is recognized, not re-appended" result == :already_present @@ -305,7 +569,7 @@ class ShadowenvRubyTest < Minitest::Test ENV["HOME"] = tmpdir When "we ensure the shell hook" - result = ShadowenvRuby.ensure_shadowenv_shell_hook! + result = Dev::ShadowenvRuby.ensure_shadowenv_shell_hook! Then "the hook is added to .bash_profile" result == :added @@ -318,6 +582,28 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.rm_rf(tmpdir) end + test "ensure_shadowenv_shell_hook! reports false when the RC file cannot be written" do + Given "a zsh shell whose home directory is not writable" + tmpdir = Dir.mktmpdir("shadowenv-hook-test-") + original_shell = ENV["SHELL"] + original_home = ENV["HOME"] + ENV["SHELL"] = "/bin/zsh" + ENV["HOME"] = tmpdir + FileUtils.chmod(0o500, tmpdir) + + When "we ensure the shell hook" + result = Dev::ShadowenvRuby.ensure_shadowenv_shell_hook! + + Then "the failure is reported as false instead of raising" + result == false + + Cleanup + ENV["SHELL"] = original_shell + ENV["HOME"] = original_home + FileUtils.chmod(0o700, tmpdir) + FileUtils.rm_rf(tmpdir) + end + # --- missing_extensions / extensions_ok? --- test "missing_extensions reports every required extension when the ruby binary is absent" do @@ -325,7 +611,7 @@ class ShadowenvRubyTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-ext-test-") Expect "all required extensions are reported missing" - ShadowenvRuby.missing_extensions(tmpdir) == ShadowenvRuby::REQUIRED_EXTENSIONS + Dev::ShadowenvRuby.missing_extensions(tmpdir) == Dev::ShadowenvRuby::REQUIRED_EXTENSIONS Cleanup FileUtils.rm_rf(tmpdir) @@ -339,7 +625,7 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.ln_s(RbConfig.ruby, File.join(bin, "ruby")) Expect "nothing is missing — the running ruby has zlib/openssl/psych" - ShadowenvRuby.missing_extensions(tmpdir).empty? == true + Dev::ShadowenvRuby.missing_extensions(tmpdir).empty? == true Cleanup FileUtils.rm_rf(tmpdir) @@ -355,7 +641,7 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.chmod(0o755, fake_ruby) Expect "every required extension is reported missing" - ShadowenvRuby.missing_extensions(tmpdir) == ShadowenvRuby::REQUIRED_EXTENSIONS + Dev::ShadowenvRuby.missing_extensions(tmpdir) == Dev::ShadowenvRuby::REQUIRED_EXTENSIONS Cleanup FileUtils.rm_rf(tmpdir) @@ -368,10 +654,10 @@ class ShadowenvRubyTest < Minitest::Test base_env = { "PATH" => "/usr/bin" } When "we build the ruby-build env" - result = ShadowenvRuby.ruby_build_env(base_env, "4.0.5") + result = Dev::ShadowenvRuby.ruby_build_env(base_env, "4.0.5") Then "the env is returned unchanged" - _ * ShadowenvRuby.homebrew_prefix >> nil + _ * Dev::ShadowenvRuby.homebrew_prefix >> nil result == base_env end @@ -381,11 +667,11 @@ class ShadowenvRubyTest < Minitest::Test base_env = { "PATH" => "/usr/bin" } When "we build the ruby-build env" - result = ShadowenvRuby.ruby_build_env(base_env, "4.0.5") + result = Dev::ShadowenvRuby.ruby_build_env(base_env, "4.0.5") Then "configure opts and compiler/pkg-config flags point at brew" - _ * ShadowenvRuby.homebrew_prefix >> tmp_prefix - _ * ShadowenvRuby.brew_prefix_for(anything) >> "/brew/opt/zlib" + _ * Dev::ShadowenvRuby.homebrew_prefix >> tmp_prefix + _ * Dev::ShadowenvRuby.brew_prefix_for(anything) >> "/brew/opt/zlib" assert_includes result["RUBY_CONFIGURE_OPTS"], "--with-zlib-dir=/brew/opt/zlib" assert_includes result["CPPFLAGS"], "-I#{File.join(tmp_prefix, "include")}" assert_includes result["LDFLAGS"], "-L#{File.join(tmp_prefix, "lib")}" @@ -404,11 +690,11 @@ class ShadowenvRubyTest < Minitest::Test ENV["RBENV_ROOT"] = tmp_rbenv_root When "we build the ruby-build env" - result = ShadowenvRuby.ruby_build_env({ "PATH" => "/usr/bin" }, "4.0.5") + result = Dev::ShadowenvRuby.ruby_build_env({ "PATH" => "/usr/bin" }, "4.0.5") Then "the version's own lib dir is rpathed before brew's lib dir" - _ * ShadowenvRuby.homebrew_prefix >> tmp_prefix - _ * ShadowenvRuby.brew_prefix_for(anything) >> nil + _ * Dev::ShadowenvRuby.homebrew_prefix >> tmp_prefix + _ * Dev::ShadowenvRuby.brew_prefix_for(anything) >> nil own_rpath = "-Wl,-rpath,#{File.join(tmp_rbenv_root, "versions", "4.0.5", "lib")}" brew_rpath = "-Wl,-rpath,#{File.join(tmp_prefix, "lib")}" assert_includes result["LDFLAGS"], own_rpath @@ -428,7 +714,7 @@ class ShadowenvRubyTest < Minitest::Test write_fake_ruby(tmpdir, reports: "4.0.6") Expect "the reported version comes from running the binary" - ShadowenvRuby.reported_ruby_version(tmpdir) == "4.0.6" + Dev::ShadowenvRuby.reported_ruby_version(tmpdir) == "4.0.6" Cleanup FileUtils.rm_rf(tmpdir) @@ -439,7 +725,7 @@ class ShadowenvRubyTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-version-test-") Expect "nil is returned" - ShadowenvRuby.reported_ruby_version(tmpdir).nil? == true + Dev::ShadowenvRuby.reported_ruby_version(tmpdir).nil? == true Cleanup FileUtils.rm_rf(tmpdir) @@ -451,10 +737,10 @@ class ShadowenvRubyTest < Minitest::Test write_fake_ruby(tmpdir, reports: "4.0.6") When "we verify the reported version" - ShadowenvRuby.verify_reported_version!(tmpdir, "4.0.5") + Dev::ShadowenvRuby.verify_reported_version!(tmpdir, "4.0.5") Then "it aborts with the hijack explanation" - 1 * Kernel.abort(ShadowenvRuby.version_hijack_message(tmpdir, "4.0.5", "4.0.6")) + 1 * Kernel.abort(Dev::ShadowenvRuby.version_hijack_message(tmpdir, "4.0.5", "4.0.6")) Cleanup FileUtils.rm_rf(tmpdir) @@ -466,7 +752,7 @@ class ShadowenvRubyTest < Minitest::Test write_fake_ruby(tmpdir, reports: "4.0.5") When "we verify the reported version" - ShadowenvRuby.verify_reported_version!(tmpdir, "4.0.5") + Dev::ShadowenvRuby.verify_reported_version!(tmpdir, "4.0.5") Then "it never aborts" 0 * Kernel.abort @@ -486,11 +772,11 @@ class ShadowenvRubyTest < Minitest::Test write_fake_ruby(ruby_root, reports: "4.0.6") When "we ensure the ruby is installed" - ShadowenvRuby.ensure_ruby_installed!("4.0.5") + Dev::ShadowenvRuby.ensure_ruby_installed!("4.0.5") Then "a forced reinstall is attempted, and the still-hijacked result aborts loudly" - 1 * ShadowenvRuby.install_ruby_with_version_manager("4.0.5", force: true) - 1 * Kernel.abort(ShadowenvRuby.version_hijack_message(ruby_root, "4.0.5", "4.0.6")) + 1 * Dev::ShadowenvRuby.install_ruby_with_version_manager("4.0.5", force: true) + 1 * Kernel.abort(Dev::ShadowenvRuby.version_hijack_message(ruby_root, "4.0.5", "4.0.6")) Cleanup ENV["RBENV_ROOT"] = original_rbenv_root @@ -506,10 +792,10 @@ class ShadowenvRubyTest < Minitest::Test write_fake_ruby(ruby_root, reports: "4.0.5", extensions_ok: false) When "we ensure the ruby is installed" - ShadowenvRuby.ensure_ruby_installed!("4.0.5") + Dev::ShadowenvRuby.ensure_ruby_installed!("4.0.5") Then "a forced reinstall is attempted, and the still-crippled result aborts loudly" - 1 * ShadowenvRuby.install_ruby_with_version_manager("4.0.5", force: true) + 1 * Dev::ShadowenvRuby.install_ruby_with_version_manager("4.0.5", force: true) 1 * Kernel.abort(anything) Cleanup @@ -532,7 +818,7 @@ class ShadowenvRubyTest < Minitest::Test ENV["HOMEBREW_PREFIX"] = File.join(tmpdir, "no-brew-here") When "we install the ruby" - result = ShadowenvRuby.install_ruby_with_version_manager("4.0.5") + result = Dev::ShadowenvRuby.install_ruby_with_version_manager("4.0.5") Then "rbenv install ran for the requested version and the run succeeded" result == true @@ -544,6 +830,24 @@ class ShadowenvRubyTest < Minitest::Test FileUtils.rm_rf(tmpdir) end + test "ensure_ruby_installed! attempts a fresh install and reports nil when the ruby never appears" do + Given "an rbenv root with no installed rubies" + tmp_rbenv_root = Dir.mktmpdir("rbenv-root-") + original_rbenv_root = ENV["RBENV_ROOT"] + ENV["RBENV_ROOT"] = tmp_rbenv_root + + When "we ensure the ruby is installed" + result = Dev::ShadowenvRuby.ensure_ruby_installed!("4.0.5") + + Then "a fresh (non-forced) install is attempted and the missing result is nil" + 1 * Dev::ShadowenvRuby.install_ruby_with_version_manager("4.0.5") + result.nil? == true + + Cleanup + ENV["RBENV_ROOT"] = original_rbenv_root + FileUtils.rm_rf(tmp_rbenv_root) + end + test "ensure_ruby_installed! returns a healthy ruby without reinstalling" do Given "an installed ruby with healthy extensions that reports the requested version" tmp_rbenv_root = Dir.mktmpdir("rbenv-root-") @@ -553,11 +857,11 @@ class ShadowenvRubyTest < Minitest::Test write_fake_ruby(ruby_root, reports: "4.0.5") When "we ensure the ruby is installed" - result = ShadowenvRuby.ensure_ruby_installed!("4.0.5") + result = Dev::ShadowenvRuby.ensure_ruby_installed!("4.0.5") Then "the existing install is returned untouched" result == ruby_root - 0 * ShadowenvRuby.install_ruby_with_version_manager + 0 * Dev::ShadowenvRuby.install_ruby_with_version_manager Cleanup ENV["RBENV_ROOT"] = original_rbenv_root @@ -566,6 +870,13 @@ class ShadowenvRubyTest < Minitest::Test private + # A stand-in brew that prints the given path for any prefix query. + def write_fake_brew(dir, prints:) + fake_brew = File.join(dir, "brew") + File.write(fake_brew, "#!/bin/sh\nprintf '%s\\n' \"#{prints}\"\n") + FileUtils.chmod(0o755, fake_brew) + end + # A stand-in bin/ruby: prints the given version for `-e "print RUBY_VERSION"`; # every other invocation (the extension `require` probes) exits 0 when # extensions_ok, 1 otherwise. diff --git a/test/lib/shadowenv_unreal_test.rb b/test/dev/shadowenv_unreal_test.rb similarity index 74% rename from test/lib/shadowenv_unreal_test.rb rename to test/dev/shadowenv_unreal_test.rb index ed10482..f5c0462 100644 --- a/test/lib/shadowenv_unreal_test.rb +++ b/test/dev/shadowenv_unreal_test.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "test_helper" -require "shadowenv_unreal" +require "dev/shadowenv_unreal" require "fileutils" require "tmpdir" @@ -26,11 +26,11 @@ def create_fake_ue_root(dir) FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "530_unreal.lisp"), - ShadowenvUnreal.generate_unreal_lisp(ue_root), + Dev::ShadowenvUnreal.generate_unreal_lisp(ue_root), ) Expect - ShadowenvUnreal.provisioned?(ue_root, project_root: tmpdir) == true + Dev::ShadowenvUnreal.provisioned?(ue_root, project_root: tmpdir) == true Cleanup FileUtils.rm_rf(tmpdir) @@ -43,11 +43,11 @@ def create_fake_ue_root(dir) FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "530_unreal.lisp"), - ShadowenvUnreal.generate_unreal_lisp("/old/engine"), + Dev::ShadowenvUnreal.generate_unreal_lisp("/old/engine"), ) Expect - ShadowenvUnreal.provisioned?("/new/engine", project_root: tmpdir) == false + Dev::ShadowenvUnreal.provisioned?("/new/engine", project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -58,7 +58,7 @@ def create_fake_ue_root(dir) tmpdir = Dir.mktmpdir("shadowenv-unreal-test-") Expect - ShadowenvUnreal.provisioned?("/opt/UnrealEngine", project_root: tmpdir) == false + Dev::ShadowenvUnreal.provisioned?("/opt/UnrealEngine", project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -68,7 +68,7 @@ def create_fake_ue_root(dir) test "generate_unreal_lisp contains provide directive with UE root" do When "generating lisp" - result = ShadowenvUnreal.generate_unreal_lisp("/opt/UnrealEngine") + result = Dev::ShadowenvUnreal.generate_unreal_lisp("/opt/UnrealEngine") Then assert_includes result, '(provide "unreal" "/opt/UnrealEngine")' @@ -76,7 +76,7 @@ def create_fake_ue_root(dir) test "generate_unreal_lisp sets UE_ROOT" do When "generating lisp" - result = ShadowenvUnreal.generate_unreal_lisp("/opt/UnrealEngine") + result = Dev::ShadowenvUnreal.generate_unreal_lisp("/opt/UnrealEngine") Then assert_includes result, '(env/set "UE_ROOT" "/opt/UnrealEngine")' @@ -84,7 +84,7 @@ def create_fake_ue_root(dir) test "generate_unreal_lisp prepends engine binaries to PATH" do When "generating lisp" - result = ShadowenvUnreal.generate_unreal_lisp("/opt/UnrealEngine") + result = Dev::ShadowenvUnreal.generate_unreal_lisp("/opt/UnrealEngine") Then assert_includes result, '(env/prepend-to-pathlist "PATH"' @@ -93,7 +93,7 @@ def create_fake_ue_root(dir) test "generate_unreal_lisp includes UE_PROJECT when specified" do When "generating lisp with a project path" - result = ShadowenvUnreal.generate_unreal_lisp( + result = Dev::ShadowenvUnreal.generate_unreal_lisp( "/opt/UnrealEngine", ue_project: "/project/FactoryGame.uproject", ) @@ -104,7 +104,7 @@ def create_fake_ue_root(dir) test "generate_unreal_lisp omits UE_PROJECT when nil" do When "generating lisp without a project path" - result = ShadowenvUnreal.generate_unreal_lisp("/opt/UnrealEngine") + result = Dev::ShadowenvUnreal.generate_unreal_lisp("/opt/UnrealEngine") Then refute_includes result, "UE_PROJECT" @@ -118,10 +118,10 @@ def create_fake_ue_root(dir) ue_root = create_fake_ue_root(Dir.mktmpdir("fake-ue-")) When "calling setup!" - result = ShadowenvUnreal.setup!(project_root: tmpdir, ue_root: ue_root) + result = Dev::ShadowenvUnreal.setup!(project_root: tmpdir, ue_root: ue_root) Then - _ * ShadowenvUnreal.method(:system) >> true + _ * Dev::ShadowenvUnreal.method(:system) >> true result == true lisp_path = File.join(tmpdir, ".shadowenv.d", "530_unreal.lisp") assert File.exist?(lisp_path), "Expected lisp file at #{lisp_path}" @@ -134,13 +134,13 @@ def create_fake_ue_root(dir) test "setup! returns false when no UE root is available" do When "calling setup! without a root" - result = ShadowenvUnreal.setup!( + result = Dev::ShadowenvUnreal.setup!( project_root: Dir.mktmpdir("shadowenv-unreal-none-"), ue_root: nil, ) Then - _ * ShadowenvUnreal.method(:detect_ue_root) >> nil + _ * Dev::ShadowenvUnreal.method(:detect_ue_root) >> nil result == false end @@ -150,7 +150,7 @@ def create_fake_ue_root(dir) ue_root = create_fake_ue_root(Dir.mktmpdir("fake-ue-")) When "calling setup! with ue_project" - ShadowenvUnreal.setup!( + Dev::ShadowenvUnreal.setup!( project_root: tmpdir, ue_root: ue_root, ue_project: "/project/FactoryGame.uproject", @@ -158,7 +158,7 @@ def create_fake_ue_root(dir) content = File.read(File.join(tmpdir, ".shadowenv.d", "530_unreal.lisp")) Then - _ * ShadowenvUnreal.method(:system) >> true + _ * Dev::ShadowenvUnreal.method(:system) >> true assert_includes content, "FactoryGame.uproject" Cleanup @@ -172,7 +172,7 @@ def create_fake_ue_root(dir) ue_root = create_fake_ue_root(Dir.mktmpdir("fake-ue-")) Expect - ShadowenvUnreal.send(:valid_ue_root?, ue_root) == true + Dev::ShadowenvUnreal.send(:valid_ue_root?, ue_root) == true Cleanup FileUtils.rm_rf(ue_root) @@ -183,7 +183,7 @@ def create_fake_ue_root(dir) tmpdir = Dir.mktmpdir("not-ue-") Expect - ShadowenvUnreal.send(:valid_ue_root?, tmpdir) == false + Dev::ShadowenvUnreal.send(:valid_ue_root?, tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -197,7 +197,7 @@ def create_fake_ue_root(dir) ENV["CI"] = "true" Expect - ShadowenvUnreal.ci_or_linux? == true + Dev::ShadowenvUnreal.ci_or_linux? == true Cleanup ENV["CI"] = original @@ -209,7 +209,7 @@ def create_fake_ue_root(dir) ENV.delete("CI") When "checking ci_or_linux?" - result = ShadowenvUnreal.ci_or_linux? + result = Dev::ShadowenvUnreal.ci_or_linux? Then result == RUBY_PLATFORM.include?("linux") @@ -217,4 +217,17 @@ def create_fake_ue_root(dir) Cleanup ENV["CI"] = original if original end + + # --- platform_subdir --- + + test "platform_subdir maps #{ruby_platform} to #{subdir}" do + Expect "the engine binaries subdirectory matches the platform" + Dev::ShadowenvUnreal.platform_subdir(ruby_platform) == subdir + + Where + ruby_platform | subdir + "arm64-darwin24" | "Mac" + "x86_64-linux" | "Linux" + "x64-mingw-ucrt" | "Win64" + end end diff --git a/test/lib/shadowenv_xcode_test.rb b/test/dev/shadowenv_xcode_test.rb similarity index 76% rename from test/lib/shadowenv_xcode_test.rb rename to test/dev/shadowenv_xcode_test.rb index 5067d1f..2e01c73 100644 --- a/test/lib/shadowenv_xcode_test.rb +++ b/test/dev/shadowenv_xcode_test.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "test_helper" -require "shadowenv_xcode" +require "dev/shadowenv_xcode" require "fileutils" require "tmpdir" @@ -12,7 +12,7 @@ class ShadowenvXcodeTest < Minitest::Test test "generate_xcode_lisp provides xcode and sets DEVELOPER_DIR" do When "generating lisp for the pin" - result = ShadowenvXcode.generate_xcode_lisp("26.1.1", DEVELOPER_DIR) + result = Dev::ShadowenvXcode.generate_xcode_lisp("26.1.1", DEVELOPER_DIR) Then "it provides the pinned version and points DEVELOPER_DIR at it" assert_includes result, '(provide "xcode" "26.1.1")' @@ -26,11 +26,11 @@ class ShadowenvXcodeTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "520_xcode.lisp"), - ShadowenvXcode.generate_xcode_lisp("26.1.1", DEVELOPER_DIR), + Dev::ShadowenvXcode.generate_xcode_lisp("26.1.1", DEVELOPER_DIR), ) Expect - ShadowenvXcode.provisioned?(DEVELOPER_DIR, project_root: tmpdir) == true + Dev::ShadowenvXcode.provisioned?(DEVELOPER_DIR, project_root: tmpdir) == true Cleanup FileUtils.rm_rf(tmpdir) @@ -41,7 +41,7 @@ class ShadowenvXcodeTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-xcode-test-") Expect - ShadowenvXcode.provisioned?(DEVELOPER_DIR, project_root: tmpdir) == false + Dev::ShadowenvXcode.provisioned?(DEVELOPER_DIR, project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -54,11 +54,11 @@ class ShadowenvXcodeTest < Minitest::Test FileUtils.mkdir_p(shadowenv_d) File.write( File.join(shadowenv_d, "520_xcode.lisp"), - ShadowenvXcode.generate_xcode_lisp("16.4", "/Applications/Xcode-16.4.app/Contents/Developer"), + Dev::ShadowenvXcode.generate_xcode_lisp("16.4", "/Applications/Xcode-16.4.app/Contents/Developer"), ) Expect - ShadowenvXcode.provisioned?(DEVELOPER_DIR, project_root: tmpdir) == false + Dev::ShadowenvXcode.provisioned?(DEVELOPER_DIR, project_root: tmpdir) == false Cleanup FileUtils.rm_rf(tmpdir) @@ -69,7 +69,7 @@ class ShadowenvXcodeTest < Minitest::Test tmpdir = Dir.mktmpdir("shadowenv-xcode-setup-") When "running setup! with the shadowenv trust call stubbed" - result = ShadowenvXcode.setup!(project_root: tmpdir, version: "26.1.1", developer_dir: DEVELOPER_DIR) + result = Dev::ShadowenvXcode.setup!(project_root: tmpdir, version: "26.1.1", developer_dir: DEVELOPER_DIR) Then "the lisp file exists with the pin" _ * Kernel.system >> true diff --git a/test/lib/build_watcher_test.rb b/test/lib/build_watcher_test.rb deleted file mode 100644 index 2de9c18..0000000 --- a/test/lib/build_watcher_test.rb +++ /dev/null @@ -1,127 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "build_watcher" -require "stringio" - -# BuildWatcher with the OS mechanism (run_once) replaced by a scripted sequence -# of results, so the retry/classify policy is tested without real processes. -class ScriptedWatcher < BuildWatcher - attr_reader :calls - - def initialize(results:, **kwargs) - super(**kwargs) - @results = results.dup - @calls = 0 - end - - def run_once(_argv) - @calls += 1 - @results.shift - end -end unless defined?(ScriptedWatcher) - -transform!(RSpock::AST::Transformation) -class BuildWatcherTest < Minitest::Test - def watcher(**kwargs) - BuildWatcher.new(container_name: "c", out: StringIO.new, stall_after: 300, cpu_floor: 5.0, **kwargs) - end - - def scripted(results, max_attempts: 5) - ScriptedWatcher.new(results: results, container_name: "c", out: StringIO.new, max_attempts: max_attempts) - end - - def result(outcome, output = "") - BuildWatcher::Result.new(outcome, output) - end - - test "stalled? is true only when both silent long enough and idle CPU" do - Given "a watcher with default thresholds" - w = watcher - - Expect "silent + idle is a stall; busy CPU or recent output is not" - w.stalled?(idle_seconds: 400, cpu_percent: 0.0) == true - w.stalled?(idle_seconds: 400, cpu_percent: 80.0) == false - w.stalled?(idle_seconds: 10, cpu_percent: 0.0) == false - end - - test "classify_failure retries on a Rosetta/clang crash signature" do - Given "output with a crash signature" - w = watcher - - Expect - w.classify_failure("rosetta error: failed to open elf") == :retry - w.classify_failure("clang++: error: unable to spawn process (posix_spawn failed)") == :retry - w.classify_failure("PLATFORM: Segmentation fault (core dumped)") == :retry - end - - test "classify_failure fails fast on a genuine compile error" do - Given "output with only a real compile error" - w = watcher - - Expect - w.classify_failure("main.cpp:3:5: error: expected ';'") == :fail - w.classify_failure("just some unrelated noise") == :fail - end - - test "run returns true on the first successful attempt" do - Given "a run that succeeds immediately" - w = scripted([result(:success)]) - - When "running" - ok = w.run(["docker", "run"]) - - Then - ok == true - w.calls == 1 - end - - test "run retries a hung build and succeeds on the next attempt" do - Given "a stall followed by a success" - w = scripted([result(:stalled, "...building..."), result(:success)]) - - When "running" - ok = w.run(["docker", "run"]) - - Then - ok == true - w.calls == 2 - end - - test "run retries a transient crash and succeeds" do - Given "a crash-signature failure followed by a success" - w = scripted([result(:failed, "rosetta error: boom"), result(:success)]) - - When "running" - ok = w.run(["docker", "run"]) - - Then - ok == true - w.calls == 2 - end - - test "run fails fast on a genuine compile error without retrying" do - Given "a failure whose output is a real compile error" - w = scripted([result(:failed, "main.cpp:3:5: error: nope"), result(:success)]) - - When "running" - ok = w.run(["docker", "run"]) - - Then "it stops after the first attempt" - ok == false - w.calls == 1 - end - - test "run gives up after the attempt cap on persistent stalls" do - Given "a build that stalls every attempt" - w = scripted([result(:stalled), result(:stalled), result(:stalled)], max_attempts: 3) - - When "running" - ok = w.run(["docker", "run"]) - - Then - ok == false - w.calls == 3 - end -end diff --git a/test/lib/shadowenv_python_test.rb b/test/lib/shadowenv_python_test.rb deleted file mode 100644 index c910059..0000000 --- a/test/lib/shadowenv_python_test.rb +++ /dev/null @@ -1,75 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "shadowenv_python" -require "fileutils" -require "tmpdir" - -transform!(RSpock::AST::Transformation) -class ShadowenvPythonTest < Minitest::Test - test "generate_python_lisp contains the provide directive" do - When "generating lisp for 3.12" - result = ShadowenvPython.generate_python_lisp("3.12", "/tmp/proj/.venv") - - Then "the lisp declares the provided version" - assert_includes result, '(provide "python" "3.12")' - end - - test "generate_python_lisp activates the venv on PATH and VIRTUAL_ENV by absolute path" do - When "generating lisp for 3.12" - result = ShadowenvPython.generate_python_lisp("3.12", "/tmp/proj/.venv") - - Then "it sets VIRTUAL_ENV + PYTHONHOME and prepends the venv bin as a literal path" - assert_includes result, '(env/set "VIRTUAL_ENV" "/tmp/proj/.venv")' - assert_includes result, '(env/set "PYTHONHOME" ())' - assert_includes result, '(env/prepend-to-pathlist "PATH" "/tmp/proj/.venv/bin")' - end - - test "provisioned? is true only when the lisp matches and the venv exists" do - Given "a project with a matching lisp and a .venv directory" - tmpdir = Dir.mktmpdir("shadowenv-python-test-") - shadowenv_d = File.join(tmpdir, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - FileUtils.mkdir_p(File.join(tmpdir, ".venv")) - File.write(File.join(shadowenv_d, "540_python.lisp"), - ShadowenvPython.generate_python_lisp("3.12", File.join(tmpdir, ".venv"))) - - Expect "provisioned? returns true" - ShadowenvPython.provisioned?("3.12", project_root: tmpdir) == true - - Cleanup - FileUtils.rm_rf(tmpdir) - end - - test "provisioned? is false when the venv is missing even if the lisp exists" do - Given "a matching lisp but no .venv directory" - tmpdir = Dir.mktmpdir("shadowenv-python-test-") - shadowenv_d = File.join(tmpdir, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - File.write(File.join(shadowenv_d, "540_python.lisp"), - ShadowenvPython.generate_python_lisp("3.12", File.join(tmpdir, ".venv"))) - - Expect "provisioned? returns false" - ShadowenvPython.provisioned?("3.12", project_root: tmpdir) == false - - Cleanup - FileUtils.rm_rf(tmpdir) - end - - test "provisioned? is false for a different version" do - Given "a lisp provisioned for 3.11 and a venv" - tmpdir = Dir.mktmpdir("shadowenv-python-test-") - shadowenv_d = File.join(tmpdir, ".shadowenv.d") - FileUtils.mkdir_p(shadowenv_d) - FileUtils.mkdir_p(File.join(tmpdir, ".venv")) - File.write(File.join(shadowenv_d, "540_python.lisp"), - ShadowenvPython.generate_python_lisp("3.11", File.join(tmpdir, ".venv"))) - - Expect "provisioned? returns false for a mismatched version" - ShadowenvPython.provisioned?("3.12", project_root: tmpdir) == false - - Cleanup - FileUtils.rm_rf(tmpdir) - end -end 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..d547fa7 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. @@ -8,16 +9,20 @@ require "simplecov-cobertura" # HTML for local browsing; cobertura for the codecov upload (its parser -# can't process SimpleCov JSON containing `# :nocov:`-ignored lines — +# can't process SimpleCov JSON containing skipped/ignored lines — # codecov/engineering-team#3592). SimpleCov.start do - add_filter("/test/") + skip("/test/") formatter SimpleCov::Formatter::MultiFormatter.new([ SimpleCov::Formatter::HTMLFormatter, SimpleCov::Formatter::CoberturaFormatter, ]) end +# simplecov/sorbet skips type-level Sorbet constructs (sig blocks, +# T.type_alias, T.absurd) so they never read as coverage misses. +require "simplecov/sorbet" + DEV_ROOT = File.expand_path("..", __dir__) $LOAD_PATH.unshift(File.join(DEV_ROOT, "src")) unless $LOAD_PATH.include?(File.join(DEV_ROOT, "src")) $LOAD_PATH.unshift(File.join(DEV_ROOT, "lib")) unless $LOAD_PATH.include?(File.join(DEV_ROOT, "lib"))