linecounter 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +99 -0
- data/exe/linecounter +7 -0
- data/lib/linecounter/analyzer.rb +59 -0
- data/lib/linecounter/branch_analyzer.rb +118 -0
- data/lib/linecounter/cli.rb +66 -0
- data/lib/linecounter/git.rb +51 -0
- data/lib/linecounter/report.rb +108 -0
- data/lib/linecounter/scanner.rb +21 -0
- data/lib/linecounter/structure_analyzer.rb +223 -0
- data/lib/linecounter/version.rb +3 -0
- data/lib/linecounter.rb +8 -0
- metadata +105 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '0516098ec3bef60429834f965c4bbbd69a1a7f2200ea5b28a0fab21efe102b11'
|
|
4
|
+
data.tar.gz: f7c0de1fb06f0e9801bf7e2b77e0b626274fa99165675d87d5f89da3bc7a5df0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 74d6fbfa03a53a8a5828c795d17d062c96b61acc05f893523872099a55e70dcb236878c27b102282894cc338074058308ce250dfc465a3a772743af8e82e3ee4
|
|
7
|
+
data.tar.gz: '058634de3a663e430475736115c16251f09edc135eafc9d358acfe27cbea9c42dcc10e10f9b8fa85965120df4ae4b4a128e6910b498dda833b085c12e2d941f8'
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Hopman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# linecounter
|
|
2
|
+
|
|
3
|
+
`linecounter` lists Ruby files with lines of code, churn, branching, and avg loc per item.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install the gem:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install linecounter
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add it to a Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "linecounter"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
From a checkout you can run it without installing:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
ruby -Ilib exe/linecounter --repo /path/to/repo
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
linecounter [options]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Options
|
|
32
|
+
|
|
33
|
+
- `--top N` Show top N rows (default: 20).
|
|
34
|
+
- `--since STR` Limit churn to commits since date. Supports git-parseable dates in `YYYY-MM-DD` (e.g., `2025-01-01`), other git-parseable strings (e.g., `last friday`), and relative forms: `N.days.ago`, `N.weeks.ago`, `N.hours.ago`, `N.months.ago`, `N.years.ago`, plus `today`/`yesterday`.
|
|
35
|
+
- `--min-loc N` Exclude files below N non-empty lines (default: 20).
|
|
36
|
+
- `--repo PATH` Path to a git repo to scan (default: current directory).
|
|
37
|
+
- `--json` Output JSON instead of text.
|
|
38
|
+
- `--show-branch-count` Show per-branch keyword breakdown under each file.
|
|
39
|
+
- `--show-structure-overview` Show a summary of class structure counts across all files, including `avg_loc_per_item` (avg statement lines per item).
|
|
40
|
+
- `--show-interaction-overview` Alias for `--show-structure-overview`.
|
|
41
|
+
- `--detailed-structure` Show overall structure averages (avg lines per item) for each regex item across all files.
|
|
42
|
+
- `-h`, `--help` Show help.
|
|
43
|
+
|
|
44
|
+
## Examples
|
|
45
|
+
|
|
46
|
+
`--repo` defaults to the current directory, so running `linecounter` with no
|
|
47
|
+
arguments scans the repo you're in. If the directory isn't a git repository it
|
|
48
|
+
exits with an error.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
linecounter
|
|
52
|
+
linecounter --repo /path/to/repo
|
|
53
|
+
linecounter --repo /path/to/repo --top 50
|
|
54
|
+
linecounter --repo /path/to/repo --since 2025-01-01
|
|
55
|
+
linecounter --repo /path/to/repo --since 2.weeks.ago
|
|
56
|
+
linecounter --repo /path/to/repo --min-loc 50
|
|
57
|
+
linecounter --repo /path/to/repo --show-branch-count
|
|
58
|
+
linecounter --repo /path/to/repo --show-structure-overview
|
|
59
|
+
linecounter --repo /path/to/repo --detailed-structure
|
|
60
|
+
linecounter --repo /path/to/repo --json
|
|
61
|
+
linecounter --repo /path/to/repo --top 30 --since 3.months.ago --show-structure-overview
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Example Output
|
|
65
|
+
|
|
66
|
+
Signals are computed from the parsed AST (via [Prism](https://github.com/ruby/prism)),
|
|
67
|
+
so keywords in strings or comments are never miscounted and `avg_loc_per_item`
|
|
68
|
+
reflects real definition length. Churn varies with git history, so your numbers
|
|
69
|
+
will differ:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
$ linecounter --repo . --min-loc 80 --detailed-structure
|
|
73
|
+
Ruby Quality Signals
|
|
74
|
+
Files scanned: 4
|
|
75
|
+
|
|
76
|
+
Column descriptions:
|
|
77
|
+
Churn = total git commits touching the file (optionally since --since).
|
|
78
|
+
Branches = count of control-flow tokens (sum of per-keyword counts).
|
|
79
|
+
LOC = non-empty lines of code in the file.
|
|
80
|
+
File = repository-relative path.
|
|
81
|
+
|
|
82
|
+
Churn Branches LOC File
|
|
83
|
+
3 34 189 lib/linecounter/structure_analyzer.rb
|
|
84
|
+
3 0 161 test/unit/structure_analyzer_test.rb
|
|
85
|
+
2 13 99 lib/linecounter/report.rb
|
|
86
|
+
1 3 99 lib/linecounter/branch_analyzer.rb
|
|
87
|
+
|
|
88
|
+
Detailed structure (all scanned files):
|
|
89
|
+
constants
|
|
90
|
+
CONSTANT = count=11 avg_loc_per_item=8.27
|
|
91
|
+
public_attribute_macros
|
|
92
|
+
public attr_reader count=2 avg_loc_per_item=1.00
|
|
93
|
+
initializer
|
|
94
|
+
initialize count=2 avg_loc_per_item=7.00
|
|
95
|
+
public_methods
|
|
96
|
+
public def count=38 avg_loc_per_item=8.53
|
|
97
|
+
private_methods
|
|
98
|
+
private def count=8 avg_loc_per_item=9.75
|
|
99
|
+
```
|
data/exe/linecounter
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require_relative "scanner"
|
|
2
|
+
require_relative "git"
|
|
3
|
+
require_relative "branch_analyzer"
|
|
4
|
+
require_relative "structure_analyzer"
|
|
5
|
+
|
|
6
|
+
module Linecounter
|
|
7
|
+
Result = Struct.new(
|
|
8
|
+
:rows,
|
|
9
|
+
:structure_overview,
|
|
10
|
+
:structure_item_loc_overview,
|
|
11
|
+
:structure_item_counts_overview,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
module Analyzer
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def run(repo_path:, min_loc:, since:)
|
|
19
|
+
rows = []
|
|
20
|
+
structure_overview = Hash.new(0)
|
|
21
|
+
structure_item_loc_overview = Hash.new(0)
|
|
22
|
+
structure_item_counts_overview = Hash.new(0)
|
|
23
|
+
|
|
24
|
+
Scanner.ruby_files(repo_path).each do |file|
|
|
25
|
+
content = File.read(File.join(repo_path, file)) rescue next
|
|
26
|
+
size = Scanner.loc(content)
|
|
27
|
+
next if size < min_loc
|
|
28
|
+
|
|
29
|
+
breakdown = BranchAnalyzer.breakdown(content)
|
|
30
|
+
b = breakdown.values.sum
|
|
31
|
+
c = Git.churn(repo_path, file, since)
|
|
32
|
+
structures, structure_item_counts, structure_item_loc = StructureAnalyzer.counts(content)
|
|
33
|
+
structures.each { |k, v| structure_overview[k] += v }
|
|
34
|
+
structure_item_loc.each { |k, v| structure_item_loc_overview[k] += v }
|
|
35
|
+
structure_item_counts.each { |k, v| structure_item_counts_overview[k] += v }
|
|
36
|
+
|
|
37
|
+
rows << {
|
|
38
|
+
file: file,
|
|
39
|
+
loc: size,
|
|
40
|
+
branches: b,
|
|
41
|
+
branch_breakdown: breakdown,
|
|
42
|
+
structures: structures,
|
|
43
|
+
structure_item_counts: structure_item_counts,
|
|
44
|
+
structure_item_loc: structure_item_loc,
|
|
45
|
+
churn: c
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
rows.sort_by! { |r| [-r[:churn], -r[:branches], -r[:loc]] }
|
|
50
|
+
|
|
51
|
+
Result.new(
|
|
52
|
+
rows: rows,
|
|
53
|
+
structure_overview: structure_overview,
|
|
54
|
+
structure_item_loc_overview: structure_item_loc_overview,
|
|
55
|
+
structure_item_counts_overview: structure_item_counts_overview
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module Linecounter
|
|
4
|
+
module BranchAnalyzer
|
|
5
|
+
# Buckets of control-flow constructs, in display order. Counted from the
|
|
6
|
+
# parsed AST, so keywords inside strings and comments are never counted.
|
|
7
|
+
BRANCH_TOKENS = [
|
|
8
|
+
[:if, "if condition"],
|
|
9
|
+
[:elsif, "elsif branch"],
|
|
10
|
+
[:unless, "unless condition"],
|
|
11
|
+
[:case, "case expression"],
|
|
12
|
+
[:when, "when branch"],
|
|
13
|
+
[:while, "while loop"],
|
|
14
|
+
[:until, "until loop"],
|
|
15
|
+
[:rescue, "rescue handler"],
|
|
16
|
+
[:ensure, "ensure block"],
|
|
17
|
+
[:begin, "begin block"],
|
|
18
|
+
[:and, "logical AND (&& / and)"],
|
|
19
|
+
[:or, "logical OR (|| / or)"],
|
|
20
|
+
[:ternary, "ternary operator (?:)"],
|
|
21
|
+
[:return, "return statement"]
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
class CountingVisitor < Prism::Visitor
|
|
25
|
+
attr_reader :counts
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
super
|
|
29
|
+
@counts = Hash.new(0)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def visit_if_node(node)
|
|
33
|
+
key =
|
|
34
|
+
if node.if_keyword_loc.nil? then :ternary
|
|
35
|
+
elsif node.if_keyword_loc.slice == "elsif" then :elsif
|
|
36
|
+
else :if
|
|
37
|
+
end
|
|
38
|
+
@counts[key] += 1
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def visit_unless_node(node)
|
|
43
|
+
@counts[:unless] += 1
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def visit_case_node(node)
|
|
48
|
+
@counts[:case] += 1
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def visit_case_match_node(node)
|
|
53
|
+
@counts[:case] += 1
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def visit_when_node(node)
|
|
58
|
+
@counts[:when] += 1
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def visit_in_node(node)
|
|
63
|
+
@counts[:when] += 1
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def visit_while_node(node)
|
|
68
|
+
@counts[:while] += 1
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def visit_until_node(node)
|
|
73
|
+
@counts[:until] += 1
|
|
74
|
+
super
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def visit_rescue_node(node)
|
|
78
|
+
@counts[:rescue] += 1
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def visit_ensure_node(node)
|
|
83
|
+
@counts[:ensure] += 1
|
|
84
|
+
super
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def visit_begin_node(node)
|
|
88
|
+
# Only explicit `begin ... end`; method/block-level rescue produces an
|
|
89
|
+
# implicit BeginNode (no begin keyword) that we don't count.
|
|
90
|
+
@counts[:begin] += 1 if node.begin_keyword_loc
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def visit_and_node(node)
|
|
95
|
+
@counts[:and] += 1
|
|
96
|
+
super
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def visit_or_node(node)
|
|
100
|
+
@counts[:or] += 1
|
|
101
|
+
super
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def visit_return_node(node)
|
|
105
|
+
@counts[:return] += 1
|
|
106
|
+
super
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
module_function
|
|
111
|
+
|
|
112
|
+
def breakdown(content)
|
|
113
|
+
visitor = CountingVisitor.new
|
|
114
|
+
Prism.parse(content).value.accept(visitor)
|
|
115
|
+
BRANCH_TOKENS.each_with_object({}) { |(name, _label), acc| acc[name] = visitor.counts[name] }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require_relative "git"
|
|
3
|
+
require_relative "analyzer"
|
|
4
|
+
require_relative "report"
|
|
5
|
+
|
|
6
|
+
module Linecounter
|
|
7
|
+
module CLI
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
top: 20,
|
|
10
|
+
since: nil,
|
|
11
|
+
json: false,
|
|
12
|
+
min_loc: 20,
|
|
13
|
+
show_branch_count: false,
|
|
14
|
+
show_structure_overview: false,
|
|
15
|
+
show_detailed_structure: false,
|
|
16
|
+
repo: nil
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def run(argv)
|
|
22
|
+
options = DEFAULTS.dup
|
|
23
|
+
build_parser(options).parse!(argv)
|
|
24
|
+
|
|
25
|
+
options[:repo] ||= "."
|
|
26
|
+
options[:since] = Git.parse_since(options[:since])
|
|
27
|
+
|
|
28
|
+
repo_path = File.expand_path(options[:repo])
|
|
29
|
+
abort "Not inside a git repository: #{repo_path}" unless Git.repo?(repo_path)
|
|
30
|
+
|
|
31
|
+
result = Analyzer.run(repo_path: repo_path, min_loc: options[:min_loc], since: options[:since])
|
|
32
|
+
Report.render(result, options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_parser(options)
|
|
36
|
+
OptionParser.new do |o|
|
|
37
|
+
o.banner = "Usage: linecounter [options]"
|
|
38
|
+
o.separator ""
|
|
39
|
+
o.separator "Options:"
|
|
40
|
+
o.on("--top N", Integer, "Show top N rows (default: 20).") { |v| options[:top] = v }
|
|
41
|
+
o.on("--since STR", String, "Limit churn to commits since date. Supports git-parseable dates in YYYY-MM-DD (e.g., '2025-01-01'), other git-parseable strings (e.g., 'last friday'), and relative forms: 'N.days.ago', 'N.weeks.ago', 'N.hours.ago', 'N.months.ago', 'N.years.ago', plus 'today'/'yesterday'.") { |v| options[:since] = v }
|
|
42
|
+
o.on("--min-loc N", Integer, "Exclude files below N non-empty lines (default: 20).") { |v| options[:min_loc] = v }
|
|
43
|
+
o.on("--repo PATH", String, "Path to a git repo to scan (default: current directory).") { |v| options[:repo] = v }
|
|
44
|
+
o.on("--json", "Output JSON instead of text.") { options[:json] = true }
|
|
45
|
+
o.on("--show-branch-count", "Show per-branch keyword breakdown under each file.") { options[:show_branch_count] = true }
|
|
46
|
+
o.on("--show-structure-overview", "Show a summary of class structure counts across all files, including avg_loc_per_item (avg statement lines per item).") { options[:show_structure_overview] = true }
|
|
47
|
+
o.on("--show-interaction-overview", "Alias for --show-structure-overview.") { options[:show_structure_overview] = true }
|
|
48
|
+
o.on("--detailed-structure", "Show overall structure averages (avg lines per item) for each regex item across all files.") { options[:show_detailed_structure] = true }
|
|
49
|
+
o.on("-h", "--help", "Show this help.") { puts o; exit }
|
|
50
|
+
o.separator ""
|
|
51
|
+
o.separator "Examples:"
|
|
52
|
+
o.separator " linecounter"
|
|
53
|
+
o.separator " linecounter --repo /path/to/repo"
|
|
54
|
+
o.separator " linecounter --repo /path/to/repo --top 50"
|
|
55
|
+
o.separator " linecounter --repo /path/to/repo --since 2025-01-01"
|
|
56
|
+
o.separator " linecounter --repo /path/to/repo --since 2.weeks.ago"
|
|
57
|
+
o.separator " linecounter --repo /path/to/repo --min-loc 50"
|
|
58
|
+
o.separator " linecounter --repo /path/to/repo --show-branch-count"
|
|
59
|
+
o.separator " linecounter --repo /path/to/repo --show-structure-overview"
|
|
60
|
+
o.separator " linecounter --repo /path/to/repo --detailed-structure"
|
|
61
|
+
o.separator " linecounter --repo /path/to/repo --json"
|
|
62
|
+
o.separator " linecounter --repo /path/to/repo --top 30 --since 3.months.ago"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Linecounter
|
|
5
|
+
module Git
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def run(*cmd)
|
|
9
|
+
stdout, _, ok = Open3.capture3(*cmd)
|
|
10
|
+
ok ? stdout : ""
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def repo?(repo_path)
|
|
14
|
+
system("git", "-C", repo_path, "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def churn(repo_path, file, since)
|
|
18
|
+
cmd = ["git", "-C", repo_path, "log", "--follow", "--pretty=oneline"]
|
|
19
|
+
cmd += ["--since", since] if since
|
|
20
|
+
cmd += ["--", file]
|
|
21
|
+
run(*cmd).lines.count
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse_since(str)
|
|
25
|
+
return nil if str.nil?
|
|
26
|
+
return str if str.strip.empty?
|
|
27
|
+
|
|
28
|
+
normalized = str.strip.downcase
|
|
29
|
+
if (m = normalized.match(/\A(\d+)\.(days?|weeks?|months?|years?|hours?)\.ago\z/))
|
|
30
|
+
count = m[1].to_i
|
|
31
|
+
unit = m[2]
|
|
32
|
+
seconds =
|
|
33
|
+
case unit
|
|
34
|
+
when "day", "days" then 86_400
|
|
35
|
+
when "week", "weeks" then 7 * 86_400
|
|
36
|
+
when "hour", "hours" then 3_600
|
|
37
|
+
when "month", "months" then 30 * 86_400
|
|
38
|
+
when "year", "years" then 365 * 86_400
|
|
39
|
+
else 0
|
|
40
|
+
end
|
|
41
|
+
return (Time.now - (count * seconds)).utc.iso8601
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
case normalized
|
|
45
|
+
when "today" then Time.now.utc.iso8601
|
|
46
|
+
when "yesterday" then (Time.now - 86_400).utc.iso8601
|
|
47
|
+
else str
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
require_relative "branch_analyzer"
|
|
4
|
+
require_relative "structure_analyzer"
|
|
5
|
+
|
|
6
|
+
module Linecounter
|
|
7
|
+
module Report
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def render(result, options)
|
|
11
|
+
if options[:json]
|
|
12
|
+
json(result, options)
|
|
13
|
+
else
|
|
14
|
+
text(result, options)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def json(result, options)
|
|
19
|
+
rows = result.rows
|
|
20
|
+
payload = {
|
|
21
|
+
generated_at: Time.now.utc.iso8601,
|
|
22
|
+
files_scanned: rows.size,
|
|
23
|
+
top: rows.first(options[:top])
|
|
24
|
+
}
|
|
25
|
+
unless options[:show_branch_count]
|
|
26
|
+
payload[:top] = payload[:top].map { |r| r.reject { |k, _| k == :branch_breakdown } }
|
|
27
|
+
end
|
|
28
|
+
if options[:show_structure_overview]
|
|
29
|
+
payload[:structure_overview] = result.structure_overview
|
|
30
|
+
payload[:structure_loc_overview] = type_loc_overview(result)
|
|
31
|
+
end
|
|
32
|
+
if options[:show_detailed_structure]
|
|
33
|
+
payload[:structure_item_counts_overview] = result.structure_item_counts_overview
|
|
34
|
+
payload[:structure_item_loc_overview] = result.structure_item_loc_overview
|
|
35
|
+
end
|
|
36
|
+
unless options[:show_detailed_structure]
|
|
37
|
+
payload[:top] = payload[:top].map { |r| r.reject { |k, _| k == :structure_item_loc || k == :structure_item_counts } }
|
|
38
|
+
end
|
|
39
|
+
puts JSON.pretty_generate(payload)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def text(result, options)
|
|
43
|
+
rows = result.rows
|
|
44
|
+
puts "Ruby Quality Signals"
|
|
45
|
+
puts "Files scanned: #{rows.size}"
|
|
46
|
+
puts
|
|
47
|
+
puts "Column descriptions:"
|
|
48
|
+
puts " Churn = total git commits touching the file (optionally since --since)."
|
|
49
|
+
puts " Branches = count of control-flow tokens (sum of per-keyword counts)."
|
|
50
|
+
puts " LOC = non-empty lines of code in the file."
|
|
51
|
+
puts " File = repository-relative path."
|
|
52
|
+
puts
|
|
53
|
+
puts "%-6s %-8s %-6s %s" % ["Churn", "Branches", "LOC", "File"]
|
|
54
|
+
|
|
55
|
+
rows.first(options[:top]).each do |r|
|
|
56
|
+
puts "%-6d %-8d %-6d %s" % [r[:churn], r[:branches], r[:loc], r[:file]]
|
|
57
|
+
if options[:show_branch_count]
|
|
58
|
+
breakdown = r[:branch_breakdown]
|
|
59
|
+
detail = BranchAnalyzer::BRANCH_TOKENS.map { |name, label, _| "#{label}=#{breakdown[name]}" }.join(" | ")
|
|
60
|
+
puts " #{detail}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if options[:show_structure_overview]
|
|
65
|
+
puts
|
|
66
|
+
puts "Structure overview (all scanned files):"
|
|
67
|
+
StructureAnalyzer::STRUCTURE_ORDER.each do |key|
|
|
68
|
+
count = result.structure_overview[key]
|
|
69
|
+
avg_loc_per_item = count.zero? ? 0.0 : (type_loc_sum(result, key).to_f / count)
|
|
70
|
+
puts "%-26s count=%-4d avg_loc_per_item=%0.2f" % [key, count, avg_loc_per_item]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if options[:show_detailed_structure]
|
|
75
|
+
puts
|
|
76
|
+
puts "Detailed structure (all scanned files):"
|
|
77
|
+
StructureAnalyzer::STRUCTURE_ORDER.each do |type|
|
|
78
|
+
items = StructureAnalyzer::STRUCTURE_ITEMS.select { |item| item[:type] == type }
|
|
79
|
+
next if items.empty?
|
|
80
|
+
lines = items.map do |item|
|
|
81
|
+
count = result.structure_item_counts_overview[item[:key]]
|
|
82
|
+
next if count.zero?
|
|
83
|
+
avg = result.structure_item_loc_overview[item[:key]].to_f / count
|
|
84
|
+
" %-26s count=%-4d avg_loc_per_item=%0.2f" % [item[:label], count, avg]
|
|
85
|
+
end.compact
|
|
86
|
+
next if lines.empty?
|
|
87
|
+
puts type
|
|
88
|
+
lines.each { |line| puts line }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Total statement LOC for a structure type, summed from its items. The
|
|
94
|
+
# per-item LOC is item-keyed, so it must be rolled up by type here rather
|
|
95
|
+
# than indexed directly by the type symbol.
|
|
96
|
+
def type_loc_sum(result, type)
|
|
97
|
+
StructureAnalyzer::STRUCTURE_ITEMS
|
|
98
|
+
.select { |item| item[:type] == type }
|
|
99
|
+
.sum { |item| result.structure_item_loc_overview[item[:key]] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def type_loc_overview(result)
|
|
103
|
+
result.structure_overview.keys.each_with_object({}) do |type, acc|
|
|
104
|
+
acc[type] = type_loc_sum(result, type)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require_relative "git"
|
|
2
|
+
|
|
3
|
+
module Linecounter
|
|
4
|
+
module Scanner
|
|
5
|
+
EXTS = %w[rb].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def loc(content)
|
|
10
|
+
content.each_line.count { |l| !l.strip.empty? }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def ruby_files(repo_path)
|
|
14
|
+
Git.run("git", "-C", repo_path, "ls-files")
|
|
15
|
+
.lines
|
|
16
|
+
.map(&:strip)
|
|
17
|
+
.select { |f| EXTS.include?(File.extname(f).delete(".")) }
|
|
18
|
+
.reject { |f| File.basename(f) == "schema.rb" }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module Linecounter
|
|
4
|
+
module StructureAnalyzer
|
|
5
|
+
STRUCTURE_ORDER = [
|
|
6
|
+
:module_inclusion,
|
|
7
|
+
:constants,
|
|
8
|
+
:association,
|
|
9
|
+
:public_attribute_macros,
|
|
10
|
+
:public_delegate,
|
|
11
|
+
:macros,
|
|
12
|
+
:public_class_methods,
|
|
13
|
+
:initializer,
|
|
14
|
+
:public_methods,
|
|
15
|
+
:protected_attribute_macros,
|
|
16
|
+
:protected_methods,
|
|
17
|
+
:private_attribute_macros,
|
|
18
|
+
:private_delegate,
|
|
19
|
+
:private_methods
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# Item metadata: key -> (type, human label). Counts are keyed by item; each
|
|
23
|
+
# item rolls up into exactly one type.
|
|
24
|
+
STRUCTURE_ITEMS = [
|
|
25
|
+
{ key: :include, type: :module_inclusion, label: "include" },
|
|
26
|
+
{ key: :extend, type: :module_inclusion, label: "extend" },
|
|
27
|
+
{ key: :prepend, type: :module_inclusion, label: "prepend" },
|
|
28
|
+
{ key: :constant_assignment, type: :constants, label: "CONSTANT =" },
|
|
29
|
+
|
|
30
|
+
{ key: :has_many, type: :association, label: "has_many" },
|
|
31
|
+
{ key: :has_one, type: :association, label: "has_one" },
|
|
32
|
+
{ key: :belongs_to, type: :association, label: "belongs_to" },
|
|
33
|
+
{ key: :habtm, type: :association, label: "has_and_belongs_to_many" },
|
|
34
|
+
{ key: :has_one_attached, type: :association, label: "has_one_attached" },
|
|
35
|
+
{ key: :has_many_attached, type: :association, label: "has_many_attached" },
|
|
36
|
+
|
|
37
|
+
{ key: :scope, type: :macros, label: "scope" },
|
|
38
|
+
{ key: :validates, type: :macros, label: "validates" },
|
|
39
|
+
{ key: :validate, type: :macros, label: "validate" },
|
|
40
|
+
{ key: :before_callback, type: :macros, label: "before_*" },
|
|
41
|
+
{ key: :after_callback, type: :macros, label: "after_*" },
|
|
42
|
+
{ key: :around_callback, type: :macros, label: "around_*" },
|
|
43
|
+
{ key: :enum, type: :macros, label: "enum" },
|
|
44
|
+
{ key: :serialize, type: :macros, label: "serialize" },
|
|
45
|
+
{ key: :store, type: :macros, label: "store" },
|
|
46
|
+
{ key: :store_accessor, type: :macros, label: "store_accessor" },
|
|
47
|
+
|
|
48
|
+
{ key: :public_attr_reader, type: :public_attribute_macros, label: "public attr_reader" },
|
|
49
|
+
{ key: :public_attr_writer, type: :public_attribute_macros, label: "public attr_writer" },
|
|
50
|
+
{ key: :public_attr_accessor, type: :public_attribute_macros, label: "public attr_accessor" },
|
|
51
|
+
{ key: :protected_attr_reader, type: :protected_attribute_macros, label: "protected attr_reader" },
|
|
52
|
+
{ key: :protected_attr_writer, type: :protected_attribute_macros, label: "protected attr_writer" },
|
|
53
|
+
{ key: :protected_attr_accessor, type: :protected_attribute_macros, label: "protected attr_accessor" },
|
|
54
|
+
{ key: :private_attr_reader, type: :private_attribute_macros, label: "private attr_reader" },
|
|
55
|
+
{ key: :private_attr_writer, type: :private_attribute_macros, label: "private attr_writer" },
|
|
56
|
+
{ key: :private_attr_accessor, type: :private_attribute_macros, label: "private attr_accessor" },
|
|
57
|
+
|
|
58
|
+
{ key: :public_delegate, type: :public_delegate, label: "public delegate" },
|
|
59
|
+
{ key: :private_delegate, type: :private_delegate, label: "private delegate" },
|
|
60
|
+
|
|
61
|
+
{ key: :public_class_method_def, type: :public_class_methods, label: "public class def" },
|
|
62
|
+
{ key: :public_instance_method_def, type: :public_methods, label: "public def" },
|
|
63
|
+
{ key: :protected_instance_method_def, type: :protected_methods, label: "protected def" },
|
|
64
|
+
{ key: :private_instance_method_def, type: :private_methods, label: "private def" },
|
|
65
|
+
{ key: :initializer_def, type: :initializer, label: "initialize" }
|
|
66
|
+
].freeze
|
|
67
|
+
|
|
68
|
+
KEY_TO_TYPE = STRUCTURE_ITEMS.each_with_object({}) { |item, h| h[item[:key]] = item[:type] }.freeze
|
|
69
|
+
|
|
70
|
+
ASSOCIATION_KEYS = {
|
|
71
|
+
has_many: :has_many, has_one: :has_one, belongs_to: :belongs_to,
|
|
72
|
+
has_and_belongs_to_many: :habtm, has_one_attached: :has_one_attached,
|
|
73
|
+
has_many_attached: :has_many_attached
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
PLAIN_MACROS = %i[scope validates validate enum serialize store store_accessor].freeze
|
|
77
|
+
MODULE_INCLUSION = %i[include extend prepend].freeze
|
|
78
|
+
ATTRIBUTE_MACROS = %i[attr_reader attr_writer attr_accessor].freeze
|
|
79
|
+
VISIBILITY_NAMES = %i[public protected private].freeze
|
|
80
|
+
|
|
81
|
+
# Walks the AST tracking visibility per class/module scope, so each method,
|
|
82
|
+
# macro, and constant is attributed accurately at the structure level only
|
|
83
|
+
# (declarations inside method bodies are not miscounted).
|
|
84
|
+
class StructureVisitor < Prism::Visitor
|
|
85
|
+
attr_reader :type_counts, :item_counts, :item_loc_sums
|
|
86
|
+
|
|
87
|
+
def initialize
|
|
88
|
+
super
|
|
89
|
+
@type_counts = Hash.new(0)
|
|
90
|
+
@item_counts = Hash.new(0)
|
|
91
|
+
@item_loc_sums = Hash.new(0)
|
|
92
|
+
@visibility = [:public]
|
|
93
|
+
@method_depth = 0
|
|
94
|
+
@singleton_depth = 0
|
|
95
|
+
@forced_visibility = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def visit_class_node(node)
|
|
99
|
+
with_scope { super }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def visit_module_node(node)
|
|
103
|
+
with_scope { super }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def visit_singleton_class_node(node)
|
|
107
|
+
@singleton_depth += 1
|
|
108
|
+
with_scope { super }
|
|
109
|
+
@singleton_depth -= 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def visit_def_node(node)
|
|
113
|
+
classify_def(node) if @method_depth.zero?
|
|
114
|
+
@method_depth += 1
|
|
115
|
+
super
|
|
116
|
+
@method_depth -= 1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def visit_constant_write_node(node)
|
|
120
|
+
record(:constant_assignment, node) if @method_depth.zero?
|
|
121
|
+
super
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def visit_constant_path_write_node(node)
|
|
125
|
+
record(:constant_assignment, node) if @method_depth.zero?
|
|
126
|
+
super
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def visit_call_node(node)
|
|
130
|
+
return super unless node.receiver.nil?
|
|
131
|
+
|
|
132
|
+
name = node.name
|
|
133
|
+
if VISIBILITY_NAMES.include?(name)
|
|
134
|
+
handle_visibility(node, name)
|
|
135
|
+
else
|
|
136
|
+
record(macro_key(name), node) if @method_depth.zero?
|
|
137
|
+
super
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def current_visibility
|
|
144
|
+
@forced_visibility || @visibility.last
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def with_scope
|
|
148
|
+
@visibility.push(:public)
|
|
149
|
+
saved_depth = @method_depth
|
|
150
|
+
@method_depth = 0
|
|
151
|
+
yield
|
|
152
|
+
@method_depth = saved_depth
|
|
153
|
+
@visibility.pop
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_visibility(node, name)
|
|
157
|
+
args = node.arguments&.arguments || []
|
|
158
|
+
if args.empty?
|
|
159
|
+
@visibility[-1] = name if @method_depth.zero?
|
|
160
|
+
# No meaningful children to record.
|
|
161
|
+
else
|
|
162
|
+
previous = @forced_visibility
|
|
163
|
+
@forced_visibility = name
|
|
164
|
+
super_visit(node)
|
|
165
|
+
@forced_visibility = previous
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Visit children of a node without re-dispatching the node itself.
|
|
170
|
+
def super_visit(node)
|
|
171
|
+
node.compact_child_nodes.each { |child| child.accept(self) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def classify_def(node)
|
|
175
|
+
if node.receiver.is_a?(Prism::SelfNode) || @singleton_depth.positive?
|
|
176
|
+
record(:public_class_method_def, node)
|
|
177
|
+
elsif node.name == :initialize
|
|
178
|
+
record(:initializer_def, node)
|
|
179
|
+
else
|
|
180
|
+
record(VISIBILITY_DEF_KEYS.fetch(current_visibility), node)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
VISIBILITY_DEF_KEYS = {
|
|
185
|
+
public: :public_instance_method_def,
|
|
186
|
+
protected: :protected_instance_method_def,
|
|
187
|
+
private: :private_instance_method_def
|
|
188
|
+
}.freeze
|
|
189
|
+
|
|
190
|
+
def macro_key(name)
|
|
191
|
+
return name if MODULE_INCLUSION.include?(name)
|
|
192
|
+
return ASSOCIATION_KEYS[name] if ASSOCIATION_KEYS.key?(name)
|
|
193
|
+
return name if PLAIN_MACROS.include?(name)
|
|
194
|
+
return :"#{current_visibility}_attr_#{name.to_s.delete_prefix("attr_")}" if ATTRIBUTE_MACROS.include?(name)
|
|
195
|
+
return current_visibility == :private ? :private_delegate : :public_delegate if name == :delegate
|
|
196
|
+
|
|
197
|
+
s = name.to_s
|
|
198
|
+
return :before_callback if s.start_with?("before_")
|
|
199
|
+
return :after_callback if s.start_with?("after_")
|
|
200
|
+
return :around_callback if s.start_with?("around_")
|
|
201
|
+
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def record(key, node)
|
|
206
|
+
type = key && KEY_TO_TYPE[key]
|
|
207
|
+
return unless type
|
|
208
|
+
|
|
209
|
+
@item_counts[key] += 1
|
|
210
|
+
@item_loc_sums[key] += node.location.end_line - node.location.start_line + 1
|
|
211
|
+
@type_counts[type] += 1
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
module_function
|
|
216
|
+
|
|
217
|
+
def counts(content)
|
|
218
|
+
visitor = StructureVisitor.new
|
|
219
|
+
Prism.parse(content).value.accept(visitor)
|
|
220
|
+
[visitor.type_counts, visitor.item_counts, visitor.item_loc_sums]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
data/lib/linecounter.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
require_relative "linecounter/version"
|
|
2
|
+
require_relative "linecounter/branch_analyzer"
|
|
3
|
+
require_relative "linecounter/structure_analyzer"
|
|
4
|
+
require_relative "linecounter/git"
|
|
5
|
+
require_relative "linecounter/scanner"
|
|
6
|
+
require_relative "linecounter/analyzer"
|
|
7
|
+
require_relative "linecounter/report"
|
|
8
|
+
require_relative "linecounter/cli"
|
metadata
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: linecounter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Robert Hopman
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: prism
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.19'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '2'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.19'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '2'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: minitest
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '5.0'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '5.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rake
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '13.0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '13.0'
|
|
60
|
+
description: linecounter scans a git repository and reports per-file quality signals
|
|
61
|
+
— non-empty lines of code, git churn, control-flow branching, and class-structure
|
|
62
|
+
counts with average statement lines per item. Output as text or JSON.
|
|
63
|
+
email:
|
|
64
|
+
- hopman.r@gmail.com
|
|
65
|
+
executables:
|
|
66
|
+
- linecounter
|
|
67
|
+
extensions: []
|
|
68
|
+
extra_rdoc_files: []
|
|
69
|
+
files:
|
|
70
|
+
- LICENSE
|
|
71
|
+
- README.md
|
|
72
|
+
- exe/linecounter
|
|
73
|
+
- lib/linecounter.rb
|
|
74
|
+
- lib/linecounter/analyzer.rb
|
|
75
|
+
- lib/linecounter/branch_analyzer.rb
|
|
76
|
+
- lib/linecounter/cli.rb
|
|
77
|
+
- lib/linecounter/git.rb
|
|
78
|
+
- lib/linecounter/report.rb
|
|
79
|
+
- lib/linecounter/scanner.rb
|
|
80
|
+
- lib/linecounter/structure_analyzer.rb
|
|
81
|
+
- lib/linecounter/version.rb
|
|
82
|
+
homepage: https://github.com/roberthopman/linecounter
|
|
83
|
+
licenses:
|
|
84
|
+
- MIT
|
|
85
|
+
metadata:
|
|
86
|
+
source_code_uri: https://github.com/roberthopman/linecounter
|
|
87
|
+
rubygems_mfa_required: 'true'
|
|
88
|
+
rdoc_options: []
|
|
89
|
+
require_paths:
|
|
90
|
+
- lib
|
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.0'
|
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '0'
|
|
101
|
+
requirements: []
|
|
102
|
+
rubygems_version: 3.6.9
|
|
103
|
+
specification_version: 4
|
|
104
|
+
summary: 'Per-file Ruby quality signals: lines of code, churn, branching, and structure.'
|
|
105
|
+
test_files: []
|