kimera 0.1.5 → 0.1.6

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +59 -1
  4. data/lib/kimera/cli/baseline/entry.rb +56 -0
  5. data/lib/kimera/cli/baseline/ledger.rb +32 -0
  6. data/lib/kimera/cli/baseline/prune.rb +68 -0
  7. data/lib/kimera/cli/baseline/review.rb +56 -0
  8. data/lib/kimera/cli/baseline.rb +49 -9
  9. data/lib/kimera/cli/help_text.rb +2 -2
  10. data/lib/kimera/cli/mutant.rb +17 -11
  11. data/lib/kimera/cli/report_file.rb +4 -0
  12. data/lib/kimera/cli/run/arguments.rb +7 -3
  13. data/lib/kimera/cli/run/cycle.rb +14 -9
  14. data/lib/kimera/cli/run/digest.rb +10 -2
  15. data/lib/kimera/cli/run/emission.rb +5 -4
  16. data/lib/kimera/cli/run/narrowing.rb +48 -0
  17. data/lib/kimera/cli/run/options.rb +8 -2
  18. data/lib/kimera/cli/run.rb +15 -1
  19. data/lib/kimera/cli/survivors/panel.rb +2 -2
  20. data/lib/kimera/cli/survivors.rb +2 -2
  21. data/lib/kimera/cli/test_command.rb +22 -3
  22. data/lib/kimera/execution/isolated_child.rb +5 -0
  23. data/lib/kimera/execution/isolated_child_minitest.rb +2 -1
  24. data/lib/kimera/execution/parallel_test_databases.rb +16 -4
  25. data/lib/kimera/execution/shift.rb +4 -2
  26. data/lib/kimera/execution/worker_pool/worker.rb +2 -2
  27. data/lib/kimera/execution/worker_pool.rb +1 -1
  28. data/lib/kimera/frameworks/minitest_adapter.rb +2 -1
  29. data/lib/kimera/frameworks/rspec_adapter.rb +4 -1
  30. data/lib/kimera/incremental/session.rb +5 -1
  31. data/lib/kimera/registry/mutant_keys.rb +45 -0
  32. data/lib/kimera/registry/registry.rb +5 -0
  33. data/lib/kimera/report/actions.rb +5 -1
  34. data/lib/kimera/report/sections.rb +15 -0
  35. data/lib/kimera/report/text.rb +1 -0
  36. data/lib/kimera/results/result.rb +17 -2
  37. data/lib/kimera/results/run_report.rb +3 -2
  38. data/lib/kimera/scope/config.rb +6 -1
  39. data/lib/kimera/scope/ignore_drift.rb +13 -0
  40. data/lib/kimera/scope/ignore_list.rb +21 -14
  41. data/lib/kimera/scope/ignore_placement.rb +30 -0
  42. data/lib/kimera/self_protection.rb +1 -0
  43. data/lib/kimera/support/test_exit.rb +40 -0
  44. data/lib/kimera/support/version.rb +1 -1
  45. data/skills/kimera/SKILL.md +66 -4
  46. metadata +10 -1
@@ -15,7 +15,7 @@ module Kimera
15
15
  MutantResult =
16
16
  Struct.new(
17
17
  :mutant_id, :status, :file, :duration, :failing_tests, :covering_tests,
18
- :detail,
18
+ :detail, :verdict,
19
19
  keyword_init: true
20
20
  ) do
21
21
  def duration
@@ -26,10 +26,22 @@ module Kimera
26
26
  Status::KILLING.include?(status)
27
27
  end
28
28
 
29
+ def lapsed?
30
+ Status::KILLING.include?(verdict)
31
+ end
32
+
29
33
  def isolated(detail)
30
34
  self.class.new(mutant_id: mutant_id, status: :isolated_only, file: file, duration: duration, detail: detail)
31
35
  end
32
36
 
37
+ def waive
38
+ self.class.new(**self.class.outcome(outcome), **waiver, mutant_id: mutant_id, file: file)
39
+ end
40
+
41
+ def waiver
42
+ { status: :ignored, verdict: status, detail: ["ignored (#{status})", detail].compact.join(": ") }
43
+ end
44
+
33
45
  def covered?
34
46
  !Status::UNJUDGED.include?(status)
35
47
  end
