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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b75f9ed6546e671904c26e7dc9bb1f29fc83a62a63a7488d05540e12c535f32f
4
- data.tar.gz: f33418ea68192ed529eb2f0b7953e2762ca5eface4c8b4b368b8a93c57344981
3
+ metadata.gz: 62dc42240a6abd35572f17207f42c631ebf2c36cc73b94adad4b163b3a2924a5
4
+ data.tar.gz: e9ea35e0aad4bee25bc42f7ee7a37973144265ed538b5e6127e5a546278a62cf
5
5
  SHA512:
6
- metadata.gz: 413ecc40fcc877a0ea0cfbdca95ef5a3b5da3b3f24035787a9619ecc385e4735e5532eea10c34cbe316727f4544865adea62fcf0233804019815c52f699674fd
7
- data.tar.gz: 84c7bd505379a49527cb75eead709eb8155225a90406ee5691afdfc53127129e013b5c3ede53c7cb35df44dcbdeb3467fdaa91adf6881f159ffe3912476ec65e
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 The Intelligent Native Application 4ramework</h3>
5
+ <h3 align="center">TINA4: The Intelligent Native Application 4ramework</h3>
6
6
  <p align="center"><em>Simple. Fast. Human. &nbsp;|&nbsp; 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 enables SCSS + live reload)
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 unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
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 service runner event system (on/emit/once/off) |
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` 5,000 requests, 50 concurrent, median of 3 runs:
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** with zero runtime dependencies.
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 same architecture, same conventions, same 55 features:
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 — matches tina4_python behavior
200
- api_key = ENV["TINA4_API_KEY"]
201
- if api_key && !api_key.empty? && token == api_key
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 — matches tina4_python behavior
239
- api_key = ENV["TINA4_API_KEY"]
240
- if api_key && !api_key.empty? && token == api_key
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/