hashira 0.7.0 → 0.9.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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +57 -3
  4. data/exe/hashira +1 -1
  5. data/lib/hashira/analysis/finding.rb +11 -1
  6. data/lib/hashira/analysis/syntax.rb +6 -6
  7. data/lib/hashira/analysis/type_walk.rb +4 -0
  8. data/lib/hashira/churn.rb +1 -10
  9. data/lib/hashira/ci/accepted.rb +9 -7
  10. data/lib/hashira/ci/baseline.rb +53 -33
  11. data/lib/hashira/ci/comparison.rb +33 -0
  12. data/lib/hashira/ci/diff.rb +0 -15
  13. data/lib/hashira/ci/gate.rb +3 -3
  14. data/lib/hashira/ci/mark.rb +7 -0
  15. data/lib/hashira/ci/ratchet.rb +12 -4
  16. data/lib/hashira/ci/ratchet_report.rb +4 -4
  17. data/lib/hashira/ci/scope.rb +0 -1
  18. data/lib/hashira/ci/slice.rb +21 -0
  19. data/lib/hashira/ci/sweep.rb +13 -0
  20. data/lib/hashira/cli/arguments.rb +12 -8
  21. data/lib/hashira/cli/choice.rb +9 -0
  22. data/lib/hashira/cli/command_line.rb +8 -5
  23. data/lib/hashira/cli/fail_on.rb +6 -6
  24. data/lib/hashira/cli/flag.rb +3 -1
  25. data/lib/hashira/cli/flags.rb +10 -0
  26. data/lib/hashira/cli/format.rb +1 -1
  27. data/lib/hashira/cli/needs.rb +13 -2
  28. data/lib/hashira/cli/only.rb +16 -0
  29. data/lib/hashira/cli/options.rb +8 -10
  30. data/lib/hashira/cli/package_by.rb +1 -1
  31. data/lib/hashira/cli/run.rb +9 -8
  32. data/lib/hashira/cli.rb +22 -21
  33. data/lib/hashira/complexity/cognitive_score.rb +44 -33
  34. data/lib/hashira/complexity/method_finding.rb +7 -5
  35. data/lib/hashira/complexity/method_score.rb +1 -1
  36. data/lib/hashira/complexity/scores.rb +11 -9
  37. data/lib/hashira/coupling/audiences.rb +3 -3
  38. data/lib/hashira/coupling/catalog.rb +8 -6
  39. data/lib/hashira/coupling/census.rb +42 -22
  40. data/lib/hashira/coupling/cycle_search.rb +11 -7
  41. data/lib/hashira/coupling/definitions.rb +1 -7
  42. data/lib/hashira/coupling/edge_map.rb +9 -8
  43. data/lib/hashira/coupling/folding.rb +5 -5
  44. data/lib/hashira/coupling/graph.rb +16 -9
  45. data/lib/hashira/coupling/metric.rb +3 -1
  46. data/lib/hashira/coupling/naming.rb +4 -4
  47. data/lib/hashira/coupling/references.rb +12 -12
  48. data/lib/hashira/coupling/report.rb +7 -9
  49. data/lib/hashira/coupling/roll_call.rb +1 -1
  50. data/lib/hashira/coupling/roll_call_findings.rb +1 -1
  51. data/lib/hashira/coupling/roster.rb +37 -12
  52. data/lib/hashira/coupling/rule.rb +1 -1
  53. data/lib/hashira/coupling/sdp_violation_findings.rb +3 -1
  54. data/lib/hashira/duplication/clones.rb +1 -1
  55. data/lib/hashira/duplication/clusters.rb +10 -7
  56. data/lib/hashira/duplication/delta.rb +1 -4
  57. data/lib/hashira/duplication/duplication_finding.rb +4 -2
  58. data/lib/hashira/duplication/fragment.rb +20 -2
  59. data/lib/hashira/duplication/harvest.rb +7 -5
  60. data/lib/hashira/duplication/index.rb +3 -2
  61. data/lib/hashira/duplication/literal.rb +25 -0
  62. data/lib/hashira/duplication/similarity.rb +2 -2
  63. data/lib/hashira/duplication/variance.rb +7 -1
  64. data/lib/hashira/error.rb +0 -3
  65. data/lib/hashira/focus.rb +29 -0
  66. data/lib/hashira/git_log.rb +19 -0
  67. data/lib/hashira/hotspots/file_cost.rb +1 -1
  68. data/lib/hashira/hotspots/rollup.rb +2 -2
  69. data/lib/hashira/pipeline.rb +22 -14
  70. data/lib/hashira/project.rb +35 -34
  71. data/lib/hashira/report/columns.rb +14 -8
  72. data/lib/hashira/report/graph_payload.rb +1 -1
  73. data/lib/hashira/report/hotspot_table.rb +1 -1
  74. data/lib/hashira/report/json.rb +2 -1
  75. data/lib/hashira/report/metrics_table.rb +3 -3
  76. data/lib/hashira/report/phrases.rb +23 -18
  77. data/lib/hashira/report/smell_phrases.rb +2 -2
  78. data/lib/hashira/smells/branches.rb +62 -0
  79. data/lib/hashira/smells/census.rb +11 -6
  80. data/lib/hashira/smells/check.rb +1 -5
  81. data/lib/hashira/smells/contexts.rb +7 -5
  82. data/lib/hashira/smells/control_parameter.rb +1 -1
  83. data/lib/hashira/smells/data_clump.rb +2 -4
  84. data/lib/hashira/smells/duplicate_method_call.rb +25 -5
  85. data/lib/hashira/smells/feature_envy.rb +3 -3
  86. data/lib/hashira/smells/foreign.rb +14 -14
  87. data/lib/hashira/smells/instance_variable_assumption.rb +5 -15
  88. data/lib/hashira/smells/kind.rb +9 -0
  89. data/lib/hashira/smells/lineage.rb +92 -0
  90. data/lib/hashira/smells/manual_dispatch.rb +1 -1
  91. data/lib/hashira/smells/module_initialize.rb +0 -2
  92. data/lib/hashira/smells/nil_check.rb +1 -1
  93. data/lib/hashira/smells/ownership.rb +19 -7
  94. data/lib/hashira/smells/param_check.rb +2 -2
  95. data/lib/hashira/smells/refs.rb +13 -7
  96. data/lib/hashira/smells/repeated_conditional.rb +1 -3
  97. data/lib/hashira/smells/report.rb +16 -7
  98. data/lib/hashira/smells/too_many_instance_variables.rb +3 -3
  99. data/lib/hashira/smells/visibility.rb +18 -11
  100. data/lib/hashira/trees.rb +8 -4
  101. data/lib/hashira/version.rb +1 -1
  102. data/lib/hashira.rb +12 -0
  103. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53480df33daebb25ecac87a601cad36cc6a3d8657a5ccb3cd094c807e1be2c15
