Class: Kettle::Dev::GemSpecReader

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/dev/gem_spec_reader.rb

Overview

Unified gemspec reader using RubyGems loader instead of regex parsing. Returns a Hash with all data used by this project from gemspecs. Results are memoized per project root within the process.

Constant Summary collapse

DEFAULT_MINIMUM_RUBY =

Default minimum Ruby version to assume when a gemspec doesn’t specify one.

Returns:

  • (Gem::Version)
Gem::Version.new("1.8").freeze

Class Method Summary collapse

Class Method Details

.clear_cache!Object



194
195
196
# File 'lib/kettle/dev/gem_spec_reader.rb', line 194

def clear_cache!
  CACHE.mutex.synchronize { CACHE.entries.clear }
end

.load(root) ⇒ Hash{Symbol=>Object}

Load gemspec data for the project at root using RubyGems. The reader is lenient: failures to load or missing fields are handled with defaults and warnings.

Parameters:

  • root (String)

    project root containing a *.gemspec file

  • return (Hash)

    a customizable set of options

Returns:

  • (Hash{Symbol=>Object})

    a Hash of gem metadata used by templating and tasks



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/kettle/dev/gem_spec_reader.rb', line 45

def load(root)
  cache_key = File.expand_path(root.to_s)
  CACHE.mutex.synchronize do
    return CACHE.entries[cache_key] if CACHE.entries.key?(cache_key)
  end

  gemspec_path = Dir.glob(File.join(root.to_s, "*.gemspec")).first
  spec = nil
  if gemspec_path && File.file?(gemspec_path)
    begin
      spec = Gem::Specification.load(gemspec_path)
    rescue StandardError => e
      Kettle::Dev.debug_error(e, __method__)
      spec = nil
    end
  end

  gemspec_source = if gemspec_path && File.file?(gemspec_path)
    begin
      File.read(gemspec_path)
    rescue StandardError => e
      Kettle::Dev.debug_error(e, __method__)
      ""
    end
  else
    ""
  end

  gem_name = spec&.name.to_s
  if gem_name.nil? || gem_name.strip.empty?
    # Be lenient here for tasks that can proceed without gem_name (e.g., choosing destination filenames).
    Kernel.warn("kettle-dev: Could not derive gem name. Ensure a valid <name> is set in the gemspec.\n  - Tip: set the gem name in your .gemspec file (spec.name).\n  - Path searched: #{Kettle::Dev.display_path(gemspec_path || "(none found)")}")
    gem_name = ""
  end
  # minimum ruby version: derived from spec.required_ruby_version
  # Always an instance of Gem::Version
  min_ruby =
    begin
      # irb(main):004> Gem::Requirement.parse(spec.required_ruby_version)
      # => [">=", Gem::Version.new("2.3.0")]
      requirement = spec&.required_ruby_version
      if requirement
        tuple = Gem::Requirement.parse(requirement)
        tuple[1] # an instance of Gem::Version
      else
        # Default to a minimum of Ruby 1.8
        puts "WARNING: Minimum Ruby not detected"
        DEFAULT_MINIMUM_RUBY
      end
    rescue StandardError => e
      puts "WARNING: Minimum Ruby detection failed:"
      Kettle::Dev.debug_error(e, __method__)
      # Default to a minimum of Ruby 1.8
      DEFAULT_MINIMUM_RUBY
    end

  homepage_val = spec&.homepage.to_s

  explicit_forge_org = env_org_override("FORGE_ORG")

  # Derive org/repo from homepage or git remote
  forge_info = derive_forge_and_origin_repo(homepage_val)
  forge_org = explicit_forge_org || forge_info[:forge_org]
  gh_repo = forge_info[:origin_repo]
  if forge_org.to_s.empty?
    Kernel.warn("kettle-dev: Could not determine forge org from spec.homepage or git remote.\n  - Ensure gemspec.homepage is set to a GitHub URL or that the git remote 'origin' points to GitHub.\n  - Example homepage: https://github.com/<org>/<repo>\n  - Proceeding with default org: kettle-rb.")
    forge_org = "kettle-rb"
  end

  camel = lambda do |s|
    s.to_s.split(/[_-]/).map { |p| p.gsub(/\b([a-z])/) { Regexp.last_match(1).upcase } }.join
  end
  entrypoint_require = derive_entrypoint_require(
    root: root,
    gem_name: gem_name,
    gemspec_source: gemspec_source,
  )
  namespace_source = entrypoint_require.to_s.empty? ? gem_name.to_s.tr("-", "/") : entrypoint_require.to_s
  namespace = namespace_source.split("/").reject(&:empty?).map { |seg| camel.call(seg) }.join("::")
  namespace_shield = namespace.gsub("::", "%3A%3A")
  gem_shield = gem_name.to_s.gsub("-", "--").gsub("_", "__")

  # Funding org (Open Collective handle) detection.
  # Precedence:
  #   1) OpenCollectiveConfig.disabled? - when true, funding_org is nil
  #   2) ENV["FUNDING_ORG"] when set and non-empty (unless already disabled above)
  #   3) OpenCollectiveConfig.handle(required: false)
  # Be lenient: allow nil when not discoverable, with a concise warning.
  begin
    # Check if Open Collective is explicitly disabled via environment variables
    if OpenCollectiveConfig.disabled?
      funding_org = nil
    else
      env_funding = ENV["FUNDING_ORG"]
      if env_funding && !env_funding.to_s.strip.empty? && !env_funding.match?(/\{KJ\|[^}]+}/)
        # FUNDING_ORG is set, non-empty, and is not an unresolved token placeholder;
        # use it as-is (already filtered by opencollective_disabled?)
        funding_org = env_funding.to_s
      else
        # Preflight: if a YAML exists under the provided root, attempt to read it here so
        # unexpected file IO errors surface within this rescue block (see specs).
        oc_path = OpenCollectiveConfig.yaml_path(root)
        File.read(oc_path) if File.file?(oc_path)

        funding_org = OpenCollectiveConfig.handle(required: false, root: root)
        if funding_org.to_s.strip.empty?
          Kernel.warn("kettle-dev: Could not determine funding org.\n  - Options:\n    * Set ENV['FUNDING_ORG'] to your funding handle, or 'false' to disable.\n    * Or set ENV['OPENCOLLECTIVE_HANDLE'].\n    * Or add .opencollective.yml with: collective: <handle> (or org: <handle>).\n    * Or proceed without funding if not applicable.")
          funding_org = nil
        end
      end
    end
  rescue StandardError => error
    Kettle::Dev.debug_error(error, __method__)
    # In an unexpected exception path, escalate to a domain error to aid callers/specs
    raise Kettle::Dev::Error, "Unable to determine funding org: #{error.message}"
  end

  result = {
    gemspec_path: gemspec_path,
    gem_name: gem_name,
    version: spec&.version.to_s,
    min_ruby: min_ruby, # Gem::Version instance
    homepage: homepage_val.to_s,
    gh_org: forge_org, # Might allow divergence from forge_org someday
    forge_org: forge_org,
    funding_org: funding_org,
    gh_repo: gh_repo,
    namespace: namespace,
    namespace_shield: namespace_shield,
    entrypoint_require: entrypoint_require,
    gem_shield: gem_shield,
    # Additional fields sourced from the gemspec for templating carry-over
    authors: Array(spec&.authors).compact.uniq,
    email: Array(spec&.email).compact.uniq,
    summary: spec&.summary.to_s,
    description: spec&.description.to_s,
    licenses: Array(spec&.licenses), # licenses will include any specified as license (singular)
    required_ruby_version: spec&.required_ruby_version, # Gem::Requirement instance
    require_paths: Array(spec&.require_paths),
    bindir: (spec&.bindir || "").to_s,
    executables: Array(spec&.executables),
  }

  CACHE.mutex.synchronize do
    CACHE.entries[cache_key] = result
  end
  result
end