tina4ruby 3.13.99 → 3.13.101

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: 11d1350ed25f20cfc7c7ba9757417c0eb33e87292eb45876d3bed30119fefcca
4
- data.tar.gz: 8bd0d33d8ae49492bd8784fb838339951ad92439bf96a615d71ffe2e488dfd7a
3
+ metadata.gz: 9102cf347865953d60e7d47b1ead3b87e25f139f1439a19e5f230ec31594c32a
4
+ data.tar.gz: 7a5323f6a9d26e41852269c63d76fac619a59b549e634aad6a56bdc887dcb780
5
5
  SHA512:
6
- metadata.gz: e24bac573c4d4b58642fdc65071857c4a910dac229057f92c9eca2ba0936c1163a6e89bc79452c82f7d4601a4e64b0c10ce5c0af3e35384c16344491c6f61203
7
- data.tar.gz: 0afe568dfd38eee2c0ce7c507b71b8c0110e11be10cce5197100244d30e56ac58e119b25a1e5757df8b26a9bb74172b4739d1e94ef029729e7b3e932e2847032
6
+ metadata.gz: b0d72050d8db6db2ca5a1c621c6212848ef094ab84a3718b32aa06d53912c74c0324973374b1dafc6dd43d64b52dd34cb574a806fe79f034ac8757c0400db1cb
7
+ data.tar.gz: b13a9d5139f751391a684e28858bc894268fbb511ca192615eb48181dda59784faddcebcbdf6ae74aefb08736a53e9e356d432bc0ec4f6b69b50132c75a6e3f3
data/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.101
10
+
11
+ ### Breaking: metrics has one owner
12
+
13
+ - Remove the framework `metrics` command and local quick census. Use the native `tina4 metrics` CLI.
14
+ - Keep dev-admin metrics as a thin `/metrics/full` and `/metrics/file` JSON handoff to that CLI.
15
+
16
+ ### App-facing AI client
17
+
18
+ - Add zero-dependency `Tina4::Ai.chat`, `Tina4::Ai.complete`, and `Tina4::Ai.embed`.
19
+ - Support local/OpenAI-compatible, OpenAI, and Anthropic chat providers.
20
+ - Normalize chat responses, stream ordered deltas, and preserve embedding cardinality.
21
+ - Fail closed on missing hosted-provider keys, verify TLS, redact sensitive failures, and
22
+ distinguish bounded connection and total-request timeouts.
23
+ - Retry only transient connection, HTTP 429, and HTTP 5xx failures, never a partial stream.
24
+
25
+ ## 3.13.100
26
+
27
+ ### Breaking: Frond instance extensions stay local
28
+
29
+ Calling `add_filter`, `add_global`, or `add_test` on a Frond instance now changes
30
+ that renderer only. Register on `Frond` itself when every later instance must
31
+ inherit the extension.
32
+
33
+ - Reject a second `{% extends %}` tag instead of replacing the first parent without warning.
34
+ - Resolve multi-level inheritance without recursing through the same child template.
35
+ - Preserve nested root blocks through a depth-aware final substitution pass.
36
+ - Bound template, fragment, and expression caches, with TTL sweeps for stale entries.
37
+ - Retry transient AI skill-download failures.
38
+ - Activate the tina4-js skill for `tina4js` and `Tina4 JS` spellings as well as `tina4-js`.
39
+ - Keep `Tina4::VERSION` and both AI-facing guide markers on one version.
40
+
41
+ ## 3.13.99
42
+
9
43
  ### Breaking: `request.params` is route-params-only, and `path_params` is renamed `params`
10
44
 
11
45
  Ruby had the worst version of the param-pollution bug: `params` used to merge the query
data/README.md CHANGED
@@ -68,6 +68,18 @@ db = Tina4::Database.new("sqlite://app.db")
68
68
 
69
69
  **2,508 tests. Zero runtime dependencies. Full parity across Python, PHP, Ruby, and Node.js.**
70
70
 
71
+ ### AI Client
72
+
73
+ ```ruby
74
+ reply = Tina4::Ai.chat([{ role: "user", content: "Summarise this text" }])
75
+ text = Tina4::Ai.complete("Give me a title")
76
+ vector = Tina4::Ai.embed("semantic search text")
77
+
78
+ Tina4::Ai.chat([{ role: "user", content: "Stream this" }], stream: true).each { |delta| print delta }
79
+ ```
80
+
81
+ Configure `TINA4_AI_PROVIDER` as `local`, `openai`, or `anthropic`. Hosted providers require `TINA4_AI_KEY`; local OpenAI-compatible endpoints do not.
82
+
71
83
  ---