4
- data.tar.gz: 6034a5afdfa48254a48b989405aed85e8e1d6c4f82dea35649d714edd8ed38ee
3
+ metadata.gz: 2750af6be7a5e6885421144e92ab4c12e554ece76c110b76d5fd2b52b72a2411
4
+ data.tar.gz: b2ab7386f4907e297b3cfc623b535808df4f6865423bd9d4f8bae4b487b81317
5
5
  SHA512:
6
- metadata.gz: 915f8ea2649037c29a4971d4ebc3df94d38c6a976fd4f0792e3e2c80e3c54d23b776a3a511c0dd5fa7552cbbd6463282e9fcd31ba5e510c2a87d82aafbfb6310
7
- data.tar.gz: 9b17e6bbc1625f7ed54d02eeb7ba8f345451c931e24cf39bd057bc8a06e92d11167d35a0bd1b883b75750aa533fdb39a1513dcb4359929c86d2cb0e21e285cdd
6
+ metadata.gz: 6a4c401b5d242175899bcf4e1e6792f74b77139346b336564c2496e1b97dd716b5014f147b94fbc076b1870020893c8d99b8f08dadd0ef5e648fa427a6fb3a91
7
+ data.tar.gz: 812c604c2cafe644d8ab0692b670f74f1c63dde77a1084f235919c1eee72d1a24c53936c403f7e84c2a46c0145c4bd702c6f183e926f9d5f27135d54b366aca6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,100 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.0] - 2026-08-20
9
+
10
+ ### Fixed
11
+
12
+ - **A rename stops reading as churn in the ratchet.** A finding is keyed by what
13
+ it names, so renaming a class reported the twelve findings under it as
14
+ resolved and new at once, and adding `?` to four predicates did the same. The
15
+ baseline now records a `traces` map beside `findings` — for each finding, the
16
+ file it sits in and what its evidence says, with line numbers stripped — and
17
+ the ratchet pairs a disappeared key with an appeared one carrying the same
18
+ trace. The match is one for one, so a rename that brought a new finding along
19
+ still fails, and a renamed method that also got worse still reports WORSE.
20
+ Acceptance is untouched: `accepted` entries still name a finding by `package`
21
+ or `digest`. Baselines recorded before this have no traces and behave exactly
22
+ as they did; the next `--update-baseline` writes them (schema version 5, which
23
+ older hashira reads too).
24
+ - **A shared namespace stops passing for a shared name.** The duplication
25
+ near-miss guard raises the mass floor when two sites have no name in common —
26
+ but `Prism::CallNode` and `Prism::BlockParameterNode` counted `Prism` as
27
+ common, so two unrelated one-liners that both mention a Prism class slipped
28
+ under the low floor. A constant is now read by what it points at, not the
29
+ namespace it sits in.
30
+ - **A nested class is named the way Ruby resolves it.** `class Widget::Broken`
31
+ written inside `class Widget` was reported as `Widget::Widget::Broken`. The
32
+ smell census now resolves a compound constant path against the constants the
33
+ codebase actually declares, the way the coupling graph already did. Findings
34
+ on such classes change name, so a baseline recorded before this reports them
35
+ once as resolved-and-new; re-record it with `--update-baseline`.
36
+
37
+ ### Changed
38
+
39
+ - **`duplicate_method_call` stops flagging calls that are supposed to differ.**
40
+ `stdout = "".b` next to `stderr = "".b` is two buffers, and
41
+ `rand(1_000_000_000)` twice is two ids — naming either once writes a bug. The
42
+ check now excuses calls that mint a fresh value each time (`new`, `dup`,
43
+ `clone`, `allocate`, `rand`, anything from `SecureRandom` or `Random`, and
44
+ any call on a literal) and repeats that no single run can reach twice: the
45
+ two arms of an `if` or `unless`, two `when` or `in` branches, a body and its
46
+ `rescue`. Nothing can be hoisted across those, and a `raise` has no result to
47
+ name.
48
+ - **`instance_variable_assumption` asks whether anything assigns, not whether
49
+ `initialize` does.** Assignment through a mixin, a superclass, an
50
+ `attr_writer`, a reopening of the class, or a private method the constructor
51
+ calls all count now — every shape that made the old check report a class that
52
+ was perfectly fine. What survives is the ivar nothing the class can reach ever
53
+ sets: a typo, or state another object is expected to install. When a class
54
+ inherits or includes something the codebase can't see, the check stays quiet
55
+ rather than guess.
56
+ - **A run of declarative macros is a schema, not a clone.** Two models opening
57
+ with the same `has_many ..., dependent: :destroy` lines, or two serializers
58
+ with the same `typelize`/`attribute` pairs, were reported as duplication whose
59
+ only fix was to hide the schema behind a class method. A fragment built purely
60
+ from directives — receiverless calls with literal arguments — no longer
61
+ clusters. A block, a method, a variable, a receiver, or a branch anywhere in
62
+ the fragment makes it code again.
63
+
64
+ ## [0.8.0] - 2026-08-15
65
+
66
+ ### Added
67
+
68
+ - **`--only PATHS` narrows the findings to the files you name.** Meant for
69
+ hooks: after a formatter, a refactor, or an agent's edit, ask whether *these*
70
+ files got worse — `hashira --only "$CHANGED" --ratchet`. The whole project is
71
+ still parsed, because half of what hashira knows is cross-file (which
72
+ constants are yours, which methods reach into a neighbour, which fragments
73
+ are clones); reading one file alone would answer differently. A focused
74
+ ratchet stays quiet about package edges, which belong to no single file, and
75
+ about findings that disappeared, which only a whole-project run can confirm —
76
+ it reports what your files introduced or made worse. `--only` refuses
77
+ `--update-baseline` and the diagram formats, and ignores paths outside the
78
+ analyzed directories so a hook can hand it every changed file.
79
+
80
+ ### Changed
81
+
82
+ - **Memoization stops reading as state.** An instance variable named `@_thing`
83
+ is a cache, not a responsibility: `instance_variable_assumption` no longer
84
+ reports lazy presence as an assumption, and `too_many_instance_variables`
85
+ no longer counts derived values against the class. Codebases that memoize
86
+ behind the `@_` convention will see both smells quieten; codebases that
87
+ don't are unaffected.
88
+ - **Every object is built the way this tool says to build one.** Constructors
89
+ only assign, class-method logic dissolves into instances (`Project.detect`
90
+ and `CLI.run` are plain constructors — `exe/hashira` now calls
91
+ `CLI.new(argv).status`), factories are named `build`, and hashes acting as
92
+ objects got names (`SdpViolationFindings::Imbalance`,
93
+ `DuplicationFinding::Overlap`, `MethodFinding::Effort`). Internal
94
+ throughout: the command line, the reports, the JSON, and the baseline
95
+ format are unchanged. Only embedders calling the Ruby API directly are
96
+ affected.
97
+ - Coupling reads its rule list from `Rule.subclasses`, the way the smells
98
+ report already read `Check.subclasses`, and parameters stop carrying their
99
+ node type (`def_node` → `definition`). hashira's own baseline is down to
100
+ zero findings and one accepted boundary.
101
+
8
102
  ## [0.7.0] - 2026-08-10