@@ -44,9 +56,11 @@ module Kimera
44
56
  end
45
57
 
46
58
  def identity
47
- { "mutant_id" => mutant_id, "status" => status.to_s, "file" => file }
59
+ { "mutant_id" => mutant_id, "status" => status.to_s, "file" => file }.merge(judged)
48
60
  end
49
61
 
62
+ def judged = verdict ? { "verdict" => verdict.to_s } : {}
63
+
50
64
  def outcome
51
65
  { "duration" => duration, "failing_tests" => failing_tests }
52
66
  .merge("covering_tests" => covering_tests, "detail" => detail)
@@ -62,6 +76,7 @@ module Kimera
62
76
 
63
77
  def identity(hash)
64
78
  { mutant_id: hash["mutant_id"], status: hash["status"].to_sym, file: hash["file"] }
79
+ .merge(verdict: hash["verdict"]&.to_sym)
65
80
  end
66
81
 
67
82
  def outcome(hash)
@@ -95,8 +95,9 @@ class Kimera::RunReport
95
95
 
96
96
  def detailed(result)
97
97
  base = result.to_h
98
- pair = @registry&.point(result.mutant_id)
99
- pair ? base.merge(rewritten(pair)) : base
98
+ id = result.mutant_id
99
+ pair = @registry&.point(id)
100
+ pair ? base.merge("key" => @registry.keys[id]).merge(rewritten(pair)) : base
100
101
  end
101
102
 
102
103
  def rewritten(pair)
@@ -40,7 +40,12 @@ module Kimera
40
40
  return {} unless File.exist?(file)
41
41
  options = normalize(YAML.safe_load_file(file) || {})
42
42
  baseline = options[:baseline]
43
- baseline ? options.merge(ignore: Array(options[:ignore]) + ignores_from(baseline, file)) : options
43
+ baseline ? options.merge(baseline_ignore: ignores_from(baseline, file)) : options
44
+ end
45
+
46
+ def ignores(options)
47
+ local = Array(options[:ignore])
48
+ options[:baseline] ? local + Array(options[:baseline_ignore]) : local
44
49
  end
45
50
 
46
51
  def root(root: ".")
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kimera::IgnoreList::Drift
4
+ private
5
+
6
+ def hits = exact.empty? ? drifted : exact
7
+
8
+ def exact = @_exact ||= matching(anchors)
9
+
10
+ def drifted = @_drifted ||= anchors[:line] && anchors[:label] ? single(matching(anchors.except(:line))) : []
11
+
12
+ def single(found) = found.one? ? found : []
13
+ end
@@ -24,26 +24,31 @@ module Kimera
24
24
  end
25
25
  end
26
26
 
27
+ Resolution = Data.define(:ids, :stale, :moved)
28
+
29
+ Shift =
30
+ Data.define(:rule, :line) do
31
+ def to_s = "#{rule[:file]}:#{rule[:line]} → #{line} [#{rule[:label]}]"
32
+ end
33
+
27
34
  module_function
28
35
 
29
- def ids(registry, rules)
30
- rules = Array(rules)
31
- registry.each.filter_map { |mutant, point| mutant.id if rules.any? { |rule| match?(rule, point, mutant) } }
36
+ def resolve(registry, rules)
37
+ candidates = Hash.new { |cache, glob| cache[glob] = pairs(registry, glob) }
38
+ placements = Array(rules).map { |rule| Placement.new(rule, candidates[rule[:file]]) }
39
+ Resolution.new(
40
+ ids: placements.flat_map(&:ids).uniq.sort,
41
+ stale: placements.select(&:stale?).map(&:rule), moved: placements.filter_map(&:moved)
42
+ )
32
43
  end
33
44
 
34
- def stale(registry, rules)
35
- Array(rules).select do |rule|
36
- relevant?(rule, registry) && !any?(rule, registry)
37
- end
38
- end
45
+ def ids(registry, rules) = resolve(registry, rules).ids
39
46
 
40
- def relevant?(rule, registry)
41
- glob = rule[:file]
42
- glob && registry.files.any? { |file| File.fnmatch?(glob, file, File::FNM_PATHNAME) }
43
- end
47
+ def stale(registry, rules) = resolve(registry, rules).stale
44
48
 
