Module: StillActive::Diff
Overview
Compares two still_active JSON snapshots and produces a structured Diff. Designed for PR review: surfaces regressions (CI-failable deltas) on top of the full added/removed/bumped breakdown.
Schema versions accepted: see SUPPORTED_SCHEMA_VERSIONS. A snapshot with a higher schema_version is rejected loudly rather than silently parsed.
Defined Under Namespace
Classes: Accepted, Added, Bumped, Regression, Removed, Result, SignalChange, UnsupportedSchemaError
Constant Summary collapse
- SUPPORTED_SCHEMA_VERSIONS =
[1].freeze
- SCORECARD_DROP_THRESHOLD =
absolute drop to flag
1.0- SCORECARD_GOOD_THRESHOLD =
categorical threshold (good -> below-good)
7.0- NEW_GEM_LIBYEAR_THRESHOLD =
added gems already this far behind regress
0.5- LIBYEAR_DELTA_THRESHOLD =
floating-point fuzz
0.01- SUPPRESSIBLE_REGRESSION_SIGNALS =
Maps a maintenance regression kind to the .still_active.yml signal that accepts it, so a committed suppression mutes the diff exactly as it already mutes the audit and SARIF gates. Only kinds a bare gem+signal entry can cover appear here: vulnerability regressions require an explicit advisory id (which the diff regression doesn't carry) and stay CI-failable, and scorecard/yanked/ruby-EOL are not gateable signals.
{ new_gem_archived: :activity, archived: :activity, new_gem_stale: :libyear, libyear_worsened: :libyear, }.freeze
- NUMERIC_GEM_FIELDS =
The diff dereferences gem fields with type-sensitive operations: a non-Hash value crashes the intersection branch, an arithmetic field that isn't numeric crashes (libyear/scorecard) or silently fabricates a count (vulnerability_count via .to_i), and a non-array vulnerabilities silently drops advisory ids. The baseline is untrusted user input, so validate the shape the diff requires here and let the rest of the code assume it.
["vulnerability_count", "scorecard_score", "libyear"].freeze
Instance Method Summary collapse
- #advisory_ids(data) ⇒ Object
- #call(baseline:, current:) ⇒ Object
-
#classify_bump(before, after) ⇒ Object
Categorises a version bump: - :introduced_vulns - new advisories appeared on the resolved version - :closed_vulns - all advisories cleared - :older_relative - libyear-to-latest grew (rare; usually unchanged) - :fresher - libyear-to-latest shrank - :neutral - no obvious signal change.
- #collect_regressions(added:, bumped:, signal_changes:, ruby_delta:) ⇒ Object
- #collect_signal_changes(before, after) ⇒ Object
- #ruby_delta(before, after) ⇒ Object
-
#suppressible_signal(kind) ⇒ Object
The suppression signal that accepts this regression kind, or nil when the kind is not suppressible by a bare gem+signal .still_active.yml entry.
- #validate_gem!(role, name, data) ⇒ Object
- #validate_schema!(snapshot, role) ⇒ Object
- #vuln_count(data) ⇒ Object
Instance Method Details
#advisory_ids(data) ⇒ Object
298 299 300 |
# File 'lib/still_active/diff.rb', line 298 def advisory_ids(data) Array(data["vulnerabilities"]).flat_map { |v| [v["id"], *Array(v["aliases"])].compact }.uniq end |
#call(baseline:, current:) ⇒ Object
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 |
# File 'lib/still_active/diff.rb', line 51 def call(baseline:, current:) validate_schema!(baseline, "baseline") validate_schema!(current, "current") b_gems = baseline.fetch("gems", {}) c_gems = current.fetch("gems", {}) added = (c_gems.keys - b_gems.keys).sort.map { |n| Added.new(name: n, data: c_gems[n]) } removed = (b_gems.keys - c_gems.keys).sort.map { |n| Removed.new(name: n, data: b_gems[n]) } bumped = [] signal_changes = [] (b_gems.keys & c_gems.keys).sort.each do |name| before = b_gems[name] after = c_gems[name] if before["version_used"] != after["version_used"] bumped << Bumped.new( name: name, before_version: before["version_used"], after_version: after["version_used"], kind: classify_bump(before, after), before: before, after: after, ) end changes = collect_signal_changes(before, after) signal_changes << SignalChange.new(name: name, changes: changes, before: before, after: after) if changes.any? end ruby = ruby_delta(baseline["ruby"], current["ruby"]) regressions = collect_regressions( added: added, bumped: bumped, signal_changes: signal_changes, ruby_delta: ruby, ) Result.new( added: added, removed: removed, bumped: bumped, signal_changes: signal_changes, regressions: regressions, ruby: ruby, ) end |
#classify_bump(before, after) ⇒ Object
Categorises a version bump:
- :introduced_vulns - new advisories appeared on the resolved version
- :closed_vulns - all advisories cleared
- :older_relative - libyear-to-latest grew (rare; usually unchanged)
- :fresher - libyear-to-latest shrank
- :neutral - no obvious signal change
168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/still_active/diff.rb', line 168 def classify_bump(before, after) opened = vuln_count(after) - vuln_count(before) return :introduced_vulns if opened.positive? return :closed_vulns if opened.negative? delta = (after["libyear"] || 0.0) - (before["libyear"] || 0.0) return :older_relative if delta > LIBYEAR_DELTA_THRESHOLD return :fresher if delta < -LIBYEAR_DELTA_THRESHOLD :neutral end |
#collect_regressions(added:, bumped:, signal_changes:, ruby_delta:) ⇒ Object
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/still_active/diff.rb', line 225 def collect_regressions(added:, bumped:, signal_changes:, ruby_delta:) regs = [] added.each do |a| data = a.data if vuln_count(data).positive? regs << Regression.new(kind: :new_gem_with_vulns, gem: a.name, detail: "#{vuln_count(data)} vulns at introduction") elsif data["archived"] regs << Regression.new(kind: :new_gem_archived, gem: a.name, detail: "added gem points at archived repo") elsif data["libyear"] && data["libyear"] > NEW_GEM_LIBYEAR_THRESHOLD regs << Regression.new(kind: :new_gem_stale, gem: a.name, detail: "added gem already #{data["libyear"]} libyears behind latest") end end bumped.each do |b| if b.kind == :introduced_vulns regs << Regression.new( kind: :bump_introduced_vulns, gem: b.name, detail: "#{b.before_version} -> #{b.after_version}", ) end end signal_changes.each do |sc| sc.changes.each do |ch| case ch[:kind] when :archived regs << Regression.new(kind: :archived, gem: sc.name, detail: "repo archived since baseline") when :new_vulnerability ids = Array(ch[:ids]).join(", ") regs << Regression.new(kind: :new_vulnerability, gem: sc.name, detail: "#{ch[:from]} -> #{ch[:to]}#{" (#{ids})" unless ids.empty?}") when :scorecard_dropped note = ch[:crossed_good] ? " crossed #{SCORECARD_GOOD_THRESHOLD}" : "" regs << Regression.new(kind: :scorecard_dropped, gem: sc.name, detail: "#{ch[:from]} -> #{ch[:to]}#{note}") when :version_yanked regs << Regression.new(kind: :version_yanked, gem: sc.name, detail: "pinned version yanked from rubygems") when :libyear_worsened regs << Regression.new( kind: :libyear_worsened, gem: sc.name, detail: "libyear #{ch[:from]} -> #{ch[:to]} (+#{ch[:delta]}y; same pinned version)", ) end end end if ruby_delta && ruby_delta[:newly_eol] regs << Regression.new(kind: :ruby_eol_introduced, gem: "(ruby)", detail: "Ruby #{ruby_delta[:to]} is now EOL") end regs end |
#collect_signal_changes(before, after) ⇒ Object
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 |
# File 'lib/still_active/diff.rb', line 180 def collect_signal_changes(before, after) changes = [] if !before["archived"] && after["archived"] changes << { kind: :archived, from: false, to: true } end opened = vuln_count(after) - vuln_count(before) if opened.positive? && before["version_used"] == after["version_used"] # Set difference is enough for the common case. Edge case: an advisory # backfilled with a CVE alias alongside an existing GHSA can show up as # "new" in this list even though it's a re-keying of the same issue. # The vulnerability_count gate above keeps that to detail-string noise. new_ids = advisory_ids(after) - advisory_ids(before) changes << { kind: :new_vulnerability, from: vuln_count(before), to: vuln_count(after), ids: new_ids.first(3) } end if before["scorecard_score"] && after["scorecard_score"] drop = before["scorecard_score"] - after["scorecard_score"] # OSSF treats >= 7.0 as "good". A score landing at 7.0 stays good (a 7.5 # -> 7.0 dip is noise within the safe zone). Only drops below 7.0 cross. crossed = before["scorecard_score"] >= SCORECARD_GOOD_THRESHOLD && after["scorecard_score"] < SCORECARD_GOOD_THRESHOLD if drop >= SCORECARD_DROP_THRESHOLD || crossed changes << { kind: :scorecard_dropped, from: before["scorecard_score"], to: after["scorecard_score"], crossed_good: crossed } end end if before["libyear"] && after["libyear"] && before["version_used"] == after["version_used"] # Same pinned version + libyear grew = upstream released and we didn't # follow. That IS a regression. If version_used moved forward we deliberately # don't flag — moving forward isn't a PR regression even when libyear-to-latest # technically grows (because upstream is releasing faster). delta = after["libyear"] - before["libyear"] if delta > LIBYEAR_DELTA_THRESHOLD changes << { kind: :libyear_worsened, from: before["libyear"], to: after["libyear"], delta: delta.round(2) } end end if !before["version_yanked"] && after["version_yanked"] changes << { kind: :version_yanked } end changes end |
#ruby_delta(before, after) ⇒ Object
279 280 281 282 283 284 285 286 287 288 289 290 291 292 |
# File 'lib/still_active/diff.rb', line 279 def ruby_delta(before, after) return if before.nil? && after.nil? before ||= {} after ||= {} { version_changed: before["version"] != after["version"], from: before["version"], to: after["version"], newly_eol: !before["eol"] && !!after["eol"], libyear_before: before["libyear"], libyear_after: after["libyear"], } end |
#suppressible_signal(kind) ⇒ Object
The suppression signal that accepts this regression kind, or nil when the kind is not suppressible by a bare gem+signal .still_active.yml entry.
47 48 49 |
# File 'lib/still_active/diff.rb', line 47 def suppressible_signal(kind) SUPPRESSIBLE_REGRESSION_SIGNALS[kind] end |
#validate_gem!(role, name, data) ⇒ Object
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 |
# File 'lib/still_active/diff.rb', line 134 def validate_gem!(role, name, data) unless data.is_a?(Hash) raise UnsupportedSchemaError, "#{role} gem #{name.inspect} is malformed (expected an object, got #{data.class})" end NUMERIC_GEM_FIELDS.each do |field| value = data[field] next if value.nil? || value.is_a?(Numeric) raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a non-numeric #{field} (got #{value.class})" end vulns = data["vulnerabilities"] return if vulns.nil? unless vulns.is_a?(Array) raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a malformed vulnerabilities list (expected an array, got #{vulns.class})" end # advisory_ids dereferences each entry as a hash (entry["id"]); a scalar # element would crash there, so reject non-object entries up front. vulns.each do |entry| next if entry.is_a?(Hash) raise UnsupportedSchemaError, "#{role} gem #{name.inspect} has a malformed vulnerability entry (expected an object, got #{entry.class})" end end |
#validate_schema!(snapshot, role) ⇒ Object
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 |
# File 'lib/still_active/diff.rb', line 98 def validate_schema!(snapshot, role) # A user can point --baseline at any JSON file. Reject a wrong shape here # as an UnsupportedSchemaError (which emit_diff turns into a clean exit 2) # rather than letting snapshot["..."] / gems.keys raise a raw stack trace. unless snapshot.is_a?(Hash) raise UnsupportedSchemaError, "#{role} is not a still_active JSON object (got #{snapshot.class})" end version = snapshot["schema_version"] unless SUPPORTED_SCHEMA_VERSIONS.include?(version) raise UnsupportedSchemaError, "#{role} has schema_version=#{version.inspect}; supported: #{SUPPORTED_SCHEMA_VERSIONS.join(", ")}" end ruby = snapshot["ruby"] unless ruby.nil? || ruby.is_a?(Hash) raise UnsupportedSchemaError, "#{role} has a malformed ruby section (expected an object, got #{ruby.class})" end gems = snapshot["gems"] return if gems.nil? unless gems.is_a?(Hash) raise UnsupportedSchemaError, "#{role} has a malformed gems section (expected an object, got #{gems.class})" end gems.each { |name, data| validate_gem!(role, name, data) } end |
#vuln_count(data) ⇒ Object
294 295 296 |
# File 'lib/still_active/diff.rb', line 294 def vuln_count(data) data["vulnerability_count"].to_i end |