9
103
 
10
104
  ### Added
data/README.md CHANGED
@@ -288,6 +288,12 @@ it does inside Ruby:
288
288
  - **Lists aren't clones.** A run of identically shaped statements — a require
289
289
  block, a routes file, a column of registrations — is skipped, so windows cut
290
290
  out of one don't report a match at every offset.
291
+ - **Declarations aren't clones either.** A fragment built only from directives —
292
+ receiverless macro calls with literal arguments, the `has_many` /
293
+ `validates` / `attribute` spine of a model or a serializer — is a schema, not
294
+ copied logic; extracting it only hides what the class declares. Two models
295
+ that open the same way are two models. As soon as a fragment carries logic —
296
+ a block, a method, a variable, a receiver, a branch — it counts again.
291
297
  - **Clusters, not pairs.** All copies of one thing collapse into a single
292
298
  finding with N sites, so the report reads as "fix this once," not a wall of
293
299
  pairwise matches.
@@ -343,13 +349,21 @@ What each one catches:
343
349
  - **data_clump** — the same two-plus parameters travel through three or more
344
350
  methods; a value object is missing.
345
351
  - **duplicate_method_call** — the identical receiver-and-arguments call repeated
346
- inside one method; name the result once.
352
+ inside one method; name the result once. Quiet wherever naming it would be
353
+ wrong: calls that mint a fresh value every time (`"".b`, `rand`, `dup`,
354
+ `SecureRandom.hex`) are meant to differ, and a repeat no single run can reach
355
+ twice — the two arms of an `if`, two `when` branches, a body and its `rescue`
356
+ — has nothing to hoist.
347
357
  - **repeated_conditional** — one class testing the same condition in three or