45
- def any?(rule, registry)
46
- registry.each.any? { |mutant, point| match?(rule, point, mutant) }
49
+ def pairs(registry, glob)
50
+ files = glob ? registry.files.select { |file| File.fnmatch?(glob, file, File::FNM_PATHNAME) } : []
51
+ files.flat_map { |file| registry.at(file).flat_map { |point| point.mutants.map { |mutant| [mutant, point] } } }
47
52
  end
48
53
 
49
54
  ANCHORS = { line: :line?, column: :column?, label: :label? }.merge(method: :method?, original: :original?).freeze
@@ -53,3 +58,5 @@ module Kimera
53
58
  end
54
59
  end
55
60
  end
61
+
62
+ require_relative "ignore_placement"
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ignore_drift"
4
+
5
+ class Kimera::IgnoreList::Placement
6
+ include Kimera::IgnoreList::Drift
7
+
8
+ attr_reader :rule
9
+
10
+ def initialize(rule, pairs)
11
+ @rule = rule
12
+ @pairs = pairs
13
+ end
14
+
15
+ def ids = hits.map { |mutant, _point| mutant.id }
16
+
17
+ def stale? = hits.empty? && !@pairs.empty?
18
+
19
+ def moved
20
+ return unless exact.empty?
21
+ _mutant, point = drifted.first
22
+ Kimera::IgnoreList::Shift.new(@rule, point.location.start_line) if point
23
+ end
24
+
25
+ private
26
+
27
+ def anchors = @rule
28
+
29
+ def matching(rule) = @pairs.select { |mutant, point| Kimera::IgnoreList.match?(rule, point, mutant) }
30
+ end
@@ -20,6 +20,7 @@ module Kimera
20
20
  kimera/frameworks/rspec_adapter.rb
21
21
  kimera/frameworks/rspec_group_index.rb
22
22
  kimera/frameworks/minitest_adapter.rb
23
+ kimera/support/test_exit.rb
23
24
  kimera/execution/shift.rb
24
25
  kimera/execution/shift/attempt.rb
25
26
  kimera/execution/shift/deadline.rb
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kimera
4
+ end
5
+
6
+ module Kimera::TestExit
7
+ module_function
8
+
9
+ def failure(error)
10
+ SystemExit.new(error.status, describe(error)).tap { |failure| failure.set_backtrace(error.backtrace) }
11
+ end
12
+
13
+ def describe(error)
14
+ text = "exit(#{error.status}) called from #{origin(error)}"
15
+ message = error.message
16
+ message == "exit" ? text : "#{text}: #{message}"
17
+ end
18
+
19
+ def origin(error) = error.backtrace.first.delete_prefix("#{Dir.pwd}#{File::SEPARATOR}")
20
+
21
+ module Example
22
+ private
23
+
24
+ def with_around_example_hooks
25
+ super
26
+ rescue SystemExit => error
27
+ set_exception(Kimera::TestExit.failure(error))
28
+ end
29
+ end
30
+
31
+ module Test
32
+ def capture_exceptions
33
+ super do
34
+ yield
35
+ rescue SystemExit => error
36
+ failures << ::Minitest::UnexpectedError.new(Kimera::TestExit.failure(error))
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kimera
4
- VERSION = "0.1.5"
4
+ VERSION = "0.1.6"
5
5
  end
@@ -44,7 +44,7 @@ bundle exec kimera doctor --check-baseline # discovery, git, and a green suite
44
44
  bundle exec kimera changed # changed lines only (vs origin/main)
45
45
  bundle exec kimera run # full, per .kimera.yml
46
46
  bundle exec kimera report REPORT.json --status survived
47
- bundle exec kimera mutant ID --report REPORT.json
47
+ bundle exec kimera mutant ID_OR_KEY --report REPORT.json [--rerun]
48
48
  bundle exec kimera run --isolated --jobs 4 # oracle mode (see Strengthen)
