tina4ruby 3.13.97 → 3.13.99

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +24 -2
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -19
  51. data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
  52. data/lib/tina4/scss/tina4css/_badges.scss +0 -22
  53. data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
  54. data/lib/tina4/scss/tina4css/_cards.scss +0 -49
  55. data/lib/tina4/scss/tina4css/_forms.scss +0 -156
  56. data/lib/tina4/scss/tina4css/_grid.scss +0 -81
  57. data/lib/tina4/scss/tina4css/_modals.scss +0 -84
  58. data/lib/tina4/scss/tina4css/_nav.scss +0 -149
  59. data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
  60. data/lib/tina4/scss/tina4css/_reset.scss +0 -94
  61. data/lib/tina4/scss/tina4css/_tables.scss +0 -54
  62. data/lib/tina4/scss/tina4css/_typography.scss +0 -55
  63. data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
  64. data/lib/tina4/scss/tina4css/_variables.scss +0 -117
  65. data/lib/tina4/scss/tina4css/base.scss +0 -1
  66. data/lib/tina4/scss/tina4css/colors.scss +0 -48
  67. data/lib/tina4/scss/tina4css/tina4.scss +0 -18
@@ -17,84 +17,131 @@ module Tina4
17
17
  #
18
18
  class SQLTranslator
19
19
  class << self
20
- # Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
20
+ # ── Literal-safe rewriting ────────────────────────────────────
21
21
  #
22
- # LIMIT 10 OFFSET 5 => ROWS 6 TO 15
23
- # LIMIT 10 => ROWS 1 TO 10
22
+ # A dialect rewrite (|| -> CONCAT, TRUE -> 1, ILIKE -> LOWER LIKE) must NEVER
23
+ # touch text inside a string literal, a quoted identifier or a comment: a
24
+ # column value of 'a||b', a label 'TRUE', or a LIKE pattern that mentions
25
+ # ILIKE is DATA, not SQL. Each transform masks every literal/identifier/
26
+ # comment to an opaque token, rewrites the masked SQL, then restores the
27
+ # tokens, so the rewrite only ever sees real SQL structure.
24
28
  #
25
- # @param sql [String]
26
- # @return [String]
27
- def limit_to_rows(sql)
28
- # Try LIMIT X OFFSET Y first
29
- if (m = sql.match(/\bLIMIT\s+(\d+)\s+OFFSET\s+(\d+)\s*$/i))
30
- limit = m[1].to_i
31
- offset = m[2].to_i
32
- start_row = offset + 1
33
- end_row = offset + limit
34
- return sql[0...m.begin(0)] + "ROWS #{start_row} TO #{end_row}"
35
- end
29
+ # NOTE on the removed methods (SQLTRANS-DEC-02): +limit_to_rows+,
30
+ # +limit_to_top+ and +placeholder_style+ were deleted here. Ruby's DRIVERS
31
+ # own pagination and placeholders by design and do it correctly per engine
32
+ # (Firebird +SELECT FIRST/SKIP+, MSSQL +OFFSET ... FETCH+, Postgres +$1+),
33
+ # whereas these translator helpers emitted a DIFFERENT and inferior shape
34
+ # (MSSQL +TOP+, Firebird +ROWS x TO y+) that nothing called. Keeping a dead
35
+ # public method that also disagrees with the live driver is a footgun, so
36
+ # they are gone. concat/bool/ilike stay (they fill a real gap - the drivers
37
+ # do not translate them) and are now WIRED into the MySQL driver.
36
38
 
