hashira 0.8.0 → 0.10.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +61 -6
  4. data/exe/hashira +1 -1
  5. data/lib/hashira/analysis/finding.rb +10 -0
  6. data/lib/hashira/analysis/type_walk.rb +4 -0
  7. data/lib/hashira/boundaries.rb +64 -0
  8. data/lib/hashira/ci/baseline.rb +26 -7
  9. data/lib/hashira/ci/comparison.rb +14 -5
  10. data/lib/hashira/ci/{edge_diff_report.rb → edge_changes.rb} +1 -1
  11. data/lib/hashira/ci/{finding_diff_report.rb → finding_changes.rb} +1 -1
  12. data/lib/hashira/ci/mark.rb +7 -0
  13. data/lib/hashira/ci/ratchet.rb +3 -1
  14. data/lib/hashira/ci/ratchet_report.rb +2 -2
  15. data/lib/hashira/ci/slice.rb +7 -3
  16. data/lib/hashira/ci/sweep.rb +1 -1
  17. data/lib/hashira/cli/flag.rb +1 -1
  18. data/lib/hashira/cli/flags.rb +1 -1
  19. data/lib/hashira/cli/options.rb +16 -6
  20. data/lib/hashira/cli/run.rb +1 -6
  21. data/lib/hashira/{cli.rb → cli/session.rb} +7 -5
  22. data/lib/hashira/coupling/{cycle_findings.rb → cycle.rb} +1 -1
  23. data/lib/hashira/coupling/definitions.rb +1 -7
  24. data/lib/hashira/coupling/{roll_call_findings.rb → echo.rb} +1 -1
  25. data/lib/hashira/coupling/{sdp_violation_findings.rb → imbalance.rb} +3 -3
  26. data/lib/hashira/coupling/{mixed_audience_findings.rb → mixed_audience.rb} +1 -1
  27. data/lib/hashira/coupling/report.rb +5 -5
  28. data/lib/hashira/coupling/{wide_edge_findings.rb → wide_edge.rb} +1 -1
  29. data/lib/hashira/duplication/clusters.rb +1 -1
  30. data/lib/hashira/duplication/fragment.rb +18 -0
  31. data/lib/hashira/duplication/literal.rb +25 -0
  32. data/lib/hashira/duplication/variance.rb +7 -1
  33. data/lib/hashira/pipeline.rb +8 -1
  34. data/lib/hashira/project.rb +4 -1
  35. data/lib/hashira/report/smell_phrases.rb +5 -5
  36. data/lib/hashira/smells/{instance_variable_assumption.rb → assumed_state.rb} +5 -15
  37. data/lib/hashira/smells/boundary_sprawl.rb +3 -2
  38. data/lib/hashira/smells/branches.rb +62 -0
  39. data/lib/hashira/smells/census.rb +8 -2
  40. data/lib/hashira/smells/contexts.rb +3 -1
  41. data/lib/hashira/smells/lineage.rb +92 -0
  42. data/lib/hashira/smells/{duplicate_method_call.rb → repeated_call.rb} +25 -5
  43. data/lib/hashira/smells/report.rb +10 -7
  44. data/lib/hashira/smells/{too_many_instance_variables.rb → state_sprawl.rb} +1 -1
  45. data/lib/hashira/version.rb +1 -1
  46. data/lib/hashira.rb +19 -11
  47. metadata +17 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 137dafce03e707a097cf684620fbd9c9efc8989347e77b88c13da103102f5da5
4
- data.tar.gz: e0ab59d2e1909042c991764b5384217601aaf42b036bd6916042efc2107e0200
3
+ metadata.gz: 28f5f325c96b2579257212ed846fba744718bf8bbdc16ea46f0f0225abdb15af
4
+ data.tar.gz: 587bc337f5e0bbd94243d7e6cb7b4d4268a8617d913750fdf85156f0f4168c78
5
5
  SHA512:
