Class: Tina4::Docs

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/docs.rb

Constant Summary collapse

USER_DIRS =

── Constants ────────────────────────────────────────────────────

%w[orm routes app services].freeze
STDLIB_ALLOWLIST =

Method names commonly referenced in markdown that must NOT be flagged as drift even when not present in the live index.

%w[
  puts print p pp inspect to_s to_a to_h to_i to_f to_sym
  keys values each map select reject reduce inject filter
  length size count first last empty? include? has_key?
  push pop shift unshift slice splice
  get set put patch post delete head options
  json html xml render redirect text file stream call
  strip lstrip rstrip chomp chop split join replace gsub sub
  upcase downcase capitalize to_str
  new initialize freeze dup clone tap then yield_self
  raise rescue retry begin ensure end
  require require_relative load
  assert assert_equal assert_nil assert_not_nil expect should
].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(project_root) ⇒ Docs

── Construction ─────────────────────────────────────────────────



48
49
50
51
52
53
54
55
56
# File 'lib/tina4/docs.rb', line 48

def initialize(project_root)
  @project_root = File.expand_path(project_root.to_s)
  @framework_root = File.expand_path(File.dirname(__FILE__))
  @gem_root = File.expand_path(File.join(@framework_root, ".."))
  @version = detect_version
  @framework_entries = nil
  @user_entries = nil
  @user_mtime = 0
end

Instance Attribute Details

#project_rootObject (readonly)

Returns the value of attribute project_root.



58
59
60
# File 'lib/tina4/docs.rb', line 58

def project_root
  @project_root
end

Class Method Details

.cached(project_root) ⇒ Object

── Internal: cached MCP instance ────────────────────────────────



236
237
238
239
240
241
242
# File 'lib/tina4/docs.rb', line 236

def self.cached(project_root)
  key = File.expand_path((project_root || Dir.pwd).to_s)
  @_mcp_mutex.synchronize do
    @_mcp_instances ||= {}
    @_mcp_instances[key] ||= new(key)
  end
end

.check_docs(md_file_path, project_root: nil) ⇒ Object

Scan a markdown file for method-call references that don’t exist in the live index. Returns ‘[…]`.



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/tina4/docs.rb', line 161

def self.check_docs(md_file_path, project_root: nil)
  return { drift: [], error: "file not found: #{md_file_path}" } unless File.file?(md_file_path)

  project_root ||= File.dirname(File.expand_path(md_file_path))
  docs = cached(project_root)
  idx = docs.index
  known = idx.each_with_object({}) do |e, acc|
    acc[e[:name].to_s.downcase] = true if e[:kind] == "method"
  end
  allow = STDLIB_ALLOWLIST.map(&:downcase).to_set

  text = File.read(md_file_path, encoding: "utf-8", invalid: :replace, undef: :replace)
  drift = []

  # Patterns: var.method(, Class::method(, Class#method(, var->method(
  patterns = [
    /(?:\b[a-z_][\w]*|\$\w+)\s*[.](\w+)\s*\(/,
    /\b[A-Z]\w*::(\w+)\s*\(/,
    /\b[A-Z]\w*#(\w+)\s*\(/,
    /\$\w+->(\w+)\s*\(/,
  ]

  in_block = false
  text.lines.each_with_index do |line, i|
    if line.lstrip.start_with?("```")
      in_block = !in_block
      next
    end
    next unless in_block

    patterns.each do |pat|
      line.scan(pat) do |captures|
        name = captures.first
        name_lc = name.to_s.downcase
        next if allow.include?(name_lc)
        next if known[name_lc]
        drift << {
          method: name,
          line:   i + 1,
          block:  line.strip,
        }
      end
    end
  end

  { drift: drift }
end

.mcp_class(fqn, project_root: nil) ⇒ Object



153
154
155
# File 'lib/tina4/docs.rb', line 153

def self.mcp_class(fqn, project_root: nil)
  cached(project_root).class_spec(fqn)
end

.mcp_method(class_fqn, name, project_root: nil) ⇒ Object



149
150
151
# File 'lib/tina4/docs.rb', line 149

def self.mcp_method(class_fqn, name, project_root: nil)
  cached(project_root).method_spec(class_fqn, name)
end

.mcp_search(query, k: 5, project_root: nil, source: "all", include_private: false) ⇒ Object

── MCP-style mirrors ────────────────────────────────────────────



145
146
147
# File 'lib/tina4/docs.rb', line 145

def self.mcp_search(query, k: 5, project_root: nil, source: "all", include_private: false)
  cached(project_root).search(query, k: k, source: source, include_private: include_private)