72
84
 
73
85
  ## CLI Reference
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Tina4
8
+ class AiError < StandardError; end
9
+ class AiConfigError < AiError; end
10
+ class AiTimeoutError < AiError; end
11
+ class AiParseError < AiError; end
12
+
13
+ class AiHTTPError < AiError
14
+ attr_reader :status
15
+
16
+ def initialize(message, status = nil)
17
+ super(message)
18
+ @status = status
19
+ end
20
+ end
21
+
22
+ ChatResponse = Struct.new(:text, :model, :usage, :finish_reason, :raw, keyword_init: true)
23
+
24
+ # Zero-dependency app-facing AI client (ADR-0053).
25
+ class Ai
26
+ PROVIDERS = %w[local openai anthropic].freeze
27
+
28
+ class << self
29
+ def chat(messages, model: nil, temperature: nil, max_tokens: nil, stream: false, timeout: nil, provider: nil)
30
+ validate_messages(messages)
31
+ config = resolve_config("chat", model, timeout, provider)
32
+ body = chat_body(config, messages, temperature, max_tokens, stream)
33
+ return stream_request(config, headers(config), body) if stream
34
+
35
+ normalize_chat(config[:provider], request_json(config, headers(config), body))
36
+ end
37
+
38
+ def complete(prompt, **options)
39
+ raise AiConfigError, "AI prompt must be a string" unless prompt.is_a?(String)
40
+
41
+ options.delete(:stream)
42
+ chat([{ role: "user", content: prompt }], **options, stream: false).text
43
+ end
44
+
45
+ def embed(text_or_texts, model: nil, timeout: nil, provider: nil)
46
+ single = text_or_texts.is_a?(String)
47
+ valid_batch = text_or_texts.is_a?(Array) && !text_or_texts.empty? && text_or_texts.all? { |item| item.is_a?(String) }
48
+ raise AiConfigError, "AI embedding input must be a string or a non-empty list of strings" unless single || valid_batch
49
+
50
+ config = resolve_config("embed", model, timeout, provider)
51
+ raise AiConfigError, "Anthropic does not provide the embedding endpoint in this contract" if config[:provider] == "anthropic"
52
+
53
+ raw = request_json(config, headers(config), { model: config[:model], input: text_or_texts })
54
+ begin
55
+ data = raw.fetch("data").sort_by { |item| item.fetch("index", 0) }
56
+ vectors = data.map { |item| item.fetch("embedding") }
57
+ expected = single ? 1 : text_or_texts.length
58
+ valid = vectors.length == expected && vectors.all? do |vector|
59
+ vector.is_a?(Array) && !vector.empty? && vector.all? { |value| value.is_a?(Numeric) }
60
+ end
61
+ raise KeyError unless valid
62
+ rescue KeyError, TypeError
63
+ raise AiParseError, "AI provider returned a malformed embedding response"
64
+ end
65
+ single ? vectors.first : vectors
66
+ end
67
+
68
+ private
69
+
70
+ def validate_messages(messages)
71
+ valid = messages.is_a?(Array) && !messages.empty? && messages.all? do |message|
72
+ message.is_a?(Hash) && %w[system user assistant].include?((message[:role] || message["role"]).to_s) &&
73
+ (message.key?(:content) ? message[:content] : message["content"]).is_a?(String)
74
+ end
75
+ raise AiConfigError, "AI messages must contain supported roles and string content" unless valid
76
+ end
77
+
78
+ def number(name, default, minimum)
79
+ value = Float(ENV.fetch(name, default.to_s))
80
+ raise AiConfigError, "#{name} must be at least #{minimum}" if value < minimum
81
+
82
+ value
83
+ rescue ArgumentError, TypeError
84
+ raise AiConfigError, "#{name} must be numeric"
85
+ end
86
+
87
+ def resolve_config(capability, model, timeout, provider)
88
+ selected = (provider || ENV["TINA4_AI_PROVIDER"] || "local").strip.downcase
89
+ raise AiConfigError, "TINA4_AI_PROVIDER must be local, openai, or anthropic" unless PROVIDERS.include?(selected)
90
+
91
+ key = ENV["TINA4_AI_KEY"]
92
+ if %w[openai anthropic].include?(selected) && (key.nil? || key.empty?)
93
+ raise AiConfigError, "TINA4_AI_KEY is required for the #{selected} provider"
94
+ end
95
+ defaults = {
96
+ "local" => ["http://localhost:11437", "llama3.2"],
97
+ "openai" => ["https://api.openai.com/v1", "gpt-4o-mini"],
98
+ "anthropic" => ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"]
99
+ }
100
+ value = capability == "embed" && ENV["TINA4_EMBED_URL"] ? ENV["TINA4_EMBED_URL"] : (ENV["TINA4_AI_URL"] || defaults[selected][0])
101
+ total = timeout.nil? ? number("TINA4_AI_TIMEOUT", 60, 0.001) : Float(timeout)
102
+ raise AiConfigError, "AI timeout must be greater than zero" unless total.positive?
103
+
104
+ chosen_model = (model || ENV["TINA4_AI_MODEL"] || defaults[selected][1]).to_s.strip
105
+ raise AiConfigError, "AI model must be a non-empty string" if chosen_model.empty?
106
+
107
+ {
108
+ provider: selected,
109
+ url: endpoint(value, capability, selected),
110
+ model: chosen_model,
111
+ key: key,
112
+ total_timeout: total,
113
+ connect_timeout: number("TINA4_AI_CONNECT_TIMEOUT", 10, 0.001),
114
+ max_retries: number("TINA4_AI_MAX_RETRIES", 2, 0).to_i
115
+ }
116
+ rescue ArgumentError, TypeError
117
+ raise AiConfigError, "AI timeout must be numeric"
118
+ end
119
+
120
+ def endpoint(value, capability, provider)
121
+ uri = URI.parse(value)
122
+ raise AiConfigError, "AI URL must be an http or https URL" unless %w[http https].include?(uri.scheme) && uri.host
123
+
124
+ path = uri.path.to_s.sub(%r{/+$}, "")
125
+ if ["", "/v1", "/api"].include?(path)
126
+ suffix = provider == "anthropic" ? "/messages" : (capability == "embed" ? "/embeddings" : "/chat/completions")
127
+ uri.path = (path.empty? ? "/v1" : path) + suffix
128
+ end
129
+ uri.to_s
130
+ rescue URI::InvalidURIError
131
+ raise AiConfigError, "AI URL must be an http or https URL"
132
+ end
133
+
134
+ def headers(config)
135
+ result = { "Content-Type" => "application/json", "Accept" => "application/json" }
136
+ if config[:provider] == "openai"
137
+ result["Authorization"] = "Bearer #{config[:key]}"
138
+ elsif config[:provider] == "anthropic"
139
+ result["x-api-key"] = config[:key]
140
+ result["anthropic-version"] = "2023-06-01"
141
+ end
142
+ result
143
+ end
144
+
145
+ def chat_body(config, messages, temperature, max_tokens, stream)
146
+ normalized = messages.map { |message| { role: (message[:role] || message["role"]).to_s, content: message.key?(:content) ? message[:content] : message["content"] } }
147
+ body = { model: config[:model], messages: normalized, stream: stream }
148
+ body[:temperature] = temperature unless temperature.nil?
149
+ body[:max_tokens] = max_tokens unless max_tokens.nil?
150
+ if config[:provider] == "anthropic"
151
+ system = normalized.select { |message| message[:role] == "system" }.map { |message| message[:content] }
152
+ body[:messages] = normalized.reject { |message| message[:role] == "system" }
153
+ body[:max_tokens] = max_tokens || 1024
154
+ body[:system] = system.join("\n\n") unless system.empty?
155
+ end
156
+ body
157
+ end
158
+
159
+ def http_request(config, deadline, request_headers, body)
160
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
+ raise AiTimeoutError, "AI total request timeout expired" unless remaining.positive?
162
+
163
+ uri = URI.parse(config[:url])
164
+ http = Net::HTTP.new(uri.host, uri.port)
165
+ http.use_ssl = uri.scheme == "https"
166
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
167
+ http.open_timeout = [config[:connect_timeout], remaining].min
168
+ http.read_timeout = remaining
169
+ http.write_timeout = remaining if http.respond_to?(:write_timeout=)
170
+ request = Net::HTTP::Post.new(uri.request_uri, request_headers)
171
+ request.body = JSON.generate(body)
172
+ [http, request]
173
+ end
174
+
175
+ def request_json(config, request_headers, body)
176
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:total_timeout]
177
+ (config[:max_retries] + 1).times do |attempt|
178
+ begin
179
+ http, request = http_request(config, deadline, request_headers, body)
180
+ response = http.request(request)
181
+ raise AiTimeoutError, "AI total request timeout expired" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
182
+
183
+ status = response.code.to_i
184
+ unless status.between?(200, 299)
185
+ if (status == 429 || status >= 500) && attempt < config[:max_retries]
186
+ retry_delay(response, deadline)
187
+ next
188
+ end
189
+ raise AiHTTPError.new("AI provider returned HTTP #{status}", status)
190
+ end
191
+ parsed = JSON.parse(response.body)
192
+ raise AiParseError, "AI provider returned a non-object JSON response" unless parsed.is_a?(Hash)
193
+
194
+ return parsed
195
+ rescue Net::OpenTimeout
196
+ raise AiTimeoutError, "AI connection timeout expired" if attempt >= config[:max_retries]
197
+ rescue Net::ReadTimeout, Timeout::Error
198
+ raise AiTimeoutError, "AI total request timeout expired" if attempt >= config[:max_retries]
199
+ rescue AiHTTPError => e
200
+ raise if e.status || attempt >= config[:max_retries]
201
+ rescue SocketError, EOFError, IOError, SystemCallError => e
202
+ raise AiHTTPError, "AI transport failed (#{e.class.name})" if attempt >= config[:max_retries]
203
+ rescue JSON::ParserError
204
+ raise AiParseError, "AI provider returned malformed JSON"
205
+ end
206
+ end
207
+ raise AiHTTPError, "AI request failed"
208
+ end
209
+
210
+ def retry_delay(response, deadline)
211
+ requested = Float(response["retry-after"] || 0.1) rescue 0.1
212
+ delay = [requested.positive? ? requested : 0, deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)].min
213
+ sleep(delay) if delay.positive?
214
+ end
215
+
216
+ def normalize_chat(provider, raw)
217
+ if provider == "anthropic"
218
+ parts = raw.fetch("content").select { |item| item.fetch("type", "text") == "text" }.map { |item| item.fetch("text") }
219
+ raise KeyError if parts.empty?
220
+ prompt = raw.fetch("usage", {}).fetch("input_tokens", 0).to_i
221
+ completion = raw.fetch("usage", {}).fetch("output_tokens", 0).to_i
222
+ return ChatResponse.new(text: parts.join, model: raw.fetch("model", "").to_s,
223
+ usage: { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion },
224
+ finish_reason: raw["stop_reason"], raw: raw)
225
+ end
226
+ choice = raw.fetch("choices").fetch(0)
227
+ text = choice.fetch("message").fetch("content")
228
+ raise TypeError unless text.is_a?(String)
229
+ usage = raw.fetch("usage", {})
230
+ ChatResponse.new(text: text, model: raw.fetch("model", "").to_s,
231
+ usage: { prompt_tokens: usage.fetch("prompt_tokens", 0).to_i,
232
+ completion_tokens: usage.fetch("completion_tokens", 0).to_i,
233
+ total_tokens: usage.fetch("total_tokens", 0).to_i },
234
+ finish_reason: choice["finish_reason"], raw: raw)
235
+ rescue KeyError, IndexError, TypeError
236
+ raise AiParseError, "AI provider returned a malformed chat response"
237
+ end
238
+
239
+ def stream_delta(provider, data)
240
+ return [true, nil] if data == "[DONE]"
241
+
242
+ event = JSON.parse(data)
243
+ text = if provider == "anthropic"
244
+ event["type"] == "content_block_delta" ? event.dig("delta", "text") : nil
245
+ else
246
+ event.dig("choices", 0, "delta", "content")
247
+ end
248
+ raise AiParseError, "AI provider returned malformed stream data" unless text.nil? || text.is_a?(String)
249
+
250
+ [false, text]
251
+ rescue JSON::ParserError
252
+ raise AiParseError, "AI provider returned malformed stream data"
253
+ end
254
+
255
+ def each_stream_data(response, deadline)
256
+ buffer = +""
257
+ response.read_body do |chunk|
258
+ raise AiTimeoutError, "AI total request timeout expired" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
259
+ buffer << chunk
260
+ while (index = buffer.index("\n"))
261
+ line = buffer.slice!(0..index).strip
262
+ yield line.delete_prefix("data:").strip if line.start_with?("data:")
263
+ end
264
+ end
265
+ end
266
+
267
+ def stream_request(config, request_headers, body)
268
+ Enumerator.new do |yielder|
269
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:total_timeout]
270
+ yielded = false
271
+ (config[:max_retries] + 1).times do |attempt|
272
+ begin
273
+ http, request = http_request(config, deadline, request_headers.merge("Accept" => "text/event-stream"), body)
274
+ retry_response = false
275
+ completed = false
276
+ http.request(request) do |response|
277
+ status = response.code.to_i
278
+ unless status.between?(200, 299)
279
+ response.read_body { |_chunk| nil }
280
+ if (status == 429 || status >= 500) && attempt < config[:max_retries]
281
+ retry_delay(response, deadline)
282
+ retry_response = true
283
+ next
284
+ end
285
+ raise AiHTTPError.new("AI provider returned HTTP #{status}", status)
286
+ end
287
+ each_stream_data(response, deadline) do |data|
288
+ completed, text = stream_delta(config[:provider], data)
289
+ break if completed
290
+ next if text.nil?
291
+ yielded = true
292
+ yielder << text
293
+ end
294
+ end
295
+ next if retry_response
296
+ raise AiParseError, "AI provider stream ended before [DONE]" unless completed
297
+ break
298
+ rescue Net::OpenTimeout
299
+ raise AiTimeoutError, "AI connection timeout expired" if yielded || attempt >= config[:max_retries]
300
+ rescue Net::ReadTimeout, Timeout::Error
301
+ raise AiTimeoutError, "AI total request timeout expired" if yielded || attempt >= config[:max_retries]
302
+ rescue SocketError, EOFError, IOError, SystemCallError => e
303
+ raise AiHTTPError, "AI transport failed (#{e.class.name})" if yielded || attempt >= config[:max_retries]
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
data/lib/tina4/cli.rb CHANGED
@@ -71,7 +71,6 @@ module Tina4
71
71
  "console" => { handler: :cmd_console, summary: "Start an interactive console" },
