still_active 1.6.0 → 3.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +131 -17
  4. data/lib/helpers/activity_helper.rb +41 -8
  5. data/lib/helpers/bundler_helper.rb +87 -26
  6. data/lib/helpers/catalog_index.rb +4 -1
  7. data/lib/helpers/constraint_helper.rb +208 -0
  8. data/lib/helpers/cvss_helper.rb +63 -0
  9. data/lib/helpers/cyclonedx_helper.rb +44 -12
  10. data/lib/helpers/diff_markdown_helper.rb +36 -17
  11. data/lib/helpers/endoflife_helper.rb +114 -0
  12. data/lib/helpers/http_helper.rb +117 -15
  13. data/lib/helpers/lockfile_dependency_parser.rb +99 -0
  14. data/lib/helpers/lockfile_indexer.rb +3 -0
  15. data/lib/helpers/markdown_escape.rb +58 -0
  16. data/lib/helpers/markdown_helper.rb +145 -6
  17. data/lib/helpers/pep440_helper.rb +100 -0
  18. data/lib/helpers/python_helper.rb +19 -0
  19. data/lib/helpers/ruby_advisory_db.rb +23 -0
  20. data/lib/helpers/ruby_helper.rb +18 -28
  21. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  22. data/lib/helpers/sarif_helper.rb +157 -28
  23. data/lib/helpers/semver_satisfaction.rb +68 -0
  24. data/lib/helpers/status_helper.rb +68 -0
  25. data/lib/helpers/summary_helper.rb +52 -0
  26. data/lib/helpers/terminal_helper.rb +167 -19
  27. data/lib/helpers/version_helper.rb +16 -1
  28. data/lib/helpers/vulnerability_helper.rb +73 -7
  29. data/lib/still_active/artifactory_client.rb +166 -0
  30. data/lib/still_active/ceiling_reconciler.rb +43 -0
  31. data/lib/still_active/cli.rb +292 -20
  32. data/lib/still_active/config.rb +59 -2
  33. data/lib/still_active/config_file.rb +207 -0
  34. data/lib/still_active/deps_dev_client.rb +140 -9
  35. data/lib/still_active/diff.rb +81 -2
  36. data/lib/still_active/ecosystem_lens.rb +284 -0
  37. data/lib/still_active/ecosystems_client.rb +123 -0
  38. data/lib/still_active/forgejo_client.rb +50 -0
  39. data/lib/still_active/github_client.rb +126 -0
  40. data/lib/still_active/gitlab_client.rb +15 -20
  41. data/lib/still_active/options.rb +58 -6
  42. data/lib/still_active/osv_client.rb +147 -0
  43. data/lib/still_active/poison_security_correlator.rb +232 -0
  44. data/lib/still_active/pypi_client.rb +56 -0
  45. data/lib/still_active/repository.rb +12 -4
  46. data/lib/still_active/sarif/rules.rb +35 -11
  47. data/lib/still_active/sbom_reader.rb +191 -0
  48. data/lib/still_active/sbom_workflow.rb +96 -0
  49. data/lib/still_active/suppressions.rb +143 -0
  50. data/lib/still_active/version.rb +1 -1
  51. data/lib/still_active/workflow.rb +312 -61
  52. data/lib/still_active.rb +3 -0
  53. data/still_active.gemspec +34 -9
  54. metadata +72 -11
@@ -1,24 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "options"
4
+ require_relative "config_file"
4
5
  require_relative "diff"
5
6
  require_relative "../helpers/activity_helper"
6
7
  require_relative "../helpers/bot_context"
7
8
  require_relative "../helpers/bundler_helper"
9
+ require_relative "../helpers/constraint_helper"
8
10
  require_relative "../helpers/cyclonedx_helper"
9
11
  require_relative "../helpers/diff_markdown_helper"
10
12
  require_relative "../helpers/emoji_helper"
11
13
  require_relative "../helpers/markdown_helper"
12
14
  require_relative "../helpers/sarif_helper"
15
+ require_relative "../helpers/status_helper"
16
+ require_relative "../helpers/summary_helper"
13
17
  require_relative "../helpers/terminal_helper"
14
18
  require_relative "../helpers/version_helper"
15
19
  require_relative "../helpers/vulnerability_helper"
20
+ require_relative "sbom_reader"
21
+ require_relative "sbom_workflow"
16
22
  require_relative "workflow"
17
23
 
18
24
  module StillActive
19
25
  class CLI
26
+ # The committed JSON Schema for the --json output. Emitted as `$schema` so
27
+ # the output is self-describing and a consumer can validate it.
28
+ SCHEMA_URL = "https://raw.githubusercontent.com/SeanLF/still_active/main/docs/still_active.schema.json"
29
+
20
30
  def run(args)
21
- options = Options.new.parse!(args)
31
+ # Apply the committed .still_active.yml first so CLI flags (parsed next)
32
+ # win over it: CLI flag > env var > config file > default.
33
+ config_data = ConfigFile.load
34
+ ConfigFile.apply(StillActive.config, config_data).each { |warning| $stderr.puts("warning: #{warning}") }
35
+ options = begin
36
+ Options.new.parse!(args)
37
+ rescue ArgumentError, OptionParser::ParseError => e
38
+ # Bad CLI input (conflicting flags, a missing file, an unknown flag, a bad
39
+ # enum value) is a user error: print it and exit 2, matching the SBOM/baseline
40
+ # paths, rather than letting the raise bubble into a Ruby backtrace.
41
+ $stderr.puts("error: #{e.message}")
42
+ exit(2)
43
+ end
44
+ # After CLI flags resolve, nudge (don't auto-inherit) an un-imported
45
+ # bundler-audit ignore list when the vulnerability gate is on.
46
+ hint = ConfigFile.import_hint(config_data)
47
+ $stderr.puts("hint: #{hint}") if hint
48
+ # An SBOM audit is cross-ecosystem: it runs the deps.dev/ecosyste.ms lens
49
+ # over the SBOM's packages, not Bundler over a lockfile. Dispatch before the
50
+ # Bundler resolution below so a Gemfile is never required (or read).
51
+ return run_sbom if options[:provided_sbom]
52
+
22
53
  unless options[:provided_gems]
23
54
  begin
24
55
  StillActive.config.gems = BundlerHelper.gemfile_dependencies
@@ -29,6 +60,7 @@ module StillActive
29
60
  end
30
61
 
31
62
  warn_output_flag_conflicts(options)
63
+ warn_stale_suppressions
32
64
 
33
65
  result = if $stderr.tty?
34
66
  Workflow.call { |done, total| $stderr.print("\rChecking #{done}/#{total} gems...") }
@@ -50,14 +82,25 @@ module StillActive
50
82
  case resolve_format
51
83
  when :json
52
84
  output = {
85
+ "$schema": SCHEMA_URL,
53
86
  schema_version: 1,
54
87
  tool: { name: "still_active", version: StillActive::VERSION },
55
88
  generated_at: Time.now.utc.iso8601,
56
- gems: result,
89
+ # A one-object digest of the audit's posture, so a machine/LLM
90
+ # consumer reads the headline counts without iterating every gem.
91
+ summary: SummaryHelper.summarize(result, ruby_info: ruby_info),
92
+ # Surface the derived verdict so a machine/LLM consumer reads it
93
+ # directly instead of re-deriving it from the raw dates.
94
+ gems: result.transform_values do |data|
95
+ data.merge(
96
+ activity_level: ActivityHelper.activity_level(data),
97
+ status: StatusHelper.gem_status(data),
98
+ )
99
+ end,
57
100
  }
58
101
  output[:ruby] = ruby_info if ruby_info
59
102
  output[:pr_context] = pr_context if pr_context
60
- puts output.to_json
103
+ puts iso8601_times(output).to_json
61
104
  when :terminal
62
105
  puts BotContext.summary(pr_context) if pr_context
63
106
  puts TerminalHelper.render(result, ruby_info: ruby_info)
@@ -71,6 +114,126 @@ module StillActive
71
114
 
72
115
  private
73
116
 
117
+ # The --sbom path: assess a CycloneDX SBOM's packages cross-ecosystem via
118
+ # EcosystemLens, then emit a JSON report shaped like the native audit's but
119
+ # keyed "ecosystem/name@version" and carrying an `unassessable` list of every
120
+ # dep we couldn't assess (reader-level: unsupported ecosystem, no version/PURL;
121
+ # or assessment-level: the lens call raised) rather than silently dropping it.
122
+ def run_sbom
123
+ path = StillActive.config.sbom_path
124
+ require_parseable_sbom(path)
125
+ # deps.dev is the SBOM path's sole vulnerability source and an alpha API; a
126
+ # field rename would silently zero every vuln count. Canary the schema once
127
+ # and warn loudly so a degraded run never reads as an authoritative all-clear.
128
+ unless DepsDevClient.advisory_schema_ok?
129
+ $stderr.puts("warning: deps.dev vulnerability schema check failed (its `advisoryKeys` field may have changed, or the API is unreachable); vulnerability counts may be understated -- a clean result is NOT authoritative")
130
+ end
131
+ sbom = SbomReader.parse(path)
132
+ outcome = if $stderr.tty?
133
+ SbomWorkflow.call(sbom) { |done, total| $stderr.print("\rAssessing #{done}/#{total} dependencies...") }
134
+ else
135
+ SbomWorkflow.call(sbom)
136
+ end
137
+ $stderr.print("\r\e[K") if $stderr.tty?
138
+
139
+ # A reader-level gap (unsupported ecosystem, no version/PURL) and an
140
+ # assessment-time failure (a raised lens call) both mean "not assessed":
141
+ # surface them together so neither is a silent hole in the reported coverage.
142
+ unassessable = sbom.unassessable + outcome.failures
143
+ emit_sbom_json(outcome.assessed, unassessable)
144
+ warn_unassessable(unassessable)
145
+ check_exit_status(outcome.assessed)
146
+ end
147
+
148
+ # SbomReader never raises (a malformed file degrades to an empty result), which
149
+ # is right for the library entrypoint but wrong for the explicit --sbom flag:
150
+ # the user pointed at a file to audit, so a truncated/wrong-format one must
151
+ # error, not flow through to an empty "all clear" report with exit 0.
152
+ def require_parseable_sbom(path)
153
+ doc = JSON.parse(File.read(path))
154
+ return if doc.is_a?(Hash) && doc["components"].is_a?(Array)
155
+
156
+ # Matches SbomReader's own parse contract (a `components` array is what it
157
+ # reads). A metadata-only CycloneDX has nothing to audit, so the message
158
+ # names the missing array rather than over-claiming "not CycloneDX".
159
+ $stderr.puts("error: #{path} has no CycloneDX `components` array to audit")
160
+ exit(2)
161
+ rescue JSON::ParserError
162
+ $stderr.puts("error: #{path} is not valid JSON")
163
+ exit(2)
164
+ rescue SystemCallError => e
165
+ # Options checked the path exists, not that it's readable (a permission
166
+ # denial, or a directory). Exit cleanly instead of crashing with a trace.
167
+ $stderr.puts("error: cannot read SBOM file #{path}: #{e.message}")
168
+ exit(2)
169
+ end
170
+
171
+ # SBOM output deliberately omits the Ruby audit's `$schema`: the shape differs
172
+ # (composite keys, an unassessable list, no Ruby/PR-context blocks), so it
173
+ # would be a false claim to point at that contract. schema_version stays 1.
174
+ def emit_sbom_json(result, unassessable)
175
+ output = {
176
+ schema_version: 1,
177
+ tool: { name: "still_active", version: StillActive::VERSION },
178
+ generated_at: Time.now.utc.iso8601,
179
+ summary: sbom_summary(result, unassessable),
180
+ dependencies: result.transform_values do |data|
181
+ data.merge(
182
+ activity_level: ActivityHelper.activity_level(data),
183
+ status: StatusHelper.gem_status(data),
184
+ )
185
+ end,
186
+ unassessable:,
187
+ }
188
+ puts iso8601_times(output).to_json
189
+ end
190
+
191
+ def sbom_summary(result, unassessable)
192
+ statuses = result.each_value.map { |data| StatusHelper.gem_status(data) }
193
+ {
194
+ total_assessed: result.size,
195
+ unassessable_count: unassessable.size,
196
+ # The single worst per-dependency verdict, so a consumer reads one
197
+ # project-level posture without scanning every dependency.
198
+ status: StatusHelper.project_status(result),
199
+ status_counts: statuses.tally,
200
+ }
201
+ end
202
+
203
+ # Unassessable deps are already in the JSON; echo a one-line count to stderr so
204
+ # a human running interactively sees the coverage gap without parsing stdout.
205
+ def warn_unassessable(unassessable)
206
+ return if unassessable.empty?
207
+
208
+ noun = unassessable.size == 1 ? "dependency" : "dependencies"
209
+ $stderr.puts("warning: #{unassessable.size} #{noun} could not be assessed " \
210
+ "(a private/alternative registry, unsupported ecosystem, missing version, no package URL, or a lookup failure); " \
211
+ "see \"unassessable\" in the JSON output")
212
+ end
213
+
214
+ # Dates live in the result as real Time objects (the activity/libyear math
215
+ # needs them), but the JSON contract is ISO8601 UTC strings, matching
216
+ # generated_at. Normalize at the serialization boundary only, so the
217
+ # terminal/markdown/SARIF paths keep their Time objects untouched.
218
+ def iso8601_times(value)
219
+ case value
220
+ when Time then value.utc.iso8601
221
+ when Hash then value.transform_values { |v| iso8601_times(v) }
222
+ when Array then value.map { |v| iso8601_times(v) }
223
+ else value
224
+ end
225
+ end
226
+
227
+ # A suppression naming a gem that isn't in the resolved dependency set can
228
+ # never fire, so it's dead config worth surfacing (the presence half of
229
+ # suppression rot, alongside the expiry half the entries handle themselves).
230
+ def warn_stale_suppressions
231
+ present = StillActive.config.gems.map { |gem| gem[:name] }
232
+ StillActive.config.suppressions.stale_gem_warnings(present).each do |warning|
233
+ $stderr.puts("warning: #{warning}")
234
+ end
235
+ end
236
+
74
237
  # The output destinations are mutually exclusive and resolved by precedence
75
238
  # (baseline > sarif > cyclonedx > terminal/markdown/json). Warn rather than
76
239
  # silently dropping the loser when more than one is set.
@@ -141,8 +304,9 @@ module StillActive
141
304
  current = current_snapshot(result, ruby_info)
142
305
  baseline = JSON.parse(File.read(baseline_path))
143
306
  diff = Diff.call(baseline: baseline, current: current)
307
+ accepted = partition_accepted_regressions!(diff)
144
308
  puts "> **#{BotContext.summary(pr_context)}**\n\n" if pr_context
145
- puts DiffMarkdownHelper.render(diff)
309
+ puts DiffMarkdownHelper.render(diff, accepted: accepted)
146
310
  exit(1) if diff.regressions.any?
147
311
  rescue JSON::ParserError => e
148
312
  $stderr.puts("error: --baseline file is not valid JSON: #{e.message}")
@@ -155,6 +319,27 @@ module StillActive
155
319
  exit(2)
156
320
  end
157
321
 
322
+ # Drops regressions a committed .still_active.yml accepts from the CI-failable
323
+ # set (mutating diff.regressions in place) and returns them as accepted
324
+ # entries so the render can show them as knowingly-suppressed rather than
325
+ # silently vanishing. Mirrors the audit/SARIF gates, which already honour the
326
+ # same suppression list; the diff was the only gate that ignored it.
327
+ def partition_accepted_regressions!(diff)
328
+ suppressions = StillActive.config.suppressions
329
+ accepted = []
330
+ diff.regressions = diff.regressions.reject do |reg|
331
+ signal = Diff.suppressible_signal(reg.kind)
332
+ next false unless signal
333
+
334
+ entry = suppressions.match(gem: reg.gem, signal: signal)
335
+ next false unless entry
336
+
337
+ accepted << Diff::Accepted.new(regression: reg, reason: entry.reason)
338
+ true
339
+ end
340
+ accepted
341
+ end
342
+
158
343
  def current_snapshot(result, ruby_info)
159
344
  snapshot = {
160
345
  "schema_version" => 1,
@@ -190,8 +375,14 @@ module StillActive
190
375
 
191
376
  puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
192
377
  end
378
+ poison = MarkdownHelper.poison_section(result)
379
+ puts poison unless poison.empty?
380
+ language_ceiling = MarkdownHelper.language_ceiling_section(result)
381
+ puts language_ceiling unless language_ceiling.empty?
193
382
  alternatives = MarkdownHelper.alternatives_section(result)
194
383
  puts alternatives unless alternatives.empty?
384
+ transitive = MarkdownHelper.transitive_section(result)
385
+ puts transitive unless transitive.empty?
195
386
  if ruby_info
196
387
  puts ""
197
388
  puts MarkdownHelper.ruby_line(ruby_info)
@@ -200,29 +391,110 @@ module StillActive
200
391
 
201
392
  def check_exit_status(result)
202
393
  config = StillActive.config
203
- return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated
394
+ return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated || config.fail_if_poison || config.fail_if_language_ceiling
204
395
 
205
- ignored = config.ignored_gems
206
- checked = result.reject { |name, _| ignored.include?(name) }
396
+ warn_unknown_severity_gate(result, config)
397
+ exit(1) if result.any? { |name, data| gate_failed?(name, data, config) }
398
+ end
207
399
 
208
- if config.fail_if_critical || config.fail_if_warning
209
- levels = checked.each_value.map { |gem_data| ActivityHelper.activity_level(gem_data) }
210
- exit(1) if config.fail_if_warning && levels.intersect?([:stale, :critical, :archived])
211
- exit(1) if config.fail_if_critical && levels.intersect?([:critical, :archived])
212
- end
400
+ # --fail-if-vulnerable=<threshold> fails closed on an advisory with no CVSS
401
+ # score (it could exceed the threshold; fresh CVEs often lack a score). Say so
402
+ # per gem, so failing a =high gate on an "unknown" severity reads as a
403
+ # deliberate conservative call the user can review and fix or suppress, not a
404
+ # mystery. Only for the thresholded form; bare --fail-if-vulnerable fails on
405
+ # every advisory regardless of severity, so there's nothing to explain.
406
+ def warn_unknown_severity_gate(result, config)
407
+ threshold = config.fail_if_vulnerable
408
+ return unless threshold.is_a?(String)
213
409
 
214
- if (vuln_setting = config.fail_if_vulnerable)
215
- checked.each_value do |d|
216
- next unless d[:vulnerability_count]&.positive?
410
+ suppressions = config.suppressions
411
+ result.each do |name, data|
412
+ next if config.ignored_gems.include?(name)
217
413
 
218
- exit(1) if vuln_setting == true
219
- exit(1) if VulnerabilityHelper.severity_at_or_above?(d[:vulnerabilities], vuln_setting)
220
- end
414
+ unknown = live_advisories(name, data, suppressions).select { |vuln| VulnerabilityHelper.unknown_severity?(vuln) }
415
+ next if unknown.empty?
416
+
417
+ ids = unknown.filter_map { |vuln| vuln[:id] }.join(", ")
418
+ labelled = ids.empty? ? "" : " (#{ids})"
419
+ $stderr.puts("warning: #{name} has an advisory of unknown severity#{labelled}; failing --fail-if-vulnerable=#{threshold} because it can't be ruled out below the threshold (review, then fix or suppress it in .still_active.yml)")
221
420
  end
421
+ end
422
+
423
+ # A gem fails the run when it trips an enabled gate that is neither
424
+ # whole-gem --ignore'd nor covered by a granular .still_active.yml
425
+ # suppression. Each signal is checked independently so accepting one finding
426
+ # (e.g. a single advisory) never blinds the others.
427
+ def gate_failed?(name, data, config)
428
+ return false if config.ignored_gems.include?(name)
429
+
430
+ suppressions = config.suppressions
431
+ failed_activity?(name, data, config, suppressions) ||
432
+ failed_vulnerability?(name, data, config, suppressions) ||
433
+ failed_outdated?(name, data, config, suppressions) ||
434
+ failed_poison?(name, data, config, suppressions) ||
435
+ failed_language_ceiling?(name, data, config, suppressions)
436
+ end
437
+
438
+ def failed_poison?(name, data, config, suppressions)
439
+ return false unless config.fail_if_poison
440
+ return false if suppressions.suppressed?(gem: name, signal: :poison)
222
441
 
223
- if (threshold = config.fail_if_outdated)
224
- exit(1) if checked.each_value.any? { |d| d[:libyear] && d[:libyear] > threshold }
442
+ # Bare --fail-if-poison fails at :warning and above (a 1-behind :note is FYI,
443
+ # not a build breaker); --fail-if-poison=TIER sets an explicit threshold.
444
+ threshold = config.fail_if_poison == true ? :warning : config.fail_if_poison
445
+ ConstraintHelper.severity_at_or_above?(data[:poison_severity], threshold)
446
+ end
447
+
448
+ def failed_language_ceiling?(name, data, config, suppressions)
449
+ return false unless config.fail_if_language_ceiling
450
+ return false if suppressions.suppressed?(gem: name, signal: :language_ceiling)
451
+
452
+ # Ceiling findings are only ever :critical (EOL-forced) or :note
453
+ # (latest-not-yet) -- there is no :warning tier for this signal -- so the
454
+ # bare flag defaults to :critical (the genuine blocker: no supported runtime
455
+ # is reachable). A latest-not-yet :note is an FYI/contribution lead that gates
456
+ # only when the user explicitly opts in with =note.
457
+ threshold = config.fail_if_language_ceiling == true ? :critical : config.fail_if_language_ceiling
458
+ ConstraintHelper.severity_at_or_above?(data.dig(:language_ceiling, :severity), threshold)
459
+ end
460
+
461
+ def failed_activity?(name, data, config, suppressions)
462
+ return false unless config.fail_if_warning || config.fail_if_critical
463
+ return false if suppressions.suppressed?(gem: name, signal: :activity)
464
+
465
+ level = ActivityHelper.activity_level(data)
466
+ (config.fail_if_warning && [:stale, :critical, :archived].include?(level)) ||
467
+ (config.fail_if_critical && [:critical, :archived].include?(level))
468
+ end
469
+
470
+ def failed_vulnerability?(name, data, config, suppressions)
471
+ setting = config.fail_if_vulnerable
472
+ return false unless setting
473
+
474
+ # Reason over the live advisory array (not vulnerability_count) so the gate
475
+ # and warn_unknown_severity_gate share one source of truth: a warning that
476
+ # says "failing" can never disagree with whether exit(1) actually fires.
477
+ live = live_advisories(name, data, suppressions)
478
+ return false if live.empty?
479
+
480
+ setting == true || VulnerabilityHelper.severity_at_or_above?(live, setting)
481
+ end
482
+
483
+ # The gem's advisories minus those an explicit .still_active.yml suppression
484
+ # accepts (by advisory id or alias). Shared by the vulnerability gate and the
485
+ # unknown-severity warning so both reason over the same live set.
486
+ def live_advisories(name, data, suppressions)
487
+ Array(data[:vulnerabilities]).reject do |vuln|
488
+ suppressions.suppressed?(gem: name, signal: :vulnerability, advisory: vuln[:id], aliases: Array(vuln[:aliases]))
225
489
  end
226
490
  end
491
+
492
+ def failed_outdated?(name, data, config, suppressions)
493
+ threshold = config.fail_if_outdated
494
+ return false unless threshold
495
+ return false if suppressions.suppressed?(gem: name, signal: :libyear)
496
+
497
+ data[:libyear] && data[:libyear] > threshold
498
+ end
227
499
  end
228
500
  end
@@ -3,16 +3,22 @@
3
3
  require "bundler"
4
4
  require "octokit"
5
5
  require "open3"
6
+ require_relative "suppressions"
6
7
 
7
8
  module StillActive
8
9
  class Config
9
- attr_writer :github_oauth_token, :gitlab_token, :gemfile_path
10
+ attr_writer :github_oauth_token, :gitlab_token, :forgejo_token, :artifactory_token, :artifactory_host, :gemfile_path, :ecosystems_email
11
+ attr_accessor :sbom_path
10
12
  attr_accessor :alternatives,
13
+ :unreleased_commits,
14
+ :direct_only,
11
15
  :baseline_path,
12
16
  :critical_warning_emoji,
13
17
  :cyclonedx_path,
14
18
  :cyclonedx_version,
15
19
  :fail_if_critical,
20
+ :fail_if_poison,
21
+ :fail_if_language_ceiling,
16
22
  :fail_if_warning,
17
23
  :futurist_emoji,
18
24
  :gems,
@@ -24,21 +30,31 @@ module StillActive
24
30
  :no_warning_range_end,
25
31
  :sarif_path,
26
32
  :success_emoji,
33
+ :suppressions,
27
34
  :unsure_emoji,
28
35
  :warning_emoji,
29
36
  :warning_range_end
30
37
 
31
38
  def initialize
32
39
  @alternatives = false
40
+ @unreleased_commits = false
41
+ @direct_only = false
33
42
  @fail_if_critical = false
43
+ @fail_if_poison = false
44
+ @fail_if_language_ceiling = false
34
45
  @fail_if_outdated = nil
35
46
  @fail_if_vulnerable = nil
36
47
  @fail_if_warning = false
37
48
  @gemfile_path = nil
38
49
  @gems = []
39
50
  @ignored_gems = []
51
+ @suppressions = Suppressions.from(nil)
40
52
  @github_oauth_token = nil
41
53
  @gitlab_token = nil
54
+ @forgejo_token = nil
55
+ @artifactory_token = nil
56
+ @artifactory_host = nil
57
+ @ecosystems_email = nil
42
58
 
43
59
  @parallelism = 10
44
60
 
@@ -54,7 +70,11 @@ module StillActive
54
70
  @unsure_emoji = "❓"
55
71
  @warning_emoji = "⚠️"
56
72
 
57
- @no_warning_range_end = 1
73
+ # Release-age thresholds (years) calibrated against real RubyGems cadence,
74
+ # not the npm-derived 12-month convention: healthy mature gems routinely go
75
+ # 12-18 months between releases, so the ok ceiling is 18 months. > 3 years
76
+ # is critical. See #32.
77
+ @no_warning_range_end = 1.5
58
78
  @warning_range_end = 3
59
79
  end
60
80
 
@@ -64,13 +84,50 @@ module StillActive
64
84
  end
65
85
 
66
86
  def github_oauth_token
87
+ # Cache the resolution including a nil result: the token can't change
88
+ # mid-run, and provider selection now reads this per gem, so a plain ||=
89
+ # (which doesn't memoize nil) would re-shell `gh auth token` for every gem
90
+ # in a no-token run.
91
+ return @github_oauth_token if @github_oauth_token_resolved
92
+
93
+ # Assign before flipping the flag: gh_cli_token shells out and yields the
94
+ # async reactor, so a concurrent fiber must never see resolved=true while
95
+ # the value is still nil (it would wrongly route a tokened gem to the
96
+ # ecosyste.ms fallback). Worst case under contention is a few extra `gh`
97
+ # calls, never a premature nil; Workflow.call also resolves this once before
98
+ # the fan-out so the contended path isn't normally hit.
67
99
  @github_oauth_token ||= presence(ENV["GITHUB_TOKEN"]) || presence(ENV["GH_TOKEN"]) || gh_cli_token
100
+ @github_oauth_token_resolved = true
101
+ @github_oauth_token
68
102
  end
69
103
 
70
104
  def gitlab_token
71
105
  @gitlab_token ||= presence(ENV["GITLAB_TOKEN"]) || glab_cli_token
72
106
  end
73
107
 
108
+ # Codeberg/Forgejo has no ubiquitous CLI to borrow a token from (unlike
109
+ # gh/glab), so it's env-var only. Anonymous works for public repos; a token
110
+ # only raises the rate limit. CODEBERG_TOKEN is accepted as a convenience
111
+ # alias for the codeberg.org default host.
112
+ # Optional contact email for ecosyste.ms's "polite pool" (sent as a mailto
113
+ # query param), which raises the anonymous rate limit and lets them reach
114
+ # out. Nil by default -- anonymous is plenty for a typical lockfile.
115
+ def ecosystems_email
116
+ @ecosystems_email ||= presence(ENV["STILL_ACTIVE_ECOSYSTEMS_EMAIL"]&.strip)
117
+ end
118
+
119
+ def forgejo_token
120
+ @forgejo_token ||= presence(ENV["STILL_ACTIVE_FORGEJO_TOKEN"]) || presence(ENV["CODEBERG_TOKEN"])
121
+ end
122
+
123
+ def artifactory_token
124
+ @artifactory_token ||= presence(ENV["STILL_ACTIVE_ARTIFACTORY_TOKEN"])
125
+ end
126
+
127
+ def artifactory_host
128
+ @artifactory_host ||= presence(ENV["STILL_ACTIVE_ARTIFACTORY_HOST"])
129
+ end
130
+
74
131
  # Lazy so that running with --gems=... (no Gemfile needed) doesn't crash
75
132
  # when invoked from a directory without a Gemfile in the tree.
76
133
  def gemfile_path