tina4ruby 3.13.94 → 3.13.96

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +243 -29
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/metrics.rb CHANGED
@@ -1,19 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Tina4 Code Metrics — Ripper-based static analysis for the dev dashboard.
3
+ # Tina4 Code Metrics — the native engine (ADR-0002) plus an instant file census.
4
4
  #
5
5
  # Two-tier analysis:
6
6
  # 1. Quick metrics (instant): LOC, file counts, class/function counts
7
7
  # 2. Full analysis (on-demand, cached): cyclomatic complexity, maintainability
8
- # index, coupling, Halstead metrics, violations
8
+ # index, coupling, Halstead metrics, offenders
9
9
  #
10
- # Zero dependencies uses Ruby's built-in Ripper module.
10
+ # Zero dependencies. The census is pure Ruby; the analysis is `tina4 metrics --json`.
11
11
 
12
- require 'ripper'
12
+ require 'json'
13
13
  require 'digest'
14
14
  require 'pathname'
15
15
 
16
16
  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
+ class MetricsEngineError < StandardError; end
22
+
17
23
  module Metrics
18
24
  # ── Cache ───────────────────────────────────────────────────
19
25
  @full_cache_hash = ""
@@ -46,6 +52,20 @@ module Tina4
46
52
  @last_scan_root
47
53
  end
48
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
+
49
69
  # ── Quick Metrics ───────────────────────────────────────────
50
70
 
51
71
  def self.quick_metrics(root = 'src')
@@ -211,928 +231,196 @@ module Tina4
211
231
  end
212
232
 
213
233
  # ── 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| _code_line?(l) }
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
- # Display-only: the top-15 for the "most complex functions" report.
342
- # Do NOT source offenders / --fail-on from this — capping here silently
343
- # hides the 16th+ over-threshold function from the gate. offenders()
344
- # reads "all_functions" (below) instead.
345
- "most_complex_functions" => all_functions.first(15),
346
- # Full, uncapped, complexity-sorted list — offenders()/--fail-on use this
347
- # so no function over the complexity threshold ever escapes the gate.
348
- "all_functions" => all_functions,
349
- "file_metrics" => file_metrics,
350
- "violations" => violations,
351
- "dependency_graph" => import_graph,
352
- "scan_mode" => scanning_framework ? "framework" : "project",
353
- "scan_root" => resolved_root
354
- }
355
-
356
- @full_cache_hash = current_hash
357
- @full_cache_data = result
358
- @full_cache_time = now
359
-
360
- result
361
- end
362
-
363
- # ── Top Offenders (CLI + dashboard) ──────────────────────────
364
-
365
- # Severity ranking for sorting (higher = more severe).
366
- SEVERITY_RANK = { "error" => 2, "warn" => 1, "info" => 0 }.freeze
367
-
368
- # Rank the worst code-quality issues into a single "top offenders" list.
369
- #
370
- # Reuses full_analysis (does NOT re-analyze). Each offender is a hash:
371
- # {"file", "line", "kind", "severity", "score", "detail"}
372
- #
373
- # Rules (one offender per matching condition):
374
- # - function complexity > 10 → kind "complexity"
375
- # severity "error" if >20 else "warn"; score = complexity
376
- # - file loc > 500 → kind "large_file" (warn); score = loc/100
377
- # - file functions > 20 → kind "too_many_functions" (warn); score = functions/4
378
- # - file maintainability < 40 → kind "low_maintainability"
379
- # severity "error" if <20 else "warn"; score = (50 - mi)
380
- # - file has_tests false → kind "untested" (info); score = loc/100
234
+ # ── The native engine (ADR-0002) ─────────────────────────────
381
235
  #
382
- # Sorted by (severity rank, score) DESCENDING and truncated to `top`.
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.
253
+ SUMMARY_KEYS = %w[files_analyzed total_functions avg_complexity avg_maintainability].freeze
254
+ FILE_KEYS = %w[path loc avg_complexity maintainability has_tests].freeze
255
+ FUNCTION_KEYS = %w[name file line complexity loc].freeze
256
+
257
+ # Absolute path to the tina4 CLI binary, or nil when it is not installed.
383
258
  #
