tep 0.11.6 → 0.11.7

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: 33b663dcc38571d71f8a729f7ed1f8da6ea8a89e1eb6bd49619beac977da2727
4
- data.tar.gz: af7b47e7e3440372325744d1274e70e41216db757ac224ca2364c1a422b5064a
3
+ metadata.gz: f69c2e25a18f0ad96cc6a765e9655cc130c35ecc90fe006715409868c8e69e72
4
+ data.tar.gz: bfe36187e4d9565b03c58b37ac1e0243f96dbe3b888752983acc6d6f87595fc7
5
5
  SHA512:
6
- metadata.gz: ed5ce816c591c5464d624e3094798d3ce8c8c03c7128e3881541cf874a0735e62f468d7d4f781e793e6ae02f2bcb642119fdc49157394acb69cb027a014d9b6e
7
- data.tar.gz: 484a1cdae65a65cbe5da98d8c2aa22add1b4a562e35ab7cfb38f9137856bcad77ad773b07d191fb425a5e6307fe18a71856d47ad3766e8a03191622314ba2989
6
+ metadata.gz: 65c8b19acd15c4548a98a3db081e5f0c9d5b73068b8e99744e9064a35f1a4cdc920aa0a447061d708cd889155c5034efc6303532abee38869b193b5a1d10241d
7
+ data.tar.gz: 6307c6fefde2da3b7a3e67480d1c59ab051527c62796d03971fe25e1596c0fbee7271ea9d9d531b458ec310b99758e9d81b075d1f4c139d9500c25afc677a71a
data/README.md CHANGED
@@ -22,7 +22,7 @@ SQLite, opt-in PostgreSQL, WebSockets, Broadcast + Presence + LiveView,
22
22
  an MCP tool catalog, TLS, a pooled HTTP client, and an OpenAI-compatible
23
23
  LLM client + server.
24
24
 
