tina4ruby 3.13.100 → 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: 850c8661fec2f0bb0ffeaf0c5d7644a6625b59878f5b053aeba49a4fa48cbde7
4
- data.tar.gz: df0cbd73ccc67d39ac6f2900d8d2e6a9875925fbf85dfd092bdf1579e4748f38
3
+ metadata.gz: 9102cf347865953d60e7d47b1ead3b87e25f139f1439a19e5f230ec31594c32a
4
+ data.tar.gz: 7a5323f6a9d26e41852269c63d76fac619a59b549e634aad6a56bdc887dcb780
5
5
  SHA512:
6
- metadata.gz: 39994977bdc2437b6abbfdeb88b0f3727e43cb46a4c189aae216e37202bdcd2e34505e396c60b083df3712c9bab1ac160f8f410b629e00a686dd9e9b48a4589b
7
- data.tar.gz: b4948b71e5ecad37aa7923bc96091d88e243d0f3599e4a3440e94cc38a8452be2d8bb993459487590ef905752424b441376d78983597b94f4ac7059426780eae
6
+ metadata.gz: b0d72050d8db6db2ca5a1c621c6212848ef094ab84a3718b32aa06d53912c74c0324973374b1dafc6dd43d64b52dd34cb574a806fe79f034ac8757c0400db1cb
7
+ data.tar.gz: b13a9d5139f751391a684e28858bc894268fbb511ca192615eb48181dda59784faddcebcbdf6ae74aefb08736a53e9e356d432bc0ec4f6b69b50132c75a6e3f3
data/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ 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
+
9
25
  ## 3.13.100
10
26
 
11
27
  ### Breaking: Frond instance extensions stay local
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.
data/lib/tina4/metrics.rb CHANGED
@@ -1,426 +1,117 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Tina4 Code Metrics — the native engine (ADR-0002) plus an instant file census.
4
- #
5
- # Two-tier analysis:
6
- # 1. Quick metrics (instant): LOC, file counts, class/function counts
7
- # 2. Full analysis (on-demand, cached): cyclomatic complexity, maintainability
8
- # index, coupling, Halstead metrics, offenders
9
- #
10
- # Zero dependencies. The census is pure Ruby; the analysis is `tina4 metrics --json`.
11
-
12
3
  require 'json'
13
- require 'digest'
4
+ require 'open3'
14
5
  require 'pathname'
15
6
 
16
7
  module Tina4
17
- # The native metrics engine could not produce a payload.
18
- #
19
- # Raised instead of falling back to a second implementation: two engines is
20
- # exactly the condition that made the four frameworks' numbers incomparable.
21
8
  class MetricsEngineError < StandardError; end
22
9
 
10
+ # Thin dev-admin adapter for the native `tina4 metrics` engine (ADR-0054).
23
11
  module Metrics