384
- # Returns {"offenders" => [...], "summary" => {...}} where summary carries
385
- # the headline numbers the CLI prints (files_analyzed, total_functions,
386
- # avg_complexity, avg_maintainability, scan_mode, scan_root, and the total
387
- # offender count before truncation).
388
- def self.offenders(root = 'src', top = 20)
389
- analysis = full_analysis(root)
390
- if analysis.key?("error")
391
- return { "offenders" => [], "summary" => { "error" => analysis["error"] } }
392
- end
393
-
394
- items = []
395
-
396
- # Function-level: cyclomatic complexity. Use the FULL function list (not the
397
- # display-capped most_complex_functions[:15]) so a 16th+ over-threshold
398
- # function is never silently dropped from the offenders list or --fail-on.
399
- (analysis["all_functions"] || analysis["most_complex_functions"] || []).each do |fn|
400
- cc = fn["complexity"]
401
- next unless cc > 10
402
- items << {
403
- "file" => fn["file"],
404
- "line" => fn["line"],
405
- "kind" => "complexity",
406
- "severity" => cc > 20 ? "error" : "warn",
407
- "score" => cc.to_f,
408
- "detail" => "#{fn['name']} — cyclomatic complexity #{cc}"
409
- }
410
- end
411
-
412
- # File-level rules.
413
- (analysis["file_metrics"] || []).each do |fm|
414
- path = fm["path"]
415
- loc = fm["loc"]
416
- funcs = fm["functions"]
417
- mi = fm["maintainability"]
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?
418
268
 
419
- if loc > 500
420
- items << {
421
- "file" => path,
422
- "line" => 1,
423
- "kind" => "large_file",
424
- "severity" => "warn",
425
- "score" => loc / 100.0,
426
- "detail" => "#{loc} LOC (max 500)"
427
- }
428
- end
429
-
430
- if funcs > 20
431
- items << {
432
- "file" => path,
433
- "line" => 1,
434
- "kind" => "too_many_functions",
435
- "severity" => "warn",
436
- "score" => funcs / 4.0,
437
- "detail" => "#{funcs} functions (max 20)"
438
- }
439
- end
440
-
441
- if mi < 40
442
- items << {
443
- "file" => path,
444
- "line" => 1,
445
- "kind" => "low_maintainability",
446
- "severity" => mi < 20 ? "error" : "warn",
447
- "score" => 50 - mi,
448
- "detail" => "maintainability index #{mi} (min 40)"
449
- }
450
- end
269
+ %w[tina4 tina4.exe].each do |name|
270
+ candidate = File.join(dir, name)
271
+ next unless File.file?(candidate) && File.executable?(candidate)
272
+ next if _shebang_script?(candidate)
451
273
 
452
- if fm["has_tests"] == false
453
- items << {
454
- "file" => path,
455
- "line" => 1,
456
- "kind" => "untested",
457
- "severity" => "info",
458
- "score" => loc / 100.0,
459
- "detail" => "no referencing test"
460
- }
274
+ return candidate
461
275
  end
462
276
  end
463
-
464
- # Sort by (severity rank, score) DESCENDING — stable so insertion order
465
- # breaks ties deterministically.
466
- items = items.each_with_index.sort_by do |o, idx|
467
- [-SEVERITY_RANK[o["severity"]], -o["score"], idx]
468
- end.map(&:first)
469
-
470
- summary = {
471
- "files_analyzed" => analysis["files_analyzed"],
472
- "total_functions" => analysis["total_functions"],
473
- "avg_complexity" => analysis["avg_complexity"],
474
- "avg_maintainability" => analysis["avg_maintainability"],
475
- "scan_mode" => analysis["scan_mode"],
476
- "scan_root" => analysis["scan_root"],
477
- "total_offenders" => items.length
478
- }
479
-
480
- { "offenders" => items.first(top), "summary" => summary }
277
+ nil
481
278
  end
482
279
 