37
- # Then try LIMIT X only
38
- if (m = sql.match(/\bLIMIT\s+(\d+)\s*$/i))
39
- limit = m[1].to_i
40
- return sql[0...m.begin(0)] + "ROWS 1 TO #{limit}"
39
+ # Replace string literals, quoted identifiers and comments with opaque
40
+ # "\x00N\x00" tokens. Returns [masked_sql, literals]; doubled-quote escapes
41
+ # ('' "" ``) are handled so an embedded quote never ends the span early.
42
+ def mask_literals(sql)
43
+ literals = []
44
+ out = +""
45
+ i = 0
46
+ n = sql.length
47
+ while i < n
48
+ ch = sql[i]
49
+ nxt = sql[i + 1]
50
+ if ch == "'" || ch == '"' || ch == "`"
51
+ start = i
52
+ i += 1
53
+ while i < n
54
+ if sql[i] == ch
55
+ if sql[i + 1] == ch
56
+ i += 2
57
+ next
58
+ end
59
+ i += 1
60
+ break
61
+ end
62
+ i += 1
63
+ end
64
+ out << "\x00#{literals.length}\x00"
65
+ literals << sql[start...i]
66
+ next
67
+ end
68
+ if ch == "-" && nxt == "-"
69
+ start = i
70
+ i += 1 while i < n && sql[i] != "\n"
71
+ out << "\x00#{literals.length}\x00"
72
+ literals << sql[start...i]
73
+ next
74
+ end
75
+ if ch == "/" && nxt == "*"
76
+ start = i
77
+ i += 2
78
+ i += 1 while i < n && !(sql[i] == "*" && sql[i + 1] == "/")
79
+ i = [i + 2, n].min
80
+ out << "\x00#{literals.length}\x00"
81
+ literals << sql[start...i]
82
+ next
83
+ end
84
+ out << ch
85
+ i += 1
41
86
  end
42
-
43
- sql
87
+ [out, literals]
44
88
  end
45
89
 
46
- # Convert LIMIT to MSSQL TOP syntax.
47
- #
48
- # SELECT ... LIMIT 10 => SELECT TOP 10 ...
49
- # OFFSET queries are left unchanged (not supported by TOP).
50
- #
51
- # @param sql [String]
52
- # @return [String]
53
- def limit_to_top(sql)
54
- if (m = sql.match(/\bLIMIT\s+(\d+)\s*$/i)) && !sql.match?(/\bOFFSET\b/i)
55
- limit = m[1].to_i
56
- body = sql[0...m.begin(0)].strip
57
- return body.sub(/^(SELECT)\b/i, "\\1 TOP #{limit}")
58
- end
59
-
60
- sql
90
+ # Inverse of #mask_literals.
91
+ def restore_literals(masked, literals)
92
+ masked.gsub(/\x00(\d+)\x00/) { literals[::Regexp.last_match(1).to_i] }
61
93
  end
62
94
 
63
- # Convert || concatenation to CONCAT() for MySQL/MSSQL.
95
+ # Convert || string concatenation to CONCAT() for MySQL/MSSQL. Rewrites ONLY
96
+ # || operators joining expression operands OUTSIDE any string literal or
97
+ # comment, and only the operand chain - never the whole statement.
64
98
  #
65
- # 'a' || 'b' || 'c' => CONCAT('a', 'b', 'c')
99
+ # SELECT a || b FROM t => SELECT CONCAT(a, b) FROM t
100
+ # WHERE data = 'a||b' => WHERE data = 'a||b' (literal untouched)
66
101
  #
67
102
  # @param sql [String]
68
103
  # @return [String]
69
104
  def concat_pipes_to_func(sql)
70
105
  return sql unless sql.include?("||")
71
106
 
72
- parts = sql.split("||")
73
- if parts.length > 1
74
- "CONCAT(#{parts.map(&:strip).join(', ')})"
75
- else
76
- sql
107
+ masked, literals = mask_literals(sql)
108
+ return sql unless masked.include?("||")
109
+
110
+ chain = /#{SQLTranslator::PRIMARY}(?:\s*\|\|\s*#{SQLTranslator::PRIMARY})+/
111
+ rewritten = masked.gsub(chain) do |m|
112
+ "CONCAT(#{m.split(/\s*\|\|\s*/).join(', ')})"
77
113
  end
114
+ restore_literals(rewritten, literals)
78
115
  end
79
116
 
80
- # Convert TRUE/FALSE to 1/0 for engines without boolean type.
117
+ # Convert a bare TRUE/FALSE to 1/0 for engines without a boolean type. A
118
+ # TRUE/FALSE INSIDE a string literal is data and is left untouched.
81
119
  #
82
120
  # @param sql [String]
83
121
  # @return [String]
84
122
  def boolean_to_int(sql)
