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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/lib/chiridion/config.rb +128 -0
- data/lib/chiridion/engine/class_linker.rb +204 -0
- data/lib/chiridion/engine/document_model.rb +299 -0
- data/lib/chiridion/engine/drift_checker.rb +146 -0
- data/lib/chiridion/engine/extractor.rb +311 -0
- data/lib/chiridion/engine/file_renderer.rb +717 -0
- data/lib/chiridion/engine/file_writer.rb +160 -0
- data/lib/chiridion/engine/frontmatter_builder.rb +248 -0
- data/lib/chiridion/engine/generated_rbs_loader.rb +344 -0
- data/lib/chiridion/engine/github_linker.rb +87 -0
- data/lib/chiridion/engine/inline_rbs_loader.rb +207 -0
- data/lib/chiridion/engine/post_processor.rb +86 -0
- data/lib/chiridion/engine/rbs_loader.rb +150 -0
- data/lib/chiridion/engine/rbs_type_alias_loader.rb +116 -0
- data/lib/chiridion/engine/renderer.rb +598 -0
- data/lib/chiridion/engine/semantic_extractor.rb +740 -0
- data/lib/chiridion/engine/semantic_renderer.rb +334 -0
- data/lib/chiridion/engine/spec_example_loader.rb +84 -0
- data/lib/chiridion/engine/template_renderer.rb +275 -0
- data/lib/chiridion/engine/type_merger.rb +126 -0
- data/lib/chiridion/engine/writer.rb +134 -0
- data/lib/chiridion/engine.rb +359 -0
- data/lib/chiridion/semantic_engine.rb +186 -0
- data/lib/chiridion/version.rb +5 -0
- data/lib/chiridion.rb +106 -0
- data/templates/constants.liquid +27 -0
- data/templates/document.liquid +48 -0
- data/templates/file.liquid +108 -0
- data/templates/index.liquid +21 -0
- data/templates/method.liquid +43 -0
- data/templates/methods.liquid +11 -0
- data/templates/type_aliases.liquid +26 -0
- data/templates/types.liquid +11 -0
- 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"
|