483
- # ── File Detail ─────────────────────────────────────────────
484
-
485
- def self.file_detail(file_path)
486
- unless File.exist?(file_path)
487
- # Try resolving relative to the last scan root (framework mode)
488
- if @last_scan_root && !@last_scan_root.empty?
489
- candidate = File.join(@last_scan_root, file_path)
490
- if File.exist?(candidate)
491
- file_path = candidate
492
- end
493
- end
494
- end
495
- unless File.exist?(file_path)
496
- return { "error" => "File not found: #{file_path}" }
497
- end
498
-
499
- source = begin
500
- File.read(file_path, encoding: 'utf-8')
501
- rescue StandardError => e
502
- return { "error" => "Read error: #{e.message}" }
503
- end
504
-
505
- tokens = begin
506
- Ripper.lex(source)
507
- rescue StandardError => e
508
- return { "error" => "Syntax error: #{e.message}" }
509
- end
510
-
511
- lines = source.lines.map(&:chomp)
512
- loc = lines.count { |l| _code_line?(l) }
513
-
514
- functions = _extract_functions(source, tokens, lines)
515
- functions.sort_by! { |f| -f["complexity"] }
516
-
517
- classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
518
- imports = _extract_imports(lines)
519
-
520
- warnings = []
521
- functions.each do |f|
522
- if f["loc"] <= 1
523
- warnings << { "type" => "empty_method", "message" => "Method '#{f["name"]}' appears to be empty", "line" => f["line"] }
524
- end
525
- end
526
- if classes > 0 && functions.empty? && loc <= 1
527
- warnings << { "type" => "empty_class", "message" => "Class/module appears to be empty", "line" => 1 }
528
- end
529
-
530
- {
531
- "path" => file_path,
532
- "loc" => loc,
533
- "total_lines" => lines.length,
534
- "classes" => classes,
535
- "functions" => functions.map { |f|
536
- {
537
- "name" => f["name"],
538
- "line" => f["line"],
539
- "complexity" => f["complexity"],
540
- "loc" => f["loc"],
541
- "args" => f["args"]
542
- }
543
- },
544
- "imports" => imports,
545
- "warnings" => warnings
546
- }
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
547
285
  end
548
286
 
549
- # ── Private Helpers ─────────────────────────────────────────
550
-
551
- private_class_method
552
-
553
- # Check whether a source file has a test that actually exercises it.
287
+ # Run `tina4 metrics --json` over path and return the raw payload.
554
288
  #
555
- # PRECISE detection (a bare word-mention is NOT enough that over-reported
556
- # badly: `sqlite3_adapter.rb` looked "tested" because some spec merely said
557
- # "sqlite3_adapter"):
558
- #
559
- # 1. Filename — a dedicated `<module>_spec.rb` / `<module>_test.rb` /
560
- # `test_<module>.rb` for THIS exact module (NOT the parent directory
561
- # one `database_spec.rb` must not mark every file under `database/`
562
- # tested).
563
- # 2. Require — a spec that actually requires this file: its require path
564
- # (`require "tina4/database/sqlite"` / `require_relative ".../sqlite"`)
565
- # matched by the basename of a require target. A constant/class that is
566
- # genuinely DEFINED in this file (top-level class/module) referenced by
567
- # a spec also counts.
568
- #
569
- # Returns true only on a real, file-specific signal — so the "untested"
570
- # offenders surfaced by `tina4 metrics` and the dashboard "T" badge are
571
- # trustworthy. (If you wire real coverage data later, prefer it over this.)
572
- def self._has_matching_test(rel_path)
573
- require 'set'
574
-
575
- name = File.basename(rel_path, '.rb')
576
-
577
- # Require path WITHOUT extension, leading lib/ stripped:
578
- # "lib/tina4/database/sqlite.rb" -> "tina4/database/sqlite"
579
- require_path = rel_path.sub(/\.rb$/, '').sub(%r{^lib/}, '')
580
-
581
- # Constants (classes/modules) DEFINED at the top level of this file — a
582
- # spec referencing one of them genuinely exercises this file. Names only,
583
- # distinctive (>3 chars, leading uppercase); bare module-name words and
584
- # guessed CamelCase are too loose to trust.
585
- defined_symbols = _defined_constants(rel_path)
586
-
587
- # Search roots: CWD plus (in framework-fallback mode) the repo root that
588
- # owns spec/ — walk up from the scan root to find it.
589
- search_roots = ['.']
590
- if @last_scan_root && !@last_scan_root.empty?
591
- scan_root = @last_scan_root
592
- 5.times do
593
- if %w[spec test tests].any? { |d| Dir.exist?(File.join(scan_root, d)) }
594
- search_roots << scan_root
595
- break
596
- end
597
- parent = File.dirname(scan_root)
598
- break if parent == scan_root
599
- scan_root = parent
600
- end
601
- end
602
- search_roots.uniq!
603
-
604
- test_dirs = %w[spec test tests]
605
-
606
- # Stage 1: a dedicated spec/test FILE named for THIS module (no parent-dir
607
- # blanket match).
608
- filename_patterns = [
609
- "#{name}_spec.rb",
610
- "#{name}s_spec.rb",
611
- "#{name}_test.rb",
612
- "test_#{name}.rb",
613
- ]
614
- search_roots.each do |root|
615
- test_dirs.each do |td|
616
- filename_patterns.each do |fn|
617
- return true if File.exist?(File.join(root, td, fn))
618
- end
619
- end
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)
293
+ 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}"
620
313
  end
