chiridion 0.3.4

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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +201 -0
  5. data/lib/chiridion/config.rb +128 -0
  6. data/lib/chiridion/engine/class_linker.rb +204 -0
  7. data/lib/chiridion/engine/document_model.rb +299 -0
  8. data/lib/chiridion/engine/drift_checker.rb +146 -0
  9. data/lib/chiridion/engine/extractor.rb +311 -0
  10. data/lib/chiridion/engine/file_renderer.rb +717 -0
  11. data/lib/chiridion/engine/file_writer.rb +160 -0
  12. data/lib/chiridion/engine/frontmatter_builder.rb +248 -0
  13. data/lib/chiridion/engine/generated_rbs_loader.rb +344 -0
  14. data/lib/chiridion/engine/github_linker.rb +87 -0
  15. data/lib/chiridion/engine/inline_rbs_loader.rb +207 -0
  16. data/lib/chiridion/engine/post_processor.rb +86 -0
  17. data/lib/chiridion/engine/rbs_loader.rb +150 -0
  18. data/lib/chiridion/engine/rbs_type_alias_loader.rb +116 -0
  19. data/lib/chiridion/engine/renderer.rb +598 -0
  20. data/lib/chiridion/engine/semantic_extractor.rb +740 -0
  21. data/lib/chiridion/engine/semantic_renderer.rb +334 -0
  22. data/lib/chiridion/engine/spec_example_loader.rb +84 -0
  23. data/lib/chiridion/engine/template_renderer.rb +275 -0
  24. data/lib/chiridion/engine/type_merger.rb +126 -0
  25. data/lib/chiridion/engine/writer.rb +134 -0
  26. data/lib/chiridion/engine.rb +359 -0
  27. data/lib/chiridion/semantic_engine.rb +186 -0
  28. data/lib/chiridion/version.rb +5 -0
  29. data/lib/chiridion.rb +106 -0
  30. data/templates/constants.liquid +27 -0
  31. data/templates/document.liquid +48 -0
  32. data/templates/file.liquid +108 -0
  33. data/templates/index.liquid +21 -0
  34. data/templates/method.liquid +43 -0
  35. data/templates/methods.liquid +11 -0
  36. data/templates/type_aliases.liquid +26 -0
  37. data/templates/types.liquid +11 -0
  38. metadata +146 -0
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Merges RBS type signatures with YARD documentation.
6
+ #
7
+ # RBS is treated as authoritative for types. When YARD and RBS disagree,
8
+ # a warning is logged but RBS types are used. This ensures documentation
9
+ # reflects the actual type contracts defined in sig/*.rbs.
10
+ class TypeMerger
11
+ # Known type equivalences between YARD conventions and RBS.
12
+ BOOLEAN_TYPES = %w[bool TrueClass FalseClass].freeze
13
+ GENERIC_PREFIXES = { "Hash" => "Hash[", "Array" => "Array[" }.freeze
14
+
15
+ def initialize(logger = nil) = @logger = logger
16
+
17
+ # Merge YARD params with RBS types - RBS is authoritative.
18
+ #
19
+ # @param yard_params [Array<Hash>] Parameters from YARD
20
+ # @param rbs_data [Hash, nil] RBS signature data
21
+ # @param class_path [String] Class path for warnings
22
+ # @param method_name [Symbol] Method name for warnings
23
+ # @return [Array<Hash>] Merged parameters
24
+ def merge_params(yard_params, rbs_data, class_path, method_name)
25
+ return yard_params unless rbs_data&.dig(:params)
26
+
27
+ rbs_params = rbs_data[:params]
28
+ yard_params.map { |p| merge_single_param(p, rbs_params, class_path, method_name) }
29
+ end
30
+
31
+ # Merge YARD return with RBS return type - RBS is authoritative.
32
+ #
33
+ # @param yard_return [Hash, nil] Return info from YARD
34
+ # @param rbs_data [Hash, nil] RBS signature data
35
+ # @param class_path [String] Class path for warnings
36
+ # @param method_name [Symbol] Method name for warnings
37
+ # @return [Hash, nil] Merged return info
38
+ def merge_return(yard_return, rbs_data, class_path, method_name)
39
+ return yard_return unless rbs_data&.dig(:returns)
40
+
41
+ rbs_return_data = rbs_data[:returns]
42
+
43
+ # Handle both old format (string) and new format ({ type:, desc: })
44
+ rbs_type = rbs_return_data.is_a?(Hash) ? rbs_return_data[:type] : rbs_return_data
45
+ rbs_desc = rbs_return_data.is_a?(Hash) ? rbs_return_data[:desc] : nil
46
+
47
+ if yard_return
48
+ check_return_mismatch(yard_return, rbs_type, class_path, method_name)
49
+ merged_desc = merge_description(yard_return[:text], rbs_desc)
50
+ yard_return.merge(types: [rbs_type], text: merged_desc)
51
+ else
52
+ { types: [rbs_type], text: rbs_desc }
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def merge_single_param(param, rbs_params, class_path, method_name)
59
+ param_name = clean_param_name(param[:name])
60
+ rbs_data = rbs_params[param_name]
61
+ return param unless rbs_data
62
+
63
+ # Handle both old format (string) and new format ({ type:, desc: })
64
+ rbs_type = rbs_data.is_a?(Hash) ? rbs_data[:type] : rbs_data
65
+ rbs_desc = rbs_data.is_a?(Hash) ? rbs_data[:desc] : nil
66
+
67
+ check_param_mismatch(param, rbs_type, class_path, method_name, param_name)
68
+
69
+ merged_desc = merge_description(param[:text], rbs_desc)
70
+ param.merge(types: [rbs_type], text: merged_desc)
71
+ end
72
+
73
+ # Merge descriptions - longer one wins, tie goes to RBS (co-located).
74
+ def merge_description(yard_desc, rbs_desc)
75
+ return rbs_desc if yard_desc.to_s.strip.empty?
76
+ return yard_desc if rbs_desc.to_s.strip.empty?
77
+
78
+ rbs_desc.to_s.length >= yard_desc.to_s.length ? rbs_desc : yard_desc
79
+ end
80
+
81
+ def clean_param_name(name) = name.to_s.gsub(/\A[*&]+/, "").delete_suffix(":")
82
+
83
+ def check_param_mismatch(param, rbs_type, class_path, method_name, param_name)
84
+ yard_type = param[:types]&.join(", ")
85
+ return if yard_type.nil? || types_compatible?(yard_type, rbs_type)
86
+
87
+ warn_mismatch(class_path, method_name, param_name, yard_type, rbs_type)
88
+ end
89
+
90
+ def check_return_mismatch(yard_return, rbs_return, class_path, method_name)
91
+ yard_type = yard_return[:types]&.join(", ")
92
+ return if yard_type.nil? || types_compatible?(yard_type, rbs_return)
93
+
94
+ warn_mismatch(class_path, method_name, "(return)", yard_type, rbs_return)
95
+ end
96
+
97
+ # Check if YARD and RBS types are compatible (loose comparison).
98
+ def types_compatible?(yard_type, rbs_type)
99
+ return true if yard_type.nil? || rbs_type.nil?
100
+
101
+ yard_norm = normalize_type(yard_type)
102
+ rbs_norm = normalize_type(rbs_type)
103
+
104
+ exact_or_prefix_match?(yard_norm, rbs_norm) || equivalent_types?(yard_norm, rbs_norm)
105
+ end
106
+
107
+ def exact_or_prefix_match?(yard_norm, rbs_norm) = yard_norm == rbs_norm || rbs_norm.start_with?(yard_norm)
108
+
109
+ def equivalent_types?(yard_norm, rbs_norm)
110
+ return true if yard_norm == "Boolean" && BOOLEAN_TYPES.include?(rbs_norm)
111
+
112
+ prefix = GENERIC_PREFIXES[yard_norm]
113
+ prefix && rbs_norm.start_with?(prefix)
114
+ end
115
+
116
+ def normalize_type(type) = type.to_s.gsub(/\s+/, "").tr("<", "[").tr(">", "]")
117
+
118
+ def warn_mismatch(class_path, method_name, param_name, yard_type, rbs_type)
119
+ return unless @logger
120
+
121
+ @logger.warn "Type mismatch in #{class_path}##{method_name} param '#{param_name}': " \
122
+ "YARD says '#{yard_type}', RBS says '#{rbs_type}' (using RBS)"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Chiridion
6
+ class Engine
7
+ # Writes generated documentation files to disk.
8
+ #
9
+ # Handles smart write detection to avoid unnecessary file updates when
10
+ # only timestamps have changed but content is identical.
11
+ class Writer
12
+ def initialize(
13
+ output,
14
+ namespace_strip,
15
+ include_specs,
16
+ verbose,
17
+ logger,
18
+ root: Dir.pwd,
19
+ github_repo: nil,
20
+ github_branch: "main",
21
+ project_title: "API Documentation",
22
+ index_description: nil,
23
+ inline_source_threshold: 10,
24
+ rbs_attr_types: {}
25
+ )
26
+ @output = output
27
+ @namespace_strip = namespace_strip
28
+ @verbose = verbose
29
+ @logger = logger
30
+ @root = root
31
+ @renderer = Renderer.new(
32
+ namespace_strip: namespace_strip,
33
+ include_specs: include_specs,
34
+ root: root,
35
+ github_repo: github_repo,
36
+ github_branch: github_branch,
37
+ project_title: project_title,
38
+ index_description: index_description,
39
+ inline_source_threshold: inline_source_threshold,
40
+ rbs_attr_types: rbs_attr_types
41
+ )
42
+ end
43
+
44
+ # Write all documentation files.
45
+ #
46
+ # @param structure [Hash] Documentation structure from Extractor
47
+ def write(structure)
48
+ FileUtils.mkdir_p(@output)
49
+ written, skipped = write_all_files(structure)
50
+ @logger.info " #{written} files written, #{skipped} unchanged"
51
+ end
52
+
53
+ private
54
+
55
+ def write_all_files(structure)
56
+ @renderer.register_classes(structure)
57
+
58
+ counts = { written: 0, skipped: 0 }
59
+ write_index(structure, counts)
60
+ write_type_aliases(structure[:type_aliases], counts)
61
+ write_objects(structure[:classes] + structure[:modules], counts)
62
+ [counts[:written], counts[:skipped]]
63
+ end
64
+
65
+ def write_type_aliases(type_aliases, counts)
66
+ return if type_aliases.nil? || type_aliases.empty?
67
+
68
+ content = @renderer.render_type_aliases(type_aliases)
69
+ return if content.nil?
70
+
71
+ wrote = write_file(File.join(@output, "type-aliases.md"), content)
72
+ counts[wrote ? :written : :skipped] += 1
73
+ end
74
+
75
+ def write_index(structure, counts)
76
+ wrote = write_file(File.join(@output, "index.md"),
77
+ @renderer.render_index(structure))
78
+ counts[wrote ? :written : :skipped] += 1
79
+ end
80
+
81
+ def write_objects(objects, counts)
82
+ objects.each do |obj|
83
+ next unless obj[:needs_regeneration]
84
+
85
+ write_object(obj, counts)
86
+ end
87
+ end
88
+
89
+ def write_object(obj, counts)
90
+ path = output_path(obj[:path])
91
+ content = obj[:type] == :class ? @renderer.render_class(obj) : @renderer.render_module(obj)
92
+
93
+ FileUtils.mkdir_p(File.dirname(path))
94
+ wrote = write_file(path, content)
95
+
96
+ counts[wrote ? :written : :skipped] += 1
97
+ @logger.info " #{wrote ? 'Wrote' : 'Unchanged'} #{path}" if @verbose
98
+ end
99
+
100
+ def write_file(path, new_content)
101
+ return File.write(path, new_content) || true unless File.exist?(path)
102
+
103
+ old_content = File.read(path)
104
+ return false unless content_changed?(old_content, new_content)
105
+
106
+ File.write(path, new_content)
107
+ true
108
+ end
109
+
110
+ def content_changed?(old, new) = normalize(old) != normalize(new)
111
+
112
+ def normalize(content)
113
+ content
114
+ .gsub(/^generated: .+$/, "generated: TIMESTAMP")
115
+ .gsub(/\n{2,}/, "\n\n")
116
+ .strip
117
+ end
118
+
119
+ def output_path(class_path)
120
+ stripped = @namespace_strip ? class_path.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : class_path
121
+ parts = stripped.split("::")
122
+ kebab_parts = parts.map { |p| to_kebab_case(p) }
123
+ File.join(@output, *kebab_parts[0..-2], "#{kebab_parts.last}.md")
124
+ end
125
+
126
+ def to_kebab_case(str)
127
+ str.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
128
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
129
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
130
+ .downcase
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "logger"
5
+
6
+ module Chiridion
7
+ # Documentation engine for generating agent-oriented docs from Ruby source.
8
+ #
9
+ # Coordinates several specialized components:
10
+ # - {Extractor} - Walks YARD registry, extracts class/method/constant metadata
11
+ # - {RbsLoader} - Loads RBS type signatures from sig/ directory
12
+ # - {SpecExampleLoader} - Extracts usage examples from RSpec files
13
+ # - {TypeMerger} - Merges RBS types with YARD documentation
14
+ # - {Renderer} - Generates markdown with Obsidian-compatible wikilinks
15
+ # - {Writer} - Handles file I/O with content-based change detection
16
+ # - {DriftChecker} - Detects when docs are out of sync with source
17
+ #
18
+ # ## YARD Registry Persistence
19
+ #
20
+ # For performance, the engine persists YARD's parsed registry to .yardoc/.
21
+ # This enables efficient partial refreshes: when a single file changes, we
22
+ # load the existing registry, re-parse only that file, and regenerate only
23
+ # the affected documentation.
24
+ #
25
+ # @example Generate docs via Engine
26
+ # engine = Chiridion::Engine.new(
27
+ # paths: ['lib/myproject'],
28
+ # output: 'docs/sys',
29
+ # namespace_filter: 'MyProject::'
30
+ # )
31
+ # engine.refresh
32
+ #
33
+ # @example Partial refresh (single file)
34
+ # engine = Chiridion::Engine.new(
35
+ # paths: ['lib/myproject/config.rb'],
36
+ # output: 'docs/sys',
37
+ # namespace_filter: 'MyProject::'
38
+ # )
39
+ # engine.refresh
40
+ #
41
+ class Engine
42
+ # @return [Array<String>] Source paths being documented
43
+ attr_reader :paths
44
+
45
+ # @return [String] Output directory for generated docs
46
+ attr_reader :output
47
+
48
+ # Create a new documentation engine.
49
+ #
50
+ # @param paths [Array<String>] Source files or directories to document.
51
+ # Can be specific files for partial refresh or directories for full refresh.
52
+ # @param output [String] Output directory for generated markdown docs.
53
+ # @param namespace_filter [String, nil] Only document classes starting with this prefix.
54
+ # @param namespace_strip [String, nil] Strip this prefix from output paths (defaults to namespace_filter).
55
+ # @param include_specs [Boolean] Whether to extract usage examples from spec files.
56
+ # @param verbose [Boolean] Whether to show detailed progress and warnings.
57
+ # @param logger [#info, #warn, nil] Logger for output messages.
58
+ # @param root [String] Project root directory for resolving relative paths.
59
+ # @param rbs_path [String] Path to RBS signatures directory.
60
+ # @param spec_path [String] Path to spec directory.
61
+ # @param github_repo [String, nil] GitHub repository for source links.
62
+ # @param github_branch [String] Git branch for source links.
63
+ # @param project_title [String] Title for the documentation index.
64
+ # @param index_description [String, nil] Custom description for the index page.
65
+ # @param inline_source_threshold [Integer, nil] Max body lines for inline source display.
66
+ # @param output_mode [:per_class, :per_file] Output organization strategy.
67
+ def initialize(
68
+ paths:,
69
+ output:,
70
+ namespace_filter: nil,
71
+ namespace_strip: nil,
72
+ include_specs: false,
73
+ verbose: false,
74
+ logger: nil,
75
+ root: Dir.pwd,
76
+ rbs_path: "sig",
77
+ spec_path: "test",
78
+ github_repo: nil,
79
+ github_branch: "main",
80
+ project_title: "API Documentation",
81
+ index_description: nil,
82
+ inline_source_threshold: 10,
83
+ output_mode: :per_class
84
+ )
85
+ @paths = Array(paths)
86
+ @output = output
87
+ @namespace_filter = namespace_filter
88
+ @namespace_strip = namespace_strip || namespace_filter
89
+ @include_specs = include_specs
90
+ @verbose = verbose
91
+ @logger = logger || DefaultLogger.new
92
+ @root = root
93
+ @rbs_path = rbs_path
94
+ @spec_path = spec_path
95
+ @github_repo = github_repo
96
+ @github_branch = github_branch
97
+ @project_title = project_title
98
+ @index_description = index_description
99
+ @inline_source_threshold = inline_source_threshold
100
+ @output_mode = output_mode
101
+ end
102
+
103
+ # Generate documentation from source and write to output directory.
104
+ #
105
+ # This is the main entry point for documentation generation. It:
106
+ # 1. Parses Ruby source files with YARD
107
+ # 2. Loads RBS type signatures
108
+ # 3. Extracts spec examples (if enabled)
109
+ # 4. Merges types with YARD docs
110
+ # 5. Renders to markdown with wikilinks
111
+ # 6. Writes files with content-based change detection
112
+ #
113
+ # @return [void]
114
+ def refresh
115
+ require "yard"
116
+ register_rbs_tag
117
+
118
+ @logger.info "Parsing Ruby files in #{paths_description}..."
119
+
120
+ load_sources
121
+
122
+ if @output_mode == :per_file
123
+ refresh_per_file
124
+ else
125
+ doc_structure = extract_documentation(YARD::Registry)
126
+ write_documentation(doc_structure)
127
+ end
128
+
129
+ @logger.info "Documentation written to #{@output}/"
130
+ end
131
+
132
+ # Generate per-file documentation using the semantic extraction pipeline.
133
+ #
134
+ # Produces one markdown file per source file, containing all namespaces
135
+ # (classes/modules) defined in that file.
136
+ #
137
+ # @return [void]
138
+ def refresh_per_file
139
+ extractor = SemanticExtractor.new(
140
+ rbs_types: @rbs_types,
141
+ rbs_attr_types: @rbs_attr_types,
142
+ type_aliases: @type_aliases,
143
+ spec_examples: @spec_examples,
144
+ namespace_filter: @namespace_filter,
145
+ logger: @logger
146
+ )
147
+
148
+ project = extractor.extract(
149
+ YARD::Registry,
150
+ title: @project_title,
151
+ description: @index_description,
152
+ root: @root
153
+ )
154
+
155
+ writer = FileWriter.new(
156
+ output: @output,
157
+ namespace_strip: @namespace_strip,
158
+ include_specs: @include_specs,
159
+ verbose: @verbose,
160
+ logger: @logger,
161
+ root: @root,
162
+ github_repo: @github_repo,
163
+ github_branch: @github_branch,
164
+ project_title: @project_title,
165
+ index_description: @index_description,
166
+ inline_source_threshold: @inline_source_threshold
167
+ )
168
+
169
+ writer.write(project)
170
+ end
171
+
172
+ # Check for documentation drift without writing files.
173
+ #
174
+ # Compares what would be generated against existing docs. Useful in CI
175
+ # to ensure docs are kept in sync with source code changes.
176
+ #
177
+ # @return [void]
178
+ # @raise [SystemExit] Exits with code 1 if drift is detected
179
+ def check
180
+ require "yard"
181
+ register_rbs_tag
182
+
183
+ @logger.info "Checking documentation drift for #{paths_description}..."
184
+
185
+ load_sources
186
+ doc_structure = extract_documentation(YARD::Registry)
187
+ check_for_drift(doc_structure)
188
+ end
189
+
190
+ private
191
+
192
+ def paths_description = @paths.size == 1 ? @paths.first : "#{@paths.size} paths"
193
+
194
+ # Register @rbs as a known YARD tag to suppress "Unknown tag" warnings
195
+ def register_rbs_tag
196
+ return if YARD::Tags::Library.labels.key?(:rbs)
197
+
198
+ YARD::Tags::Library.define_tag("RBS type annotation", :rbs, :with_types_and_name)
199
+ end
200
+
201
+ def load_sources
202
+ # Suppress YARD's verbose proxy warnings unless in verbose mode
203
+ original_log_level = YARD::Logger.instance.level
204
+ YARD::Logger.instance.level = @verbose ? ::Logger::WARN : ::Logger::ERROR
205
+
206
+ @source_files = @paths.flat_map { |p| resolve_ruby_files(p) }.map { |f| File.expand_path(f) }
207
+
208
+ if partial_refresh?
209
+ load_or_create_registry
210
+ else
211
+ YARD::Registry.clear
212
+ end
213
+ YARD.parse(@source_files)
214
+ YARD::Registry.save(true)
215
+
216
+ YARD::Logger.instance.level = original_log_level
217
+
218
+ # Load RBS types: inline annotations take precedence over sig/ files
219
+ inline_types, @rbs_file_namespaces, @rbs_attr_types = InlineRbsLoader.new(@verbose, @logger).load(@source_files)
220
+ sig_types = RbsLoader.new(@rbs_path, @verbose, @logger).load
221
+ @rbs_types = merge_rbs_types(sig_types, inline_types)
222
+
223
+ # Load type aliases from generated RBS files (sig/generated/ is standard for RBS::Inline)
224
+ rbs_generated_dir = find_rbs_generated_dir
225
+ @type_aliases = RbsTypeAliasLoader.new(@verbose, @logger, rbs_dir: rbs_generated_dir).load
226
+
227
+ @spec_examples = @include_specs ? SpecExampleLoader.new(@spec_path, @verbose, @logger).load : {}
228
+ end
229
+
230
+ def partial_refresh? = @paths.all? { |p| File.file?(p) }
231
+
232
+ def load_or_create_registry
233
+ yardoc_path = File.join(@root, ".yardoc")
234
+ if File.exist?(yardoc_path)
235
+ YARD::Registry.load(yardoc_path)
236
+ else
237
+ YARD::Registry.clear
238
+ end
239
+ end
240
+
241
+ # Locate directory containing generated RBS files.
242
+ #
243
+ # RBS::Inline outputs to sig/generated/ by convention. Falls back to
244
+ # sig/ if generated/ doesn't exist, or nil if no RBS directory exists.
245
+ def find_rbs_generated_dir
246
+ generated_dir = File.join(@root, @rbs_path, "generated")
247
+ return generated_dir if Dir.exist?(generated_dir)
248
+
249
+ sig_dir = File.join(@root, @rbs_path)
250
+ return sig_dir if Dir.exist?(sig_dir)
251
+
252
+ nil
253
+ end
254
+
255
+ def resolve_ruby_files(path)
256
+ if File.directory?(path)
257
+ Dir.glob("#{path}/**/*.rb")
258
+ elsif File.file?(path) && path.end_with?(".rb")
259
+ [path]
260
+ else
261
+ @logger.warn "Skipping invalid path: #{path}"
262
+ []
263
+ end
264
+ end
265
+
266
+ def extract_documentation(registry)
267
+ source_filter = partial_refresh? ? @source_files : nil
268
+ structure = Extractor.new(
269
+ @rbs_types,
270
+ @spec_examples,
271
+ @namespace_filter,
272
+ @logger,
273
+ rbs_file_namespaces: @rbs_file_namespaces,
274
+ type_aliases: @type_aliases
275
+ ).extract(registry, source_filter: source_filter)
276
+
277
+ # Add type aliases to the structure (for standalone reference page)
278
+ structure[:type_aliases] = @type_aliases
279
+ structure
280
+ end
281
+
282
+ def write_documentation(structure)
283
+ Writer.new(
284
+ @output,
285
+ @namespace_strip,
286
+ @include_specs,
287
+ @verbose,
288
+ @logger,
289
+ root: @root,
290
+ github_repo: @github_repo,
291
+ github_branch: @github_branch,
292
+ project_title: @project_title,
293
+ index_description: @index_description,
294
+ inline_source_threshold: @inline_source_threshold,
295
+ rbs_attr_types: @rbs_attr_types
296
+ ).write(structure)
297
+ end
298
+
299
+ def check_for_drift(structure)
300
+ DriftChecker.new(
301
+ @output,
302
+ @namespace_strip,
303
+ @include_specs,
304
+ @verbose,
305
+ @logger,
306
+ root: @root,
307
+ github_repo: @github_repo,
308
+ github_branch: @github_branch,
309
+ project_title: @project_title,
310
+ inline_source_threshold: @inline_source_threshold
311
+ ).check(structure)
312
+ end
313
+
314
+ # Merge RBS types from sig/ files and inline annotations.
315
+ # Inline types take precedence over sig/ file types.
316
+ def merge_rbs_types(sig_types, inline_types)
317
+ merged = sig_types.dup
318
+
319
+ inline_types.each do |class_name, methods|
320
+ merged[class_name] ||= {}
321
+ methods.each do |method_name, sig|
322
+ merged[class_name][method_name] = sig
323
+ end
324
+ end
325
+
326
+ merged
327
+ end
328
+ end
329
+
330
+ # Simple default logger that prints to stderr.
331
+ class DefaultLogger
332
+ def info(msg) = Kernel.warn(msg)
333
+ def warn(msg) = Kernel.warn("WARNING: #{msg}")
334
+ def error(msg) = Kernel.warn("ERROR: #{msg}")
335
+ end
336
+ end
337
+
338
+ # Load engine subcomponents
339
+ require_relative "engine/extractor"
340
+ require_relative "engine/rbs_loader"
341
+ require_relative "engine/inline_rbs_loader"
342
+ require_relative "engine/rbs_type_alias_loader"
343
+ require_relative "engine/spec_example_loader"
344
+ require_relative "engine/type_merger"
345
+ require_relative "engine/class_linker"
346
+ require_relative "engine/github_linker"
347
+ require_relative "engine/frontmatter_builder"
348
+ require_relative "engine/template_renderer"
349
+ require_relative "engine/renderer"
350
+ require_relative "engine/writer"
351
+ require_relative "engine/drift_checker"
352
+
353
+ # Semantic extraction and per-file output pipeline
354
+ require_relative "engine/document_model"
355
+ require_relative "engine/semantic_extractor"
356
+ require_relative "engine/semantic_renderer"
357
+ require_relative "engine/file_renderer"
358
+ require_relative "engine/file_writer"
359
+ require_relative "engine/post_processor"