end

.reset_cache!Object



244
245
246
247
248
249
# File 'lib/tina4/docs.rb', line 244

def self.reset_cache!
  @_mcp_mutex ||= Mutex.new
  @_mcp_mutex.synchronize do
    @_mcp_instances = {}
  end
end

.sync_docs(md_file_path, project_root: nil) ⇒ Object

Overwrite the ‘<!– BEGIN GENERATED API –>` block in the markdown file. Append a fresh block if the markers are absent.



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/tina4/docs.rb', line 211

def self.sync_docs(md_file_path, project_root: nil)
  project_root ||= (File.file?(md_file_path) ? File.dirname(File.expand_path(md_file_path)) : Dir.pwd)
  docs = cached(project_root)
  generated = docs.send(:render_generated_block)
  begin_marker = "<!-- BEGIN GENERATED API -->"
  end_marker   = "<!-- END GENERATED API -->"
  existing = File.file?(md_file_path) ? File.read(md_file_path, encoding: "utf-8") : ""

  if existing.include?(begin_marker) && existing.include?(end_marker)
    b = existing.index(begin_marker)
    e = existing.index(end_marker)
    if b && e && e > b
      before = existing[0, b + begin_marker.length]
      after  = existing[e..-1]
      File.write(md_file_path, "#{before}\n#{generated}\n#{after}")
      return
    end
  end

  block = "\n\n#{begin_marker}\n#{generated}\n#{end_marker}\n"
  File.write(md_file_path, existing.rstrip + block)
end

Instance Method Details

#class_spec(fqn) ⇒ Object

Full reflection of a single class — ‘nil` for unknown.



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/tina4/docs.rb', line 99

def class_spec(fqn)
  ensure_index
  key = normalise_fqn(fqn)
  class_entry = all_entries.find { |e| e[:kind] == "class" && e[:fqn] == key }
  return nil if class_entry.nil?

  methods = all_entries.select do |m|
    m[:kind] == "method" && m[:class_fqn] == class_entry[:fqn] && m[:visibility] == "public"
  end.map { |m| method_payload(m) }

  {
    fqn:        class_entry[:fqn],
    kind:       "class",
    name:       class_entry[:name],
    file:       class_entry[:file],
    line:       class_entry[:line],
    summary:    class_entry[:summary],
    source:     class_entry[:source],
    version:    class_entry[:version],
    methods:    methods,
    properties: [],
  }
end

#indexObject

Flat list of every entity (classes + methods).



134
135
136
137
138
139
140
141
# File 'lib/tina4/docs.rb', line 134

def index
  ensure_index
  all_entries.map do |e|
    clean = e.dup
    clean.delete(:_private)
    clean
  end
end

#method_spec(class_fqn, method_name) ⇒ Object

Single method spec — ‘nil` for unknown.



124
125
126
127
128
129
130
131
# File 'lib/tina4/docs.rb', line 124

def method_spec(class_fqn, method_name)
  ensure_index
  key = normalise_fqn(class_fqn)
  entry = all_entries.find do |e|
    e[:kind] == "method" && e[:class_fqn] == key && e[:name] == method_name.to_s
  end
  entry && method_payload(entry)
end

#search(query, k: 5, source: "all", include_private: false) ⇒ Array<Hash>

Search the merged framework + user index for ranked hits.

Parameters:

  • query (String)

    Free-text query

  • k (Integer) (defaults to: 5)

    Top-K to return

  • source (String) (defaults to: "all")

    “all” (default), “framework”, “user”, “vendor”

  • include_private (Boolean) (defaults to: false)

    Include private/protected/_underscore methods

Returns:

  • (Array<Hash>)


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/tina4/docs.rb', line 69

def search(query, k: 5, source: "all", include_private: false)
  ensure_index
  tokens = tokenise(query.to_s)
  return [] if tokens.empty?

  joined = query.to_s.downcase.gsub(/\s+/, "")
  results = []
  all_entries.each do |entry|
    src = entry[:source]
    next if source == "all" && src == "vendor"
    next if source != "all" && src != source
    next if !include_private && entry[:_private]

    score = score_entry(entry, tokens, joined)
    next if score <= 0
    score *= 1.2 if src == "user"
    hit = entry.dup
    hit.delete(:_private)
    hit.delete(:docstring)
    hit[:score] = score.round(4)
    results << hit
  end
  results.sort! do |a, b|
    cmp = b[:score] <=> a[:score]
    cmp.nonzero? || (a[:fqn] <=> b[:fqn])
  end
  results.first([k, 1].max)
end