621
314
 
622
- # Stage 2: a spec that actually REQUIRES this module (precise — matched by
623
- # the require target's basename / tail of the require path), or references
624
- # a constant defined in it. NO bare word-of-the-module-name match.
625
- require_regexps = []
626
- unless require_path.empty?
627
- # require "…/<module>" or require_relative "…/<module>" — match the
628
- # require string ending in this file's require path or basename.
629
- rp = Regexp.escape(require_path)
630
- nm = Regexp.escape(name)
631
- require_regexps << /(?:require|require_relative)\s+['"][^'"]*#{rp}['"]/
632
- require_regexps << %r{(?:require|require_relative)\s+['"][^'"]*/#{nm}['"]}
633
- end
634
- unless defined_symbols.empty?
635
- sym_alt = defined_symbols.map { |s| Regexp.escape(s) }.join('|')
636
- require_regexps << /\b(?:#{sym_alt})\b/
315
+ 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}"
637
319
  end
638
320
 
639
- return false if require_regexps.empty?
321
+ raise MetricsEngineError, "tina4 metrics produced no output for #{path}" if stdout.to_s.strip.empty?
640
322
 
641
- search_roots.each do |root|
642
- test_dirs.each do |td|
643
- dir = File.join(root, td)
644
- next unless Dir.exist?(dir)
645
- Dir.glob(File.join(dir, '**', '*.rb')).each do |test_file|
646
- content = begin
647
- File.read(test_file, encoding: 'utf-8')
648
- rescue StandardError
649
- next
650
- end
651
- return true if require_regexps.any? { |re| content.match?(re) }
652
- end
653
- end
323
+ begin
324
+ payload = JSON.parse(stdout)
325
+ rescue JSON::ParserError => e
326
+ raise MetricsEngineError, "tina4 metrics returned unreadable JSON: #{e.message}"
654
327
  end
655
328
 
656
- false
657
- end
658
-
659
- # Top-level class/module names defined in the file at rel_path (resolved
660
- # against the last scan root when present). Distinctive names only:
661
- # leading-uppercase, longer than 2 chars — so genuine 3-char constants like
662
- # ORM (orm.rb) and API (api.rb), which specs reference as `Tina4::ORM` /
663
- # `Tina4::API`, are detected as tested instead of being mislabelled
664
- # untested. (Was > 3, which silently excluded every 3-char constant.)
665
- def self._defined_constants(rel_path)
666
- src_file = if @last_scan_root && !@last_scan_root.empty? && !File.exist?(rel_path)
667
- File.join(@last_scan_root, rel_path)
668
- else
669
- rel_path
670
- end
671
- symbols = Set.new
672
- content = begin
673
- File.read(src_file, encoding: 'utf-8')
674
- rescue StandardError
675
- return symbols
676
- end
677
- content.each_line do |line|
678
- stripped = line.strip
679
- m = stripped.match(/\A(?:class|module)\s+([A-Z][A-Za-z0-9_]*)/)
680
- next unless m
681
- const = m[1]
682
- symbols.add(const) if const.length > 2
683
- end
684
- symbols
685
- end
329
+ raise MetricsEngineError, 'tina4 metrics returned a non-object payload' unless payload.is_a?(Hash)
686
330
 
687
- def self._files_hash(root)
688
- md5 = Digest::MD5.new
689
- root_path = Pathname.new(root)
690
- if root_path.directory?
691
- Dir.glob(root_path.join('**', '*.rb')).sort.each do |f|
692
- begin
693
- md5.update("#{f}:#{File.mtime(f).to_f}")
694
- rescue StandardError
695
- # ignore
696
- end
697
- end
698
- end
699
- md5.hexdigest
331
+ payload
700
332
  end
701
333
 