72
72
  "generate" => { handler: :cmd_generate, usage: "<what> <name> [options]", subcommands: GENERATORS.keys, summary: "Generate scaffolding (see Generators below)" },
73
73
  "ai" => { handler: :cmd_ai, usage: "[--all]", summary: "Detect AI tools and install context files" },
74
- "metrics" => { handler: :cmd_metrics, usage: "[--top N] [--json] [--fail-on warn|error] [--path DIR]", summary: "Rank top code-quality offenders" },
75
74
  "commands" => { handler: :cmd_commands, usage: "[--json]", summary: "List available commands (add --json for machine form)" },
76
75
  "help" => { handler: :cmd_help, summary: "Show this help message" },
77
76
  }.freeze
@@ -1060,104 +1059,6 @@ module Tina4
1060
1059
  end
1061
1060
  end
1062
1061
 
1063
- # ── metrics ───────────────────────────────────────────────────────────
1064
-
1065
- # Report top code-quality offenders (complexity, size, maintainability,
1066
- # tests). Mirrors the Python-master `tina4python metrics` command.
1067
- #
1068
- # tina4ruby metrics # human report, scans src/ (or framework)
1069
- # tina4ruby metrics --top 10 # only the worst 10
1070
- # tina4ruby metrics --path lib # scan a specific directory
1071
- # tina4ruby metrics --json # machine-readable for CI
1072
- # tina4ruby metrics --fail-on warn # exit 1 if any warn/error offender
1073
- # tina4ruby metrics --fail-on error # exit 1 only on error-severity
1074
- def cmd_metrics(argv)
1075
- require "json"
1076
- require "set"
1077
- require_relative "metrics"
1078
-
1079
- flags, _positional = parse_flags(argv)
1080
-
1081
- top = (flags["top"].to_s =~ /\A\d+\z/) ? flags["top"].to_i : 20
1082
- as_json = flags.key?("json")
1083
- path = flags["path"].is_a?(String) ? flags["path"] : "src"
1084
- fail_on = flags["fail-on"].is_a?(String) ? flags["fail-on"] : nil
1085
-
1086
- unless [nil, "warn", "error"].include?(fail_on)
1087
- puts " invalid --fail-on '#{fail_on}' (use warn or error)"
1088
- exit 2
1089
- end
1090
-
1091
- # ONE engine run. Ask for every offender and slice for display: the gate
1092
- # must read the FULL set, not the printed top-N, and the old second call
1093
- # re-ran the whole analysis (its "full_analysis is cached" comment stopped
1094
- # being true when the in-process analyzer and its cache were deleted).
1095
- # An Integer, not Float::INFINITY -- Array#first demands an Integer.
1096
- every_offender = 2**31
1097
- begin
1098
- result = Tina4::Metrics.offenders(path, every_offender)
1099
- rescue Tina4::MetricsEngineError => e
1100
- warn " metrics error: #{e.message}"
1101
- exit 2
1102
- end
1103
- summary = result["summary"]
1104
- all_offenders = result["offenders"]
1105
- found = all_offenders.first(top)
1106
-
1107
- severities = all_offenders.map { |o| o["severity"] }.to_set
1108
- exit_code = 0
1109
- if fail_on == "warn" && !(severities & %w[warn error]).empty?
1110
- exit_code = 1
1111
- elsif fail_on == "error" && severities.include?("error")
1112
- exit_code = 1
1113
- end
1114
-
1115
- if as_json
1116
- puts JSON.pretty_generate({ "summary" => summary, "offenders" => found })
1117
- exit exit_code
1118
- end
1119
-
1120
- # ── Human report ──────────────────────────────────────────────────
1121
- use_color = $stdout.tty?
1122
- colorize = lambda do |text, code|
1123
- use_color ? "\e[#{code}m#{text}\e[0m" : text
1124
- end
1125
- sev_color = { "error" => "31", "warn" => "33", "info" => "2" } # red / yellow / dim
1126
-
1127
- puts
1128
- puts " Tina4 Metrics — #{summary['scan_mode']} scan (#{summary['scan_root']})"
1129
- puts " files: #{summary['files_analyzed']} " \
1130
- "functions: #{summary['total_functions']} " \
1131
- "avg complexity: #{summary['avg_complexity']} " \
1132
- "avg maintainability: #{summary['avg_maintainability']}"
1133
- showing = found.empty? ? "" : " (showing top #{found.length})"
1134
- puts " offenders: #{summary['total_offenders']} total#{showing}"
1135
- puts
1136
-
1137
- if found.empty?
1138
- puts " " + colorize.call("✓ no offenders — clean", "32")
1139
- puts
1140
- exit exit_code
1141
- end
1142
-
1143
- # Compute column widths so the table lines up.
1144
- locs = found.map { |o| "#{o['file']}:#{o['line']}" }
1145
- loc_w = [("FILE:LINE".length)].concat(locs.map(&:length)).max
1146
- kind_w = [("KIND".length)].concat(found.map { |o| o["kind"].length }).max
1147
-
1148
- header = format(" %3s %-8s %-#{kind_w}s %-#{loc_w}s DETAIL", "#", "SEVERITY", "KIND", "FILE:LINE")
1149
- puts colorize.call(header, "1")
1150
- puts " " + ("-" * (header.length - 2))
1151
- found.each_with_index do |o, i|
1152
- sev = o["severity"]
1153
- sev_cell = colorize.call(format("%-8s", sev), sev_color[sev])
1154
- puts format(" %3d %s %-#{kind_w}s %-#{loc_w}s %s",
1155
- i + 1, sev_cell, o["kind"], locs[i], o["detail"])
1156
- end
1157
- puts
1158
- exit exit_code
1159
- end
1160
-
1161
1062
  # ── generate ────────────────────────────────────────────────────────
