tina4ruby 3.10.32 → 3.10.38
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/lib/tina4/ai.rb +237 -211
- data/lib/tina4/cli.rb +5 -13
- data/lib/tina4/dev_admin.rb +281 -2
- data/lib/tina4/frond.rb +15 -3
- data/lib/tina4/metrics.rb +673 -0
- data/lib/tina4/rack_app.rb +57 -2
- data/lib/tina4/request.rb +40 -2
- data/lib/tina4/response.rb +7 -2
- data/lib/tina4/router.rb +5 -1
- data/lib/tina4/version.rb +1 -1
- metadata +8 -4
|
@@ -0,0 +1,673 @@
|
|
|
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
|
+
# ── Quick Metrics ───────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def self.quick_metrics(root = 'src')
|
|
27
|
+
root_path = Pathname.new(root)
|
|
28
|
+
return { "error" => "Directory not found: #{root}" } unless root_path.directory?
|
|
29
|
+
|
|
30
|
+
rb_files = Dir.glob(root_path.join('**', '*.rb'))
|
|
31
|
+
twig_files = Dir.glob(root_path.join('**', '*.twig')) + Dir.glob(root_path.join('**', '*.erb'))
|
|
32
|
+
|
|
33
|
+
migrations_path = Pathname.new('migrations')
|
|
34
|
+
sql_files = if migrations_path.directory?
|
|
35
|
+
Dir.glob(migrations_path.join('**', '*.sql')) + Dir.glob(migrations_path.join('**', '*.rb'))
|
|
36
|
+
else
|
|
37
|
+
[]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
scss_files = Dir.glob(root_path.join('**', '*.scss')) + Dir.glob(root_path.join('**', '*.css'))
|
|
41
|
+
|
|
42
|
+
total_loc = 0
|
|
43
|
+
total_blank = 0
|
|
44
|
+
total_comment = 0
|
|
45
|
+
total_classes = 0
|
|
46
|
+
total_functions = 0
|
|
47
|
+
file_details = []
|
|
48
|
+
|
|
49
|
+
rb_files.each do |f|
|
|
50
|
+
source = begin
|
|
51
|
+
File.read(f, encoding: 'utf-8')
|
|
52
|
+
rescue StandardError
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
lines = source.lines.map(&:chomp)
|
|
57
|
+
loc = 0
|
|
58
|
+
blank = 0
|
|
59
|
+
comment = 0
|
|
60
|
+
in_heredoc = false
|
|
61
|
+
heredoc_id = nil
|
|
62
|
+
in_block_comment = false
|
|
63
|
+
|
|
64
|
+
lines.each do |line|
|
|
65
|
+
stripped = line.strip
|
|
66
|
+
|
|
67
|
+
if stripped.empty?
|
|
68
|
+
blank += 1
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# =begin/=end block comments
|
|
73
|
+
if in_block_comment
|
|
74
|
+
comment += 1
|
|
75
|
+
in_block_comment = false if stripped.start_with?('=end')
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if stripped.start_with?('=begin')
|
|
80
|
+
comment += 1
|
|
81
|
+
in_block_comment = true
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Heredoc tracking (simplified)
|
|
86
|
+
if in_heredoc
|
|
87
|
+
if stripped == heredoc_id
|
|
88
|
+
in_heredoc = false
|
|
89
|
+
end
|
|
90
|
+
loc += 1
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if stripped.match?(/<<[~-]?['"]?(\w+)['"]?/)
|
|
95
|
+
m = stripped.match(/<<[~-]?['"]?(\w+)['"]?/)
|
|
96
|
+
heredoc_id = m[1]
|
|
97
|
+
in_heredoc = true unless stripped.include?(heredoc_id + stripped[-1].to_s)
|
|
98
|
+
loc += 1
|
|
99
|
+
next
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if stripped.start_with?('#')
|
|
103
|
+
comment += 1
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
loc += 1
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Count classes and methods via simple pattern matching
|
|
111
|
+
classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
|
|
112
|
+
functions = lines.count { |l| l.strip.match?(/\Adef\s+/) }
|
|
113
|
+
|
|
114
|
+
total_loc += loc
|
|
115
|
+
total_blank += blank
|
|
116
|
+
total_comment += comment
|
|
117
|
+
total_classes += classes
|
|
118
|
+
total_functions += functions
|
|
119
|
+
|
|
120
|
+
rel_path = begin
|
|
121
|
+
Pathname.new(f).relative_path_from(Pathname.new('.')).to_s
|
|
122
|
+
rescue ArgumentError
|
|
123
|
+
f
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
file_details << {
|
|
127
|
+
"path" => rel_path,
|
|
128
|
+
"loc" => loc,
|
|
129
|
+
"blank" => blank,
|
|
130
|
+
"comment" => comment,
|
|
131
|
+
"classes" => classes,
|
|
132
|
+
"functions" => functions
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
file_details.sort_by! { |d| -d["loc"] }
|
|
137
|
+
|
|
138
|
+
# Route and ORM counts
|
|
139
|
+
route_count = 0
|
|
140
|
+
orm_count = 0
|
|
141
|
+
begin
|
|
142
|
+
if defined?(Tina4::Router) && Tina4::Router.respond_to?(:routes)
|
|
143
|
+
route_count = Tina4::Router.routes.length
|
|
144
|
+
elsif defined?(Tina4::Router) && Tina4::Router.instance_variable_defined?(:@routes)
|
|
145
|
+
route_count = Tina4::Router.instance_variable_get(:@routes).length
|
|
146
|
+
end
|
|
147
|
+
rescue StandardError
|
|
148
|
+
# ignore
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
begin
|
|
152
|
+
if defined?(Tina4::ORM)
|
|
153
|
+
orm_count = ObjectSpace.each_object(Class).count { |c| c < Tina4::ORM }
|
|
154
|
+
end
|
|
155
|
+
rescue StandardError
|
|
156
|
+
# ignore
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
breakdown = {
|
|
160
|
+
"ruby" => rb_files.length,
|
|
161
|
+
"templates" => twig_files.length,
|
|
162
|
+
"migrations" => sql_files.length,
|
|
163
|
+
"stylesheets" => scss_files.length
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
"file_count" => rb_files.length,
|
|
168
|
+
"total_loc" => total_loc,
|
|
169
|
+
"total_blank" => total_blank,
|
|
170
|
+
"total_comment" => total_comment,
|
|
171
|
+
"lloc" => total_loc,
|
|
172
|
+
"classes" => total_classes,
|
|
173
|
+
"functions" => total_functions,
|
|
174
|
+
"route_count" => route_count,
|
|
175
|
+
"orm_count" => orm_count,
|
|
176
|
+
"template_count" => twig_files.length,
|
|
177
|
+
"migration_count" => sql_files.length,
|
|
178
|
+
"avg_file_size" => rb_files.empty? ? 0 : (total_loc.to_f / rb_files.length).round(1),
|
|
179
|
+
"largest_files" => file_details.first(10),
|
|
180
|
+
"breakdown" => breakdown
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ── Full Analysis (Ripper-based) ────────────────────────────
|
|
185
|
+
|
|
186
|
+
def self.full_analysis(root = 'src')
|
|
187
|
+
root_path = Pathname.new(root)
|
|
188
|
+
return { "error" => "Directory not found: #{root}" } unless root_path.directory?
|
|
189
|
+
|
|
190
|
+
current_hash = _files_hash(root)
|
|
191
|
+
now = Time.now.to_f
|
|
192
|
+
|
|
193
|
+
if @full_cache_hash == current_hash && !@full_cache_data.nil? && (now - @full_cache_time) < CACHE_TTL
|
|
194
|
+
return @full_cache_data
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
rb_files = Dir.glob(root_path.join('**', '*.rb'))
|
|
198
|
+
|
|
199
|
+
all_functions = []
|
|
200
|
+
file_metrics = []
|
|
201
|
+
import_graph = {}
|
|
202
|
+
reverse_graph = {}
|
|
203
|
+
|
|
204
|
+
rb_files.each do |f|
|
|
205
|
+
source = begin
|
|
206
|
+
File.read(f, encoding: 'utf-8')
|
|
207
|
+
rescue StandardError
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
tokens = begin
|
|
212
|
+
Ripper.lex(source)
|
|
213
|
+
rescue StandardError
|
|
214
|
+
next
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
rel_path = begin
|
|
218
|
+
Pathname.new(f).relative_path_from(Pathname.new('.')).to_s
|
|
219
|
+
rescue ArgumentError
|
|
220
|
+
f
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
lines = source.lines.map(&:chomp)
|
|
224
|
+
loc = lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
|
|
225
|
+
|
|
226
|
+
# Extract imports (require/require_relative)
|
|
227
|
+
imports = _extract_imports(lines)
|
|
228
|
+
import_graph[rel_path] = imports
|
|
229
|
+
|
|
230
|
+
imports.each do |imp|
|
|
231
|
+
reverse_graph[imp] ||= []
|
|
232
|
+
reverse_graph[imp] << rel_path
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Parse functions/methods and their complexity
|
|
236
|
+
file_functions = _extract_functions(source, tokens, lines)
|
|
237
|
+
file_complexity = 0
|
|
238
|
+
|
|
239
|
+
file_functions.each do |func_info|
|
|
240
|
+
func_info["file"] = rel_path
|
|
241
|
+
all_functions << func_info
|
|
242
|
+
file_complexity += func_info["complexity"]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Halstead metrics from tokens
|
|
246
|
+
halstead = _count_halstead(tokens)
|
|
247
|
+
n1 = halstead[:unique_operators].length
|
|
248
|
+
n2 = halstead[:unique_operands].length
|
|
249
|
+
n_total_1 = halstead[:operators]
|
|
250
|
+
n_total_2 = halstead[:operands]
|
|
251
|
+
vocabulary = n1 + n2
|
|
252
|
+
length = n_total_1 + n_total_2
|
|
253
|
+
volume = vocabulary > 0 ? length * Math.log2(vocabulary) : 0.0
|
|
254
|
+
|
|
255
|
+
# Maintainability index
|
|
256
|
+
avg_cc = file_functions.empty? ? 0 : file_complexity.to_f / file_functions.length
|
|
257
|
+
mi = _maintainability_index(volume, avg_cc, loc)
|
|
258
|
+
|
|
259
|
+
# Coupling
|
|
260
|
+
ce = imports.length
|
|
261
|
+
ca = (reverse_graph[rel_path] || []).length
|
|
262
|
+
instability = (ca + ce) > 0 ? ce.to_f / (ca + ce) : 0.0
|
|
263
|
+
|
|
264
|
+
file_metrics << {
|
|
265
|
+
"path" => rel_path,
|
|
266
|
+
"loc" => loc,
|
|
267
|
+
"complexity" => file_complexity,
|
|
268
|
+
"avg_complexity" => avg_cc.round(2),
|
|
269
|
+
"functions" => file_functions.length,
|
|
270
|
+
"maintainability" => mi.round(1),
|
|
271
|
+
"halstead_volume" => volume.round(1),
|
|
272
|
+
"coupling_afferent" => ca,
|
|
273
|
+
"coupling_efferent" => ce,
|
|
274
|
+
"instability" => instability.round(3)
|
|
275
|
+
}
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Update afferent coupling now that all files are processed
|
|
279
|
+
file_metrics.each do |fm|
|
|
280
|
+
fm["coupling_afferent"] = (reverse_graph[fm["path"]] || []).length
|
|
281
|
+
ca = fm["coupling_afferent"]
|
|
282
|
+
ce = fm["coupling_efferent"]
|
|
283
|
+
fm["instability"] = (ca + ce) > 0 ? (ce.to_f / (ca + ce)).round(3) : 0.0
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
all_functions.sort_by! { |f| -f["complexity"] }
|
|
287
|
+
file_metrics.sort_by! { |f| f["maintainability"] }
|
|
288
|
+
|
|
289
|
+
violations = _detect_violations(all_functions, file_metrics)
|
|
290
|
+
|
|
291
|
+
total_cc = all_functions.sum { |f| f["complexity"] }
|
|
292
|
+
avg_cc = all_functions.empty? ? 0 : total_cc.to_f / all_functions.length
|
|
293
|
+
total_mi = file_metrics.sum { |f| f["maintainability"] }
|
|
294
|
+
avg_mi = file_metrics.empty? ? 0 : total_mi.to_f / file_metrics.length
|
|
295
|
+
|
|
296
|
+
result = {
|
|
297
|
+
"files_analyzed" => file_metrics.length,
|
|
298
|
+
"total_functions" => all_functions.length,
|
|
299
|
+
"avg_complexity" => avg_cc.round(2),
|
|
300
|
+
"avg_maintainability" => avg_mi.round(1),
|
|
301
|
+
"most_complex_functions" => all_functions.first(15),
|
|
302
|
+
"file_metrics" => file_metrics,
|
|
303
|
+
"violations" => violations,
|
|
304
|
+
"dependency_graph" => import_graph
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@full_cache_hash = current_hash
|
|
308
|
+
@full_cache_data = result
|
|
309
|
+
@full_cache_time = now
|
|
310
|
+
|
|
311
|
+
result
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# ── File Detail ─────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
def self.file_detail(file_path)
|
|
317
|
+
unless File.exist?(file_path)
|
|
318
|
+
return { "error" => "File not found: #{file_path}" }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
source = begin
|
|
322
|
+
File.read(file_path, encoding: 'utf-8')
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
return { "error" => "Read error: #{e.message}" }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
tokens = begin
|
|
328
|
+
Ripper.lex(source)
|
|
329
|
+
rescue StandardError => e
|
|
330
|
+
return { "error" => "Syntax error: #{e.message}" }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
lines = source.lines.map(&:chomp)
|
|
334
|
+
loc = lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
|
|
335
|
+
|
|
336
|
+
functions = _extract_functions(source, tokens, lines)
|
|
337
|
+
functions.sort_by! { |f| -f["complexity"] }
|
|
338
|
+
|
|
339
|
+
classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
|
|
340
|
+
imports = _extract_imports(lines)
|
|
341
|
+
|
|
342
|
+
{
|
|
343
|
+
"path" => file_path,
|
|
344
|
+
"loc" => loc,
|
|
345
|
+
"total_lines" => lines.length,
|
|
346
|
+
"classes" => classes,
|
|
347
|
+
"functions" => functions.map { |f|
|
|
348
|
+
{
|
|
349
|
+
"name" => f["name"],
|
|
350
|
+
"line" => f["line"],
|
|
351
|
+
"complexity" => f["complexity"],
|
|
352
|
+
"loc" => f["loc"],
|
|
353
|
+
"args" => f["args"]
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
"imports" => imports
|
|
357
|
+
}
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# ── Private Helpers ─────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
private_class_method
|
|
363
|
+
|
|
364
|
+
def self._files_hash(root)
|
|
365
|
+
md5 = Digest::MD5.new
|
|
366
|
+
root_path = Pathname.new(root)
|
|
367
|
+
if root_path.directory?
|
|
368
|
+
Dir.glob(root_path.join('**', '*.rb')).sort.each do |f|
|
|
369
|
+
begin
|
|
370
|
+
md5.update("#{f}:#{File.mtime(f).to_f}")
|
|
371
|
+
rescue StandardError
|
|
372
|
+
# ignore
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
md5.hexdigest
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def self._extract_imports(lines)
|
|
380
|
+
imports = []
|
|
381
|
+
lines.each do |line|
|
|
382
|
+
stripped = line.strip
|
|
383
|
+
if stripped.match?(/\Arequire\s+/)
|
|
384
|
+
m = stripped.match(/\Arequire\s+['"]([^'"]+)['"]/)
|
|
385
|
+
imports << m[1] if m
|
|
386
|
+
elsif stripped.match?(/\Arequire_relative\s+/)
|
|
387
|
+
m = stripped.match(/\Arequire_relative\s+['"]([^'"]+)['"]/)
|
|
388
|
+
imports << m[1] if m
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
imports
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def self._extract_functions(source, tokens, lines)
|
|
395
|
+
functions = []
|
|
396
|
+
# Track class/module nesting for method names
|
|
397
|
+
context_stack = []
|
|
398
|
+
i = 0
|
|
399
|
+
|
|
400
|
+
while i < lines.length
|
|
401
|
+
stripped = lines[i].strip
|
|
402
|
+
|
|
403
|
+
# Track class/module context
|
|
404
|
+
if stripped.match?(/\A(class|module)\s+(\S+)/)
|
|
405
|
+
m = stripped.match(/\A(class|module)\s+(\S+)/)
|
|
406
|
+
class_name = m[2].to_s.split('<').first.to_s.strip
|
|
407
|
+
context_stack.push(class_name) unless class_name.empty?
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Detect method definitions
|
|
411
|
+
if stripped.match?(/\Adef\s+/)
|
|
412
|
+
method_match = stripped.match(/\Adef\s+(self\.)?(\S+?)(\(.*\))?\s*$/)
|
|
413
|
+
if method_match
|
|
414
|
+
prefix = method_match[1] ? 'self.' : ''
|
|
415
|
+
method_name = prefix + method_match[2]
|
|
416
|
+
|
|
417
|
+
# Build full name with class context
|
|
418
|
+
full_name = if context_stack.any?
|
|
419
|
+
"#{context_stack.last}.#{method_name}"
|
|
420
|
+
else
|
|
421
|
+
method_name
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Extract arguments
|
|
425
|
+
args = []
|
|
426
|
+
if method_match[3]
|
|
427
|
+
arg_str = method_match[3].gsub(/[()]/, '')
|
|
428
|
+
arg_str.split(',').each do |arg|
|
|
429
|
+
arg = arg.strip.split('=').first.strip.gsub(/^[*&]+/, '')
|
|
430
|
+
args << arg unless arg == 'self' || arg.empty?
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Find method end and calculate LOC
|
|
435
|
+
method_start = i
|
|
436
|
+
method_end = _find_method_end(lines, i)
|
|
437
|
+
method_loc = method_end - method_start + 1
|
|
438
|
+
|
|
439
|
+
# Calculate complexity for this method's body
|
|
440
|
+
method_lines = lines[method_start..method_end]
|
|
441
|
+
method_source = method_lines.join("\n")
|
|
442
|
+
cc = _cyclomatic_complexity_from_source(method_source)
|
|
443
|
+
|
|
444
|
+
functions << {
|
|
445
|
+
"name" => full_name,
|
|
446
|
+
"line" => i + 1,
|
|
447
|
+
"complexity" => cc,
|
|
448
|
+
"loc" => method_loc,
|
|
449
|
+
"args" => args
|
|
450
|
+
}
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Track end keywords for context popping
|
|
455
|
+
if stripped == 'end'
|
|
456
|
+
# Check if this closes a class/module
|
|
457
|
+
# Simple heuristic: count def/class/module opens vs end closes
|
|
458
|
+
# We only pop context when we're back at the class/module level
|
|
459
|
+
indent = lines[i].length - lines[i].lstrip.length
|
|
460
|
+
if indent == 0 && context_stack.any?
|
|
461
|
+
context_stack.pop
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
i += 1
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
functions
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def self._find_method_end(lines, start_index)
|
|
472
|
+
depth = 0
|
|
473
|
+
i = start_index
|
|
474
|
+
base_indent = lines[i].length - lines[i].lstrip.length
|
|
475
|
+
|
|
476
|
+
while i < lines.length
|
|
477
|
+
stripped = lines[i].strip
|
|
478
|
+
|
|
479
|
+
unless stripped.empty? || stripped.start_with?('#')
|
|
480
|
+
# Count block openers
|
|
481
|
+
if stripped.match?(/\b(def|class|module|if|unless|case|while|until|for|begin|do)\b/) &&
|
|
482
|
+
!stripped.match?(/\bend\b/) &&
|
|
483
|
+
!stripped.end_with?(' if ', ' unless ', ' while ', ' until ') &&
|
|
484
|
+
!(stripped.match?(/\bif\b|\bunless\b|\bwhile\b|\buntil\b/) && i != start_index && _is_modifier?(stripped))
|
|
485
|
+
depth += 1
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
if stripped == 'end' || stripped.start_with?('end ') || stripped.start_with?('end;')
|
|
489
|
+
depth -= 1
|
|
490
|
+
return i if depth <= 0
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
i += 1
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# If we never found the end, return last line
|
|
498
|
+
lines.length - 1
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def self._is_modifier?(line)
|
|
502
|
+
# A rough check: if the keyword is not at the start of the meaningful content,
|
|
503
|
+
# it's likely a modifier (e.g., "return x if condition")
|
|
504
|
+
stripped = line.strip
|
|
505
|
+
!stripped.match?(/\A(if|unless|while|until)\b/)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def self._cyclomatic_complexity_from_source(source)
|
|
509
|
+
cc = 1
|
|
510
|
+
|
|
511
|
+
# Use Ripper tokens for accurate counting
|
|
512
|
+
tokens = begin
|
|
513
|
+
Ripper.lex(source)
|
|
514
|
+
rescue StandardError
|
|
515
|
+
return cc
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
tokens.each do |(_pos, type, token)|
|
|
519
|
+
case type
|
|
520
|
+
when :on_kw
|
|
521
|
+
case token
|
|
522
|
+
when 'if', 'elsif', 'unless', 'when', 'while', 'until', 'for', 'rescue'
|
|
523
|
+
# Skip modifier forms by checking if it's the first keyword on the line
|
|
524
|
+
# For simplicity, count all — modifiers still add a decision path
|
|
525
|
+
cc += 1
|
|
526
|
+
end
|
|
527
|
+
when :on_op
|
|
528
|
+
case token
|
|
529
|
+
when '&&', '||'
|
|
530
|
+
cc += 1
|
|
531
|
+
when '?'
|
|
532
|
+
# Ternary operator
|
|
533
|
+
cc += 1
|
|
534
|
+
end
|
|
535
|
+
when :on_ident
|
|
536
|
+
# 'and' and 'or' are parsed as identifiers in some contexts
|
|
537
|
+
# but usually as keywords
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Check for 'and'/'or' as keywords
|
|
541
|
+
if type == :on_kw && (token == 'and' || token == 'or')
|
|
542
|
+
cc += 1
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
cc
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
OPERATOR_TYPES = %i[
|
|
550
|
+
on_op
|
|
551
|
+
].freeze
|
|
552
|
+
|
|
553
|
+
OPERAND_TYPES = %i[
|
|
554
|
+
on_ident on_int on_float on_tstring_content
|
|
555
|
+
on_const on_symbeg on_rational on_imaginary
|
|
556
|
+
].freeze
|
|
557
|
+
|
|
558
|
+
def self._count_halstead(tokens)
|
|
559
|
+
stats = {
|
|
560
|
+
operators: 0,
|
|
561
|
+
operands: 0,
|
|
562
|
+
unique_operators: Set.new,
|
|
563
|
+
unique_operands: Set.new
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
# Need Set
|
|
567
|
+
require 'set' unless defined?(Set)
|
|
568
|
+
|
|
569
|
+
stats[:unique_operators] = Set.new
|
|
570
|
+
stats[:unique_operands] = Set.new
|
|
571
|
+
|
|
572
|
+
tokens.each do |(_pos, type, token)|
|
|
573
|
+
case type
|
|
574
|
+
when :on_op
|
|
575
|
+
stats[:operators] += 1
|
|
576
|
+
stats[:unique_operators].add(token)
|
|
577
|
+
when :on_kw
|
|
578
|
+
# Keywords that act as operators
|
|
579
|
+
if %w[and or not defined? return yield raise].include?(token)
|
|
580
|
+
stats[:operators] += 1
|
|
581
|
+
stats[:unique_operators].add(token)
|
|
582
|
+
end
|
|
583
|
+
when :on_ident, :on_const
|
|
584
|
+
stats[:operands] += 1
|
|
585
|
+
stats[:unique_operands].add(token)
|
|
586
|
+
when :on_int, :on_float, :on_rational, :on_imaginary
|
|
587
|
+
stats[:operands] += 1
|
|
588
|
+
stats[:unique_operands].add(token)
|
|
589
|
+
when :on_tstring_content
|
|
590
|
+
stats[:operands] += 1
|
|
591
|
+
stats[:unique_operands].add(token[0, 50])
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
stats
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def self._maintainability_index(halstead_volume, avg_cc, loc)
|
|
599
|
+
return 100.0 if loc <= 0
|
|
600
|
+
|
|
601
|
+
v = [halstead_volume, 1].max
|
|
602
|
+
mi = 171 - 5.2 * Math.log(v) - 0.23 * avg_cc - 16.2 * Math.log(loc)
|
|
603
|
+
[[0.0, mi * 100.0 / 171].max, 100.0].min
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def self._detect_violations(functions, file_metrics)
|
|
607
|
+
violations = []
|
|
608
|
+
|
|
609
|
+
functions.each do |f|
|
|
610
|
+
if f["complexity"] > 20
|
|
611
|
+
violations << {
|
|
612
|
+
"type" => "error",
|
|
613
|
+
"rule" => "high_complexity",
|
|
614
|
+
"message" => "#{f['name']} has cyclomatic complexity #{f['complexity']} (max 20)",
|
|
615
|
+
"file" => f["file"],
|
|
616
|
+
"line" => f["line"]
|
|
617
|
+
}
|
|
618
|
+
elsif f["complexity"] > 10
|
|
619
|
+
violations << {
|
|
620
|
+
"type" => "warning",
|
|
621
|
+
"rule" => "moderate_complexity",
|
|
622
|
+
"message" => "#{f['name']} has cyclomatic complexity #{f['complexity']} (recommended max 10)",
|
|
623
|
+
"file" => f["file"],
|
|
624
|
+
"line" => f["line"]
|
|
625
|
+
}
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
file_metrics.each do |fm|
|
|
630
|
+
if fm["loc"] > 500
|
|
631
|
+
violations << {
|
|
632
|
+
"type" => "warning",
|
|
633
|
+
"rule" => "large_file",
|
|
634
|
+
"message" => "#{fm['path']} has #{fm['loc']} LOC (recommended max 500)",
|
|
635
|
+
"file" => fm["path"],
|
|
636
|
+
"line" => 1
|
|
637
|
+
}
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
if fm["functions"] > 20
|
|
641
|
+
violations << {
|
|
642
|
+
"type" => "warning",
|
|
643
|
+
"rule" => "too_many_functions",
|
|
644
|
+
"message" => "#{fm['path']} has #{fm['functions']} functions (recommended max 20)",
|
|
645
|
+
"file" => fm["path"],
|
|
646
|
+
"line" => 1
|
|
647
|
+
}
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
if fm["maintainability"] < 20
|
|
651
|
+
violations << {
|
|
652
|
+
"type" => "error",
|
|
653
|
+
"rule" => "low_maintainability",
|
|
654
|
+
"message" => "#{fm['path']} has maintainability index #{fm['maintainability']} (min 20)",
|
|
655
|
+
"file" => fm["path"],
|
|
656
|
+
"line" => 1
|
|
657
|
+
}
|
|
658
|
+
elsif fm["maintainability"] < 40
|
|
659
|
+
violations << {
|
|
660
|
+
"type" => "warning",
|
|
661
|
+
"rule" => "moderate_maintainability",
|
|
662
|
+
"message" => "#{fm['path']} has maintainability index #{fm['maintainability']} (recommended min 40)",
|
|
663
|
+
"file" => fm["path"],
|
|
664
|
+
"line" => 1
|
|
665
|
+
}
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
violations.sort_by! { |v| [v["type"] == "error" ? 0 : 1, v["file"]] }
|
|
670
|
+
violations
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
end
|