702
- def self._extract_imports(lines)
703
- imports = []
704
- lines.each do |line|
705
- stripped = line.strip
706
- if stripped.match?(/\Arequire\s+/)
707
- m = stripped.match(/\Arequire\s+['"]([^'"]+)['"]/)
708
- imports << m[1] if m
709
- elsif stripped.match?(/\Arequire_relative\s+/)
710
- m = stripped.match(/\Arequire_relative\s+['"]([^'"]+)['"]/)
711
- imports << m[1] if m
712
- end
334
+ # Pull a key out of the payload or raise naming what the engine is missing.
335
+ def self._require(payload, key, kind)
336
+ 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}"
713
341
  end
714
- imports
342
+ value
715
343
  end
716
344
 
717
- # Replace the CONTENT of Ruby string literals, regex literals, and comments
718
- # with neutral spaces — keeping every line's length and the line count
719
- # identical to the original — so decision-point keywords and method-shaped
720
- # text that live INSIDE strings/comments are never miscounted. Returns an
721
- # array of cleaned lines (chomped) aligned 1:1 with the original lines.
722
- #
723
- # Ruby's own lexer (Ripper) does the hard parsing: it tags string/heredoc/
724
- # regex bodies as :on_tstring_content (and :on_comment, :on_embdoc — the
725
- # =begin/=end block-comment body), which we blank out positionally. The
726
- # surrounding code structure (def/if/end keywords, operators) is left intact.
727
- NOISE_TOKEN_TYPES = %i[
728
- on_tstring_content on_comment on_embdoc on_embdoc_beg on_embdoc_end
729
- ].freeze
730
-
731
- def self._clean_source(source)
732
- lines = source.lines.map(&:chomp)
733
- # Mutable per-line character buffers we can blank out by column range.
734
- buffers = lines.map(&:dup)
735
-
736
- tokens = begin
737
- Ripper.lex(source)
738
- rescue StandardError
739
- return lines
740
- end
741
-
742
- tokens.each do |(pos, type, token)|
743
- next unless NOISE_TOKEN_TYPES.include?(type)
744
-
745
- row = pos[0] - 1
746
- col = pos[1]
747
- # A noise token may span multiple physical lines (heredocs, block
748
- # comments, multi-line strings). Blank each covered line segment.
749
- token.to_s.each_line.with_index do |seg, offset|
750
- line_idx = row + offset
751
- next if line_idx.negative? || line_idx >= buffers.length
752
-
753
- buf = buffers[line_idx]
754
- # On the token's first line the content starts at `col`; on
755
- # continuation lines it starts at column 0.
756
- start = offset.zero? ? col : 0
757
- seg_len = seg.chomp.length
758
- stop = [start + seg_len, buf.length].min
759
- (start...stop).each { |c| buf[c] = ' ' } if stop > start
760
- end
761
- end
762
-
763
- buffers
764
- end
765
-
766
- # True for a line that counts toward LOC: not blank, not a comment.
767
- #
768
- # The single definition of the rule. Method LOC used to ignore it and return a
769
- # raw line span while file LOC excluded blanks and comments, so `loc` meant
770
- # two different things in one payload - the dashboard sized bubbles in one
771
- # unit and printed the method table in the other.
772
- def self._code_line?(line)
773
- stripped = line.strip
774
- !stripped.empty? && !stripped.start_with?("#")
775
- end
776
-
777
- def self._extract_functions(source, _tokens, _lines)
778
- functions = []
779
- # Operate on a neutralised copy: string/regex/comment CONTENT is blanked
780
- # so keywords inside them are never read as real code (line numbers, line
781
- # count and column widths are preserved).
782
- lines = _clean_source(source)
783
- # Track class/module nesting for method names
784
- context_stack = []
785
- i = 0
786
-
787
- while i < lines.length
788
- stripped = lines[i].strip
789
-
790
- # Track class/module context
791
- if stripped.match?(/\A(class|module)\s+(\S+)/)
792
- m = stripped.match(/\A(class|module)\s+(\S+)/)
793
- class_name = m[2].to_s.split('<').first.to_s.strip
794
- context_stack.push(class_name) unless class_name.empty?
795
- end
796
-
797
- # Detect method definitions — require a real `def ` declaration so a
798
- # `def`-shaped substring inside a (now-blanked) string is never a method.
799
- if stripped.match?(/\Adef\s+/)
800
- method_match = stripped.match(/\Adef\s+(self\.)?(\S+?)(\(.*\))?\s*$/)
801
- if method_match
802
- prefix = method_match[1] ? 'self.' : ''
803
- method_name = prefix + method_match[2]
804
-
805
- # Build full name with class context
806
- full_name = if context_stack.any?
807
- "#{context_stack.last}.#{method_name}"
808
- else
809
- method_name
810
- end
811
-
812
- # Extract arguments
813
- args = []
814
- if method_match[3]
815
- arg_str = method_match[3].gsub(/[()]/, '')
816
- arg_str.split(',').each do |arg|
817
- arg = arg.strip.split('=').first.strip.gsub(/^[*&]+/, '')
818
- args << arg unless arg == 'self' || arg.empty?
819
- end
820
- end
821
-
822
- # Find method end and calculate LOC
823
- method_start = i
824
- method_end = _find_method_end(lines, i)
825
- # Code lines over the method's span, by the same rule as file LOC.
826
- # Floor of 1: a one-line body must never report 0.
827
- method_loc = [1, lines[method_start..method_end].count { |l| _code_line?(l) }].max
828
-
829
- # Calculate complexity for this method's body
830
- method_lines = lines[method_start..method_end]
831
- method_source = method_lines.join("\n")
832
- cc = _cyclomatic_complexity_from_source(method_source)
833
-
834
- functions << {
835
- "name" => full_name,
836
- "line" => i + 1,
837
- "complexity" => cc,
838
- "loc" => method_loc,
839
- "args" => args
840
- }
841
- end
842
- end
843
-
844
- # Track end keywords for context popping
845
- if stripped == 'end'
846
- # Check if this closes a class/module
847
- # Simple heuristic: count def/class/module opens vs end closes
848
- # We only pop context when we're back at the class/module level
849
- indent = lines[i].length - lines[i].lstrip.length
850
- if indent == 0 && context_stack.any?
851
- context_stack.pop
852
- end
853
- end
854
-
855
- i += 1
856
- end
857
-
858
- _charge_nested_complexity_to_the_nested_function(functions)
345
+ # Full code analysis from the native engine, shaped for the dashboard.
346
+ 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?
362
+ end
363
+ 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?
366
+ end
367
+
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
859
380
  end