85
- sql.gsub(/\bTRUE\b/i, "1").gsub(/\bFALSE\b/i, "0")
123
+ return sql unless sql.match?(/\b(?:TRUE|FALSE)\b/i)
124
+
125
+ masked, literals = mask_literals(sql)
126
+ masked = masked.gsub(/\bTRUE\b/i, "1").gsub(/\bFALSE\b/i, "0")
127
+ restore_literals(masked, literals)
86
128
  end
87
129
 
88
- # Convert ILIKE to LOWER() LIKE LOWER() for engines without ILIKE.
130
+ # Convert +col ILIKE pattern+ to +LOWER(col) LIKE LOWER(pattern)+ for engines
131
+ # without ILIKE. The pattern operand is captured whole (a multi-word
132
+ # '%two words%' survives), and an ILIKE INSIDE a string literal is untouched.
89
133
  #
90
134
  # @param sql [String]
91
135
  # @return [String]
92
136
  def ilike_to_like(sql)
93
- sql.gsub(/(\S+)\s+ILIKE\s+(\S+)/i) do
94
- col = ::Regexp.last_match(1).strip
95
- val = ::Regexp.last_match(2).strip
96
- "LOWER(#{col}) LIKE LOWER(#{val})"
137
+ return sql unless sql =~ /ilike/i
138
+
139
+ masked, literals = mask_literals(sql)
140
+ pattern = /(#{SQLTranslator::PRIMARY})\s+ILIKE\s+(#{SQLTranslator::PRIMARY})/i
141
+ rewritten = masked.gsub(pattern) do
142
+ "LOWER(#{::Regexp.last_match(1)}) LIKE LOWER(#{::Regexp.last_match(2)})"
97
143
  end
144
+ restore_literals(rewritten, literals)
98
145
  end
99
146
 
100
147
  # Translate AUTOINCREMENT across engines in DDL.
@@ -107,7 +154,13 @@ module Tina4
107
154
  when "mysql"
108
155
  sql.gsub("AUTOINCREMENT", "AUTO_INCREMENT")
109
156
  when "postgresql"
110
- sql.gsub(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/i, "SERIAL PRIMARY KEY")
157
+ # BIGINT PRIMARY KEY AUTOINCREMENT -> BIGSERIAL (a real 64-bit sequence);
158
+ # INTEGER -> SERIAL. A plain BIGINT with the keyword merely stripped has
159
+ # no sequence and cannot auto-increment.
160
+ sql
161
+ .gsub(/\bBIGINT\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "BIGSERIAL PRIMARY KEY")
162
+ .gsub(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "SERIAL PRIMARY KEY")
163
+ .gsub(/\s*\bAUTOINCREMENT\b/i, "")
111
164
  when "mssql"
112
165
  sql.gsub(/AUTOINCREMENT/i, "IDENTITY(1,1)")
113
166
  when "firebird"
@@ -117,42 +170,13 @@ module Tina4
117
170
  end
118
171
  end
119
172
 
120
- # Convert ? placeholders to engine-specific style.
121
- #
122
- # ? => %s (MySQL, PostgreSQL)
123
- # ? => :1, :2 (Oracle, Firebird)
124
- #
125
- # @param sql [String]
126
- # @param style [String] target placeholder style: "%s" or ":"
127
- # @return [String]
128
- def placeholder_style(sql, style)
129
- case style
130
- when "%s"
131
- sql.gsub("?", "%s")
132
- when ":"
133
- count = 0
134
- sql.chars.map do |ch|
135
- if ch == "?"
136
- count += 1
137
- ":#{count}"
138
- else
139
- ch
140
- end
141
- end.join
142
- else
143
- sql
144
- end
145
- end
146
-
147
- # Generate a cache key for a query and its parameters.
148
- #
149
- # @param sql [String]
150
- # @param params [Array, nil]
151
- # @return [String]
152
- def query_key(sql, params = nil)
153
- raw = params ? "#{sql}|#{params.inspect}" : sql
154
- "query:#{Digest::SHA256.hexdigest(raw)}"
155
- end
173
+ # NOTE (SQLTRANS-DEC-02): +placeholder_style+ was removed - every Ruby
174
+ # driver owns its own placeholder shape (+?+ for MySQL/SQLite/MSSQL/Firebird,
175
+ # +$1+ for Postgres), so this helper had zero callers and disagreed with the
176
+ # live drivers. The cache KEY helper +query_key+ was also removed here: it
177
+ # duplicated the live +.query_key+ class method the ORM cache path (defined
178
+ # in lib/tina4/cache.rb, per spec/docs_truth_spec.rb's D12 lock-in that this
179
+ # file stays free of that class's name) actually uses. One source, not two.
156
180
 
157
181
  # Collapse a row-at-a-time INSERT batch into chunked multi-row VALUES.
158
182
  #
@@ -233,6 +257,11 @@ module Tina4
233
257
  end
234
258
  end
235
259
 
260
+ # A concat/ilike operand: a masked literal-or-identifier token, a simple
261
+ # function call, a (qualified) identifier, a placeholder, or a number. The
262
+ # function-call args exclude +|+ so a nested +||+ never splits the chain.
263
+ PRIMARY = '(?:\x00\d+\x00|[A-Za-z_][\w$]*\s*\([^()|]*\)|[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)*|:[A-Za-z_]\w*|\$\d+|\?|%s|\d+(?:\.\d+)?)'
264
+
236
265
  # Hard per-statement bind-parameter ceiling per engine. 0 = never collapse.
237
266
  # Sourced from spec/fixtures/batch_write_contract.json, byte-identical in
238
267
  # all four frameworks.
data/lib/tina4/swagger.rb CHANGED
@@ -173,10 +173,22 @@ module Tina4
173
173
  scheme && !scheme.empty? ? scheme : "bearerAuth"
174
174
  end
175
175
 
176
- # Path filtering. Framework internals (/swagger, /__dev) are ALWAYS
176
+ # Framework-internal route prefixes that are NEVER part of an
177
+ # application's public API document. SHARED across all four frameworks
178
+ # (SWAG-EXCLUSION-NOT-SHARED, ADR-0004) so the exclusion is one rule
179
+ # everywhere, not three mechanisms: the dev tools (/swagger, /__dev),
180
+ # the feedback widget (/__feedback), and the built-in AI/RAG service
181
+ # probes (/ai, /rag, /vision, /embed, /image). Ruby dispatches these
182
+ # OUTSIDE Tina4::Router today, so most never reach included? in
183
+ # practice — the list is here so the RULE holds regardless of how a
184
+ # given route got registered, not because of where each internal
185
+ # happens to be wired.
186
+ INTERNAL_PREFIXES = ["/swagger", "/__dev", "/__feedback", "/ai", "/rag", "/vision", "/embed", "/image"].freeze
187
+
188
+ # Path filtering. Framework internals (INTERNAL_PREFIXES) are ALWAYS
177
189
  # excluded; then TINA4_SWAGGER_INCLUDE (allow-list) / _EXCLUDE apply.
178
190
  def included?(raw_path)
179
- ["/swagger", "/__dev"].each do |internal|
191
+ INTERNAL_PREFIXES.each do |internal|
180
192
  return false if raw_path == internal || raw_path.start_with?("#{internal}/")
181
193
  end
182
194
 
@@ -240,11 +252,15 @@ module Tina4
240
252
  operation = {
241
253
  "operationId" => unique_operation_id(method, path, ctx[:seen_ids]),
242
254
  "summary" => meta[:summary] || "#{method.upcase} #{route.path}",
243
- "description" => meta[:description] || "",
244
255
  "tags" => tags,
245
256
  "parameters" => build_parameters(route),
246
257
  "responses" => build_responses(meta, ref, ctx)
247
258
  }
259
+ # description is OMITTED when unset (SWAG-SHAPE-DRIFT, ADR-0004) —
260
+ # python/php/node never fabricate a description key either; Ruby used
261
+ # to always stamp "" on every undecorated operation, the one shape
262
+ # drift where Ruby (not Python) was the odd one out.
263
+ operation["description"] = meta[:description] if meta[:description]
248
264
 
249
265
  operation["deprecated"] = true if meta[:deprecated]
250
266
  operation["security"] = security unless security.nil?
@@ -5,6 +5,50 @@ module Tina4
5
5
  TEMPLATE_DIRS = %w[templates src/templates src/views views].freeze
6
6
 
7
7
  class << self
8
+ # Content negotiation for an error response (feature 42, ERR-DEC-02): does
9
+ # an Accept header prefer application/json over text/html?
10
+ #
11
+ # `Accept: application/json` (an API client) prefers JSON; a browser
12
+ # Accept (`text/html`, `*/*`, or no header at all) prefers HTML. A mixed
13
+ # Accept header - a real browser's
14
+ # `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8` - is
15
+ # resolved by q-value: whichever of the two media types this method cares
16
+ # about is weighted higher wins; a tie or neither present defaults to
17
+ # HTML, the historical/back-compatible behaviour for an unspecified
18
+ # client. This is the ONE shared decision reused by the 403/404/500 error
19
+ # paths (#render_error's callers in rack_app.rb and middleware.rb), so a
20
+ # JSON API client sees the SAME negotiated shape everywhere
21
+ # (ERR-403-SPLIT) - ported with the same algorithm to Python/PHP/Node.
22
+ def wants_json?(accept)
23
+ accept = accept.to_s
24
+ return false if accept.empty?
25
+
26
+ best_json = -1.0
27
+ best_html = -1.0
28
+ accept.split(",").each do |part|
29
+ segments = part.strip.split(";")
30
+ media = segments[0].to_s.strip.downcase
31
+ q = 1.0
32
+ segments[1..].each do |param|
33
+ param = param.strip
34
+ next unless param.start_with?("q=")
35
+
36
+ q_str = param[2..]
37
+ q = q_str.to_f if q_str =~ /\A-?\d+(\.\d+)?\z/
38
+ end
39
+ if media == "application/json"
40
+ best_json = q if q > best_json
41
+ elsif ["text/html", "*/*", "application/xhtml+xml"].include?(media)
42
+ best_html = q if q > best_html
43
+ end
44
+ end
45
+
46
+ return false if best_json.negative?
47
+ return true if best_html.negative?
48
+
49
+ best_json > best_html
50
+ end
51
+
8
52
  def globals
9
53
  @globals ||= {}
10
54
  end
@@ -19,7 +63,7 @@ module Tina4
19
63
  raise "Template not found: #{template_path}"
20
64
  end
21
65
 
22
- content = File.read(full_path)
66
+ content = File.read(full_path, encoding: "utf-8")
23
67
  ext = File.extname(full_path).downcase
24
68
  context = globals.merge(data.transform_keys(&:to_s))
25
69
 
@@ -43,7 +87,18 @@ module Tina4
43
87
  %w[.twig .html .erb].each do |ext|
44
88
  path = File.join(dir, "#{code}#{ext}")
45
89
  if File.exist?(path)
46
- content = File.read(path)
90
+ # encoding: "utf-8" (feature 42, ERR-DEC-01): 403.twig ships an
91
+ # em-dash. Without a forced encoding, File.read tags the string
92
+ # with Encoding.default_external, which is UTF-8 on a typical
93
+ # dev machine but US-ASCII on a bare/minimal locale (no LANG/
94
+ # LC_ALL - common in a container) - and Ruby's regex engine then
95
+ # raises ArgumentError: invalid byte sequence in US-ASCII the
96
+ # moment TwigEngine tries to match against it. render_error's
97
+ # caller used to `rescue` broadly and silently fall back to a
98
+ # bare string, so every 403 request on such a host rendered NO
99
+ # template at all and nobody noticed. Matches lib/tina4/frond.rb,
100
+ # which already reads its templates this way.
101
+ content = File.read(path, encoding: "utf-8")
47
102
  return TwigEngine.new(context, dir).render(content)
48
103
  end
49
104
  end
@@ -123,7 +178,7 @@ module Tina4
123
178
 
124
179
  def error_overlay_source(file, line)
125
180
  return "" unless file && line && File.exist?(file)
126
- lines = File.readlines(file)
181
+ lines = File.readlines(file, encoding: "utf-8")
127
182
  start = [line.to_i - 4, 0].max
128
183
  finish = [line.to_i + 3, lines.length - 1].min
129
184
  snippet = lines[start..finish].each_with_index.map do |l, i|
@@ -230,7 +285,7 @@ module Tina4
230
285
  parent_path = Regexp.last_match(1)
231
286
  full_parent = resolve_template(parent_path)
232
287
  if full_parent && File.exist?(full_parent)
233
- parent_source = File.read(full_parent)
288
+ parent_source = File.read(full_parent, encoding: "utf-8")
234
289
  child_blocks = extract_blocks(content)
235
290
  @blocks.merge!(child_blocks)
236
291
  content = render_with_blocks(parent_source, @blocks)
@@ -296,7 +351,7 @@ module Tina4
296
351
  grandparent_name = Regexp.last_match(1)
297
352
  full_grandparent = resolve_template(grandparent_name)
298
353
  if full_grandparent && File.exist?(full_grandparent)
299
- grandparent_source = File.read(full_grandparent)
354
+ grandparent_source = File.read(full_grandparent, encoding: "utf-8")
300
355
 
301
356
  # Extract block defaults defined in the parent template
302
357
  parent_blocks = extract_blocks(parent_source)
@@ -349,7 +404,7 @@ module Tina4
349
404
  inc_path = Regexp.last_match(1)
350
405
  full_path = resolve_template(inc_path)
351
406
  if full_path && File.exist?(full_path)
352
- inc_content = File.read(full_path)
407
+ inc_content = File.read(full_path, encoding: "utf-8")
353
408
  TwigEngine.new(@context.dup, File.dirname(full_path)).render(inc_content)
354
409
  else
355
410
  "<!-- include not found: #{inc_path} -->"
@@ -22,12 +22,45 @@ module Tina4
22
22
  # Build from a Rack response tuple [status, headers, body_array]
23
23
  def initialize(rack_response)
24
24
  @status = rack_response[0]
25
- @headers = rack_response[1] || {}
25
+ raw_headers = rack_response[1] || {}
26
+
27
+ # Rack folds a genuinely repeated header (Tina4::Response#to_rack only
28
+ # ever repeats Set-Cookie, joined "\n" — the Rack 2/3 convention for a
29
+ # multi-value header carried in a single hash slot) into ONE value; a
30
+ # header value can never legitimately contain a raw newline (RFC 7230),
31
+ # so splitting on "\n" is safe and general, not a Set-Cookie special
32
+ # case. @header_list keeps every occurrence, in emission order, so a
33
+ # duplicate is assertable via get_all(); @headers stays the back-compat
34
+ # single-value view — the LAST occurrence, the "last wins" contract the
35
+ # other three frameworks converge on — so nothing that reads a plain
36
+ # (never-repeated) header sees any change (TC-HEADER-COLLAPSE,
37
+ # TC-DEC-02).
38
+ @header_list = {}
39
+ flat = {}
40
+ raw_headers.each do |name, value|
41
+ key = name.to_s.downcase
42
+ values = value.is_a?(Array) ? value.map(&:to_s) : value.to_s.split("\n")
43
+ values = [""] if values.empty?
44
+ @header_list[key] = values
45
+ flat[key] = values.last
46
+ end
47
+ @headers = flat
48
+
26
49
  @content_type = @headers["content-type"] || ""
27
50
  raw_body = rack_response[2]
28
51
  @body = raw_body.is_a?(Array) ? raw_body.join : raw_body.to_s
29
52
  end
30
53
 
54
+ # Every value sent for +name+ (case-insensitive), in emission order.
55
+ #
56
+ # A header sent once returns a one-item array; a header never sent
57
+ # returns an empty array. This is the one place a duplicate response
58
+ # header (two Set-Cookie) is visible — headers[name] always collapses to
59
+ # the LAST value, same as before (TC-HEADER-COLLAPSE, TC-DEC-02).
60
+ def get_all(name)
61
+ (@header_list[name.to_s.downcase] || []).dup
62
+ end
63
+
31
64
  # Parse body as JSON.
32
65
  def json
33
66
  return nil if @body.nil? || @body.empty?
@@ -138,10 +171,23 @@ module Tina4
138
171
  env["CONTENT_TYPE"] = content_type unless content_type.empty?
139
172
  env["CONTENT_LENGTH"] = raw_body.bytesize.to_s unless raw_body.empty?
140
173
 
141
- # Add custom headers (convert to Rack format: X-Custom → HTTP_X_CUSTOM)
174
+ # Add custom headers (convert to Rack format: X-Custom → HTTP_X_CUSTOM).
175
+ # Content-Type and Content-Length are the two CGI/Rack headers that do
176
+ # NOT get an HTTP_ prefix (Request#content_type reads env["CONTENT_TYPE"]
177
+ # directly) — without this a caller-supplied `headers: {"Content-Type"
178
+ # => ...}` silently landed in HTTP_CONTENT_TYPE, which nothing reads, so
179
+ # #content_type stayed "" and body parsing fell through to the generic
180
+ # {} branch regardless of what the caller asked for. PHP's/Node's/
181
+ # Python's TestClient never had this gap (their headers carry no CGI
182
+ # prefix convention to special-case).
142
183
  if headers
143
184
  headers.each do |key, value|
144
- rack_key = "HTTP_#{key.upcase.tr('-', '_')}"
185
+ normalised = key.to_s.downcase
186
+ rack_key = case normalised
187
+ when "content-type" then "CONTENT_TYPE"
188
+ when "content-length" then "CONTENT_LENGTH"
189
+ else "HTTP_#{key.upcase.tr('-', '_')}"
190
+ end
145
191
  env[rack_key] = value
146
192
  end
147
193
  end
data/lib/tina4/testing.rb CHANGED
@@ -41,32 +41,37 @@ module Tina4
41
41
  end
42
42
 
43
43
  # ── Inline testing (parity with Python/PHP/Node decorator pattern) ──
44
+ #
45
+ # The builders are named expect_* — DESCRIPTORS that record an expectation
46
+ # for run_all to execute later — deliberately distinct from the immediate
47
+ # xUnit-style TestContext#assert_* used inside describe/it blocks, so the two
48
+ # surfaces never collide on a name with an incompatible signature.
44
49
 
45
- # Assertion builder: assert_equal(args, expected)
46
- def assert_equal(args, expected)
50
+ # Expectation builder: expect_equal(args, expected)
51
+ def expect_equal(args, expected)
47
52
  { type: :equal, args: args, expected: expected }
48
53
  end
49
54
 
50
- # Assertion builder: assert_raises(exception_class, args)
51
- def assert_raises(exception_class, args)
55
+ # Expectation builder: expect_raises(exception_class, args)
56
+ def expect_raises(exception_class, args)
52
57
  { type: :raises, exception: exception_class, args: args }
53
58
  end
54
59
 
55
- # Assertion builder: assert_true(args)
56
- def assert_true(args)
60
+ # Expectation builder: expect_true(args)
61
+ def expect_true(args)
57
62
  { type: :true, args: args }
58
63
  end
59
64
 
60
- # Assertion builder: assert_false(args)
61
- def assert_false(args)
65
+ # Expectation builder: expect_false(args)
66
+ def expect_false(args)
62
67
  { type: :false, args: args }
63
68
  end
64
69
 
65
- # Register a callable with inline assertions (mirrors Python's @tests decorator).
70
+ # Register a callable with inline expectations (mirrors Python's @tests decorator).
66
71
  #
67
72
  # Tina4::Testing.tests(
68
- # Tina4::Testing.assert_equal([5, 3], 8),
69
- # Tina4::Testing.assert_raises(ArgumentError, [nil]),
73
+ # Tina4::Testing.expect_equal([5, 3], 8),
74
+ # Tina4::Testing.expect_raises(ArgumentError, [nil]),
70
75
  # ) { |a, b| raise ArgumentError, "b required" if b.nil?; a + b }
71
76
  #
72
77
  def tests(*assertions, name: nil, &block)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Tina4
4
6
  # Request body validator with chainable rules.
5
7
  #
@@ -140,7 +142,11 @@ module Tina4
140
142
  return self if value.nil?
141
143
 
142
144
  unless allowed.include?(value)
143
- @validation_errors << { field: key, message: "#{key} must be one of #{allowed}" }
145
+ # Canonical wording (VALID-TWO-MESSAGES): render the allowed set as
146
+ # compact JSON so every framework prints the SAME list -- Node
147
+ # JSON.stringify, PHP json_encode, Python json.dumps and this to_json all
148
+ # emit ["admin","user","guest"] (no spaces), never a language repr.
149
+ @validation_errors << { field: key, message: "#{key} must be one of #{allowed.to_json}" }
144
150
  end
145
151
  self
146
152
  end
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.97"
4
+ VERSION = "3.13.99"
5
5
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "port_takeover"
4
+
3
5
  module Tina4
4
6
  class WebServer
5
7
  DEFAULT_HOST = "0.0.0.0"
@@ -17,49 +19,29 @@ module Tina4
17
19
  @port = port || Tina4.resolve_bind_port(DEFAULT_PORT)
18
20
  end
19
21
 
20
- # Kill whatever process is listening on *port*.
21
- # Uses lsof on macOS/Linux and netstat + taskkill on Windows.
22
- # Raises RuntimeError if the port cannot be freed.
22
+ # Reclaim *port* from a stale Tina4 dev server via the shared, guarded path.
23
+ #
24
+ # This is the runtime bind-failure fallback. It used to SIGTERM whatever held
25
+ # the port with NONE of the CLI's guards -- no identity check, no container
26
+ # guard, no PID-safety filter -- so a foreign holder (another dev server, a
27
+ # database) was killed on any bind failure. It now routes through the SAME
28
+ # identity-checked helper the CLI uses (TAKEOVER-DEC-02), so only a
29
+ # PID-file-confirmed Tina4 dev server is ever signalled.
30
+ #
31
+ # Raises RuntimeError when the port is held by a non-Tina4 process (or
32
+ # takeover is opted out / disabled outside dev), so the bind fails loudly with
33
+ # a clear message instead of killing an innocent process.
23
34
  def free_port(port)
24
- puts " Port #{port} in use — killing existing process..."
25
-
26
- if RUBY_PLATFORM =~ /mswin|mingw|cygwin/
27
- output = `netstat -ano 2>&1`
28
- pid = nil
29
- output.each_line do |line|
30
- if line.include?(":#{port}") && (line.include?("LISTENING") || line.include?("ESTABLISHED"))
31
- parts = line.strip.split(/\s+/)
32
- candidate = parts.last
33
- if candidate =~ /^\d+$/
34
- pid = candidate
35
- break
36
- end
37
- end
38
- end
39
- if pid
40
- system("taskkill /PID #{pid} /F")
41
- else
42
- raise "Could not free port #{port}: no PID found"
43
- end
44
- else
45
- pids = `lsof -ti :#{port} 2>/dev/null`.strip.split("\n")
46
- if pids.empty?
47
- return # Nothing found — port may have freed itself
48
- end
49
- pids.each do |pid|
50
- pid = pid.strip
51
- next unless pid =~ /^\d+$/
52
- begin
53
- Process.kill("TERM", pid.to_i)
54
- rescue Errno::ESRCH
55
- # Process already gone
56
- end
57
- end
35
+ result = Tina4::PortTakeover.take_over_port(
36
+ port, dev: Tina4::PortTakeover.dev?, no_takeover: Tina4::PortTakeover.no_takeover_opted_out?
37
+ )
38
+ if result.reclaimed?
39
+ puts " #{result.message}"
40
+ return
58
41
  end
42
+ raise result.message if result.refused?
59
43
 
60
- # Give the OS a moment to reclaim the port
61
- sleep(0.5)
62
- puts " Port #{port} freed"
44
+ # NOTHING / container: nothing to reclaim -- let the real bind decide.
63
45
  end
64
46
 
65
47
  def start
@@ -115,6 +97,10 @@ module Tina4
115
97
  AccessLog: []
116
98
  )
117
99
 
100
+ # Record THIS process as the Tina4 dev server on the main port, so a later
101
+ # `tina4 serve` can identify it as reclaimable (TAKEOVER-DEC-01).
102
+ Tina4::PortTakeover.write_pidfile(@port)
103
+
118
104
  # Setup graceful shutdown with WEBrick server reference
119
105
  Tina4::Shutdown.setup(server: @server)
120
106
 
@@ -327,6 +313,8 @@ module Tina4
327
313
  @ai_server&.shutdown
328
314
  @ai_thread&.join(5)
329
315
  @server&.shutdown
316
+ # Drop our identity marker so a later takeover does not match a dead PID.
317
+ Tina4::PortTakeover.remove_pidfile(@port)
330
318
  end
331
319
 
332
320
  # Dispatch a Rack-style env through the Tina4 app and return [status, headers, body].