tina4ruby 3.13.100 → 3.13.103
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 +44 -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/frond.rb +21 -15
- data/lib/tina4/metrics.rb +66 -375
- data/lib/tina4/public/js/tina4-dev-admin.min.js +2 -2
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +6 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6097dca4375329be7c8539cf9e40a8cde39fd9a6041169e58b9edf6bcbcf491a
|
|
4
|
+
data.tar.gz: 9d90b71f00d6a59a151a5a3108319aed79faccaac0858f40c3572825765f0acf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74d5064e7aca1cffcdfd0389b6662a4f2d921ed18de43f8d9f0741bffa6a8ceb7d381af281b44b9e114237d7c783068c1adef1b709eb8c36efa017aa6eeb633e
|
|
7
|
+
data.tar.gz: 13fd4fa1ea1e667eee942d516cd48c14a2488e686ace7d9597d3affe368d2e7ad8e86772367b8d5ed5b3cc94b9a5631a41d62694abf055999ccbab30c9efad99
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,50 @@ 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.103
|
|
10
|
+
|
|
11
|
+
### Metrics reports what it can prove
|
|
12
|
+
|
|
13
|
+
- Require signed Tina4 client 3.8.76 for the native metrics handoff.
|
|
14
|
+
- Expose `has_referencing_test` as a source-reference signal. It does not claim
|
|
15
|
+
that a test ran or that coverage exists.
|
|
16
|
+
- Fail when the native client is stale instead of falling back to a second
|
|
17
|
+
framework-owned analyser.
|
|
18
|
+
|
|
19
|
+
### Frond stays stable and gets smaller
|
|
20
|
+
|
|
21
|
+
- Split expression parsing and evaluation into focused internal steps.
|
|
22
|
+
- Preserve public APIs and the shared 84-case expression corpus across all four
|
|
23
|
+
languages.
|
|
24
|
+
|
|
25
|
+
### One client starts every project
|
|
26
|
+
|
|
27
|
+
- Lead framework skills with `tina4 init` and `tina4 serve`.
|
|
28
|
+
- Keep scaffolding guidance visible and separate runtime dependencies from
|
|
29
|
+
language extensions.
|
|
30
|
+
|
|
31
|
+
### Release integrity
|
|
32
|
+
|
|
33
|
+
- Align source, runtime, package, lockfile, and AI-facing guide versions.
|
|
34
|
+
- Reject a release tag that does not match the source package version before
|
|
35
|
+
any registry publish begins.
|
|
36
|
+
|
|
37
|
+
+## 3.13.101
|
|
38
|
+
|
|
39
|
+
### Breaking: metrics has one owner
|
|
40
|
+
|
|
41
|
+
- Remove the framework `metrics` command and local quick census. Use the native `tina4 metrics` CLI.
|
|
42
|
+
- Keep dev-admin metrics as a thin `/metrics/full` and `/metrics/file` JSON handoff to that CLI.
|
|
43
|
+
|
|
44
|
+
### App-facing AI client
|
|
45
|
+
|
|
46
|
+
- Add zero-dependency `Tina4::Ai.chat`, `Tina4::Ai.complete`, and `Tina4::Ai.embed`.
|
|
47
|
+
- Support local/OpenAI-compatible, OpenAI, and Anthropic chat providers.
|
|
48
|
+
- Normalize chat responses, stream ordered deltas, and preserve embedding cardinality.
|
|
49
|
+
- Fail closed on missing hosted-provider keys, verify TLS, redact sensitive failures, and
|
|
50
|
+
distinguish bounded connection and total-request timeouts.
|
|
51
|
+
- Retry only transient connection, HTTP 429, and HTTP 5xx failures, never a partial stream.
|
|
52
|
+
|
|
9
53
|
## 3.13.100
|
|
10
54
|
|
|
11
55
|
### 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/frond.rb
CHANGED
|
@@ -1566,6 +1566,17 @@ module Tina4
|
|
|
1566
1566
|
# FIFO drop-oldest-half, deliberately not LRU (no per-hit bookkeeping on the
|
|
1567
1567
|
# hot path).
|
|
1568
1568
|
EXPR_FORM_CACHE_MAX = 2048
|
|
1569
|
+
EXPR_FORM_HANDLERS = {
|
|
1570
|
+
pipe: [:eval_filter_pipe, :not_filter_pipe, true],
|
|
1571
|
+
literal: [:eval_literal, :not_literal, false],
|
|
1572
|
+
function: [:eval_function_call, :not_function, true],
|
|
1573
|
+
concat: [:eval_concat, :not_concat, true],
|
|
1574
|
+
arithmetic: [:eval_arithmetic, :not_arithmetic, true],
|
|
1575
|
+
ternary: [:eval_ternary, :not_ternary, true],
|
|
1576
|
+
inline_if: [:eval_inline_if, :not_inline_if, true],
|
|
1577
|
+
coalesce: [:eval_null_coalesce, :not_coalesce, true],
|
|
1578
|
+
collection: [:eval_collection_literal, :not_collection, true]
|
|
1579
|
+
}.freeze
|
|
1569
1580
|
|
|
1570
1581
|
# ── Expression evaluation: a cascade, with the branch memoised ──
|
|
1571
1582
|
#
|
|
@@ -1648,21 +1659,16 @@ module Tina4
|
|
|
1648
1659
|
# Evaluate via the one remembered branch. Returns :form_declined when that
|
|
1649
1660
|
# branch no longer applies, so the caller re-runs the full cascade.
|
|
1650
1661
|
def eval_expr_as(form, expr, context)
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
when :coalesce then (r = eval_null_coalesce(expr, context)) == :not_coalesce ? :form_declined : r
|
|
1662
|
-
when :collection then (r = eval_collection_literal(expr, context)) == :not_collection ? :form_declined : r
|
|
1663
|
-
when :parens then matched_parens?(expr) ? eval_expr(expr[1..-2], context) : :form_declined
|
|
1664
|
-
else :form_declined
|
|
1665
|
-
end
|
|
1662
|
+
return resolve(expr, context) if form == :resolve
|
|
1663
|
+
return has_comparison?(expr) ? eval_comparison(expr, context) : :form_declined if form == :comparison
|
|
1664
|
+
return matched_parens?(expr) ? eval_expr(expr[1..-2], context) : :form_declined if form == :parens
|
|
1665
|
+
|
|
1666
|
+
handler = EXPR_FORM_HANDLERS[form]
|
|
1667
|
+
return :form_declined unless handler
|
|
1668
|
+
|
|
1669
|
+
method_name, declined, needs_context = handler
|
|
1670
|
+
result = needs_context ? send(method_name, expr, context) : send(method_name, expr)
|
|
1671
|
+
result == declined ? :form_declined : result
|
|
1666
1672
|
end
|
|
1667
1673
|
|
|
1668
1674
|
# ── Filter pipe: value|filter(args) ──
|
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
|
-
FILE_KEYS = %w[path loc avg_complexity maintainability
|
|
14
|
+
FILE_KEYS = %w[path loc avg_complexity maintainability has_referencing_test].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
|
|
@@ -234,11 +234,11 @@ ${t.traceback||""}`;window.__switchTab("chat"),setTimeout(()=>{window.__prefillC
|
|
|
234
234
|
</tr>`).join("")}
|
|
235
235
|
</tbody>
|
|
236
236
|
</table>
|
|
237
|
-
`)}function Xy(i,e){const t=[],n={},r={};i.forEach((s,o)=>{n[Rd(s)]=o,r[s]=o});for(const[s,o]of Object.entries(e)){const a=r[s];if(a!==void 0)for(const l of o){const O=r[l];if(O!==void 0){O!==a&&t.push([a,O]);continue}const c=l.replace(/^\.\//,"").replace(/^\.\.\//,"").split(/[./]/);let d;for(let h=c.length-1;h>=0;h--){const f=c[h].toLowerCase();if(f&&f!=="js"&&f!=="py"&&f!=="rb"&&f!=="ts"&&f!=="index"&&(d=n[f],d!==void 0))break}d===void 0&&(d=n[Rd(l)]),d!==void 0&&a!==d&&t.push([a,d])}}return t}function Rd(i){const e=i.replace(/\\/g,"/").split("/").pop()||"",t=e.lastIndexOf(".");return(t>0?e.substring(0,t):e).toLowerCase()}function _y(i,e,t,n){var gi,$n,ps;const r=e.offsetWidth||900,s=Math.max(450,Math.min(650,r*.45)),o=Math.max(...i.map(G=>G.loc))||1,a=Math.max(...i.map(G=>G.dep_count||0))||1,l=Math.max(...i.map(G=>G.coupling_afferent||0))||1,O=i.some(G=>(G.coupling_afferent||0)>0),c=14,d=Math.min(70,r/10);function h(G){const I=Math.min((G.avg_complexity||0)/10,1),D=G.
|
|
237
|
+
`)}function Xy(i,e){const t=[],n={},r={};i.forEach((s,o)=>{n[Rd(s)]=o,r[s]=o});for(const[s,o]of Object.entries(e)){const a=r[s];if(a!==void 0)for(const l of o){const O=r[l];if(O!==void 0){O!==a&&t.push([a,O]);continue}const c=l.replace(/^\.\//,"").replace(/^\.\.\//,"").split(/[./]/);let d;for(let h=c.length-1;h>=0;h--){const f=c[h].toLowerCase();if(f&&f!=="js"&&f!=="py"&&f!=="rb"&&f!=="ts"&&f!=="index"&&(d=n[f],d!==void 0))break}d===void 0&&(d=n[Rd(l)]),d!==void 0&&a!==d&&t.push([a,d])}}return t}function Rd(i){const e=i.replace(/\\/g,"/").split("/").pop()||"",t=e.lastIndexOf(".");return(t>0?e.substring(0,t):e).toLowerCase()}function _y(i,e,t,n){var gi,$n,ps;const r=e.offsetWidth||900,s=Math.max(450,Math.min(650,r*.45)),o=Math.max(...i.map(G=>G.loc))||1,a=Math.max(...i.map(G=>G.dep_count||0))||1,l=Math.max(...i.map(G=>G.coupling_afferent||0))||1,O=i.some(G=>(G.coupling_afferent||0)>0),c=14,d=Math.min(70,r/10);function h(G){const I=Math.min((G.avg_complexity||0)/10,1),D=G.has_referencing_test?0:1,de=Math.min((G.dep_count||0)/5,1),j=I*.4+D*.4+de*.2,W=Math.max(0,Math.min(1,j)),H=Math.round(120*(1-W)),te=Math.round(70+W*30),pe=Math.round(42+18*(1-W));return`hsl(${H},${te}%,${pe}%)`}function f(G){return G.loc/o*.4+(G.avg_complexity||0)/10*.4+(G.dep_count||0)/a*.2}const p=[...i].sort((G,I)=>f(G)-f(I)),m=r/2,$=s/2,g=[];let y=0,S=0;for(const G of p){const I=c+Math.sqrt(f(G))*(d-c),D=h(G);let de=!1;for(let j=0;j<800;j++){const W=m+S*Math.cos(y),H=$+S*Math.sin(y);let te=!1;for(const pe of g){const he=W-pe.x,ke=H-pe.y;if(Math.sqrt(he*he+ke*ke)<I+pe.r+2){te=!0;break}}if(!te&&W>I+2&&W<r-I-2&&H>I+25&&H<s-I-2){g.push({x:W,y:H,vx:0,vy:0,r:I,color:D,f:G}),de=!0;break}y+=.2,S+=.04}de||g.push({x:m+(Math.random()-.5)*r*.3,y:$+(Math.random()-.5)*s*.3,vx:0,vy:0,r:I,color:D,f:G})}const x=Xy(g.map(G=>G.f.path),t),w=document.createElement("canvas");w.width=r,w.height=s,w.style.cssText="display:block;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:#0f172a";const R=n==="framework"?'<span style="color:#cba6f7;font-weight:600">(Framework)</span> Add code to src/ to see your project':"";e.innerHTML=`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;font-size:0.85rem">Code Landscape ${R}</h3><span style="font-size:0.65rem;color:var(--muted)">Drag bubbles | Dbl-click to drill down</span></div><div style="position:relative" id="metrics-canvas-wrap"></div><div style="margin-top:0.4rem;font-size:0.62rem;color:var(--muted);display:flex;gap:0.9rem;flex-wrap:wrap">${O?'<span>Centre = most depended-on</span><span><span style="color:hsl(210,85%,60%)">●</span> stable ring</span><span><span style="color:hsl(30,85%,60%)">●</span> unstable ring</span>':""}<span id="metrics-hover-info" class="text-mono"></span></div>`,document.getElementById("metrics-canvas-wrap").appendChild(w);const _=document.getElementById("metrics-hover-info"),A=document.createElement("div");A.style.cssText="position:absolute;top:8px;left:8px;z-index:2;display:flex;gap:4px;flex-direction:column",A.innerHTML=`
|
|
238
238
|
<button class="btn btn-sm" id="metrics-zoom-in" style="width:28px;height:28px;padding:0;font-size:14px;font-weight:700;line-height:1">+</button>
|
|
239
239
|
<button class="btn btn-sm" id="metrics-zoom-out" style="width:28px;height:28px;padding:0;font-size:14px;font-weight:700;line-height:1">−</button>
|
|
240
240
|
<button class="btn btn-sm" id="metrics-zoom-fit" style="width:28px;height:28px;padding:0;font-size:10px;font-weight:700;line-height:1">Fit</button>
|
|
241
|
-
`,document.getElementById("metrics-canvas-wrap").appendChild(A),(gi=document.getElementById("metrics-zoom-in"))==null||gi.addEventListener("click",()=>{le=Math.min(5,le*1.3)}),($n=document.getElementById("metrics-zoom-out"))==null||$n.addEventListener("click",()=>{le=Math.max(.3,le*.7)}),(ps=document.getElementById("metrics-zoom-fit"))==null||ps.addEventListener("click",()=>{le=1,U=0,oe=0});const P=w.getContext("2d");let v=-1,k=-1,X=0,z=0,U=0,oe=0,le=1,rt=!1,mn=0,Gt=0,us=0,ft=0;function De(){for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j],H=m-W.x,te=$-W.y,pe=.3+W.r/d*.7,he=O?.3+(W.f.coupling_afferent||0)/l*.7:pe,ke=.008*pe*he;W.vx+=H*ke,W.vy+=te*ke}for(const[j,W]of x){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20,Bt=(ke-Dt)*.002,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx+=ms,H.vy+=$s),W!==k&&(te.vx-=ms,te.vy-=$s)}for(let j=0;j<g.length;j++)for(let W=j+1;W<g.length;W++){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20;if(ke<Dt){const Bt=40*(Dt-ke)/Dt,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx-=ms,H.vy-=$s),W!==k&&(te.vx+=ms,te.vy+=$s)}}for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j];W.vx*=.65,W.vy*=.65;const H=2;W.vx=Math.max(-H,Math.min(H,W.vx)),W.vy=Math.max(-H,Math.min(H,W.vy)),W.x+=W.vx,W.y+=W.vy,W.x=Math.max(W.r+2,Math.min(r-W.r-2,W.x)),W.y=Math.max(W.r+25,Math.min(s-W.r-2,W.y))}}function st(){var G;De(),P.clearRect(0,0,r,s),P.save(),P.translate(U,oe),P.scale(le,le),P.strokeStyle="rgba(255,255,255,0.03)",P.lineWidth=1/le;for(let I=0;I<r/le;I+=50)P.beginPath(),P.moveTo(I,0),P.lineTo(I,s/le),P.stroke();for(let I=0;I<s/le;I+=50)P.beginPath(),P.moveTo(0,I),P.lineTo(r/le,I),P.stroke();for(const[I,D]of x){const de=g[I],j=g[D],W=j.x-de.x,H=j.y-de.y,te=Math.sqrt(W*W+H*H)||1,pe=v===I||v===D;P.beginPath(),P.moveTo(de.x+W/te*de.r,de.y+H/te*de.r);const he=j.x-W/te*j.r,ke=j.y-H/te*j.r;P.lineTo(he,ke),P.strokeStyle=pe?"rgba(139,180,250,0.9)":"rgba(255,255,255,0.15)",P.lineWidth=pe?3:1,P.stroke();const Dt=pe?12:6,Bt=Math.atan2(H,W);P.beginPath(),P.moveTo(he,ke),P.lineTo(he-Dt*Math.cos(Bt-.4),ke-Dt*Math.sin(Bt-.4)),P.lineTo(he-Dt*Math.cos(Bt+.4),ke-Dt*Math.sin(Bt+.4)),P.closePath(),P.fillStyle=P.strokeStyle,P.fill()}for(let I=0;I<g.length;I++){const D=g[I],de=I===v,j=de?D.r+4:D.r;de&&(P.beginPath(),P.arc(D.x,D.y,j+8,0,Math.PI*2),P.fillStyle="rgba(255,255,255,0.08)",P.fill()),P.beginPath(),P.arc(D.x,D.y,j,0,Math.PI*2),P.fillStyle=D.color,P.globalAlpha=de?1:.85,P.fill(),P.globalAlpha=1,P.strokeStyle=de?"rgba(255,255,255,0.6)":"rgba(255,255,255,0.25)",P.lineWidth=de?2.5:1.5,P.stroke();const W=D.f.instability;O&&typeof W=="number"&&(D.f.coupling_afferent||0)+(D.f.coupling_efferent||0)>0&&(P.beginPath(),P.arc(D.x,D.y,j+3,0,Math.PI*2),P.strokeStyle=`hsl(${Math.round(210-180*W)},85%,60%)`,P.lineWidth=1+W*2.5,P.stroke());const H=((G=D.f.path.split("/").pop())==null?void 0:G.replace(/\.\w+$/,""))||"?";if(j>16){const he=Math.max(8,Math.min(13,j*.38));P.fillStyle="#fff",P.font=`600 ${he}px monospace`,P.textAlign="center",P.fillText(H,D.x,D.y-2),P.fillStyle="rgba(255,255,255,0.65)",P.font=`${he-1}px monospace`,P.fillText(`${D.f.loc} LOC`,D.x,D.y+he)}const te=Math.max(9,j*.3),pe=te*.7;if(j>14&&D.f.dep_count>0){const he=D.y-j+pe+3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#ea580c",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("D",D.x,he+te*.35)}if(j>14&&D.f.
|
|
241
|
+
`,document.getElementById("metrics-canvas-wrap").appendChild(A),(gi=document.getElementById("metrics-zoom-in"))==null||gi.addEventListener("click",()=>{le=Math.min(5,le*1.3)}),($n=document.getElementById("metrics-zoom-out"))==null||$n.addEventListener("click",()=>{le=Math.max(.3,le*.7)}),(ps=document.getElementById("metrics-zoom-fit"))==null||ps.addEventListener("click",()=>{le=1,U=0,oe=0});const P=w.getContext("2d");let v=-1,k=-1,X=0,z=0,U=0,oe=0,le=1,rt=!1,mn=0,Gt=0,us=0,ft=0;function De(){for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j],H=m-W.x,te=$-W.y,pe=.3+W.r/d*.7,he=O?.3+(W.f.coupling_afferent||0)/l*.7:pe,ke=.008*pe*he;W.vx+=H*ke,W.vy+=te*ke}for(const[j,W]of x){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20,Bt=(ke-Dt)*.002,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx+=ms,H.vy+=$s),W!==k&&(te.vx-=ms,te.vy-=$s)}for(let j=0;j<g.length;j++)for(let W=j+1;W<g.length;W++){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20;if(ke<Dt){const Bt=40*(Dt-ke)/Dt,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx-=ms,H.vy-=$s),W!==k&&(te.vx+=ms,te.vy+=$s)}}for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j];W.vx*=.65,W.vy*=.65;const H=2;W.vx=Math.max(-H,Math.min(H,W.vx)),W.vy=Math.max(-H,Math.min(H,W.vy)),W.x+=W.vx,W.y+=W.vy,W.x=Math.max(W.r+2,Math.min(r-W.r-2,W.x)),W.y=Math.max(W.r+25,Math.min(s-W.r-2,W.y))}}function st(){var G;De(),P.clearRect(0,0,r,s),P.save(),P.translate(U,oe),P.scale(le,le),P.strokeStyle="rgba(255,255,255,0.03)",P.lineWidth=1/le;for(let I=0;I<r/le;I+=50)P.beginPath(),P.moveTo(I,0),P.lineTo(I,s/le),P.stroke();for(let I=0;I<s/le;I+=50)P.beginPath(),P.moveTo(0,I),P.lineTo(r/le,I),P.stroke();for(const[I,D]of x){const de=g[I],j=g[D],W=j.x-de.x,H=j.y-de.y,te=Math.sqrt(W*W+H*H)||1,pe=v===I||v===D;P.beginPath(),P.moveTo(de.x+W/te*de.r,de.y+H/te*de.r);const he=j.x-W/te*j.r,ke=j.y-H/te*j.r;P.lineTo(he,ke),P.strokeStyle=pe?"rgba(139,180,250,0.9)":"rgba(255,255,255,0.15)",P.lineWidth=pe?3:1,P.stroke();const Dt=pe?12:6,Bt=Math.atan2(H,W);P.beginPath(),P.moveTo(he,ke),P.lineTo(he-Dt*Math.cos(Bt-.4),ke-Dt*Math.sin(Bt-.4)),P.lineTo(he-Dt*Math.cos(Bt+.4),ke-Dt*Math.sin(Bt+.4)),P.closePath(),P.fillStyle=P.strokeStyle,P.fill()}for(let I=0;I<g.length;I++){const D=g[I],de=I===v,j=de?D.r+4:D.r;de&&(P.beginPath(),P.arc(D.x,D.y,j+8,0,Math.PI*2),P.fillStyle="rgba(255,255,255,0.08)",P.fill()),P.beginPath(),P.arc(D.x,D.y,j,0,Math.PI*2),P.fillStyle=D.color,P.globalAlpha=de?1:.85,P.fill(),P.globalAlpha=1,P.strokeStyle=de?"rgba(255,255,255,0.6)":"rgba(255,255,255,0.25)",P.lineWidth=de?2.5:1.5,P.stroke();const W=D.f.instability;O&&typeof W=="number"&&(D.f.coupling_afferent||0)+(D.f.coupling_efferent||0)>0&&(P.beginPath(),P.arc(D.x,D.y,j+3,0,Math.PI*2),P.strokeStyle=`hsl(${Math.round(210-180*W)},85%,60%)`,P.lineWidth=1+W*2.5,P.stroke());const H=((G=D.f.path.split("/").pop())==null?void 0:G.replace(/\.\w+$/,""))||"?";if(j>16){const he=Math.max(8,Math.min(13,j*.38));P.fillStyle="#fff",P.font=`600 ${he}px monospace`,P.textAlign="center",P.fillText(H,D.x,D.y-2),P.fillStyle="rgba(255,255,255,0.65)",P.font=`${he-1}px monospace`,P.fillText(`${D.f.loc} LOC`,D.x,D.y+he)}const te=Math.max(9,j*.3),pe=te*.7;if(j>14&&D.f.dep_count>0){const he=D.y-j+pe+3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#ea580c",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("D",D.x,he+te*.35)}if(j>14&&D.f.has_referencing_test){const he=D.y+j-pe-3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#16a34a",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("T",D.x,he+te*.35)}}if(P.restore(),v>=0){const I=g[v].f,D=I.coupling_afferent??0,de=I.coupling_efferent??0,j=typeof I.instability=="number"?I.instability.toFixed(2):"-";_.textContent=`${I.path} ca ${D} / ce ${de} / I ${j} / MI ${I.maintainability}`}else _.textContent&&(_.textContent="");requestAnimationFrame(st)}w.addEventListener("mousemove",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(rt){U=us+(G.clientX-mn),oe=ft+(G.clientY-Gt);return}if(k>=0){ut=!0,g[k].x=D+X,g[k].y=de+z,g[k].vx=0,g[k].vy=0;return}v=-1;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){v=j;break}}w.style.cursor=v>=0?"grab":"default"}),w.addEventListener("mousedown",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(G.button===2){rt=!0,mn=G.clientX,Gt=G.clientY,us=U,ft=oe,w.style.cursor="move";return}v>=0&&(k=v,X=g[k].x-D,z=g[k].y-de,ut=!1,w.style.cursor="grabbing")});let ut=!1;w.addEventListener("mouseup",G=>{if(rt){rt=!1,w.style.cursor="default";return}if(k>=0){ut||Xa(g[k].f.path),w.style.cursor="grab",k=-1,ut=!1;return}}),w.addEventListener("mouseleave",()=>{v=-1,k=-1,rt=!1}),w.addEventListener("dblclick",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){Xa(W.f.path);break}}}),w.addEventListener("contextmenu",G=>G.preventDefault()),requestAnimationFrame(st)}async function Xa(i){const e=document.getElementById("metrics-detail");if(!e)return;e.innerHTML='<p class="text-muted">Loading file analysis...</p>';const t=await L("/metrics/file?path="+encodeURIComponent(i));if(t.error){e.innerHTML=`<p style="color:var(--danger)">${Q(t.error)}</p>`;return}const n=t.functions||[],r=Math.max(1,...n.map(s=>s.complexity));e.innerHTML=`
|
|
242
242
|
<div style="background:var(--surface);border:1px solid var(--border);border-radius:0.5rem;padding:1rem">
|
|
243
243
|
<div class="flex items-center" style="justify-content:space-between;margin-bottom:0.75rem">
|
|
244
244
|
<h3 style="font-size:0.9rem">${Q(t.path)}</h3>
|
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,14 +1,14 @@
|
|
|
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.103
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -304,10 +304,8 @@ dependencies:
|
|
|
304
304
|
- - "~>"
|
|
305
305
|
- !ruby/object:Gem::Version
|
|
306
306
|
version: '0.10'
|
|
307
|
-
description: 'TINA4: The Intelligent Native Application 4ramework for Ruby
|
|
308
|
-
|
|
309
|
-
(Rack-based; the runtime gems are production-deployment essentials, not framework
|
|
310
|
-
bloat). Full parity with Python, PHP, and Node.js.'
|
|
307
|
+
description: 'TINA4: The Intelligent Native Application 4ramework for Ruby. A Rack-based
|
|
308
|
+
backend with native Ruby conventions and shared cross-language contracts.'
|
|
311
309
|
email:
|
|
312
310
|
- info@tina4.com
|
|
313
311
|
executables:
|
|
@@ -321,6 +319,7 @@ files:
|
|
|
321
319
|
- exe/tina4ruby
|
|
322
320
|
- lib/tina4.rb
|
|
323
321
|
- lib/tina4/ai.rb
|
|
322
|
+
- lib/tina4/ai_client.rb
|
|
324
323
|
- lib/tina4/api.rb
|
|
325
324
|
- lib/tina4/auth.rb
|
|
326
325
|
- lib/tina4/auto_crud.rb
|
|
@@ -493,5 +492,5 @@ requirements: []
|
|
|
493
492
|
rubygems_version: 3.4.19
|
|
494
493
|
signing_key:
|
|
495
494
|
specification_version: 4
|
|
496
|
-
summary: Tina4 for Ruby —
|
|
495
|
+
summary: Tina4 for Ruby — native Ruby conventions and shared Tina4 contracts
|
|
497
496
|
test_files: []
|