6
- metadata.gz: 2c4895c6d045e05e610fda4c51ce763e0ef876049d933dbd4a3d9f99ac24c8648583a4a3f39e5c2baadfae263736914ba2a428781024ead0410b2d968585b8b8
7
- data.tar.gz: dec6a978383a76ad57fdf34c98b5ea4b571775d44baafc0f5c02c3bbe75b7a56e274609c1586fc77af94e93f65f501579a5b6fa09bfd4db3952bb3cb412c153b
6
+ metadata.gz: 28516d160aa0888bf1e9de0e00f80b1992444554309cc2a414a68dfc3c9272580e314b6593ec4b1a113b50185b7aea99bd82af7c1ac7143da9fb802b115aec46
7
+ data.tar.gz: eb711b0c77caff20baca0e36d8526126dde23e40bda06c6ed8efe5b1fd62d180e372b3a4447d66ab5be3b19abdf000e34ae94629ea45142373840992589cd6b7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,86 @@ 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.10.0] - 2026-08-30
9
+
10
+ ### Added
11
+
12
+ - **Interpreted foreign models are verified architecture, not accepted debt.**
13
+ A baseline can declare a foreign root with `role: interpreted_model`, its one
14
+ API entrypoint, and a reason. Type dispatch over that model no longer emits
15
+ `boundary_sprawl`, while a missing root call or a call which bypasses the
16
+ entrypoint is rejected as misuse. Baseline schema 6 preserves these
17
+ declarations on `--update-baseline`. Hashira now declares Prism this way and
18
+ dogfoods with zero findings and zero acceptances.
19
+
20
+ ### Changed
21
+
22
+ - `boundary_sprawl` now distinguishes a missing adapter from a program which
23
+ deliberately interprets a foreign data model. Undeclared foreign roots keep
24
+ the same 12-method, 3-file threshold and behavior.
25
+
26
+ - Three smell kinds are renamed to name the smell, not the mechanism:
27
+ `duplicate_method_call` is now `repeated_call`, `too_many_instance_variables`
28
+ is `state_sprawl`, and `instance_variable_assumption` is `assumed_state`. A
29
+ saved baseline reports the old kinds as resolved and the new ones as new;
30
+ accept once to move on.
31
+
32
+ ## [0.9.0] - 2026-08-20
33
+
34
+ ### Fixed
35
+
36
+ - **A rename stops reading as churn in the ratchet.** A finding is keyed by what
37
+ it names, so renaming a class reported the twelve findings under it as
38
+ resolved and new at once, and adding `?` to four predicates did the same. The
39
+ baseline now records a `traces` map beside `findings` — for each finding, the
40
+ file it sits in and what its evidence says, with line numbers stripped — and
41
+ the ratchet pairs a disappeared key with an appeared one carrying the same
42
+ trace. The match is one for one, so a rename that brought a new finding along
43
+ still fails, and a renamed method that also got worse still reports WORSE.
44
+ Acceptance is untouched: `accepted` entries still name a finding by `package`
45
+ or `digest`. Baselines recorded before this have no traces and behave exactly
46
+ as they did; the next `--update-baseline` writes them (schema version 5, which
47
+ older hashira reads too).
48
+ - **A shared namespace stops passing for a shared name.** The duplication
49
+ near-miss guard raises the mass floor when two sites have no name in common —
50
+ but `Prism::CallNode` and `Prism::BlockParameterNode` counted `Prism` as
51
+ common, so two unrelated one-liners that both mention a Prism class slipped
52
+ under the low floor. A constant is now read by what it points at, not the
53
+ namespace it sits in.
54
+ - **A nested class is named the way Ruby resolves it.** `class Widget::Broken`
55
+ written inside `class Widget` was reported as `Widget::Widget::Broken`. The
56
+ smell census now resolves a compound constant path against the constants the
57
+ codebase actually declares, the way the coupling graph already did. Findings
58
+ on such classes change name, so a baseline recorded before this reports them
59
+ once as resolved-and-new; re-record it with `--update-baseline`.
60
+
61
+ ### Changed
62
+
63
+ - **`duplicate_method_call` stops flagging calls that are supposed to differ.**
64
+ `stdout = "".b` next to `stderr = "".b` is two buffers, and
65
+ `rand(1_000_000_000)` twice is two ids — naming either once writes a bug. The
66
+ check now excuses calls that mint a fresh value each time (`new`, `dup`,
67
+ `clone`, `allocate`, `rand`, anything from `SecureRandom` or `Random`, and
68
+ any call on a literal) and repeats that no single run can reach twice: the
69
+ two arms of an `if` or `unless`, two `when` or `in` branches, a body and its
70
+ `rescue`. Nothing can be hoisted across those, and a `raise` has no result to
71
+ name.
72
+ - **`instance_variable_assumption` asks whether anything assigns, not whether
73
+ `initialize` does.** Assignment through a mixin, a superclass, an
74
+ `attr_writer`, a reopening of the class, or a private method the constructor
75
+ calls all count now — every shape that made the old check report a class that
76
+ was perfectly fine. What survives is the ivar nothing the class can reach ever
77
+ sets: a typo, or state another object is expected to install. When a class
78
+ inherits or includes something the codebase can't see, the check stays quiet
79
+ rather than guess.
80
+ - **A run of declarative macros is a schema, not a clone.** Two models opening
81
+ with the same `has_many ..., dependent: :destroy` lines, or two serializers
82
+ with the same `typelize`/`attribute` pairs, were reported as duplication whose
83
+ only fix was to hide the schema behind a class method. A fragment built purely
84
+ from directives — receiverless calls with literal arguments — no longer
85
+ clusters. A block, a method, a variable, a receiver, or a branch anywhere in
86
+ the fragment makes it code again.
87
+
8
88
  ## [0.8.0] - 2026-08-15
