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 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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # linecounter
3
+ # Lists Ruby files with lines of code, churn, branching, and avg loc per item.
4
+
5
+ require "linecounter"
6
+
7
+ Linecounter::CLI.run(ARGV)
@@ -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
@@ -0,0 +1,3 @@
1
+ module Linecounter
2
+ VERSION = "0.1.0"
3
+ end
@@ -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: []