test_impact 0.2.0

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.
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'zlib'
7
+
8
+ module TestImpact
9
+ # `test-impact` command line entry point: merges per-process coverage parts
10
+ # into a single map, and plans which specs a diff requires.
11
+ # Thor subcommands must live in one class to share its DSL and options, so
12
+ # splitting merge/info/plan out would break the command definitions.
13
+ class CLI < Thor # rubocop:disable Metrics/ClassLength
14
+ # Any unreadable map (schema mismatch, malformed payload, truncated gzip,
15
+ # corrupt JSON) must degrade to "no map" so plan falls back to a full run.
16
+ MAP_LOAD_ERRORS = [
17
+ TestImpact::SchemaVersionError, TestImpact::MapFormatError, Zlib::Error, JSON::ParserError
18
+ ].freeze
19
+
20
+ def self.exit_on_failure?
21
+ true
22
+ end
23
+
24
+ desc 'merge', 'Merge part-*.json.gz coverage maps into a single map'
25
+ method_option :input, type: :string, default: 'tmp/test_impact', desc: 'Directory containing part-*.json.gz files'
26
+ method_option :output, type: :string, default: '.test_impact/map.json.gz', desc: 'Output path for the merged map'
27
+ def merge
28
+ input_dir = options[:input]
29
+ output_path = options[:output]
30
+
31
+ part_paths = Dir.glob(File.join(input_dir, 'part-*.json.gz'))
32
+ die("no part-*.json.gz files found in #{input_dir}") if part_paths.empty?
33
+
34
+ merged = merge_parts(load_parts(part_paths), part_paths)
35
+
36
+ FileUtils.mkdir_p(File.dirname(output_path))
37
+ MapSerializer.dump(merged, output_path)
38
+
39
+ warn_merge_summary(merged, part_paths.size, output_path)
40
+ end
41
+
42
+ desc 'info', 'Show summary information about a test impact map'
43
+ method_option :map, type: :string, default: '.test_impact/map.json.gz', desc: 'Path to the map file'
44
+ def info
45
+ map_path = options[:map]
46
+
47
+ die("map file not found: #{map_path}") unless File.exist?(map_path)
48
+
49
+ begin
50
+ map = MapSerializer.load(map_path)
51
+ rescue *MAP_LOAD_ERRORS => e
52
+ die("could not read map #{map_path}: #{e.message}")
53
+ end
54
+
55
+ puts "schema_version: #{map.schema_version}"
56
+ puts "commit_sha: #{map.commit_sha}"
57
+ puts "branch: #{map.branch}"
58
+ puts "generated_at: #{map.generated_at}"
59
+ puts "backend: #{map.collector.fetch('backend', 'unknown')}"
60
+ puts "source_files: #{map.index.keys.size}"
61
+ puts "spec_files: #{map.spec_count}"
62
+ puts "known_spec_files: #{map.known_spec_files.size}"
63
+ end
64
+
65
+ desc 'plan', 'Print the spec files impacted by the current diff'
66
+ method_option :map, type: :string, default: '.test_impact/map.json.gz', desc: 'Path to the map file'
67
+ method_option :base, type: :string, desc: 'Base ref to diff against (overrides config and GITHUB_BASE_REF)'
68
+ method_option :format, type: :string, default: 'lines', enum: %w[lines json], desc: 'Output format'
69
+ method_option :fallback_to_all_exit_code,
70
+ type: :numeric,
71
+ default: 10,
72
+ desc: 'Exit code used for lines format when mode is all'
73
+ method_option :include_uncommitted,
74
+ type: :boolean,
75
+ default: false,
76
+ desc: 'Also consider staged, unstaged and untracked working tree changes'
77
+ def plan
78
+ map_path = options[:map]
79
+ config = Config.load
80
+ map = load_map_or_nil(map_path)
81
+
82
+ base = options.fetch(:base, normalized_github_base_ref) || config.base
83
+ result = Planner.new(map:, config:).plan(base:, include_uncommitted: options[:include_uncommitted])
84
+
85
+ warn "mode: #{result.mode}"
86
+ warn "reason: #{result.reason}" if result.reason
87
+ warn "spec_files: #{result.spec_files.size}"
88
+
89
+ if options[:format] == 'json'
90
+ print_plan_json(result)
91
+ else
92
+ print_plan_lines(result)
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def load_parts(part_paths)
99
+ part_paths.map do |path|
100
+ MapSerializer.load(path)
101
+ rescue *MAP_LOAD_ERRORS => e
102
+ die("could not read part #{path}: #{e.message}")
103
+ end
104
+ end
105
+
106
+ def merge_parts(maps, part_paths)
107
+ base_commit_sha = maps.first.commit_sha
108
+
109
+ maps[1..].each_with_index.reduce(maps[0]) do |merged, (map, idx)|
110
+ if map.commit_sha != base_commit_sha
111
+ warn "warning: commit_sha mismatch in #{part_paths[idx + 1]} (#{map.commit_sha} != #{base_commit_sha})"
112
+ end
113
+ merged.merge(map)
114
+ end
115
+ end
116
+
117
+ def warn_merge_summary(merged, part_count, output_path)
118
+ warn "merged #{part_count} part(s) into #{output_path}"
119
+ warn "source files: #{merged.index.keys.size}, specs: #{merged.spec_count}, " \
120
+ "known_spec_files: #{merged.known_spec_files.size}"
121
+ end
122
+
123
+ def print_plan_json(result)
124
+ puts JSON.generate({ 'mode' => result.mode.to_s, 'spec_files' => result.spec_files, 'reason' => result.reason })
125
+ exit(0)
126
+ end
127
+
128
+ def print_plan_lines(result)
129
+ exit(options[:fallback_to_all_exit_code]) if result.mode == :all
130
+
131
+ puts result.spec_files.join("\n") unless result.spec_files.empty?
132
+ exit(0)
133
+ end
134
+
135
+ def die(message)
136
+ warn "error: #{message}"
137
+ exit(1)
138
+ end
139
+
140
+ def load_map_or_nil(map_path)
141
+ return nil unless File.exist?(map_path)
142
+
143
+ begin
144
+ MapSerializer.load(map_path)
145
+ rescue *MAP_LOAD_ERRORS => e
146
+ warn "warning: could not read map #{map_path}: #{e.message}"
147
+ nil
148
+ end
149
+ end
150
+
151
+ def normalized_github_base_ref
152
+ ref = ENV.fetch('GITHUB_BASE_REF', nil)
153
+ ref.nil? || ref.empty? ? nil : "origin/#{ref}"
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_impact/collector/ddcov_backend'
4
+ require 'test_impact/collector/null_backend'
5
+
6
+ module TestImpact
7
+ module Collector
8
+ # Chooses the coverage backend for this process: the native ddcov backend
9
+ # when it loads, otherwise a hard failure unless the caller opted out.
10
+ module CoverageBackend
11
+ UNAVAILABLE_MESSAGE = 'test_impact: coverage backend unavailable, ' \
12
+ 'falling back to null backend because TEST_IMPACT_REQUIRE_COVERAGE ' \
13
+ 'opts out (no coverage will be collected)'
14
+
15
+ # Spelled generously on purpose: unlike an opt-in flag, a misspelled
16
+ # opt-out fails the collection job, and the typo only surfaces on the
17
+ # day the backend actually breaks.
18
+ OPT_OUT_VALUES = %w[0 false no off].freeze
19
+
20
+ def self.build(config)
21
+ # Bind once: both lookups memoize per parameter, so they must be
22
+ # asked about the very same one.
23
+ allocation_tracing = DdcovBackend.allocation_tracing?(config)
24
+ reason = DdcovBackend.unavailable_reason(use_allocation_tracing: allocation_tracing)
25
+ return DdcovBackend.new(config) if reason.nil?
26
+
27
+ # Collecting coverage is the only reason this process runs, so an
28
+ # unusable backend is a hard failure unless explicitly opted out of.
29
+ raise CoverageUnavailableError, unavailable_error_message(reason) unless opted_out?
30
+
31
+ warn UNAVAILABLE_MESSAGE
32
+ NullBackend.new
33
+ end
34
+
35
+ def self.opted_out?
36
+ OPT_OUT_VALUES.include?(ENV['TEST_IMPACT_REQUIRE_COVERAGE'].to_s.strip.downcase)
37
+ end
38
+
39
+ # Spelled out for operators who only ever see the CI log.
40
+ def self.unavailable_error_message(reason)
41
+ "test_impact: coverage backend unavailable (#{reason.class}: #{reason.message}). " \
42
+ 'Set TEST_IMPACT_REQUIRE_COVERAGE=0 to fall back to the null backend instead ' \
43
+ '(the map is then tagged as invalid and every spec runs).'
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_impact/paths'
4
+
5
+ module TestImpact
6
+ module Collector
7
+ # Coverage backend built on datadog-ci's native DDCov extension. Records
8
+ # the source files touched between #start and #stop.
9
+ class DdcovBackend
10
+ NATIVE_REQUIRE_PATH = "datadog_ci_native.#{RUBY_VERSION}_#{RUBY_PLATFORM}".freeze
11
+ DDCOV_CONST = 'Datadog::CI::TestImpactAnalysis::Coverage::DDCov'
12
+
13
+ class << self
14
+ # Probes with the same parameters the real instance will use
15
+ # (ignored_path and allocation tracing included), so a code path that
16
+ # only breaks under the real configuration still fails safe here.
17
+ def available?(use_allocation_tracing: true)
18
+ probe(use_allocation_tracing).nil?
19
+ end
20
+
21
+ # The exception that made the probe fail, or nil when the backend is
22
+ # available. Callers that turn unavailability into a hard failure need
23
+ # it to tell the user *why* ddcov could not load.
24
+ def unavailable_reason(use_allocation_tracing: true)
25
+ probe(use_allocation_tracing)
26
+ end
27
+
28
+ def allocation_tracing?(config)
29
+ config.collector['allocation_tracing'] ? true : false
30
+ end
31
+
32
+ # Must be the same value the instance passes, so the availability
33
+ # check exercises the real parameter shape (nil without Bundler).
34
+ def default_ignored_path
35
+ return nil unless defined?(Bundler)
36
+
37
+ Bundler.bundle_path.to_s
38
+ rescue StandardError
39
+ nil
40
+ end
41
+
42
+ def load_ddcov_class!
43
+ require NATIVE_REQUIRE_PATH
44
+ Object.const_get(DDCOV_CONST)
45
+ end
46
+
47
+ def build_instance(root:, ignored_path:, use_allocation_tracing:)
48
+ load_ddcov_class!.new(
49
+ root:,
50
+ ignored_path:,
51
+ threading_mode: :multi,
52
+ use_allocation_tracing:
53
+ )
54
+ end
55
+
56
+ def reset_memoization!
57
+ @probe = nil
58
+ end
59
+
60
+ private
61
+
62
+ # Memoizes one probe result per parameter: nil when ddcov works, the
63
+ # exception that broke it otherwise. Keeping "did it work" and "why
64
+ # not" in a single entry means the two can never disagree.
65
+ def probe(use_allocation_tracing)
66
+ @probe ||= {}
67
+ return @probe[use_allocation_tracing] if @probe.key?(use_allocation_tracing)
68
+
69
+ @probe[use_allocation_tracing] =
70
+ begin
71
+ build_instance(root: Paths.repo_root,
72
+ ignored_path: default_ignored_path,
73
+ use_allocation_tracing:)
74
+ .tap(&:start).stop
75
+ nil
76
+ rescue LoadError, StandardError => e
77
+ e
78
+ end
79
+ end
80
+ end
81
+
82
+ def initialize(config)
83
+ @ddcov = self.class.build_instance(
84
+ root: Paths.repo_root,
85
+ ignored_path: self.class.default_ignored_path,
86
+ use_allocation_tracing: self.class.allocation_tracing?(config)
87
+ )
88
+ end
89
+
90
+ def start
91
+ @ddcov.start
92
+ end
93
+
94
+ def stop
95
+ @ddcov.stop
96
+ end
97
+
98
+ def name
99
+ 'ddcov'
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestImpact
4
+ module Collector
5
+ # Fallback backend that records nothing. Used when coverage is unavailable
6
+ # and the caller opted out of failing; the resulting map stays invalid so
7
+ # planning degrades to a full run.
8
+ class NullBackend
9
+ def start
10
+ nil
11
+ end
12
+
13
+ def stop
14
+ {}
15
+ end
16
+
17
+ def name
18
+ 'null'
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module TestImpact
6
+ # Settings loaded from .test_impact.yml, with defaults for the base ref,
7
+ # map staleness, always-run specs, global files, ignored files, and collector
8
+ # options.
9
+ class Config
10
+ DEFAULT_GLOBAL_FILES = [
11
+ 'Gemfile',
12
+ 'Gemfile.lock',
13
+ '*.gemspec',
14
+ '.ruby-version',
15
+ 'Dockerfile',
16
+ 'config/**/*',
17
+ 'db/schema.rb',
18
+ 'db/structure.sql',
19
+ 'spec/spec_helper.rb',
20
+ 'spec/rails_helper.rb',
21
+ 'spec/factories/**/*',
22
+ 'spec/fixtures/**/*',
23
+ ].freeze
24
+
25
+ DEFAULT_COLLECTOR = {
26
+ 'allocation_tracing' => true,
27
+ 'ignored_paths' => ['vendor/', 'tmp/'].freeze,
28
+ }.freeze
29
+
30
+ attr_reader :base, :max_age_days, :always_run, :global_files, :ignore, :collector
31
+
32
+ def self.load(path = nil)
33
+ path ||= File.join(Paths.repo_root, '.test_impact.yml')
34
+ raw = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
35
+ new(raw)
36
+ end
37
+
38
+ def initialize(raw = {})
39
+ raw = raw.transform_keys(&:to_s)
40
+
41
+ @base = value_or_default(raw, 'base', 'origin/main')
42
+ @max_age_days = value_or_default(raw, 'max_age_days', 7)
43
+ @always_run = value_or_default(raw, 'always_run', [])
44
+ @global_files = value_or_default(raw, 'global_files', DEFAULT_GLOBAL_FILES.dup)
45
+ @ignore = value_or_default(raw, 'ignore', [])
46
+ @collector = value_or_default(raw, 'collector', DEFAULT_COLLECTOR.dup)
47
+ end
48
+
49
+ private
50
+
51
+ # Unlike Hash#fetch, treats an explicitly nil value (e.g. a bare
52
+ # "collector:" line in YAML) as absent so defaults still apply. A Hash
53
+ # default is merged key-by-key with the same rule applied one level down,
54
+ # so a bare "ignored_paths:" nested under it falls back to the default too.
55
+ def value_or_default(raw, key, default)
56
+ value = raw[key]
57
+ return default if value.nil?
58
+
59
+ default.is_a?(Hash) ? default.merge(value.compact) : value
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless ENV['TEST_IMPACT_COLLECT'] == '1'
4
+
5
+ require 'test_impact'
6
+ require 'test_impact/collector/coverage_backend'
7
+ require 'test_impact/recorder'
8
+
9
+ test_impact_config = TestImpact::Config.load
10
+ TestImpact.recorder = TestImpact::Recorder.new(
11
+ backend: TestImpact::Collector::CoverageBackend.build(test_impact_config),
12
+ config: test_impact_config
13
+ )
14
+
15
+ RSpec.configure do |config|
16
+ config.prepend_before(:each) { TestImpact.recorder.start_example }
17
+
18
+ config.append_after(:each) do |example|
19
+ TestImpact.recorder.finish_example(example.metadata[:absolute_file_path])
20
+ end
21
+
22
+ config.after(:suite) do
23
+ TestImpact.recorder.record_known_spec_files(RSpec.configuration.files_to_run)
24
+ TestImpact.recorder.write_part
25
+ end
26
+ end
27
+
28
+ at_exit { TestImpact.recorder&.write_part }
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module TestImpact
7
+ # Thin wrapper over the git commands planning needs: merge-base lookup,
8
+ # name-status diffs (rename/copy aware), and reachability checks.
9
+ class Git
10
+ def initialize(repo_root: Paths.repo_root)
11
+ @repo_root = repo_root
12
+ end
13
+
14
+ def merge_base(base_ref)
15
+ stdout, status = run('merge-base', base_ref, 'HEAD')
16
+ status.success? ? stdout.strip : nil
17
+ end
18
+
19
+ # Returns nil when the diff itself fails, so callers can distinguish
20
+ # "no changes" ([]) from "could not compute the diff". Omitting the
21
+ # trailing HEAD diffs merge_base_sha against the working tree
22
+ # (staged + unstaged combined) instead of against the last commit.
23
+ def changed_files(merge_base_sha, include_uncommitted: false)
24
+ diff_args = ['diff', '--name-status', '-M', '-C', merge_base_sha]
25
+ diff_args << 'HEAD' unless include_uncommitted
26
+
27
+ stdout, status = run(*diff_args)
28
+ return nil unless status.success?
29
+
30
+ diffed = stdout.each_line.filter_map { |line| parse_diff_line(line) }
31
+ return diffed unless include_uncommitted
32
+
33
+ merge_untracked(diffed)
34
+ end
35
+
36
+ # True when sha is reachable from ref. A mere `cat-file -e` object check
37
+ # would also accept dangling objects left behind by a force-push.
38
+ def in_history?(sha, ref)
39
+ _, status = run('merge-base', '--is-ancestor', sha, ref)
40
+ status.success?
41
+ end
42
+
43
+ # On pull_request events actions/checkout checks out the synthetic
44
+ # refs/pull/N/merge commit, which exists in no branch history — a map
45
+ # recorded under it would always look stale to Planner#in_history?.
46
+ # The event payload carries the real head commit, so prefer it there.
47
+ def head_sha
48
+ pull_request_head_sha || rev_parse('HEAD')
49
+ end
50
+
51
+ # Detached HEAD (the default actions/checkout state) yields the literal
52
+ # string "HEAD"; only then is CI's branch name a valid substitute.
53
+ # GITHUB_HEAD_REF holds the source branch on pull_request events (where
54
+ # GITHUB_REF_NAME would be the synthetic "N/merge" ref). An empty result
55
+ # means git itself failed — keep it visible as "".
56
+ def head_branch
57
+ name = rev_parse('--abbrev-ref', 'HEAD')
58
+ return name unless name == 'HEAD'
59
+
60
+ ci_branch = [ENV.fetch('GITHUB_HEAD_REF', nil), ENV.fetch('GITHUB_REF_NAME', nil)].find { |v| v && !v.empty? }
61
+ ci_branch || name
62
+ end
63
+
64
+ private
65
+
66
+ def rev_parse(*)
67
+ stdout, status = run('rev-parse', *)
68
+ status.success? ? stdout.strip : ''
69
+ rescue StandardError
70
+ ''
71
+ end
72
+
73
+ # Only pull_request-shaped events carry pull_request.head.sha; on push
74
+ # GITHUB_EVENT_PATH is still set but the key is absent, so this returns
75
+ # nil and the plain HEAD lookup stands. dig raises TypeError when an
76
+ # intermediate value is not a Hash, so the rescue must cover it too.
77
+ def pull_request_head_sha
78
+ path = ENV.fetch('GITHUB_EVENT_PATH', nil)
79
+ return nil if path.nil? || path.empty? || !File.file?(path)
80
+
81
+ sha = JSON.parse(File.read(path)).dig('pull_request', 'head', 'sha')
82
+ sha if sha.is_a?(String) && !sha.empty?
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ # Untracked files are new by definition, so they map straight to 'A'
88
+ # and cannot collide with the diff output.
89
+ def merge_untracked(diffed)
90
+ untracked = untracked_files
91
+ return nil unless untracked
92
+
93
+ diffed + untracked.map { |path| { status: 'A', path: } }
94
+ end
95
+
96
+ def untracked_files
97
+ stdout, status = run('ls-files', '--others', '--exclude-standard', '-z')
98
+ return nil unless status.success?
99
+
100
+ stdout.split("\0").reject(&:empty?)
101
+ end
102
+
103
+ def parse_diff_line(line)
104
+ fields = line.chomp.split("\t")
105
+ return nil if fields.empty?
106
+
107
+ raw_status, *paths = fields
108
+
109
+ case raw_status[0]
110
+ when 'R'
111
+ { status: 'R', path: paths[1], old_path: paths[0] }
112
+ when 'C'
113
+ # Copy lines list source then destination; only the destination is new.
114
+ { status: 'A', path: paths[1] }
115
+ else
116
+ { status: raw_status[0], path: paths[0] }
117
+ end
118
+ end
119
+
120
+ def run(*)
121
+ stdout, _stderr, status = Open3.capture3('git', '-C', @repo_root, *)
122
+ [stdout, status]
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestImpact
4
+ # The coverage map handed between phases: which specs touch which source
5
+ # files, plus the commit it was built from and the backend that built it.
6
+ class Map
7
+ SCHEMA_VERSION = 1
8
+
9
+ attr_reader :schema_version, :generated_at, :commit_sha, :branch, :collector, :known_spec_files, :index
10
+
11
+ def self.build(commit_sha:, branch:, collector:, generated_at: Time.now, known_spec_files: [], index: {})
12
+ new(
13
+ schema_version: SCHEMA_VERSION,
14
+ generated_at:,
15
+ commit_sha:,
16
+ branch:,
17
+ collector:,
18
+ known_spec_files:,
19
+ index:
20
+ )
21
+ end
22
+
23
+ def initialize(schema_version:, generated_at:, commit_sha:, branch:, collector:, known_spec_files:, index:)
24
+ validate!(generated_at:, commit_sha:, branch:, collector:, known_spec_files:)
25
+
26
+ @schema_version = schema_version
27
+ @generated_at = generated_at
28
+ @commit_sha = commit_sha
29
+ @branch = branch
30
+ @collector = collector
31
+ @known_spec_files = Set.new(known_spec_files)
32
+ @index =
33
+ index.each_with_object({}) do |(k, v), h|
34
+ raise TypeError, "index value for #{k.inspect} must not be nil" if v.nil?
35
+
36
+ h[k] = Set.new(v)
37
+ end
38
+ end
39
+
40
+ def commit_sha_mismatch?(other)
41
+ commit_sha != other.commit_sha
42
+ end
43
+
44
+ # Metadata (generated_at, commit_sha, branch, collector) always comes from
45
+ # the newer of the two maps, so merge order cannot pair a fresh timestamp
46
+ # with a stale commit_sha.
47
+ def merge(other)
48
+ merged_index = index.each_with_object({}) { |(k, v), h| h[k] = v.dup }
49
+ other.index.each do |k, v|
50
+ merged_index[k] = (merged_index[k] || Set.new) | v
51
+ end
52
+
53
+ newer = other.generated_at >= generated_at ? other : self
54
+
55
+ self.class.new(
56
+ schema_version: SCHEMA_VERSION,
57
+ generated_at: newer.generated_at,
58
+ commit_sha: newer.commit_sha,
59
+ branch: newer.branch,
60
+ collector: merged_collector(newer:, other:),
61
+ known_spec_files: known_spec_files | other.known_spec_files,
62
+ index: merged_index
63
+ )
64
+ end
65
+
66
+ def specs_for(source_path)
67
+ index[source_path] || Set.new
68
+ end
69
+
70
+ def covered?(source_path)
71
+ index.key?(source_path)
72
+ end
73
+
74
+ def empty?
75
+ index.empty?
76
+ end
77
+
78
+ def spec_count
79
+ index.each_value.with_object(Set.new) { |specs, acc| acc.merge(specs) }.size
80
+ end
81
+
82
+ def valid_backend?
83
+ collector['backend'] != 'null'
84
+ end
85
+
86
+ def ==(other)
87
+ other.is_a?(Map) && comparable_fields == other.comparable_fields
88
+ end
89
+ alias eql? ==
90
+
91
+ def hash
92
+ comparable_fields.hash
93
+ end
94
+
95
+ protected
96
+
97
+ def comparable_fields
98
+ [schema_version, generated_at, commit_sha, branch, collector, known_spec_files, index]
99
+ end
100
+
101
+ private
102
+
103
+ # Set.new(nil) silently yields an empty Set; nils and wrong types here
104
+ # are malformed payloads and must raise so MapSerializer degrades them
105
+ # to MapFormatError instead of crashing later in Planner.
106
+ def validate!(generated_at:, commit_sha:, branch:, collector:, known_spec_files:)
107
+ raise TypeError, 'known_spec_files must not be nil' if known_spec_files.nil?
108
+ raise TypeError, 'collector must be a Hash' unless collector.is_a?(Hash)
109
+ raise TypeError, 'commit_sha must be a String' unless commit_sha.is_a?(String)
110
+ raise TypeError, 'branch must be a String' unless branch.is_a?(String)
111
+ raise TypeError, 'generated_at must be a Time' unless generated_at.is_a?(Time)
112
+ end
113
+
114
+ # A "null" backend in any merged part means part of the coverage is missing,
115
+ # so the merged map must stay invalid regardless of merge order.
116
+ def merged_collector(newer:, other:)
117
+ if collector['backend'] == 'null' || other.collector['backend'] == 'null'
118
+ newer.collector.merge('backend' => 'null')
119
+ else
120
+ newer.collector
121
+ end
122
+ end
123
+ end
124
+ end