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.
@@ -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