tina4ruby 3.11.18 → 3.11.32
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/lib/tina4/database.rb +31 -4
- data/lib/tina4/dev_admin.rb +106 -0
- data/lib/tina4/docs.rb +636 -0
- data/lib/tina4/mcp.rb +15 -0
- data/lib/tina4/public/js/tina4-dev-admin.js +121 -121
- data/lib/tina4/public/js/tina4-dev-admin.min.js +121 -121
- data/lib/tina4/rack_app.rb +46 -1
- data/lib/tina4/response.rb +3 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +3 -2
data/lib/tina4/docs.rb
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4::Docs — Live API RAG.
|
|
4
|
+
#
|
|
5
|
+
# Walks the running framework (`lib/tina4/`) and the user's project surface
|
|
6
|
+
# (`<root>/src/orm`, `routes`, `app`, `services`) and exposes them via a
|
|
7
|
+
# ranked search, class/method specs, a flat index, MCP-style mirrors and
|
|
8
|
+
# a Markdown drift / sync helper.
|
|
9
|
+
#
|
|
10
|
+
# Stdlib only — no new gems. Class introspection where safe, regex parsing
|
|
11
|
+
# of `.rb` source for unloaded user code (so user files with unresolved
|
|
12
|
+
# requires never blow up the indexer).
|
|
13
|
+
#
|
|
14
|
+
# Spec: plan/v3/22-LIVE-API-RAG.md
|
|
15
|
+
|
|
16
|
+
require "json"
|
|
17
|
+
require "set"
|
|
18
|
+
|
|
19
|
+
module Tina4
|
|
20
|
+
class Docs
|
|
21
|
+
# ── Constants ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
USER_DIRS = %w[orm routes app services].freeze
|
|
24
|
+
|
|
25
|
+
# Method names commonly referenced in markdown that must NOT be flagged
|
|
26
|
+
# as drift even when not present in the live index.
|
|
27
|
+
STDLIB_ALLOWLIST = %w[
|
|
28
|
+
puts print p pp inspect to_s to_a to_h to_i to_f to_sym
|
|
29
|
+
keys values each map select reject reduce inject filter
|
|
30
|
+
length size count first last empty? include? has_key?
|
|
31
|
+
push pop shift unshift slice splice
|
|
32
|
+
get set put patch post delete head options
|
|
33
|
+
json html xml render redirect text file stream call
|
|
34
|
+
strip lstrip rstrip chomp chop split join replace gsub sub
|
|
35
|
+
upcase downcase capitalize to_str
|
|
36
|
+
new initialize freeze dup clone tap then yield_self
|
|
37
|
+
raise rescue retry begin ensure end
|
|
38
|
+
require require_relative load
|
|
39
|
+
assert assert_equal assert_nil assert_not_nil expect should
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# Module-level cache keyed by absolute project_root.
|
|
43
|
+
@_mcp_instances = {}
|
|
44
|
+
@_mcp_mutex = Mutex.new
|
|
45
|
+
|
|
46
|
+
# ── Construction ─────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def initialize(project_root)
|
|
49
|
+
@project_root = File.expand_path(project_root.to_s)
|
|
50
|
+
@framework_root = File.expand_path(File.dirname(__FILE__))
|
|
51
|
+
@gem_root = File.expand_path(File.join(@framework_root, ".."))
|
|
52
|
+
@version = detect_version
|
|
53
|
+
@framework_entries = nil
|
|
54
|
+
@user_entries = nil
|
|
55
|
+
@user_mtime = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
attr_reader :project_root
|
|
59
|
+
|
|
60
|
+
# ── Public API ───────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
# Search the merged framework + user index for ranked hits.
|
|
63
|
+
#
|
|
64
|
+
# @param query [String] Free-text query
|
|
65
|
+
# @param k [Integer] Top-K to return
|
|
66
|
+
# @param source [String] "all" (default), "framework", "user", "vendor"
|
|
67
|
+
# @param include_private [Boolean] Include private/protected/_underscore methods
|
|
68
|
+
# @return [Array<Hash>]
|
|
69
|
+
def search(query, k: 5, source: "all", include_private: false)
|
|
70
|
+
ensure_index
|
|
71
|
+
tokens = tokenise(query.to_s)
|
|
72
|
+
return [] if tokens.empty?
|
|
73
|
+
|
|
74
|
+
joined = query.to_s.downcase.gsub(/\s+/, "")
|
|
75
|
+
results = []
|
|
76
|
+
all_entries.each do |entry|
|
|
77
|
+
src = entry[:source]
|
|
78
|
+
next if source == "all" && src == "vendor"
|
|
79
|
+
next if source != "all" && src != source
|
|
80
|
+
next if !include_private && entry[:_private]
|
|
81
|
+
|
|
82
|
+
score = score_entry(entry, tokens, joined)
|
|
83
|
+
next if score <= 0
|
|
84
|
+
score *= 1.2 if src == "user"
|
|
85
|
+
hit = entry.dup
|
|
86
|
+
hit.delete(:_private)
|
|
87
|
+
hit.delete(:docstring)
|
|
88
|
+
hit[:score] = score.round(4)
|
|
89
|
+
results << hit
|
|
90
|
+
end
|
|
91
|
+
results.sort! do |a, b|
|
|
92
|
+
cmp = b[:score] <=> a[:score]
|
|
93
|
+
cmp.nonzero? || (a[:fqn] <=> b[:fqn])
|
|
94
|
+
end
|
|
95
|
+
results.first([k, 1].max)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Full reflection of a single class — `nil` for unknown.
|
|
99
|
+
def class_spec(fqn)
|
|
100
|
+
ensure_index
|
|
101
|
+
key = normalise_fqn(fqn)
|
|
102
|
+
class_entry = all_entries.find { |e| e[:kind] == "class" && e[:fqn] == key }
|
|
103
|
+
return nil if class_entry.nil?
|
|
104
|
+
|
|
105
|
+
methods = all_entries.select do |m|
|
|
106
|
+
m[:kind] == "method" && m[:class_fqn] == class_entry[:fqn] && m[:visibility] == "public"
|
|
107
|
+
end.map { |m| method_payload(m) }
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
fqn: class_entry[:fqn],
|
|
111
|
+
kind: "class",
|
|
112
|
+
name: class_entry[:name],
|
|
113
|
+
file: class_entry[:file],
|
|
114
|
+
line: class_entry[:line],
|
|
115
|
+
summary: class_entry[:summary],
|
|
116
|
+
source: class_entry[:source],
|
|
117
|
+
version: class_entry[:version],
|
|
118
|
+
methods: methods,
|
|
119
|
+
properties: [],
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Single method spec — `nil` for unknown.
|
|
124
|
+
def method_spec(class_fqn, method_name)
|
|
125
|
+
ensure_index
|
|
126
|
+
key = normalise_fqn(class_fqn)
|
|
127
|
+
entry = all_entries.find do |e|
|
|
128
|
+
e[:kind] == "method" && e[:class_fqn] == key && e[:name] == method_name.to_s
|
|
129
|
+
end
|
|
130
|
+
entry && method_payload(entry)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Flat list of every entity (classes + methods).
|
|
134
|
+
def index
|
|
135
|
+
ensure_index
|
|
136
|
+
all_entries.map do |e|
|
|
137
|
+
clean = e.dup
|
|
138
|
+
clean.delete(:_private)
|
|
139
|
+
clean
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# ── MCP-style mirrors ────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def self.mcp_search(query, k: 5, project_root: nil, source: "all", include_private: false)
|
|
146
|
+
cached(project_root).search(query, k: k, source: source, include_private: include_private)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.mcp_method(class_fqn, name, project_root: nil)
|
|
150
|
+
cached(project_root).method_spec(class_fqn, name)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.mcp_class(fqn, project_root: nil)
|
|
154
|
+
cached(project_root).class_spec(fqn)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ── Drift detector ───────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
# Scan a markdown file for method-call references that don't exist in
|
|
160
|
+
# the live index. Returns `{drift: [...]}`.
|
|
161
|
+
def self.check_docs(md_file_path, project_root: nil)
|
|
162
|
+
return { drift: [], error: "file not found: #{md_file_path}" } unless File.file?(md_file_path)
|
|
163
|
+
|
|
164
|
+
project_root ||= File.dirname(File.expand_path(md_file_path))
|
|
165
|
+
docs = cached(project_root)
|
|
166
|
+
idx = docs.index
|
|
167
|
+
known = idx.each_with_object({}) do |e, acc|
|
|
168
|
+
acc[e[:name].to_s.downcase] = true if e[:kind] == "method"
|
|
169
|
+
end
|
|
170
|
+
allow = STDLIB_ALLOWLIST.map(&:downcase).to_set
|
|
171
|
+
|
|
172
|
+
text = File.read(md_file_path, encoding: "utf-8", invalid: :replace, undef: :replace)
|
|
173
|
+
drift = []
|
|
174
|
+
|
|
175
|
+
# Patterns: var.method(, Class::method(, Class#method(, var->method(
|
|
176
|
+
patterns = [
|
|
177
|
+
/(?:\b[a-z_][\w]*|\$\w+)\s*[.](\w+)\s*\(/,
|
|
178
|
+
/\b[A-Z]\w*::(\w+)\s*\(/,
|
|
179
|
+
/\b[A-Z]\w*#(\w+)\s*\(/,
|
|
180
|
+
/\$\w+->(\w+)\s*\(/,
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
in_block = false
|
|
184
|
+
text.lines.each_with_index do |line, i|
|
|
185
|
+
if line.lstrip.start_with?("```")
|
|
186
|
+
in_block = !in_block
|
|
187
|
+
next
|
|
188
|
+
end
|
|
189
|
+
next unless in_block
|
|
190
|
+
|
|
191
|
+
patterns.each do |pat|
|
|
192
|
+
line.scan(pat) do |captures|
|
|
193
|
+
name = captures.first
|
|
194
|
+
name_lc = name.to_s.downcase
|
|
195
|
+
next if allow.include?(name_lc)
|
|
196
|
+
next if known[name_lc]
|
|
197
|
+
drift << {
|
|
198
|
+
method: name,
|
|
199
|
+
line: i + 1,
|
|
200
|
+
block: line.strip,
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
{ drift: drift }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Overwrite the `<!-- BEGIN GENERATED API -->` block in the markdown
|
|
210
|
+
# file. Append a fresh block if the markers are absent.
|
|
211
|
+
def self.sync_docs(md_file_path, project_root: nil)
|
|
212
|
+
project_root ||= (File.file?(md_file_path) ? File.dirname(File.expand_path(md_file_path)) : Dir.pwd)
|
|
213
|
+
docs = cached(project_root)
|
|
214
|
+
generated = docs.send(:render_generated_block)
|
|
215
|
+
begin_marker = "<!-- BEGIN GENERATED API -->"
|
|
216
|
+
end_marker = "<!-- END GENERATED API -->"
|
|
217
|
+
existing = File.file?(md_file_path) ? File.read(md_file_path, encoding: "utf-8") : ""
|
|
218
|
+
|
|
219
|
+
if existing.include?(begin_marker) && existing.include?(end_marker)
|
|
220
|
+
b = existing.index(begin_marker)
|
|
221
|
+
e = existing.index(end_marker)
|
|
222
|
+
if b && e && e > b
|
|
223
|
+
before = existing[0, b + begin_marker.length]
|
|
224
|
+
after = existing[e..-1]
|
|
225
|
+
File.write(md_file_path, "#{before}\n#{generated}\n#{after}")
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
block = "\n\n#{begin_marker}\n#{generated}\n#{end_marker}\n"
|
|
231
|
+
File.write(md_file_path, existing.rstrip + block)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# ── Internal: cached MCP instance ────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def self.cached(project_root)
|
|
237
|
+
key = File.expand_path((project_root || Dir.pwd).to_s)
|
|
238
|
+
@_mcp_mutex.synchronize do
|
|
239
|
+
@_mcp_instances ||= {}
|
|
240
|
+
@_mcp_instances[key] ||= new(key)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def self.reset_cache!
|
|
245
|
+
@_mcp_mutex ||= Mutex.new
|
|
246
|
+
@_mcp_mutex.synchronize do
|
|
247
|
+
@_mcp_instances = {}
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ── Internal: index lifecycle ────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
def all_entries
|
|
256
|
+
(@framework_entries || []) + (@user_entries || [])
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def ensure_index
|
|
260
|
+
@framework_entries ||= build_framework_index
|
|
261
|
+
current = current_user_mtime
|
|
262
|
+
if @user_entries.nil? || current != @user_mtime
|
|
263
|
+
@user_entries = build_user_index
|
|
264
|
+
@user_mtime = current
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def current_user_mtime
|
|
269
|
+
max = 0
|
|
270
|
+
USER_DIRS.each do |sub|
|
|
271
|
+
root = File.join(@project_root, "src", sub)
|
|
272
|
+
next unless File.directory?(root)
|
|
273
|
+
Dir.glob(File.join(root, "**", "*.rb")).each do |f|
|
|
274
|
+
mt = File.mtime(f).to_f rescue 0
|
|
275
|
+
max = mt if mt > max
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
max
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# ── Internal: framework reflection ───────────────────────────────
|
|
282
|
+
|
|
283
|
+
def build_framework_index
|
|
284
|
+
entries = []
|
|
285
|
+
Dir.glob(File.join(@framework_root, "**", "*.rb")).each do |path|
|
|
286
|
+
parse_ruby_file(path, "framework", entries)
|
|
287
|
+
end
|
|
288
|
+
entries
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# ── Internal: user reflection ────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
def build_user_index
|
|
294
|
+
entries = []
|
|
295
|
+
USER_DIRS.each do |sub|
|
|
296
|
+
root = File.join(@project_root, "src", sub)
|
|
297
|
+
next unless File.directory?(root)
|
|
298
|
+
Dir.glob(File.join(root, "**", "*.rb")).each do |path|
|
|
299
|
+
parse_ruby_file(path, "user", entries)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
entries
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# ── Parsing — regex-based AST-lite ───────────────────────────────
|
|
306
|
+
|
|
307
|
+
# Parse a Ruby source file into class + method entries. Tracks nested
|
|
308
|
+
# `module`/`class` declarations to assemble `Foo::Bar` FQNs and pairs
|
|
309
|
+
# each `def` with the leading comment block.
|
|
310
|
+
def parse_ruby_file(abs_path, source, entries)
|
|
311
|
+
text = File.read(abs_path, encoding: "utf-8", invalid: :replace, undef: :replace)
|
|
312
|
+
return if text.bytesize > 1024 * 1024 # 1MB sanity cap
|
|
313
|
+
|
|
314
|
+
rel_file = relative_path(abs_path, source)
|
|
315
|
+
stack = [] # [{ kind:, name:, line:, doc:, fqn: }]
|
|
316
|
+
pending_doc = []
|
|
317
|
+
pending_visibility = nil
|
|
318
|
+
visibility_scope = {} # fqn => current visibility
|
|
319
|
+
|
|
320
|
+
text.each_line.with_index(1) do |raw_line, lineno|
|
|
321
|
+
line = raw_line.rstrip
|
|
322
|
+
stripped = line.lstrip
|
|
323
|
+
|
|
324
|
+
if stripped.start_with?("#")
|
|
325
|
+
# Skip shebang and frozen-string magic comments.
|
|
326
|
+
unless stripped.start_with?("#!") || stripped.start_with?("# frozen_string_literal")
|
|
327
|
+
pending_doc << stripped.sub(/\A#\s?/, "")
|
|
328
|
+
end
|
|
329
|
+
next
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Track scope-changing visibility modifiers on their own line.
|
|
333
|
+
if stack.last && %w[private protected public].include?(stripped.split(/\s/).first)
|
|
334
|
+
# `private` alone on a line — switches default for the class.
|
|
335
|
+
first_word = stripped.split(/\s/).first
|
|
336
|
+
if stripped.match?(/\A(private|protected|public)\s*\z/)
|
|
337
|
+
current_class = stack.reverse.find { |s| s[:kind] == "class" }
|
|
338
|
+
visibility_scope[current_class[:fqn]] = first_word if current_class
|
|
339
|
+
pending_doc.clear
|
|
340
|
+
next
|
|
341
|
+
end
|
|
342
|
+
# `private :foo` symbol form — apply to that one method
|
|
343
|
+
if (m = stripped.match(/\A(private|protected|public)\s+:(\w+)/))
|
|
344
|
+
mname = m[2]
|
|
345
|
+
target = entries.reverse.find { |e| e[:kind] == "method" && e[:name] == mname }
|
|
346
|
+
target[:visibility] = m[1] if target
|
|
347
|
+
target[:_private] = (m[1] != "public") if target
|
|
348
|
+
pending_doc.clear
|
|
349
|
+
next
|
|
350
|
+
end
|
|
351
|
+
if (m = stripped.match(/\A(private|protected|public)\s+def\s+/))
|
|
352
|
+
pending_visibility = m[1]
|
|
353
|
+
stripped = stripped.sub(/\A(private|protected|public)\s+/, "")
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Module / class declarations
|
|
358
|
+
if (m = stripped.match(/\Amodule\s+([A-Z]\w*(?:::[A-Z]\w*)*)/))
|
|
359
|
+
name = m[1]
|
|
360
|
+
fqn = qualify(stack, name)
|
|
361
|
+
stack << { kind: "module", name: name, line: lineno, doc: pending_doc.dup, fqn: fqn,
|
|
362
|
+
indent: leading_indent(line) }
|
|
363
|
+
pending_doc.clear
|
|
364
|
+
next
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
if (m = stripped.match(/\Aclass\s+([A-Z]\w*(?:::[A-Z]\w*)*)(?:\s*<\s*[\w:]+)?/))
|
|
368
|
+
name = m[1]
|
|
369
|
+
fqn = qualify(stack, name)
|
|
370
|
+
stack << { kind: "class", name: name, line: lineno, doc: pending_doc.dup, fqn: fqn,
|
|
371
|
+
indent: leading_indent(line) }
|
|
372
|
+
summary, doc = split_doc(pending_doc)
|
|
373
|
+
entries << {
|
|
374
|
+
fqn: fqn,
|
|
375
|
+
kind: "class",
|
|
376
|
+
name: name.split("::").last,
|
|
377
|
+
signature: "class #{name}",
|
|
378
|
+
summary: summary,
|
|
379
|
+
docstring: doc,
|
|
380
|
+
file: rel_file,
|
|
381
|
+
line: lineno,
|
|
382
|
+
version: @version,
|
|
383
|
+
source: source,
|
|
384
|
+
visibility: "public",
|
|
385
|
+
}
|
|
386
|
+
visibility_scope[fqn] = "public"
|
|
387
|
+
pending_doc.clear
|
|
388
|
+
next
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# `end` — pop matching module/class. Match by indent so we don't
|
|
392
|
+
# confuse method-end with class-end.
|
|
393
|
+
if stripped == "end" || stripped.start_with?("end ") || stripped.start_with?("end\t")
|
|
394
|
+
if stack.last && leading_indent(line) == stack.last[:indent]
|
|
395
|
+
stack.pop
|
|
396
|
+
next
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Method definition.
|
|
401
|
+
if (m = stripped.match(/\Adef\s+(self\.)?([A-Za-z_][\w]*[!?=]?)(.*?)(?:\z|\s*#|;)/))
|
|
402
|
+
is_static = !m[1].nil?
|
|
403
|
+
mname = m[2]
|
|
404
|
+
rest = m[3].to_s.strip
|
|
405
|
+
# Capture args until the matching close paren or end-of-line.
|
|
406
|
+
if rest.start_with?("(")
|
|
407
|
+
args = balanced_paren(rest)
|
|
408
|
+
sig_args = args
|
|
409
|
+
elsif rest.empty?
|
|
410
|
+
sig_args = "()"
|
|
411
|
+
else
|
|
412
|
+
sig_args = "(#{rest})"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
current_class = stack.reverse.find { |s| s[:kind] == "class" }
|
|
416
|
+
if current_class.nil?
|
|
417
|
+
pending_doc.clear
|
|
418
|
+
pending_visibility = nil
|
|
419
|
+
next
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
base_visibility = visibility_scope[current_class[:fqn]] || "public"
|
|
423
|
+
visibility = pending_visibility || base_visibility
|
|
424
|
+
# Names starting with `_` are treated as private for search filtering.
|
|
425
|
+
underscored = mname.start_with?("_")
|
|
426
|
+
private_flag = visibility != "public" || underscored
|
|
427
|
+
|
|
428
|
+
summary, doc = split_doc(pending_doc)
|
|
429
|
+
method_fqn = if is_static
|
|
430
|
+
"#{current_class[:fqn]}.#{mname}"
|
|
431
|
+
else
|
|
432
|
+
"#{current_class[:fqn]}##{mname}"
|
|
433
|
+
end
|
|
434
|
+
signature = is_static ? "self.#{mname}#{sig_args}" : "#{mname}#{sig_args}"
|
|
435
|
+
|
|
436
|
+
entries << {
|
|
437
|
+
fqn: method_fqn,
|
|
438
|
+
kind: "method",
|
|
439
|
+
name: mname,
|
|
440
|
+
class_fqn: current_class[:fqn],
|
|
441
|
+
class: current_class[:fqn],
|
|
442
|
+
signature: signature,
|
|
443
|
+
summary: summary,
|
|
444
|
+
docstring: doc,
|
|
445
|
+
file: rel_file,
|
|
446
|
+
line: lineno,
|
|
447
|
+
version: @version,
|
|
448
|
+
source: source,
|
|
449
|
+
visibility: visibility,
|
|
450
|
+
static: is_static,
|
|
451
|
+
_private: private_flag,
|
|
452
|
+
}
|
|
453
|
+
pending_doc.clear
|
|
454
|
+
pending_visibility = nil
|
|
455
|
+
next
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Anything else clears the pending docblock.
|
|
459
|
+
pending_doc.clear unless stripped.empty?
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def qualify(stack, name)
|
|
464
|
+
return name if name.start_with?("::")
|
|
465
|
+
parts = stack.map { |s| s[:name] } + [name]
|
|
466
|
+
parts.join("::")
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def leading_indent(line)
|
|
470
|
+
line[/\A[ \t]*/].length
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Read a balanced parenthesised string starting at `s[0] == "("`.
|
|
474
|
+
def balanced_paren(s)
|
|
475
|
+
depth = 0
|
|
476
|
+
out = +""
|
|
477
|
+
s.each_char do |ch|
|
|
478
|
+
out << ch
|
|
479
|
+
if ch == "("
|
|
480
|
+
depth += 1
|
|
481
|
+
elsif ch == ")"
|
|
482
|
+
depth -= 1
|
|
483
|
+
break if depth.zero?
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
out
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def split_doc(lines)
|
|
490
|
+
cleaned = lines.map(&:strip).reject { |l| l.empty? || l.start_with?("@") }
|
|
491
|
+
summary = cleaned.first.to_s
|
|
492
|
+
[summary[0, 240], cleaned.join(" ")]
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# ── Relative path ────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
def relative_path(abs_path, source)
|
|
498
|
+
abs = File.expand_path(abs_path)
|
|
499
|
+
if source == "framework"
|
|
500
|
+
# Framework-relative — strip the gem root so files appear as
|
|
501
|
+
# "lib/tina4/response.rb".
|
|
502
|
+
if abs.start_with?(@gem_root + File::SEPARATOR)
|
|
503
|
+
return abs.sub(@gem_root + File::SEPARATOR, "")
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
project = @project_root + File::SEPARATOR
|
|
507
|
+
return abs.sub(project, "") if abs.start_with?(project)
|
|
508
|
+
abs
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# ── Ranking ──────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
CAMEL_RE = /(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/.freeze
|
|
514
|
+
|
|
515
|
+
SPLIT_RE = /[\s_\-.\/:,;()\[\]{}\\]+/.freeze
|
|
516
|
+
|
|
517
|
+
def tokenise(text)
|
|
518
|
+
return [] if text.nil? || text.empty?
|
|
519
|
+
t = text.gsub(CAMEL_RE, " ").downcase
|
|
520
|
+
t.split(SPLIT_RE).reject(&:empty?)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def score_entry(entry, tokens, joined)
|
|
524
|
+
name = entry[:name].to_s.downcase
|
|
525
|
+
stripped = name.sub(/\A_+/, "")
|
|
526
|
+
summary = entry[:summary].to_s.downcase
|
|
527
|
+
doc = entry[:docstring].to_s.downcase
|
|
528
|
+
score = 0.0
|
|
529
|
+
|
|
530
|
+
# 5: exact name match (case-insensitive) on the joined query.
|
|
531
|
+
if name == joined || stripped == joined
|
|
532
|
+
score += 5
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
name_tokens = tokenise(entry[:name].to_s)
|
|
536
|
+
tokens.each do |tk|
|
|
537
|
+
next if tk.empty?
|
|
538
|
+
if name.start_with?(tk) || stripped.start_with?(tk)
|
|
539
|
+
score += 3
|
|
540
|
+
next
|
|
541
|
+
end
|
|
542
|
+
name_tokens.each do |nt|
|
|
543
|
+
if nt == tk
|
|
544
|
+
score += 3
|
|
545
|
+
break
|
|
546
|
+
elsif nt.start_with?(tk)
|
|
547
|
+
score += 2
|
|
548
|
+
break
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# 2: per token in summary.
|
|
554
|
+
tokens.each do |tk|
|
|
555
|
+
score += 2 if !tk.empty? && summary.include?(tk)
|
|
556
|
+
end
|
|
557
|
+
# 1: per token in docstring body.
|
|
558
|
+
tokens.each do |tk|
|
|
559
|
+
score += 1 if !tk.empty? && doc.include?(tk)
|
|
560
|
+
end
|
|
561
|
+
# Substring fallback — full joined query inside the name.
|
|
562
|
+
if !joined.empty? && score.zero? && name.include?(joined)
|
|
563
|
+
score += 2
|
|
564
|
+
end
|
|
565
|
+
score
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# ── Spec helpers ─────────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
def method_payload(entry)
|
|
571
|
+
{
|
|
572
|
+
name: entry[:name],
|
|
573
|
+
fqn: entry[:fqn],
|
|
574
|
+
class: entry[:class_fqn],
|
|
575
|
+
kind: "method",
|
|
576
|
+
signature: entry[:signature],
|
|
577
|
+
summary: entry[:summary],
|
|
578
|
+
docblock: entry[:docstring],
|
|
579
|
+
file: entry[:file],
|
|
580
|
+
line: entry[:line],
|
|
581
|
+
visibility: entry[:visibility],
|
|
582
|
+
static: entry[:static] || false,
|
|
583
|
+
source: entry[:source],
|
|
584
|
+
version: entry[:version],
|
|
585
|
+
params: [],
|
|
586
|
+
return: "",
|
|
587
|
+
}
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# ── Sync writer ──────────────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
def render_generated_block
|
|
593
|
+
ensure_index
|
|
594
|
+
lines = []
|
|
595
|
+
lines << "_Generated by `Tina4::Docs` — version #{@version}._"
|
|
596
|
+
lines << ""
|
|
597
|
+
lines << "## Framework classes"
|
|
598
|
+
lines << ""
|
|
599
|
+
fw_classes = (@framework_entries || []).select { |e| e[:kind] == "class" }.sort_by { |e| e[:fqn] }
|
|
600
|
+
method_counts = Hash.new(0)
|
|
601
|
+
(@framework_entries || []).each do |e|
|
|
602
|
+
method_counts[e[:class_fqn]] += 1 if e[:kind] == "method"
|
|
603
|
+
end
|
|
604
|
+
fw_classes.each do |c|
|
|
605
|
+
summary = c[:summary].to_s.empty? ? "" : " — #{c[:summary]}"
|
|
606
|
+
lines << "- `#{c[:fqn]}` (#{method_counts[c[:fqn]]} methods)#{summary}"
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
user_classes = (@user_entries || []).select { |e| e[:kind] == "class" }.sort_by { |e| e[:fqn] }
|
|
610
|
+
unless user_classes.empty?
|
|
611
|
+
lines << ""
|
|
612
|
+
lines << "## User code"
|
|
613
|
+
lines << ""
|
|
614
|
+
user_classes.each do |c|
|
|
615
|
+
summary = c[:summary].to_s.empty? ? "" : " — #{c[:summary]}"
|
|
616
|
+
lines << "- `#{c[:fqn]}`#{summary}"
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
lines.join("\n")
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# ── Bootstrap helpers ────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
def detect_version
|
|
626
|
+
if defined?(Tina4::VERSION) && !Tina4::VERSION.to_s.empty?
|
|
627
|
+
return Tina4::VERSION.to_s
|
|
628
|
+
end
|
|
629
|
+
"0.0.0"
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def normalise_fqn(fqn)
|
|
633
|
+
fqn.to_s.sub(/\A::/, "")
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -855,6 +855,21 @@ module Tina4
|
|
|
855
855
|
Tina4::Plan.flesh(name, prompt)
|
|
856
856
|
}, "Auto-generate concrete build steps via AI and append them to an existing plan")
|
|
857
857
|
|
|
858
|
+
# ── Live API Docs (Live API RAG) ──────────────────
|
|
859
|
+
server.register_tool("api_search", lambda { |query:, k: 5, source: "all", include_private: false|
|
|
860
|
+
Tina4::Docs.cached(project_root).search(
|
|
861
|
+
query.to_s, k: k.to_i, source: source.to_s, include_private: include_private == true || include_private.to_s == "true"
|
|
862
|
+
)
|
|
863
|
+
}, "Search the live API index (framework + user code) for ranked hits")
|
|
864
|
+
|
|
865
|
+
server.register_tool("api_class", lambda { |name:|
|
|
866
|
+
Tina4::Docs.cached(project_root).class_spec(name.to_s)
|
|
867
|
+
}, "Full class reflection (methods, file, line) from the live API index")
|
|
868
|
+
|
|
869
|
+
server.register_tool("api_method", lambda { |class_name:, name:|
|
|
870
|
+
Tina4::Docs.cached(project_root).method_spec(class_name.to_s, name.to_s)
|
|
871
|
+
}, "Single method spec (signature, summary, file, line) from the live API index")
|
|
872
|
+
|
|
858
873
|
# ── System Tools ──────────────────────────────────
|
|
859
874
|
server.register_tool("system_info", lambda {
|
|
860
875
|
{
|