tina4ruby 3.13.99 → 3.13.101

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.
data/lib/tina4/metrics.rb CHANGED
@@ -1,426 +1,117 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Tina4 Code Metrics — the native engine (ADR-0002) plus an instant file census.
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, offenders
9
- #
10
- # Zero dependencies. The census is pure Ruby; the analysis is `tina4 metrics --json`.
11
-
12
3
  require 'json'
13
- require 'digest'
4
+ require 'open3'
14
5
  require 'pathname'
15
6
 
16
7
  module Tina4
17
- # The native metrics engine could not produce a payload.
18
- #
19
- # Raised instead of falling back to a second implementation: two engines is
20
- # exactly the condition that made the four frameworks' numbers incomparable.
21
8
  class MetricsEngineError < StandardError; end
22
9
 
10
+ # Thin dev-admin adapter for the native `tina4 metrics` engine (ADR-0054).
23
11
  module Metrics
24
- # ── Cache ───────────────────────────────────────────────────
25
- @full_cache_hash = ""
26
- @full_cache_data = nil
27
- @full_cache_time = 0
28
- CACHE_TTL = 60
29
-
30
- # Stores the resolved scan root so file_detail can locate framework files.
31
- @last_scan_root = ""
32
-
33
- # ── Root Resolution ──────────────────────────────────────────
34
-
35
- # Pick the right directory to scan.
36
- #
37
- # If the root dir has Ruby files, scan the user's project code.
38
- # Otherwise, scan the framework itself — so the bubble chart is never empty.
39
- def self._resolve_root(root = 'src')
40
- root_path = Pathname.new(root)
41
- if root_path.directory? && !Dir.glob(root_path.join('**', '*.rb')).empty?
42
- @last_scan_root = File.expand_path(root)
43
- return root
44
- end
45
- # Fallback: scan the framework package itself
46
- fw_dir = File.dirname(__FILE__)
47
- @last_scan_root = fw_dir
48
- fw_dir
49
- end
50
-
51
- def self.last_scan_root
52
- @last_scan_root
53
- end
54
-
55
- # Return [directory to scan, scan_mode] for any metrics producer.
56
- #
57
- # The CLI engine is language-agnostic and cannot know which directory holds a
58
- # framework package, so root resolution and the "framework" label stay here,
59
- # shared by the census and the engine adapter so the two never disagree about
60
- # what was measured.
61
- def self.resolve_scan_target(root = 'src')
62
- resolved = _resolve_root(root)
63
- framework_dir = File.dirname(__FILE__)
64
- resolved_real = File.expand_path(resolved)
65
- scanning_framework = resolved_real == framework_dir || resolved_real.start_with?(framework_dir)
66
- [resolved, scanning_framework ? 'framework' : 'project']
67
- end
68
-
69
- # ── Quick Metrics ───────────────────────────────────────────
70
-
71
- def self.quick_metrics(root = 'src')
72
- # Check if the requested directory exists before falling back
73
- root_path = Pathname.new(root)
74
- return { "error" => "Directory not found: #{root}" } unless root_path.directory?
75
-
76
- root = _resolve_root(root)
77
- root_path = Pathname.new(root)
78
-
79
- rb_files = Dir.glob(root_path.join('**', '*.rb'))
80
- twig_files = Dir.glob(root_path.join('**', '*.twig')) + Dir.glob(root_path.join('**', '*.erb'))
81
-
82
- migrations_path = Pathname.new('migrations')
83
- sql_files = if migrations_path.directory?
84
- Dir.glob(migrations_path.join('**', '*.sql')) + Dir.glob(migrations_path.join('**', '*.rb'))
85
- else
86
- []
87
- end
88
-
89
- scss_files = Dir.glob(root_path.join('**', '*.scss')) + Dir.glob(root_path.join('**', '*.css'))
90
-
91
- total_loc = 0
92
- total_blank = 0
93
- total_comment = 0
94
- total_classes = 0
95
- total_functions = 0
96
- file_details = []
97
-
98
- rb_files.each do |f|
99
- source = begin
100
- File.read(f, encoding: 'utf-8')
101
- rescue StandardError
102
- next
103
- end
104
-
105
- lines = source.lines.map(&:chomp)
106
- loc = 0
107
- blank = 0
108
- comment = 0
109
- in_heredoc = false
110
- heredoc_id = nil
111
- in_block_comment = false
112
-
113
- lines.each do |line|
114
- stripped = line.strip
115
-
116
- if stripped.empty?
117
- blank += 1
118
- next
119
- end
120
-
121
- # =begin/=end block comments
122
- if in_block_comment
123
- comment += 1
124
- in_block_comment = false if stripped.start_with?('=end')
125
- next
126
- end
127
-
128
- if stripped.start_with?('=begin')
129
- comment += 1
130
- in_block_comment = true
131
- next
132
- end
133
-
134
- # Heredoc tracking (simplified)
135
- if in_heredoc
136
- if stripped == heredoc_id
137
- in_heredoc = false
138
- end
139
- loc += 1
140
- next
141
- end
142
-
143
- if stripped.match?(/<<[~-]?['"]?(\w+)['"]?/)
144
- m = stripped.match(/<<[~-]?['"]?(\w+)['"]?/)
145
- heredoc_id = m[1]
146
- in_heredoc = true unless stripped.include?(heredoc_id + stripped[-1].to_s)
147
- loc += 1
148
- next
149
- end
150
-
151
- if stripped.start_with?('#')
152
- comment += 1
153
- next
154
- end
155
-
156
- loc += 1
157
- end
158
-
159
- # Count classes and methods via simple pattern matching
160
- classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
161
- functions = lines.count { |l| l.strip.match?(/\Adef\s+/) }
162
-
163
- total_loc += loc
164
- total_blank += blank
165
- total_comment += comment
166
- total_classes += classes
167
- total_functions += functions
168
-
169
- rel_path = begin
170
- Pathname.new(f).relative_path_from(root_path).to_s
171
- rescue ArgumentError
172
- f
173
- end
174
-
175
- file_details << {
176
- "path" => rel_path,
177
- "loc" => loc,
178
- "blank" => blank,
179
- "comment" => comment,
180
- "classes" => classes,
181
- "functions" => functions
182
- }
183
- end
184
-
185
- file_details.sort_by! { |d| -d["loc"] }
186
-
187
- # Route and ORM counts
188
- route_count = 0
189
- orm_count = 0
190
- begin
191
- if defined?(Tina4::Router) && Tina4::Router.respond_to?(:routes)
192
- route_count = Tina4::Router.routes.length
193
- elsif defined?(Tina4::Router) && Tina4::Router.instance_variable_defined?(:@routes)
194
- route_count = Tina4::Router.instance_variable_get(:@routes).length
195
- end
196
- rescue StandardError
197
- # ignore
198
- end
199
-
200
- begin
201
- if defined?(Tina4::ORM)
202
- orm_count = ObjectSpace.each_object(Class).count { |c| c < Tina4::ORM }
203
- end
204
- rescue StandardError
205
- # ignore
206
- end
207
-
208
- breakdown = {
209
- "ruby" => rb_files.length,
210
- "templates" => twig_files.length,
211
- "migrations" => sql_files.length,
212
- "stylesheets" => scss_files.length
213
- }
214
-
215
- {
216
- "file_count" => rb_files.length,
217
- "total_loc" => total_loc,
218
- "total_blank" => total_blank,
219
- "total_comment" => total_comment,
220
- "lloc" => total_loc,
221
- "classes" => total_classes,
222
- "functions" => total_functions,
223
- "route_count" => route_count,
224
- "orm_count" => orm_count,
225
- "template_count" => twig_files.length,
226
- "migration_count" => sql_files.length,
227
- "avg_file_size" => rb_files.empty? ? 0 : (total_loc.to_f / rb_files.length).round(1),
228
- "largest_files" => file_details.first(10),
229
- "breakdown" => breakdown
230
- }
231
- end
232
-
233
- # ── Full Analysis (Ripper-based) ────────────────────────────
234
- # ── The native engine (ADR-0002) ─────────────────────────────
235
- #
236
- # The Ripper-based analyzer that used to live below here is gone. Everything
237
- # except the instant file census now comes from `tina4 metrics --json`, so a
238
- # number measured in Ruby is comparable with the same number measured in
239
- # Python, PHP or Node. There is deliberately NO fallback: a second engine is
240
- # exactly the condition that made the four frameworks' numbers incomparable.
241
-
242
- TIMEOUT_SECONDS = 60
243
-
244
- INSTALL_HINT = <<~HINT.strip
245
- the tina4 CLI provides the metrics engine (ADR-0002). Install it with
246
- curl -fsSL https://tina4.com/install.sh | sh
247
- or see https://tina4.com/cli
248
- HINT
249
-
250
- # Fields the dashboard renders. Checking for the DATA is honest where checking
251
- # a version string is not: a user may run any CLI build, and the payload is
252
- # what tells us what that build can actually do.
12
+ INSTALL_HINT = 'update the native tina4 CLI: https://tina4.com/cli'
253
13
  SUMMARY_KEYS = %w[files_analyzed total_functions avg_complexity avg_maintainability].freeze
254
14
  FILE_KEYS = %w[path loc avg_complexity maintainability has_tests].freeze
255
15
  FUNCTION_KEYS = %w[name file line complexity loc].freeze
256
16
 
257
- # Absolute path to the tina4 CLI binary, or nil when it is not installed.
258
- #
259
- # Skips shebang scripts. The engine is a COMPILED Rust binary, but `bundle
260
- # exec` prepends RubyGems' bin directory to PATH, and a gem executable named
261
- # `tina4` sits there as a Ruby shim. Taking the first PATH hit found that
262
- # shim and running it died with "can't find executable tina4 for gem" -- so
263
- # every metrics call failed under the ordinary `bundle exec` workflow. The
264
- # same guard also steps over rbenv/asdf shims and any gem squatting the name.
265
- def self.engine_path
266
- ENV['PATH'].to_s.split(File::PATH_SEPARATOR).each do |dir|
267
- next if dir.empty?
17
+ def self.resolve_target(root = 'src')
18
+ source = Pathname.new(root)
19
+ resolved, mode = if source.directory? && !Dir.glob(source.join('**/*.rb').to_s).empty?
20
+ [source.expand_path, 'project']
21
+ else
22
+ [Pathname.new(__dir__).expand_path, 'framework']
23
+ end
24
+ @last_scan_root = resolved.to_s
25
+ [resolved.to_s, mode]
26
+ end
268
27
 
28
+ def self.engine_path
29
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |directory|
269
30
  %w[tina4 tina4.exe].each do |name|
270
- candidate = File.join(dir, name)
31
+ candidate = File.join(directory, name)
271
32
  next unless File.file?(candidate) && File.executable?(candidate)
272
- next if _shebang_script?(candidate)
33
+ next if File.binread(candidate, 2) == '#!'
273
34
 
274
35
  return candidate
36
+ rescue StandardError
37
+ next
275
38
  end
276
39
  end
277
40
  nil
278
41
  end
279
42
 
280
- # True when the file begins with `#!` -- a script, never the native engine.
281
- def self._shebang_script?(path)
282
- File.binread(path, 2) == '#!'
283
- rescue StandardError
284
- false
285
- end
286
-
287
- # Run `tina4 metrics --json` over path and return the raw payload.
288
- #
289
- # Raises MetricsEngineError naming the actual cause: a caller that cannot get
290
- # metrics needs to know whether the binary is missing, the run failed, or the
291
- # output was unreadable.
292
- def self._run_engine(path)
43
+ def self.run_engine(path)
293
44
  binary = engine_path
294
- raise MetricsEngineError, "tina4 not found on PATH - #{INSTALL_HINT}" if binary.nil?
295
-
296
- stdout = nil
297
- status = nil
298
- stderr = nil
299
- begin
300
- require 'open3'
301
- stdout, stderr, status = Open3.capture3(
302
- binary, 'metrics', '--path', path.to_s, '--json'
303
- )
304
- # capture3 tags the output with the LOCALE's encoding, so under a
305
- # minimal locale (LANG=C / LANG unset, common on CI runners and in slim
306
- # containers) the engine's UTF-8 JSON arrives labelled US-ASCII and the
307
- # first String#strip raises Encoding::CompatibilityError. The bytes were
308
- # always UTF-8; only the label was wrong.
309
- stdout = stdout.to_s.dup.force_encoding(Encoding::UTF_8)
310
- stderr = stderr.to_s.dup.force_encoding(Encoding::UTF_8)
311
- rescue StandardError => e
312
- raise MetricsEngineError, "could not run #{binary}: #{e.message}"
313
- end
45
+ raise MetricsEngineError, "tina4 not found on PATH - #{INSTALL_HINT}" unless binary
314
46
 
47
+ stdout, stderr, status = Open3.capture3(binary, 'metrics', '--path', path.to_s, '--json')
315
48
  unless status.success?
316
- detail = (stderr.to_s.strip.empty? ? stdout.to_s : stderr.to_s).strip.lines.first
317
- first = detail ? detail.strip : "exit code #{status.exitstatus}"
318
- raise MetricsEngineError, "tina4 metrics failed on #{path}: #{first}"
49
+ detail = (stderr.empty? ? stdout : stderr).strip.lines.first || "exit code #{status.exitstatus}"
50
+ raise MetricsEngineError, "tina4 metrics failed on #{path}: #{detail.strip}"
319
51
  end
320
-
321
- raise MetricsEngineError, "tina4 metrics produced no output for #{path}" if stdout.to_s.strip.empty?
322
-
323
- begin
324
- payload = JSON.parse(stdout)
325
- rescue JSON::ParserError => e
326
- raise MetricsEngineError, "tina4 metrics returned unreadable JSON: #{e.message}"
327
- end
328
-
52
+ payload = JSON.parse(stdout)
329
53
  raise MetricsEngineError, 'tina4 metrics returned a non-object payload' unless payload.is_a?(Hash)
330
54
 
331
55
  payload
56
+ rescue JSON::ParserError => error
57
+ raise MetricsEngineError, "tina4 metrics returned unreadable JSON: #{error.message}"
58
+ rescue SystemCallError => error
59
+ raise MetricsEngineError, "could not run #{binary}: #{error.message}"
332
60
  end
333
61
 
334
- # Pull a key out of the payload or raise naming what the engine is missing.
335
- def self._require(payload, key, kind)
62
+ def self.require_array(payload, key)
336
63
  value = payload[key]
337
- unless value.is_a?(kind)
338
- raise MetricsEngineError,
339
- "engine payload has no usable '#{key}' - the installed tina4 CLI predates " \
340
- "a field the dashboard renders. Update it: #{INSTALL_HINT}"
341
- end
342
- value
64
+ return value if value.is_a?(Array)
65
+
66
+ raise MetricsEngineError, "engine payload has no usable '#{key}' - #{INSTALL_HINT}"
343
67
  end
344
68
 
345
- # Full code analysis from the native engine, shaped for the dashboard.
346
69
  def self.full_analysis(root = 'src')
347
- resolved, scan_mode = resolve_scan_target(root)
348
- payload = _run_engine(resolved)
349
-
350
- summary = _require(payload, 'summary', Hash)
351
- file_metrics = _require(payload, 'file_metrics', Array)
352
- functions = _require(payload, 'most_complex_functions', Array)
353
-
354
- missing = SUMMARY_KEYS.reject { |k| summary.key?(k) }
355
- unless missing.empty?
356
- raise MetricsEngineError,
357
- "engine summary is missing #{missing.join(', ')} - update the CLI: #{INSTALL_HINT}"
358
- end
359
- unless file_metrics.empty?
360
- absent = FILE_KEYS.reject { |k| file_metrics.first.key?(k) }
361
- raise MetricsEngineError, "engine file_metrics is missing #{absent.join(', ')}" unless absent.empty?
70
+ resolved, scan_mode = resolve_target(root)
71
+ payload = run_engine(resolved)
72
+ summary = payload['summary']
73
+ raise MetricsEngineError, "engine payload has no usable 'summary' - #{INSTALL_HINT}" unless summary.is_a?(Hash)
74
+
75
+ files = require_array(payload, 'file_metrics')
76
+ functions = require_array(payload, 'most_complex_functions')
77
+ missing = SUMMARY_KEYS.reject { |key| summary.key?(key) }
78
+ raise MetricsEngineError, "engine summary is missing #{missing.join(', ')}" unless missing.empty?
79
+ unless files.empty?
80
+ missing = FILE_KEYS.reject { |key| files.first.key?(key) }
81
+ raise MetricsEngineError, "engine file_metrics is missing #{missing.join(', ')}" unless missing.empty?
362
82
  end
363
83
  unless functions.empty?
364
- absent = FUNCTION_KEYS.reject { |k| functions.first.key?(k) }
365
- raise MetricsEngineError, "engine function metrics are missing #{absent.join(', ')}" unless absent.empty?
84
+ missing = FUNCTION_KEYS.reject { |key| functions.first.key?(key) }
85
+ raise MetricsEngineError, "engine function metrics are missing #{missing.join(', ')}" unless missing.empty?
366
86
  end
367
87
 
368
- result = SUMMARY_KEYS.each_with_object({}) { |k, h| h[k] = summary[k] }
369
- result['file_metrics'] = file_metrics
370
- # Display cap only. offenders reads the engine's own uncapped list, so a
371
- # 16th over-threshold function is never hidden from the gate.
372
- result['most_complex_functions'] = functions.first(15)
373
- result['dependency_graph'] = payload['dependency_graph'] || {}
374
- # The framework owns these two: the engine always reports "project" because
375
- # it cannot know which directory is a framework package.
376
- result['scan_mode'] = scan_mode
377
- result['scan_root'] = File.expand_path(resolved)
378
- result['engine'] = 'tina4-cli'
379
- result
88
+ SUMMARY_KEYS.to_h { |key| [key, summary[key]] }.merge(
89
+ 'file_metrics' => files,
90
+ 'most_complex_functions' => functions.first(15),
91
+ 'dependency_graph' => payload['dependency_graph'] || {},
92
+ 'scan_mode' => scan_mode,
93
+ 'scan_root' => resolved,
94
+ 'engine' => 'tina4-cli'
95
+ )
380
96
  end
381
97
 
382
- # Top code-health offenders from the native engine.
383
- #
384
- # The engine ranks and severity-tags them, and its own --fail-on gate reads
385
- # the same list, so the CLI and the dashboard can never disagree about what
386
- # counts as an offender.
387
- def self.offenders(root = 'src', top = 20)
388
- resolved, scan_mode = resolve_scan_target(root)
389
- payload = _run_engine(resolved)
390
-
391
- found = _require(payload, 'offenders', Array)
392
- summary = _require(payload, 'summary', Hash).dup
393
- summary['scan_mode'] = scan_mode
394
- summary['scan_root'] = File.expand_path(resolved)
395
- summary['engine'] = 'tina4-cli'
396
- summary['total_offenders'] ||= found.length
397
- { 'offenders' => found.first(top), 'summary' => summary }
398
- end
399
-
400
- # Per-file metrics from the native engine.
401
- #
402
- # The engine accepts a single file for --path, so one code path serves both
403
- # the whole-tree scan and one file.
404
98
  def self.file_detail(file_path)
405
- raise MetricsEngineError, 'file_detail needs a path' if file_path.nil? || file_path.to_s.empty?
99
+ raise MetricsEngineError, 'file_detail needs a path' if file_path.to_s.empty?
406
100
 
407
101
  target = Pathname.new(file_path.to_s)
408
- unless target.exist?
409
- # Try it relative to whatever the census last resolved, so the dashboard
410
- # can pass a path taken straight out of file_metrics.
411
- unless @last_scan_root.to_s.empty?
412
- candidate = Pathname.new(@last_scan_root).join(file_path.to_s)
413
- target = candidate if candidate.exist?
414
- end
415
- end
102
+ target = Pathname.new(@last_scan_root).join(file_path.to_s) if !target.exist? && @last_scan_root
416
103
  raise MetricsEngineError, "no such file: #{file_path}" unless target.exist?
417
104
  raise MetricsEngineError, "not a file: #{file_path}" if target.directory?
418
105
 
419
- payload = _run_engine(target.to_s)
420
- file_metrics = _require(payload, 'file_metrics', Array)
421
- raise MetricsEngineError, "engine reported no metrics for #{file_path}" if file_metrics.empty?
106
+ payload = run_engine(target.to_s)
107
+ files = require_array(payload, 'file_metrics')
108
+ raise MetricsEngineError, "engine reported no metrics for #{file_path}" if files.empty?
422
109
 
423
- file_metrics.first.dup.merge('engine' => 'tina4-cli')
110
+ files.first.merge(
111
+ 'function_count' => files.first.fetch('functions', 0),
112
+ 'functions' => require_array(payload, 'most_complex_functions'),
113
+ 'engine' => 'tina4-cli'
114
+ )
424
115
  end
425
116
  end
426
117
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.99"
4
+ VERSION = "3.13.101"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -42,6 +42,7 @@ require_relative "tina4/dev_admin"
42
42
  require_relative "tina4/feedback"
43
43
  require_relative "tina4/dev_mailbox"
44
44
  require_relative "tina4/ai"
45
+ require_relative "tina4/ai_client"
45
46
  require_relative "tina4/cache"
46
47
  require_relative "tina4/sql_translator"
47
48
  require_relative "tina4/cache_backends"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.99
4
+ version: 3.13.101
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -321,6 +321,7 @@ files:
321
321
  - exe/tina4ruby
322
322
  - lib/tina4.rb
323
323
  - lib/tina4/ai.rb
324
+ - lib/tina4/ai_client.rb
324
325
  - lib/tina4/api.rb
325
326
  - lib/tina4/auth.rb
326
327
  - lib/tina4/auto_crud.rb