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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. 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