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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -0
- data/README.md +57 -3
- data/exe/hashira +1 -1
- data/lib/hashira/analysis/finding.rb +11 -1
- data/lib/hashira/analysis/syntax.rb +6 -6
- data/lib/hashira/analysis/type_walk.rb +4 -0
- data/lib/hashira/churn.rb +1 -10
- data/lib/hashira/ci/accepted.rb +9 -7
- data/lib/hashira/ci/baseline.rb +53 -33
- data/lib/hashira/ci/comparison.rb +33 -0
- data/lib/hashira/ci/diff.rb +0 -15
- data/lib/hashira/ci/gate.rb +3 -3
- data/lib/hashira/ci/mark.rb +7 -0
- data/lib/hashira/ci/ratchet.rb +12 -4
- data/lib/hashira/ci/ratchet_report.rb +4 -4
- data/lib/hashira/ci/scope.rb +0 -1
- data/lib/hashira/ci/slice.rb +21 -0
- data/lib/hashira/ci/sweep.rb +13 -0
- data/lib/hashira/cli/arguments.rb +12 -8
- data/lib/hashira/cli/choice.rb +9 -0
- data/lib/hashira/cli/command_line.rb +8 -5
- data/lib/hashira/cli/fail_on.rb +6 -6
- data/lib/hashira/cli/flag.rb +3 -1
- data/lib/hashira/cli/flags.rb +10 -0
- data/lib/hashira/cli/format.rb +1 -1
- data/lib/hashira/cli/needs.rb +13 -2
- data/lib/hashira/cli/only.rb +16 -0
- data/lib/hashira/cli/options.rb +8 -10
- data/lib/hashira/cli/package_by.rb +1 -1
- data/lib/hashira/cli/run.rb +9 -8
- data/lib/hashira/cli.rb +22 -21
- data/lib/hashira/complexity/cognitive_score.rb +44 -33
- data/lib/hashira/complexity/method_finding.rb +7 -5
- data/lib/hashira/complexity/method_score.rb +1 -1
- data/lib/hashira/complexity/scores.rb +11 -9
- data/lib/hashira/coupling/audiences.rb +3 -3
- data/lib/hashira/coupling/catalog.rb +8 -6
- data/lib/hashira/coupling/census.rb +42 -22
- data/lib/hashira/coupling/cycle_search.rb +11 -7
- data/lib/hashira/coupling/definitions.rb +1 -7
- data/lib/hashira/coupling/edge_map.rb +9 -8
- data/lib/hashira/coupling/folding.rb +5 -5
- data/lib/hashira/coupling/graph.rb +16 -9
- data/lib/hashira/coupling/metric.rb +3 -1
- data/lib/hashira/coupling/naming.rb +4 -4
- data/lib/hashira/coupling/references.rb +12 -12
- data/lib/hashira/coupling/report.rb +7 -9
- data/lib/hashira/coupling/roll_call.rb +1 -1
- data/lib/hashira/coupling/roll_call_findings.rb +1 -1
- data/lib/hashira/coupling/roster.rb +37 -12
- data/lib/hashira/coupling/rule.rb +1 -1
- data/lib/hashira/coupling/sdp_violation_findings.rb +3 -1
- data/lib/hashira/duplication/clones.rb +1 -1
- data/lib/hashira/duplication/clusters.rb +10 -7
- data/lib/hashira/duplication/delta.rb +1 -4
- data/lib/hashira/duplication/duplication_finding.rb +4 -2
- data/lib/hashira/duplication/fragment.rb +20 -2
- data/lib/hashira/duplication/harvest.rb +7 -5
- data/lib/hashira/duplication/index.rb +3 -2
- data/lib/hashira/duplication/literal.rb +25 -0
- data/lib/hashira/duplication/similarity.rb +2 -2
- data/lib/hashira/duplication/variance.rb +7 -1
- data/lib/hashira/error.rb +0 -3
- data/lib/hashira/focus.rb +29 -0
- data/lib/hashira/git_log.rb +19 -0
- data/lib/hashira/hotspots/file_cost.rb +1 -1
- data/lib/hashira/hotspots/rollup.rb +2 -2
- data/lib/hashira/pipeline.rb +22 -14
- data/lib/hashira/project.rb +35 -34
- data/lib/hashira/report/columns.rb +14 -8
- data/lib/hashira/report/graph_payload.rb +1 -1
- data/lib/hashira/report/hotspot_table.rb +1 -1
- data/lib/hashira/report/json.rb +2 -1
- data/lib/hashira/report/metrics_table.rb +3 -3
- data/lib/hashira/report/phrases.rb +23 -18
- data/lib/hashira/report/smell_phrases.rb +2 -2
- data/lib/hashira/smells/branches.rb +62 -0
- data/lib/hashira/smells/census.rb +11 -6
- data/lib/hashira/smells/check.rb +1 -5
- data/lib/hashira/smells/contexts.rb +7 -5
- data/lib/hashira/smells/control_parameter.rb +1 -1
- data/lib/hashira/smells/data_clump.rb +2 -4
- data/lib/hashira/smells/duplicate_method_call.rb +25 -5
- data/lib/hashira/smells/feature_envy.rb +3 -3
- data/lib/hashira/smells/foreign.rb +14 -14
- data/lib/hashira/smells/instance_variable_assumption.rb +5 -15
- data/lib/hashira/smells/kind.rb +9 -0
- data/lib/hashira/smells/lineage.rb +92 -0
- data/lib/hashira/smells/manual_dispatch.rb +1 -1
- data/lib/hashira/smells/module_initialize.rb +0 -2
- data/lib/hashira/smells/nil_check.rb +1 -1
- data/lib/hashira/smells/ownership.rb +19 -7
- data/lib/hashira/smells/param_check.rb +2 -2
- data/lib/hashira/smells/refs.rb +13 -7
- data/lib/hashira/smells/repeated_conditional.rb +1 -3
- data/lib/hashira/smells/report.rb +16 -7
- data/lib/hashira/smells/too_many_instance_variables.rb +3 -3
- data/lib/hashira/smells/visibility.rb +18 -11
- data/lib/hashira/trees.rb +8 -4
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +12 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2750af6be7a5e6885421144e92ab4c12e554ece76c110b76d5fd2b52b72a2411
|
|
4
|
+
data.tar.gz: b2ab7386f4907e297b3cfc623b535808df4f6865423bd9d4f8bae4b487b81317
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
352
|
-
assigns
|
|
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
|
@@ -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
|
|
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
|
|
20
|
+
def rooted?(node)
|
|
21
21
|
return false unless node.is_a?(Prism::ConstantPathNode)
|
|
22
22
|
parent = node.parent
|
|
23
|
-
!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(
|
|
36
|
+
def direct(type) = statements(type).grep(Prism::DefNode)
|
|
37
37
|
|
|
38
|
-
def constants(
|
|
38
|
+
def constants(type) = statements(type).grep(Prism::ConstantWriteNode)
|
|
39
39
|
|
|
40
|
-
def statements(
|
|
41
|
-
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.
|
|
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
|
data/lib/hashira/ci/accepted.rb
CHANGED
|
@@ -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.
|
|
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 =
|
|
20
|
+
def to_h = super.compact
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def self.
|
|
23
|
+
def self.build(path) = new(Hashira::CI::Baseline.new(path).accepted)
|
|
24
24
|
|
|
25
|
-
def initialize(
|
|
26
|
-
@
|
|
25
|
+
def initialize(list)
|
|
26
|
+
@list = list
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def entries =
|
|
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
|
-
|
|
41
|
+
records.find { it.matches?(finding) }&.label
|
|
40
42
|
end
|
|
41
43
|
end
|
data/lib/hashira/ci/baseline.rb
CHANGED
|
@@ -3,59 +3,79 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
class Hashira::CI::Baseline
|
|
6
|
-
SCHEMA_VERSION =
|
|
6
|
+
SCHEMA_VERSION = 5
|
|
7
7
|
|
|
8
|
-
def
|
|
9
|
-
|
|
8
|
+
def initialize(path, analyzers: [], targets: [])
|
|
9
|
+
@path = path
|
|
10
|
+
@analyzers = analyzers
|
|
11
|
+
@targets = targets
|
|
10
12
|
end
|
|
11
13
|
|
|
12
|
-
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@
|
|
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
|
-
|
|
24
|
+
def edges = recorded.fetch("edges", [])
|
|
31
25
|
|
|
32
|
-
def
|
|
26
|
+
def findings = keyed(recorded.fetch("findings", {}))
|
|
33
27
|
|
|
34
|
-
def
|
|
28
|
+
def marks = findings.to_h { |key, magnitude| [key, Hashira::CI::Mark.new(magnitude:, trace: traces[key])] }
|
|
35
29
|
|
|
36
|
-
def
|
|
30
|
+
def traces = recorded.fetch("traces", {})
|
|
37
31
|
|
|
38
|
-
def
|
|
32
|
+
def findings? = recorded.key?("findings")
|
|
39
33
|
|
|
40
|
-
def
|
|
34
|
+
def accepted = recorded.fetch("accepted", [])
|
|
41
35
|
|
|
42
|
-
def packaging =
|
|
36
|
+
def packaging = recorded.fetch("packaging", "folder")
|
|
43
37
|
|
|
44
|
-
def analyzers =
|
|
38
|
+
def analyzers = recorded.fetch("analyzers", wanted[:analyzers])
|
|
45
39
|
|
|
46
|
-
def targets =
|
|
40
|
+
def targets = recorded.fetch("targets", wanted[:targets])
|
|
47
41
|
|
|
48
|
-
def wanted =
|
|
42
|
+
def wanted = scope.to_h
|
|
49
43
|
|
|
50
|
-
def write(edges,
|
|
51
|
-
File.write(@path, JSON.pretty_generate(payload(edges,
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
data/lib/hashira/ci/diff.rb
CHANGED
|
@@ -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?
|
data/lib/hashira/ci/gate.rb
CHANGED
|
@@ -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,
|
|
4
|
+
def initialize(findings, kinds, io: $stdout, error: $stderr)
|
|
5
5
|
@findings = findings
|
|
6
6
|
@kinds = kinds
|
|
7
7
|
@io = io
|
|
8
|
-
@
|
|
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
|
-
@
|
|
22
|
+
@error.puts(verdict)
|
|
23
23
|
1
|
|
24
24
|
end
|
|
25
25
|
|
data/lib/hashira/ci/ratchet.rb
CHANGED
|
@@ -14,7 +14,11 @@ class Hashira::CI::Ratchet
|
|
|
14
14
|
Hashira::CI::Status::CLEAN
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def check
|
|
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
|
-
@
|
|
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
|
-
|
|
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: #{
|
|
21
|
+
def unchanged(tally)
|
|
22
|
+
@io.puts("Ratchet OK: #{tally}, unchanged.")
|
|
23
23
|
Hashira::CI::Status::CLEAN
|
|
24
24
|
end
|
|
25
25
|
|
data/lib/hashira/ci/scope.rb
CHANGED
|
@@ -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
|