tina4ruby 3.13.86 → 3.13.87

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dae161f7ae0f39d834a01876bde153cd83b4df51ac355225c2c123ce20eec210
4
- data.tar.gz: 1e12103ca5685fc65dcfa7dedd16c38ddf5d4435ccd77ad4f73ad9a987596e2f
3
+ metadata.gz: 7743996e98fff70baf3c82a0f2a26dfafb24cb0c9ddc46cfc6a3be5a1df4c92f
4
+ data.tar.gz: 2b747045e71df0e69e7d6bdd3cb2b19309fcea16617770982b5743a4c6db9edc
5
5
  SHA512:
6
- metadata.gz: 5ea0ee3373aab820be175916dda33ca1e353f8cd9894ab1fa2613af2a85438195b5345a0af2b441ec444c51482e8bb64632f49c9af3e4f6acedbef8c3f66fe45
7
- data.tar.gz: cd556ef4a20f2b6acd245a2926af62e8592ca56a11fb3827ffcb9e8fede128f928b070ab1bfd7acb850a0b4371f8ec6367499d140488216eeb7a0f65939303ab
6
+ metadata.gz: f94e1f6feeda985b8c5091c0235181bb34165f4ee42120fa808b5b8a39cd8deaabc8ae9fe68a9c05d33598d184680d3b49c40f9791b1f0d2fb57689a87b73054
7
+ data.tar.gz: 6d31b432dddc04f237bddd28e6580d294c9427a22b0fda2947a9e2d9e3aba469b2fd06cdb2e0560d8edb60f1383c6916ba407a7efb257202d7cb95e381514083
@@ -213,16 +213,38 @@ module Tina4
213
213
  "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
214
214
  end
215
215
 
216
+ # Transaction handling — mirrors the Python master's connection-level
217
+ # contract (tina4_python firebird.py: start_transaction sets a flag,
218
+ # commit/rollback act on the connection).
219
+ #
220
+ # In the `fb` gem the transaction lives ON the connection:
221
+ # `Fb::Connection#transaction` (no block) STARTS a transaction and returns
222
+ # `true` (a boolean — NOT a transaction object), and
223
+ # `Fb::Connection#commit` / `#rollback` end it. The old code stored that
224
+ # boolean in `@transaction` and called `@transaction&.commit` /
225
+ # `&.rollback`, i.e. `true.commit` / `true.rollback` — a NoMethodError that
226
+ # broke every explicit-transaction commit AND rollback (a rolled-back write
227
+ # was never undone). We now start the transaction on the connection and
228
+ # commit/rollback the CONNECTION, tracking open-ness with an
229
+ # `@in_transaction` boolean (parity with Python's `_in_transaction`).
230
+ #
231
+ # A standalone write auto-commits inside the gem's own `execute`, so the
232
+ # framework's autocommit_standalone_write then calls #commit with no txn
233
+ # open — `Fb::Connection#commit` is a harmless no-op there (returns nil, no
234
+ # raise), so standalone-autocommit is preserved.
216
235
  def begin_transaction
217
- @transaction = @connection.transaction
236
+ @connection.transaction
237
+ @in_transaction = true
218
238
  end
219
239
 
220
240
  def commit
221
- @transaction&.commit
241
+ @connection&.commit
242
+ @in_transaction = false
222
243
  end
223
244
 
224
245
  def rollback
225
- @transaction&.rollback
246
+ @connection&.rollback
247
+ @in_transaction = false
226
248
  end
227
249
 
228
250
  def tables
@@ -263,7 +285,7 @@ module Tina4
263
285
  # connection already gone — nothing to clean up
264
286
  end
265
287
  @connection = nil
266
- @transaction = nil
288
+ @in_transaction = false
267
289
  open_connection
268
290
  end
269
291
 
@@ -273,7 +295,7 @@ module Tina4
273
295
  def with_reconnect
274
296
  yield
275
297
  rescue StandardError => e
276
- raise unless self.class.dead_connection?(e) && @transaction.nil?
298
+ raise unless self.class.dead_connection?(e) && !@in_transaction
277
299
  reconnect!
278
300
  yield
279
301
  end
data/lib/tina4/frond.rb CHANGED
@@ -106,7 +106,11 @@ module Tina4
106
106
  " and ", " or ", " not ",
107
107
  " + ", " - ", " * ", " // ", " / ", " % ", " ** "
108
108
  ].freeze
