|
| 1 | +#!/usr/bin/env ruby |
| 2 | +# This file is distributed under New Relic's license terms. |
| 3 | +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. |
| 4 | +# frozen_string_literal: true |
| 5 | + |
| 6 | +require 'json' |
| 7 | + |
| 8 | +# Classifies the difference between two generated config schemas and turns that |
| 9 | +# into a semantic-version bump for the Fleet Control config schema. |
| 10 | +# |
| 11 | +# The schema uses flat dotted keys with no `required` array and a fixed |
| 12 | +# `additionalProperties: true` at the root, so only the change kinds that shape |
| 13 | +# can actually produce are classified. If the generator ever starts emitting a |
| 14 | +# `required` array, nested objects, or `additionalProperties: false`, add the |
| 15 | +# matching rules here — otherwise those changes would be silently under-bumped. |
| 16 | +module SchemaDiff |
| 17 | + # Change kinds grouped by the bump they force. Most severe wins. |
| 18 | + BREAKING = %w[property_removed type_changed enum_introduced enum_value_removed].freeze |
| 19 | + ADDITIVE = %w[property_added enum_removed_entirely enum_value_added default_changed].freeze |
| 20 | + COSMETIC = %w[description_changed].freeze |
| 21 | + |
| 22 | + NO_DEFAULT = Object.new # sentinel so an absent default is distinct from a nil default |
| 23 | + |
| 24 | + module_function |
| 25 | + |
| 26 | + # Parse a schema JSON file into a Hash. Missing or malformed files yield {} |
| 27 | + # so a bootstrap run (no baseline schema yet) is handled by the caller. |
| 28 | + def load_existing(path) |
| 29 | + return {} unless File.exist?(path) |
| 30 | + |
| 31 | + JSON.parse(File.read(path)) |
| 32 | + rescue JSON::ParserError |
| 33 | + {} |
| 34 | + end |
| 35 | + |
| 36 | + # Decide the version bump from the previous release's schema. `baseline` is |
| 37 | + # the previous release's parsed schema (nil/empty means there was no schema at |
| 38 | + # the last release — treat this as the first one, no bump). `starter_version` |
| 39 | + # is the schema version at that release, which the bump is applied to (so a |
| 40 | + # re-run against the same release is idempotent). Returns |
| 41 | + # { action:, bump:, old_version:, new_version:, changes: }. |
| 42 | + # |
| 43 | + # Actions: :first_release (no prior schema), :no_change (unchanged since the |
| 44 | + # last release), :bump (a version bump is recommended). |
| 45 | + def bump(baseline:, current:, starter_version:) |
| 46 | + # No prior schema, or no version to bump from -> treat as the first release. |
| 47 | + return {action: :first_release, bump: 'none', changes: []} if baseline.nil? || baseline.empty? || starter_version.to_s.empty? |
| 48 | + |
| 49 | + changes = classify_changes(baseline, current) |
| 50 | + kind = recommend_bump(changes) |
| 51 | + return {action: :no_change, bump: 'none', changes: changes} if kind == 'none' |
| 52 | + |
| 53 | + {action: :bump, bump: kind, old_version: starter_version, |
| 54 | + new_version: apply_bump(starter_version, kind), changes: changes} |
| 55 | + end |
| 56 | + |
| 57 | + # Compare two schemas' property maps and return a list of change records: |
| 58 | + # { path:, kind:, severity:, detail: }. |
| 59 | + def classify_changes(old_schema, new_schema) |
| 60 | + old_props = old_schema['properties'] || {} |
| 61 | + new_props = new_schema['properties'] || {} |
| 62 | + changes = [] |
| 63 | + |
| 64 | + (old_props.keys | new_props.keys).sort.each do |key| |
| 65 | + if !old_props.key?(key) |
| 66 | + changes << change(key, 'property_added', 'new property') |
| 67 | + elsif !new_props.key?(key) |
| 68 | + changes << change(key, 'property_removed', 'property removed') |
| 69 | + else |
| 70 | + changes.concat(classify_leaf(old_props[key], new_props[key], key)) |
| 71 | + end |
| 72 | + end |
| 73 | + |
| 74 | + changes |
| 75 | + end |
| 76 | + |
| 77 | + # Classify the changes within a single property (type, enum, default, |
| 78 | + # description). Returns an array; a property can change in more than one way. |
| 79 | + def classify_leaf(old_prop, new_prop, path) |
| 80 | + changes = [] |
| 81 | + changes.concat(type_changes(old_prop, new_prop, path)) |
| 82 | + changes.concat(enum_changes(old_prop, new_prop, path)) |
| 83 | + changes.concat(default_changes(old_prop, new_prop, path)) |
| 84 | + changes.concat(description_changes(old_prop, new_prop, path)) |
| 85 | + changes |
| 86 | + end |
| 87 | + |
| 88 | + # Highest-severity bump implied by the changes. |
| 89 | + def recommend_bump(changes) |
| 90 | + severities = changes.map { |c| c[:severity] } |
| 91 | + return 'major' if severities.include?('breaking') |
| 92 | + return 'minor' if severities.include?('additive') |
| 93 | + return 'patch' if severities.include?('cosmetic') |
| 94 | + |
| 95 | + 'none' |
| 96 | + end |
| 97 | + |
| 98 | + # Apply a bump kind to a semver string (X.Y.Z). 'none' returns it unchanged. |
| 99 | + def apply_bump(version, bump) |
| 100 | + return version if bump == 'none' |
| 101 | + |
| 102 | + major, minor, patch = parse_semver(version) |
| 103 | + case bump |
| 104 | + when 'major' then "#{major + 1}.0.0" |
| 105 | + when 'minor' then "#{major}.#{minor + 1}.0" |
| 106 | + when 'patch' then "#{major}.#{minor}.#{patch + 1}" |
| 107 | + else raise ArgumentError, "unknown bump kind: #{bump.inspect}" |
| 108 | + end |
| 109 | + end |
| 110 | + |
| 111 | + # Replace the single `version:` line in configurationDefinitions.yml text. |
| 112 | + # Raises unless exactly one such line exists, so a malformed file can't be |
| 113 | + # silently half-updated. |
| 114 | + def bump_version_line(yaml_text, new_version) |
| 115 | + pattern = /^(\s*version:\s*)(\S+)(\s*)$/ |
| 116 | + matches = yaml_text.scan(pattern).size |
| 117 | + raise "expected exactly 1 'version:' line, found #{matches}" unless matches == 1 |
| 118 | + |
| 119 | + yaml_text.sub(pattern) { "#{Regexp.last_match(1)}#{new_version}#{Regexp.last_match(3)}" } |
| 120 | + end |
| 121 | + |
| 122 | + # One-line human rendering: + added, - removed, ~ modified. |
| 123 | + def render_change(change) |
| 124 | + symbol = case change[:kind] |
| 125 | + when 'property_added' then '+' |
| 126 | + when 'property_removed' then '-' |
| 127 | + else '~' |
| 128 | + end |
| 129 | + |
| 130 | + "#{symbol} #{change[:path]}: #{change[:detail]}" |
| 131 | + end |
| 132 | + |
| 133 | + # --- internals -------------------------------------------------------------- |
| 134 | + |
| 135 | + def change(path, kind, detail) |
| 136 | + {path: path, kind: kind, severity: severity_for(kind), detail: detail} |
| 137 | + end |
| 138 | + |
| 139 | + def severity_for(kind) |
| 140 | + return 'breaking' if BREAKING.include?(kind) |
| 141 | + return 'additive' if ADDITIVE.include?(kind) |
| 142 | + return 'cosmetic' if COSMETIC.include?(kind) |
| 143 | + |
| 144 | + raise ArgumentError, "unclassified change kind: #{kind.inspect}" |
| 145 | + end |
| 146 | + |
| 147 | + # The type aspect of a property: its `anyOf` shape if present, else its `type`. |
| 148 | + def type_signature(prop) |
| 149 | + prop['anyOf'] || prop['type'] |
| 150 | + end |
| 151 | + |
| 152 | + def type_changes(old_prop, new_prop, path) |
| 153 | + old_sig = type_signature(old_prop) |
| 154 | + new_sig = type_signature(new_prop) |
| 155 | + return [] if old_sig == new_sig |
| 156 | + |
| 157 | + [change(path, 'type_changed', "#{describe_type(old_sig)} -> #{describe_type(new_sig)}")] |
| 158 | + end |
| 159 | + |
| 160 | + def describe_type(signature) |
| 161 | + signature.is_a?(String) ? signature : 'array-or-string' |
| 162 | + end |
| 163 | + |
| 164 | + def enum_changes(old_prop, new_prop, path) |
| 165 | + old_enum = old_prop['enum'] |
| 166 | + new_enum = new_prop['enum'] |
| 167 | + return [] if old_enum == new_enum |
| 168 | + return [change(path, 'enum_introduced', "enum added: #{new_enum.join(', ')}")] if old_enum.nil? |
| 169 | + return [change(path, 'enum_removed_entirely', 'enum constraint removed')] if new_enum.nil? |
| 170 | + |
| 171 | + changes = [] |
| 172 | + removed = old_enum - new_enum |
| 173 | + added = new_enum - old_enum |
| 174 | + changes << change(path, 'enum_value_removed', "enum values removed: #{removed.join(', ')}") unless removed.empty? |
| 175 | + changes << change(path, 'enum_value_added', "enum values added: #{added.join(', ')}") unless added.empty? |
| 176 | + changes |
| 177 | + end |
| 178 | + |
| 179 | + def default_changes(old_prop, new_prop, path) |
| 180 | + old_default = old_prop.key?('default') ? old_prop['default'] : NO_DEFAULT |
| 181 | + new_default = new_prop.key?('default') ? new_prop['default'] : NO_DEFAULT |
| 182 | + return [] if old_default == new_default |
| 183 | + |
| 184 | + [change(path, 'default_changed', "default changed: #{old_default.inspect} -> #{new_default.inspect}")] |
| 185 | + end |
| 186 | + |
| 187 | + def description_changes(old_prop, new_prop, path) |
| 188 | + return [] if old_prop['description'] == new_prop['description'] |
| 189 | + |
| 190 | + [change(path, 'description_changed', 'description changed')] |
| 191 | + end |
| 192 | + |
| 193 | + def parse_semver(version) |
| 194 | + match = /\A(\d+)\.(\d+)\.(\d+)\z/.match(version.to_s) |
| 195 | + raise ArgumentError, "not a semver version: #{version.inspect}" unless match |
| 196 | + |
| 197 | + match.captures.map(&:to_i) |
| 198 | + end |
| 199 | +end |
0 commit comments