49
49
  ```
50
50
 
@@ -52,12 +52,19 @@ bundle exec kimera run --isolated --jobs 4 # oracle mode (see Strengthen)
52
52
  report, except isolated mode, which traces one verdict per line to stderr.
53
53
  - `--session FILE` persists per-mutant verdicts and resumes interrupted runs.
54
54
  - `--report FILE` writes the machine-readable report. Each result carries
55
- `mutant_id`, `status`, `file`, `line`, `operator`, and the `original` ->
56
- `mutated` source; don't re-parse the human log.
55
+ `mutant_id`, `key`, `status`, `file`, `line`, `operator`, and the
56
+ `original` -> `mutated` source; don't re-parse the human log.
57
+ - Refer to a mutant by its `key` (`path:line:digest`), not its `mutant_id`.
58
+ The ID numbers every mutant in that run, so it changes with the set of
59
+ files scanned; the key doesn't. `kimera mutant` and `--focus` take either,
60
+ and a key still resolves after unrelated edits move its line.
57
61
  - Minitest/Rails: kimera puts `test/` (or `spec/`) on `$LOAD_PATH`, so test
58
62
  files can `require "test_helper"` without `RUBYOPT="-Itest"`.
59
63
  - `--tests` on the CLI *replaces* the config `tests:` glob (it does not
60
- append).
64
+ append). A narrowed run's verdicts hold only for the narrowed set: a
65
+ survivor may be killed by a test left out, so confirm it with the full glob
66
+ before writing a test or an ignore entry. The report flags such a run
67
+ (`narrowed run: ...`, `"narrowed": true` in the JSON `run` section).
61
68
  - `--jobs` sizes the warm pool and isolated mirrors. Coverage-based test
62
69
  selection and kill-on-first-failure are automatic.
63
70
  - A `timeout` verdict is a *detected* mutant (the suite hung on it), not an
@@ -69,6 +76,9 @@ bundle exec kimera run --isolated --jobs 4 # oracle mode (see Strengthen)
69
76
  raise `--hard-timeout`. If it waits on something the workers share (a Redis
70
77
  db, a lock, a port), give each worker its own through a
71
78
  `parallelize_setup`/`after_fork_hook`.
79
+ - Code under test that calls `exit` or `abort` fails that test (`detail`:
80
+ `SystemExit: exit(1) called from FILE:LINE`), so a mutant that makes a test
81
+ abort is `killed`, not `harness_error`.
72
82
  - A kill's `detail` holds the killing test's failure message and first
73
83
  frames. A state-leak warning names a test that failed in a warm worker for
74
84
  reasons unrelated to the mutant. Kimera already judged that mutant again on
@@ -87,6 +97,25 @@ bundle exec kimera run --isolated --jobs 4 # oracle mode (see Strengthen)
87
97
  an `--isolated` case. Its mutants report as `unmutatable` (never gating,
88
98
  never scored, with the reason in the detail), not `no_coverage`.
89
99
 
100
+ ## Burn down a baseline
101
+
102
+ ```sh
103
+ bundle exec kimera run --evaluate-ignored --report r.json # judge ignored mutants too
104
+ bundle exec kimera baseline review .kimera-baseline.yml --report r.json
105
+ bundle exec kimera baseline prune .kimera-baseline.yml --report r.json # --dry-run first
106
+ ```
107
+
108
+ - `--evaluate-ignored` keeps ignored mutants `ignored` (never a gating
109
+ survivor, still counted by `max_ignored`) but records each one's `verdict`.
110
+ Don't copy `.kimera.yml` to drop the `baseline:` line; `--no-baseline`
111
+ does that.
112
+ - `review` sorts entries into killed (prune), still surviving (triage them
113
+ like any survivor), unjudged, stale, and out of scope. `prune` drops killed
114
+ and stale entries, moves re-anchored ones to their current line, and says
115
+ how far to lower `max_ignored`: lower it in the same diff.
116
+ - A drifted line anchor re-anchors on its own when the label (and `original`)
117
+ single out one mutant; the `re-anchored` warning means update the entry.
118
+
90
119
  ## Triage a surviving mutant: the only four verdicts
91
120
 
92
121
  1. **Real gap**: write the test. Assert the exact distinction the mutant
@@ -102,6 +131,39 @@ bundle exec kimera run --isolated --jobs 4 # oracle mode (see Strengthen)
102
131
  or accept it visibly. Never stub internals just to kill a mutant; a test
103
132
  that pins the implementation makes the suite worse.
104
133
 
134
+ ### Survivors that recur on Rails apps
135
+
136
+ These shapes survive again and again. Recognizing one tells you which verdict
137
+ to test first. It never replaces the `--isolated` check.
138
+
139
+ Usually **dead code**. The guard only saves a query, so delete it:
140
+
141
+ - A guard before a query that already returns nil/false/0: `return false
142
+ unless user` before `memberships.exists?(user:)` on a NOT NULL column, or
143
+ `return if ids.empty?` before `where(id: ids)` / `update_all`.
144
+ - A no-op write under dirty tracking: `update!(status: :past_due) unless
145
+ past_due?`. Rails skips the UPDATE when nothing changed. Delete the guard
146
+ unless callbacks or `updated_at` must not fire, and if they must, test
147
+ that.
148
+ - A readiness check that compares constants with constants
149
+ (`REQUIRED - LIVE_STATES`). Nothing at runtime can change it, so assert it
150
+ once at load time or drop it.
151
+
152
+ Usually **keep and ignore** (after `--isolated`, with the mechanism as the
153
+ `reason:`), because the code hardens against something tests don't drive:
154
+
155
+ - `reload` before `with_lock`/`lock!` on the same record, or double-checked
156
+ locking. `with_lock` reloads the row anyway, and only a real race tells
157
+ the two apart.
158
+ - `transaction(requires_new: true)` around a `create!` that rescues
159
+ `RecordNotUnique`. It matters only when a concurrent insert wins.
160
+ - Barrier or queue values that nothing reads (`ready << true`), and
161
+ timing-safe compares.
162
+
163
+ Before you delete one of these guards, check that it doesn't also skip a side
164
+ effect (a callback, a job, an audit row). If it does, the survivor is a real
165
+ gap: test that side effect.
166
+
105
167
  ## Ignore discipline: hard rules (especially for agents)
106
168
 
107
169
  - **Raise `max_ignored` only for an entry that has earned it**: survival
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kimera
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Giacomo GK
@@ -72,6 +72,10 @@ files:
72
72
  - lib/kimera/cli.rb
73
73
  - lib/kimera/cli/argv.rb
74
74
  - lib/kimera/cli/baseline.rb
75
+ - lib/kimera/cli/baseline/entry.rb
76
+ - lib/kimera/cli/baseline/ledger.rb
77
+ - lib/kimera/cli/baseline/prune.rb
78
+ - lib/kimera/cli/baseline/review.rb
75
79
  - lib/kimera/cli/changed.rb
76
80
  - lib/kimera/cli/ci.rb
77
81
  - lib/kimera/cli/completion.rb
@@ -87,6 +91,7 @@ files:
87
91
  - lib/kimera/cli/run/digest.rb
88
92
  - lib/kimera/cli/run/emission.rb
89
93
  - lib/kimera/cli/run/gate.rb
94
+ - lib/kimera/cli/run/narrowing.rb
90
95
  - lib/kimera/cli/run/options.rb
91
96
  - lib/kimera/cli/run/pass.rb
92
97
  - lib/kimera/cli/run/sources.rb
@@ -199,6 +204,7 @@ files:
199
204
  - lib/kimera/operators/vocabulary.rb
200
205
  - lib/kimera/plugins.rb
201
206
  - lib/kimera/registry/builder.rb
207
+ - lib/kimera/registry/mutant_keys.rb
202
208
  - lib/kimera/registry/mutation_point.rb
203
209
  - lib/kimera/registry/numbering.rb
204
210
  - lib/kimera/registry/registry.rb
@@ -226,13 +232,16 @@ files:
226
232
  - lib/kimera/runtime.rb
227
233
  - lib/kimera/scope/config.rb
228
234
  - lib/kimera/scope/file_set.rb
235
+ - lib/kimera/scope/ignore_drift.rb
229
236
  - lib/kimera/scope/ignore_list.rb
237
+ - lib/kimera/scope/ignore_placement.rb
230
238
  - lib/kimera/self_protection.rb
231
239
  - lib/kimera/support/duration.rb
232
240
  - lib/kimera/support/errors.rb
233
241
  - lib/kimera/support/operator_protocol.rb
234
242
  - lib/kimera/support/syntax.rb
235
243
  - lib/kimera/support/syntax_types.rb
244
+ - lib/kimera/support/test_exit.rb
236
245
  - lib/kimera/support/unparse.rb
237
246
  - lib/kimera/support/version.rb
238
247
  - lib/kimera/support/warnings.rb