109
- FUNC_CALL_RE = /\A(\w+)\s*\((.*)\)\z/m
109
+ # A dot is allowed in the callee so {% import "f" as m %} can register its macros
110
+ # under the literal key "m.greet" and {{ m.greet("Andre") }} resolves as a call.
111
+ # Without the dot the whole expression was not recognised as a function call at
112
+ # all, so an aliased macro rendered as SILENTLY EMPTY.
113
+ FUNC_CALL_RE = /\A([\w.]+)\s*\((.*)\)\z/m
110
114
  FILTER_WITH_ARGS_RE = /\A(\w+)\s*\((.*)\)\z/m
111
115
  FILTER_CMP_RE = /\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/
112
116
  OR_SPLIT_RE = /\s+or\s+/
@@ -136,6 +140,7 @@ module Tina4
136
140
  .gsub("<", "&lt;").gsub(">", "&gt;")
137
141
  end
138
142
  FROM_IMPORT_RE = /\Afrom\s+["'](.+?)["']\s+import\s+(.+)/
143
+ IMPORT_AS_RE = /\Aimport\s+["'](.+?)["']\s+as\s+(\w+)/
139
144
  CACHE_RE = /\Acache\s+["'](.+?)["']\s*(\d+)?/
140
145
  SPACELESS_RE = />\s+</
141
146
  AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
@@ -147,6 +152,17 @@ module Tina4
147
152
  # Set of common no-arg filter names that can be inlined for speed
148
153
  INLINE_FILTERS = %w[upper lower length trim capitalize title string int escape e].each_with_object({}) { |f, h| h[f] = true }.freeze
149
154
 
155
+ # Hard cap on the template caches — @compiled and @compiled_strings
156
+ # (ADR-0004, parity with PHP/Python/Node TEMPLATE_CACHE_MAX).
157
+ #
158
+ # An entry here is a whole token list, so the cap sits well below what a
159
+ # per-expression memo would justify. 256 is far above any real
160
+ # application's template count, so a normal app never evicts. The cap
161
+ # exists for the workload that genuinely grows without limit for the life
162
+ # of a worker: +render_string+ keys on md5(source), so an app that builds
163
+ # template strings dynamically adds an entry per distinct string.
164
+ TEMPLATE_CACHE_MAX = 256
165
+
150
166
  # -- Lazy context overlay for for-loops (avoids full Hash#dup) --
151
167
  class LoopContext
152
168
  def initialize(parent)
@@ -302,6 +318,7 @@ module Tina4
302
318
  source = File.read(path, encoding: "utf-8")
303
319
  mtime = File.mtime(path)
304
320
  tokens = tokenize(source)
321
+ cap_cache(@compiled, TEMPLATE_CACHE_MAX)
305
322
  @compiled[template] = [tokens, mtime, Time.now.to_i]
306
323
  execute_with_tokens(source, tokens, context)
307
324
  end
@@ -318,6 +335,7 @@ module Tina4
318
335
  end
319
336
 
320
337
  tokens = tokenize(source)
338
+ cap_cache(@compiled_strings, TEMPLATE_CACHE_MAX)
321
339
  @compiled_strings[key] = tokens
322
340
  execute_cached(tokens, context)
323
341
  end
@@ -413,6 +431,27 @@ module Tina4
413
431
 
414
432
  private
415
433
 
434
+ # Keep a memo cache bounded (ADR-0004). Call immediately before inserting
435
+ # a new entry.
436
+ #
437
+ # Eviction is insertion-ordered (oldest first), not true LRU: a Ruby Hash
438
+ # preserves insertion order, so dropping from the front is cheap, whereas
439
+ # refreshing recency on every cache HIT would add writes to the hottest
440
+ # path in a render and cost more than it saves. Half the cache is dropped
441
+ # at once so the sweep amortises to O(1) per insert.
442
+ #
443
+ # Evicting can never change what a render produces: every read site treats
444
+ # a miss as "recompute", so a swept entry is rebuilt on next use.
445
+ #
446
+ # @param cache [Hash] memo cache to bound, mutated in place
447
+ # @param max_entries [Integer] cap for this cache
448
+ # @return [void]
449
+ def cap_cache(cache, max_entries)
450
+ return if cache.size < max_entries
451
+
452
+ cache.keys.first(max_entries / 2).each { |key| cache.delete(key) }
453
+ end
454
+
416
455
  # -----------------------------------------------------------------------
417
456
  # Tokenizer
418
457
  # -----------------------------------------------------------------------
@@ -625,6 +664,9 @@ module Tina4
625
664
  end
626
665
  when "macro"
627
666
  i = handle_macro(tokens, i, context)
667
+ when "import"
668
+ handle_import_as(content, context)
669
+ i += 1
628
670
  when "from"
629
671
  handle_from_import(content, context)
630
672
  i += 1
@@ -1082,44 +1124,111 @@ module Tina4
1082
1124
  # Helpers return :not_matched when the expression doesn't match their
1083
1125
  # type, so the dispatcher falls through to the next handler.
1084
1126
 
1127
+ # Bound for the expression-form cache. Python bounds its expression caches
1128
+ # (lru_cache 1024) and PHP's were unbounded instance arrays until ADR-0004;
1129
+ # a template with generated expression strings must not grow this forever.
1130
+ # FIFO drop-oldest-half, deliberately not LRU (no per-hit bookkeeping on the
1131
+ # hot path).
1132
+ EXPR_FORM_CACHE_MAX = 2048
1133
+
1134
+ # ── Expression evaluation: a cascade, with the branch memoised ──
1135
+ #
1136
+ # eval_expr tries 12 structural forms in Twig precedence order. The catch is
1137
+ # that the LAST one, `resolve`, is by far the most common (a plain `i.name`),
1138
+ # so every ordinary variable used to walk all 11 detectors ahead of it on
1139
+ # EVERY render. Measured: ~6-7 microseconds and ~38 Frond method calls per
1140
+ # expression evaluation, linear in expression count.
1141
+ #
1142
+ # So: on first sight run the real cascade and RECORD which branch fired; on
1143
+ # later renders jump straight to that branch. The cache key is the expression
1144
+ # string, and a form is a pure property of that string.
1145
+ #
1146
+ # Correctness is owned by the existing evaluators, not by a second copy of the
1147
+ # detection logic (which is what would drift). The fast path calls exactly one
1148
+ # evaluator; if it declines -- returns its own :not_* sentinel -- we fall
1149
+ # through to the full cascade and re-record. A wrong cache entry therefore
1150
+ # costs one wasted call, never a wrong render.
1085
1151
  def eval_expr(expr, context)
1086
1152
  expr = expr.strip
1087
1153
  return nil if expr.empty?
1088
1154
 
1155
+ form = (@expr_form ||= {})[expr]
1156
+ if form
1157
+ result = eval_expr_as(form, expr, context)
1158
+ return result unless result == :form_declined
1159
+ end
1160
+
1089
1161
  result = eval_literal(expr)
1090
- return result unless result == :not_literal
1162
+ return remember_form(expr, :literal, result) unless result == :not_literal
1091
1163
 
1092
1164
  result = eval_collection_literal(expr, context)
1093
- return result unless result == :not_collection
1165
+ return remember_form(expr, :collection, result) unless result == :not_collection
1094
1166
 
1095
- return eval_expr(expr[1..-2], context) if matched_parens?(expr)
1167
+ if matched_parens?(expr)
1168
+ remember_form(expr, :parens, nil)
1169
+ return eval_expr(expr[1..-2], context)
1170
+ end
1096
1171
 
1097
1172
  result = eval_ternary(expr, context)
1098
- return result unless result == :not_ternary
1173
+ return remember_form(expr, :ternary, result) unless result == :not_ternary
1099
1174
 
1100
1175
  result = eval_inline_if(expr, context)
1101
- return result unless result == :not_inline_if
1176
+ return remember_form(expr, :inline_if, result) unless result == :not_inline_if
1102
1177
 
1103
1178
  result = eval_null_coalesce(expr, context)
1104
- return result unless result == :not_coalesce
1179
+ return remember_form(expr, :coalesce, result) unless result == :not_coalesce
1105
1180
 
1106
1181
  result = eval_concat(expr, context)
1107
- return result unless result == :not_concat
1182
+ return remember_form(expr, :concat, result) unless result == :not_concat
1108
1183
 
1109
- return eval_comparison(expr, context) if has_comparison?(expr)
1184
+ if has_comparison?(expr)
1185
+ remember_form(expr, :comparison, nil)
1186
+ return eval_comparison(expr, context)
1187
+ end
1110
1188
 
1111
1189
  result = eval_arithmetic(expr, context)
1112
- return result unless result == :not_arithmetic
1190
+ return remember_form(expr, :arithmetic, result) unless result == :not_arithmetic
1113
1191
 
1114
1192
  result = eval_filter_pipe(expr, context)
1115
- return result unless result == :not_filter_pipe
1193
+ return remember_form(expr, :pipe, result) unless result == :not_filter_pipe
1116
1194
 
1117
1195
  result = eval_function_call(expr, context)
1118
- return result unless result == :not_function
1196
+ return remember_form(expr, :function, result) unless result == :not_function
1119
1197
 
1198
+ remember_form(expr, :resolve, nil)
1120
1199
  resolve(expr, context)
1121
1200
  end
1122
1201
 
1202
+ # Record the branch an expression took, then hand back its value unchanged.
1203
+ def remember_form(expr, form, result)
1204
+ cache = (@expr_form ||= {})
1205
+ if cache.length >= EXPR_FORM_CACHE_MAX
1206
+ cache.keys.first(EXPR_FORM_CACHE_MAX / 2).each { |k| cache.delete(k) }
1207
+ end
1208
+ cache[expr] = form
1209
+ result
1210
+ end
1211
+
1212
+ # Evaluate via the one remembered branch. Returns :form_declined when that
1213
+ # branch no longer applies, so the caller re-runs the full cascade.
1214
+ def eval_expr_as(form, expr, context)
1215
+ case form
1216
+ when :resolve then resolve(expr, context)
1217
+ when :pipe then (r = eval_filter_pipe(expr, context)) == :not_filter_pipe ? :form_declined : r
1218
+ when :literal then (r = eval_literal(expr)) == :not_literal ? :form_declined : r
1219
+ when :function then (r = eval_function_call(expr, context)) == :not_function ? :form_declined : r
1220
+ when :concat then (r = eval_concat(expr, context)) == :not_concat ? :form_declined : r
1221
+ when :comparison then has_comparison?(expr) ? eval_comparison(expr, context) : :form_declined
1222
+ when :arithmetic then (r = eval_arithmetic(expr, context)) == :not_arithmetic ? :form_declined : r
1223
+ when :ternary then (r = eval_ternary(expr, context)) == :not_ternary ? :form_declined : r
1224
+ when :inline_if then (r = eval_inline_if(expr, context)) == :not_inline_if ? :form_declined : r
1225
+ when :coalesce then (r = eval_null_coalesce(expr, context)) == :not_coalesce ? :form_declined : r
1226
+ when :collection then (r = eval_collection_literal(expr, context)) == :not_collection ? :form_declined : r
1227
+ when :parens then matched_parens?(expr) ? eval_expr(expr[1..-2], context) : :form_declined
1228
+ else :form_declined
1229
+ end
1230
+ end
1231
+
1123
1232
  # ── Filter pipe: value|filter(args) ──
1124
1233
  # Reached only after every looser-binding operator (concat ~, comparison,
1125
1234
  # arithmetic, ternary, ...) has been ruled out at this level, so the pipe
@@ -1139,6 +1248,10 @@ module Tina4
1139
1248
  # looser than the filter pipe. Quote/paren-aware via find_outside_quotes so
1140
1249
  # operator-like text inside a string literal or filter args never matches.
1141
1250
  def has_looser_than_pipe_operator?(expr)
1251
+ # Leading unary `not` -- see has_comparison? for why a space-delimited
1252
+ # match cannot see it. `not x|upper` must be `not (x|upper)`.
1253
+ return true if expr.start_with?("not ")
1254
+
1142
1255
  LOOSER_THAN_PIPE_OPS.any? { |op| find_outside_quotes(expr, op) >= 0 }
1143
1256
  end
1144
1257
 
@@ -1254,7 +1367,12 @@ module Tina4
1254
1367
  def eval_concat(expr, context)
1255
1368
  return :not_concat unless expr.include?("~")
1256
1369
  parts = expr.split("~")
1257
- parts.map { |p| (eval_expr(p.strip, context) || "").to_s }.join
1370
+ # `|| ""` would swallow a legitimate `false`: in Ruby only nil and false are
1371
+ # falsy, so `false || ""` is "" and a boolean rendered as EMPTY. Guard on nil
1372
+ # only -- false must render as "false", matching the other three frameworks
1373
+ # (and line 635, which already got this right, which is why a comparison
1374
+ # rendered "false" while a bare false variable rendered "").
1375
+ parts.map { |p| v = eval_expr(p.strip, context); v.nil? ? "" : v.to_s }.join
1258
1376
  end
1259
1377
 
1260
1378
  # ── Arithmetic: +, -, *, //, /, %, ** ──
@@ -1282,7 +1400,21 @@ module Tina4
1282
1400
  fn.call(*args)
1283
1401
  end
1284
1402
 
1403
+ # True when the expression carries a comparison/logical operator, so it is
1404
+ # routed to eval_comparison -- the SAME evaluator {% if %} uses. A condition
1405
+ # therefore means the same thing in a condition and in an output expression.
1406
+ #
1407
+ # The LEADING unary `not` needs its own check: every operator in the list is
1408
+ # matched WITH surrounding spaces, so `not x` (nothing to its left) matched
1409
+ # none of them and fell through to the variable-resolution tail, which
1410
+ # looked up a variable literally named "not x", found nothing, and rendered
1411
+ # EMPTY. `{% if not x %}` and `x and not y` always worked; only the
1412
+ # standalone `{{ not x }}` was dropped, and before booleans rendered
1413
+ # lowercase a dropped expression and `false -> ''` looked identical.
1414
+ # Fixed in 3.13.87 alongside the boolean contract.
1285
1415
  def has_comparison?(expr)
1416
+ return true if expr.start_with?("not ")
1417
+
1286
1418
  [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<",
1287
1419
  " and ", " or ", " not "].any? { |op| expr.include?(op) }
1288
1420
  end
@@ -1448,7 +1580,19 @@ module Tina4
1448
1580
  parts.each do |part|
1449
1581
  part = part.strip.gsub(RESOLVE_STRIP_RE, "") # strip quotes from bracket access
1450
1582
  if value.is_a?(Hash) || value.is_a?(LoopContext)
1451
- value = value[part] || value[part.to_sym]
1583
+ # `a[k] || a[k.to_sym]` LOSES a stored `false`: only nil and false are
1584
+ # falsy in Ruby, so a legitimate false fell through to the symbol lookup,
1585
+ # missed, and became nil -- rendering as EMPTY. That is why
1586
+ # `{{ flag }}` printed nothing while `{{ n < 3 }}` printed "false".
1587
+ # Probe the string key by presence, not truthiness.
1588
+ value = if value.is_a?(Hash) && value.key?(part)
1589
+ value[part]
1590
+ elsif value.is_a?(Hash) && value.key?(part.to_sym)
1591
+ value[part.to_sym]
1592
+ else
1593
+ sv = value[part]
1594
+ sv.nil? ? value[part.to_sym] : sv
1595
+ end
1452
1596
  elsif value.is_a?(Array)
1453
1597
  # Slice syntax: value[1:5], value[:10], value[start:end]
1454
1598
  if part.include?(":") && !(part.start_with?('"') || part.start_with?("'"))
@@ -1750,7 +1894,7 @@ module Tina4
1750
1894
  end
1751
1895
 
1752
1896
  macro_name = m[1]
1753
- param_names = m[2].split(",").map(&:strip).reject(&:empty?)
1897
+ params = parse_macro_params(m[2])
1754
1898
 
1755
1899
  body_tokens = []
1756
1900
  i = start + 1
@@ -1769,8 +1913,8 @@ module Tina4
1769
1913
 
1770
1914
  context[macro_name] = lambda { |*args|
1771
1915
  macro_ctx = captured_context.dup
1772
- param_names.each_with_index do |pname, pi|
1773
- macro_ctx[pname] = pi < args.length ? args[pi] : nil
1916
+ params.each_with_index do |(pname, pdefault), pi|
1917
+ macro_ctx[pname] = pi < args.length ? args[pi] : pdefault
1774
1918
  end
1775
1919
  Tina4::SafeString.new(engine.send(:render_tokens, captured_body.dup, macro_ctx))
1776
1920
  }
@@ -1778,6 +1922,54 @@ module Tina4
1778
1922
  i
1779
1923
  end
1780
1924
 
1925
+ # {% import "file" as alias %} — load EVERY macro in a file under one namespace.
1926
+ #
1927
+ # Macros are registered in the context under the dotted key "alias.name", so
1928
+ # {{ alias.greet("Andre") }} resolves through the ordinary function-call path
1929
+ # (FUNC_CALL_RE now admits a dot) and reuses _make_macro_fn — identical argument
1930
+ # binding, default handling and SafeString output as every other macro. Both
1931
+ # import forms therefore render identically, the contract Python locks too.
1932
+ def handle_import_as(content, context)
1933
+ m = content.match(IMPORT_AS_RE)
1934
+ return unless m
1935
+
1936
+ filename = m[1]
1937
+ alias_name = m[2]
1938
+ source = load_template(filename)
1939
+ tokens = tokenize(source)
1940
+
1941
+ i = 0
1942
+ while i < tokens.length
1943
+ ttype, raw = tokens[i]
1944
+ if ttype == BLOCK
1945
+ tag_content, = strip_tag(raw)
1946
+ if tag_content.split(/\s+/).first == "macro"
1947
+ macro_m = tag_content.match(MACRO_RE)
1948
+ if macro_m
1949
+ macro_name = macro_m[1]
1950
+ params = parse_macro_params(macro_m[2])
1951
+
1952
+ body_tokens = []
1953
+ i += 1
1954
+ while i < tokens.length
1955
+ if tokens[i][0] == BLOCK && tokens[i][1].include?("endmacro")
1956
+ i += 1
1957
+ break
1958
+ end
1959
+ body_tokens << tokens[i]
1960
+ i += 1
1961
+ end
1962
+
1963
+ context["#{alias_name}.#{macro_name}"] =
1964
+ _make_macro_fn(body_tokens.dup, params.dup, context.dup)
1965
+ next
1966
+ end
1967
+ end
1968
+ end
1969
+ i += 1
1970
+ end
1971
+ end
1972
+
1781
1973
  # {% from "file" import macro1, macro2 %}
1782
1974
  def handle_from_import(content, context)
1783
1975
  m = content.match(FROM_IMPORT_RE)
@@ -1799,7 +1991,7 @@ module Tina4
1799
1991
  macro_m = tag_content.match(MACRO_RE)
1800
1992
  if macro_m && names.include?(macro_m[1])
1801
1993
  macro_name = macro_m[1]
1802
- param_names = macro_m[2].split(",").map(&:strip).reject(&:empty?)
1994
+ param_names = parse_macro_params(macro_m[2])
1803
1995
 
1804
1996
  body_tokens = []
1805
1997
  i += 1
@@ -1821,13 +2013,34 @@ module Tina4
1821
2013
  end
1822
2014
  end
1823
2015
 
2016
+ # Parse a macro parameter list into [name, default] pairs.
2017
+ #
2018
+ # Handles: name, name="default", name='default'. Splitting on "," alone left
2019
+ # a defaulted parameter NAMED "greeting='Hello'", so the body's {{ greeting }}
2020
+ # matched nothing (rendered empty) AND the caller's positional argument was
2021
+ # stored under that junk key and lost. Mirrors the Python master's
2022
+ # _parse_macro_params. `default` is nil when none is declared.
2023
+ def parse_macro_params(raw_params)
2024
+ raw_params.split(",").map(&:strip).reject(&:empty?).map do |p|
2025
+ name, default = p.split("=", 2)
2026
+ default = default.strip if default
2027
+ if default && default.length >= 2 &&
2028
+ ((default.start_with?('"') && default.end_with?('"')) ||
2029
+ (default.start_with?("'") && default.end_with?("'")))
2030
+ default = default[1..-2]
2031
+ end
2032
+ [name.strip, default]
2033
+ end
2034
+ end
2035
+
1824
2036
  # Build an isolated lambda for a macro — avoids closure-in-loop variable sharing.
1825
- def _make_macro_fn(body_tokens, param_names, ctx)
2037
+ # `params` is the [name, default] list from parse_macro_params.
2038
+ def _make_macro_fn(body_tokens, params, ctx)
1826
2039
  engine = self
1827
2040
  lambda { |*args|
1828
2041
  macro_ctx = ctx.dup
1829
- param_names.each_with_index do |pname, pi|
1830
- macro_ctx[pname] = pi < args.length ? args[pi] : nil
2042
+ params.each_with_index do |(pname, pdefault), pi|
2043
+ macro_ctx[pname] = pi < args.length ? args[pi] : pdefault
1831
2044
  end
1832
2045
  Tina4::SafeString.new(engine.send(:render_tokens, body_tokens.dup, macro_ctx))
1833
2046
  }
data/lib/tina4/metrics.rb CHANGED
@@ -338,7 +338,14 @@ module Tina4
338
338
  "total_functions" => all_functions.length,
339
339
  "avg_complexity" => avg_cc.round(2),
340
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.
341
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,
342
349
  "file_metrics" => file_metrics,
343
350
  "violations" => violations,
344
351
  "dependency_graph" => import_graph,
@@ -386,8 +393,10 @@ module Tina4
386
393
 
387
394
  items = []
388
395
 
389
- # Function-level: cyclomatic complexity.
390
- (analysis["most_complex_functions"] || []).each do |fn|
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|
391
400
  cc = fn["complexity"]
392
401
  next unless cc > 10
393
402
  items << {
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.86"
4
+ VERSION = "3.13.87"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.86
4
+ version: 3.13.87
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-25 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack