tina4ruby 3.11.13 → 3.11.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/metrics.rb
CHANGED
|
@@ -1,793 +1,793 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Tina4 Code Metrics — Ripper-based static analysis for the dev dashboard.
|
|
4
|
-
#
|
|
5
|
-
# Two-tier analysis:
|
|
6
|
-
# 1. Quick metrics (instant): LOC, file counts, class/function counts
|
|
7
|
-
# 2. Full analysis (on-demand, cached): cyclomatic complexity, maintainability
|
|
8
|
-
# index, coupling, Halstead metrics, violations
|
|
9
|
-
#
|
|
10
|
-
# Zero dependencies — uses Ruby's built-in Ripper module.
|
|
11
|
-
|
|
12
|
-
require 'ripper'
|
|
13
|
-
require 'digest'
|
|
14
|
-
require 'pathname'
|
|
15
|
-
|
|
16
|
-
module Tina4
|
|
17
|
-
module Metrics
|
|
18
|
-
# ── Cache ───────────────────────────────────────────────────
|
|
19
|
-
@full_cache_hash = ""
|
|
20
|
-
@full_cache_data = nil
|
|
21
|
-
@full_cache_time = 0
|
|
22
|
-
CACHE_TTL = 60
|
|
23
|
-
|
|
24
|
-
# Stores the resolved scan root so file_detail can locate framework files.
|
|
25
|
-
@last_scan_root = ""
|
|
26
|
-
|
|
27
|
-
# ── Root Resolution ──────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
# Pick the right directory to scan.
|
|
30
|
-
#
|
|
31
|
-
# If the root dir has Ruby files, scan the user's project code.
|
|
32
|
-
# Otherwise, scan the framework itself — so the bubble chart is never empty.
|
|
33
|
-
def self._resolve_root(root = 'src')
|
|
34
|
-
root_path = Pathname.new(root)
|
|
35
|
-
if root_path.directory? && !Dir.glob(root_path.join('**', '*.rb')).empty?
|
|
36
|
-
@last_scan_root = File.expand_path(root)
|
|
37
|
-
return root
|
|
38
|
-
end
|
|
39
|
-
# Fallback: scan the framework package itself
|
|
40
|
-
fw_dir = File.dirname(__FILE__)
|
|
41
|
-
@last_scan_root = fw_dir
|
|
42
|
-
fw_dir
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def self.last_scan_root
|
|
46
|
-
@last_scan_root
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# ── Quick Metrics ───────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
def self.quick_metrics(root = 'src')
|
|
52
|
-
# Check if the requested directory exists before falling back
|
|
53
|
-
root_path = Pathname.new(root)
|
|
54
|
-
return { "error" => "Directory not found: #{root}" } unless root_path.directory?
|
|
55
|
-
|
|
56
|
-
root = _resolve_root(root)
|
|
57
|
-
root_path = Pathname.new(root)
|
|
58
|
-
|
|
59
|
-
rb_files = Dir.glob(root_path.join('**', '*.rb'))
|
|
60
|
-
twig_files = Dir.glob(root_path.join('**', '*.twig')) + Dir.glob(root_path.join('**', '*.erb'))
|
|
61
|
-
|
|
62
|
-
migrations_path = Pathname.new('migrations')
|
|
63
|
-
sql_files = if migrations_path.directory?
|
|
64
|
-
Dir.glob(migrations_path.join('**', '*.sql')) + Dir.glob(migrations_path.join('**', '*.rb'))
|
|
65
|
-
else
|
|
66
|
-
[]
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
scss_files = Dir.glob(root_path.join('**', '*.scss')) + Dir.glob(root_path.join('**', '*.css'))
|
|
70
|
-
|
|
71
|
-
total_loc = 0
|
|
72
|
-
total_blank = 0
|
|
73
|
-
total_comment = 0
|
|
74
|
-
total_classes = 0
|
|
75
|
-
total_functions = 0
|
|
76
|
-
file_details = []
|
|
77
|
-
|
|
78
|
-
rb_files.each do |f|
|
|
79
|
-
source = begin
|
|
80
|
-
File.read(f, encoding: 'utf-8')
|
|
81
|
-
rescue StandardError
|
|
82
|
-
next
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
lines = source.lines.map(&:chomp)
|
|
86
|
-
loc = 0
|
|
87
|
-
blank = 0
|
|
88
|
-
comment = 0
|
|
89
|
-
in_heredoc = false
|
|
90
|
-
heredoc_id = nil
|
|
91
|
-
in_block_comment = false
|
|
92
|
-
|
|
93
|
-
lines.each do |line|
|
|
94
|
-
stripped = line.strip
|
|
95
|
-
|
|
96
|
-
if stripped.empty?
|
|
97
|
-
blank += 1
|
|
98
|
-
next
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# =begin/=end block comments
|
|
102
|
-
if in_block_comment
|
|
103
|
-
comment += 1
|
|
104
|
-
in_block_comment = false if stripped.start_with?('=end')
|
|
105
|
-
next
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
if stripped.start_with?('=begin')
|
|
109
|
-
comment += 1
|
|
110
|
-
in_block_comment = true
|
|
111
|
-
next
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Heredoc tracking (simplified)
|
|
115
|
-
if in_heredoc
|
|
116
|
-
if stripped == heredoc_id
|
|
117
|
-
in_heredoc = false
|
|
118
|
-
end
|
|
119
|
-
loc += 1
|
|
120
|
-
next
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
if stripped.match?(/<<[~-]?['"]?(\w+)['"]?/)
|
|
124
|
-
m = stripped.match(/<<[~-]?['"]?(\w+)['"]?/)
|
|
125
|
-
heredoc_id = m[1]
|
|
126
|
-
in_heredoc = true unless stripped.include?(heredoc_id + stripped[-1].to_s)
|
|
127
|
-
loc += 1
|
|
128
|
-
next
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
if stripped.start_with?('#')
|
|
132
|
-
comment += 1
|
|
133
|
-
next
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
loc += 1
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# Count classes and methods via simple pattern matching
|
|
140
|
-
classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
|
|
141
|
-
functions = lines.count { |l| l.strip.match?(/\Adef\s+/) }
|
|
142
|
-
|
|
143
|
-
total_loc += loc
|
|
144
|
-
total_blank += blank
|
|
145
|
-
total_comment += comment
|
|
146
|
-
total_classes += classes
|
|
147
|
-
total_functions += functions
|
|
148
|
-
|
|
149
|
-
rel_path = begin
|
|
150
|
-
Pathname.new(f).relative_path_from(root_path).to_s
|
|
151
|
-
rescue ArgumentError
|
|
152
|
-
f
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
file_details << {
|
|
156
|
-
"path" => rel_path,
|
|
157
|
-
"loc" => loc,
|
|
158
|
-
"blank" => blank,
|
|
159
|
-
"comment" => comment,
|
|
160
|
-
"classes" => classes,
|
|
161
|
-
"functions" => functions
|
|
162
|
-
}
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
file_details.sort_by! { |d| -d["loc"] }
|
|
166
|
-
|
|
167
|
-
# Route and ORM counts
|
|
168
|
-
route_count = 0
|
|
169
|
-
orm_count = 0
|
|
170
|
-
begin
|
|
171
|
-
if defined?(Tina4::Router) && Tina4::Router.respond_to?(:routes)
|
|
172
|
-
route_count = Tina4::Router.routes.length
|
|
173
|
-
elsif defined?(Tina4::Router) && Tina4::Router.instance_variable_defined?(:@routes)
|
|
174
|
-
route_count = Tina4::Router.instance_variable_get(:@routes).length
|
|
175
|
-
end
|
|
176
|
-
rescue StandardError
|
|
177
|
-
# ignore
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
begin
|
|
181
|
-
if defined?(Tina4::ORM)
|
|
182
|
-
orm_count = ObjectSpace.each_object(Class).count { |c| c < Tina4::ORM }
|
|
183
|
-
end
|
|
184
|
-
rescue StandardError
|
|
185
|
-
# ignore
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
breakdown = {
|
|
189
|
-
"ruby" => rb_files.length,
|
|
190
|
-
"templates" => twig_files.length,
|
|
191
|
-
"migrations" => sql_files.length,
|
|
192
|
-
"stylesheets" => scss_files.length
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
{
|
|
196
|
-
"file_count" => rb_files.length,
|
|
197
|
-
"total_loc" => total_loc,
|
|
198
|
-
"total_blank" => total_blank,
|
|
199
|
-
"total_comment" => total_comment,
|
|
200
|
-
"lloc" => total_loc,
|
|
201
|
-
"classes" => total_classes,
|
|
202
|
-
"functions" => total_functions,
|
|
203
|
-
"route_count" => route_count,
|
|
204
|
-
"orm_count" => orm_count,
|
|
205
|
-
"template_count" => twig_files.length,
|
|
206
|
-
"migration_count" => sql_files.length,
|
|
207
|
-
"avg_file_size" => rb_files.empty? ? 0 : (total_loc.to_f / rb_files.length).round(1),
|
|
208
|
-
"largest_files" => file_details.first(10),
|
|
209
|
-
"breakdown" => breakdown
|
|
210
|
-
}
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
# ── Full Analysis (Ripper-based) ────────────────────────────
|
|
214
|
-
|
|
215
|
-
def self.full_analysis(root = 'src')
|
|
216
|
-
# Check if the requested directory exists before falling back
|
|
217
|
-
root_path = Pathname.new(root)
|
|
218
|
-
return { "error" => "Directory not found: #{root}" } unless root_path.directory?
|
|
219
|
-
|
|
220
|
-
root = _resolve_root(root)
|
|
221
|
-
root_path = Pathname.new(root)
|
|
222
|
-
|
|
223
|
-
current_hash = _files_hash(root)
|
|
224
|
-
now = Time.now.to_f
|
|
225
|
-
|
|
226
|
-
if @full_cache_hash == current_hash && !@full_cache_data.nil? && (now - @full_cache_time) < CACHE_TTL
|
|
227
|
-
return @full_cache_data
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
rb_files = Dir.glob(root_path.join('**', '*.rb'))
|
|
231
|
-
|
|
232
|
-
all_functions = []
|
|
233
|
-
file_metrics = []
|
|
234
|
-
import_graph = {}
|
|
235
|
-
reverse_graph = {}
|
|
236
|
-
|
|
237
|
-
rb_files.each do |f|
|
|
238
|
-
source = begin
|
|
239
|
-
File.read(f, encoding: 'utf-8')
|
|
240
|
-
rescue StandardError
|
|
241
|
-
next
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
tokens = begin
|
|
245
|
-
Ripper.lex(source)
|
|
246
|
-
rescue StandardError
|
|
247
|
-
next
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
rel_path = begin
|
|
251
|
-
Pathname.new(f).relative_path_from(root_path).to_s
|
|
252
|
-
rescue ArgumentError
|
|
253
|
-
f
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
lines = source.lines.map(&:chomp)
|
|
257
|
-
loc = lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
|
|
258
|
-
|
|
259
|
-
# Extract imports (require/require_relative)
|
|
260
|
-
imports = _extract_imports(lines)
|
|
261
|
-
import_graph[rel_path] = imports
|
|
262
|
-
|
|
263
|
-
imports.each do |imp|
|
|
264
|
-
reverse_graph[imp] ||= []
|
|
265
|
-
reverse_graph[imp] << rel_path
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# Parse functions/methods and their complexity
|
|
269
|
-
file_functions = _extract_functions(source, tokens, lines)
|
|
270
|
-
file_complexity = 0
|
|
271
|
-
|
|
272
|
-
file_functions.each do |func_info|
|
|
273
|
-
func_info["file"] = rel_path
|
|
274
|
-
all_functions << func_info
|
|
275
|
-
file_complexity += func_info["complexity"]
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
# Halstead metrics from tokens
|
|
279
|
-
halstead = _count_halstead(tokens)
|
|
280
|
-
n1 = halstead[:unique_operators].length
|
|
281
|
-
n2 = halstead[:unique_operands].length
|
|
282
|
-
n_total_1 = halstead[:operators]
|
|
283
|
-
n_total_2 = halstead[:operands]
|
|
284
|
-
vocabulary = n1 + n2
|
|
285
|
-
length = n_total_1 + n_total_2
|
|
286
|
-
volume = vocabulary > 0 ? length * Math.log2(vocabulary) : 0.0
|
|
287
|
-
|
|
288
|
-
# Maintainability index
|
|
289
|
-
avg_cc = file_functions.empty? ? 0 : file_complexity.to_f / file_functions.length
|
|
290
|
-
mi = _maintainability_index(volume, avg_cc, loc)
|
|
291
|
-
|
|
292
|
-
# Coupling
|
|
293
|
-
ce = imports.length
|
|
294
|
-
ca = (reverse_graph[rel_path] || []).length
|
|
295
|
-
instability = (ca + ce) > 0 ? ce.to_f / (ca + ce) : 0.0
|
|
296
|
-
|
|
297
|
-
file_metrics << {
|
|
298
|
-
"path" => rel_path,
|
|
299
|
-
"loc" => loc,
|
|
300
|
-
"complexity" => file_complexity,
|
|
301
|
-
"avg_complexity" => avg_cc.round(2),
|
|
302
|
-
"functions" => file_functions.length,
|
|
303
|
-
"maintainability" => mi.round(1),
|
|
304
|
-
"halstead_volume" => volume.round(1),
|
|
305
|
-
"coupling_afferent" => ca,
|
|
306
|
-
"coupling_efferent" => ce,
|
|
307
|
-
"instability" => instability.round(3),
|
|
308
|
-
"has_tests" => _has_matching_test(rel_path),
|
|
309
|
-
"dep_count" => imports.length
|
|
310
|
-
}
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
# Update afferent coupling now that all files are processed
|
|
314
|
-
file_metrics.each do |fm|
|
|
315
|
-
fm["coupling_afferent"] = (reverse_graph[fm["path"]] || []).length
|
|
316
|
-
ca = fm["coupling_afferent"]
|
|
317
|
-
ce = fm["coupling_efferent"]
|
|
318
|
-
fm["instability"] = (ca + ce) > 0 ? (ce.to_f / (ca + ce)).round(3) : 0.0
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
all_functions.sort_by! { |f| -f["complexity"] }
|
|
322
|
-
file_metrics.sort_by! { |f| f["maintainability"] }
|
|
323
|
-
|
|
324
|
-
violations = _detect_violations(all_functions, file_metrics)
|
|
325
|
-
|
|
326
|
-
total_cc = all_functions.sum { |f| f["complexity"] }
|
|
327
|
-
avg_cc = all_functions.empty? ? 0 : total_cc.to_f / all_functions.length
|
|
328
|
-
total_mi = file_metrics.sum { |f| f["maintainability"] }
|
|
329
|
-
avg_mi = file_metrics.empty? ? 0 : total_mi.to_f / file_metrics.length
|
|
330
|
-
|
|
331
|
-
# Detect if we're scanning framework or project
|
|
332
|
-
framework_dir = File.expand_path(File.dirname(__FILE__))
|
|
333
|
-
resolved_root = File.expand_path(root_path.to_s)
|
|
334
|
-
scanning_framework = resolved_root == framework_dir || resolved_root.start_with?(framework_dir + '/')
|
|
335
|
-
|
|
336
|
-
result = {
|
|
337
|
-
"files_analyzed" => file_metrics.length,
|
|
338
|
-
"total_functions" => all_functions.length,
|
|
339
|
-
"avg_complexity" => avg_cc.round(2),
|
|
340
|
-
"avg_maintainability" => avg_mi.round(1),
|
|
341
|
-
"most_complex_functions" => all_functions.first(15),
|
|
342
|
-
"file_metrics" => file_metrics,
|
|
343
|
-
"violations" => violations,
|
|
344
|
-
"dependency_graph" => import_graph,
|
|
345
|
-
"scan_mode" => scanning_framework ? "framework" : "project",
|
|
346
|
-
"scan_root" => resolved_root
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
@full_cache_hash = current_hash
|
|
350
|
-
@full_cache_data = result
|
|
351
|
-
@full_cache_time = now
|
|
352
|
-
|
|
353
|
-
result
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
# ── File Detail ─────────────────────────────────────────────
|
|
357
|
-
|
|
358
|
-
def self.file_detail(file_path)
|
|
359
|
-
unless File.exist?(file_path)
|
|
360
|
-
# Try resolving relative to the last scan root (framework mode)
|
|
361
|
-
if @last_scan_root && !@last_scan_root.empty?
|
|
362
|
-
candidate = File.join(@last_scan_root, file_path)
|
|
363
|
-
if File.exist?(candidate)
|
|
364
|
-
file_path = candidate
|
|
365
|
-
end
|
|
366
|
-
end
|
|
367
|
-
end
|
|
368
|
-
unless File.exist?(file_path)
|
|
369
|
-
return { "error" => "File not found: #{file_path}" }
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
source = begin
|
|
373
|
-
File.read(file_path, encoding: 'utf-8')
|
|
374
|
-
rescue StandardError => e
|
|
375
|
-
return { "error" => "Read error: #{e.message}" }
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
tokens = begin
|
|
379
|
-
Ripper.lex(source)
|
|
380
|
-
rescue StandardError => e
|
|
381
|
-
return { "error" => "Syntax error: #{e.message}" }
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
lines = source.lines.map(&:chomp)
|
|
385
|
-
loc = lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
|
|
386
|
-
|
|
387
|
-
functions = _extract_functions(source, tokens, lines)
|
|
388
|
-
functions.sort_by! { |f| -f["complexity"] }
|
|
389
|
-
|
|
390
|
-
classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
|
|
391
|
-
imports = _extract_imports(lines)
|
|
392
|
-
|
|
393
|
-
warnings = []
|
|
394
|
-
functions.each do |f|
|
|
395
|
-
if f["loc"] <= 1
|
|
396
|
-
warnings << { "type" => "empty_method", "message" => "Method '#{f["name"]}' appears to be empty", "line" => f["line"] }
|
|
397
|
-
end
|
|
398
|
-
end
|
|
399
|
-
if classes > 0 && functions.empty? && loc <= 1
|
|
400
|
-
warnings << { "type" => "empty_class", "message" => "Class/module appears to be empty", "line" => 1 }
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
{
|
|
404
|
-
"path" => file_path,
|
|
405
|
-
"loc" => loc,
|
|
406
|
-
"total_lines" => lines.length,
|
|
407
|
-
"classes" => classes,
|
|
408
|
-
"functions" => functions.map { |f|
|
|
409
|
-
{
|
|
410
|
-
"name" => f["name"],
|
|
411
|
-
"line" => f["line"],
|
|
412
|
-
"complexity" => f["complexity"],
|
|
413
|
-
"loc" => f["loc"],
|
|
414
|
-
"args" => f["args"]
|
|
415
|
-
}
|
|
416
|
-
},
|
|
417
|
-
"imports" => imports,
|
|
418
|
-
"warnings" => warnings
|
|
419
|
-
}
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
# ── Private Helpers ─────────────────────────────────────────
|
|
423
|
-
|
|
424
|
-
private_class_method
|
|
425
|
-
|
|
426
|
-
def self._has_matching_test(rel_path)
|
|
427
|
-
require 'set'
|
|
428
|
-
|
|
429
|
-
name = File.basename(rel_path, '.rb')
|
|
430
|
-
# Parent directory name (e.g. "database" from "database/sqlite3_adapter.rb")
|
|
431
|
-
parent_dir = File.dirname(rel_path)
|
|
432
|
-
parent_module = (parent_dir != '.' && !parent_dir.empty?) ? File.basename(parent_dir) : ''
|
|
433
|
-
|
|
434
|
-
# Stage 1: Filename matching — name_spec, name_test, test_name patterns
|
|
435
|
-
test_dirs = ['spec', 'spec/tina4', 'test', 'tests']
|
|
436
|
-
test_dirs.each do |td|
|
|
437
|
-
patterns = [
|
|
438
|
-
"#{td}/#{name}_spec.rb",
|
|
439
|
-
"#{td}/#{name}s_spec.rb",
|
|
440
|
-
"#{td}/#{name}_test.rb",
|
|
441
|
-
"#{td}/test_#{name}.rb",
|
|
442
|
-
]
|
|
443
|
-
# Also check parent-named tests (spec/database_spec.rb covers database/sqlite3_adapter.rb)
|
|
444
|
-
if parent_module && !parent_module.empty? && parent_module != name
|
|
445
|
-
patterns << "#{td}/#{parent_module}_spec.rb"
|
|
446
|
-
patterns << "#{td}/#{parent_module}s_spec.rb"
|
|
447
|
-
patterns << "#{td}/#{parent_module}_test.rb"
|
|
448
|
-
patterns << "#{td}/test_#{parent_module}.rb"
|
|
449
|
-
end
|
|
450
|
-
return true if patterns.any? { |p| File.exist?(p) }
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
# Build a dotted/slashed require path for import matching
|
|
454
|
-
# e.g. "lib/tina4/database/sqlite3_adapter.rb" → "tina4/database/sqlite3_adapter"
|
|
455
|
-
path_without_ext = rel_path.sub(/\.rb$/, '')
|
|
456
|
-
# Strip leading lib/ prefix if present
|
|
457
|
-
require_path = path_without_ext.sub(%r{^lib/}, '')
|
|
458
|
-
|
|
459
|
-
# Build CamelCase class name from snake_case module name
|
|
460
|
-
# e.g. "sqlite3_adapter" → "Sqlite3Adapter"
|
|
461
|
-
class_name = name.split('_').map(&:capitalize).join
|
|
462
|
-
|
|
463
|
-
# Stage 2+3: Content scan — check if any spec/test file references this module
|
|
464
|
-
scan_dirs = ['spec', 'test', 'tests']
|
|
465
|
-
scan_dirs.each do |td|
|
|
466
|
-
next unless Dir.exist?(td)
|
|
467
|
-
Dir.glob(File.join(td, '**', '*.rb')).each do |test_file|
|
|
468
|
-
content = begin
|
|
469
|
-
File.read(test_file, encoding: 'utf-8')
|
|
470
|
-
rescue StandardError
|
|
471
|
-
next
|
|
472
|
-
end
|
|
473
|
-
# Stage 2: require/require_relative path matching
|
|
474
|
-
return true if !require_path.empty? && content.include?(require_path)
|
|
475
|
-
# Stage 3: class name or module name mention
|
|
476
|
-
return true if content.match?(/\b#{Regexp.escape(class_name)}\b/)
|
|
477
|
-
return true if content.match?(/\b#{Regexp.escape(name)}\b/i)
|
|
478
|
-
end
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
false
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
def self._files_hash(root)
|
|
485
|
-
md5 = Digest::MD5.new
|
|
486
|
-
root_path = Pathname.new(root)
|
|
487
|
-
if root_path.directory?
|
|
488
|
-
Dir.glob(root_path.join('**', '*.rb')).sort.each do |f|
|
|
489
|
-
begin
|
|
490
|
-
md5.update("#{f}:#{File.mtime(f).to_f}")
|
|
491
|
-
rescue StandardError
|
|
492
|
-
# ignore
|
|
493
|
-
end
|
|
494
|
-
end
|
|
495
|
-
end
|
|
496
|
-
md5.hexdigest
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
def self._extract_imports(lines)
|
|
500
|
-
imports = []
|
|
501
|
-
lines.each do |line|
|
|
502
|
-
stripped = line.strip
|
|
503
|
-
if stripped.match?(/\Arequire\s+/)
|
|
504
|
-
m = stripped.match(/\Arequire\s+['"]([^'"]+)['"]/)
|
|
505
|
-
imports << m[1] if m
|
|
506
|
-
elsif stripped.match?(/\Arequire_relative\s+/)
|
|
507
|
-
m = stripped.match(/\Arequire_relative\s+['"]([^'"]+)['"]/)
|
|
508
|
-
imports << m[1] if m
|
|
509
|
-
end
|
|
510
|
-
end
|
|
511
|
-
imports
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
def self._extract_functions(source, tokens, lines)
|
|
515
|
-
functions = []
|
|
516
|
-
# Track class/module nesting for method names
|
|
517
|
-
context_stack = []
|
|
518
|
-
i = 0
|
|
519
|
-
|
|
520
|
-
while i < lines.length
|
|
521
|
-
stripped = lines[i].strip
|
|
522
|
-
|
|
523
|
-
# Track class/module context
|
|
524
|
-
if stripped.match?(/\A(class|module)\s+(\S+)/)
|
|
525
|
-
m = stripped.match(/\A(class|module)\s+(\S+)/)
|
|
526
|
-
class_name = m[2].to_s.split('<').first.to_s.strip
|
|
527
|
-
context_stack.push(class_name) unless class_name.empty?
|
|
528
|
-
end
|
|
529
|
-
|
|
530
|
-
# Detect method definitions
|
|
531
|
-
if stripped.match?(/\Adef\s+/)
|
|
532
|
-
method_match = stripped.match(/\Adef\s+(self\.)?(\S+?)(\(.*\))?\s*$/)
|
|
533
|
-
if method_match
|
|
534
|
-
prefix = method_match[1] ? 'self.' : ''
|
|
535
|
-
method_name = prefix + method_match[2]
|
|
536
|
-
|
|
537
|
-
# Build full name with class context
|
|
538
|
-
full_name = if context_stack.any?
|
|
539
|
-
"#{context_stack.last}.#{method_name}"
|
|
540
|
-
else
|
|
541
|
-
method_name
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
# Extract arguments
|
|
545
|
-
args = []
|
|
546
|
-
if method_match[3]
|
|
547
|
-
arg_str = method_match[3].gsub(/[()]/, '')
|
|
548
|
-
arg_str.split(',').each do |arg|
|
|
549
|
-
arg = arg.strip.split('=').first.strip.gsub(/^[*&]+/, '')
|
|
550
|
-
args << arg unless arg == 'self' || arg.empty?
|
|
551
|
-
end
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
# Find method end and calculate LOC
|
|
555
|
-
method_start = i
|
|
556
|
-
method_end = _find_method_end(lines, i)
|
|
557
|
-
method_loc = method_end - method_start + 1
|
|
558
|
-
|
|
559
|
-
# Calculate complexity for this method's body
|
|
560
|
-
method_lines = lines[method_start..method_end]
|
|
561
|
-
method_source = method_lines.join("\n")
|
|
562
|
-
cc = _cyclomatic_complexity_from_source(method_source)
|
|
563
|
-
|
|
564
|
-
functions << {
|
|
565
|
-
"name" => full_name,
|
|
566
|
-
"line" => i + 1,
|
|
567
|
-
"complexity" => cc,
|
|
568
|
-
"loc" => method_loc,
|
|
569
|
-
"args" => args
|
|
570
|
-
}
|
|
571
|
-
end
|
|
572
|
-
end
|
|
573
|
-
|
|
574
|
-
# Track end keywords for context popping
|
|
575
|
-
if stripped == 'end'
|
|
576
|
-
# Check if this closes a class/module
|
|
577
|
-
# Simple heuristic: count def/class/module opens vs end closes
|
|
578
|
-
# We only pop context when we're back at the class/module level
|
|
579
|
-
indent = lines[i].length - lines[i].lstrip.length
|
|
580
|
-
if indent == 0 && context_stack.any?
|
|
581
|
-
context_stack.pop
|
|
582
|
-
end
|
|
583
|
-
end
|
|
584
|
-
|
|
585
|
-
i += 1
|
|
586
|
-
end
|
|
587
|
-
|
|
588
|
-
functions
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
def self._find_method_end(lines, start_index)
|
|
592
|
-
depth = 0
|
|
593
|
-
i = start_index
|
|
594
|
-
base_indent = lines[i].length - lines[i].lstrip.length
|
|
595
|
-
|
|
596
|
-
while i < lines.length
|
|
597
|
-
stripped = lines[i].strip
|
|
598
|
-
|
|
599
|
-
unless stripped.empty? || stripped.start_with?('#')
|
|
600
|
-
# Count block openers
|
|
601
|
-
if stripped.match?(/\b(def|class|module|if|unless|case|while|until|for|begin|do)\b/) &&
|
|
602
|
-
!stripped.match?(/\bend\b/) &&
|
|
603
|
-
!stripped.end_with?(' if ', ' unless ', ' while ', ' until ') &&
|
|
604
|
-
!(stripped.match?(/\bif\b|\bunless\b|\bwhile\b|\buntil\b/) && i != start_index && _is_modifier?(stripped))
|
|
605
|
-
depth += 1
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
if stripped == 'end' || stripped.start_with?('end ') || stripped.start_with?('end;')
|
|
609
|
-
depth -= 1
|
|
610
|
-
return i if depth <= 0
|
|
611
|
-
end
|
|
612
|
-
end
|
|
613
|
-
|
|
614
|
-
i += 1
|
|
615
|
-
end
|
|
616
|
-
|
|
617
|
-
# If we never found the end, return last line
|
|
618
|
-
lines.length - 1
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
def self._is_modifier?(line)
|
|
622
|
-
# A rough check: if the keyword is not at the start of the meaningful content,
|
|
623
|
-
# it's likely a modifier (e.g., "return x if condition")
|
|
624
|
-
stripped = line.strip
|
|
625
|
-
!stripped.match?(/\A(if|unless|while|until)\b/)
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
def self._cyclomatic_complexity_from_source(source)
|
|
629
|
-
cc = 1
|
|
630
|
-
|
|
631
|
-
# Use Ripper tokens for accurate counting
|
|
632
|
-
tokens = begin
|
|
633
|
-
Ripper.lex(source)
|
|
634
|
-
rescue StandardError
|
|
635
|
-
return cc
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
tokens.each do |(_pos, type, token)|
|
|
639
|
-
case type
|
|
640
|
-
when :on_kw
|
|
641
|
-
case token
|
|
642
|
-
when 'if', 'elsif', 'unless', 'when', 'while', 'until', 'for', 'rescue'
|
|
643
|
-
# Skip modifier forms by checking if it's the first keyword on the line
|
|
644
|
-
# For simplicity, count all — modifiers still add a decision path
|
|
645
|
-
cc += 1
|
|
646
|
-
end
|
|
647
|
-
when :on_op
|
|
648
|
-
case token
|
|
649
|
-
when '&&', '||'
|
|
650
|
-
cc += 1
|
|
651
|
-
when '?'
|
|
652
|
-
# Ternary operator
|
|
653
|
-
cc += 1
|
|
654
|
-
end
|
|
655
|
-
when :on_ident
|
|
656
|
-
# 'and' and 'or' are parsed as identifiers in some contexts
|
|
657
|
-
# but usually as keywords
|
|
658
|
-
end
|
|
659
|
-
|
|
660
|
-
# Check for 'and'/'or' as keywords
|
|
661
|
-
if type == :on_kw && (token == 'and' || token == 'or')
|
|
662
|
-
cc += 1
|
|
663
|
-
end
|
|
664
|
-
end
|
|
665
|
-
|
|
666
|
-
cc
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
OPERATOR_TYPES = %i[
|
|
670
|
-
on_op
|
|
671
|
-
].freeze
|
|
672
|
-
|
|
673
|
-
OPERAND_TYPES = %i[
|
|
674
|
-
on_ident on_int on_float on_tstring_content
|
|
675
|
-
on_const on_symbeg on_rational on_imaginary
|
|
676
|
-
].freeze
|
|
677
|
-
|
|
678
|
-
def self._count_halstead(tokens)
|
|
679
|
-
stats = {
|
|
680
|
-
operators: 0,
|
|
681
|
-
operands: 0,
|
|
682
|
-
unique_operators: Set.new,
|
|
683
|
-
unique_operands: Set.new
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
# Need Set
|
|
687
|
-
require 'set' unless defined?(Set)
|
|
688
|
-
|
|
689
|
-
stats[:unique_operators] = Set.new
|
|
690
|
-
stats[:unique_operands] = Set.new
|
|
691
|
-
|
|
692
|
-
tokens.each do |(_pos, type, token)|
|
|
693
|
-
case type
|
|
694
|
-
when :on_op
|
|
695
|
-
stats[:operators] += 1
|
|
696
|
-
stats[:unique_operators].add(token)
|
|
697
|
-
when :on_kw
|
|
698
|
-
# Keywords that act as operators
|
|
699
|
-
if %w[and or not defined? return yield raise].include?(token)
|
|
700
|
-
stats[:operators] += 1
|
|
701
|
-
stats[:unique_operators].add(token)
|
|
702
|
-
end
|
|
703
|
-
when :on_ident, :on_const
|
|
704
|
-
stats[:operands] += 1
|
|
705
|
-
stats[:unique_operands].add(token)
|
|
706
|
-
when :on_int, :on_float, :on_rational, :on_imaginary
|
|
707
|
-
stats[:operands] += 1
|
|
708
|
-
stats[:unique_operands].add(token)
|
|
709
|
-
when :on_tstring_content
|
|
710
|
-
stats[:operands] += 1
|
|
711
|
-
stats[:unique_operands].add(token[0, 50])
|
|
712
|
-
end
|
|
713
|
-
end
|
|
714
|
-
|
|
715
|
-
stats
|
|
716
|
-
end
|
|
717
|
-
|
|
718
|
-
def self._maintainability_index(halstead_volume, avg_cc, loc)
|
|
719
|
-
return 100.0 if loc <= 0
|
|
720
|
-
|
|
721
|
-
v = [halstead_volume, 1].max
|
|
722
|
-
mi = 171 - 5.2 * Math.log(v) - 0.23 * avg_cc - 16.2 * Math.log(loc)
|
|
723
|
-
[[0.0, mi * 100.0 / 171].max, 100.0].min
|
|
724
|
-
end
|
|
725
|
-
|
|
726
|
-
def self._detect_violations(functions, file_metrics)
|
|
727
|
-
violations = []
|
|
728
|
-
|
|
729
|
-
functions.each do |f|
|
|
730
|
-
if f["complexity"] > 20
|
|
731
|
-
violations << {
|
|
732
|
-
"type" => "error",
|
|
733
|
-
"rule" => "high_complexity",
|
|
734
|
-
"message" => "#{f['name']} has cyclomatic complexity #{f['complexity']} (max 20)",
|
|
735
|
-
"file" => f["file"],
|
|
736
|
-
"line" => f["line"]
|
|
737
|
-
}
|
|
738
|
-
elsif f["complexity"] > 10
|
|
739
|
-
violations << {
|
|
740
|
-
"type" => "warning",
|
|
741
|
-
"rule" => "moderate_complexity",
|
|
742
|
-
"message" => "#{f['name']} has cyclomatic complexity #{f['complexity']} (recommended max 10)",
|
|
743
|
-
"file" => f["file"],
|
|
744
|
-
"line" => f["line"]
|
|
745
|
-
}
|
|
746
|
-
end
|
|
747
|
-
end
|
|
748
|
-
|
|
749
|
-
file_metrics.each do |fm|
|
|
750
|
-
if fm["loc"] > 500
|
|
751
|
-
violations << {
|
|
752
|
-
"type" => "warning",
|
|
753
|
-
"rule" => "large_file",
|
|
754
|
-
"message" => "#{fm['path']} has #{fm['loc']} LOC (recommended max 500)",
|
|
755
|
-
"file" => fm["path"],
|
|
756
|
-
"line" => 1
|
|
757
|
-
}
|
|
758
|
-
end
|
|
759
|
-
|
|
760
|
-
if fm["functions"] > 20
|
|
761
|
-
violations << {
|
|
762
|
-
"type" => "warning",
|
|
763
|
-
"rule" => "too_many_functions",
|
|
764
|
-
"message" => "#{fm['path']} has #{fm['functions']} functions (recommended max 20)",
|
|
765
|
-
"file" => fm["path"],
|
|
766
|
-
"line" => 1
|
|
767
|
-
}
|
|
768
|
-
end
|
|
769
|
-
|
|
770
|
-
if fm["maintainability"] < 20
|
|
771
|
-
violations << {
|
|
772
|
-
"type" => "error",
|
|
773
|
-
"rule" => "low_maintainability",
|
|
774
|
-
"message" => "#{fm['path']} has maintainability index #{fm['maintainability']} (min 20)",
|
|
775
|
-
"file" => fm["path"],
|
|
776
|
-
"line" => 1
|
|
777
|
-
}
|
|
778
|
-
elsif fm["maintainability"] < 40
|
|
779
|
-
violations << {
|
|
780
|
-
"type" => "warning",
|
|
781
|
-
"rule" => "moderate_maintainability",
|
|
782
|
-
"message" => "#{fm['path']} has maintainability index #{fm['maintainability']} (recommended min 40)",
|
|
783
|
-
"file" => fm["path"],
|
|
784
|
-
"line" => 1
|
|
785
|
-
}
|
|
786
|
-
end
|
|
787
|
-
end
|
|
788
|
-
|
|
789
|
-
violations.sort_by! { |v| [v["type"] == "error" ? 0 : 1, v["file"]] }
|
|
790
|
-
violations
|
|
791
|
-
end
|
|
792
|
-
end
|
|
793
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4 Code Metrics — Ripper-based static analysis for the dev dashboard.
|
|
4
|
+
#
|
|
5
|
+
# Two-tier analysis:
|
|
6
|
+
# 1. Quick metrics (instant): LOC, file counts, class/function counts
|
|
7
|
+
# 2. Full analysis (on-demand, cached): cyclomatic complexity, maintainability
|
|
8
|
+
# index, coupling, Halstead metrics, violations
|
|
9
|
+
#
|
|
10
|
+
# Zero dependencies — uses Ruby's built-in Ripper module.
|
|
11
|
+
|
|
12
|
+
require 'ripper'
|
|
13
|
+
require 'digest'
|
|
14
|
+
require 'pathname'
|
|
15
|
+
|
|
16
|
+
module Tina4
|
|
17
|
+
module Metrics
|
|
18
|
+
# ── Cache ───────────────────────────────────────────────────
|
|
19
|
+
@full_cache_hash = ""
|
|
20
|
+
@full_cache_data = nil
|
|
21
|
+
@full_cache_time = 0
|
|
22
|
+
CACHE_TTL = 60
|
|
23
|
+
|
|
24
|
+
# Stores the resolved scan root so file_detail can locate framework files.
|
|
25
|
+
@last_scan_root = ""
|
|
26
|
+
|
|
27
|
+
# ── Root Resolution ──────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
# Pick the right directory to scan.
|
|
30
|
+
#
|
|
31
|
+
# If the root dir has Ruby files, scan the user's project code.
|
|
32
|
+
# Otherwise, scan the framework itself — so the bubble chart is never empty.
|
|
33
|
+
def self._resolve_root(root = 'src')
|
|
34
|
+
root_path = Pathname.new(root)
|
|
35
|
+
if root_path.directory? && !Dir.glob(root_path.join('**', '*.rb')).empty?
|
|
36
|
+
@last_scan_root = File.expand_path(root)
|
|
37
|
+
return root
|
|
38
|
+
end
|
|
39
|
+
# Fallback: scan the framework package itself
|
|
40
|
+
fw_dir = File.dirname(__FILE__)
|
|
41
|
+
@last_scan_root = fw_dir
|
|
42
|
+
fw_dir
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.last_scan_root
|
|
46
|
+
@last_scan_root
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ── Quick Metrics ───────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def self.quick_metrics(root = 'src')
|
|
52
|
+
# Check if the requested directory exists before falling back
|
|
53
|
+
root_path = Pathname.new(root)
|
|
54
|
+
return { "error" => "Directory not found: #{root}" } unless root_path.directory?
|
|
55
|
+
|
|
56
|
+
root = _resolve_root(root)
|
|
57
|
+
root_path = Pathname.new(root)
|
|
58
|
+
|
|
59
|
+
rb_files = Dir.glob(root_path.join('**', '*.rb'))
|
|
60
|
+
twig_files = Dir.glob(root_path.join('**', '*.twig')) + Dir.glob(root_path.join('**', '*.erb'))
|
|
61
|
+
|
|
62
|
+
migrations_path = Pathname.new('migrations')
|
|
63
|
+
sql_files = if migrations_path.directory?
|
|
64
|
+
Dir.glob(migrations_path.join('**', '*.sql')) + Dir.glob(migrations_path.join('**', '*.rb'))
|
|
65
|
+
else
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
scss_files = Dir.glob(root_path.join('**', '*.scss')) + Dir.glob(root_path.join('**', '*.css'))
|
|
70
|
+
|
|
71
|
+
total_loc = 0
|
|
72
|
+
total_blank = 0
|
|
73
|
+
total_comment = 0
|
|
74
|
+
total_classes = 0
|
|
75
|
+
total_functions = 0
|
|
76
|
+
file_details = []
|
|
77
|
+
|
|
78
|
+
rb_files.each do |f|
|
|
79
|
+
source = begin
|
|
80
|
+
File.read(f, encoding: 'utf-8')
|
|
81
|
+
rescue StandardError
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
lines = source.lines.map(&:chomp)
|
|
86
|
+
loc = 0
|
|
87
|
+
blank = 0
|
|
88
|
+
comment = 0
|
|
89
|
+
in_heredoc = false
|
|
90
|
+
heredoc_id = nil
|
|
91
|
+
in_block_comment = false
|
|
92
|
+
|
|
93
|
+
lines.each do |line|
|
|
94
|
+
stripped = line.strip
|
|
95
|
+
|
|
96
|
+
if stripped.empty?
|
|
97
|
+
blank += 1
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# =begin/=end block comments
|
|
102
|
+
if in_block_comment
|
|
103
|
+
comment += 1
|
|
104
|
+
in_block_comment = false if stripped.start_with?('=end')
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if stripped.start_with?('=begin')
|
|
109
|
+
comment += 1
|
|
110
|
+
in_block_comment = true
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Heredoc tracking (simplified)
|
|
115
|
+
if in_heredoc
|
|
116
|
+
if stripped == heredoc_id
|
|
117
|
+
in_heredoc = false
|
|
118
|
+
end
|
|
119
|
+
loc += 1
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if stripped.match?(/<<[~-]?['"]?(\w+)['"]?/)
|
|
124
|
+
m = stripped.match(/<<[~-]?['"]?(\w+)['"]?/)
|
|
125
|
+
heredoc_id = m[1]
|
|
126
|
+
in_heredoc = true unless stripped.include?(heredoc_id + stripped[-1].to_s)
|
|
127
|
+
loc += 1
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if stripped.start_with?('#')
|
|
132
|
+
comment += 1
|
|
133
|
+
next
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
loc += 1
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Count classes and methods via simple pattern matching
|
|
140
|
+
classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
|
|
141
|
+
functions = lines.count { |l| l.strip.match?(/\Adef\s+/) }
|
|
142
|
+
|
|
143
|
+
total_loc += loc
|
|
144
|
+
total_blank += blank
|
|
145
|
+
total_comment += comment
|
|
146
|
+
total_classes += classes
|
|
147
|
+
total_functions += functions
|
|
148
|
+
|
|
149
|
+
rel_path = begin
|
|
150
|
+
Pathname.new(f).relative_path_from(root_path).to_s
|
|
151
|
+
rescue ArgumentError
|
|
152
|
+
f
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
file_details << {
|
|
156
|
+
"path" => rel_path,
|
|
157
|
+
"loc" => loc,
|
|
158
|
+
"blank" => blank,
|
|
159
|
+
"comment" => comment,
|
|
160
|
+
"classes" => classes,
|
|
161
|
+
"functions" => functions
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
file_details.sort_by! { |d| -d["loc"] }
|
|
166
|
+
|
|
167
|
+
# Route and ORM counts
|
|
168
|
+
route_count = 0
|
|
169
|
+
orm_count = 0
|
|
170
|
+
begin
|
|
171
|
+
if defined?(Tina4::Router) && Tina4::Router.respond_to?(:routes)
|
|
172
|
+
route_count = Tina4::Router.routes.length
|
|
173
|
+
elsif defined?(Tina4::Router) && Tina4::Router.instance_variable_defined?(:@routes)
|
|
174
|
+
route_count = Tina4::Router.instance_variable_get(:@routes).length
|
|
175
|
+
end
|
|
176
|
+
rescue StandardError
|
|
177
|
+
# ignore
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
if defined?(Tina4::ORM)
|
|
182
|
+
orm_count = ObjectSpace.each_object(Class).count { |c| c < Tina4::ORM }
|
|
183
|
+
end
|
|
184
|
+
rescue StandardError
|
|
185
|
+
# ignore
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
breakdown = {
|
|
189
|
+
"ruby" => rb_files.length,
|
|
190
|
+
"templates" => twig_files.length,
|
|
191
|
+
"migrations" => sql_files.length,
|
|
192
|
+
"stylesheets" => scss_files.length
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
"file_count" => rb_files.length,
|
|
197
|
+
"total_loc" => total_loc,
|
|
198
|
+
"total_blank" => total_blank,
|
|
199
|
+
"total_comment" => total_comment,
|
|
200
|
+
"lloc" => total_loc,
|
|
201
|
+
"classes" => total_classes,
|
|
202
|
+
"functions" => total_functions,
|
|
203
|
+
"route_count" => route_count,
|
|
204
|
+
"orm_count" => orm_count,
|
|
205
|
+
"template_count" => twig_files.length,
|
|
206
|
+
"migration_count" => sql_files.length,
|
|
207
|
+
"avg_file_size" => rb_files.empty? ? 0 : (total_loc.to_f / rb_files.length).round(1),
|
|
208
|
+
"largest_files" => file_details.first(10),
|
|
209
|
+
"breakdown" => breakdown
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ── Full Analysis (Ripper-based) ────────────────────────────
|
|
214
|
+
|
|
215
|
+
def self.full_analysis(root = 'src')
|
|
216
|
+
# Check if the requested directory exists before falling back
|
|
217
|
+
root_path = Pathname.new(root)
|
|
218
|
+
return { "error" => "Directory not found: #{root}" } unless root_path.directory?
|
|
219
|
+
|
|
220
|
+
root = _resolve_root(root)
|
|
221
|
+
root_path = Pathname.new(root)
|
|
222
|
+
|
|
223
|
+
current_hash = _files_hash(root)
|
|
224
|
+
now = Time.now.to_f
|
|
225
|
+
|
|
226
|
+
if @full_cache_hash == current_hash && !@full_cache_data.nil? && (now - @full_cache_time) < CACHE_TTL
|
|
227
|
+
return @full_cache_data
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
rb_files = Dir.glob(root_path.join('**', '*.rb'))
|
|
231
|
+
|
|
232
|
+
all_functions = []
|
|
233
|
+
file_metrics = []
|
|
234
|
+
import_graph = {}
|
|
235
|
+
reverse_graph = {}
|
|
236
|
+
|
|
237
|
+
rb_files.each do |f|
|
|
238
|
+
source = begin
|
|
239
|
+
File.read(f, encoding: 'utf-8')
|
|
240
|
+
rescue StandardError
|
|
241
|
+
next
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
tokens = begin
|
|
245
|
+
Ripper.lex(source)
|
|
246
|
+
rescue StandardError
|
|
247
|
+
next
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
rel_path = begin
|
|
251
|
+
Pathname.new(f).relative_path_from(root_path).to_s
|
|
252
|
+
rescue ArgumentError
|
|
253
|
+
f
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
lines = source.lines.map(&:chomp)
|
|
257
|
+
loc = lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
|
|
258
|
+
|
|
259
|
+
# Extract imports (require/require_relative)
|
|
260
|
+
imports = _extract_imports(lines)
|
|
261
|
+
import_graph[rel_path] = imports
|
|
262
|
+
|
|
263
|
+
imports.each do |imp|
|
|
264
|
+
reverse_graph[imp] ||= []
|
|
265
|
+
reverse_graph[imp] << rel_path
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Parse functions/methods and their complexity
|
|
269
|
+
file_functions = _extract_functions(source, tokens, lines)
|
|
270
|
+
file_complexity = 0
|
|
271
|
+
|
|
272
|
+
file_functions.each do |func_info|
|
|
273
|
+
func_info["file"] = rel_path
|
|
274
|
+
all_functions << func_info
|
|
275
|
+
file_complexity += func_info["complexity"]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Halstead metrics from tokens
|
|
279
|
+
halstead = _count_halstead(tokens)
|
|
280
|
+
n1 = halstead[:unique_operators].length
|
|
281
|
+
n2 = halstead[:unique_operands].length
|
|
282
|
+
n_total_1 = halstead[:operators]
|
|
283
|
+
n_total_2 = halstead[:operands]
|
|
284
|
+
vocabulary = n1 + n2
|
|
285
|
+
length = n_total_1 + n_total_2
|
|
286
|
+
volume = vocabulary > 0 ? length * Math.log2(vocabulary) : 0.0
|
|
287
|
+
|
|
288
|
+
# Maintainability index
|
|
289
|
+
avg_cc = file_functions.empty? ? 0 : file_complexity.to_f / file_functions.length
|
|
290
|
+
mi = _maintainability_index(volume, avg_cc, loc)
|
|
291
|
+
|
|
292
|
+
# Coupling
|
|
293
|
+
ce = imports.length
|
|
294
|
+
ca = (reverse_graph[rel_path] || []).length
|
|
295
|
+
instability = (ca + ce) > 0 ? ce.to_f / (ca + ce) : 0.0
|
|
296
|
+
|
|
297
|
+
file_metrics << {
|
|
298
|
+
"path" => rel_path,
|
|
299
|
+
"loc" => loc,
|
|
300
|
+
"complexity" => file_complexity,
|
|
301
|
+
"avg_complexity" => avg_cc.round(2),
|
|
302
|
+
"functions" => file_functions.length,
|
|
303
|
+
"maintainability" => mi.round(1),
|
|
304
|
+
"halstead_volume" => volume.round(1),
|
|
305
|
+
"coupling_afferent" => ca,
|
|
306
|
+
"coupling_efferent" => ce,
|
|
307
|
+
"instability" => instability.round(3),
|
|
308
|
+
"has_tests" => _has_matching_test(rel_path),
|
|
309
|
+
"dep_count" => imports.length
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Update afferent coupling now that all files are processed
|
|
314
|
+
file_metrics.each do |fm|
|
|
315
|
+
fm["coupling_afferent"] = (reverse_graph[fm["path"]] || []).length
|
|
316
|
+
ca = fm["coupling_afferent"]
|
|
317
|
+
ce = fm["coupling_efferent"]
|
|
318
|
+
fm["instability"] = (ca + ce) > 0 ? (ce.to_f / (ca + ce)).round(3) : 0.0
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
all_functions.sort_by! { |f| -f["complexity"] }
|
|
322
|
+
file_metrics.sort_by! { |f| f["maintainability"] }
|
|
323
|
+
|
|
324
|
+
violations = _detect_violations(all_functions, file_metrics)
|
|
325
|
+
|
|
326
|
+
total_cc = all_functions.sum { |f| f["complexity"] }
|
|
327
|
+
avg_cc = all_functions.empty? ? 0 : total_cc.to_f / all_functions.length
|
|
328
|
+
total_mi = file_metrics.sum { |f| f["maintainability"] }
|
|
329
|
+
avg_mi = file_metrics.empty? ? 0 : total_mi.to_f / file_metrics.length
|
|
330
|
+
|
|
331
|
+
# Detect if we're scanning framework or project
|
|
332
|
+
framework_dir = File.expand_path(File.dirname(__FILE__))
|
|
333
|
+
resolved_root = File.expand_path(root_path.to_s)
|
|
334
|
+
scanning_framework = resolved_root == framework_dir || resolved_root.start_with?(framework_dir + '/')
|
|
335
|
+
|
|
336
|
+
result = {
|
|
337
|
+
"files_analyzed" => file_metrics.length,
|
|
338
|
+
"total_functions" => all_functions.length,
|
|
339
|
+
"avg_complexity" => avg_cc.round(2),
|
|
340
|
+
"avg_maintainability" => avg_mi.round(1),
|
|
341
|
+
"most_complex_functions" => all_functions.first(15),
|
|
342
|
+
"file_metrics" => file_metrics,
|
|
343
|
+
"violations" => violations,
|
|
344
|
+
"dependency_graph" => import_graph,
|
|
345
|
+
"scan_mode" => scanning_framework ? "framework" : "project",
|
|
346
|
+
"scan_root" => resolved_root
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
@full_cache_hash = current_hash
|
|
350
|
+
@full_cache_data = result
|
|
351
|
+
@full_cache_time = now
|
|
352
|
+
|
|
353
|
+
result
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# ── File Detail ─────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
def self.file_detail(file_path)
|
|
359
|
+
unless File.exist?(file_path)
|
|
360
|
+
# Try resolving relative to the last scan root (framework mode)
|
|
361
|
+
if @last_scan_root && !@last_scan_root.empty?
|
|
362
|
+
candidate = File.join(@last_scan_root, file_path)
|
|
363
|
+
if File.exist?(candidate)
|
|
364
|
+
file_path = candidate
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
unless File.exist?(file_path)
|
|
369
|
+
return { "error" => "File not found: #{file_path}" }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
source = begin
|
|
373
|
+
File.read(file_path, encoding: 'utf-8')
|
|
374
|
+
rescue StandardError => e
|
|
375
|
+
return { "error" => "Read error: #{e.message}" }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
tokens = begin
|
|
379
|
+
Ripper.lex(source)
|
|
380
|
+
rescue StandardError => e
|
|
381
|
+
return { "error" => "Syntax error: #{e.message}" }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
lines = source.lines.map(&:chomp)
|
|
385
|
+
loc = lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
|
|
386
|
+
|
|
387
|
+
functions = _extract_functions(source, tokens, lines)
|
|
388
|
+
functions.sort_by! { |f| -f["complexity"] }
|
|
389
|
+
|
|
390
|
+
classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
|
|
391
|
+
imports = _extract_imports(lines)
|
|
392
|
+
|
|
393
|
+
warnings = []
|
|
394
|
+
functions.each do |f|
|
|
395
|
+
if f["loc"] <= 1
|
|
396
|
+
warnings << { "type" => "empty_method", "message" => "Method '#{f["name"]}' appears to be empty", "line" => f["line"] }
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
if classes > 0 && functions.empty? && loc <= 1
|
|
400
|
+
warnings << { "type" => "empty_class", "message" => "Class/module appears to be empty", "line" => 1 }
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
{
|
|
404
|
+
"path" => file_path,
|
|
405
|
+
"loc" => loc,
|
|
406
|
+
"total_lines" => lines.length,
|
|
407
|
+
"classes" => classes,
|
|
408
|
+
"functions" => functions.map { |f|
|
|
409
|
+
{
|
|
410
|
+
"name" => f["name"],
|
|
411
|
+
"line" => f["line"],
|
|
412
|
+
"complexity" => f["complexity"],
|
|
413
|
+
"loc" => f["loc"],
|
|
414
|
+
"args" => f["args"]
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
"imports" => imports,
|
|
418
|
+
"warnings" => warnings
|
|
419
|
+
}
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# ── Private Helpers ─────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
private_class_method
|
|
425
|
+
|
|
426
|
+
def self._has_matching_test(rel_path)
|
|
427
|
+
require 'set'
|
|
428
|
+
|
|
429
|
+
name = File.basename(rel_path, '.rb')
|
|
430
|
+
# Parent directory name (e.g. "database" from "database/sqlite3_adapter.rb")
|
|
431
|
+
parent_dir = File.dirname(rel_path)
|
|
432
|
+
parent_module = (parent_dir != '.' && !parent_dir.empty?) ? File.basename(parent_dir) : ''
|
|
433
|
+
|
|
434
|
+
# Stage 1: Filename matching — name_spec, name_test, test_name patterns
|
|
435
|
+
test_dirs = ['spec', 'spec/tina4', 'test', 'tests']
|
|
436
|
+
test_dirs.each do |td|
|
|
437
|
+
patterns = [
|
|
438
|
+
"#{td}/#{name}_spec.rb",
|
|
439
|
+
"#{td}/#{name}s_spec.rb",
|
|
440
|
+
"#{td}/#{name}_test.rb",
|
|
441
|
+
"#{td}/test_#{name}.rb",
|
|
442
|
+
]
|
|
443
|
+
# Also check parent-named tests (spec/database_spec.rb covers database/sqlite3_adapter.rb)
|
|
444
|
+
if parent_module && !parent_module.empty? && parent_module != name
|
|
445
|
+
patterns << "#{td}/#{parent_module}_spec.rb"
|
|
446
|
+
patterns << "#{td}/#{parent_module}s_spec.rb"
|
|
447
|
+
patterns << "#{td}/#{parent_module}_test.rb"
|
|
448
|
+
patterns << "#{td}/test_#{parent_module}.rb"
|
|
449
|
+
end
|
|
450
|
+
return true if patterns.any? { |p| File.exist?(p) }
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Build a dotted/slashed require path for import matching
|
|
454
|
+
# e.g. "lib/tina4/database/sqlite3_adapter.rb" → "tina4/database/sqlite3_adapter"
|
|
455
|
+
path_without_ext = rel_path.sub(/\.rb$/, '')
|
|
456
|
+
# Strip leading lib/ prefix if present
|
|
457
|
+
require_path = path_without_ext.sub(%r{^lib/}, '')
|
|
458
|
+
|
|
459
|
+
# Build CamelCase class name from snake_case module name
|
|
460
|
+
# e.g. "sqlite3_adapter" → "Sqlite3Adapter"
|
|
461
|
+
class_name = name.split('_').map(&:capitalize).join
|
|
462
|
+
|
|
463
|
+
# Stage 2+3: Content scan — check if any spec/test file references this module
|
|
464
|
+
scan_dirs = ['spec', 'test', 'tests']
|
|
465
|
+
scan_dirs.each do |td|
|
|
466
|
+
next unless Dir.exist?(td)
|
|
467
|
+
Dir.glob(File.join(td, '**', '*.rb')).each do |test_file|
|
|
468
|
+
content = begin
|
|
469
|
+
File.read(test_file, encoding: 'utf-8')
|
|
470
|
+
rescue StandardError
|
|
471
|
+
next
|
|
472
|
+
end
|
|
473
|
+
# Stage 2: require/require_relative path matching
|
|
474
|
+
return true if !require_path.empty? && content.include?(require_path)
|
|
475
|
+
# Stage 3: class name or module name mention
|
|
476
|
+
return true if content.match?(/\b#{Regexp.escape(class_name)}\b/)
|
|
477
|
+
return true if content.match?(/\b#{Regexp.escape(name)}\b/i)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
false
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def self._files_hash(root)
|
|
485
|
+
md5 = Digest::MD5.new
|
|
486
|
+
root_path = Pathname.new(root)
|
|
487
|
+
if root_path.directory?
|
|
488
|
+
Dir.glob(root_path.join('**', '*.rb')).sort.each do |f|
|
|
489
|
+
begin
|
|
490
|
+
md5.update("#{f}:#{File.mtime(f).to_f}")
|
|
491
|
+
rescue StandardError
|
|
492
|
+
# ignore
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
md5.hexdigest
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def self._extract_imports(lines)
|
|
500
|
+
imports = []
|
|
501
|
+
lines.each do |line|
|
|
502
|
+
stripped = line.strip
|
|
503
|
+
if stripped.match?(/\Arequire\s+/)
|
|
504
|
+
m = stripped.match(/\Arequire\s+['"]([^'"]+)['"]/)
|
|
505
|
+
imports << m[1] if m
|
|
506
|
+
elsif stripped.match?(/\Arequire_relative\s+/)
|
|
507
|
+
m = stripped.match(/\Arequire_relative\s+['"]([^'"]+)['"]/)
|
|
508
|
+
imports << m[1] if m
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
imports
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def self._extract_functions(source, tokens, lines)
|
|
515
|
+
functions = []
|
|
516
|
+
# Track class/module nesting for method names
|
|
517
|
+
context_stack = []
|
|
518
|
+
i = 0
|
|
519
|
+
|
|
520
|
+
while i < lines.length
|
|
521
|
+
stripped = lines[i].strip
|
|
522
|
+
|
|
523
|
+
# Track class/module context
|
|
524
|
+
if stripped.match?(/\A(class|module)\s+(\S+)/)
|
|
525
|
+
m = stripped.match(/\A(class|module)\s+(\S+)/)
|
|
526
|
+
class_name = m[2].to_s.split('<').first.to_s.strip
|
|
527
|
+
context_stack.push(class_name) unless class_name.empty?
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Detect method definitions
|
|
531
|
+
if stripped.match?(/\Adef\s+/)
|
|
532
|
+
method_match = stripped.match(/\Adef\s+(self\.)?(\S+?)(\(.*\))?\s*$/)
|
|
533
|
+
if method_match
|
|
534
|
+
prefix = method_match[1] ? 'self.' : ''
|
|
535
|
+
method_name = prefix + method_match[2]
|
|
536
|
+
|
|
537
|
+
# Build full name with class context
|
|
538
|
+
full_name = if context_stack.any?
|
|
539
|
+
"#{context_stack.last}.#{method_name}"
|
|
540
|
+
else
|
|
541
|
+
method_name
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Extract arguments
|
|
545
|
+
args = []
|
|
546
|
+
if method_match[3]
|
|
547
|
+
arg_str = method_match[3].gsub(/[()]/, '')
|
|
548
|
+
arg_str.split(',').each do |arg|
|
|
549
|
+
arg = arg.strip.split('=').first.strip.gsub(/^[*&]+/, '')
|
|
550
|
+
args << arg unless arg == 'self' || arg.empty?
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Find method end and calculate LOC
|
|
555
|
+
method_start = i
|
|
556
|
+
method_end = _find_method_end(lines, i)
|
|
557
|
+
method_loc = method_end - method_start + 1
|
|
558
|
+
|
|
559
|
+
# Calculate complexity for this method's body
|
|
560
|
+
method_lines = lines[method_start..method_end]
|
|
561
|
+
method_source = method_lines.join("\n")
|
|
562
|
+
cc = _cyclomatic_complexity_from_source(method_source)
|
|
563
|
+
|
|
564
|
+
functions << {
|
|
565
|
+
"name" => full_name,
|
|
566
|
+
"line" => i + 1,
|
|
567
|
+
"complexity" => cc,
|
|
568
|
+
"loc" => method_loc,
|
|
569
|
+
"args" => args
|
|
570
|
+
}
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Track end keywords for context popping
|
|
575
|
+
if stripped == 'end'
|
|
576
|
+
# Check if this closes a class/module
|
|
577
|
+
# Simple heuristic: count def/class/module opens vs end closes
|
|
578
|
+
# We only pop context when we're back at the class/module level
|
|
579
|
+
indent = lines[i].length - lines[i].lstrip.length
|
|
580
|
+
if indent == 0 && context_stack.any?
|
|
581
|
+
context_stack.pop
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
i += 1
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
functions
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def self._find_method_end(lines, start_index)
|
|
592
|
+
depth = 0
|
|
593
|
+
i = start_index
|
|
594
|
+
base_indent = lines[i].length - lines[i].lstrip.length
|
|
595
|
+
|
|
596
|
+
while i < lines.length
|
|
597
|
+
stripped = lines[i].strip
|
|
598
|
+
|
|
599
|
+
unless stripped.empty? || stripped.start_with?('#')
|
|
600
|
+
# Count block openers
|
|
601
|
+
if stripped.match?(/\b(def|class|module|if|unless|case|while|until|for|begin|do)\b/) &&
|
|
602
|
+
!stripped.match?(/\bend\b/) &&
|
|
603
|
+
!stripped.end_with?(' if ', ' unless ', ' while ', ' until ') &&
|
|
604
|
+
!(stripped.match?(/\bif\b|\bunless\b|\bwhile\b|\buntil\b/) && i != start_index && _is_modifier?(stripped))
|
|
605
|
+
depth += 1
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
if stripped == 'end' || stripped.start_with?('end ') || stripped.start_with?('end;')
|
|
609
|
+
depth -= 1
|
|
610
|
+
return i if depth <= 0
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
i += 1
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# If we never found the end, return last line
|
|
618
|
+
lines.length - 1
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def self._is_modifier?(line)
|
|
622
|
+
# A rough check: if the keyword is not at the start of the meaningful content,
|
|
623
|
+
# it's likely a modifier (e.g., "return x if condition")
|
|
624
|
+
stripped = line.strip
|
|
625
|
+
!stripped.match?(/\A(if|unless|while|until)\b/)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def self._cyclomatic_complexity_from_source(source)
|
|
629
|
+
cc = 1
|
|
630
|
+
|
|
631
|
+
# Use Ripper tokens for accurate counting
|
|
632
|
+
tokens = begin
|
|
633
|
+
Ripper.lex(source)
|
|
634
|
+
rescue StandardError
|
|
635
|
+
return cc
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
tokens.each do |(_pos, type, token)|
|
|
639
|
+
case type
|
|
640
|
+
when :on_kw
|
|
641
|
+
case token
|
|
642
|
+
when 'if', 'elsif', 'unless', 'when', 'while', 'until', 'for', 'rescue'
|
|
643
|
+
# Skip modifier forms by checking if it's the first keyword on the line
|
|
644
|
+
# For simplicity, count all — modifiers still add a decision path
|
|
645
|
+
cc += 1
|
|
646
|
+
end
|
|
647
|
+
when :on_op
|
|
648
|
+
case token
|
|
649
|
+
when '&&', '||'
|
|
650
|
+
cc += 1
|
|
651
|
+
when '?'
|
|
652
|
+
# Ternary operator
|
|
653
|
+
cc += 1
|
|
654
|
+
end
|
|
655
|
+
when :on_ident
|
|
656
|
+
# 'and' and 'or' are parsed as identifiers in some contexts
|
|
657
|
+
# but usually as keywords
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# Check for 'and'/'or' as keywords
|
|
661
|
+
if type == :on_kw && (token == 'and' || token == 'or')
|
|
662
|
+
cc += 1
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
cc
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
OPERATOR_TYPES = %i[
|
|
670
|
+
on_op
|
|
671
|
+
].freeze
|
|
672
|
+
|
|
673
|
+
OPERAND_TYPES = %i[
|
|
674
|
+
on_ident on_int on_float on_tstring_content
|
|
675
|
+
on_const on_symbeg on_rational on_imaginary
|
|
676
|
+
].freeze
|
|
677
|
+
|
|
678
|
+
def self._count_halstead(tokens)
|
|
679
|
+
stats = {
|
|
680
|
+
operators: 0,
|
|
681
|
+
operands: 0,
|
|
682
|
+
unique_operators: Set.new,
|
|
683
|
+
unique_operands: Set.new
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
# Need Set
|
|
687
|
+
require 'set' unless defined?(Set)
|
|
688
|
+
|
|
689
|
+
stats[:unique_operators] = Set.new
|
|
690
|
+
stats[:unique_operands] = Set.new
|
|
691
|
+
|
|
692
|
+
tokens.each do |(_pos, type, token)|
|
|
693
|
+
case type
|
|
694
|
+
when :on_op
|
|
695
|
+
stats[:operators] += 1
|
|
696
|
+
stats[:unique_operators].add(token)
|
|
697
|
+
when :on_kw
|
|
698
|
+
# Keywords that act as operators
|
|
699
|
+
if %w[and or not defined? return yield raise].include?(token)
|
|
700
|
+
stats[:operators] += 1
|
|
701
|
+
stats[:unique_operators].add(token)
|
|
702
|
+
end
|
|
703
|
+
when :on_ident, :on_const
|
|
704
|
+
stats[:operands] += 1
|
|
705
|
+
stats[:unique_operands].add(token)
|
|
706
|
+
when :on_int, :on_float, :on_rational, :on_imaginary
|
|
707
|
+
stats[:operands] += 1
|
|
708
|
+
stats[:unique_operands].add(token)
|
|
709
|
+
when :on_tstring_content
|
|
710
|
+
stats[:operands] += 1
|
|
711
|
+
stats[:unique_operands].add(token[0, 50])
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
stats
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def self._maintainability_index(halstead_volume, avg_cc, loc)
|
|
719
|
+
return 100.0 if loc <= 0
|
|
720
|
+
|
|
721
|
+
v = [halstead_volume, 1].max
|
|
722
|
+
mi = 171 - 5.2 * Math.log(v) - 0.23 * avg_cc - 16.2 * Math.log(loc)
|
|
723
|
+
[[0.0, mi * 100.0 / 171].max, 100.0].min
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def self._detect_violations(functions, file_metrics)
|
|
727
|
+
violations = []
|
|
728
|
+
|
|
729
|
+
functions.each do |f|
|
|
730
|
+
if f["complexity"] > 20
|
|
731
|
+
violations << {
|
|
732
|
+
"type" => "error",
|
|
733
|
+
"rule" => "high_complexity",
|
|
734
|
+
"message" => "#{f['name']} has cyclomatic complexity #{f['complexity']} (max 20)",
|
|
735
|
+
"file" => f["file"],
|
|
736
|
+
"line" => f["line"]
|
|
737
|
+
}
|
|
738
|
+
elsif f["complexity"] > 10
|
|
739
|
+
violations << {
|
|
740
|
+
"type" => "warning",
|
|
741
|
+
"rule" => "moderate_complexity",
|
|
742
|
+
"message" => "#{f['name']} has cyclomatic complexity #{f['complexity']} (recommended max 10)",
|
|
743
|
+
"file" => f["file"],
|
|
744
|
+
"line" => f["line"]
|
|
745
|
+
}
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
file_metrics.each do |fm|
|
|
750
|
+
if fm["loc"] > 500
|
|
751
|
+
violations << {
|
|
752
|
+
"type" => "warning",
|
|
753
|
+
"rule" => "large_file",
|
|
754
|
+
"message" => "#{fm['path']} has #{fm['loc']} LOC (recommended max 500)",
|
|
755
|
+
"file" => fm["path"],
|
|
756
|
+
"line" => 1
|
|
757
|
+
}
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
if fm["functions"] > 20
|
|
761
|
+
violations << {
|
|
762
|
+
"type" => "warning",
|
|
763
|
+
"rule" => "too_many_functions",
|
|
764
|
+
"message" => "#{fm['path']} has #{fm['functions']} functions (recommended max 20)",
|
|
765
|
+
"file" => fm["path"],
|
|
766
|
+
"line" => 1
|
|
767
|
+
}
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
if fm["maintainability"] < 20
|
|
771
|
+
violations << {
|
|
772
|
+
"type" => "error",
|
|
773
|
+
"rule" => "low_maintainability",
|
|
774
|
+
"message" => "#{fm['path']} has maintainability index #{fm['maintainability']} (min 20)",
|
|
775
|
+
"file" => fm["path"],
|
|
776
|
+
"line" => 1
|
|
777
|
+
}
|
|
778
|
+
elsif fm["maintainability"] < 40
|
|
779
|
+
violations << {
|
|
780
|
+
"type" => "warning",
|
|
781
|
+
"rule" => "moderate_maintainability",
|
|
782
|
+
"message" => "#{fm['path']} has maintainability index #{fm['maintainability']} (recommended min 40)",
|
|
783
|
+
"file" => fm["path"],
|
|
784
|
+
"line" => 1
|
|
785
|
+
}
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
violations.sort_by! { |v| [v["type"] == "error" ? 0 : 1, v["file"]] }
|
|
790
|
+
violations
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
end
|