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.
data/lib/tina4/env.rb CHANGED
@@ -62,8 +62,13 @@ module Tina4
62
62
  end
63
63
  lines.concat([
64
64
  "",
65
- "Run `tina4 env --migrate` to rewrite your .env automatically,",
66
- "or rename manually. See https://tina4.com/release/3.12.0",
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
  ])
@@ -20,8 +20,11 @@ module Tina4
20
20
  @table_name = name
21
21
  else
22
22
  base = self.name.split("::").last.downcase
23
- # Pluralize by default (add "s") unless ORM_PLURAL_TABLE_NAMES is explicitly disabled
24
- unless ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
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
- "[TINA4_LOG_NONE]" => 4
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". Defaults to "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? ? output_env.downcase : "both"
71
- unless %w[stdout file both].include?(@output)
72
- @output = "both"
73
- end
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
- # TINA4_LOG_CRITICAL — when true, raise on log write failures instead of swallowing.
76
- @critical = truthy?(ENV["TINA4_LOG_CRITICAL"])
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 @critical
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
- # Check if the server is running on localhost.
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
- # Resolve whether the built-in MCP dev server should be active.
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
- # Precedence:
143
- # * TINA4_MCP set explicitly → use that (truthy/falsey).
144
- # * Otherwise: enabled only when TINA4_DEBUG=true.
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 %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
177
+ return truthy?(explicit)
149
178
  end
150
- %w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
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
- unless resolved.start_with?(project_root)
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
- result = db.fetch(sql, param_list)
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 chars.
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 > 3
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
- def self._extract_functions(source, tokens, lines)
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
- depth = 0
784
- i = start_index
785
- base_indent = lines[i].length - lines[i].lstrip.length
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
- while i < lines.length
788
- stripped = lines[i].strip
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
- unless stripped.empty? || stripped.start_with?('#')
791
- # Count block openers
792
- if stripped.match?(/\b(def|class|module|if|unless|case|while|until|for|begin|do)\b/) &&
793
- !stripped.match?(/\bend\b/) &&
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
- end
798
-
799
- if stripped == 'end' || stripped.start_with?('end ') || stripped.start_with?('end;')
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
- return i if depth <= 0
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