tina4ruby 3.13.37 → 3.13.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +110 -2
- data/lib/tina4/database.rb +407 -52
- data/lib/tina4/dev_admin.rb +47 -14
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +35 -8
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +351 -73
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +113 -24
- data/lib/tina4/orm.rb +196 -32
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +22 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +458 -21
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +91 -12
- metadata +6 -47
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62dc42240a6abd35572f17207f42c631ebf2c36cc73b94adad4b163b3a2924a5
|
|
4
|
+
data.tar.gz: e9ea35e0aad4bee25bc42f7ee7a37973144265ed538b5e6127e5a546278a62cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58cf9b41857e5905b5eeda37a6881f812aad230a12533051d4510724222453f344ae1a0e05120e915a69450a05788dad7468eb1b99925e44a5bd8a625dca4c59
|
|
7
|
+
data.tar.gz: 8ff5447f091aafe2ff0a06c7e23a8f7838190545ec96f99bc2329753ee0a9ed0714cc1a7293d127805efe504552293fbb6f08e3fa7651e8423998cc8939869f5
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img src="https://tina4.com/logo.svg" alt="Tina4" width="200">
|
|
3
3
|
</p>
|
|
4
4
|
<h1 align="center">Tina4 Ruby</h1>
|
|
5
|
-
<h3 align="center">TINA4
|
|
5
|
+
<h3 align="center">TINA4: The Intelligent Native Application 4ramework</h3>
|
|
6
6
|
<p align="center"><em>Simple. Fast. Human. | Built for AI. Built for you.</em></p>
|
|
7
7
|
<p align="center">55 built-in features. Zero runtime dependencies. One require, everything works.</p>
|
|
8
8
|
<p align="center">
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
## Quick Start
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
# With the Tina4 CLI (recommended
|
|
21
|
+
# With the Tina4 CLI (recommended, enables SCSS + live reload)
|
|
22
22
|
cargo install tina4 # or grab a binary from https://github.com/tina4stack/tina4/releases
|
|
23
23
|
tina4 init ruby ./my-app
|
|
24
24
|
cd my-app && tina4 serve
|
|
@@ -56,12 +56,12 @@ db = Tina4::Database.new("sqlite://app.db")
|
|
|
56
56
|
| Category | Features |
|
|
57
57
|
|----------|----------|
|
|
58
58
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
59
|
-
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird
|
|
59
|
+
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
60
60
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
61
61
|
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
62
62
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8× faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
63
63
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
64
|
-
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters
|
|
64
|
+
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
65
65
|
| **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
|
|
66
66
|
| **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
|
|
67
67
|
| **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
|
|
@@ -84,14 +84,14 @@ tina4ruby generate model <name>
|
|
|
84
84
|
|
|
85
85
|
## Performance
|
|
86
86
|
|
|
87
|
-
Benchmarked with `wrk
|
|
87
|
+
Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
88
88
|
|
|
89
89
|
| Framework | JSON req/s | Deps | Features |
|
|
90
90
|
|-----------|-----------|------|----------|
|
|
91
91
|
| **Tina4 Ruby** | **10,243** | 0 | 55 |
|
|
92
92
|
| Sinatra | 9,548 | 5+ | ~4 |
|
|
93
93
|
|
|
94
|
-
Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4
|
|
94
|
+
Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4**, with zero runtime dependencies.
|
|
95
95
|
|
|
96
96
|
**Across all 4 Tina4 implementations:**
|
|
97
97
|
|
|
@@ -105,7 +105,7 @@ Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4** — with z
|
|
|
105
105
|
|
|
106
106
|
## Cross-Framework Parity
|
|
107
107
|
|
|
108
|
-
Tina4 ships identical features across four languages
|
|
108
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
|
|
109
109
|
|
|
110
110
|
| | Python | PHP | Ruby | Node.js |
|
|
111
111
|
|---|--------|-----|------|---------|
|
data/lib/tina4/api.rb
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "net/http"
|
|
3
|
+
require "openssl"
|
|
3
4
|
require "uri"
|
|
4
5
|
require "json"
|
|
5
6
|
require "base64"
|
|
6
7
|
|
|
7
8
|
module Tina4
|
|
9
|
+
# Statuses that warrant an automatic retry when max_retries > 0: rate-limit
|
|
10
|
+
# (429) plus the transient server-side 5xx family. 4xx client errors (401,
|
|
11
|
+
# 404, …) are NOT retried — a repeat won't succeed. Parity with the Python
|
|
12
|
+
# master's _RETRY_STATUSES.
|
|
13
|
+
API_RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
14
|
+
|
|
8
15
|
class API
|
|
9
16
|
attr_reader :base_url, :headers
|
|
10
17
|
|
|
@@ -18,9 +25,16 @@ module Tina4
|
|
|
18
25
|
# api = Tina4::API.new("https://self-signed.local", verify_ssl: false)
|
|
19
26
|
#
|
|
20
27
|
# Bearer wins over basic-auth when both are passed.
|
|
28
|
+
#
|
|
29
|
+
# 3.13.39: +max_retries / +retry_backoff enable opt-in automatic retry with
|
|
30
|
+
# exponential backoff (default max_retries: 0 = off, non-breaking) on a
|
|
31
|
+
# transport error (APIResponse#status == 0) or a retryable status
|
|
32
|
+
# (429/5xx). A retried non-idempotent request (POST/PUT/PATCH/DELETE) may be
|
|
33
|
+
# re-sent — retries are opt-in for exactly that reason. Parity with the
|
|
34
|
+
# Python master.
|
|
21
35
|
def initialize(base_url, headers: {}, timeout: 30,
|
|
22
36
|
bearer_token: nil, username: nil, password: nil,
|
|
23
|
-
verify_ssl: nil)
|
|
37
|
+
verify_ssl: nil, max_retries: 0, retry_backoff: 0.5)
|
|
24
38
|
@base_url = base_url.chomp("/")
|
|
25
39
|
@headers = {
|
|
26
40
|
"Content-Type" => "application/json",
|
|
@@ -28,6 +42,8 @@ module Tina4
|
|
|
28
42
|
}.merge(headers)
|
|
29
43
|
@timeout = timeout
|
|
30
44
|
@verify_ssl = verify_ssl
|
|
45
|
+
@max_retries = [0, max_retries.to_i].max
|
|
46
|
+
@retry_backoff = retry_backoff.to_f
|
|
31
47
|
|
|
32
48
|
# Bearer wins over basic-auth when both passed
|
|
33
49
|
if bearer_token
|
|
@@ -142,9 +158,35 @@ module Tina4
|
|
|
142
158
|
end
|
|
143
159
|
end
|
|
144
160
|
|
|
161
|
+
# Execute the request with opt-in retry/backoff. Returns an APIResponse.
|
|
162
|
+
#
|
|
163
|
+
# With @max_retries > 0, a transport error (APIResponse#status == 0, the
|
|
164
|
+
# existing error sentinel) or a retryable status (429/5xx) is retried up to
|
|
165
|
+
# @max_retries times with exponential backoff (@retry_backoff seconds base,
|
|
166
|
+
# doubling each attempt); any other outcome (2xx, 3xx, other 4xx) returns at
|
|
167
|
+
# once. Parity with the Python master's _request.
|
|
145
168
|
def execute(uri, request)
|
|
169
|
+
attempts = @max_retries + 1
|
|
170
|
+
response = nil
|
|
171
|
+
(0...attempts).each do |attempt|
|
|
172
|
+
response = attempt_request(uri, request)
|
|
173
|
+
code = response.status
|
|
174
|
+
retryable = code.zero? || API_RETRY_STATUSES.include?(code)
|
|
175
|
+
return response if !retryable || attempt == attempts - 1
|
|
176
|
+
|
|
177
|
+
sleep(@retry_backoff * (2**attempt))
|
|
178
|
+
end
|
|
179
|
+
response
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A single HTTP attempt. Returns the standardized APIResponse.
|
|
183
|
+
def attempt_request(uri, request)
|
|
146
184
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
147
185
|
http.use_ssl = uri.scheme == "https"
|
|
186
|
+
# 3.13.39: honour verify_ssl: false (the dead-since-3.13.1 kwarg). Only
|
|
187
|
+
# disable verification when EXPLICITLY false — nil/true keep the secure
|
|
188
|
+
# default (OpenSSL::SSL::VERIFY_PEER).
|
|
189
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
|
|
148
190
|
http.open_timeout = @timeout
|
|
149
191
|
http.read_timeout = @timeout
|
|
150
192
|
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -3,11 +3,20 @@ require "openssl"
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "json"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
|
|
7
8
|
module Tina4
|
|
8
9
|
module Auth
|
|
9
10
|
KEYS_DIR = ".keys"
|
|
10
11
|
|
|
12
|
+
# Single source of truth for the blank-secret warning, emitted identically
|
|
13
|
+
# from both the CI/prod boot path (ensure_dev_secret) and the lazy
|
|
14
|
+
# per-call resolver (hmac_secret). Actionable: names exactly what to set.
|
|
15
|
+
BLANK_SECRET_WARNING =
|
|
16
|
+
"Auth: TINA4_SECRET is not set — JWT signing is insecure. Set TINA4_SECRET " \
|
|
17
|
+
"to a random value (e.g. `openssl rand -hex 32`) in your environment or " \
|
|
18
|
+
".env before serving traffic."
|
|
19
|
+
|
|
11
20
|
class << self
|
|
12
21
|
def setup(root_dir = Dir.pwd)
|
|
13
22
|
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
@@ -15,6 +24,54 @@ module Tina4
|
|
|
15
24
|
ensure_keys
|
|
16
25
|
end
|
|
17
26
|
|
|
27
|
+
# Boot-time bootstrap (run once after env load, before auth is used).
|
|
28
|
+
#
|
|
29
|
+
# Mirrors the Python master's tina4_python.auth.ensure_dev_secret:
|
|
30
|
+
# - If TINA4_SECRET is already set → no-op (returns nil).
|
|
31
|
+
# - Else if NOT dev, OR CI, OR production → emit the actionable
|
|
32
|
+
# blank-secret warning and return nil. NEVER generates or persists a
|
|
33
|
+
# secret in CI or production. (Hard security constraint.)
|
|
34
|
+
# - Else (dev, not CI, not prod, blank secret) → mint a 32-byte
|
|
35
|
+
# (64 hex char) random secret, set it in the process env immediately,
|
|
36
|
+
# then APPEND it to <root_dir>/.env.local (gitignored, created if
|
|
37
|
+
# missing). On ANY write failure keep the in-memory secret and warn —
|
|
38
|
+
# never raise (boot must not crash).
|
|
39
|
+
#
|
|
40
|
+
# `root_dir` exists only so tests can target a temp dir without chdir;
|
|
41
|
+
# production callers pass nothing (defaults to Dir.pwd).
|
|
42
|
+
#
|
|
43
|
+
# Returns the generated secret (String) when it mints one, else nil.
|
|
44
|
+
def ensure_dev_secret(root_dir = Dir.pwd)
|
|
45
|
+
existing = ENV["TINA4_SECRET"]
|
|
46
|
+
return nil if existing && !existing.empty?
|
|
47
|
+
|
|
48
|
+
unless dev? && !ci? && !production?
|
|
49
|
+
warn_blank_secret
|
|
50
|
+
return nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
new_secret = SecureRandom.hex(32) # 32 bytes -> 64 hex chars
|
|
54
|
+
ENV["TINA4_SECRET"] = new_secret # available for this run immediately
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
local_path = File.join(root_dir, ".env.local")
|
|
58
|
+
# If the file exists and does not end in a newline, prepend one so the
|
|
59
|
+
# new key lands on its own line rather than gluing onto the last value.
|
|
60
|
+
prefix = ""
|
|
61
|
+
if File.exist?(local_path)
|
|
62
|
+
content = File.read(local_path)
|
|
63
|
+
prefix = "\n" if !content.empty? && !content.end_with?("\n")
|
|
64
|
+
end
|
|
65
|
+
File.open(local_path, "a") { |f| f.write("#{prefix}TINA4_SECRET=#{new_secret}\n") }
|
|
66
|
+
log_info("Auth: generated a development secret, saved to .env.local (gitignored)")
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
# Keep the in-memory secret for this run; just warn. Never crash boot.
|
|
69
|
+
log_warning("Auth: generated a development secret but could not write .env.local (#{e.message}); using it for this run only")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
new_secret
|
|
73
|
+
end
|
|
74
|
+
|
|
18
75
|
# ── HS256 helpers (stdlib only, no gem) ──────────────────────
|
|
19
76
|
|
|
20
77
|
# Returns true when SECRET env var is set and no RSA keys exist in .keys/
|
|
@@ -28,8 +85,13 @@ module Tina4
|
|
|
28
85
|
File.exist?(File.join(@keys_dir, "public.pem")))
|
|
29
86
|
end
|
|
30
87
|
|
|
88
|
+
# Lazy per-call secret resolver. When the secret is blank, emit the
|
|
89
|
+
# actionable blank-secret warning (the same text the CI/prod bootstrap
|
|
90
|
+
# path uses) before returning. Parity with Python's _resolve_secret.
|
|
31
91
|
def hmac_secret
|
|
32
|
-
ENV["TINA4_SECRET"]
|
|
92
|
+
secret = ENV["TINA4_SECRET"]
|
|
93
|
+
warn_blank_secret if secret.nil? || secret.empty?
|
|
94
|
+
secret
|
|
33
95
|
end
|
|
34
96
|
|
|
35
97
|
# Base64url-encode without padding (JWT spec)
|
|
@@ -196,9 +258,12 @@ module Tina4
|
|
|
196
258
|
|
|
197
259
|
token = Regexp.last_match(1)
|
|
198
260
|
|
|
199
|
-
# API_KEY bypass —
|
|
200
|
-
|
|
201
|
-
|
|
261
|
+
# API_KEY bypass — timing-safe comparison via validate_api_key
|
|
262
|
+
# (OpenSSL.fixed_length_secure_compare). Parity with Python's
|
|
263
|
+
# authenticate_request (validate_api_key), PHP (hash_equals) and
|
|
264
|
+
# Node (timingSafeEqual). Never use a plain `==` here — that leaks the
|
|
265
|
+
# key length/prefix through comparison timing.
|
|
266
|
+
if validate_api_key(token)
|
|
202
267
|
return { "api_key" => true }
|
|
203
268
|
end
|
|
204
269
|
|
|
@@ -235,9 +300,12 @@ module Tina4
|
|
|
235
300
|
|
|
236
301
|
token = Regexp.last_match(1)
|
|
237
302
|
|
|
238
|
-
# API_KEY bypass —
|
|
239
|
-
|
|
240
|
-
|
|
303
|
+
# API_KEY bypass — timing-safe comparison via validate_api_key
|
|
304
|
+
# (OpenSSL.fixed_length_secure_compare). Parity with Python's
|
|
305
|
+
# authenticate_request (validate_api_key), PHP (hash_equals) and
|
|
306
|
+
# Node (timingSafeEqual). Never use a plain `==` here — that leaks the
|
|
307
|
+
# key length/prefix through comparison timing.
|
|
308
|
+
if validate_api_key(token)
|
|
241
309
|
env["tina4.auth"] = { "api_key" => true }
|
|
242
310
|
return true
|
|
243
311
|
end
|
|
@@ -271,6 +339,49 @@ module Tina4
|
|
|
271
339
|
|
|
272
340
|
private
|
|
273
341
|
|
|
342
|
+
# ── Dev-secret bootstrap helpers (parity with Python master) ──
|
|
343
|
+
|
|
344
|
+
# Dev when the framework debug flag is truthy (TINA4_DEBUG).
|
|
345
|
+
def dev?
|
|
346
|
+
Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# CI when the de-facto CI env var is truthy.
|
|
350
|
+
def ci?
|
|
351
|
+
Tina4::Env.is_truthy(ENV["CI"])
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Production when TINA4_ENV (default "development") is "production".
|
|
355
|
+
def production?
|
|
356
|
+
(ENV["TINA4_ENV"] || "development").to_s.strip.downcase == "production"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Emit the single actionable blank-secret warning. Same text from the
|
|
360
|
+
# CI/prod bootstrap path and the lazy per-call resolver.
|
|
361
|
+
def warn_blank_secret
|
|
362
|
+
log_warning(BLANK_SECRET_WARNING)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def log_info(message)
|
|
366
|
+
if defined?(Tina4::Log)
|
|
367
|
+
Tina4::Log.info(message)
|
|
368
|
+
else
|
|
369
|
+
warn(message)
|
|
370
|
+
end
|
|
371
|
+
rescue StandardError
|
|
372
|
+
warn(message)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def log_warning(message)
|
|
376
|
+
if defined?(Tina4::Log)
|
|
377
|
+
Tina4::Log.warning(message)
|
|
378
|
+
else
|
|
379
|
+
warn(message)
|
|
380
|
+
end
|
|
381
|
+
rescue StandardError
|
|
382
|
+
warn(message)
|
|
383
|
+
end
|
|
384
|
+
|
|
274
385
|
def ensure_keys
|
|
275
386
|
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
276
387
|
FileUtils.mkdir_p(@keys_dir)
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "fileutils"
|
|
|
5
5
|
|
|
6
6
|
module Tina4
|
|
7
7
|
class CLI
|
|
8
|
-
COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
|
|
8
|
+
COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai metrics help].freeze
|
|
9
9
|
|
|
10
10
|
# ── Field type mapping ──────────────────────────────────────────────
|
|
11
11
|
FIELD_TYPE_MAP = {
|
|
@@ -43,6 +43,7 @@ module Tina4
|
|
|
43
43
|
when "console" then cmd_console
|
|
44
44
|
when "generate" then cmd_generate(argv)
|
|
45
45
|
when "ai" then cmd_ai(argv)
|
|
46
|
+
when "metrics" then cmd_metrics(argv)
|
|
46
47
|
when "help", "-h", "--help" then cmd_help
|
|
47
48
|
else
|
|
48
49
|
puts "Unknown command: #{command}"
|
|
@@ -85,7 +86,7 @@ module Tina4
|
|
|
85
86
|
# Parse --key value and --flag from args. Returns [flags_hash, positional_array]
|
|
86
87
|
def parse_flags(args)
|
|
87
88
|
# Boolean-only flags that never take a value argument
|
|
88
|
-
boolean_flags = %w[no-browser no-reload production managed all clear dev]
|
|
89
|
+
boolean_flags = %w[no-browser no-reload production managed all clear dev json]
|
|
89
90
|
|
|
90
91
|
flags = {}
|
|
91
92
|
positional = []
|
|
@@ -286,6 +287,10 @@ module Tina4
|
|
|
286
287
|
status_icon = r[:status] == "success" ? "OK" : "FAIL"
|
|
287
288
|
puts " [#{status_icon}] #{r[:name]}"
|
|
288
289
|
end
|
|
290
|
+
# FAIL-FAST: a failed migration must give CI a non-zero exit (parity
|
|
291
|
+
# with the Python master). Only the startup auto-migration hook
|
|
292
|
+
# swallows failures; the explicit CLI does not.
|
|
293
|
+
exit 1 if results.any? { |r| r[:status] == "failed" }
|
|
289
294
|
end
|
|
290
295
|
end
|
|
291
296
|
end
|
|
@@ -488,6 +493,100 @@ module Tina4
|
|
|
488
493
|
end
|
|
489
494
|
end
|
|
490
495
|
|
|
496
|
+
# ── metrics ───────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
# Report top code-quality offenders (complexity, size, maintainability,
|
|
499
|
+
# tests). Mirrors the Python-master `tina4python metrics` command.
|
|
500
|
+
#
|
|
501
|
+
# tina4ruby metrics # human report, scans src/ (or framework)
|
|
502
|
+
# tina4ruby metrics --top 10 # only the worst 10
|
|
503
|
+
# tina4ruby metrics --path lib # scan a specific directory
|
|
504
|
+
# tina4ruby metrics --json # machine-readable for CI
|
|
505
|
+
# tina4ruby metrics --fail-on warn # exit 1 if any warn/error offender
|
|
506
|
+
# tina4ruby metrics --fail-on error # exit 1 only on error-severity
|
|
507
|
+
def cmd_metrics(argv)
|
|
508
|
+
require "json"
|
|
509
|
+
require "set"
|
|
510
|
+
require_relative "metrics"
|
|
511
|
+
|
|
512
|
+
flags, _positional = parse_flags(argv)
|
|
513
|
+
|
|
514
|
+
top = (flags["top"].to_s =~ /\A\d+\z/) ? flags["top"].to_i : 20
|
|
515
|
+
as_json = flags.key?("json")
|
|
516
|
+
path = flags["path"].is_a?(String) ? flags["path"] : "src"
|
|
517
|
+
fail_on = flags["fail-on"].is_a?(String) ? flags["fail-on"] : nil
|
|
518
|
+
|
|
519
|
+
unless [nil, "warn", "error"].include?(fail_on)
|
|
520
|
+
puts " invalid --fail-on '#{fail_on}' (use warn or error)"
|
|
521
|
+
exit 2
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
result = Tina4::Metrics.offenders(path, top)
|
|
525
|
+
summary = result["summary"]
|
|
526
|
+
found = result["offenders"]
|
|
527
|
+
|
|
528
|
+
if summary.key?("error")
|
|
529
|
+
puts " metrics error: #{summary['error']}"
|
|
530
|
+
exit 2
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Decide exit code from the FULL offender set, not just the printed top-N.
|
|
534
|
+
# full_analysis is cached, so this reuses the same analysis.
|
|
535
|
+
all_offenders = Tina4::Metrics.offenders(path, [summary["total_offenders"], 1].max)["offenders"]
|
|
536
|
+
severities = all_offenders.map { |o| o["severity"] }.to_set
|
|
537
|
+
exit_code = 0
|
|
538
|
+
if fail_on == "warn" && !(severities & %w[warn error]).empty?
|
|
539
|
+
exit_code = 1
|
|
540
|
+
elsif fail_on == "error" && severities.include?("error")
|
|
541
|
+
exit_code = 1
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
if as_json
|
|
545
|
+
puts JSON.pretty_generate({ "summary" => summary, "offenders" => found })
|
|
546
|
+
exit exit_code
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# ── Human report ──────────────────────────────────────────────────
|
|
550
|
+
use_color = $stdout.tty?
|
|
551
|
+
colorize = lambda do |text, code|
|
|
552
|
+
use_color ? "\e[#{code}m#{text}\e[0m" : text
|
|
553
|
+
end
|
|
554
|
+
sev_color = { "error" => "31", "warn" => "33", "info" => "2" } # red / yellow / dim
|
|
555
|
+
|
|
556
|
+
puts
|
|
557
|
+
puts " Tina4 Metrics — #{summary['scan_mode']} scan (#{summary['scan_root']})"
|
|
558
|
+
puts " files: #{summary['files_analyzed']} " \
|
|
559
|
+
"functions: #{summary['total_functions']} " \
|
|
560
|
+
"avg complexity: #{summary['avg_complexity']} " \
|
|
561
|
+
"avg maintainability: #{summary['avg_maintainability']}"
|
|
562
|
+
showing = found.empty? ? "" : " (showing top #{found.length})"
|
|
563
|
+
puts " offenders: #{summary['total_offenders']} total#{showing}"
|
|
564
|
+
puts
|
|
565
|
+
|
|
566
|
+
if found.empty?
|
|
567
|
+
puts " " + colorize.call("✓ no offenders — clean", "32")
|
|
568
|
+
puts
|
|
569
|
+
exit exit_code
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Compute column widths so the table lines up.
|
|
573
|
+
locs = found.map { |o| "#{o['file']}:#{o['line']}" }
|
|
574
|
+
loc_w = [("FILE:LINE".length)].concat(locs.map(&:length)).max
|
|
575
|
+
kind_w = [("KIND".length)].concat(found.map { |o| o["kind"].length }).max
|
|
576
|
+
|
|
577
|
+
header = format(" %3s %-8s %-#{kind_w}s %-#{loc_w}s DETAIL", "#", "SEVERITY", "KIND", "FILE:LINE")
|
|
578
|
+
puts colorize.call(header, "1")
|
|
579
|
+
puts " " + ("-" * (header.length - 2))
|
|
580
|
+
found.each_with_index do |o, i|
|
|
581
|
+
sev = o["severity"]
|
|
582
|
+
sev_cell = colorize.call(format("%-8s", sev), sev_color[sev])
|
|
583
|
+
puts format(" %3d %s %-#{kind_w}s %-#{loc_w}s %s",
|
|
584
|
+
i + 1, sev_cell, o["kind"], locs[i], o["detail"])
|
|
585
|
+
end
|
|
586
|
+
puts
|
|
587
|
+
exit exit_code
|
|
588
|
+
end
|
|
589
|
+
|
|
491
590
|
# ── generate ────────────────────────────────────────────────────────
|
|
492
591
|
|
|
493
592
|
def cmd_generate(argv)
|
|
@@ -1225,6 +1324,7 @@ module Tina4
|
|
|
1225
1324
|
routes List all registered routes
|
|
1226
1325
|
console Start an interactive console
|
|
1227
1326
|
ai Detect AI tools and install context files
|
|
1327
|
+
metrics Rank top code-quality offenders
|
|
1228
1328
|
help Show this help message
|
|
1229
1329
|
|
|
1230
1330
|
Generators:
|
|
@@ -1238,6 +1338,13 @@ module Tina4
|
|
|
1238
1338
|
generate view <Name> [--fields "..."] List + detail templates for viewing records
|
|
1239
1339
|
generate auth Login/register/logout routes + User model + templates
|
|
1240
1340
|
|
|
1341
|
+
Metrics:
|
|
1342
|
+
metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]
|
|
1343
|
+
--top N Show only the worst N offenders (default: 20)
|
|
1344
|
+
--json Print machine-readable JSON ({summary, offenders}) for CI
|
|
1345
|
+
--fail-on Exit 1 if any offender at/above this severity (warn|error)
|
|
1346
|
+
--path DIR Scan DIR (default: src/, auto-resolves to the framework)
|
|
1347
|
+
|
|
1241
1348
|
Field types: string, int, float, bool, text, datetime, blob
|
|
1242
1349
|
Table names: singular by default (Product -> product)
|
|
1243
1350
|
|
|
@@ -1346,6 +1453,7 @@ module Tina4
|
|
|
1346
1453
|
unless File.exist?(File.join(dir, ".gitignore"))
|
|
1347
1454
|
File.write(File.join(dir, ".gitignore"), <<~TEXT)
|
|
1348
1455
|
.env
|
|
1456
|
+
.env.local
|
|
1349
1457
|
.keys/
|
|
1350
1458
|
logs/
|
|
1351
1459
|
sessions/
|