tina4ruby 3.13.37 → 3.13.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +106 -2
- data/lib/tina4/database.rb +356 -46
- data/lib/tina4/dev_admin.rb +27 -10
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/mcp.rb +10 -3
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +238 -47
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +6 -4
- data/lib/tina4/orm.rb +13 -10
- data/lib/tina4/rack_app.rb +17 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +354 -18
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +11 -9
- metadata +6 -47
data/lib/tina4/metrics.rb
CHANGED
|
@@ -353,6 +353,124 @@ module Tina4
|
|
|
353
353
|
result
|
|
354
354
|
end
|
|
355
355
|
|
|
356
|
+
# ── Top Offenders (CLI + dashboard) ──────────────────────────
|
|
357
|
+
|
|
358
|
+
# Severity ranking for sorting (higher = more severe).
|
|
359
|
+
SEVERITY_RANK = { "error" => 2, "warn" => 1, "info" => 0 }.freeze
|
|
360
|
+
|
|
361
|
+
# Rank the worst code-quality issues into a single "top offenders" list.
|
|
362
|
+
#
|
|
363
|
+
# Reuses full_analysis (does NOT re-analyze). Each offender is a hash:
|
|
364
|
+
# {"file", "line", "kind", "severity", "score", "detail"}
|
|
365
|
+
#
|
|
366
|
+
# Rules (one offender per matching condition):
|
|
367
|
+
# - function complexity > 10 → kind "complexity"
|
|
368
|
+
# severity "error" if >20 else "warn"; score = complexity
|
|
369
|
+
# - file loc > 500 → kind "large_file" (warn); score = loc/100
|
|
370
|
+
# - file functions > 20 → kind "too_many_functions" (warn); score = functions/4
|
|
371
|
+
# - file maintainability < 40 → kind "low_maintainability"
|
|
372
|
+
# severity "error" if <20 else "warn"; score = (50 - mi)
|
|
373
|
+
# - file has_tests false → kind "untested" (info); score = loc/100
|
|
374
|
+
#
|
|
375
|
+
# Sorted by (severity rank, score) DESCENDING and truncated to `top`.
|
|
376
|
+
#
|
|
377
|
+
# Returns {"offenders" => [...], "summary" => {...}} where summary carries
|
|
378
|
+
# the headline numbers the CLI prints (files_analyzed, total_functions,
|
|
379
|
+
# avg_complexity, avg_maintainability, scan_mode, scan_root, and the total
|
|
380
|
+
# offender count before truncation).
|
|
381
|
+
def self.offenders(root = 'src', top = 20)
|
|
382
|
+
analysis = full_analysis(root)
|
|
383
|
+
if analysis.key?("error")
|
|
384
|
+
return { "offenders" => [], "summary" => { "error" => analysis["error"] } }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
items = []
|
|
388
|
+
|
|
389
|
+
# Function-level: cyclomatic complexity.
|
|
390
|
+
(analysis["most_complex_functions"] || []).each do |fn|
|
|
391
|
+
cc = fn["complexity"]
|
|
392
|
+
next unless cc > 10
|
|
393
|
+
items << {
|
|
394
|
+
"file" => fn["file"],
|
|
395
|
+
"line" => fn["line"],
|
|
396
|
+
"kind" => "complexity",
|
|
397
|
+
"severity" => cc > 20 ? "error" : "warn",
|
|
398
|
+
"score" => cc.to_f,
|
|
399
|
+
"detail" => "#{fn['name']} — cyclomatic complexity #{cc}"
|
|
400
|
+
}
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# File-level rules.
|
|
404
|
+
(analysis["file_metrics"] || []).each do |fm|
|
|
405
|
+
path = fm["path"]
|
|
406
|
+
loc = fm["loc"]
|
|
407
|
+
funcs = fm["functions"]
|
|
408
|
+
mi = fm["maintainability"]
|
|
409
|
+
|
|
410
|
+
if loc > 500
|
|
411
|
+
items << {
|
|
412
|
+
"file" => path,
|
|
413
|
+
"line" => 1,
|
|
414
|
+
"kind" => "large_file",
|
|
415
|
+
"severity" => "warn",
|
|
416
|
+
"score" => loc / 100.0,
|
|
417
|
+
"detail" => "#{loc} LOC (max 500)"
|
|
418
|
+
}
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
if funcs > 20
|
|
422
|
+
items << {
|
|
423
|
+
"file" => path,
|
|
424
|
+
"line" => 1,
|
|
425
|
+
"kind" => "too_many_functions",
|
|
426
|
+
"severity" => "warn",
|
|
427
|
+
"score" => funcs / 4.0,
|
|
428
|
+
"detail" => "#{funcs} functions (max 20)"
|
|
429
|
+
}
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
if mi < 40
|
|
433
|
+
items << {
|
|
434
|
+
"file" => path,
|
|
435
|
+
"line" => 1,
|
|
436
|
+
"kind" => "low_maintainability",
|
|
437
|
+
"severity" => mi < 20 ? "error" : "warn",
|
|
438
|
+
"score" => 50 - mi,
|
|
439
|
+
"detail" => "maintainability index #{mi} (min 40)"
|
|
440
|
+
}
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
if fm["has_tests"] == false
|
|
444
|
+
items << {
|
|
445
|
+
"file" => path,
|
|
446
|
+
"line" => 1,
|
|
447
|
+
"kind" => "untested",
|
|
448
|
+
"severity" => "info",
|
|
449
|
+
"score" => loc / 100.0,
|
|
450
|
+
"detail" => "no referencing test"
|
|
451
|
+
}
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Sort by (severity rank, score) DESCENDING — stable so insertion order
|
|
456
|
+
# breaks ties deterministically.
|
|
457
|
+
items = items.each_with_index.sort_by do |o, idx|
|
|
458
|
+
[-SEVERITY_RANK[o["severity"]], -o["score"], idx]
|
|
459
|
+
end.map(&:first)
|
|
460
|
+
|
|
461
|
+
summary = {
|
|
462
|
+
"files_analyzed" => analysis["files_analyzed"],
|
|
463
|
+
"total_functions" => analysis["total_functions"],
|
|
464
|
+
"avg_complexity" => analysis["avg_complexity"],
|
|
465
|
+
"avg_maintainability" => analysis["avg_maintainability"],
|
|
466
|
+
"scan_mode" => analysis["scan_mode"],
|
|
467
|
+
"scan_root" => analysis["scan_root"],
|
|
468
|
+
"total_offenders" => items.length
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
{ "offenders" => items.first(top), "summary" => summary }
|
|
472
|
+
end
|
|
473
|
+
|
|
356
474
|
# ── File Detail ─────────────────────────────────────────────
|
|
357
475
|
|
|
358
476
|
def self.file_detail(file_path)
|
|
@@ -423,64 +541,137 @@ module Tina4
|
|
|
423
541
|
|
|
424
542
|
private_class_method
|
|
425
543
|
|
|
544
|
+
# Check whether a source file has a test that actually exercises it.
|
|
545
|
+
#
|
|
546
|
+
# PRECISE detection (a bare word-mention is NOT enough — that over-reported
|
|
547
|
+
# badly: `sqlite3_adapter.rb` looked "tested" because some spec merely said
|
|
548
|
+
# "sqlite3_adapter"):
|
|
549
|
+
#
|
|
550
|
+
# 1. Filename — a dedicated `<module>_spec.rb` / `<module>_test.rb` /
|
|
551
|
+
# `test_<module>.rb` for THIS exact module (NOT the parent directory —
|
|
552
|
+
# one `database_spec.rb` must not mark every file under `database/`
|
|
553
|
+
# tested).
|
|
554
|
+
# 2. Require — a spec that actually requires this file: its require path
|
|
555
|
+
# (`require "tina4/database/sqlite"` / `require_relative ".../sqlite"`)
|
|
556
|
+
# matched by the basename of a require target. A constant/class that is
|
|
557
|
+
# genuinely DEFINED in this file (top-level class/module) referenced by
|
|
558
|
+
# a spec also counts.
|
|
559
|
+
#
|
|
560
|
+
# Returns true only on a real, file-specific signal — so the "untested"
|
|
561
|
+
# offenders surfaced by `tina4 metrics` and the dashboard "T" badge are
|
|
562
|
+
# trustworthy. (If you wire real coverage data later, prefer it over this.)
|
|
426
563
|
def self._has_matching_test(rel_path)
|
|
427
564
|
require 'set'
|
|
428
565
|
|
|
429
566
|
name = File.basename(rel_path, '.rb')
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
#
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
567
|
+
|
|
568
|
+
# Require path WITHOUT extension, leading lib/ stripped:
|
|
569
|
+
# "lib/tina4/database/sqlite.rb" -> "tina4/database/sqlite"
|
|
570
|
+
require_path = rel_path.sub(/\.rb$/, '').sub(%r{^lib/}, '')
|
|
571
|
+
|
|
572
|
+
# Constants (classes/modules) DEFINED at the top level of this file — a
|
|
573
|
+
# spec referencing one of them genuinely exercises this file. Names only,
|
|
574
|
+
# distinctive (>3 chars, leading uppercase); bare module-name words and
|
|
575
|
+
# guessed CamelCase are too loose to trust.
|
|
576
|
+
defined_symbols = _defined_constants(rel_path)
|
|
577
|
+
|
|
578
|
+
# Search roots: CWD plus (in framework-fallback mode) the repo root that
|
|
579
|
+
# owns spec/ — walk up from the scan root to find it.
|
|
580
|
+
search_roots = ['.']
|
|
581
|
+
if @last_scan_root && !@last_scan_root.empty?
|
|
582
|
+
scan_root = @last_scan_root
|
|
583
|
+
5.times do
|
|
584
|
+
if %w[spec test tests].any? { |d| Dir.exist?(File.join(scan_root, d)) }
|
|
585
|
+
search_roots << scan_root
|
|
586
|
+
break
|
|
587
|
+
end
|
|
588
|
+
parent = File.dirname(scan_root)
|
|
589
|
+
break if parent == scan_root
|
|
590
|
+
scan_root = parent
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
search_roots.uniq!
|
|
594
|
+
|
|
595
|
+
test_dirs = %w[spec test tests]
|
|
596
|
+
|
|
597
|
+
# Stage 1: a dedicated spec/test FILE named for THIS module (no parent-dir
|
|
598
|
+
# blanket match).
|
|
599
|
+
filename_patterns = [
|
|
600
|
+
"#{name}_spec.rb",
|
|
601
|
+
"#{name}s_spec.rb",
|
|
602
|
+
"#{name}_test.rb",
|
|
603
|
+
"test_#{name}.rb",
|
|
604
|
+
]
|
|
605
|
+
search_roots.each do |root|
|
|
606
|
+
test_dirs.each do |td|
|
|
607
|
+
filename_patterns.each do |fn|
|
|
608
|
+
return true if File.exist?(File.join(root, td, fn))
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Stage 2: a spec that actually REQUIRES this module (precise — matched by
|
|
614
|
+
# the require target's basename / tail of the require path), or references
|
|
615
|
+
# a constant defined in it. NO bare word-of-the-module-name match.
|
|
616
|
+
require_regexps = []
|
|
617
|
+
unless require_path.empty?
|
|
618
|
+
# require "…/<module>" or require_relative "…/<module>" — match the
|
|
619
|
+
# require string ending in this file's require path or basename.
|
|
620
|
+
rp = Regexp.escape(require_path)
|
|
621
|
+
nm = Regexp.escape(name)
|
|
622
|
+
require_regexps << /(?:require|require_relative)\s+['"][^'"]*#{rp}['"]/
|
|
623
|
+
require_regexps << %r{(?:require|require_relative)\s+['"][^'"]*/#{nm}['"]}
|
|
624
|
+
end
|
|
625
|
+
unless defined_symbols.empty?
|
|
626
|
+
sym_alt = defined_symbols.map { |s| Regexp.escape(s) }.join('|')
|
|
627
|
+
require_regexps << /\b(?:#{sym_alt})\b/
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
return false if require_regexps.empty?
|
|
631
|
+
|
|
632
|
+
search_roots.each do |root|
|
|
633
|
+
test_dirs.each do |td|
|
|
634
|
+
dir = File.join(root, td)
|
|
635
|
+
next unless Dir.exist?(dir)
|
|
636
|
+
Dir.glob(File.join(dir, '**', '*.rb')).each do |test_file|
|
|
637
|
+
content = begin
|
|
638
|
+
File.read(test_file, encoding: 'utf-8')
|
|
639
|
+
rescue StandardError
|
|
640
|
+
next
|
|
641
|
+
end
|
|
642
|
+
return true if require_regexps.any? { |re| content.match?(re) }
|
|
472
643
|
end
|
|
473
|
-
# Stage 2: require/require_relative path matching
|
|
474
|
-
return true if !require_path.empty? && content.include?(require_path)
|
|
475
|
-
# Stage 3: class name or module name mention
|
|
476
|
-
return true if content.match?(/\b#{Regexp.escape(class_name)}\b/)
|
|
477
|
-
return true if content.match?(/\b#{Regexp.escape(name)}\b/i)
|
|
478
644
|
end
|
|
479
645
|
end
|
|
480
646
|
|
|
481
647
|
false
|
|
482
648
|
end
|
|
483
649
|
|
|
650
|
+
# Top-level class/module names defined in the file at rel_path (resolved
|
|
651
|
+
# against the last scan root when present). Distinctive names only:
|
|
652
|
+
# leading-uppercase, longer than 3 chars.
|
|
653
|
+
def self._defined_constants(rel_path)
|
|
654
|
+
src_file = if @last_scan_root && !@last_scan_root.empty? && !File.exist?(rel_path)
|
|
655
|
+
File.join(@last_scan_root, rel_path)
|
|
656
|
+
else
|
|
657
|
+
rel_path
|
|
658
|
+
end
|
|
659
|
+
symbols = Set.new
|
|
660
|
+
content = begin
|
|
661
|
+
File.read(src_file, encoding: 'utf-8')
|
|
662
|
+
rescue StandardError
|
|
663
|
+
return symbols
|
|
664
|
+
end
|
|
665
|
+
content.each_line do |line|
|
|
666
|
+
stripped = line.strip
|
|
667
|
+
m = stripped.match(/\A(?:class|module)\s+([A-Z][A-Za-z0-9_]*)/)
|
|
668
|
+
next unless m
|
|
669
|
+
const = m[1]
|
|
670
|
+
symbols.add(const) if const.length > 3
|
|
671
|
+
end
|
|
672
|
+
symbols
|
|
673
|
+
end
|
|
674
|
+
|
|
484
675
|
def self._files_hash(root)
|
|
485
676
|
md5 = Digest::MD5.new
|
|
486
677
|
root_path = Pathname.new(root)
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -41,24 +41,44 @@ module Tina4
|
|
|
41
41
|
@global_middleware = []
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
# Run all "before" hooks: block-based handlers, then class-based before_*
|
|
44
|
+
# Run all "before" hooks: block-based handlers, then class-based before_*
|
|
45
|
+
# methods (in definition order).
|
|
45
46
|
#
|
|
46
47
|
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
47
48
|
# middleware classes explicitly.
|
|
48
49
|
#
|
|
49
|
-
#
|
|
50
|
+
# M2 — visible-but-resilient: every before_* call is wrapped so a THROW
|
|
51
|
+
# never crashes the worker. On a throw the error is LOGGED and the
|
|
52
|
+
# response becomes a clean 500 ({"error":"Internal Server Error",
|
|
53
|
+
# "status":500}), then processing halts (handler skipped) — deterministic,
|
|
54
|
+
# never an unhandled exception. A before_* that sets status >= 400 also
|
|
55
|
+
# halts (the existing 4xx short-circuit). after_* still run on either
|
|
56
|
+
# halt path (see the dispatcher / #run_after docstring).
|
|
57
|
+
#
|
|
58
|
+
# Returns true on success, or false to halt the request (handler skipped).
|
|
50
59
|
def run_before(middleware_classes, request, response)
|
|
51
60
|
# 1. Block-based before handlers (pattern-matched)
|
|
52
61
|
before_handlers.each do |entry|
|
|
53
62
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
54
|
-
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
result = entry[:handler].call(request, response)
|
|
66
|
+
rescue StandardError, ScriptError => error
|
|
67
|
+
middleware_500(response, "before handler", error)
|
|
68
|
+
return false
|
|
69
|
+
end
|
|
55
70
|
return false if result == false
|
|
56
71
|
end
|
|
57
72
|
|
|
58
|
-
# 2. Class-based middleware: call every before_* method
|
|
73
|
+
# 2. Class-based middleware: call every before_* method (definition order)
|
|
59
74
|
middleware_classes.each do |klass|
|
|
60
75
|
before_methods_for(klass).each do |method_name|
|
|
61
|
-
|
|
76
|
+
begin
|
|
77
|
+
result = klass.send(method_name, request, response)
|
|
78
|
+
rescue StandardError, ScriptError => error
|
|
79
|
+
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
62
82
|
# Support returning [request, response] (Python convention) or false to halt
|
|
63
83
|
if result == false
|
|
64
84
|
return false
|
|
@@ -73,21 +93,42 @@ module Tina4
|
|
|
73
93
|
true
|
|
74
94
|
end
|
|
75
95
|
|
|
76
|
-
# Run all "after" hooks: block-based handlers, then class-based after_*
|
|
96
|
+
# Run all "after" hooks: block-based handlers, then class-based after_*
|
|
97
|
+
# methods (in definition order).
|
|
77
98
|
#
|
|
78
99
|
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
79
100
|
# middleware classes explicitly.
|
|
101
|
+
#
|
|
102
|
+
# AFTER-ON-4xx RULE (M2, documented + consistent across all 4 frameworks):
|
|
103
|
+
# after_* ALWAYS run even when a before_* short-circuited with status >= 400
|
|
104
|
+
# and the handler was skipped — so they can still add headers / logging.
|
|
105
|
+
# The dispatcher calls #run_after unconditionally after the before/handler
|
|
106
|
+
# block (including on the 4xx / throw halt path).
|
|
107
|
+
#
|
|
108
|
+
# M2 — every after_* call is wrapped: a THROW is LOGGED and turns the
|
|
109
|
+
# response into a clean 500, then the REMAINING after_* still run (they
|
|
110
|
+
# may add headers/logging). Never an unhandled crash.
|
|
80
111
|
def run_after(middleware_classes, request, response)
|
|
81
112
|
# 1. Block-based after handlers (pattern-matched)
|
|
82
113
|
after_handlers.each do |entry|
|
|
83
114
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
84
|
-
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
entry[:handler].call(request, response)
|
|
118
|
+
rescue StandardError, ScriptError => error
|
|
119
|
+
middleware_500(response, "after handler", error)
|
|
120
|
+
end
|
|
85
121
|
end
|
|
86
122
|
|
|
87
|
-
# 2. Class-based middleware: call every after_* method
|
|
123
|
+
# 2. Class-based middleware: call every after_* method (definition order)
|
|
88
124
|
middleware_classes.each do |klass|
|
|
89
125
|
after_methods_for(klass).each do |method_name|
|
|
90
|
-
|
|
126
|
+
begin
|
|
127
|
+
result = klass.send(method_name, request, response)
|
|
128
|
+
rescue StandardError, ScriptError => error
|
|
129
|
+
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
130
|
+
next
|
|
131
|
+
end
|
|
91
132
|
if result.is_a?(Array) && result.length == 2
|
|
92
133
|
request, response = result
|
|
93
134
|
end
|
|
@@ -95,8 +136,38 @@ module Tina4
|
|
|
95
136
|
end
|
|
96
137
|
end
|
|
97
138
|
|
|
139
|
+
# Deterministic clean 500 for a middleware that threw. Logs the cause
|
|
140
|
+
# (NEVER silent) then sets the response to the canonical error shape —
|
|
141
|
+
# byte-identical to the Python master ({"error":"Internal Server Error",
|
|
142
|
+
# "status":500} + status 500). Returns the response for chaining.
|
|
143
|
+
def middleware_500(response, label, error)
|
|
144
|
+
begin
|
|
145
|
+
Tina4::Log.error(
|
|
146
|
+
"Middleware #{label} raised #{error.class.name}: #{error.message}"
|
|
147
|
+
)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
begin
|
|
150
|
+
$stderr.puts("Middleware #{label} raised #{error.class.name}: #{error.message}")
|
|
151
|
+
$stderr.flush
|
|
152
|
+
rescue StandardError
|
|
153
|
+
# never let logging break the worker
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
response.json({ error: "Internal Server Error", status: 500 }, 500)
|
|
157
|
+
end
|
|
158
|
+
|
|
98
159
|
private
|
|
99
160
|
|
|
161
|
+
# Human-readable label for a middleware (class name, or the class of an
|
|
162
|
+
# instance) used in the logged 500 message.
|
|
163
|
+
def class_label(klass)
|
|
164
|
+
if klass.is_a?(Class) || klass.is_a?(Module)
|
|
165
|
+
klass.name || klass.to_s
|
|
166
|
+
else
|
|
167
|
+
klass.class.name || klass.class.to_s
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
100
171
|
def matches_pattern?(path, pattern)
|
|
101
172
|
return true if pattern.nil?
|
|
102
173
|
case pattern
|
|
@@ -109,14 +180,66 @@ module Tina4
|
|
|
109
180
|
end
|
|
110
181
|
end
|
|
111
182
|
|
|
112
|
-
# Collect all
|
|
183
|
+
# Collect all class methods matching before_* in DEFINITION order.
|
|
113
184
|
def before_methods_for(klass)
|
|
114
|
-
klass
|
|
185
|
+
discover_methods(klass, "before_")
|
|
115
186
|
end
|
|
116
187
|
|
|
117
|
-
# Collect all
|
|
188
|
+
# Collect all class methods matching after_* in DEFINITION order.
|
|
118
189
|
def after_methods_for(klass)
|
|
119
|
-
klass
|
|
190
|
+
discover_methods(klass, "after_")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ----------------------------------------------------------------------
|
|
194
|
+
# MIDDLEWARE ORDERING (M1) — within a class, before_*/after_* methods run
|
|
195
|
+
# in SOURCE-DEFINITION order, NOT alphabetical. Cross-class order is the
|
|
196
|
+
# natural iteration of the registered middleware list (registration
|
|
197
|
+
# order). before_* run before the handler, after_* after.
|
|
198
|
+
#
|
|
199
|
+
# WHY source line numbers, not instance_methods(false): in Ruby/PRISM
|
|
200
|
+
# `instance_methods(false)` is NOT a reliable definition-order report —
|
|
201
|
+
# once a method NAME (symbol) has been defined on any other class first,
|
|
202
|
+
# that name can sort ahead in a later class's list. So we sort the
|
|
203
|
+
# matching methods by their `source_location` line number, which IS the
|
|
204
|
+
# true source-definition order and is immune to the symbol-table quirk.
|
|
205
|
+
# (Methods with no source_location — e.g. C-defined — sort to the front
|
|
206
|
+
# deterministically by name.) We walk the ancestry base→derived so
|
|
207
|
+
# inherited middleware methods run before a subclass's own, de-duping
|
|
208
|
+
# overrides to their first (base) position. Mirrors the Python master's
|
|
209
|
+
# Middleware._discover_methods MRO walk (which leans on __dict__ insertion
|
|
210
|
+
# order — the equivalent of source-definition order).
|
|
211
|
+
def discover_methods(klass, prefix)
|
|
212
|
+
target = klass.is_a?(Class) || klass.is_a?(Module) ? klass.singleton_class : klass.class
|
|
213
|
+
seen = {}
|
|
214
|
+
names = []
|
|
215
|
+
target.ancestors.reverse_each do |ancestor|
|
|
216
|
+
matched = begin
|
|
217
|
+
ancestor.instance_methods(false).select do |name|
|
|
218
|
+
name.to_s.start_with?(prefix) &&
|
|
219
|
+
!seen.key?(name) &&
|
|
220
|
+
klass.respond_to?(name)
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError
|
|
223
|
+
[]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
ordered = matched.sort_by.with_index do |name, idx|
|
|
227
|
+
line = begin
|
|
228
|
+
loc = ancestor.instance_method(name).source_location
|
|
229
|
+
loc ? loc[1] : -1
|
|
230
|
+
rescue StandardError
|
|
231
|
+
-1
|
|
232
|
+
end
|
|
233
|
+
# Tie-break on the symbol-table index so the result is total/stable.
|
|
234
|
+
[line, idx]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
ordered.each do |name|
|
|
238
|
+
seen[name] = true
|
|
239
|
+
names << name
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
names
|
|
120
243
|
end
|
|
121
244
|
end
|
|
122
245
|
end
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -356,10 +356,12 @@ module Tina4
|
|
|
356
356
|
Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
|
|
357
357
|
next
|
|
358
358
|
end
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
359
|
+
# db.execute() now RAISES on a SQL error (it no longer returns false),
|
|
360
|
+
# so a bad statement throws straight up to run_migration's rescue, which
|
|
361
|
+
# records the migration as failed and surfaces the error. The old
|
|
362
|
+
# "if result == false: raise" check is dead — a plain execute carries
|
|
363
|
+
# the same failure semantics.
|
|
364
|
+
@db.execute(stmt)
|
|
363
365
|
end
|
|
364
366
|
end
|
|
365
367
|
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -425,17 +425,20 @@ module Tina4
|
|
|
425
425
|
translator_engine = %w[postgres postgresql].include?(engine) ? "postgresql" : engine
|
|
426
426
|
sql = SQLTranslator.auto_increment_syntax(sql, translator_engine)
|
|
427
427
|
|
|
428
|
-
# Don't claim success when the DDL failed. db.execute()
|
|
429
|
-
#
|
|
430
|
-
# any DDL error)
|
|
431
|
-
#
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
428
|
+
# Don't claim success when the DDL failed. db.execute() now RAISES on a
|
|
429
|
+
# SQL error (it no longer swallows it into get_error() and returns
|
|
430
|
+
# false), so a bad type (or any DDL error) surfaces here as an
|
|
431
|
+
# exception. create_table keeps its documented bool contract: catch the
|
|
432
|
+
# raise, log the cause, and return false so callers that test the return
|
|
433
|
+
# still see a clean failure instead of a thrown error.
|
|
434
|
+
begin
|
|
435
|
+
db.execute(sql)
|
|
436
|
+
db.commit
|
|
437
|
+
true
|
|
438
|
+
rescue => e
|
|
439
|
+
Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
|
|
440
|
+
false
|
|
437
441
|
end
|
|
438
|
-
true
|
|
439
442
|
end
|
|
440
443
|
|
|
441
444
|
def scope(name, filter_sql, params = [])
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -351,9 +351,12 @@ module Tina4
|
|
|
351
351
|
env["tina4.request"] = request # Store for session save after response
|
|
352
352
|
response = Tina4::Response.new
|
|
353
353
|
|
|
354
|
-
# Run global middleware (block-based + class-based before_* methods)
|
|
354
|
+
# Run global middleware (block-based + class-based before_* methods).
|
|
355
|
+
# M2 — AFTER-ON-4xx RULE: when a before_* short-circuits (4xx/skip) or
|
|
356
|
+
# throws (clean 500), the after-pass STILL runs so after_* can add
|
|
357
|
+
# headers/logging — consistent across all 4 frameworks.
|
|
355
358
|
unless Tina4::Middleware.run_before(Tina4::Middleware.global_middleware, request, response)
|
|
356
|
-
|
|
359
|
+
Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, response)
|
|
357
360
|
return response.to_rack
|
|
358
361
|
end
|
|
359
362
|
|
|
@@ -885,18 +888,23 @@ module Tina4
|
|
|
885
888
|
# Wire the route handler into the WebSocket engine events
|
|
886
889
|
handler = ws_route.handler
|
|
887
890
|
|
|
888
|
-
# Create a dedicated WebSocket engine for this route so
|
|
891
|
+
# Create a dedicated WebSocket *event* engine for this route so each
|
|
892
|
+
# upgrade keeps its own isolated open/message/close handlers (the engine's
|
|
893
|
+
# on() appends handlers, so a single shared event engine would cross-wire
|
|
894
|
+
# routes).
|
|
889
895
|
ws = Tina4::WebSocket.new
|
|
890
896
|
|
|
891
|
-
# The
|
|
892
|
-
#
|
|
893
|
-
#
|
|
894
|
-
# Python's single
|
|
897
|
+
# The connection itself is OWNED by a process-wide shared manager so that
|
|
898
|
+
# broadcasts, rooms, the multi-instance backplane and the idle reaper span
|
|
899
|
+
# every route's connections — not just this one per-socket event engine.
|
|
900
|
+
# Mirrors Python's single WebSocketManager. The dev-reload channel uses its
|
|
901
|
+
# own shared manager (Tina4::DevReload) so POST /__dev/api/reload reaches
|
|
902
|
+
# every browser.
|
|
895
903
|
dev_reload = ws_route.path == "/__dev_reload"
|
|
904
|
+
manager = dev_reload ? Tina4::DevReload.manager : @websocket_engine
|
|
896
905
|
|
|
897
906
|
ws.on(:open) do |connection|
|
|
898
907
|
connection.params = ws_params
|
|
899
|
-
Tina4::DevReload.add(connection) if dev_reload
|
|
900
908
|
handler.call(connection, :open, nil)
|
|
901
909
|
end
|
|
902
910
|
|
|
@@ -905,7 +913,6 @@ module Tina4
|
|
|
905
913
|
end
|
|
906
914
|
|
|
907
915
|
ws.on(:close) do |connection|
|
|
908
|
-
Tina4::DevReload.remove(connection) if dev_reload
|
|
909
916
|
handler.call(connection, :close, nil)
|
|
910
917
|
end
|
|
911
918
|
|
|
@@ -913,7 +920,7 @@ module Tina4
|
|
|
913
920
|
Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
|
|
914
921
|
end
|
|
915
922
|
|
|
916
|
-
ws.handle_upgrade(env, socket)
|
|
923
|
+
ws.handle_upgrade(env, socket, manager: manager)
|
|
917
924
|
|
|
918
925
|
# Return async response (-1 signals Rack the response is handled via hijack)
|
|
919
926
|
[-1, {}, []]
|