9
89
 
10
90
  ### 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.
@@ -334,7 +340,9 @@ What each one catches:
334
340
  the method" needs a destination you own.
335
341
  - **boundary_sprawl** — 12+ methods across 3+ files each type-guard against the
336
342
  same foreign root (`Prism`, `ActiveRecord`, ...). One method inspecting a
337
- foreign type is a fact of life; a sprawl of them is a missing adapter.
343
+ foreign type is a fact of life; a sprawl of them usually means a missing
344
+ adapter. An analyzer or interpreter which deliberately understands a foreign
345
+ data model can declare and verify that boundary instead.
338
346
  - **utility_function** — a public instance method that touches no instance state;
339
347
  it isn't really a method of this class. Private stateless helpers are fine, and
340
348
  `module_function` modules are exempt — that's what they're for.
@@ -342,14 +350,22 @@ What each one catches:
342
350
  caller already knew which branch it wanted.
343
351
  - **data_clump** — the same two-plus parameters travel through three or more
344
352
  methods; a value object is missing.
345
- - **duplicate_method_call** — the identical receiver-and-arguments call repeated
346
- inside one method; name the result once.
353
+ - **repeated_call** — the identical receiver-and-arguments call repeated
354
+ inside one method; name the result once. Quiet wherever naming it would be
355
+ wrong: calls that mint a fresh value every time (`"".b`, `rand`, `dup`,
356
+ `SecureRandom.hex`) are meant to differ, and a repeat no single run can reach
357
+ twice — the two arms of an `if`, two `when` branches, a body and its `rescue`
358
+ — has nothing to hoist.
347
359
  - **repeated_conditional** — one class testing the same condition in three or
348
360
  more places; polymorphism is overdue.
