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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'zlib'
5
+ require 'stringio'
6
+ require 'time'
7
+
8
+ module TestImpact
9
+ # Reads and writes Map as gzip-compressed JSON. Unreadable payloads are
10
+ # raised as MapFormatError / SchemaVersionError so callers can fall back.
11
+ module MapSerializer
12
+ class << self
13
+ def dump(map, io_or_path)
14
+ bytes = dump_bytes(map)
15
+ if io_or_path.is_a?(String)
16
+ File.binwrite(io_or_path, bytes)
17
+ else
18
+ io_or_path.write(bytes)
19
+ end
20
+ end
21
+
22
+ def load(io_or_path)
23
+ bytes =
24
+ if io_or_path.is_a?(String)
25
+ File.binread(io_or_path)
26
+ else
27
+ io_or_path.read
28
+ end
29
+ load_bytes(bytes)
30
+ end
31
+
32
+ def dump_bytes(map)
33
+ payload = {
34
+ 'schema_version' => map.schema_version,
35
+ 'generated_at' => to_iso8601(map.generated_at),
36
+ 'commit_sha' => map.commit_sha,
37
+ 'branch' => map.branch,
38
+ 'collector' => map.collector,
39
+ 'known_spec_files' => map.known_spec_files.to_a.sort,
40
+ 'index' => map.index.transform_values { |specs| specs.to_a.sort },
41
+ }
42
+
43
+ io = StringIO.new
44
+ io.set_encoding(Encoding::BINARY)
45
+ gz = Zlib::GzipWriter.new(io)
46
+ gz.write(JSON.generate(payload))
47
+ gz.close
48
+ io.string
49
+ end
50
+
51
+ def load_bytes(bytes)
52
+ io = StringIO.new(bytes)
53
+ gz = Zlib::GzipReader.new(io)
54
+ json = gz.read
55
+ gz.close
56
+
57
+ build_map(JSON.parse(json))
58
+ end
59
+
60
+ private
61
+
62
+ def build_map(data)
63
+ if data['schema_version'] != Map::SCHEMA_VERSION
64
+ raise SchemaVersionError, "unsupported schema_version: #{data['schema_version'].inspect}"
65
+ end
66
+
67
+ Map.new(
68
+ schema_version: data['schema_version'],
69
+ generated_at: Time.parse(data['generated_at']),
70
+ commit_sha: data['commit_sha'],
71
+ branch: data['branch'],
72
+ collector: data['collector'],
73
+ known_spec_files: data['known_spec_files'],
74
+ index: data['index']
75
+ )
76
+ rescue TypeError, NoMethodError, ArgumentError => e
77
+ # Missing or malformed fields (nil generated_at, non-hash index, ...)
78
+ raise MapFormatError, "malformed map payload: #{e.message}"
79
+ end
80
+
81
+ def to_iso8601(generated_at)
82
+ time = generated_at.is_a?(String) ? Time.parse(generated_at) : generated_at
83
+ # getutc (not utc/gmtime) so the caller's Time object is not mutated
84
+ time.getutc.iso8601
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestImpact
4
+ # The single source of pattern matching semantics for the config keys that
5
+ # take patterns. global_files, always_run and ignore are pure globs;
6
+ # collector.ignored_paths also accepts a plain path prefix (see
7
+ # prefix_or_glob_match?).
8
+ module PathMatcher
9
+ # FNM_PATHNAME is required for "**" to match across directory levels.
10
+ # FNM_DOTMATCH lets "**/*.yml" pick up dotfiles such as ".rubocop.yml".
11
+ FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
12
+
13
+ # Glob metacharacters; a pattern without any of them is a plain literal.
14
+ GLOB_CHARS = /[*?\[\]{}]/
15
+
16
+ def self.match?(pattern, path)
17
+ File.fnmatch?(pattern, path, FLAGS)
18
+ end
19
+
20
+ def self.any_match?(patterns, path)
21
+ Array(patterns).any? { |pattern| match?(pattern, path) }
22
+ end
23
+
24
+ def self.glob?(pattern)
25
+ GLOB_CHARS.match?(pattern)
26
+ end
27
+
28
+ # collector.ignored_paths predates glob support, so a pattern without
29
+ # metacharacters keeps its original "path prefix" meaning: the default
30
+ # 'vendor/' would match nothing as a glob.
31
+ def self.prefix_or_glob_match?(pattern, path)
32
+ glob?(pattern) ? match?(pattern, path) : path.start_with?(pattern)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'pathname'
5
+
6
+ module TestImpact
7
+ # Resolves the repository root and converts between absolute paths and the
8
+ # repo-relative paths the map is keyed by.
9
+ module Paths
10
+ class << self
11
+ def repo_root
12
+ @repo_root ||=
13
+ begin
14
+ stdout, status = Open3.capture2('git', 'rev-parse', '--show-toplevel')
15
+ status.success? ? stdout.strip : Dir.pwd
16
+ rescue StandardError
17
+ Dir.pwd
18
+ end
19
+ end
20
+
21
+ def relative(abs_path)
22
+ Pathname.new(abs_path).relative_path_from(root_pathname).to_s
23
+ end
24
+
25
+ def absolute(rel_path)
26
+ File.expand_path(rel_path, repo_root)
27
+ end
28
+
29
+ private
30
+
31
+ # Cached per repo_root value so stubs and re-memoization stay consistent.
32
+ def root_pathname
33
+ root = repo_root
34
+ @root_pathname = Pathname.new(root) if @root_pathname.nil? || @root_pathname.to_s != root
35
+ @root_pathname
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestImpact
4
+ # Outcome of planning: run everything (:all, with a reason), a selected set
5
+ # of specs (:partial), or nothing at all (:none).
6
+ class PlanResult
7
+ attr_reader :mode, :spec_files, :reason
8
+
9
+ def self.all(reason)
10
+ new(mode: :all, spec_files: [], reason:)
11
+ end
12
+
13
+ def self.partial(spec_files)
14
+ spec_files = spec_files.to_a.uniq.sort
15
+ new(mode: spec_files.empty? ? :none : :partial, spec_files:, reason: nil)
16
+ end
17
+
18
+ def initialize(mode:, spec_files:, reason:)
19
+ @mode = mode
20
+ @spec_files = spec_files
21
+ @reason = reason
22
+ end
23
+
24
+ def ==(other)
25
+ other.is_a?(PlanResult) &&
26
+ mode == other.mode &&
27
+ spec_files == other.spec_files &&
28
+ reason == other.reason
29
+ end
30
+ alias eql? ==
31
+
32
+ def hash
33
+ [mode, spec_files, reason].hash
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestImpact
4
+ # Classifies the files changed since the merge-base and turns them into a
5
+ # PlanResult, degrading to a full run whenever the map cannot be trusted.
6
+ # The classify_* methods all serve the single job of deciding what a changed
7
+ # file implies, so splitting them out would scatter one decision across classes.
8
+ class Planner # rubocop:disable Metrics/ClassLength
9
+ IGNORABLE_EXTENSIONS = ['.md', '.txt', '.adoc'].freeze
10
+ # Ruby sources and view templates. ActionView compiles templates under
11
+ # their absolute path, so DDCov records them like any other source file.
12
+ TRACKED_EXTENSIONS = ['.rb', '.erb', '.haml', '.slim', '.jbuilder'].freeze
13
+
14
+ def initialize(map:, config:, git: Git.new)
15
+ @map = map
16
+ @config = config
17
+ @git = git
18
+ end
19
+
20
+ def plan(base: nil, include_uncommitted: false)
21
+ changed = changed_files_or_reason(base || config.base, include_uncommitted:)
22
+ return PlanResult.all(changed) if changed.is_a?(String)
23
+
24
+ spec_files = Set.new
25
+ all_reason = nil
26
+
27
+ changed.each do |change|
28
+ all_reason = classify(change, spec_files)
29
+ break if all_reason
30
+ end
31
+
32
+ return PlanResult.all(all_reason) if all_reason
33
+
34
+ apply_always_run(spec_files)
35
+ spec_files.select! { |path| File.exist?(Paths.absolute(path)) }
36
+
37
+ PlanResult.partial(spec_files)
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :map, :config, :git
43
+
44
+ # Returns the changed files for base_ref, or a String reason when the diff
45
+ # cannot be trusted and the caller must degrade to a full run.
46
+ def changed_files_or_reason(base_ref, include_uncommitted:)
47
+ return 'no map available or backend invalid' if invalid_map?
48
+
49
+ stale_reason = staleness_reason(base_ref)
50
+ return stale_reason if stale_reason
51
+
52
+ merge_base_sha = git.merge_base(base_ref)
53
+ return 'could not compute merge-base (shallow clone? try fetch-depth: 0 in checkout)' unless merge_base_sha
54
+
55
+ git.changed_files(merge_base_sha, include_uncommitted:) ||
56
+ "could not compute git diff against #{merge_base_sha}"
57
+ end
58
+
59
+ def invalid_map?
60
+ map.nil? || map.empty? || !map.valid_backend?
61
+ end
62
+
63
+ def staleness_reason(base_ref)
64
+ if map.generated_at < (Time.now - (config.max_age_days * 86_400))
65
+ return "map is older than max_age_days (#{config.max_age_days})"
66
+ end
67
+
68
+ unless git.in_history?(map.commit_sha, base_ref)
69
+ return "map commit_sha #{map.commit_sha} not found in #{base_ref} history"
70
+ end
71
+
72
+ nil
73
+ end
74
+
75
+ # Returns an "all" reason string if this change forces a full run, otherwise nil
76
+ # (and mutates spec_files as a side effect).
77
+ def classify(change, spec_files)
78
+ # ignore runs first so it can subtract from global_files (carving
79
+ # config/locales/** out of the default config/**/*). Suppressing the
80
+ # "unknown file type" fallback alone would only need it in
81
+ # classify_other; winning over global_files is what buys the top slot,
82
+ # and what makes the rename rule below necessary.
83
+ return nil if ignored_change?(change)
84
+
85
+ path = change[:path]
86
+
87
+ global_reason = global_change_reason(change)
88
+ return global_reason if global_reason
89
+
90
+ return classify_as_spec(change, spec_files) if spec_file?(path)
91
+
92
+ # A file renamed away from a tracked path still carries its old
93
+ # coverage, so the old extension counts too — its dependent specs must
94
+ # not be silently dropped.
95
+ if tracked_file?(path) || (change[:old_path] && tracked_file?(change[:old_path]))
96
+ return classify_tracked(change, spec_files)
97
+ end
98
+
99
+ classify_other(change)
100
+ end
101
+
102
+ # Unlike global_change_reason, which reacts to either side of a rename,
103
+ # a rename is only ignored when BOTH sides match. Ignoring
104
+ # "R lib/foo.rb -> docs/foo.rb" on a docs/**/* pattern would silently drop
105
+ # the specs the old path still covers: ignore asserts that a file has no
106
+ # impact, not that its history had none.
107
+ def ignored_change?(change)
108
+ return false unless ignored_file?(change[:path])
109
+
110
+ old_path = change[:old_path]
111
+ old_path.nil? || ignored_file?(old_path)
112
+ end
113
+
114
+ # A rename away from a global location is still a change to that
115
+ # global file — it must not slip past the safeguard.
116
+ def global_change_reason(change)
117
+ return "global file changed: #{change[:path]}" if global_file?(change[:path])
118
+ return "global file changed: #{change[:old_path]}" if change[:old_path] && global_file?(change[:old_path])
119
+
120
+ nil
121
+ end
122
+
123
+ # A source file renamed into a spec path still carries its old coverage —
124
+ # pull its dependents (or fall back) in addition to scheduling the new
125
+ # spec itself.
126
+ def classify_as_spec(change, spec_files)
127
+ classify_spec(change, spec_files)
128
+
129
+ old_path = change[:old_path]
130
+ return nil unless old_path && tracked_file?(old_path) && !spec_file?(old_path)
131
+
132
+ classify_renamed_tracked(change, spec_files)
133
+ end
134
+
135
+ # Spec files are never indexed as coverage sources (Recorder skips spec/),
136
+ # so a changed spec only schedules itself. Non-spec helpers under spec/
137
+ # (support files etc.) fall through to the uncovered-file fallback instead.
138
+ def classify_spec(change, spec_files)
139
+ case change[:status]
140
+ when 'A', 'M', 'R'
141
+ spec_files << change[:path]
142
+ when 'D'
143
+ # excluded: do nothing
144
+ end
145
+
146
+ nil
147
+ end
148
+
149
+ def classify_tracked(change, spec_files)
150
+ case change[:status]
151
+ when 'R'
152
+ classify_renamed_tracked(change, spec_files)
153
+ else # "A", "M", "D" — an uncovered file always forces a full run
154
+ pull_covered_specs_or_fallback(change[:path], spec_files)
155
+ end
156
+ end
157
+
158
+ # The map predates the rename, so the old path carries the known
159
+ # dependents; a covered old path must not force a full run just because
160
+ # the new name is absent from the map.
161
+ def classify_renamed_tracked(change, spec_files)
162
+ covered_old = map&.covered?(change[:old_path])
163
+ spec_files.merge(map.specs_for(change[:old_path])) if covered_old
164
+
165
+ if map&.covered?(change[:path])
166
+ spec_files.merge(map.specs_for(change[:path]))
167
+ nil
168
+ elsif covered_old
169
+ nil
170
+ else
171
+ "uncovered file changed: #{change[:path]}"
172
+ end
173
+ end
174
+
175
+ def pull_covered_specs_or_fallback(path, spec_files)
176
+ if map&.covered?(path)
177
+ spec_files.merge(map.specs_for(path))
178
+ nil
179
+ else
180
+ "uncovered file changed: #{path}"
181
+ end
182
+ end
183
+
184
+ def classify_other(change)
185
+ path = change[:path]
186
+ ext = File.extname(path)
187
+
188
+ return nil if IGNORABLE_EXTENSIONS.include?(ext)
189
+
190
+ "unknown file type changed: #{path}"
191
+ end
192
+
193
+ def apply_always_run(spec_files)
194
+ return if config.always_run.empty?
195
+
196
+ candidates = Set.new(spec_files)
197
+ candidates.merge(map.known_spec_files) if map
198
+
199
+ candidates.each do |spec_path|
200
+ next unless PathMatcher.any_match?(config.always_run, spec_path)
201
+ next unless File.exist?(Paths.absolute(spec_path))
202
+
203
+ spec_files << spec_path
204
+ end
205
+ end
206
+
207
+ def global_file?(path)
208
+ PathMatcher.any_match?(config.global_files, path)
209
+ end
210
+
211
+ def ignored_file?(path)
212
+ PathMatcher.any_match?(config.ignore, path)
213
+ end
214
+
215
+ def spec_file?(path)
216
+ path.end_with?('_spec.rb') && path.start_with?('spec/')
217
+ end
218
+
219
+ # A file that can appear as a key in the coverage map.
220
+ def tracked_file?(path)
221
+ TRACKED_EXTENSIONS.include?(File.extname(path))
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+ require 'socket'
6
+
7
+ require 'test_impact/paths'
8
+ require 'test_impact/path_matcher'
9
+ require 'test_impact/git'
10
+ require 'test_impact/map'
11
+ require 'test_impact/map_serializer'
12
+
13
+ module TestImpact
14
+ # Drives the coverage backend around each example and writes this process's
15
+ # results to a part-*.json.gz file for `test-impact merge` to combine.
16
+ class Recorder
17
+ DEFAULT_PART_DIR = 'tmp/test_impact'
18
+
19
+ attr_reader :backend, :config, :index, :known_spec_files
20
+
21
+ def initialize(backend:, config:)
22
+ @backend = backend
23
+ @config = config
24
+ @index = {}
25
+ @known_spec_files = Set.new
26
+ @written = false
27
+ @ignored_paths = Array(config.collector['ignored_paths'])
28
+ end
29
+
30
+ def start_example
31
+ backend.start
32
+ end
33
+
34
+ def finish_example(spec_abs_path)
35
+ covered = backend.stop || {}
36
+ spec_rel = spec_abs_path && Paths.relative(spec_abs_path)
37
+ return unless inside_repo?(spec_rel)
38
+
39
+ covered.each_key { |abs| record_covered(abs, spec_rel) }
40
+ end
41
+
42
+ def record_known_spec_files(paths)
43
+ Array(paths).each do |path|
44
+ rel = Paths.relative(path)
45
+ next unless inside_repo?(rel)
46
+
47
+ @known_spec_files << rel
48
+ end
49
+ end
50
+
51
+ def write_part(dir = ENV.fetch('TEST_IMPACT_PART_DIR', DEFAULT_PART_DIR))
52
+ return if @written
53
+
54
+ FileUtils.mkdir_p(dir)
55
+
56
+ # commit_sha is a hard requirement for the plan side: Planner treats a
57
+ # map whose commit is no longer reachable from the base ref as stale
58
+ # (force-push detection). branch is informational (info command).
59
+ git = Git.new
60
+ map = Map.build(
61
+ commit_sha: git.head_sha,
62
+ branch: git.head_branch,
63
+ collector: collector_metadata,
64
+ known_spec_files: @known_spec_files,
65
+ index: @index
66
+ )
67
+
68
+ path = File.join(dir, part_filename)
69
+ MapSerializer.dump(map, path)
70
+ # Only flip after a successful write so the at_exit fallback can retry
71
+ # when the after(:suite) attempt fails midway.
72
+ @written = true
73
+ path
74
+ end
75
+
76
+ private
77
+
78
+ def record_covered(abs, spec_rel)
79
+ rel = Paths.relative(abs)
80
+ return unless inside_repo?(rel)
81
+ return if spec_file?(rel, spec_rel)
82
+ return if ignored?(rel)
83
+
84
+ (@index[rel] ||= Set.new) << spec_rel
85
+ end
86
+
87
+ def collector_metadata
88
+ {
89
+ 'backend' => backend.name,
90
+ 'allocation_tracing' => config.collector['allocation_tracing'] ? true : false,
91
+ }
92
+ end
93
+
94
+ def part_filename
95
+ "part-#{Process.pid}-#{Socket.gethostname}-#{SecureRandom.hex(4)}.json.gz"
96
+ end
97
+
98
+ def inside_repo?(rel)
99
+ !rel.nil? && !rel.start_with?('..')
100
+ end
101
+
102
+ def ignored?(rel)
103
+ @ignored_paths.any? { |pattern| PathMatcher.prefix_or_glob_match?(pattern, rel) }
104
+ end
105
+
106
+ # spec/ files are intentionally never indexed as sources; Planner relies on
107
+ # this contract (changed specs run themselves, other spec/ files fall back).
108
+ def spec_file?(rel, spec_rel)
109
+ rel == spec_rel || rel.start_with?('spec/')
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestImpact
4
+ VERSION = '0.2.0'
5
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_impact/version'
4
+ require 'test_impact/paths'
5
+ require 'test_impact/path_matcher'
6
+ require 'test_impact/config'
7
+ require 'test_impact/map'
8
+ require 'test_impact/map_serializer'
9
+
10
+ # Test Impact Analysis for Ruby: records which source files each spec touches,
11
+ # then plans a minimal spec set from a git diff.
12
+ module TestImpact
13
+ class Error < StandardError; end
14
+ class SchemaVersionError < Error; end
15
+ class MapFormatError < Error; end
16
+ class CoverageUnavailableError < Error; end
17
+
18
+ class << self
19
+ attr_accessor :recorder
20
+ end
21
+ end
22
+
23
+ require 'test_impact/collector/coverage_backend'
24
+ require 'test_impact/recorder'
25
+ require 'test_impact/git'
26
+ require 'test_impact/plan_result'
27
+ require 'test_impact/planner'
28
+ require 'test_impact/cli'
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: impacted-specs
3
+ description: Use when you want to find which RSpec specs to run for locally changed code, including uncommitted changes. Refreshes the coverage map produced by the test_impact gem's GitHub Actions collect workflow, then runs `test-impact plan` to narrow the run down to the impacted specs. Triggers on requests like "run only the tests related to my changes", "which specs does this diff affect", "figure out the impacted specs", or "what should I run for the current changes".
4
+ license: MIT
5
+ ---
6
+
7
+ # impacted-specs
8
+
9
+ This skill runs inside a **project that consumes the test_impact gem**. It determines which specs
10
+ to run from local changes, including uncommitted ones.
11
+
12
+ Prerequisites:
13
+ - `gh` CLI is authenticated
14
+ - The consuming project has the `test_impact` gem in its `Gemfile`
15
+ - That project runs a collect workflow in GitHub Actions that uploads the coverage map as an
16
+ artifact named `test-impact-map`
17
+
18
+ ## Steps
19
+
20
+ ### 1. Refresh the base
21
+
22
+ Check `base` in `.test_impact.yml` (defaults to `origin/main`) and fetch the corresponding remote
23
+ branch:
24
+
25
+ ```sh
26
+ git fetch origin main
27
+ ```
28
+
29
+ Why: if the local `origin/main` is stale, the map's `commit_sha` fails the history reachability
30
+ check and the plan degrades to a full run (`all`).
31
+
32
+ ### 2. Download the map (every time, auto-discovered)
33
+
34
+ Do not judge freshness even if a local map exists — **always download the latest artifact and
35
+ overwrite**. Do not depend on a specific workflow name:
36
+
37
+ ```sh
38
+ run_id=$(gh api 'repos/{owner}/{repo}/actions/artifacts?name=test-impact-map&per_page=1' \
39
+ --jq '.artifacts[] | select(.expired == false) | .workflow_run.id' | head -1)
40
+ rm -f .test_impact/map.json.gz
41
+ gh run download "$run_id" -n test-impact-map -D .test_impact
42
+ ```
43
+
44
+ Notes:
45
+ - Test `expired` with an explicit `== false` comparison (`//` treats `false` as falsy and falls
46
+ back to the right-hand side, so an `enabled // true` style check misjudges it).
47
+ - `gh run download` fails when a file of the same name already exists in the destination, so
48
+ remove it first with `rm -f`.
49
+ - If `run_id` comes back empty (no artifact found), report to the user that the collect workflow
50
+ is not set up and stop here.
51
+
52
+ ### 3. Run plan
53
+
54
+ ```sh
55
+ bundle exec test-impact plan --format json --include-uncommitted > /tmp/plan.json
56
+ ```
57
+
58
+ `--format json` always exits 0. stdout is a single line of JSON:
59
+ `{"mode":"all|partial|none","spec_files":[...],"reason":null|"..."}`.
60
+ Diagnostics (mode / reason / spec_files count, etc.) go to stderr.
61
+
62
+ ### 4. Interpret the result
63
+
64
+ - `mode: "partial"` → run only the selected specs:
65
+ ```sh
66
+ bundle exec rspec $(jq -r '.spec_files[]' /tmp/plan.json)
67
+ ```
68
+ - `mode: "none"` → report to the user that there are no specs to run.
69
+ - `mode: "all"` → **do not run the full suite on your own.** Report that a full run is required
70
+ along with `reason`, and ask the user how to proceed. Common `reason` values:
71
+ - `uncovered file changed: <path>` — a new or uncollected file was changed
72
+ - `no map available or backend invalid` — the map is missing or unusable
73
+
74
+ ## Caveats
75
+
76
+ - If `.test_impact/` is not in the consuming project's `.gitignore`, the map itself is treated as
77
+ an untracked new file and triggers a full run.
78
+ - In a shallow clone, merge-base computation fails and the plan degrades to a full run.
79
+ - `--include-uncommitted` includes staged / unstaged / untracked changes in the diff, but never
80
+ modifies repository state (read-only).
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/test_impact/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'test_impact'
7
+ spec.version = TestImpact::VERSION
8
+ spec.authors = ['aki77']
9
+
10
+ spec.summary = 'Test Impact Analysis for Ruby without Datadog backend'
11
+ spec.description = "Collects per-test coverage via datadog-ci's native extension " \
12
+ 'and selects impacted specs from git diff.'
13
+ spec.homepage = 'https://github.com/aki77/test_impact_analysis'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.3'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = spec.homepage
19
+ spec.metadata['rubygems_mfa_required'] = 'true'
20
+
21
+ spec.files =
22
+ Dir.chdir(__dir__) do
23
+ # The `-ja` skill variants are for repo contributors only; the gem ships the English ones.
24
+ `git ls-files -z -- lib exe skills README.md LICENSE.txt test_impact.gemspec`
25
+ .split("\x0")
26
+ .grep_v(%r{\Askills/.*-ja\.md\z})
27
+ end
28
+ spec.bindir = 'exe'
29
+ spec.executables = ['test-impact']
30
+ spec.require_paths = ['lib']
31
+
32
+ spec.add_dependency 'datadog-ci', '>= 1.20', '< 2.0'
33
+ spec.add_dependency 'thor', '~> 1.3'
34
+ end