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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +61 -6
- data/exe/hashira +1 -1
- data/lib/hashira/analysis/finding.rb +10 -0
- data/lib/hashira/analysis/type_walk.rb +4 -0
- data/lib/hashira/boundaries.rb +64 -0
- data/lib/hashira/ci/baseline.rb +26 -7
- data/lib/hashira/ci/comparison.rb +14 -5
- data/lib/hashira/ci/{edge_diff_report.rb → edge_changes.rb} +1 -1
- data/lib/hashira/ci/{finding_diff_report.rb → finding_changes.rb} +1 -1
- data/lib/hashira/ci/mark.rb +7 -0
- data/lib/hashira/ci/ratchet.rb +3 -1
- data/lib/hashira/ci/ratchet_report.rb +2 -2
- data/lib/hashira/ci/slice.rb +7 -3
- data/lib/hashira/ci/sweep.rb +1 -1
- data/lib/hashira/cli/flag.rb +1 -1
- data/lib/hashira/cli/flags.rb +1 -1
- data/lib/hashira/cli/options.rb +16 -6
- data/lib/hashira/cli/run.rb +1 -6
- data/lib/hashira/{cli.rb → cli/session.rb} +7 -5
- data/lib/hashira/coupling/{cycle_findings.rb → cycle.rb} +1 -1
- data/lib/hashira/coupling/definitions.rb +1 -7
- data/lib/hashira/coupling/{roll_call_findings.rb → echo.rb} +1 -1
- data/lib/hashira/coupling/{sdp_violation_findings.rb → imbalance.rb} +3 -3
- data/lib/hashira/coupling/{mixed_audience_findings.rb → mixed_audience.rb} +1 -1
- data/lib/hashira/coupling/report.rb +5 -5
- data/lib/hashira/coupling/{wide_edge_findings.rb → wide_edge.rb} +1 -1
- data/lib/hashira/duplication/clusters.rb +1 -1
- data/lib/hashira/duplication/fragment.rb +18 -0
- data/lib/hashira/duplication/literal.rb +25 -0
- data/lib/hashira/duplication/variance.rb +7 -1
- data/lib/hashira/pipeline.rb +8 -1
- data/lib/hashira/project.rb +4 -1
- data/lib/hashira/report/smell_phrases.rb +5 -5
- data/lib/hashira/smells/{instance_variable_assumption.rb → assumed_state.rb} +5 -15
- data/lib/hashira/smells/boundary_sprawl.rb +3 -2
- data/lib/hashira/smells/branches.rb +62 -0
- data/lib/hashira/smells/census.rb +8 -2
- data/lib/hashira/smells/contexts.rb +3 -1
- data/lib/hashira/smells/lineage.rb +92 -0
- data/lib/hashira/smells/{duplicate_method_call.rb → repeated_call.rb} +25 -5
- data/lib/hashira/smells/report.rb +10 -7
- data/lib/hashira/smells/{too_many_instance_variables.rb → state_sprawl.rb} +1 -1
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +19 -11
- metadata +17 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28f5f325c96b2579257212ed846fba744718bf8bbdc16ea46f0f0225abdb15af
|
|
4
|
+
data.tar.gz: 587bc337f5e0bbd94243d7e6cb7b4d4268a8617d913750fdf85156f0f4168c78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
361
|
+
- **state_sprawl** — more than four per class. Memoization
|
|
350
362
|
(`@x ||=`) doesn't count as state.
|
|
351
|
-
- **
|
|
352
|
-
assigns
|
|
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
|
@@ -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
|
data/lib/hashira/ci/baseline.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
class Hashira::CI::Baseline
|
|
6
|
-
SCHEMA_VERSION =
|
|
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,
|
|
41
|
-
File.write(@path, JSON.pretty_generate(payload(edges,
|
|
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,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
18
|
-
|
|
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
|
data/lib/hashira/ci/ratchet.rb
CHANGED
|
@@ -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
|
|
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::
|
|
30
|
-
Hashira::CI::
|
|
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)
|
data/lib/hashira/ci/slice.rb
CHANGED
|
@@ -9,9 +9,13 @@ class Hashira::CI::Slice
|
|
|
9
9
|
|
|
10
10
|
def edges(_diff) = SETTLED
|
|
11
11
|
|
|
12
|
-
def findings(
|
|
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
|
data/lib/hashira/ci/sweep.rb
CHANGED
|
@@ -7,7 +7,7 @@ class Hashira::CI::Sweep
|
|
|
7
7
|
|
|
8
8
|
def edges(diff) = diff
|
|
9
9
|
|
|
10
|
-
def findings(
|
|
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
|
data/lib/hashira/cli/flag.rb
CHANGED
data/lib/hashira/cli/flags.rb
CHANGED
data/lib/hashira/cli/options.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
data/lib/hashira/cli/run.rb
CHANGED
|
@@ -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)
|
|
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
|
|
@@ -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,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "rule"
|
|
4
4
|
|
|
5
|
-
class Hashira::Coupling::
|
|
5
|
+
class Hashira::Coupling::Imbalance < Hashira::Coupling::Rule
|
|
6
6
|
KIND = "sdp_violation"
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|