1162
1063
 
1163
1064
  def cmd_generate(argv)
@@ -2939,13 +2840,6 @@ module Tina4
2939
2840
  "shaped ones emit working code. Writes are secure by default; use --public",
2940
2841
  "to open them.",
2941
2842
  "",
2942
- "Metrics:",
2943
- " metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]",
2944
- " --top N Show only the worst N offenders (default: 20)",
2945
- " --json Print machine-readable JSON ({summary, offenders}) for CI",
2946
- " --fail-on Exit 1 if any offender at/above this severity (warn|error)",
2947
- " --path DIR Scan DIR (default: src/, auto-resolves to the framework)",
2948
- "",
2949
2843
  "Field types: string, int, float, bool, text, datetime, blob",
2950
2844
  "Table names: singular by default (Product -> product)",
2951
2845
  "",
@@ -632,8 +632,6 @@ module Tina4
632
632
  json_response(gallery_deploy(name))
633
633
  when ["GET", "/__dev/api/version-check"]
634
634
  json_response(version_check_payload)
635
- when ["GET", "/__dev/api/metrics"]
636
- json_response(Tina4::Metrics.quick_metrics)
637
635
  when ["GET", "/__dev/api/metrics/full"]
638
636
  # No fallback (ADR-0002). A missing or stale CLI is a 503 naming the
639
637
  # install command, never zeros that read as a healthy codebase.