24
- # ── Cache ───────────────────────────────────────────────────
25
- @full_cache_hash = ""
26
- @full_cache_data = nil
27
- @full_cache_time = 0
28
- CACHE_TTL = 60
29
-
30
- # Stores the resolved scan root so file_detail can locate framework files.
31
- @last_scan_root = ""
32
-
33
- # ── Root Resolution ──────────────────────────────────────────
34
-
35
- # Pick the right directory to scan.
36
- #
37
- # If the root dir has Ruby files, scan the user's project code.
38
- # Otherwise, scan the framework itself — so the bubble chart is never empty.
39
- def self._resolve_root(root = 'src')
40
- root_path = Pathname.new(root)
41
- if root_path.directory? && !Dir.glob(root_path.join('**', '*.rb')).empty?
42
- @last_scan_root = File.expand_path(root)
43
- return root
44
- end
45
- # Fallback: scan the framework package itself
46
- fw_dir = File.dirname(__FILE__)
47
- @last_scan_root = fw_dir
48
- fw_dir
49
- end
50
-
51
- def self.last_scan_root
52
- @last_scan_root
53
- end
54
-
55
- # Return [directory to scan, scan_mode] for any metrics producer.
56
- #
57
- # The CLI engine is language-agnostic and cannot know which directory holds a
58
- # framework package, so root resolution and the "framework" label stay here,
59
- # shared by the census and the engine adapter so the two never disagree about
60
- # what was measured.
61
- def self.resolve_scan_target(root = 'src')
62
- resolved = _resolve_root(root)
63
- framework_dir = File.dirname(__FILE__)
64
- resolved_real = File.expand_path(resolved)
65
- scanning_framework = resolved_real == framework_dir || resolved_real.start_with?(framework_dir)
66
- [resolved, scanning_framework ? 'framework' : 'project']
67
- end
68
-
69
- # ── Quick Metrics ───────────────────────────────────────────
70
-
71
- def self.quick_metrics(root = 'src')
72
- # Check if the requested directory exists before falling back
73
- root_path = Pathname.new(root)
74
- return { "error" => "Directory not found: #{root}" } unless root_path.directory?
75
-
76
- root = _resolve_root(root)
77
- root_path = Pathname.new(root)
78
-
79
- rb_files = Dir.glob(root_path.join('**', '*.rb'))
80
- twig_files = Dir.glob(root_path.join('**', '*.twig')) + Dir.glob(root_path.join('**', '*.erb'))
81
-
82
- migrations_path = Pathname.new('migrations')
83
- sql_files = if migrations_path.directory?
84
- Dir.glob(migrations_path.join('**', '*.sql')) + Dir.glob(migrations_path.join('**', '*.rb'))
85
- else
86
- []
87
- end
88
-
89
- scss_files = Dir.glob(root_path.join('**', '*.scss')) + Dir.glob(root_path.join('**', '*.css'))
90
-
91
- total_loc = 0
92
- total_blank = 0
93
- total_comment = 0
94
- total_classes = 0
95
- total_functions = 0
96
- file_details = []
97
-
98
- rb_files.each do |f|
99
- source = begin
100
- File.read(f, encoding: 'utf-8')
101
- rescue StandardError
102
- next
103
- end
104
-
105
- lines = source.lines.map(&:chomp)
106
- loc = 0
107
- blank = 0
108
- comment = 0
109
- in_heredoc = false
110
- heredoc_id = nil
111
- in_block_comment = false
112
-
113
- lines.each do |line|
114
- stripped = line.strip
115
-
116
- if stripped.empty?
117
- blank += 1
118
- next
119
- end
120
-
121
- # =begin/=end block comments
122
- if in_block_comment
123
- comment += 1
124
- in_block_comment = false if stripped.start_with?('=end')
125
- next
126
- end
127
-
128
- if stripped.start_with?('=begin')
129
- comment += 1
130
- in_block_comment = true
131
- next
132
- end
133
-
134
- # Heredoc tracking (simplified)
135
- if in_heredoc
136
- if stripped == heredoc_id
137
- in_heredoc = false
138
- end
139
- loc += 1
140
- next
141
- end
142
-
143
- if stripped.match?(/<<[~-]?['"]?(\w+)['"]?/)
144
- m = stripped.match(/<<[~-]?['"]?(\w+)['"]?/)
145
- heredoc_id = m[1]
146
- in_heredoc = true unless stripped.include?(heredoc_id + stripped[-1].to_s)
147
- loc += 1
148
- next
149
- end
150
-
151
- if stripped.start_with?('#')
152
- comment += 1
153
- next
154
- end
155
-
156
- loc += 1
157
- end
158
-
159
- # Count classes and methods via simple pattern matching
160
- classes = lines.count { |l| l.strip.match?(/\A(class|module)\s+/) }
161
- functions = lines.count { |l| l.strip.match?(/\Adef\s+/) }
162
-
163
- total_loc += loc
164
- total_blank += blank
165
- total_comment += comment
166
- total_classes += classes
167
- total_functions += functions
168
-
169
- rel_path = begin
170
- Pathname.new(f).relative_path_from(root_path).to_s
171
- rescue ArgumentError
172
- f
173
- end
174
-
175
- file_details << {
176
- "path" => rel_path,
177
- "loc" => loc,
178
- "blank" => blank,
179
- "comment" => comment,
180
- "classes" => classes,
181
- "functions" => functions
182
- }
183
- end
184
-
185
- file_details.sort_by! { |d| -d["loc"] }
186
-
187
- # Route and ORM counts
188
- route_count = 0
189
- orm_count = 0
190
- begin
191
- if defined?(Tina4::Router) && Tina4::Router.respond_to?(:routes)
192
- route_count = Tina4::Router.routes.length
193
- elsif defined?(Tina4::Router) && Tina4::Router.instance_variable_defined?(:@routes)
194
- route_count = Tina4::Router.instance_variable_get(:@routes).length
195
- end
196
- rescue StandardError
197
- # ignore
198
- end
199
-
200
- begin
201
- if defined?(Tina4::ORM)
202
- orm_count = ObjectSpace.each_object(Class).count { |c| c < Tina4::ORM }
203
- end
204
- rescue StandardError
205
- # ignore
206
- end
207
-
208
- breakdown = {
209
- "ruby" => rb_files.length,
210
- "templates" => twig_files.length,
211
- "migrations" => sql_files.length,
212
- "stylesheets" => scss_files.length
213
- }
214
-
215
- {
216
- "file_count" => rb_files.length,
217
- "total_loc" => total_loc,
218
- "total_blank" => total_blank,
219
- "total_comment" => total_comment,
220
- "lloc" => total_loc,
221
- "classes" => total_classes,
222
- "functions" => total_functions,
223
- "route_count" => route_count,
224
- "orm_count" => orm_count,
225
- "template_count" => twig_files.length,
226
- "migration_count" => sql_files.length,
227
- "avg_file_size" => rb_files.empty? ? 0 : (total_loc.to_f / rb_files.length).round(1),
228
- "largest_files" => file_details.first(10),
229
- "breakdown" => breakdown
230
- }
231
- end
232
-
233
- # ── Full Analysis (Ripper-based) ────────────────────────────
234
- # ── The native engine (ADR-0002) ─────────────────────────────
235
- #
236
- # The Ripper-based analyzer that used to live below here is gone. Everything
237
- # except the instant file census now comes from `tina4 metrics --json`, so a
238
- # number measured in Ruby is comparable with the same number measured in
239
- # Python, PHP or Node. There is deliberately NO fallback: a second engine is
240
- # exactly the condition that made the four frameworks' numbers incomparable.
241
-
242
- TIMEOUT_SECONDS = 60
243
-
244
- INSTALL_HINT = <<~HINT.strip
245
- the tina4 CLI provides the metrics engine (ADR-0002). Install it with
246
- curl -fsSL https://tina4.com/install.sh | sh
247
- or see https://tina4.com/cli
248
- HINT
249
-
250
- # Fields the dashboard renders. Checking for the DATA is honest where checking
251
- # a version string is not: a user may run any CLI build, and the payload is
252
- # what tells us what that build can actually do.
12
+ INSTALL_HINT = 'update the native tina4 CLI: https://tina4.com/cli'
253
13
  SUMMARY_KEYS = %w[files_analyzed total_functions avg_complexity avg_maintainability].freeze
254
14
  FILE_KEYS = %w[path loc avg_complexity maintainability has_tests].freeze
255
15
  FUNCTION_KEYS = %w[name file line complexity loc].freeze
256
16
 
257
- # Absolute path to the tina4 CLI binary, or nil when it is not installed.
258
- #
259
- # Skips shebang scripts. The engine is a COMPILED Rust binary, but `bundle
260
- # exec` prepends RubyGems' bin directory to PATH, and a gem executable named
261
- # `tina4` sits there as a Ruby shim. Taking the first PATH hit found that
262
- # shim and running it died with "can't find executable tina4 for gem" -- so
263
- # every metrics call failed under the ordinary `bundle exec` workflow. The
264
- # same guard also steps over rbenv/asdf shims and any gem squatting the name.
265
- def self.engine_path
266
- ENV['PATH'].to_s.split(File::PATH_SEPARATOR).each do |dir|
267
- next if dir.empty?
17
+ def self.resolve_target(root = 'src')
18
+ source = Pathname.new(root)
19
+ resolved, mode = if source.directory? && !Dir.glob(source.join('**/*.rb').to_s).empty?
20
+ [source.expand_path, 'project']
21
+ else
22
+ [Pathname.new(__dir__).expand_path, 'framework']
23
+ end
24
+ @last_scan_root = resolved.to_s
25
+ [resolved.to_s, mode]
26
+ end
268
27
 
28
+ def self.engine_path
29
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |directory|
269
30
  %w[tina4 tina4.exe].each do |name|
270
- candidate = File.join(dir, name)
31
+ candidate = File.join(directory, name)
271
32
  next unless File.file?(candidate) && File.executable?(candidate)
272
- next if _shebang_script?(candidate)
33
+ next if File.binread(candidate, 2) == '#!'
273
34
 
274
35
  return candidate
36
+ rescue StandardError
37
+ next
275
38
  end
276
39
  end
277
40
  nil
278
41
  end
279
42
 
280
- # True when the file begins with `#!` -- a script, never the native engine.
281
- def self._shebang_script?(path)
282
- File.binread(path, 2) == '#!'
283
- rescue StandardError
284
- false
285
- end
286
-
287
- # Run `tina4 metrics --json` over path and return the raw payload.
288
- #
289
- # Raises MetricsEngineError naming the actual cause: a caller that cannot get
290
- # metrics needs to know whether the binary is missing, the run failed, or the
291
- # output was unreadable.
292
- def self._run_engine(path)
43
+ def self.run_engine(path)
293
44
  binary = engine_path
294
- raise MetricsEngineError, "tina4 not found on PATH - #{INSTALL_HINT}" if binary.nil?
295
-
296
- stdout = nil
297
- status = nil
298
- stderr = nil
299
- begin
300
- require 'open3'
301
- stdout, stderr, status = Open3.capture3(
302
- binary, 'metrics', '--path', path.to_s, '--json'
303
- )
304
- # capture3 tags the output with the LOCALE's encoding, so under a
305
- # minimal locale (LANG=C / LANG unset, common on CI runners and in slim
306
- # containers) the engine's UTF-8 JSON arrives labelled US-ASCII and the
307
- # first String#strip raises Encoding::CompatibilityError. The bytes were
308
- # always UTF-8; only the label was wrong.
309
- stdout = stdout.to_s.dup.force_encoding(Encoding::UTF_8)
310
- stderr = stderr.to_s.dup.force_encoding(Encoding::UTF_8)
311
- rescue StandardError => e
312
- raise MetricsEngineError, "could not run #{binary}: #{e.message}"
313
- end
45
+ raise MetricsEngineError, "tina4 not found on PATH - #{INSTALL_HINT}" unless binary
314
46
 
47
+ stdout, stderr, status = Open3.capture3(binary, 'metrics', '--path', path.to_s, '--json')
315
48
  unless status.success?
316
- detail = (stderr.to_s.strip.empty? ? stdout.to_s : stderr.to_s).strip.lines.first
317
- first = detail ? detail.strip : "exit code #{status.exitstatus}"
318
- raise MetricsEngineError, "tina4 metrics failed on #{path}: #{first}"
49
+ detail = (stderr.empty? ? stdout : stderr).strip.lines.first || "exit code #{status.exitstatus}"
50
+ raise MetricsEngineError, "tina4 metrics failed on #{path}: #{detail.strip}"
319
51
  end
320
-
321
- raise MetricsEngineError, "tina4 metrics produced no output for #{path}" if stdout.to_s.strip.empty?
322
-
323
- begin
324
- payload = JSON.parse(stdout)
325
- rescue JSON::ParserError => e
326
- raise MetricsEngineError, "tina4 metrics returned unreadable JSON: #{e.message}"
327
- end
328
-
52
+ payload = JSON.parse(stdout)
329
53
  raise MetricsEngineError, 'tina4 metrics returned a non-object payload' unless payload.is_a?(Hash)
330
54
 
331
55
  payload
56
+ rescue JSON::ParserError => error
57
+ raise MetricsEngineError, "tina4 metrics returned unreadable JSON: #{error.message}"
58
+ rescue SystemCallError => error
59
+ raise MetricsEngineError, "could not run #{binary}: #{error.message}"
332
60
  end
333
61
 
334
- # Pull a key out of the payload or raise naming what the engine is missing.
335
- def self._require(payload, key, kind)
62
+ def self.require_array(payload, key)
336
63
  value = payload[key]
337
- unless value.is_a?(kind)
338
- raise MetricsEngineError,
339
- "engine payload has no usable '#{key}' - the installed tina4 CLI predates " \
340
- "a field the dashboard renders. Update it: #{INSTALL_HINT}"
341
- end
342
- value
64
+ return value if value.is_a?(Array)
65
+
66
+ raise MetricsEngineError, "engine payload has no usable '#{key}' - #{INSTALL_HINT}"
343
67
  end
344
68
 
345
- # Full code analysis from the native engine, shaped for the dashboard.
346
69
  def self.full_analysis(root = 'src')
347
- resolved, scan_mode = resolve_scan_target(root)
348
- payload = _run_engine(resolved)
349
-
350
- summary = _require(payload, 'summary', Hash)
351
- file_metrics = _require(payload, 'file_metrics', Array)
352
- functions = _require(payload, 'most_complex_functions', Array)
353
-
354
- missing = SUMMARY_KEYS.reject { |k| summary.key?(k) }
355
- unless missing.empty?
356
- raise MetricsEngineError,
357
- "engine summary is missing #{missing.join(', ')} - update the CLI: #{INSTALL_HINT}"
358
- end
359
- unless file_metrics.empty?
360
- absent = FILE_KEYS.reject { |k| file_metrics.first.key?(k) }
361
- raise MetricsEngineError, "engine file_metrics is missing #{absent.join(', ')}" unless absent.empty?
70
+ resolved, scan_mode = resolve_target(root)
71
+ payload = run_engine(resolved)
72
+ summary = payload['summary']
73
+ raise MetricsEngineError, "engine payload has no usable 'summary' - #{INSTALL_HINT}" unless summary.is_a?(Hash)
74
+
75
+ files = require_array(payload, 'file_metrics')
76
+ functions = require_array(payload, 'most_complex_functions')
77
+ missing = SUMMARY_KEYS.reject { |key| summary.key?(key) }
78
+ raise MetricsEngineError, "engine summary is missing #{missing.join(', ')}" unless missing.empty?
79
+ unless files.empty?
80
+ missing = FILE_KEYS.reject { |key| files.first.key?(key) }
81
+ raise MetricsEngineError, "engine file_metrics is missing #{missing.join(', ')}" unless missing.empty?
362
82
  end
363
83
  unless functions.empty?
364
- absent = FUNCTION_KEYS.reject { |k| functions.first.key?(k) }
365
- raise MetricsEngineError, "engine function metrics are missing #{absent.join(', ')}" unless absent.empty?
84
+ missing = FUNCTION_KEYS.reject { |key| functions.first.key?(key) }
85
+ raise MetricsEngineError, "engine function metrics are missing #{missing.join(', ')}" unless missing.empty?
366
86
  end
367
87
 
368
- result = SUMMARY_KEYS.each_with_object({}) { |k, h| h[k] = summary[k] }
369
- result['file_metrics'] = file_metrics
370
- # Display cap only. offenders reads the engine's own uncapped list, so a
371
- # 16th over-threshold function is never hidden from the gate.
372
- result['most_complex_functions'] = functions.first(15)
373
- result['dependency_graph'] = payload['dependency_graph'] || {}
374
- # The framework owns these two: the engine always reports "project" because
375
- # it cannot know which directory is a framework package.
376
- result['scan_mode'] = scan_mode
377
- result['scan_root'] = File.expand_path(resolved)
378
- result['engine'] = 'tina4-cli'
379
- result
88
+ SUMMARY_KEYS.to_h { |key| [key, summary[key]] }.merge(
89
+ 'file_metrics' => files,
90
+ 'most_complex_functions' => functions.first(15),
91
+ 'dependency_graph' => payload['dependency_graph'] || {},
92
+ 'scan_mode' => scan_mode,
93
+ 'scan_root' => resolved,
94
+ 'engine' => 'tina4-cli'
95
+ )
380
96
  end
381
97
 
382
- # Top code-health offenders from the native engine.
383
- #
384
- # The engine ranks and severity-tags them, and its own --fail-on gate reads
385
- # the same list, so the CLI and the dashboard can never disagree about what
386
- # counts as an offender.
387
- def self.offenders(root = 'src', top = 20)
388
- resolved, scan_mode = resolve_scan_target(root)
389
- payload = _run_engine(resolved)
390
-
391
- found = _require(payload, 'offenders', Array)
392
- summary = _require(payload, 'summary', Hash).dup
393
- summary['scan_mode'] = scan_mode
394
- summary['scan_root'] = File.expand_path(resolved)
395
- summary['engine'] = 'tina4-cli'
396
- summary['total_offenders'] ||= found.length
397
- { 'offenders' => found.first(top), 'summary' => summary }
398
- end
399
-
400
- # Per-file metrics from the native engine.
401
- #
402
- # The engine accepts a single file for --path, so one code path serves both
403
- # the whole-tree scan and one file.
404
98
  def self.file_detail(file_path)
405
- raise MetricsEngineError, 'file_detail needs a path' if file_path.nil? || file_path.to_s.empty?
99
+ raise MetricsEngineError, 'file_detail needs a path' if file_path.to_s.empty?
406
100
 
407
101
  target = Pathname.new(file_path.to_s)
408
- unless target.exist?
409
- # Try it relative to whatever the census last resolved, so the dashboard
410
- # can pass a path taken straight out of file_metrics.
411
- unless @last_scan_root.to_s.empty?
412
- candidate = Pathname.new(@last_scan_root).join(file_path.to_s)
413
- target = candidate if candidate.exist?
414
- end
415
- end
102
+ target = Pathname.new(@last_scan_root).join(file_path.to_s) if !target.exist? && @last_scan_root
416
103
  raise MetricsEngineError, "no such file: #{file_path}" unless target.exist?
417
104
  raise MetricsEngineError, "not a file: #{file_path}" if target.directory?
418
105
 
419
- payload = _run_engine(target.to_s)
420
- file_metrics = _require(payload, 'file_metrics', Array)
421
- raise MetricsEngineError, "engine reported no metrics for #{file_path}" if file_metrics.empty?
106
+ payload = run_engine(target.to_s)
107
+ files = require_array(payload, 'file_metrics')
108
+ raise MetricsEngineError, "engine reported no metrics for #{file_path}" if files.empty?
422
109
 
423
- file_metrics.first.dup.merge('engine' => 'tina4-cli')
110
+ files.first.merge(
111
+ 'function_count' => files.first.fetch('functions', 0),
112
+ 'functions' => require_array(payload, 'most_complex_functions'),
113
+ 'engine' => 'tina4-cli'
114
+ )
424
115
  end
425
116
  end
426
117
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.100"
4
+ VERSION = "3.13.101"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -42,6 +42,7 @@ require_relative "tina4/dev_admin"
42
42
  require_relative "tina4/feedback"
43
43
  require_relative "tina4/dev_mailbox"
44
44
  require_relative "tina4/ai"
45
+ require_relative "tina4/ai_client"
45
46
  require_relative "tina4/cache"
46
47
  require_relative "tina4/sql_translator"
47
48
  require_relative "tina4/cache_backends"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.100
4
+ version: 3.13.101
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
@@ -321,6 +321,7 @@ files:
321
321
  - exe/tina4ruby
322
322
  - lib/tina4.rb
323
323
  - lib/tina4/ai.rb
324
+ - lib/tina4/ai_client.rb
324
325
  - lib/tina4/api.rb
325
326
  - lib/tina4/auth.rb
326
327
  - lib/tina4/auto_crud.rb