860
381
 
861
- # Stop a function being charged for the complexity of the functions nested
862
- # inside it.
863
- #
864
- # Each function's raw score is measured over its whole span, so a branch
865
- # inside a nested function landed on BOTH that function and every function
866
- # enclosing it. The over-count compounded with depth: a wrapper around twenty
867
- # inner handlers absorbed the entire file's complexity and topped the
868
- # offenders list, hiding the genuine hot spots.
382
+ # Top code-health offenders from the native engine.
869
383
  #
870
- # The correction is exact. A raw score is 1 + every decision in the span, so
871
- # (raw - 1) is the total decision count of a function's whole subtree.
872
- # Subtracting that for each DIRECT child leaves the function's own branches:
873
- #
874
- # own(F) = raw(F) - sum over direct children C of (raw(C) - 1)
875
- #
876
- # Blocks and lambdas are deliberately unaffected: they are not reported as
877
- # functions of their own, so nothing subtracts them and their decisions stay
878
- # with the method that contains them - moved, never lost.
879
- def self._charge_nested_complexity_to_the_nested_function(functions)
880
- return functions if functions.length < 2
881
-
882
- last_line = ->(f) { f["line"] + [1, f["loc"]].max - 1 }
883
- contains = ->(outer, inner) do
884
- inner["line"] > outer["line"] && last_line.call(inner) <= last_line.call(outer)
885
- end
886
-
887
- raw = functions.map { |f| f["complexity"] }
888
- functions.each_with_index do |outer, i|
889
- subtract = 0
890
- functions.each_with_index do |inner, j|
891
- next if i == j || !contains.call(outer, inner)
892
-
893
- # Direct child only: skip it if another function sits between the two,
894
- # or its complexity would be subtracted twice.
895
- nested_deeper = functions.each_with_index.any? do |mid, k|
896
- k != i && k != j && contains.call(outer, mid) && contains.call(mid, inner)
897
- end
898
- subtract += raw[j] - 1 unless nested_deeper
899
- end
900
- outer["complexity"] = [1, raw[i] - subtract].max
901
- end
902
-
903
- functions
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 }
904
398
  end
905
399
 
906
- # Keywords that ALWAYS open a block needing a matching `end`.
907
- BLOCK_OPENERS = %w[def class module begin case].freeze
908
- # Keywords that open a block ONLY in statement-leading position; in trailing
909
- # position they are modifiers (`return x if y`) and need no `end`.
910
- CONDITIONAL_OPENERS = %w[if unless while until for].freeze
911
-
912
- # Find the line index where the method that starts at `start_index` ends.
400
+ # Per-file metrics from the native engine.
913
401
  #
