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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +12 -0
- data/lib/tina4/ai_client.rb +310 -0
- data/lib/tina4/cli.rb +0 -106
- data/lib/tina4/dev_admin.rb +0 -2
- data/lib/tina4/metrics.rb +65 -374
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9102cf347865953d60e7d47b1ead3b87e25f139f1439a19e5f230ec31594c32a
|
|
4
|
+
data.tar.gz: 7a5323f6a9d26e41852269c63d76fac619a59b549e634aad6a56bdc887dcb780
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
"",
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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(
|
|
31
|
+
candidate = File.join(directory, name)
|
|
271
32
|
next unless File.file?(candidate) && File.executable?(candidate)
|
|
272
|
-
next if
|
|
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
|
-
|
|
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}"
|
|
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.
|
|
317
|
-
|
|
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
|
-
|
|
335
|
-
def self._require(payload, key, kind)
|
|
62
|
+
def self.require_array(payload, key)
|
|
336
63
|
value = payload[key]
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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 =
|
|
348
|
-
payload =
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
missing = SUMMARY_KEYS.reject { |
|
|
355
|
-
unless missing.empty?
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
365
|
-
raise MetricsEngineError, "engine function metrics are missing #{
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
420
|
-
|
|
421
|
-
raise MetricsEngineError, "engine reported no metrics for #{file_path}" if
|
|
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
|
-
|
|
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
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.
|
|
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
|