tina4ruby 3.13.38 → 3.13.40
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/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/auth.rb +5 -1
- data/lib/tina4/cli.rb +4 -0
- data/lib/tina4/database.rb +51 -6
- data/lib/tina4/dev_admin.rb +92 -9
- data/lib/tina4/docstore.rb +753 -0
- data/lib/tina4/env.rb +7 -2
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +101 -9
- data/lib/tina4/metrics.rb +115 -28
- data/lib/tina4/migration.rb +107 -20
- data/lib/tina4/orm.rb +182 -21
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +22 -5
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/swagger.rb +166 -32
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +105 -4
- data/lib/tina4.rb +81 -3
- metadata +3 -2
data/lib/tina4/env.rb
CHANGED
|
@@ -62,8 +62,13 @@ module Tina4
|
|
|
62
62
|
end
|
|
63
63
|
lines.concat([
|
|
64
64
|
"",
|
|
65
|
-
"
|
|
66
|
-
"
|
|
65
|
+
"Note: these may come from a .env file loaded by dotenv, not just",
|
|
66
|
+
"the runtime environment - check your image / build context (a .env",
|
|
67
|
+
"baked into a Docker image is loaded at startup) as well as k8s/CI env.",
|
|
68
|
+
"",
|
|
69
|
+
"FIX: run `tina4 env --migrate` to rewrite your .env automatically",
|
|
70
|
+
"(it renames every legacy name to its TINA4_ form in place).",
|
|
71
|
+
"Or rename manually. See https://tina4.com/release/3.12.0",
|
|
67
72
|
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
68
73
|
sep, ""
|
|
69
74
|
])
|
data/lib/tina4/field_types.rb
CHANGED
|
@@ -20,8 +20,11 @@ module Tina4
|
|
|
20
20
|
@table_name = name
|
|
21
21
|
else
|
|
22
22
|
base = self.name.split("::").last.downcase
|
|
23
|
-
#
|
|
24
|
-
|
|
23
|
+
# Pluralization is OFF by default (canonical, matching the Python
|
|
24
|
+
# master): the table name is the bare class name lowercased. Opt in by
|
|
25
|
+
# setting TINA4_ORM_PLURAL_TABLE_NAMES to a truthy value (true/1/yes/on)
|
|
26
|
+
# to append "s".
|
|
27
|
+
if ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(true|1|yes|on)\z/i)
|
|
25
28
|
base += "s" unless base.end_with?("s")
|
|
26
29
|
end
|
|
27
30
|
@table_name || base
|
data/lib/tina4/log.rb
CHANGED
|
@@ -12,11 +12,12 @@ module Tina4
|
|
|
12
12
|
"[TINA4_LOG_INFO]" => 1,
|
|
13
13
|
"[TINA4_LOG_WARNING]" => 2,
|
|
14
14
|
"[TINA4_LOG_ERROR]" => 3,
|
|
15
|
-
"[
|
|
15
|
+
"[TINA4_LOG_CRITICAL]" => 4,
|
|
16
|
+
"[TINA4_LOG_NONE]" => 5
|
|
16
17
|
}.freeze
|
|
17
18
|
|
|
18
19
|
SEVERITY_MAP = {
|
|
19
|
-
debug: 0, info: 1, warn: 2, error: 3
|
|
20
|
+
debug: 0, info: 1, warn: 2, error: 3, critical: 4
|
|
20
21
|
}.freeze
|
|
21
22
|
|
|
22
23
|
COLORS = {
|
|
@@ -65,15 +66,36 @@ module Tina4
|
|
|
65
66
|
@format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
|
|
66
67
|
@json_mode = @format == "json"
|
|
67
68
|
|
|
68
|
-
# TINA4_LOG_OUTPUT — "stdout", "file", or "both".
|
|
69
|
+
# TINA4_LOG_OUTPUT — "stdout", "file", or "both".
|
|
70
|
+
#
|
|
71
|
+
# Default (UNSET): stdout is ALWAYS on. The log FILE (tina4.log + any
|
|
72
|
+
# error log) is written ONLY in development — i.e. when TINA4_DEBUG is
|
|
73
|
+
# truthy. In production / containers (TINA4_DEBUG falsy) the logger is
|
|
74
|
+
# stdout-only: writing a log file inside a container just bloats the
|
|
75
|
+
# writable layer + disk, and 12-factor wants logs on stdout for the
|
|
76
|
+
# platform to capture. An explicit TINA4_LOG_OUTPUT=file/both (or an
|
|
77
|
+
# explicit TINA4_LOG_FILE path) overrides this and STILL writes a file.
|
|
78
|
+
# Mirrors the Python master (debug/__init__.py configure()).
|
|
79
|
+
# An explicit TINA4_LOG_FILE always wins: a path the operator named must
|
|
80
|
+
# be written even in production (parity with the Python master, where an
|
|
81
|
+
# explicit log_file builds a writer unconditionally), so the dev-gated
|
|
82
|
+
# default below resolves to "both" (stdout + file) rather than "stdout".
|
|
83
|
+
explicit_file = !(log_file_env.nil? || log_file_env.empty?)
|
|
84
|
+
default_output = if explicit_file || truthy?(ENV["TINA4_DEBUG"])
|
|
85
|
+
"both"
|
|
86
|
+
else
|
|
87
|
+
"stdout"
|
|
88
|
+
end
|
|
69
89
|
output_env = ENV["TINA4_LOG_OUTPUT"]
|
|
70
|
-
@output = output_env && !output_env.empty?
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
@output = if output_env && !output_env.empty?
|
|
91
|
+
output_env.downcase
|
|
92
|
+
else
|
|
93
|
+
default_output
|
|
94
|
+
end
|
|
95
|
+
@output = default_output unless %w[stdout file both].include?(@output)
|
|
74
96
|
|
|
75
|
-
#
|
|
76
|
-
@
|
|
97
|
+
# TINA4_LOG_STRICT — when true, raise on log write failures instead of swallowing.
|
|
98
|
+
@strict = truthy?(ENV["TINA4_LOG_STRICT"])
|
|
77
99
|
|
|
78
100
|
@console_level = resolve_level
|
|
79
101
|
@request_id = nil
|
|
@@ -122,6 +144,28 @@ module Tina4
|
|
|
122
144
|
@json_mode
|
|
123
145
|
end
|
|
124
146
|
|
|
147
|
+
# Would a message at `level` pass the configured MINIMUM CONSOLE LEVEL
|
|
148
|
+
# (TINA4_LOG_LEVEL)? Returns true iff `log` would print it to stdout —
|
|
149
|
+
# it reflects CONSOLE visibility only. The log FILE records every level
|
|
150
|
+
# regardless of this threshold, so this never gates file output.
|
|
151
|
+
#
|
|
152
|
+
# `level` accepts a String or Symbol and is case-insensitive
|
|
153
|
+
# ("INFO", :info, "Warning", :warning all work). Mirrors Python's
|
|
154
|
+
# Log.is_enabled. It REUSES the exact severity >= @console_level
|
|
155
|
+
# comparison the console branch in `log` uses (line ~167) via
|
|
156
|
+
# SEVERITY_MAP / resolve_level — it never re-implements level
|
|
157
|
+
# comparison, so it can never disagree with what the logger prints.
|
|
158
|
+
#
|
|
159
|
+
# "critical" is a FIRST-CLASS top-level severity (4 — above error 3),
|
|
160
|
+
# not a parity alias for error. It is evaluated with ordinary threshold
|
|
161
|
+
# logic (critical 4 >= @console_level), so it passes at every level
|
|
162
|
+
# except none (5) — matching the Python master.
|
|
163
|
+
def enabled?(level)
|
|
164
|
+
sym = normalize_level(level)
|
|
165
|
+
severity = SEVERITY_MAP[sym] || 0
|
|
166
|
+
severity >= console_level
|
|
167
|
+
end
|
|
168
|
+
|
|
125
169
|
def info(message, context = {})
|
|
126
170
|
log(:info, message, context)
|
|
127
171
|
end
|
|
@@ -138,6 +182,14 @@ module Tina4
|
|
|
138
182
|
log(:error, message, context)
|
|
139
183
|
end
|
|
140
184
|
|
|
185
|
+
# critical is the HIGHEST severity (4, above error). Like every other
|
|
186
|
+
# level it ALWAYS emits, subject only to the TINA4_LOG_LEVEL threshold
|
|
187
|
+
# (which critical passes at every level except none). A critical log is
|
|
188
|
+
# never a silent no-op. Mirrors the Python master.
|
|
189
|
+
def critical(message, context = {})
|
|
190
|
+
log(:critical, message, context)
|
|
191
|
+
end
|
|
192
|
+
|
|
141
193
|
# Test/teardown helper — closes the underlying Logger so the file
|
|
142
194
|
# handle is released (Windows / tmpdir cleanup).
|
|
143
195
|
def close_file_logger
|
|
@@ -181,6 +233,28 @@ module Tina4
|
|
|
181
233
|
@current_context = {}
|
|
182
234
|
end
|
|
183
235
|
|
|
236
|
+
# The current minimum console level as an integer (the same value
|
|
237
|
+
# the console branch in `log` compares against). Ensures the logger
|
|
238
|
+
# is configured so `enabled?` works before any log call has run.
|
|
239
|
+
def console_level
|
|
240
|
+
configure unless @initialized
|
|
241
|
+
@console_level
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Map a level (String or Symbol, case-insensitive) onto the symbol
|
|
245
|
+
# space used by SEVERITY_MAP. Accepts the public method names
|
|
246
|
+
# (debug/info/warning/error/critical) and the internal :warn symbol.
|
|
247
|
+
# critical is a FIRST-CLASS level (severity 4), not an alias for error.
|
|
248
|
+
# Unknown levels fall through to their own symbol and resolve to
|
|
249
|
+
# severity 0 in `enabled?`.
|
|
250
|
+
def normalize_level(level)
|
|
251
|
+
sym = level.to_s.strip.downcase.to_sym
|
|
252
|
+
case sym
|
|
253
|
+
when :warning then :warn
|
|
254
|
+
else sym
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
184
258
|
def resolve_level
|
|
185
259
|
# v3.13.14: default is INFO (was ALL) so a deployed app surfaces
|
|
186
260
|
# request/startup/warn/error without debug noise, matching
|
|
@@ -199,6 +273,7 @@ module Tina4
|
|
|
199
273
|
when :info then "INFO"
|
|
200
274
|
when :warn then "WARNING"
|
|
201
275
|
when :error then "ERROR"
|
|
276
|
+
when :critical then "CRITICAL"
|
|
202
277
|
else level.to_s.upcase
|
|
203
278
|
end
|
|
204
279
|
end
|
|
@@ -278,6 +353,7 @@ module Tina4
|
|
|
278
353
|
when :info then COLORS[:green]
|
|
279
354
|
when :warn then COLORS[:yellow]
|
|
280
355
|
when :error then COLORS[:red]
|
|
356
|
+
when :critical then COLORS[:magenta]
|
|
281
357
|
else COLORS[:reset]
|
|
282
358
|
end
|
|
283
359
|
"#{color}#{line}#{COLORS[:reset]}"
|
|
@@ -288,7 +364,7 @@ module Tina4
|
|
|
288
364
|
# Use << to bypass Logger's severity filtering — we already filtered above.
|
|
289
365
|
@file_logger << "#{line}\n"
|
|
290
366
|
rescue IOError, SystemCallError => e
|
|
291
|
-
raise if @
|
|
367
|
+
raise if @strict
|
|
292
368
|
# Don't crash on log write failure
|
|
293
369
|
end
|
|
294
370
|
end
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -131,23 +131,76 @@ module Tina4
|
|
|
131
131
|
schema
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
-
#
|
|
134
|
+
# Informational only — whether the CONFIGURED host looks local.
|
|
135
|
+
#
|
|
136
|
+
# NOT the security gate. This reads TINA4_HOST_NAME (the configured bind
|
|
137
|
+
# address), which on a 0.0.0.0 bind looks "local" while still accepting
|
|
138
|
+
# remote clients. Trust decisions use {request_allowed?} with the RAW
|
|
139
|
+
# socket peer instead. Kept for diagnostics / back-compat.
|
|
135
140
|
def self.is_localhost?
|
|
136
141
|
host = ENV.fetch("TINA4_HOST_NAME", "localhost:7145").split(":").first
|
|
137
142
|
["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
|
|
138
143
|
end
|
|
139
144
|
|
|
140
|
-
#
|
|
145
|
+
# Whether an address is a loopback (in-process / same-host) peer.
|
|
146
|
+
#
|
|
147
|
+
# Operates on the RAW socket peer, never X-Forwarded-For. Empty means an
|
|
148
|
+
# in-process / synthetic request (no socket) and is trusted. The `::ffff:`
|
|
149
|
+
# IPv4-mapped prefix is stripped. NOTE: 0.0.0.0 is a BIND address, never a
|
|
150
|
+
# client address, so it is deliberately NOT loopback.
|
|
151
|
+
#
|
|
152
|
+
# Python master parity: tina4_python.mcp.is_loopback.
|
|
153
|
+
def self.is_loopback?(ip)
|
|
154
|
+
return true if ip.nil? || ip.to_s.empty?
|
|
155
|
+
|
|
156
|
+
addr = ip.to_s.strip.downcase
|
|
157
|
+
addr = addr[7..] if addr.start_with?("::ffff:")
|
|
158
|
+
addr == "::1" || addr == "localhost" || addr.start_with?("127.")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Capability gate — whether the MCP subsystem may run at all.
|
|
141
162
|
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
163
|
+
# Pure capability, host-INDEPENDENT (Python master parity):
|
|
164
|
+
# 1. TINA4_MCP set explicitly → use it (sysadmin override, any host).
|
|
165
|
+
# 2. Else TINA4_DEBUG truthy → MCP is a capability of this deployment.
|
|
166
|
+
# 3. Otherwise off.
|
|
167
|
+
#
|
|
168
|
+
# This NO LONGER consults the host. A debug box bound to 0.0.0.0 still "has"
|
|
169
|
+
# the capability, but {request_allowed?} is what decides whether a given
|
|
170
|
+
# CALLER may use it — loopback always, remote only with an explicit opt-in
|
|
171
|
+
# plus a valid token. Splitting capability from per-request authorisation
|
|
172
|
+
# closes the hole where a 0.0.0.0 bind auto-exposed DB/file tools to remote
|
|
173
|
+
# unauthenticated callers (pre-3.13.40 is_localhost? treated 0.0.0.0 local).
|
|
145
174
|
def self.mcp_enabled?
|
|
146
175
|
explicit = ENV["TINA4_MCP"]
|
|
147
176
|
if explicit && !explicit.empty?
|
|
148
|
-
return
|
|
177
|
+
return truthy?(explicit)
|
|
149
178
|
end
|
|
150
|
-
|
|
179
|
+
|
|
180
|
+
truthy?(ENV["TINA4_DEBUG"])
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Per-request authorisation — whether THIS caller may use MCP.
|
|
184
|
+
#
|
|
185
|
+
# @param remote_ip [String] raw socket peer (env["REMOTE_ADDR"]), never XFF.
|
|
186
|
+
# @param has_valid_token [Boolean] true when the request carried a token
|
|
187
|
+
# matching TINA4_MCP_TOKEN.
|
|
188
|
+
#
|
|
189
|
+
# Rules (Python master parity, tina4_python.mcp.is_request_allowed):
|
|
190
|
+
# - Capability off ({mcp_enabled?} false) → deny.
|
|
191
|
+
# - Loopback peer → allow.
|
|
192
|
+
# - Remote peer → only when TINA4_MCP_REMOTE is truthy AND a valid token
|
|
193
|
+
# was presented. No configured token ⇒ remote can never pass.
|
|
194
|
+
def self.request_allowed?(remote_ip, has_valid_token: false)
|
|
195
|
+
return false unless mcp_enabled?
|
|
196
|
+
return true if is_loopback?(remote_ip)
|
|
197
|
+
|
|
198
|
+
truthy?(ENV["TINA4_MCP_REMOTE"]) && has_valid_token
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Case-insensitive truthiness for env values: true/1/yes/on.
|
|
202
|
+
def self.truthy?(val)
|
|
203
|
+
%w[true 1 yes on].include?(val.to_s.strip.downcase)
|
|
151
204
|
end
|
|
152
205
|
|
|
153
206
|
# Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps
|
|
@@ -573,9 +626,27 @@ module Tina4
|
|
|
573
626
|
err = looks_like_prose.call(rel_path)
|
|
574
627
|
raise ArgumentError, "Invalid path #{rel_path.inspect}: #{err}" if err
|
|
575
628
|
resolved = File.expand_path(rel_path, project_root)
|
|
576
|
-
|
|
629
|
+
# Compare against root + separator, not a bare prefix: a plain
|
|
630
|
+
# start_with?(project_root) would also accept a sibling like
|
|
631
|
+
# "<root>-evil". expand_path already resolves ".." so a climb-out
|
|
632
|
+
# lands outside root and is rejected here.
|
|
633
|
+
root_prefix = "#{project_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
|
|
634
|
+
unless resolved == project_root || resolved.start_with?(root_prefix)
|
|
577
635
|
raise ArgumentError, "Path escapes project directory: #{rel_path}"
|
|
578
636
|
end
|
|
637
|
+
# Belt-and-braces against symlink escapes: if the path already exists,
|
|
638
|
+
# canonicalise it and re-check containment. A symlink inside the tree
|
|
639
|
+
# pointing outside would otherwise slip past the textual check. New
|
|
640
|
+
# paths (parent not created yet) have no realpath and rely on the
|
|
641
|
+
# expand_path containment above.
|
|
642
|
+
if File.exist?(resolved)
|
|
643
|
+
real = File.realpath(resolved)
|
|
644
|
+
real_root = File.realpath(project_root)
|
|
645
|
+
real_prefix = "#{real_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
|
|
646
|
+
unless real == real_root || real.start_with?(real_prefix)
|
|
647
|
+
raise ArgumentError, "Path escapes project directory: #{rel_path}"
|
|
648
|
+
end
|
|
649
|
+
end
|
|
579
650
|
resolved
|
|
580
651
|
end
|
|
581
652
|
|
|
@@ -643,7 +714,22 @@ module Tina4
|
|
|
643
714
|
db = Tina4.database
|
|
644
715
|
return { "error" => "No database connection" } if db.nil?
|
|
645
716
|
param_list = params.is_a?(String) ? JSON.parse(params) : params
|
|
646
|
-
|
|
717
|
+
param_list = [] unless param_list.is_a?(Array)
|
|
718
|
+
# Defense-in-depth: this tool is read-only. Strip comments, reject
|
|
719
|
+
# multiple statements, and require a leading SELECT/WITH so it can
|
|
720
|
+
# never mutate data even if reached (database_execute is the write
|
|
721
|
+
# surface, gated separately). Mirrors the Python master.
|
|
722
|
+
cleaned = sql.to_s.gsub(/--[^\r\n]*/, " ").gsub(%r{/\*.*?\*/}m, " ")
|
|
723
|
+
cleaned = cleaned.strip.sub(/[;\s]+\z/, "")
|
|
724
|
+
return { "error" => "database_query rejects multiple statements" } if cleaned.include?(";")
|
|
725
|
+
unless cleaned =~ /\A(select|with)\b/i
|
|
726
|
+
return { "error" => "database_query is read-only (SELECT/WITH only)" }
|
|
727
|
+
end
|
|
728
|
+
begin
|
|
729
|
+
result = db.fetch(cleaned, param_list)
|
|
730
|
+
rescue => e
|
|
731
|
+
return { "error" => (db.get_error rescue nil) || e.message }
|
|
732
|
+
end
|
|
647
733
|
{ "records" => result.to_a, "count" => result.count }
|
|
648
734
|
}, "Execute a read-only SQL query (SELECT)")
|
|
649
735
|
|
|
@@ -672,6 +758,12 @@ module Tina4
|
|
|
672
758
|
server.register_tool("database_columns", lambda { |table:|
|
|
673
759
|
db = Tina4.database
|
|
674
760
|
return { "error" => "No database connection" } if db.nil?
|
|
761
|
+
# Constrain the table name to a safe identifier (optionally
|
|
762
|
+
# schema-qualified) — defense-in-depth so it can never be abused for
|
|
763
|
+
# injection even if an adapter interpolates it. Parity with Python/PHP.
|
|
764
|
+
unless table.to_s =~ /\A[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?\z/
|
|
765
|
+
return { "error" => "Invalid table name" }
|
|
766
|
+
end
|
|
675
767
|
db.columns(table)
|
|
676
768
|
}, "Get column definitions for a table")
|
|
677
769
|
|
data/lib/tina4/metrics.rb
CHANGED
|
@@ -649,7 +649,10 @@ module Tina4
|
|
|
649
649
|
|
|
650
650
|
# Top-level class/module names defined in the file at rel_path (resolved
|
|
651
651
|
# against the last scan root when present). Distinctive names only:
|
|
652
|
-
# leading-uppercase, longer than 3
|
|
652
|
+
# leading-uppercase, longer than 2 chars — so genuine 3-char constants like
|
|
653
|
+
# ORM (orm.rb) and API (api.rb), which specs reference as `Tina4::ORM` /
|
|
654
|
+
# `Tina4::API`, are detected as tested instead of being mislabelled
|
|
655
|
+
# untested. (Was > 3, which silently excluded every 3-char constant.)
|
|
653
656
|
def self._defined_constants(rel_path)
|
|
654
657
|
src_file = if @last_scan_root && !@last_scan_root.empty? && !File.exist?(rel_path)
|
|
655
658
|
File.join(@last_scan_root, rel_path)
|
|
@@ -667,7 +670,7 @@ module Tina4
|
|
|
667
670
|
m = stripped.match(/\A(?:class|module)\s+([A-Z][A-Za-z0-9_]*)/)
|
|
668
671
|
next unless m
|
|
669
672
|
const = m[1]
|
|
670
|
-
symbols.add(const) if const.length >
|
|
673
|
+
symbols.add(const) if const.length > 2
|
|
671
674
|
end
|
|
672
675
|
symbols
|
|
673
676
|
end
|
|
@@ -702,8 +705,61 @@ module Tina4
|
|
|
702
705
|
imports
|
|
703
706
|
end
|
|
704
707
|
|
|
705
|
-
|
|
708
|
+
# Replace the CONTENT of Ruby string literals, regex literals, and comments
|
|
709
|
+
# with neutral spaces — keeping every line's length and the line count
|
|
710
|
+
# identical to the original — so decision-point keywords and method-shaped
|
|
711
|
+
# text that live INSIDE strings/comments are never miscounted. Returns an
|
|
712
|
+
# array of cleaned lines (chomped) aligned 1:1 with the original lines.
|
|
713
|
+
#
|
|
714
|
+
# Ruby's own lexer (Ripper) does the hard parsing: it tags string/heredoc/
|
|
715
|
+
# regex bodies as :on_tstring_content (and :on_comment, :on_embdoc — the
|
|
716
|
+
# =begin/=end block-comment body), which we blank out positionally. The
|
|
717
|
+
# surrounding code structure (def/if/end keywords, operators) is left intact.
|
|
718
|
+
NOISE_TOKEN_TYPES = %i[
|
|
719
|
+
on_tstring_content on_comment on_embdoc on_embdoc_beg on_embdoc_end
|
|
720
|
+
].freeze
|
|
721
|
+
|
|
722
|
+
def self._clean_source(source)
|
|
723
|
+
lines = source.lines.map(&:chomp)
|
|
724
|
+
# Mutable per-line character buffers we can blank out by column range.
|
|
725
|
+
buffers = lines.map(&:dup)
|
|
726
|
+
|
|
727
|
+
tokens = begin
|
|
728
|
+
Ripper.lex(source)
|
|
729
|
+
rescue StandardError
|
|
730
|
+
return lines
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
tokens.each do |(pos, type, token)|
|
|
734
|
+
next unless NOISE_TOKEN_TYPES.include?(type)
|
|
735
|
+
|
|
736
|
+
row = pos[0] - 1
|
|
737
|
+
col = pos[1]
|
|
738
|
+
# A noise token may span multiple physical lines (heredocs, block
|
|
739
|
+
# comments, multi-line strings). Blank each covered line segment.
|
|
740
|
+
token.to_s.each_line.with_index do |seg, offset|
|
|
741
|
+
line_idx = row + offset
|
|
742
|
+
next if line_idx.negative? || line_idx >= buffers.length
|
|
743
|
+
|
|
744
|
+
buf = buffers[line_idx]
|
|
745
|
+
# On the token's first line the content starts at `col`; on
|
|
746
|
+
# continuation lines it starts at column 0.
|
|
747
|
+
start = offset.zero? ? col : 0
|
|
748
|
+
seg_len = seg.chomp.length
|
|
749
|
+
stop = [start + seg_len, buf.length].min
|
|
750
|
+
(start...stop).each { |c| buf[c] = ' ' } if stop > start
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
buffers
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def self._extract_functions(source, _tokens, _lines)
|
|
706
758
|
functions = []
|
|
759
|
+
# Operate on a neutralised copy: string/regex/comment CONTENT is blanked
|
|
760
|
+
# so keywords inside them are never read as real code (line numbers, line
|
|
761
|
+
# count and column widths are preserved).
|
|
762
|
+
lines = _clean_source(source)
|
|
707
763
|
# Track class/module nesting for method names
|
|
708
764
|
context_stack = []
|
|
709
765
|
i = 0
|
|
@@ -718,7 +774,8 @@ module Tina4
|
|
|
718
774
|
context_stack.push(class_name) unless class_name.empty?
|
|
719
775
|
end
|
|
720
776
|
|
|
721
|
-
# Detect method definitions
|
|
777
|
+
# Detect method definitions — require a real `def ` declaration so a
|
|
778
|
+
# `def`-shaped substring inside a (now-blanked) string is never a method.
|
|
722
779
|
if stripped.match?(/\Adef\s+/)
|
|
723
780
|
method_match = stripped.match(/\Adef\s+(self\.)?(\S+?)(\(.*\))?\s*$/)
|
|
724
781
|
if method_match
|
|
@@ -779,43 +836,73 @@ module Tina4
|
|
|
779
836
|
functions
|
|
780
837
|
end
|
|
781
838
|
|
|
839
|
+
# Keywords that ALWAYS open a block needing a matching `end`.
|
|
840
|
+
BLOCK_OPENERS = %w[def class module begin case].freeze
|
|
841
|
+
# Keywords that open a block ONLY in statement-leading position; in trailing
|
|
842
|
+
# position they are modifiers (`return x if y`) and need no `end`.
|
|
843
|
+
CONDITIONAL_OPENERS = %w[if unless while until for].freeze
|
|
844
|
+
|
|
845
|
+
# Find the line index where the method that starts at `start_index` ends.
|
|
846
|
+
#
|
|
847
|
+
# Token-driven (Ripper) so it is immune to the line-regex footguns that made
|
|
848
|
+
# this over-run to end-of-file (CC 496 on tiny methods):
|
|
849
|
+
# * `self.class` — `class` after a `.` is an identifier, not a block opener
|
|
850
|
+
# (Ripper tags it :on_ident), so it no longer bumps depth.
|
|
851
|
+
# * modifier `if/unless/while/until/for` (`return x if y`) — only counted
|
|
852
|
+
# as an opener in statement-LEADING position (first real token of a
|
|
853
|
+
# statement), never trailing.
|
|
854
|
+
# * `lines` are already string/comment-cleaned, so keywords inside string
|
|
855
|
+
# bodies are gone too.
|
|
856
|
+
# Falls back to the last line only if no matching `end` is found.
|
|
782
857
|
def self._find_method_end(lines, start_index)
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
858
|
+
source = lines[start_index..].join("\n")
|
|
859
|
+
tokens = begin
|
|
860
|
+
Ripper.lex(source)
|
|
861
|
+
rescue StandardError
|
|
862
|
+
return lines.length - 1
|
|
863
|
+
end
|
|
786
864
|
|
|
787
|
-
|
|
788
|
-
|
|
865
|
+
depth = 0
|
|
866
|
+
# A keyword is a block opener only when it leads a statement. Track that:
|
|
867
|
+
# we are at statement start initially and right after a newline / `;`.
|
|
868
|
+
at_statement_start = true
|
|
869
|
+
seen_opener = false
|
|
789
870
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
!stripped.end_with?(' if ', ' unless ', ' while ', ' until ') &&
|
|
795
|
-
!(stripped.match?(/\bif\b|\bunless\b|\bwhile\b|\buntil\b/) && i != start_index && _is_modifier?(stripped))
|
|
871
|
+
tokens.each do |(pos, type, token)|
|
|
872
|
+
case type
|
|
873
|
+
when :on_kw
|
|
874
|
+
if BLOCK_OPENERS.include?(token)
|
|
796
875
|
depth += 1
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
876
|
+
seen_opener = true
|
|
877
|
+
elsif token == 'do'
|
|
878
|
+
depth += 1
|
|
879
|
+
seen_opener = true
|
|
880
|
+
elsif CONDITIONAL_OPENERS.include?(token)
|
|
881
|
+
# Leading => real block opener; trailing => modifier (no end).
|
|
882
|
+
if at_statement_start
|
|
883
|
+
depth += 1
|
|
884
|
+
seen_opener = true
|
|
885
|
+
end
|
|
886
|
+
elsif token == 'end'
|
|
800
887
|
depth -= 1
|
|
801
|
-
|
|
888
|
+
if seen_opener && depth <= 0
|
|
889
|
+
return start_index + (pos[0] - 1)
|
|
890
|
+
end
|
|
802
891
|
end
|
|
892
|
+
at_statement_start = false
|
|
893
|
+
when :on_nl, :on_ignored_nl, :on_semicolon
|
|
894
|
+
at_statement_start = true
|
|
895
|
+
when :on_sp, :on_comment, :on_embdoc, :on_embdoc_beg, :on_embdoc_end
|
|
896
|
+
# whitespace/comments don't change statement-start state
|
|
897
|
+
else
|
|
898
|
+
at_statement_start = false
|
|
803
899
|
end
|
|
804
|
-
|
|
805
|
-
i += 1
|
|
806
900
|
end
|
|
807
901
|
|
|
808
902
|
# If we never found the end, return last line
|
|
809
903
|
lines.length - 1
|
|
810
904
|
end
|
|
811
905
|
|
|
812
|
-
def self._is_modifier?(line)
|
|
813
|
-
# A rough check: if the keyword is not at the start of the meaningful content,
|
|
814
|
-
# it's likely a modifier (e.g., "return x if condition")
|
|
815
|
-
stripped = line.strip
|
|
816
|
-
!stripped.match?(/\A(if|unless|while|until)\b/)
|
|
817
|
-
end
|
|
818
|
-
|
|
819
906
|
def self._cyclomatic_complexity_from_source(source)
|
|
820
907
|
cc = 1
|
|
821
908
|
|