914
- # Token-driven (Ripper) so it is immune to the line-regex footguns that made
915
- # this over-run to end-of-file (CC 496 on tiny methods):
916
- # * `self.class` — `class` after a `.` is an identifier, not a block opener
917
- # (Ripper tags it :on_ident), so it no longer bumps depth.
918
- # * modifier `if/unless/while/until/for` (`return x if y`) — only counted
919
- # as an opener in statement-LEADING position (first real token of a
920
- # statement), never trailing.
921
- # * `lines` are already string/comment-cleaned, so keywords inside string
922
- # bodies are gone too.
923
- # Falls back to the last line only if no matching `end` is found.
924
- def self._find_method_end(lines, start_index)
925
- source = lines[start_index..].join("\n")
926
- tokens = begin
927
- Ripper.lex(source)
928
- rescue StandardError
929
- return lines.length - 1
930
- end
931
-
932
- depth = 0
933
- # A keyword is a block opener only when it leads a statement. Track that:
934
- # we are at statement start initially and right after a newline / `;`.
935
- at_statement_start = true
936
- seen_opener = false
937
-
938
- tokens.each do |(pos, type, token)|
939
- case type
940
- when :on_kw
941
- if BLOCK_OPENERS.include?(token)
942
- depth += 1
943
- seen_opener = true
944
- elsif token == 'do'
945
- depth += 1
946
- seen_opener = true
947
- elsif CONDITIONAL_OPENERS.include?(token)
948
- # Leading => real block opener; trailing => modifier (no end).
949
- if at_statement_start
950
- depth += 1
951
- seen_opener = true
952
- end
953
- elsif token == 'end'
954
- depth -= 1
955
- if seen_opener && depth <= 0
956
- return start_index + (pos[0] - 1)
957
- end
958
- end
959
- at_statement_start = false
960
- when :on_nl, :on_ignored_nl, :on_semicolon
961
- at_statement_start = true
962
- when :on_sp, :on_comment, :on_embdoc, :on_embdoc_beg, :on_embdoc_end
963
- # whitespace/comments don't change statement-start state
964
- else
965
- at_statement_start = false
966
- end
967
- end
968
-
969
- # If we never found the end, return last line
970
- lines.length - 1
971
- end
972
-
973
- def self._cyclomatic_complexity_from_source(source)
974
- cc = 1
975
-
976
- # Use Ripper tokens for accurate counting
977
- tokens = begin
978
- Ripper.lex(source)
979
- rescue StandardError
980
- return cc
981
- end
982
-
983
- tokens.each do |(_pos, type, token)|
984
- case type
985
- when :on_kw
986
- case token
987
- when 'if', 'elsif', 'unless', 'when', 'while', 'until', 'for', 'rescue'
988
- # Skip modifier forms by checking if it's the first keyword on the line
989
- # For simplicity, count all — modifiers still add a decision path
990
- cc += 1
991
- end
992
- when :on_op
993
- case token
994
- when '&&', '||'
995
- cc += 1
996
- when '?'
997
- # Ternary operator
998
- cc += 1
999
- end
1000
- when :on_ident
1001
- # 'and' and 'or' are parsed as identifiers in some contexts
1002
- # but usually as keywords
1003
- end
1004
-
1005
- # Check for 'and'/'or' as keywords
1006
- if type == :on_kw && (token == 'and' || token == 'or')
1007
- cc += 1
1008
- end
1009
- end
1010
-
1011
- cc
1012
- end
1013
-
1014
- OPERATOR_TYPES = %i[
1015
- on_op
1016
- ].freeze
1017
-
1018
- OPERAND_TYPES = %i[
1019
- on_ident on_int on_float on_tstring_content
1020
- on_const on_symbeg on_rational on_imaginary
1021
- ].freeze
1022
-
1023
- def self._count_halstead(tokens)
1024
- stats = {
1025
- operators: 0,
1026
- operands: 0,
1027
- unique_operators: Set.new,
1028
- unique_operands: Set.new
1029
- }
1030
-
1031
- # Need Set
1032
- require 'set' unless defined?(Set)
1033
-
1034
- stats[:unique_operators] = Set.new
1035
- stats[:unique_operands] = Set.new
1036
-
1037
- tokens.each do |(_pos, type, token)|
1038
- case type
1039
- when :on_op
1040
- stats[:operators] += 1
1041
- stats[:unique_operators].add(token)
1042
- when :on_kw
1043
- # Keywords that act as operators
1044
- if %w[and or not defined? return yield raise].include?(token)
1045
- stats[:operators] += 1
1046
- stats[:unique_operators].add(token)
1047
- end
1048
- when :on_ident, :on_const
1049
- stats[:operands] += 1
1050
- stats[:unique_operands].add(token)
1051
- when :on_int, :on_float, :on_rational, :on_imaginary
1052
- stats[:operands] += 1
1053
- stats[:unique_operands].add(token)
1054
- when :on_tstring_content
1055
- stats[:operands] += 1
1056
- stats[:unique_operands].add(token[0, 50])
1057
- end
1058
- end
1059
-
1060
- stats
1061
- end
1062
-
1063
- def self._maintainability_index(halstead_volume, avg_cc, loc)
1064
- return 100.0 if loc <= 0
1065
-
1066
- v = [halstead_volume, 1].max
1067
- mi = 171 - 5.2 * Math.log(v) - 0.23 * avg_cc - 16.2 * Math.log(loc)
1068
- [[0.0, mi * 100.0 / 171].max, 100.0].min
1069
- end
1070
-
1071
- def self._detect_violations(functions, file_metrics)
1072
- violations = []
402
+ # The engine accepts a single file for --path, so one code path serves both
403
+ # the whole-tree scan and one file.
404
+ def self.file_detail(file_path)
405
+ raise MetricsEngineError, 'file_detail needs a path' if file_path.nil? || file_path.to_s.empty?
1073
406
 