349
- - **too_many_instance_variables** — more than four per class. Memoization
361
+ - **state_sprawl** — more than four per class. Memoization
350
362
  (`@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.
363
+ - **assumed_state** — an ivar read that nothing the class can
364
+ reach ever assigns: not `initialize`, not another of its own methods, not an
365
+ `attr_writer`, not a reopening of the class, not a module it mixes in or a
366
+ class it inherits. Usually a typo, or state some other object is expected to
367
+ install. Silent when the class inherits or includes something the codebase
368
+ can't see, because the assignment may live in there.
353
369
  - **manual_dispatch** — `respond_to?` then send: a type check wearing a duck
354
370
  costume.
355
371
  - **module_initialize** — `initialize` in a mixin; construction order becomes
@@ -460,6 +476,17 @@ WORSE FINDING (was 13, now 24):
460
476
  Baselines written by earlier versions still work: they record identity only, so
461
477
  they ratchet on appearance until the next `--update-baseline` records magnitudes.
462
478
 
479
+ It also records a *trace* of each finding — the file it sits in and what it says,
480
+ with the line numbers left out. A finding is keyed by what it names
481
+ (`Class#method`), so renaming a class, or adding a `?` to four predicates, would
482
+ otherwise report every finding under it as resolved and new in the same breath.
483
+ When a key disappears and another appears carrying the same trace, the ratchet
484
+ reads them as one finding that changed name. The match is one for one: a rename
485
+ that *brought* a new finding with it still fails, and a renamed method that also
486
+ got more complex still reports WORSE. Moving the file changes the trace, because
487
+ that is a relocation rather than a rename — re-record it. Baselines without
488
+ traces behave exactly as they did before.
489
+
463
490
  A regression prints in full, with the evidence that introduced it:
464
491
 
465
492
  ```console
@@ -522,6 +549,34 @@ English, so the codes are distinct:
522
549
  baseline is stale. Failing on `1` while treating `3` as a nudge lets a build
523
550
  block regressions without blocking progress.
524
551
 
552
+ ### Declaring interpreted models
553
+
554
+ Some foreign models are the subject of the program rather than a dependency to
555
+ hide. A compiler interprets an AST; a serializer interprets a schema. Declare
556
+ that architecture in the baseline instead of accepting a `boundary_sprawl`
557
+ finding:
558
+
559
+ ```json
560
+ "boundaries": [
561
+ {
562
+ "root": "Prism",
563
+ "role": "interpreted_model",
564
+ "entrypoint": "lib/my_tool/trees.rb",
565
+ "reason": "the Prism AST is the input model"
566
+ }
567
+ ]
568
+ ```
569
+
570
+ Type dispatch over that root no longer counts as boundary sprawl. The
571
+ declaration is checked, not waived: the root API must be called, every such call
572
+ must go through the named entrypoint, and every field needs a value. A missing,
573
+ unknown, unused, or bypassed declaration exits as misuse. `--update-baseline`
574
+ preserves declarations.
575
+
576
+ This is deliberately narrower than acceptance. A declared boundary says what
577
+ architecture the project conforms to; an accepted finding records why a real
578
+ violation is allowed to remain.
579
+
525
580
  ### Accepting by design
526
581
 
527
582
  Anything deliberate goes in the baseline with a reason. It leaves reports and
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.new(ARGV).status
9
+ exit Hashira::CLI::Session.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,6 +16,14 @@ module Hashira
14
16
 
15
17
  def identity = digest || package
16
18
 
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
+
17
27
  def to_h = super.merge(detail: detail&.to_h).compact
18
28
  end
19
29
  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)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Boundaries
6
+ ROLE = "interpreted_model"
7
+
8
+ Declaration =
9
+ Data.define(:root, :role, :entrypoint, :reason) do
10
+ def verify
11
+ missing = members.find { !present?(public_send(it)) }
12
+ root, role, _entrypoint, _reason = deconstruct
13
+ raise(Hashira::Error, "boundary #{root || "(unknown)"} #{missing} is missing") if missing
14
+ raise(Hashira::Error, "boundary #{root} has unknown role #{role.inspect}") unless role == Hashira::Boundaries::ROLE
15
+ end
16
+ private
17
+ def present?(value) = value.is_a?(String) && !value.empty?
18
+ end
19
+
20
+ def initialize(records, trees)
21
+ @records = records
22
+ @trees = trees
23
+ end
24
+
25
+ def interpreted
26
+ return @_interpreted if @_interpreted
27
+ verify
28
+ @_interpreted = entries.map(&:root)
29
+ end
30
+
31
+ private
32
+
33
+ def entries = @_entries ||= @records.map { declaration(it) }
34
+
35
+ def declaration(record)
36
+ raise(Hashira::Error, "boundary declaration is not an object") unless record.is_a?(Hash)
37
+ Declaration.new(*%w[root role entrypoint reason].map { record[it] })
38
+ end
39
+
40
+ def verify
41
+ entries.each do
42
+ it.verify
43
+ route(it)
44
+ end
45
+ end
46
+
47
+ def route(entry)
48
+ root, _role, entrypoint, _reason = entry.deconstruct
49
+ files = calls(root)
50
+ raise(Hashira::Error, "boundary #{root} has no root calls") if files.empty?
51
+ bypasses = files.reject { same?(it, entrypoint) }
52
+ raise(Hashira::Error, "boundary #{root} bypasses #{entrypoint}: #{bypasses.join(", ")}") unless bypasses.empty?
53
+ end
54
+
55
+ def calls(root)
56
+ @trees.filter_map { |file, tree| file if Hashira::Analysis::NodeWalk.collect(tree).any? { invocation?(it, root) } }
57
+ end
58
+
59
+ def invocation?(node, root)
60
+ node.is_a?(Prism::CallNode) && Hashira::Analysis::Syntax.segments(node.receiver).first == root
61
+ end
62
+
63
+ def same?(file, entrypoint) = File.expand_path(file) == File.expand_path(entrypoint)
64
+ end
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  class Hashira::CI::Baseline
6
- SCHEMA_VERSION = 4
6
+ SCHEMA_VERSION = 6
7
7
 
8
8
  def initialize(path, analyzers: [], targets: [])
9
9
  @path = path
@@ -25,10 +25,16 @@ class Hashira::CI::Baseline
25
25
 
26
26
  def findings = keyed(recorded.fetch("findings", {}))
27
27
 
28
+ def marks = findings.to_h { |key, magnitude| [key, Hashira::CI::Mark.new(magnitude:, trace: traces[key])] }
29
+
30
+ def traces = recorded.fetch("traces", {})
31
+
28
32
  def findings? = recorded.key?("findings")
29
33
 
30
34
  def accepted = recorded.fetch("accepted", [])
31
35
 
36
+ def boundaries = recorded.fetch("boundaries", [])
37
+
32
38
  def packaging = recorded.fetch("packaging", "folder")
33
39
 
34
40
  def analyzers = recorded.fetch("analyzers", wanted[:analyzers])
@@ -37,8 +43,8 @@ class Hashira::CI::Baseline
37
43
 
38
44
  def wanted = scope.to_h
39
45
 
40
- def write(edges, findings, packaging:)
41
- File.write(@path, JSON.pretty_generate(payload(edges, findings, packaging)) << "\n")
46
+ def write(edges, marks, packaging:)
47
+ File.write(@path, JSON.pretty_generate(payload(edges, marks, packaging)) << "\n")
42
48
  end
43
49
 
44
50
  private
@@ -57,9 +63,22 @@ class Hashira::CI::Baseline
57
63
 
58
64
  def scope = Hashira::CI::Scope.new(analyzers: @analyzers, targets: @targets)
59
65
 
60
- def payload(edges, findings, packaging)
61
- base = { version: SCHEMA_VERSION, packaging: }.merge(scope.to_h).merge(edges:, findings:)
62
- kept = Hashira::CI::Accepted.new(accepted).entries
63
- kept.empty? ? base : base.merge(accepted: kept)
66
+ def payload(edges, marks, packaging)
67
+ stem(packaging).merge(edges:, findings: sized(marks)).merge(traced(marks)).merge(kept)
68
+ end
69
+
70
+ def stem(packaging) = { version: SCHEMA_VERSION, packaging: }.merge(scope.to_h)
71
+
72
+ def sized(marks) = marks.transform_values(&:magnitude)
73
+
74
+ def traced(marks)
75
+ found = marks.transform_values(&:trace).compact
76
+ found.empty? ? {} : { traces: found }
64
77
  end
78
+
79
+ def kept
80
+ optional(:accepted, Hashira::CI::Accepted.new(accepted).entries).merge(optional(:boundaries, boundaries))
81
+ end
82
+
83
+ def optional(name, values) = values.empty? ? {} : { name => values }
65
84
  end
@@ -7,17 +7,26 @@ class Hashira::CI::Comparison
7
7
  end
8
8
 
9
9
  def diff
10
- current = @current.keys
11
- recorded = @recorded.keys
12
- Hashira::CI::Diff.new(added: current - recorded, removed: recorded - current, worsened:)
10
+ moved = renamed
11
+ Hashira::CI::Diff.new(added: fresh - moved.keys, removed: gone - moved.values, worsened: worsened(moved))
13
12
  end
14
13
 
15
14
  private
16
15
 
17
- def worsened
18
- (@current.keys & @recorded.keys).filter_map { entry(it, @recorded[it], @current[it]) }
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) }
19
26
  end
20
27
 
28
+ def paired(moved) = (@current.keys & @recorded.keys).to_h { [it, it] }.merge(moved)
29
+
21
30
  def entry(key, before, after)
22
31
  [key, before, after] if before.is_a?(Integer) && after.is_a?(Integer) && after > before
23
32
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::CI::EdgeDiffReport
3
+ class Hashira::CI::EdgeChanges
4
4
  def initialize(graph, io: $stdout)
5
5
  @graph = graph
6
6
  @io = io
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::CI::FindingDiffReport
3
+ class Hashira::CI::FindingChanges
4
4
  def initialize(findings, io: $stdout)
5
5
  @findings = findings
6
6
  @io = io
@@ -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
@@ -63,9 +63,11 @@ class Hashira::CI::Ratchet
63
63
  def tally(compared) = compared.counts("#{@graph.edges.size} edges", "#{@findings.size} findings").join(", ")
64
64
 
65
65
  def scored
66
- @_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)] }
67
67
  end
68
68
 
69
+ def mark(group) = Hashira::CI::Mark.new(magnitude: group.filter_map(&:magnitude).max, trace: group.first.trace)
70
+
69
71
  def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
70
72
 
71
73
  def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
@@ -26,8 +26,8 @@ class Hashira::CI::RatchetReport
26
26
  def verdict(*diffs) = diffs.compact.any?(&:worse?) ? Hashira::CI::Status::WORSE : Hashira::CI::Status::BETTER
27
27
 
28
28
  def details(edges, findings)
29
- Hashira::CI::EdgeDiffReport.new(@graph, io: @io).print(edges)
30
- Hashira::CI::FindingDiffReport.new(@findings, io: @io).print(findings) if findings
29
+ Hashira::CI::EdgeChanges.new(@graph, io: @io).print(edges)
30
+ Hashira::CI::FindingChanges.new(@findings, io: @io).print(findings) if findings
31
31
  end
32
32
 
33
33
  def advice(*diffs)
@@ -9,9 +9,13 @@ class Hashira::CI::Slice
9
9
 
10
10
  def edges(_diff) = SETTLED
11
11
 
12
- def findings(scored) = Hashira::CI::Comparison.new(scored, seen(scored)).diff
13
-
14
- def seen(scored) = @baseline.findings.slice(*scored.keys)
12
+ def findings(marks) = Hashira::CI::Comparison.new(marks, seen(marks, traces(marks))).diff.with(removed: [])
15
13
 
16
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) }
17
21
  end
@@ -7,7 +7,7 @@ class Hashira::CI::Sweep
7
7
 
8
8
  def edges(diff) = diff
9
9
 
10
- def findings(scored) = Hashira::CI::Comparison.new(scored, @baseline.findings).diff
10
+ def findings(marks) = Hashira::CI::Comparison.new(marks, @baseline.marks).diff
11
11
 
12
12
  def counts(edges, findings) = [edges, findings]
13
13
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::CLI
3
+ module Hashira::CLI
4
4
  BLANK = %i[arg field parse mode].to_h { [it, nil] }.freeze
5
5
 
6
6
  Flag =
@@ -8,7 +8,7 @@ require_relative "package_by"
8
8
  require_relative "skip"
9
9
  require_relative "top"
10
10
 
11
- class Hashira::CLI
11
+ module Hashira::CLI
12
12
  FLAGS = [
13
13
  Flag.new(
14
14
  name: "--format", arg: "FORMAT", parse: Format, mode: :parsed,
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::CLI
3
+ module Hashira::CLI
4
4
  Options =
5
5
  Data.define(:directories, :mode, :baseline, :fail_on, :skip, :only, :packaging, :top, :compact) do
6
6
  def self.parse(argv) = CommandLine.new(argv).options
@@ -13,13 +13,23 @@ class Hashira::CLI
13
13
  end
14
14
 
15
15
  def pipeline
16
- chosen = Hashira::Project.new(directories)
17
- told = Hashira::Report::Notices.new
18
- told.scanning(chosen.files.size)
19
- told.rails if directories.empty? && File.exist?("config/application.rb")
20
- Hashira::Pipeline.new(chosen, enabled: analyzers, packaging:, only:)
16
+ chosen = Hashira::Project.new(directories, boundaries: decisions.boundaries)
17
+ announce(chosen)
18
+ Hashira::Pipeline.new(chosen, enabled: analyzers, packaging:, only:).verify
21
19
  end
22
20
 
23
21
  def analyzers = Hashira::Pipeline::ANALYZERS - skip
22
+ private
23
+ def decisions
24
+ recorded = Hashira::CI::Baseline.new(baseline)
25
+ raise(Hashira::Error, recorded.trouble) if recorded.trouble
26
+ recorded
27
+ end
28
+
29
+ def announce(project)
30
+ told = Hashira::Report::Notices.new
31
+ told.scanning(project.files.size)
32
+ told.rails if directories.empty? && File.exist?("config/application.rb")
33
+ end
24
34
  end
25
35
  end
@@ -17,12 +17,7 @@ class Hashira::CLI::Run
17
17
 
18
18
  def findings = @_findings ||= accepted.screen(@pipeline.findings)
19
19
 
20
- def accepted
21
- path = @options.baseline
22
- stop = Hashira::CI::Baseline.new(path).trouble
23
- raise(Hashira::Error, stop) if stop
24
- Hashira::CI::Accepted.build(path)
25
- end
20
+ def accepted = Hashira::CI::Accepted.build(@options.baseline)
26
21
 
27
22
  def update
28
23
  ratchet.update
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::CLI
3
+ class Hashira::CLI::Session
4
4
  MISUSE = 2
5
5
  BROKEN = 70
6
6
 
@@ -9,7 +9,7 @@ class Hashira::CLI
9
9
  end
10
10
 
11
11
  def status
12
- dispatch(Options.parse(@argv))
12
+ dispatch(Hashira::CLI::Options.parse(@argv))
13
13
  rescue Hashira::Error => error
14
14
  failure(error)
15
15
  rescue StandardError => error
@@ -18,14 +18,16 @@ class Hashira::CLI
18
18
 
19
19
  private
20
20
 
21
- def dispatch(options) = usage?(options) ? Usage.public_send(options.mode) : timed(options)
21
+ def dispatch(options)
22
+ usage?(options) ? Hashira::CLI::Usage.public_send(options.mode) : timed(options)
23
+ end
22
24
 
23
- def usage?(options) = Usage::PAGES.include?(options.mode)
25
+ def usage?(options) = Hashira::CLI::Usage::PAGES.include?(options.mode)
24
26
 
25
27
  def timed(options)
26
28
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
29
  pipeline = options.pipeline
28
- Run.new(pipeline, options).status.tap do
30
+ Hashira::CLI::Run.new(pipeline, options).status.tap do
29
31
  Hashira::Report::Notices.new.finished(pipeline.project.files.size, elapsed(started))
30
32
  end
31
33
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "rule"
4
4
 
5
- class Hashira::Coupling::CycleFindings < Hashira::Coupling::Rule
5
+ class Hashira::Coupling::Cycle < Hashira::Coupling::Rule
6
6
  KIND = "cycle"
7
7
 
8
8
  def list
@@ -14,16 +14,10 @@ class Hashira::Coupling::Definitions
14
14
 
15
15
  def packages = @trees.keys.map { @project.package(it) }.uniq
16
16
 
17
- def roots
18
- @_roots ||= @trees.each_value.with_object(Set.new) { |tree, set| survey(tree, set) }
19
- end
17
+ def roots = @_roots ||= Hashira::Analysis::TypeWalk.roots(@trees)
20
18
 
21
19
  private
22
20
 
23
- def survey(tree, set)
24
- Hashira::Analysis::TypeWalk.each(tree) { |_node, full| set << full }
25
- end
26
-
27
21
  def scan(file, tree)
28
22
  package = @project.package(file)
29
23
  Hashira::Analysis::TypeWalk.each(tree, roots: roots) do |node, full|
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "rule"
4
4
 
5
- class Hashira::Coupling::RollCallFindings < Hashira::Coupling::Rule
5
+ class Hashira::Coupling::Echo < Hashira::Coupling::Rule
6
6
  KIND = "roll_call"
7
7
 
8
8
  def list = rolls.map { entry(it) }
@@ -2,10 +2,10 @@
2
2
 
3
3
  require_relative "rule"
4
4
 
5
- class Hashira::Coupling::SdpViolationFindings < Hashira::Coupling::Rule
5
+ class Hashira::Coupling::Imbalance < Hashira::Coupling::Rule
6
6
  KIND = "sdp_violation"
7
7
 
8
- Imbalance = Data.define(:from, :to, :from_instability, :to_instability)
8
+ Gap = Data.define(:from, :to, :from_instability, :to_instability)
9
9
 
10
10
  def list
11
11
  ranked.map { |from, to| violation(from, to) }
@@ -20,7 +20,7 @@ class Hashira::Coupling::SdpViolationFindings < Hashira::Coupling::Rule
20
20
  end
21
21
 
22
22
  def detail(from, to)
23
- Imbalance.new(from:, to:, from_instability: instability(from), to_instability: instability(to))
23
+ Gap.new(from:, to:, from_instability: instability(from), to_instability: instability(to))
24
24
  end
25
25
 
26
26
  def instability(package) = metrics[package].instability