348
358
  more places; polymorphism is overdue.
349
359
  - **too_many_instance_variables** — more than four per class. Memoization
350
360
  (`@x ||=`) doesn't count as state.
351
- - **instance_variable_assumption** — an ivar read that no `initialize` ever
352
- assigns; the reader is assuming another method ran first.
361
+ - **instance_variable_assumption** — an ivar read that nothing the class can
362
+ reach ever assigns: not `initialize`, not another of its own methods, not an
363
+ `attr_writer`, not a reopening of the class, not a module it mixes in or a
364
+ class it inherits. Usually a typo, or state some other object is expected to
365
+ install. Silent when the class inherits or includes something the codebase
366
+ can't see, because the assignment may live in there.
353
367
  - **manual_dispatch** — `respond_to?` then send: a type check wearing a duck
354
368
  costume.
355
369
  - **module_initialize** — `initialize` in a mixin; construction order becomes
@@ -460,6 +474,17 @@ WORSE FINDING (was 13, now 24):
460
474
  Baselines written by earlier versions still work: they record identity only, so
461
475
  they ratchet on appearance until the next `--update-baseline` records magnitudes.
462
476
 
477
+ It also records a *trace* of each finding — the file it sits in and what it says,
478
+ with the line numbers left out. A finding is keyed by what it names
479
+ (`Class#method`), so renaming a class, or adding a `?` to four predicates, would
480
+ otherwise report every finding under it as resolved and new in the same breath.
481
+ When a key disappears and another appears carrying the same trace, the ratchet
482
+ reads them as one finding that changed name. The match is one for one: a rename
483
+ that *brought* a new finding with it still fails, and a renamed method that also
484
+ got more complex still reports WORSE. Moving the file changes the trace, because
485
+ that is a relocation rather than a rename — re-record it. Baselines without
486
+ traces behave exactly as they did before.
487
+
463
488
  A regression prints in full, with the evidence that introduced it:
464
489
 
