hashira 0.7.0 → 0.8.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 +38 -0
- data/README.md +29 -0
- data/exe/hashira +1 -1
- data/lib/hashira/analysis/finding.rb +1 -1
- data/lib/hashira/analysis/syntax.rb +6 -6
- data/lib/hashira/churn.rb +1 -10
- data/lib/hashira/ci/accepted.rb +9 -7
- data/lib/hashira/ci/baseline.rb +34 -30
- data/lib/hashira/ci/comparison.rb +24 -0
- data/lib/hashira/ci/diff.rb +0 -15
- data/lib/hashira/ci/gate.rb +3 -3
- data/lib/hashira/ci/ratchet.rb +10 -4
- data/lib/hashira/ci/ratchet_report.rb +4 -4
- data/lib/hashira/ci/scope.rb +0 -1
- data/lib/hashira/ci/slice.rb +17 -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 -1
- 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 +2 -2
- data/lib/hashira/duplication/harvest.rb +7 -5
- data/lib/hashira/duplication/index.rb +3 -2
- data/lib/hashira/duplication/similarity.rb +2 -2
- data/lib/hashira/duplication/variance.rb +1 -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/smells/census.rb +3 -4
- data/lib/hashira/smells/check.rb +1 -5
- data/lib/hashira/smells/contexts.rb +4 -4
- 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 +3 -3
- 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 +3 -3
- data/lib/hashira/smells/kind.rb +9 -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 +8 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 137dafce03e707a097cf684620fbd9c9efc8989347e77b88c13da103102f5da5
|
|
4
|
+
data.tar.gz: e0ab59d2e1909042c991764b5384217601aaf42b036bd6916042efc2107e0200
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c4895c6d045e05e610fda4c51ce763e0ef876049d933dbd4a3d9f99ac24c8648583a4a3f39e5c2baadfae263736914ba2a428781024ead0410b2d968585b8b8
|
|
7
|
+
data.tar.gz: dec6a978383a76ad57fdf34c98b5ea4b571775d44baafc0f5c02c3bbe75b7a56e274609c1586fc77af94e93f65f501579a5b6fa09bfd4db3952bb3cb412c153b
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ 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.8.0] - 2026-08-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`--only PATHS` narrows the findings to the files you name.** Meant for
|
|
13
|
+
hooks: after a formatter, a refactor, or an agent's edit, ask whether *these*
|
|
14
|
+
files got worse — `hashira --only "$CHANGED" --ratchet`. The whole project is
|
|
15
|
+
still parsed, because half of what hashira knows is cross-file (which
|
|
16
|
+
constants are yours, which methods reach into a neighbour, which fragments
|
|
17
|
+
are clones); reading one file alone would answer differently. A focused
|
|
18
|
+
ratchet stays quiet about package edges, which belong to no single file, and
|
|
19
|
+
about findings that disappeared, which only a whole-project run can confirm —
|
|
20
|
+
it reports what your files introduced or made worse. `--only` refuses
|
|
21
|
+
`--update-baseline` and the diagram formats, and ignores paths outside the
|
|
22
|
+
analyzed directories so a hook can hand it every changed file.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **Memoization stops reading as state.** An instance variable named `@_thing`
|
|
27
|
+
is a cache, not a responsibility: `instance_variable_assumption` no longer
|
|
28
|
+
reports lazy presence as an assumption, and `too_many_instance_variables`
|
|
29
|
+
no longer counts derived values against the class. Codebases that memoize
|
|
30
|
+
behind the `@_` convention will see both smells quieten; codebases that
|
|
31
|
+
don't are unaffected.
|
|
32
|
+
- **Every object is built the way this tool says to build one.** Constructors
|
|
33
|
+
only assign, class-method logic dissolves into instances (`Project.detect`
|
|
34
|
+
and `CLI.run` are plain constructors — `exe/hashira` now calls
|
|
35
|
+
`CLI.new(argv).status`), factories are named `build`, and hashes acting as
|
|
36
|
+
objects got names (`SdpViolationFindings::Imbalance`,
|
|
37
|
+
`DuplicationFinding::Overlap`, `MethodFinding::Effort`). Internal
|
|
38
|
+
throughout: the command line, the reports, the JSON, and the baseline
|
|
39
|
+
format are unchanged. Only embedders calling the Ruby API directly are
|
|
40
|
+
affected.
|
|
41
|
+
- Coupling reads its rule list from `Rule.subclasses`, the way the smells
|
|
42
|
+
report already read `Check.subclasses`, and parameters stop carrying their
|
|
43
|
+
node type (`def_node` → `definition`). hashira's own baseline is down to
|
|
44
|
+
zero findings and one accepted boundary.
|
|
45
|
+
|
|
8
46
|
## [0.7.0] - 2026-08-10
|
|
9
47
|
|
|
10
48
|
### Added
|
data/README.md
CHANGED
|
@@ -476,6 +476,35 @@ record the decision: update the baseline, or accept it with a reason.
|
|
|
476
476
|
Improvements fail the build too, and say so cheerfully — an unrecorded gain is one
|
|
477
477
|
the next commit can quietly undo. Re-run `--update-baseline` to lock it in.
|
|
478
478
|
|
|
479
|
+
### Hooks: ratchet the files you just touched
|
|
480
|
+
|
|
481
|
+
`--only` narrows the findings to the files you name, so an editor hook, a
|
|
482
|
+
pre-commit hook, or an agent finishing an edit can ask the one question that
|
|
483
|
+
matters there: *did these files get worse?*
|
|
484
|
+
|
|
485
|
+
```sh
|
|
486
|
+
hashira --only lib/billing/refund.rb,lib/orders/checkout.rb --ratchet
|
|
487
|
+
hashira --only "$(git diff --cached --name-only -- '*.rb' | paste -sd, -)" --ratchet
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
The whole project is still parsed — that is the point. Half of what hashira knows
|
|
491
|
+
is cross-file (which constants are yours, which methods pick apart a neighbour's
|
|
492
|
+
internals, which fragments are clones of each other), so a file read alone would
|
|
493
|
+
answer differently. `--only` narrows the *report*, never the analysis. On this
|
|
494
|
+
repository a full run is under a second for 129 files; smells alone, a quarter of
|
|
495
|
+
that.
|
|
496
|
+
|
|
497
|
+
Two things a focused run cannot judge, and so stays quiet about: package edges,
|
|
498
|
+
which belong to no single file, and findings that *disappeared*, which you would
|
|
499
|
+
have to read the whole project to be sure of. It reports what your files
|
|
500
|
+
introduced or made worse, and nothing else. Run the unfocused `--ratchet` in CI —
|
|
501
|
+
that is where removals get celebrated and the baseline gets relocked.
|
|
502
|
+
|
|
503
|
+
`--only` refuses to combine with `--update-baseline` (which would record a
|
|
504
|
+
baseline missing everything you did not name) or with the diagram formats (which
|
|
505
|
+
draw the graph, not the findings). Paths outside the analyzed directories are
|
|
506
|
+
ignored, so a hook can hand it every changed file without filtering first.
|
|
507
|
+
|
|
479
508
|
### Exit codes
|
|
480
509
|
|
|
481
510
|
A build step should be able to tell a regression from a typo without grepping
|
data/exe/hashira
CHANGED
|
@@ -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
|
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
|
@@ -5,47 +5,37 @@ require "json"
|
|
|
5
5
|
class Hashira::CI::Baseline
|
|
6
6
|
SCHEMA_VERSION = 4
|
|
7
7
|
|
|
8
|
-
def
|
|
9
|
-
new(path, read(path), Hashira::CI::Scope.new(analyzers:, targets:))
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def self.read(path)
|
|
13
|
-
return {} unless path && File.exist?(path)
|
|
14
|
-
recorded = JSON.parse(File.read(path))
|
|
15
|
-
recorded.is_a?(Hash) ? recorded : raise(JSON::ParserError, "its top level is a list, not an object")
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def self.trouble(path)
|
|
19
|
-
read(path) && nil
|
|
20
|
-
rescue JSON::ParserError, SystemCallError => error
|
|
21
|
-
"#{path} is not a usable baseline — #{error.message.lines.first.strip}. Re-record it with --update-baseline"
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def initialize(path, recorded, scope = Hashira::CI::Scope.none)
|
|
8
|
+
def initialize(path, analyzers: [], targets: [])
|
|
25
9
|
@path = path
|
|
26
|
-
@
|
|
27
|
-
@
|
|
10
|
+
@analyzers = analyzers
|
|
11
|
+
@targets = targets
|
|
28
12
|
end
|
|
29
13
|
|
|
30
14
|
attr_reader :path
|
|
31
15
|
|
|
32
16
|
def exist? = File.exist?(@path)
|
|
33
17
|
|
|
34
|
-
def
|
|
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"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def edges = recorded.fetch("edges", [])
|
|
35
25
|
|
|
36
|
-
def findings =
|
|
26
|
+
def findings = keyed(recorded.fetch("findings", {}))
|
|
37
27
|
|
|
38
|
-
def
|
|
28
|
+
def findings? = recorded.key?("findings")
|
|
39
29
|
|
|
40
|
-
def
|
|
30
|
+
def accepted = recorded.fetch("accepted", [])
|
|
41
31
|
|
|
42
|
-
def packaging =
|
|
32
|
+
def packaging = recorded.fetch("packaging", "folder")
|
|
43
33
|
|
|
44
|
-
def analyzers =
|
|
34
|
+
def analyzers = recorded.fetch("analyzers", wanted[:analyzers])
|
|
45
35
|
|
|
46
|
-
def targets =
|
|
36
|
+
def targets = recorded.fetch("targets", wanted[:targets])
|
|
47
37
|
|
|
48
|
-
def wanted =
|
|
38
|
+
def wanted = scope.to_h
|
|
49
39
|
|
|
50
40
|
def write(edges, findings, packaging:)
|
|
51
41
|
File.write(@path, JSON.pretty_generate(payload(edges, findings, packaging)) << "\n")
|
|
@@ -53,9 +43,23 @@ class Hashira::CI::Baseline
|
|
|
53
43
|
|
|
54
44
|
private
|
|
55
45
|
|
|
46
|
+
def recorded
|
|
47
|
+
@_recorded ||= read
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def read
|
|
51
|
+
return {} unless @path && File.exist?(@path)
|
|
52
|
+
stored = JSON.parse(File.read(@path))
|
|
53
|
+
stored.is_a?(Hash) ? stored : raise(JSON::ParserError, "its top level is a list, not an object")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def keyed(findings) = findings.is_a?(Array) ? findings.to_h { [it, nil] } : findings
|
|
57
|
+
|
|
58
|
+
def scope = Hashira::CI::Scope.new(analyzers: @analyzers, targets: @targets)
|
|
59
|
+
|
|
56
60
|
def payload(edges, findings, packaging)
|
|
57
|
-
base = { version: SCHEMA_VERSION, packaging
|
|
58
|
-
|
|
59
|
-
|
|
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)
|
|
60
64
|
end
|
|
61
65
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
current = @current.keys
|
|
11
|
+
recorded = @recorded.keys
|
|
12
|
+
Hashira::CI::Diff.new(added: current - recorded, removed: recorded - current, worsened:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def worsened
|
|
18
|
+
(@current.keys & @recorded.keys).filter_map { entry(it, @recorded[it], @current[it]) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def entry(key, before, after)
|
|
22
|
+
[key, before, after] if before.is_a?(Integer) && after.is_a?(Integer) && after > before
|
|
23
|
+
end
|
|
24
|
+
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,17 @@ 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, group.filter_map(&:magnitude).max] }
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
|
|
64
70
|
|
|
65
71
|
def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
|
|
66
72
|
|
|
67
|
-
def delta
|
|
68
|
-
|
|
73
|
+
def delta(compared)
|
|
74
|
+
compared.findings(scored) if @baseline.findings?
|
|
69
75
|
end
|
|
70
76
|
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,17 @@
|
|
|
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(scored) = Hashira::CI::Comparison.new(scored, seen(scored)).diff
|
|
13
|
+
|
|
14
|
+
def seen(scored) = @baseline.findings.slice(*scored.keys)
|
|
15
|
+
|
|
16
|
+
def counts(_edges, findings) = [findings]
|
|
17
|
+
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(scored) = Hashira::CI::Comparison.new(scored, @baseline.findings).diff
|
|
11
|
+
|
|
12
|
+
def counts(edges, findings) = [edges, findings]
|
|
13
|
+
end
|
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI::Arguments
|
|
4
|
-
def initialize(
|
|
5
|
-
@
|
|
4
|
+
def initialize(given)
|
|
5
|
+
@given = given
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def take(flag, default)
|
|
9
|
-
position =
|
|
9
|
+
position = argv.index(flag)
|
|
10
10
|
return default unless position
|
|
11
|
-
_flag, value =
|
|
11
|
+
_flag, value = argv.slice!(position, 2)
|
|
12
12
|
vet(flag, value)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def vet(flag, value)
|
|
16
16
|
raise(Hashira::Error, "#{flag} needs a value") if value.to_s.strip.empty?
|
|
17
|
-
raise(Hashira::Error, "#{flag} given more than once") if
|
|
17
|
+
raise(Hashira::Error, "#{flag} given more than once") if argv.include?(flag)
|
|
18
18
|
value
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def delete(flag) =
|
|
21
|
+
def delete(flag) = argv.delete(flag)
|
|
22
22
|
|
|
23
23
|
def rest
|
|
24
|
-
stray =
|
|
24
|
+
stray = argv.find { it.start_with?("-") }
|
|
25
25
|
raise(Hashira::Error, "unknown option #{stray}") if stray
|
|
26
|
-
|
|
26
|
+
argv
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def argv = @_argv ||= @given.dup
|
|
28
32
|
end
|
|
@@ -2,23 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI::CommandLine
|
|
4
4
|
def initialize(argv)
|
|
5
|
-
@
|
|
5
|
+
@argv = argv
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def options = page || parsed
|
|
9
9
|
|
|
10
10
|
private
|
|
11
11
|
|
|
12
|
+
def arguments = @_arguments ||= Hashira::CLI::Arguments.new(@argv)
|
|
13
|
+
|
|
12
14
|
def page
|
|
13
15
|
flag = Hashira::CLI::FLAGS.select(&:page?).find { seen?(it) }
|
|
14
|
-
Hashira::CLI::Options.
|
|
16
|
+
Hashira::CLI::Options.build(flag.mode) if flag
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
def seen?(flag) = flag.names.any? {
|
|
19
|
+
def seen?(flag) = flag.names.any? { arguments.delete(it) }
|
|
18
20
|
|
|
19
21
|
def parsed
|
|
20
|
-
parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(
|
|
21
|
-
Hashira::CLI::Options.new(directories:
|
|
22
|
+
parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(arguments)] }
|
|
23
|
+
Hashira::CLI::Options.new(directories: arguments.rest, mode: mode(parses), **fields(parses))
|
|
24
|
+
.tap { Hashira::CLI::Needs.check(it) }
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def fields(parses) = parses.to_h { |flag, value| [flag.field, value] }.except(nil)
|
data/lib/hashira/cli/fail_on.rb
CHANGED
|
@@ -5,12 +5,12 @@ require_relative "../pipeline"
|
|
|
5
5
|
module Hashira::CLI::FailOn
|
|
6
6
|
MEASURES = (Hashira::Pipeline::ANALYZERS - %i[coupling smells]).map(&:to_s).freeze
|
|
7
7
|
|
|
8
|
-
KINDS = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
KINDS = { "cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication" }
|
|
9
|
+
.merge(Hashira::Pipeline::STRUCTURAL.to_h { [it, it] })
|
|
10
|
+
.merge(MEASURES.to_h { [it, it] })
|
|
11
|
+
.merge("smells" => Hashira::Pipeline::SMELLS)
|
|
12
|
+
.merge(Hashira::Pipeline::SMELLS.to_h { [it, it] })
|
|
13
|
+
.freeze
|
|
14
14
|
|
|
15
15
|
OWNERS = {
|
|
16
16
|
**Hashira::Pipeline::STRUCTURAL.to_h { [it, :coupling] },
|
data/lib/hashira/cli/flag.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI
|
|
4
|
+
BLANK = %i[arg field parse mode].to_h { [it, nil] }.freeze
|
|
5
|
+
|
|
4
6
|
Flag =
|
|
5
7
|
Data.define(:name, :arg, :default, :field, :parse, :mode, :text) do
|
|
6
|
-
def initialize(**attributes) = super(
|
|
8
|
+
def initialize(**attributes) = super(**BLANK, default: "", **attributes)
|
|
7
9
|
|
|
8
10
|
def read(arguments) = arg ? take(arguments) : arguments.delete(name)
|
|
9
11
|
|
data/lib/hashira/cli/flags.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "fail_on"
|
|
4
4
|
require_relative "flag"
|
|
5
5
|
require_relative "format"
|
|
6
|
+
require_relative "only"
|
|
6
7
|
require_relative "package_by"
|
|
7
8
|
require_relative "skip"
|
|
8
9
|
require_relative "top"
|
|
@@ -31,6 +32,15 @@ class Hashira::CLI
|
|
|
31
32
|
name: "--skip", arg: "ANALYZERS", field: :skip, parse: Skip,
|
|
32
33
|
text: ["drop an analyzer; comma-separated: coupling,", "complexity, duplication, smells"]
|
|
33
34
|
),
|
|
35
|
+
Flag.new(
|
|
36
|
+
name: "--only", arg: "PATHS", field: :only, parse: Only,
|
|
37
|
+
text: [
|
|
38
|
+
"keep only the findings that name these files;",
|
|
39
|
+
"comma-separated. The whole project is still read,",
|
|
40
|
+
"so cross-file signals stay right. Meant for hooks:",
|
|
41
|
+
"hashira --only $CHANGED --ratchet"
|
|
42
|
+
]
|
|
43
|
+
),
|
|
34
44
|
Flag.new(
|
|
35
45
|
name: "--top", arg: "N", field: :top, parse: Top,
|
|
36
46
|
text: [
|
data/lib/hashira/cli/format.rb
CHANGED
data/lib/hashira/cli/needs.rb
CHANGED
|
@@ -12,10 +12,21 @@ module Hashira::CLI::Needs
|
|
|
12
12
|
skip = options.skip
|
|
13
13
|
drawing(mode, skip)
|
|
14
14
|
gate(options.fail_on, skip)
|
|
15
|
-
shaping(mode)
|
|
15
|
+
shaping(options, mode)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def shaping(mode)
|
|
18
|
+
def shaping(options, mode)
|
|
19
|
+
compacting(mode) if options.compact
|
|
20
|
+
focusing(mode) unless options.only.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def focusing(mode)
|
|
24
|
+
raise(Hashira::Error, "--only narrows the findings, but --update-baseline records them all") if mode == :update
|
|
25
|
+
return unless DIAGRAMS.include?(mode)
|
|
26
|
+
raise(Hashira::Error, "--format #{mode} draws the coupling graph, which --only cannot narrow")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def compacting(mode)
|
|
19
30
|
raise(Hashira::Error, "--compact shapes JSON, but this run emits #{mode}") unless mode == :json
|
|
20
31
|
end
|
|
21
32
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::CLI::Only
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def parse(value)
|
|
7
|
+
return [] if value.empty?
|
|
8
|
+
value.split(",").map { vet(it.strip) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def vet(path)
|
|
12
|
+
here = path.delete_prefix("#{Dir.pwd}/").delete_prefix("./")
|
|
13
|
+
raise(Hashira::Error, "--only #{path.inspect} is not a file here") unless File.file?(here)
|
|
14
|
+
here
|
|
15
|
+
end
|
|
16
|
+
end
|