tina4ruby 3.13.94 → 3.13.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +256 -33
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- 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 —
|
|
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,
|
|
8
|
+
# index, coupling, Halstead metrics, offenders
|
|
9
9
|
#
|
|
10
|
-
# Zero dependencies
|
|
10
|
+
# Zero dependencies. The census is pure Ruby; the analysis is `tina4 metrics --json`.
|
|
11
11
|
|
|
12
|
-
require '
|
|
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
|
-
#
|
|
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
|
-
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
556
|
-
#
|
|
557
|
-
#
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
321
|
+
raise MetricsEngineError, "tina4 metrics produced no output for #{path}" if stdout.to_s.strip.empty?
|
|
640
322
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
342
|
+
value
|
|
715
343
|
end
|
|
716
344
|
|
|
717
|
-
#
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
#
|
|
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
|
|
871
|
-
#
|
|
872
|
-
#
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
915
|
-
#
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
|
|
1135
|
-
violations
|
|
423
|
+
file_metrics.first.dup.merge('engine' => 'tina4-cli')
|
|
1136
424
|
end
|
|
1137
425
|
end
|
|
1138
426
|
end
|