465
490
  ```console
@@ -476,6 +501,35 @@ record the decision: update the baseline, or accept it with a reason.
476
501
  Improvements fail the build too, and say so cheerfully — an unrecorded gain is one
477
502
  the next commit can quietly undo. Re-run `--update-baseline` to lock it in.
478
503
 
504
+ ### Hooks: ratchet the files you just touched
505
+
506
+ `--only` narrows the findings to the files you name, so an editor hook, a
507
+ pre-commit hook, or an agent finishing an edit can ask the one question that
508
+ matters there: *did these files get worse?*
509
+
510
+ ```sh
511
+ hashira --only lib/billing/refund.rb,lib/orders/checkout.rb --ratchet
512
+ hashira --only "$(git diff --cached --name-only -- '*.rb' | paste -sd, -)" --ratchet
513
+ ```
514
+
515
+ The whole project is still parsed — that is the point. Half of what hashira knows
516
+ is cross-file (which constants are yours, which methods pick apart a neighbour's
517
+ internals, which fragments are clones of each other), so a file read alone would
518
+ answer differently. `--only` narrows the *report*, never the analysis. On this
519
+ repository a full run is under a second for 129 files; smells alone, a quarter of
520
+ that.
521
+
522
+ Two things a focused run cannot judge, and so stays quiet about: package edges,
523
+ which belong to no single file, and findings that *disappeared*, which you would
524
+ have to read the whole project to be sure of. It reports what your files
525
+ introduced or made worse, and nothing else. Run the unfocused `--ratchet` in CI —
526
+ that is where removals get celebrated and the baseline gets relocked.
527
+
528
+ `--only` refuses to combine with `--update-baseline` (which would record a
529
+ baseline missing everything you did not name) or with the diagram formats (which
530
+ draw the graph, not the findings). Paths outside the analyzed directories are
531
+ ignored, so a hook can hand it every changed file without filtering first.
532
+
479
533
  ### Exit codes
480
534
 
481
535
  A build step should be able to tell a regression from a typo without grepping
data/exe/hashira CHANGED
@@ -6,4 +6,4 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
6
 
7
7
  require "hashira"
8
8
 
9
- exit Hashira::CLI.run(ARGV)
9
+ exit Hashira::CLI.new(ARGV).status
@@ -4,6 +4,8 @@ module Hashira
4
4
  module Analysis
5
5
  MAGNITUDES = { "complexity" => :cognitive, "duplication" => :mass, "boundary_sprawl" => :count }.freeze
6
6
 
7
+ TRACKS = [/ \(lines? [\d, ]+\)/, /:[\d, -]+\z/, /:\d+(?=:)/].freeze
8
+
7
9
  Finding =
8
10
  Data.define(:kind, :package, :detail, :evidence, :cycle, :digest) do
9
11
  def initialize(cycle: nil, digest: nil, detail: nil, **rest) = super
@@ -14,7 +16,15 @@ module Hashira
14
16
 
15
17
  def identity = digest || package
16
18
 
17
- def to_h = super.compact
19
+ def trace
20
+ "#{kind}|#{plain(site)}|#{evidence.map { plain(it) }.join(";")}" unless digest
21
+ end
22
+
23
+ def site = detail.to_h[:site].to_s
24
+
25
+ def plain(text) = TRACKS.reduce(text) { |left, mark| left.gsub(mark, "") }
26
+
27
+ def to_h = super.merge(detail: detail&.to_h).compact
18
28
  end
19
29
  end
20
30
  end
@@ -17,10 +17,10 @@ module Hashira
17
17
 
18
18
  def label(node) = [node.name.to_s]
19
19
 
20
- def cbase?(node)
20
+ def rooted?(node)
21
21
  return false unless node.is_a?(Prism::ConstantPathNode)
22
22
  parent = node.parent
23
- !parent || cbase?(parent)
23
+ !parent || rooted?(parent)
24
24
  end
25
25
 
26
26
  def anchor(stack, segments, roots)
@@ -33,12 +33,12 @@ module Hashira
33
33
  roots.include?(segments.first(1)) ? segments : (stack.last || []) + segments
34
34
  end
35
35
 
36
- def direct(type_node) = statements(type_node).grep(Prism::DefNode)
36
+ def direct(type) = statements(type).grep(Prism::DefNode)
37
37
 
38
- def constants(type_node) = statements(type_node).grep(Prism::ConstantWriteNode)
38
+ def constants(type) = statements(type).grep(Prism::ConstantWriteNode)
39
39
 
40
- def statements(type_node)
41
- body = type_node.body
40
+ def statements(type)
41
+ body = type.body
42
42
  body.is_a?(Prism::StatementsNode) ? body.body : [body]
43
43
  end
44
44
  end
@@ -7,6 +7,10 @@ module Hashira
7
7
  module TypeWalk
8
8
  module_function
9
9
 
10
+ def roots(trees)
11
+ trees.each_value.with_object(Set.new) { |tree, set| each(tree) { |_node, full| set << full } }
12
+ end
13
+
10
14
  def each(node, stack = [], roots: nil, &)
11
15
  return descend(node, stack, roots, &) unless type?(node)
12
16
  full = Syntax.anchor(stack, Syntax.segments(node.constant_path), roots)
data/lib/hashira/churn.rb CHANGED
@@ -1,18 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Churn
4
- LOG = %w[log --no-renames --name-only --format=].freeze
5
4
  SITES_THAT_DRIFT_APART = 2
6
5
 
7
- def self.scan(directory) = new(tally(read(directory)))
8
-
9
- def self.read(directory)
10
- IO.popen(["git", "-C", directory, *LOG], err: File::NULL, &:read)
11
- rescue SystemCallError
12
- ""
13
- end
14
-
15
- def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
6
+ def self.build(directory) = new(Hashira::GitLog.new(directory).counts)
16
7
 
17
8
  def initialize(counts)
18
9
  @counts = counts
@@ -7,7 +7,7 @@ class Hashira::CI::Accepted
7
7
 
8
8
  Entry =
9
9
  Data.define(:kind, :package, :digest, :reason) do
10
- def self.from(hash)
10
+ def self.build(hash)
11
11
  new(kind: hash["kind"], package: hash["package"], digest: hash["digest"], reason: hash["reason"])
12
12
  end
13
13
 
@@ -17,16 +17,16 @@ class Hashira::CI::Accepted
17
17
 
18
18
  def label = reason || "accepted (no reason recorded)"
19
19
 
20
- def to_h = { kind:, package:, digest:, reason: }.compact
20
+ def to_h = super.compact
21
21
  end
22
22
 
23
- def self.load(path) = new(Hashira::CI::Baseline.read(path).fetch("accepted", []))
23
+ def self.build(path) = new(Hashira::CI::Baseline.new(path).accepted)
24
24
 
25
- def initialize(entries)
26
- @entries = entries.map { Entry.from(it) }
25
+ def initialize(list)
26
+ @list = list
27
27
  end
28
28
 
29
- def entries = @entries.map(&:to_h)
29
+ def entries = records.map(&:to_h)
30
30
 
31
31
  def screen(findings)
32
32
  accepted, live = findings.map { [it, reason(it)] }.partition(&:last)
@@ -35,7 +35,9 @@ class Hashira::CI::Accepted
35
35
 
36
36
  private
37
37
 
38
+ def records = @_records ||= @list.map { Entry.build(it) }
39
+
38
40
  def reason(finding)
39
- @entries.find { it.matches?(finding) }&.label
41
+ records.find { it.matches?(finding) }&.label
40
42
  end
41
43
  end
@@ -3,59 +3,79 @@
3
3
  require "json"
4
4
 
5
5
  class Hashira::CI::Baseline
6
- SCHEMA_VERSION = 4
6
+ SCHEMA_VERSION = 5
7
7
 
8
- def self.load(path, analyzers: [], targets: [])
9
- new(path, read(path), Hashira::CI::Scope.new(analyzers:, targets:))
8
+ def initialize(path, analyzers: [], targets: [])
9
+ @path = path
10
+ @analyzers = analyzers
11
+ @targets = targets
10
12
  end
11
13
 
12
- def self.read(path)
13
- return {} unless path && File.exist?(path)
14
- recorded = JSON.parse(File.read(path))
15
- recorded.is_a?(Hash) ? recorded : raise(JSON::ParserError, "its top level is a list, not an object")
16
- end
14
+ attr_reader :path
17
15
 
18
- def self.trouble(path)
19
- read(path) && nil
20
- rescue JSON::ParserError, SystemCallError => error
21
- "#{path} is not a usable baseline — #{error.message.lines.first.strip}. Re-record it with --update-baseline"
22
- end
16
+ def exist? = File.exist?(@path)
23
17
 
24
- def initialize(path, recorded, scope = Hashira::CI::Scope.none)
25
- @path = path
26
- @recorded = recorded
27
- @scope = scope
18
+ def trouble
19
+ recorded && nil
20
+ rescue JSON::ParserError, SystemCallError => error
21
+ "#{@path} is not a usable baseline — #{error.message.lines.first.strip}. Re-record it with --update-baseline"
28
22
  end
29
23
 
30
- attr_reader :path
24
+ def edges = recorded.fetch("edges", [])
31
25
 
32
- def exist? = File.exist?(@path)
26
+ def findings = keyed(recorded.fetch("findings", {}))
33
27
 
34
- def edges = @recorded.fetch("edges", [])
28
+ def marks = findings.to_h { |key, magnitude| [key, Hashira::CI::Mark.new(magnitude:, trace: traces[key])] }
35
29
 
36
- def findings = self.class.scored(@recorded.fetch("findings", {}))
30
+ def traces = recorded.fetch("traces", {})
37
31
 
38
- def self.scored(recorded) = recorded.is_a?(Array) ? recorded.to_h { [it, nil] } : recorded
32
+ def findings? = recorded.key?("findings")
39
33
 
40
- def findings? = @recorded.key?("findings")
34
+ def accepted = recorded.fetch("accepted", [])
41
35
 
42
- def packaging = @recorded.fetch("packaging", "folder")
36
+ def packaging = recorded.fetch("packaging", "folder")
43
37
 
44
- def analyzers = @recorded.fetch("analyzers", wanted[:analyzers])
38
+ def analyzers = recorded.fetch("analyzers", wanted[:analyzers])
45
39
 
46
- def targets = @recorded.fetch("targets", wanted[:targets])
40
+ def targets = recorded.fetch("targets", wanted[:targets])
47
41
 
48
- def wanted = @scope.to_h
42
+ def wanted = scope.to_h
49
43
 
50
- def write(edges, findings, packaging:)
51
- File.write(@path, JSON.pretty_generate(payload(edges, findings, packaging)) << "\n")
44
+ def write(edges, marks, packaging:)
45
+ File.write(@path, JSON.pretty_generate(payload(edges, marks, packaging)) << "\n")
52
46
  end
53
47
 
54
48
  private
55
49
 
56
- def payload(edges, findings, packaging)
57
- base = { version: SCHEMA_VERSION, packaging:, **@scope.to_h, edges:, findings: }
58
- accepted = Hashira::CI::Accepted.new(@recorded.fetch("accepted", [])).entries
59
- accepted.empty? ? base : base.merge(accepted:)
50
+ def recorded
51
+ @_recorded ||= read
52
+ end
53
+
54
+ def read
55
+ return {} unless @path && File.exist?(@path)
56
+ stored = JSON.parse(File.read(@path))
57
+ stored.is_a?(Hash) ? stored : raise(JSON::ParserError, "its top level is a list, not an object")
58
+ end
59
+
60
+ def keyed(findings) = findings.is_a?(Array) ? findings.to_h { [it, nil] } : findings
61
+
62
+ def scope = Hashira::CI::Scope.new(analyzers: @analyzers, targets: @targets)
63
+
64
+ def payload(edges, marks, packaging)
65
+ stem(packaging).merge(edges:, findings: sized(marks)).merge(traced(marks)).merge(kept)
66
+ end
67
+
68
+ def stem(packaging) = { version: SCHEMA_VERSION, packaging: }.merge(scope.to_h)
69
+
70
+ def sized(marks) = marks.transform_values(&:magnitude)
71
+
72
+ def traced(marks)
73
+ found = marks.transform_values(&:trace).compact
74
+ found.empty? ? {} : { traces: found }
75
+ end
76
+
77
+ def kept
78
+ entries = Hashira::CI::Accepted.new(accepted).entries
79
+ entries.empty? ? {} : { accepted: entries }
60
80
  end
61
81
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::CI::Comparison
4
+ def initialize(current, recorded)
5
+ @current = current
6
+ @recorded = recorded
7
+ end
8
+
9
+ def diff
10
+ moved = renamed
11
+ Hashira::CI::Diff.new(added: fresh - moved.keys, removed: gone - moved.values, worsened: worsened(moved))
12
+ end
13
+
14
+ private
15
+
16
+ def fresh = @current.keys - @recorded.keys
17
+
18
+ def gone = @recorded.keys - @current.keys
19
+
20
+ def renamed = fresh.to_h { [it, pool[@current[it].trace]&.shift] }.compact
21
+
22
+ def pool = @_pool ||= gone.group_by { @recorded[it].trace }.except(nil)
23
+
24
+ def worsened(moved)
25
+ paired(moved).filter_map { |key, was| entry(key, @recorded[was].magnitude, @current[key].magnitude) }
26
+ end
27
+
28
+ def paired(moved) = (@current.keys & @recorded.keys).to_h { [it, it] }.merge(moved)
29
+
30
+ def entry(key, before, after)
31
+ [key, before, after] if before.is_a?(Integer) && after.is_a?(Integer) && after > before
32
+ end
33
+ end
@@ -4,21 +4,6 @@ module Hashira
4
4
  module CI
5
5
  Diff =
6
6
  Data.define(:added, :removed, :worsened) do
7
- def self.between(current, recorded)
8
- new(
9
- added: current.keys - recorded.keys, removed: recorded.keys - current.keys,
10
- worsened: grown(current, recorded)
11
- )
12
- end
13
-
14
- def self.grown(current, recorded)
15
- (current.keys & recorded.keys).filter_map { entry(it, recorded[it], current[it]) }
16
- end
17
-
18
- def self.entry(key, before, after)
19
- [key, before, after] if before.is_a?(Integer) && after.is_a?(Integer) && after > before
20
- end
21
-
22
7
  def initialize(worsened: [], **) = super
23
8
  def empty? = added.empty? && removed.empty? && worsened.empty?
24
9
  def worse? = !added.empty? || !worsened.empty?
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CI::Gate
4
- def initialize(findings, kinds, io: $stdout, err: $stderr)
4
+ def initialize(findings, kinds, io: $stdout, error: $stderr)
5
5
  @findings = findings
6
6
  @kinds = kinds
7
7
  @io = io
8
- @err = err
8
+ @error = error
9
9
  end
10
10
 
11
11
  def check
@@ -19,7 +19,7 @@ class Hashira::CI::Gate
19
19
  offending.each { Hashira::Report::FindingLines.new(it, io: @io).print }
20
20
  verdict = "Gate FAILED: #{offending.size} finding(s) — #{tally(offending)}."
21
21
  @io.puts("\n#{verdict}")
22
- @err.puts(verdict)
22
+ @error.puts(verdict)
23
23
  1
24
24
  end
25
25
 
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module CI
5
+ Mark = Data.define(:magnitude, :trace)
6
+ end
7
+ end
@@ -14,7 +14,11 @@ class Hashira::CI::Ratchet
14
14
  Hashira::CI::Status::CLEAN
15
15
  end
16
16
 
17
- def check = Hashira::CI::RatchetReport.new(@graph, @findings, io: @io).print(drift, delta)
17
+ def check(focus)
18
+ compared = focus.narrowing? ? Hashira::CI::Slice.new(@baseline) : Hashira::CI::Sweep.new(@baseline)
19
+ Hashira::CI::RatchetReport.new(@graph, @findings, io: @io)
20
+ .print(compared.edges(drift), delta(compared), tally(compared))
21
+ end
18
22
 
19
23
  def blocker
20
24
  return "no baseline at #{@baseline.path} — run --update-baseline first" unless @baseline.exist?
@@ -56,15 +60,19 @@ class Hashira::CI::Ratchet
56
60
 
57
61
  def edges = @graph.edges.map(&:to_s)
58
62
 
63
+ def tally(compared) = compared.counts("#{@graph.edges.size} edges", "#{@findings.size} findings").join(", ")
64
+
59
65
  def scored
60
- @scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, group.filter_map(&:magnitude).max] }
66
+ @_scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, mark(group)] }
61
67
  end
62
68
 
69
+ def mark(group) = Hashira::CI::Mark.new(magnitude: group.filter_map(&:magnitude).max, trace: group.first.trace)
70
+
63
71
  def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
64
72
 
65
73
  def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
66
74
 
67
- def delta
68
- Hashira::CI::Diff.between(scored, @baseline.findings) if @baseline.findings?
75
+ def delta(compared)
76
+ compared.findings(scored) if @baseline.findings?
69
77
  end
70
78
  end
@@ -7,8 +7,8 @@ class Hashira::CI::RatchetReport
7
7
  @io = io
8
8
  end
9
9
 
10
- def print(edges, findings)
11
- return unchanged if quiet?(edges, findings)
10
+ def print(edges, findings, tally)
11
+ return unchanged(tally) if quiet?(edges, findings)
12
12
  details(edges, findings)
13
13
  advice(edges, findings)
14
14
  verdict(edges, findings)
@@ -18,8 +18,8 @@ class Hashira::CI::RatchetReport
18
18
 
19
19
  def quiet?(*diffs) = diffs.compact.all?(&:empty?)
20
20
 
21
- def unchanged
22
- @io.puts("Ratchet OK: #{@graph.edges.size} edges, #{@findings.size} findings, unchanged.")
21
+ def unchanged(tally)
22
+ @io.puts("Ratchet OK: #{tally}, unchanged.")
23
23
  Hashira::CI::Status::CLEAN
24
24
  end
25
25
 
@@ -4,7 +4,6 @@ module Hashira
4
4
  module CI
5
5
  Scope =
6
6
  Data.define(:analyzers, :targets) do
7
- def self.none = new(analyzers: [], targets: [])
8
7
  def to_h = { analyzers: analyzers.map(&:to_s).sort, targets: targets.sort }
9
8
  end
10
9
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::CI::Slice
4
+ SETTLED = Hashira::CI::Diff.new(added: [], removed: [])
5
+
6
+ def initialize(baseline)
7
+ @baseline = baseline
8
+ end
9
+
10
+ def edges(_diff) = SETTLED
11
+
12
+ def findings(marks) = Hashira::CI::Comparison.new(marks, seen(marks, traces(marks))).diff.with(removed: [])
13
+
14
+ def counts(_edges, findings) = [findings]
15
+
16
+ private
17
+
18
+ def traces(marks) = marks.each_value.filter_map(&:trace)
19
+
20
+ def seen(marks, traces) = @baseline.marks.select { |key, mark| marks.key?(key) || traces.include?(mark.trace) }
21
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::CI::Sweep
4
+ def initialize(baseline)
5
+ @baseline = baseline
6
+ end
7
+
8
+ def edges(diff) = diff
9
+
10
+ def findings(marks) = Hashira::CI::Comparison.new(marks, @baseline.marks).diff
11
+
12
+ def counts(edges, findings) = [edges, findings]
13
+ end