25
- > **Current release:** [v0.11.6](https://github.com/OriPekelman/tep/releases/tag/v0.11.6)
25
+ > **Current release:** [v0.11.7](https://github.com/OriPekelman/tep/releases/tag/v0.11.7)
26
26
  > on [RubyGems](https://rubygems.org/gems/tep) — `gem install tep`.
27
27
  > Pre-alpha; API still in motion.
28
28
 
data/bin/tep CHANGED
@@ -86,7 +86,14 @@ def fatal(msg)
86
86
  end
87
87
 
88
88
  # Build the translated Ruby source from a Sinatra-style file.
89
- def translate(input_path)
89
+ #
90
+ # Strict by default (mirror condition 3, docs/mirrors/sinatra.md):
91
+ # any construct the translator would drop -- Rack `use`, `helpers`,
92
+ # unknown `set` keys, on_stop, malformed DSL blocks, missing views --
93
+ # is a fatal error, because building anyway produces a binary that
94
+ # silently diverges from what the same source does under real
95
+ # sinatra. `--lax` (or TEP_LAX=1) restores warn-and-continue.
96
+ def translate(input_path, lax: ENV["TEP_LAX"] == "1")
90
97
  raw_source = File.read(input_path)
91
98
 
92
99
  # Split off `__END__` inline templates. Sinatra's convention is
@@ -909,7 +916,19 @@ def translate(input_path)
909
916
  RB
910
917
  out << ""
911
918
 
912
- warnings.each { |w| warn "tep: #{w}" }
919
+ if warnings.any?
920
+ if lax
921
+ warnings.each { |w| warn "tep: #{w}" }
922
+ warn "tep: --lax: built with #{warnings.length} dropped construct(s); " \
923
+ "behaviour diverges from real sinatra where they applied"
924
+ else
925
+ warnings.each { |w| warn "tep: error: #{w}" }
926
+ fatal "#{warnings.length} unsupported construct(s) -- refusing to build " \
927
+ "a binary that silently diverges (docs/mirrors/sinatra.md, " \
928
+ "condition 3). Re-run with --lax (or TEP_LAX=1) to drop them " \
929
+ "with a warning instead."
930
+ end
931
+ end
913
932
 
914
933
  out.join("\n")
915
934
  end
@@ -1596,8 +1615,12 @@ def rewrite_block(src, force_string: true)
1596
1615
  # `headers["X"] = "y"` -> `res.headers[...]` when not already qualified
1597
1616
  s = s.gsub(/(?<![\w.])headers\[/, "res.headers[")
1598
1617
 
1599
- # `cookies["k"]` -> `req.cookies["k"]` when not already qualified
1600
- s = s.gsub(/(?<![\w.])cookies\[/, "req.cookies[")
1618
+ # `cookies["k"] = v` (write) -> direct hash write; `cookies["k"]`
1619
+ # (read) -> Tep.cookie(req, "k"), which nil-guards the miss: the
1620
+ # documented cookies[] contract is ""-on-miss (test_cookies pins
1621
+ # it), but the underlying hash reads nil for a missing key.
1622
+ s = s.gsub(/(?<![\w.])cookies\[([^\]]*)\]\s*=(?![=~])/, 'req.cookies[\1] =')
1623
+ s = s.gsub(/(?<![\w.])cookies\[([^\]]*)\]/, 'Tep.cookie(req, \1)')
1601
1624
 
1602
1625
  # `erb :name` -> `tep_view_name(Tep.str_hash, req.ivars)`
1603
1626
  # `erb :name, locals: {a: "b", c: "d"}` -> build a String=>String
@@ -1697,6 +1720,12 @@ def rewrite_block(src, force_string: true)
1697
1720
  # to string keys.
1698
1721
  s = s.gsub(/(req\.params)\[:(\w+)\]/, '\1["\2"]')
1699
1722
 
1723
+ # Read form -> Tep.param(req, k), which nil-guards the miss: tep's
1724
+ # documented params[] contract is ""-on-miss (test_params pins it;
1725
+ # ledgered as a sinatra divergence -- sinatra reads nil). Writes
1726
+ # (assignment) stay direct hash writes.
1727
+ s = s.gsub(/req\.params\[([^\]]*)\](?!\s*=[^=~])/, 'Tep.param(req, \1)')
1728
+
1700
1729
  # `"#{params['x']}"` interpolation works fine after the params rewrite.
1701
1730
 
1702
1731
  # String-coerce the last expression of the block body. Spinel's
@@ -2176,23 +2205,25 @@ def cmd_build(args)
2176
2205
  input = nil
2177
2206
  out_path = nil
2178
2207
  c_only = false
2208
+ lax = ENV["TEP_LAX"] == "1"
2179
2209
  i = 0
2180
2210
  while i < args.length
2181
2211
  a = args[i]
2182
2212
  case a
2183
2213
  when "-o" then out_path = args[i + 1]; i += 2
2184
2214
  when "-c" then c_only = true; i += 1
2215
+ when "--lax" then lax = true; i += 1
2185
2216
  when /^-/ then fatal "unknown option: #{a}"
2186
2217
  else
2187
2218
  input ||= a
2188
2219
  i += 1
2189
2220
  end
2190
2221
  end
2191
- fatal "usage: tep build app.rb [-o out] [-c]" unless input
2222
+ fatal "usage: tep build app.rb [-o out] [-c] [--lax]" unless input
2192
2223
 
2193
2224
  base = File.basename(input, ".rb")
2194
2225
  out_path ||= File.join(File.dirname(input), base)
2195
- translated = translate(input)
2226
+ translated = translate(input, lax: lax)
2196
2227
 
2197
2228
  # Spinel's require_relative resolves against the source file's
2198
2229
  # directory and chokes on absolute paths. Drop the generated file
@@ -2230,8 +2261,10 @@ def cmd_run(args)
2230
2261
  end
2231
2262
 
2232
2263
  def cmd_translate(args)
2233
- input = args.first or fatal "usage: tep translate app.rb"
2234
- puts translate(input)
2264
+ lax = ENV["TEP_LAX"] == "1"
2265
+ lax = true if args.delete("--lax")
2266
+ input = args.first or fatal "usage: tep translate app.rb [--lax]"
2267
+ puts translate(input, lax: lax)
2235
2268
  end
2236
2269
 
2237
2270
  case ARGV.shift
@@ -2243,9 +2276,14 @@ when nil, "-h", "--help"
2243
2276
  tep -- Sinatra-flavoured framework that compiles to a native binary.
2244
2277
 
2245
2278
  Usage:
2246
- tep build app.rb [-o out] [-c] translate + spinel-compile
2247
- tep run app.rb [-p PORT] [-w N] build then exec
2248
- tep translate app.rb print the translated Ruby
2279
+ tep build app.rb [-o out] [-c] [--lax] translate + spinel-compile
2280
+ tep run app.rb [-p PORT] [-w N] build then exec
2281
+ tep translate app.rb [--lax] print the translated Ruby
2282
+
2283
+ Strict by default: source constructs tep would have to drop
2284
+ (Rack `use`, `helpers do`, unknown `set` keys, ...) fail the
2285
+ build rather than silently diverging from real sinatra.
2286
+ `--lax` (or TEP_LAX=1) downgrades them to warnings.
2249
2287
 
2250
2288
  Source compatibility: top-level `get '/path' do ... end`,
2251
2289
  `post`, `put`, `patch`, `delete`, `before`, `after`, `not_found`,
data/examples/blog/app.rb CHANGED
@@ -332,6 +332,7 @@ end
332
332
  post '/api/posts' do
333
333
  res.headers["Content-Type"] = "application/json"
334
334
  auth = req.req_headers["authorization"]
335
+ auth = "" if auth.nil? # missing header reads as nil (tep#235)
335
336
  bearer = ""
336
337
  if auth.length > 7 && auth[0, 7] == "Bearer "
337
338
  bearer = auth[7, auth.length - 7]
@@ -218,7 +218,9 @@ end
218
218
  # baked in. Here, accept an X-Demo-Cap-Run header as the
219
219
  # capability source so humans can drive the demo with curl.
220
220
  before do
221
- if req.req_headers["x-demo-cap-run"].length > 0
221
+ cap = req.req_headers["x-demo-cap-run"]
222
+ cap = "" if cap.nil? # missing header reads as nil (tep#235)
223
+ if cap.length > 0
222
224
  req.identity = Tep::Identity.new(
223
225
  "user:demo", nil, [:run_experiments])
224
226
  end
data/lib/tep/app.rb CHANGED
@@ -187,6 +187,7 @@ module Tep
187
187
  secret = Tep.session_secret
188
188
  if secret.length > 0
189
189
  cv = req.cookies[Tep::COOKIE_NAME]
190
+ cv = "" if cv.nil?
190
191
  if cv.length > 0
191
192
  req.session.load_from(cv, secret)
192
193
  end
@@ -47,6 +47,7 @@ module Tep
47
47
  # signature / expired / malformed payload.
48
48
  def self.try(req)
49
49
  header = req.req_headers["authorization"]
50
+ header = "" if header.nil?
50
51
  if header.length < 8 || header[0, 7] != "Bearer "
51
52
  return nil
52
53
  end
@@ -84,12 +84,16 @@ module Tep
84
84
  # if the session has no identity (no prior #set call, or after
85
85
  # #clear) or the stored identity is expired.
86
86
  def self.try(req)
87
+ # Session#get on a missing key reads as nil (sinatra-parity Hash
88
+ # semantics; same class as tep#235) -- guard before String calls.
87
89
  sub = req.session.get("identity_sub")
90
+ sub = "" if sub.nil?
88
91
  if sub.length == 0
89
92
  return nil
90
93
  end
91
94
 
92
95
  exp_str = req.session.get("identity_exp")
96
+ exp_str = "" if exp_str.nil?
93
97
  if exp_str.length > 0
94
98
  exp = exp_str.to_i
95
99
  if exp > 0 && Time.now.to_i >= exp
@@ -98,9 +102,11 @@ module Tep
98
102
  end
99
103
 
100
104
  caps_str = req.session.get("identity_caps")
105
+ caps_str = "" if caps_str.nil?
101
106
  caps = Tep::AuthBearerToken.parse_caps(caps_str)
102
107
 
103
108
  delegate_str = req.session.get("identity_delegate")
109
+ delegate_str = "" if delegate_str.nil?
104
110
  delegation = Tep::AuthBearerToken.parse_delegate(delegate_str)
105
111
 
106
112
  Tep::Identity.new(sub, delegation, caps)
data/lib/tep/cache.rb CHANGED
@@ -18,8 +18,10 @@ module Tep
18
18
  # tag is matched as a substring so a comma-separated list of tags
19
19
  # in If-None-Match works.
20
20
  etag = res.headers["ETag"]
21
+ etag = "" if etag.nil?
21
22
  if etag.length > 0
22
23
  inm = req.headers["if-none-match"]
24
+ inm = "" if inm.nil?
23
25
  if inm.length > 0
24
26
  if inm == "*"
25
27
  return true
@@ -35,6 +37,7 @@ module Tep
35
37
  lm = res.lastmod_epoch
36
38
  if lm > 0
37
39
  ims = req.headers["if-modified-since"]
40
+ ims = "" if ims.nil?
38
41
  if ims.length > 0
39
42
  ims_epoch = Sock.sphttp_parse_http_date(ims)
40
43
  if ims_epoch >= 0 && lm <= ims_epoch
data/lib/tep/http.rb CHANGED
@@ -9,10 +9,12 @@
9
9
  # plumbing (sphttp_connect, sphttp_set_nonblock, sphttp_recv_*); the
10
10
  # missing piece is the HTTP/1.0 client on top.
11
11
  #
12
- # Scope (v1)
13
- # ----------
14
- # * **HTTP only** -- no TLS. Talk to internal services, the local
15
- # Ollama API, vLLM, your own tep-backed sidecars.
12
+ # Scope
13
+ # -----
14
+ # * **http + https** -- outbound TLS landed in #150
15
+ # (Sock.sphttp_connect_tls; non-blocking handshake under the
16
+ # scheduled server). Talk to internal services, the local Ollama
17
+ # API, vLLM, your own tep-backed sidecars, or external https APIs.
16
18
  # * **HTTP/1.0 + Connection: close** -- one socket per request,
17
19
  # read until EOF. No keep-alive, no pipelining.
18
20
  # * **No chunked-transfer reading** -- assumes Content-Length or
@@ -24,7 +26,7 @@
24
26
  #
25
27
  # These limits cover the dashboard's needs (talking to local
26
28
  # inference backends) and the bulk of "hit an internal API"
27
- # workloads. HTTPS + keep-alive + chunked land as a v2 surface.
29
+ # workloads. Keep-alive + chunked reading land as a v2 surface.
28
30
  #
29
31
  # API shape
30
32
  # ---------
@@ -141,7 +141,10 @@ module Tep
141
141
  def self.serve!(events_jsonl = "")
142
142
  events = Tep::Events.new(events_jsonl)
143
143
  Tep::APP.set_openai_events(events)
144
+ # ENV[] reads nil for an unset variable (CRuby semantics) --
145
+ # guard before String calls (same class as tep#235).
144
146
  host = ENV["HOSTNAME"]
147
+ host = "" if host.nil?
145
148
  if host.length == 0
146
149
  host = "tep"
147
150
  end
data/lib/tep/parser.rb CHANGED
@@ -55,8 +55,11 @@ module Tep
55
55
  end
56
56
 
57
57
  # Parse Cookie header into req.cookies. Format: "k=v; k2=v2; ...".
58
- # Whitespace around `;` is allowed and stripped.
58
+ # Whitespace around `;` is allowed and stripped. A request with no
59
+ # Cookie header reads as nil (don't call methods on it: spinel's
60
+ # unresolved-call gate raises NoMethodError, same as CRuby).
59
61
  cookie_blob = req.req_headers["cookie"]
62
+ cookie_blob = "" if cookie_blob.nil?
60
63
  if cookie_blob.length > 0
61
64
  cookie_blob.split(";").each do |pair|
62
65
  eq = Tep.str_find(pair, "=", 0)
data/lib/tep/pg.rb CHANGED
@@ -190,6 +190,16 @@ module PG
190
190
  @last_sqlstate = ""
191
191
  @last_error_message = ""
192
192
  @last_result_rh = -1
193
+ # Never-connected sentinel: PG::Connection.new("") is the
194
+ # type-seeding shape (see the _tep_seed_pg_* block). Exec on a
195
+ # seed conn returns the empty-Result sentinel instead of
196
+ # raising -- a REAL failed connect (non-empty conninfo) still
197
+ # raises PG::UnableToSend on first exec, per the documented
198
+ # non-raising-connect / raising-exec contract.
199
+ @seed_conn = false
200
+ if opts.is_a?(String) && opts.length == 0
201
+ @seed_conn = true
202
+ end
193
203
  if opts.is_a?(String)
194
204
  if Tep::Scheduler.scheduled_context?
195
205
  h = Connection.async_connect(opts)
@@ -382,6 +392,9 @@ module PG
382
392
  Pg.tep_pg_set_nonblocking(@pgh, 1)
383
393
  ok = Pg.tep_pg_send_query(@pgh, sql)
384
394
  if ok != 1
395
+ if @seed_conn
396
+ return PG::Result.new(-1)
397
+ end
385
398
  Connection.raise_send_failure(self)
386
399
  end
387
400
  Connection.drain_send(@pgh)
@@ -421,6 +434,9 @@ module PG
421
434
  Pg.tep_pg_set_nonblocking(@pgh, 1)
422
435
  ok = Pg.tep_pg_send_query_params(@pgh, sql)
423
436
  if ok != 1
437
+ if @seed_conn
438
+ return PG::Result.new(-1)
439
+ end
424
440
  Connection.raise_send_failure(self)
425
441
  end
426
442
  Connection.drain_send(@pgh)
data/lib/tep/request.rb CHANGED
@@ -51,11 +51,16 @@ module Tep
51
51
  def headers; @req_headers; end
52
52
  def body; @raw_body; end
53
53
 
54
- # Spinel's Hash[k] returns "" for missing string keys, not nil --
55
- # so an empty Connection header looks the same as no header at all.
56
- # We treat both as "use HTTP/1.1 default behaviour".
54
+ # A missing header reads as nil (CRuby Hash semantics; spinel's
55
+ # unresolved-call gate raises NoMethodError on nil receivers, same
56
+ # as CRuby would). Guard before calling String methods; an empty
57
+ # header and no header both mean "use HTTP/1.1 default behaviour".
58
+ # NOTE: guard with `x = "" if x.nil?`, not `x || ""` -- the ||-form
59
+ # mis-compiles at spinel pin f6d5eef (blanks the hit value).
57
60
  def keep_alive?
58
- lc = @req_headers["connection"].downcase
61
+ lc = @req_headers["connection"]
62
+ lc = "" if lc.nil?
63
+ lc = lc.downcase
59
64
  if lc == "close"
60
65
  return false
61
66
  end
@@ -70,7 +75,9 @@ module Tep
70
75
  end
71
76
 
72
77
  def form?
73
- @req_headers["content-type"].downcase.start_with?("application/x-www-form-urlencoded")
78
+ ct = @req_headers["content-type"]
79
+ ct = "" if ct.nil?
80
+ ct.downcase.start_with?("application/x-www-form-urlencoded")
74
81
  end
75
82
 
76
83
  # True when the request body is a multipart/form-data submission
@@ -78,7 +85,9 @@ module Tep
78
85
  # or carrying file inputs). Tep::Multipart.parse handles the
79
86
  # text fields; file-upload parts are skipped in v1.
80
87
  def multipart?
81
- @req_headers["content-type"].downcase.start_with?("multipart/form-data")
88
+ ct = @req_headers["content-type"]
89
+ ct = "" if ct.nil?
90
+ ct.downcase.start_with?("multipart/form-data")
82
91
  end
83
92
 
84
93
  # ---- Rack::Request-style accessors (reads only, no .ip yet) ----
@@ -99,6 +108,7 @@ module Tep
99
108
  # proxy sets.
100
109
  def scheme
101
110
  proto = @req_headers["x-forwarded-proto"]
111
+ proto = "" if proto.nil?
102
112
  if proto.length > 0
103
113
  return proto.downcase
104
114
  end
data/lib/tep/server.rb CHANGED
@@ -231,7 +231,11 @@ module Tep
231
231
  res.set_body("")
232
232
  end
233
233
 
234
- if res.body.length > 0 && !res.headers.key?("Content-Type")
234
+ # Default Content-Type even on empty bodies (redirects!) --
235
+ # sinatra/Rack parity, caught by the differential oracle
236
+ # (test/differential/runner.rb). 204/304 are the exception:
237
+ # Rack strips entity headers there, so we don't default one in.
238
+ if !res.headers.key?("Content-Type") && res.status != 204 && res.status != 304
235
239
  res.headers["Content-Type"] = "text/html; charset=utf-8"
236
240
  end
237
241
  res.headers["Content-Length"] = res.body.length.to_s
@@ -327,11 +327,14 @@ module Tep
327
327
  res.file_path = ""
328
328
  end
329
329
 
330
- # Default Content-Type for inline-body responses. Matches
331
- # Tep::Server#send; without it, the Security::Headers nosniff
332
- # default leaves the browser refusing to interpret an erb
333
- # response as HTML.
334
- if res.file_path.length == 0 && res.body.length > 0 && !res.headers.key?("Content-Type")
330
+ # Default Content-Type for inline-body responses -- including
331
+ # empty ones (redirects), for sinatra/Rack parity (differential
332
+ # oracle finding; matches Tep::Server). 204/304 excepted: Rack
333
+ # strips entity headers there. Without a Content-Type, the
334
+ # Security::Headers nosniff default leaves the browser refusing
335
+ # to interpret an erb response as HTML.
336
+ if res.file_path.length == 0 && !res.headers.key?("Content-Type") &&
337
+ res.status != 204 && res.status != 304
335
338
  res.headers["Content-Type"] = "text/html; charset=utf-8"
336
339
  end
337
340
  reason = Tep.reason(res.status)
data/lib/tep/session.rb CHANGED
@@ -24,7 +24,15 @@ module Tep
24
24
  # exposes only named methods; the translator rewrites
25
25
  # `session[k] = v` to `session.set(k, v)` and `session[k]` to
26
26
  # `session.get(k)` for source compatibility with Sinatra.
27
- def get(k); @data[k]; end
27
+ # A missing key reads as "" -- the documented `session[k]` DSL
28
+ # contract (test_sessions pins it; a deliberate divergence from
29
+ # sinatra's nil-on-miss, ledgered in docs/mirrors/sinatra.md).
30
+ # The raw @data hash reads nil for a miss.
31
+ def get(k)
32
+ v = @data[k]
33
+ v = "" if v.nil?
34
+ v
35
+ end
28
36
  def set(k, v); @data[k] = v; @dirty = true; end
29
37
  def has?(k); @data.key?(k); end
30
38
  def length; @data.length; end
data/lib/tep/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Tep
2
- VERSION = "0.11.6"
2
+ VERSION = "0.11.7"
3
3
  end
@@ -26,13 +26,16 @@ module Tep
26
26
  end
27
27
 
28
28
  # Upgrade + Connection headers (downcased per Tep::Request).
29
+ # A missing header reads as nil; treat it as absent ("").
29
30
  upgrade = req.headers["upgrade"]
31
+ upgrade = "" if upgrade.nil?
30
32
  if Handshake.icontains(upgrade, "websocket") == false
31
33
  out.valid = false
32
34
  out.reason = "missing/invalid Upgrade"
33
35
  return out
34
36
  end
35
37
  conn = req.headers["connection"]
38
+ conn = "" if conn.nil?
36
39
  if Handshake.icontains(conn, "upgrade") == false
37
40
  out.valid = false
38
41
  out.reason = "missing/invalid Connection"
@@ -49,6 +52,7 @@ module Tep
49
52
 
50
53
  # Sec-WebSocket-Key: 24-char base64 (16-byte nonce).
51
54
  key = req.headers["sec-websocket-key"]
55
+ key = "" if key.nil?
52
56
  if key.length == 0
53
57
  out.valid = false
54
58
  out.reason = "missing Sec-WebSocket-Key"
@@ -60,7 +64,9 @@ module Tep
60
64
 
61
65
  # Parse Sec-WebSocket-Protocol (comma-separated). Handler
62
66
  # gets the offered list; can opt-in via Driver.accept_protocol.
63
- out.protocols = Handshake.split_csv(req.headers["sec-websocket-protocol"])
67
+ proto_blob = req.headers["sec-websocket-protocol"]
68
+ proto_blob = "" if proto_blob.nil?
69
+ out.protocols = Handshake.split_csv(proto_blob)
64
70
  out
65
71
  end
66
72
 
data/lib/tep.rb CHANGED
@@ -129,6 +129,28 @@ module Tep
129
129
  h
130
130
  end
131
131
 
132
+ # Read a request cookie. A missing cookie reads as "" -- that is the
133
+ # documented `cookies["k"]` DSL contract (test_cookies pins it) --
134
+ # while the underlying hash reads nil for a missing key. bin/tep
135
+ # rewrites `cookies[k]` reads to this call.
136
+ def self.cookie(req, k)
137
+ v = req.cookies[k]
138
+ v = "" if v.nil?
139
+ v
140
+ end
141
+
142
+ # Read a request param. A missing param reads as "" -- tep's
143
+ # documented `params[k]` DSL contract (test_params pins it; a
144
+ # deliberate divergence from sinatra's nil, ledgered in
145
+ # docs/mirrors/sinatra.md). bin/tep rewrites `params[k]` reads to
146
+ # this call; direct `req.params[...]` access keeps raw Hash
147
+ # semantics (nil on miss).
148
+ def self.param(req, k)
149
+ v = req.params[k]
150
+ v = "" if v.nil?
151
+ v
152
+ end
153
+
132
154
  # str_find -- naive substring search returning the int position of
133
155
  # `needle` in `s` starting from `start`, or -1 if not found.
134
156
  #
@@ -0,0 +1,56 @@
1
+ # Differential fixture: core routing/response semantics that both real
2
+ # sinatra and tep claim. This file must stay valid under BOTH dialects
3
+ # (docs/mirrors/sinatra.md condition 2) -- no tep extensions
4
+ # (request.headers, cookies[], set_cookie) and no sinatra-contrib.
5
+ require 'sinatra'
6
+
7
+ get '/hi/:name' do
8
+ 'hi ' + params[:name]
9
+ end
10
+
11
+ get '/two/:a/:b' do
12
+ params[:a] + '-' + params[:b]
13
+ end
14
+
15
+ get '/q' do
16
+ q = params[:q]
17
+ q = '' if q.nil?
18
+ 'q=' + q
19
+ end
20
+
21
+ post '/form' do
22
+ t = params[:text]
23
+ t = '' if t.nil?
24
+ 'text=' + t
25
+ end
26
+
27
+ get '/redir' do
28
+ redirect '/hi/there'
29
+ end
30
+
31
+ get '/redir301' do
32
+ redirect '/hi/there', 301
33
+ end
34
+
35
+ get '/teapot' do
36
+ status 418
37
+ 'short and stout'
38
+ end
39
+
40
+ get '/halted' do
41
+ halt 401, 'no entry'
42
+ end
43
+
44
+ get '/hdr' do
45
+ headers['x-custom'] = 'yes'
46
+ 'ok'
47
+ end
48
+
49
+ get '/ct' do
50
+ content_type 'text/plain'
51
+ 'plain body'
52
+ end
53
+
54
+ not_found do
55
+ 'nope: ' + request.path
56
+ end
@@ -0,0 +1,63 @@
1
+ # Differential fixture: broader Phase A matrix coverage. Valid under
2
+ # BOTH real sinatra and tep (docs/mirrors/sinatra.md condition 2).
3
+ # Divergent read-APIs (splat value, regex captures, cookies DSL) are
4
+ # deliberately NOT read here -- routes only exercise surface both
5
+ # dialects share; the known divergences are pinned by the runner's
6
+ # ledgered-divergence assertions instead.
7
+ require 'sinatra'
8
+
9
+ put '/verb' do
10
+ 'put-ok'
11
+ end
12
+
13
+ patch '/verb' do
14
+ 'patch-ok'
15
+ end
16
+
17
+ # Splat: single-segment matches in both dialects. The VALUE is not
18
+ # read (sinatra: params['splat'] array; tep: no exposed value) and
19
+ # multi-segment matching diverges -- see runner ledger L5.
20
+ get '/files/*' do
21
+ 'file-route'
22
+ end
23
+
24
+ # Regex route, no capture reads (sinatra: params['captures']; tep:
25
+ # params["1"] -- runner ledger L6). Unanchored on purpose: sinatra's
26
+ # mustermann raises at boot on ^/$ anchors (it anchors implicitly),
27
+ # while tep accepts them -- anchored regex routes are tep-only (L6).
28
+ get %r{/rx/\d+} do
29
+ 'rx-route'
30
+ end
31
+
32
+ # after-filter mutating a response header; both dialects claim it.
33
+ after do
34
+ headers['x-after'] = 'ran'
35
+ end
36
+
37
+ get '/afterhdr' do
38
+ 'body'
39
+ end
40
+
41
+ # Query-string URL decoding: %xx and `+` as space.
42
+ get '/dec' do
43
+ v = params[:v]
44
+ v = '' if v.nil?
45
+ 'v=[' + v + ']'
46
+ end
47
+
48
+ # Multipart/form-data: text fields merge into params in both dialects
49
+ # (file parts are tep-ledgered as skipped; not exercised here).
50
+ post '/mp' do
51
+ f = params[:field]
52
+ f = '' if f.nil?
53
+ 'field=[' + f + ']'
54
+ end
55
+
56
+ # Bare halt (status only, empty body).
57
+ get '/gone' do
58
+ halt 410
59
+ end
60
+
61
+ not_found do
62
+ 'nf: ' + request.path
63
+ end
@@ -0,0 +1,14 @@
1
+ # Gems for the differential oracle ONLY (test/differential/runner.rb):
2
+ # real sinatra as the behavioural reference. Deliberately a separate
3
+ # Gemfile so sinatra never becomes a dependency of tep itself -- the
4
+ # main Gemfile stays rake/minitest/base64/rbs.
5
+ #
6
+ # BUNDLE_GEMFILE=test/differential/Gemfile bundle install
7
+ # ruby test/differential/runner.rb
8
+
9
+ source "https://rubygems.org"
10
+
11
+ gem "sinatra"
12
+ gem "rackup"
13
+ gem "puma"
14
+ gem "minitest"
@@ -0,0 +1,250 @@
1
+ # Differential oracle: real sinatra vs tep (mirror condition 2,
2
+ # docs/mirrors/sinatra.md). Boots each fixture app twice -- once under
3
+ # CRuby + the real sinatra gem, once as a tep-compiled binary -- fires
4
+ # the same requests at both, and diffs status / body / declared
5
+ # headers. Any divergence not recorded in LEDGER is a failure: new
6
+ # divergences must be either fixed or explicitly ledgered with a
7
+ # reason (which then belongs in docs/mirrors/sinatra.md too).
8
+ #
9
+ # Not part of `rake test` (FileList only globs test/test_*.rb): the
10
+ # sinatra gem must never become a tep dependency. Run explicitly:
11
+ #
12
+ # gem install sinatra rackup puma # or bundle with ./Gemfile
13
+ # ruby test/differential/runner.rb
14
+ #
15
+ # CI runs this as its own job (`differential` in ci.yml).
16
+
17
+ begin
18
+ gem "sinatra"
19
+ rescue Gem::LoadError
20
+ abort "differential runner needs the sinatra gem (gem install sinatra rackup puma)"
21
+ end
22
+
23
+ require "minitest/autorun"
24
+ require "net/http"
25
+ require "socket"
26
+ require "tmpdir"
27
+ require "fileutils"
28
+ require "shellwords"
29
+ require "rbconfig"
30
+
31
+ module Differential
32
+ TEP_BIN = File.expand_path("../../bin/tep", __dir__)
33
+ ROOT = File.expand_path("../..", __dir__)
34
+ PORT_BASE = 5300 + ($$ % 100)
35
+
36
+ @port = PORT_BASE
37
+ def self.next_port
38
+ @port += 1
39
+ end
40
+
41
+ # ---- the exclusion ledger, harness-side ----
42
+ # Divergences we accept, keyed by response facet. Every entry needs a
43
+ # reason and should be mirrored in docs/mirrors/sinatra.md. The
44
+ # runner *normalizes* these away before diffing; anything left is a
45
+ # real, unledgered divergence and fails the run.
46
+ #
47
+ # L1 content-type charset: sinatra appends ";charset=utf-8" to text
48
+ # types; tep emits the bare type.
49
+ # L2 protection headers: sinatra ships rack-protection by default
50
+ # (x-xss-protection / x-content-type-options / x-frame-options);
51
+ # tep's Tep::Security::Headers is opt-in.
52
+ # L3 server/date/connection/content-length: transport furniture --
53
+ # body equality already covers length.
54
+ # L4 redirect Location absolutization: sinatra expands "/path" to an
55
+ # absolute http://host:port/path; tep echoes the path as given.
56
+ # Compare path component only.
57
+ # L5 splat scope: sinatra's `*` spans path segments; tep's matches a
58
+ # single trailing segment. Pinned per-request via `diverge:`.
59
+ # L6 divergent read APIs, not exercised by fixtures: regex captures
60
+ # (sinatra params['captures'] / block args vs tep params["1".."9"]),
61
+ # splat value (params['splat'] array vs none), request.headers
62
+ # (tep extension), bare set_cookie/cookies[] (tep mirrors
63
+ # contrib-style helpers), and ^/$-anchored regex routes (tep
64
+ # accepts; sinatra's mustermann raises at boot -- anchor-free
65
+ # regexes are the portable form). Fixtures avoid these; the
66
+ # ledger in docs/mirrors/sinatra.md records them.
67
+ IGNORED_HEADERS = %w[
68
+ server date connection content-length x-xss-protection
69
+ x-content-type-options x-frame-options keep-alive
70
+ ].freeze
71
+
72
+ def self.normalize_content_type(v)
73
+ v.to_s.split(";").first.to_s.strip.downcase # L1
74
+ end
75
+
76
+ def self.normalize_location(v)
77
+ return "" if v.nil?
78
+ v.sub(%r{\Ahttps?://[^/]+}, "") # L4
79
+ end
80
+
81
+ Server = Struct.new(:pid, :port, :log) do
82
+ def stop
83
+ return unless pid
84
+ Process.kill("TERM", -pid) rescue Process.kill("TERM", pid) rescue nil
85
+ Process.wait(pid) rescue nil
86
+ end
87
+ end
88
+
89
+ def self.wait_for_port(port, pid, timeout: 15.0)
90
+ deadline = Time.now + timeout
91
+ while Time.now < deadline
92
+ begin
93
+ TCPSocket.new("127.0.0.1", port).close
94
+ return true
95
+ rescue Errno::ECONNREFUSED
96
+ raise "server died before binding :#{port}" if Process.wait(pid, Process::WNOHANG) rescue nil
97
+ sleep 0.05
98
+ end
99
+ end
100
+ raise "server on :#{port} didn't come up"
101
+ end
102
+
103
+ def self.boot_sinatra(app_path)
104
+ port = next_port
105
+ log = File.join(Dir.mktmpdir("diff-sin"), "sinatra.log")
106
+ pid = Process.spawn(
107
+ RbConfig.ruby, app_path, "-p", port.to_s, "-o", "127.0.0.1", "-e", "production",
108
+ out: log, err: [:child, :out], pgroup: true
109
+ )
110
+ wait_for_port(port, pid)
111
+ Server.new(pid, port, log)
112
+ end
113
+
114
+ def self.boot_tep(app_path)
115
+ tmp = Dir.mktmpdir("diff-tep")
116
+ bin = File.join(tmp, "app")
117
+ out = `#{Shellwords.escape(TEP_BIN)} build #{Shellwords.escape(app_path)} -o #{Shellwords.escape(bin)} 2>&1`
118
+ raise "tep build failed for #{app_path}:\n#{out}" unless $?.success?
119
+ port = next_port
120
+ log = File.join(tmp, "tep.log")
121
+ pid = Process.spawn(bin, "-p", port.to_s, "-q",
122
+ out: log, err: [:child, :out], pgroup: true)
123
+ wait_for_port(port, pid)
124
+ Server.new(pid, port, log)
125
+ end
126
+
127
+ def self.request(port, verb, path, body: nil, headers: {})
128
+ http = Net::HTTP.new("127.0.0.1", port)
129
+ http.open_timeout = 5
130
+ http.read_timeout = 5
131
+ klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
132
+ "PUT" => Net::HTTP::Put, "PATCH" => Net::HTTP::Patch,
133
+ "DELETE" => Net::HTTP::Delete }.fetch(verb)
134
+ req = klass.new(path)
135
+ headers.each { |k, v| req[k] = v }
136
+ if body
137
+ req.body = body
138
+ req["Content-Type"] ||= "application/x-www-form-urlencoded"
139
+ end
140
+ http.request(req)
141
+ end
142
+ end
143
+
144
+ # One test class per fixture; each boots both servers once and replays
145
+ # the same request script against each.
146
+ class DifferentialCase < Minitest::Test
147
+ FIXTURES = {
148
+ File.expand_path("../real_world/01_simple.rb", __dir__) => [
149
+ ["GET", "/"],
150
+ ],
151
+ File.expand_path("../real_world/04_health_api.rb", __dir__) => [
152
+ ["GET", "/healthz"],
153
+ ["GET", "/version"],
154
+ ["GET", "/"],
155
+ ["GET", "/missing"], # custom not_found on both
156
+ ],
157
+ File.expand_path("../real_world/05_todo_api.rb", __dir__) => [
158
+ ["GET", "/todos"],
159
+ ["POST", "/todos", { body: "text=buy-milk" }],
160
+ ["POST", "/todos", { body: "text=ship-tep" }],
161
+ ["GET", "/todos"],
162
+ ["DELETE", "/todos/1"],
163
+ ["DELETE", "/todos/9999"],
164
+ ["GET", "/todos"],
165
+ ],
166
+ File.expand_path("11_matrix.rb", __dir__) => [
167
+ ["PUT", "/verb"],
168
+ ["PATCH", "/verb"],
169
+ ["GET", "/files/one"],
170
+ # L5: sinatra's splat spans segments (/files/a/b matches); tep's
171
+ # splat is last-segment-only (404s). Pinned divergence.
172
+ ["GET", "/files/a/b", { diverge: { "status" => "L5 splat last-segment-only" } }],
173
+ ["GET", "/rx/123"],
174
+ ["GET", "/rx/abc"], # regex miss -> not_found on both
175
+ ["GET", "/afterhdr", { expect_headers: ["x-after"] }],
176
+ ["GET", "/dec?v=a%20b+c"], # %xx and + decoding
177
+ ["POST", "/mp", {
178
+ body: "--XDIFFB\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nzap-mp\r\n--XDIFFB--\r\n",
179
+ headers: { "Content-Type" => "multipart/form-data; boundary=XDIFFB" },
180
+ }],
181
+ ["GET", "/gone"], # bare halt 410
182
+ ["GET", "/nowhere"],
183
+ ],
184
+ File.expand_path("10_semantics.rb", __dir__) => [
185
+ ["GET", "/hi/tep"],
186
+ ["GET", "/two/a/b"],
187
+ ["GET", "/q?q=hello"],
188
+ ["GET", "/q"], # missing param -> ""
189
+ ["POST", "/form", { body: "text=zap" }],
190
+ ["GET", "/redir"],
191
+ ["GET", "/redir301"],
192
+ ["GET", "/teapot"],
193
+ ["GET", "/halted"],
194
+ ["GET", "/hdr", { expect_headers: ["x-custom"] }],
195
+ ["GET", "/ct"],
196
+ ["GET", "/hi%20there"], # url-decoded 404 path via not_found
197
+ ],
198
+ }.freeze
199
+
200
+ def compare(app, sin, tep, verb, path, opts)
201
+ body = opts[:body]
202
+ headers = opts[:headers] || {}
203
+ r_sin = Differential.request(sin.port, verb, path, body: body, headers: headers)
204
+ r_tep = Differential.request(tep.port, verb, path, body: body, headers: headers)
205
+ ctx = "#{File.basename(app)} #{verb} #{path}"
206
+
207
+ # Ledgered per-request divergences (opts[:diverge] = {facet =>
208
+ # "Ln reason"}): the divergence is EXPECTED and pinned -- if the
209
+ # two servers ever agree again, the assertion flips, telling us to
210
+ # clean the ledger entry (and docs/mirrors/sinatra.md). A status
211
+ # divergence short-circuits the body/content-type diff: different
212
+ # routes answered, comparing their bodies is meaningless.
213
+ diverge = opts[:diverge] || {}
214
+ if diverge.key?("status")
215
+ refute_equal r_sin.code, r_tep.code,
216
+ "#{ctx}: ledgered status divergence (#{diverge["status"]}) healed -- remove from ledger"
217
+ return
218
+ end
219
+
220
+ assert_equal r_sin.code, r_tep.code, "#{ctx}: status diverged"
221
+ assert_equal r_sin.body, r_tep.body, "#{ctx}: body diverged"
222
+ assert_equal Differential.normalize_content_type(r_sin["content-type"]),
223
+ Differential.normalize_content_type(r_tep["content-type"]),
224
+ "#{ctx}: content-type diverged (post-normalization)"
225
+ if %w[301 302 303 307 308].include?(r_sin.code)
226
+ assert_equal Differential.normalize_location(r_sin["location"]),
227
+ Differential.normalize_location(r_tep["location"]),
228
+ "#{ctx}: redirect Location diverged (path component)"
229
+ end
230
+ (opts[:expect_headers] || []).each do |h|
231
+ assert_equal r_sin[h], r_tep[h], "#{ctx}: declared header #{h} diverged"
232
+ end
233
+ end
234
+
235
+ FIXTURES.each do |app, script|
236
+ name = File.basename(app, ".rb")
237
+ define_method("test_differential_#{name}") do
238
+ sin = Differential.boot_sinatra(app)
239
+ tep = Differential.boot_tep(app)
240
+ begin
241
+ script.each do |verb, path, opts|
242
+ compare(app, sin, tep, verb, path, opts || {})
243
+ end
244
+ ensure
245
+ sin.stop
246
+ tep.stop
247
+ end
248
+ end
249
+ end
250
+ end
@@ -111,8 +111,12 @@ class TestBroadcastPg < TepTest
111
111
  end
112
112
 
113
113
  def test_poll_returns_zero_on_timeout
114
- # With no preceding publish + no other publisher on the channel,
115
- # poll should time out cleanly.
114
+ # Test order is randomized and the LISTEN channel is shared:
115
+ # another test's publish can leave an unconsumed NOTIFY queued.
116
+ # Drain first, then assert a clean timeout.
117
+ 5.times do
118
+ break if get("/poll?timeout=50").body == "0"
119
+ end
116
120
  res = get("/poll?timeout=100")
117
121
  assert_equal "0", res.body
118
122
  end
@@ -18,8 +18,10 @@ class TestLiveView < TepTest
18
18
  end
19
19
  def mount(req)
20
20
  # Pull a seed value from the request's params if present;
21
- # otherwise leave at 0.
21
+ # otherwise leave at 0. A missing param reads as nil
22
+ # (sinatra-parity) -- guard before String calls.
22
23
  seed = req.params["seed"]
24
+ seed = "" if seed.nil?
23
25
  if seed.length > 0
24
26
  @count = seed.to_i
25
27
  end
data/test/test_mcp.rb CHANGED
@@ -12,7 +12,9 @@ class TestMCP < TepTest
12
12
  # paths -- we just override req.identity with a synthetic one
13
13
  # so req.identity.may?(:admin) returns true on demand.
14
14
  before do
15
- if req.req_headers["x-test-cap-admin"].length > 0
15
+ cap = req.req_headers["x-test-cap-admin"]
16
+ cap = "" if cap.nil?
17
+ if cap.length > 0
16
18
  req.identity = Tep::Identity.new(
17
19
  "user:42", nil, [:admin])
18
20
  end
data/test/test_pg.rb CHANGED
@@ -268,10 +268,22 @@ class TestPg < TepTest
268
268
  r.clear
269
269
  out = "raised=no"
270
270
  rescue PG::UndefinedTable => e
271
+ # Hierarchy proof via a nested rescue arm instead of
272
+ # `e.is_a?(PG::Error)`: is_a? on a rescued local is
273
+ # unsupported at the current spinel pin (typed-rescue local
274
+ # widens; tracked in the SPINEL_PIN notes). Re-raising e and
275
+ # catching it with the PARENT class proves the subclass
276
+ # relation just as strictly.
277
+ hier = "no"
278
+ begin
279
+ raise e
280
+ rescue PG::Error
281
+ hier = "yes"
282
+ end
271
283
  out = "raised=UndefinedTable" +
272
284
  " sqlstate=" + c.last_sqlstate +
273
285
  " match42P01=" + (c.last_sqlstate == "42P01" ? "yes" : "no") +
274
- " is_pg_error=" + (e.is_a?(PG::Error) ? "yes" : "no")
286
+ " is_pg_error=" + hier
275
287
  end
276
288
  c.close
277
289
  out
@@ -295,9 +307,16 @@ class TestPg < TepTest
295
307
  r2.clear
296
308
  out = "first_ok=yes second_raised=no"
297
309
  rescue PG::UniqueViolation => e
310
+ # Same nested-rescue hierarchy proof as /missing_table.
311
+ hier = "no"
312
+ begin
313
+ raise e
314
+ rescue PG::Error
315
+ hier = "yes"
316
+ end
298
317
  out = "first_ok=yes second_raised=UniqueViolation" +
299
318
  " sqlstate=" + c.last_sqlstate +
300
- " is_pg_error=" + (e.is_a?(PG::Error) ? "yes" : "no")
319
+ " is_pg_error=" + hier
301
320
  end
302
321
  r3 = c.exec_params("DELETE FROM " + TBL + " WHERE id = $1", ["99001"])
303
322
  r3.clear
@@ -30,10 +30,14 @@ class TestRealWorld < TepTest
30
30
  PORT_BASE + 100 + @@port_counter
31
31
  end
32
32
 
33
- def with_app(example_filename)
33
+ # lax: true builds with --lax for fidelity copies of upstream apps
34
+ # that use out-of-contract DSL on purpose (02_lifecycle's `on_stop`)
35
+ # -- the strict default (mirror condition 3) would rightly refuse.
36
+ def with_app(example_filename, lax: false)
34
37
  src = File.join(EXAMPLES_DIR, example_filename)
35
38
  bin = Dir.mktmpdir + "/app"
36
- out = `#{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
39
+ lax_flag = lax ? " --lax" : ""
40
+ out = `#{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build#{lax_flag} #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
37
41
  raise "build failed:\n#{out}" unless $?.success?
38
42
  port = TestRealWorld.next_port
39
43
  pid = Process.spawn(bin, "-p", port.to_s, "-q",
@@ -73,7 +77,8 @@ class TestRealWorld < TepTest
73
77
  # ---- 02: lifecycle ----
74
78
 
75
79
  def test_02_lifecycle_root_renders
76
- with_app("02_lifecycle.rb") do
80
+ # upstream example uses `on_stop` (no tep shutdown path) -> --lax
81
+ with_app("02_lifecycle.rb", lax: true) do
77
82
  res = get("/")
78
83
  assert_equal "200", res.code
79
84
  assert_match(/lifecycle events/, res.body)
@@ -24,7 +24,13 @@ class TestRequestMethods < TepTest
24
24
  end
25
25
 
26
26
  get '/accept-and-ct' do
27
- "accept=" + request.accept + " ct=" + request.content_type
27
+ # Absent headers read as nil through the Rack-parity accessors
28
+ # (sinatra too) -- guard before concatenation.
29
+ a = request.accept
30
+ a = "" if a.nil?
31
+ ct = request.content_type
32
+ ct = "" if ct.nil?
33
+ "accept=" + a + " ct=" + ct
28
34
  end
29
35
  RB
30
36
 
@@ -29,4 +29,51 @@ class TestUnsupported < TepTest
29
29
 
30
30
  # send_file, configure, pass, multiple filters, optional segments
31
31
  # have all moved into supported and have their own test files.
32
+
33
+ # ---- strict-by-default translator (mirror condition 3) ----
34
+ # Out-of-contract constructs must FAIL the build, not silently
35
+ # drop (docs/mirrors/sinatra.md). These assert at translate level,
36
+ # so no spinel compile is needed. `--lax` restores warn-and-continue.
37
+
38
+ def translate_out(source, flags: "")
39
+ tmp = Dir.mktmpdir("tep-strict")
40
+ src = File.join(tmp, "app.rb")
41
+ File.write(src, "require 'sinatra'\n" + source + "\nget('/') { 'ok' }\n")
42
+ out = `#{TepHarness::TEP_BIN} translate #{flags} #{src} 2>&1 >/dev/null`
43
+ [$?.success?, out]
44
+ ensure
45
+ FileUtils.remove_entry(tmp) if tmp
46
+ end
47
+
48
+ def assert_strict_refusal(source, message_fragment)
49
+ ok, out = translate_out(source)
50
+ refute ok, "expected strict translate to fail, but it succeeded"
51
+ assert_match(/error: .*#{Regexp.escape(message_fragment)}/, out)
52
+ assert_match(/--lax/, out, "refusal should mention the --lax escape hatch")
53
+ end
54
+
55
+ def test_strict_rejects_rack_use
56
+ assert_strict_refusal(%(use Rack::Session::Cookie, secret: "x"),
57
+ "unsupported `use`")
58
+ end
59
+
60
+ def test_strict_rejects_helpers_block
61
+ assert_strict_refusal(%(helpers do\n def shout(s); s.upcase; end\nend),
62
+ "unsupported `helpers do ... end`")
63
+ end
64
+
65
+ def test_strict_rejects_unknown_set_key
66
+ assert_strict_refusal(%(set :sessions, true), "unsupported `set :sessions`")
67
+ end
68
+
69
+ def test_strict_rejects_on_stop
70
+ assert_strict_refusal(%(on_stop do\n puts "bye"\nend), "on_stop")
71
+ end
72
+
73
+ def test_lax_downgrades_to_warning
74
+ ok, out = translate_out("use Rack::Head", flags: "--lax")
75
+ assert ok, "expected --lax translate to succeed, got:\n#{out}"
76
+ assert_match(/unsupported `use`/, out)
77
+ refute_match(/error:/, out)
78
+ end
32
79
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tep
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.6
4
+ version: 0.11.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ori Pekelman
@@ -162,6 +162,10 @@ files:
162
162
  - public/hello.txt
163
163
  - public/style.css
164
164
  - spinel-ext.json
165
+ - test/differential/10_semantics.rb
166
+ - test/differential/11_matrix.rb
167
+ - test/differential/Gemfile
168
+ - test/differential/runner.rb
165
169
  - test/helper.rb
166
170
  - test/real_world/01_simple.rb
167
171
  - test/real_world/02_lifecycle.rb
@@ -273,7 +277,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
273
277
  - !ruby/object:Gem::Version
274
278
  version: '0'
275
279
  requirements: []
276
- rubygems_version: 4.0.3
280
+ rubygems_version: 3.6.9
277
281
  specification_version: 4
278
282
  summary: A Sinatra-flavoured web framework that compiles to a native binary via Spinel
279
283
  test_files: []