1074
- functions.each do |f|
1075
- if f["complexity"] > 20
1076
- violations << {
1077
- "type" => "error",
1078
- "rule" => "high_complexity",
1079
- "message" => "#{f['name']} has cyclomatic complexity #{f['complexity']} (max 20)",
1080
- "file" => f["file"],
1081
- "line" => f["line"]
1082
- }
1083
- elsif f["complexity"] > 10
1084
- violations << {
1085
- "type" => "warning",
1086
- "rule" => "moderate_complexity",
1087
- "message" => "#{f['name']} has cyclomatic complexity #{f['complexity']} (recommended max 10)",
1088
- "file" => f["file"],
1089
- "line" => f["line"]
1090
- }
407
+ 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?
1091
414
  end
1092
415
  end
416
+ raise MetricsEngineError, "no such file: #{file_path}" unless target.exist?
417
+ raise MetricsEngineError, "not a file: #{file_path}" if target.directory?
1093
418
 
1094
- file_metrics.each do |fm|
1095
- if fm["loc"] > 500
1096
- violations << {
1097
- "type" => "warning",
1098
- "rule" => "large_file",
1099
- "message" => "#{fm['path']} has #{fm['loc']} LOC (recommended max 500)",
1100
- "file" => fm["path"],
1101
- "line" => 1
1102
- }
1103
- end
1104
-
1105
- if fm["functions"] > 20
1106
- violations << {
1107
- "type" => "warning",
1108
- "rule" => "too_many_functions",
1109
- "message" => "#{fm['path']} has #{fm['functions']} functions (recommended max 20)",
1110
- "file" => fm["path"],
1111
- "line" => 1
1112
- }
1113
- end
1114
-
1115
- if fm["maintainability"] < 20
1116
- violations << {
1117
- "type" => "error",
1118
- "rule" => "low_maintainability",
1119
- "message" => "#{fm['path']} has maintainability index #{fm['maintainability']} (min 20)",
1120
- "file" => fm["path"],
1121
- "line" => 1
1122
- }
1123
- elsif fm["maintainability"] < 40
1124
- violations << {
1125
- "type" => "warning",
1126
- "rule" => "moderate_maintainability",
1127
- "message" => "#{fm['path']} has maintainability index #{fm['maintainability']} (recommended min 40)",
1128
- "file" => fm["path"],
1129
- "line" => 1
1130
- }
1131
- end
1132
- end
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?
1133
422
 
1134
- violations.sort_by! { |v| [v["type"] == "error" ? 0 : 1, v["file"]] }
1135
- violations
423
+ file_metrics.first.dup.merge('engine' => 'tina4-cli')
1136
424
  end
1137
425
  end
1138
426
  end