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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Comprehensive semantic document model for extracted documentation.
6
+ #
7
+ # This module defines immutable data structures that capture ALL information
8
+ # from YARD and RBS sources. The goal is complete semantic extraction,
9
+ # independent of rendering concerns.
10
+ #
11
+ # Design principles:
12
+ # - Capture everything, filter/format at render time
13
+ # - Prefer explicit nil over missing keys
14
+ # - Use typed structures (Data.define) for compile-time safety
15
+ # - Group related data (e.g., yield info together)
16
+ #
17
+ # @see TODO.md for the complete tag inventory this model addresses
18
+ module DocumentModel
19
+ # Parameter documentation (method param or @option entry).
20
+ #
21
+ # @example Basic parameter
22
+ # ParamDoc.new(name: "id", type: "String", description: "User ID", default: nil)
23
+ #
24
+ # @example Optional with default
25
+ # ParamDoc.new(name: "limit", type: "Integer", description: "Max results", default: "10")
26
+ ParamDoc = Data.define(
27
+ :name, # String - parameter name (cleaned, no sigils)
28
+ :type, # String? - RBS/YARD type expression
29
+ :description, # String? - description text
30
+ :default, # String? - default value (as string from source)
31
+ :prefix # String? - "*", "**", "&" for splat/block params
32
+ ) do
33
+ def self.from_hash(h)
34
+ new(
35
+ name: h[:name]&.to_s,
36
+ type: normalize_type(h[:types]),
37
+ description: h[:text],
38
+ default: h[:default],
39
+ prefix: extract_prefix(h[:name])
40
+ )
41
+ end
42
+
43
+ def self.normalize_type(types)
44
+ return nil if types.nil? || types.empty?
45
+
46
+ Array(types).first&.to_s
47
+ end
48
+
49
+ def self.extract_prefix(name)
50
+ s = name.to_s
51
+ return "**" if s.start_with?("**")
52
+ return "*" if s.start_with?("*")
53
+ return "&" if s.start_with?("&")
54
+
55
+ nil
56
+ end
57
+ end
58
+
59
+ # @option tag documentation (hash parameter options).
60
+ OptionDoc = Data.define(
61
+ :param_name, # String - the hash param this option belongs to
62
+ :key, # String - the option key name
63
+ :type, # String? - option value type
64
+ :description # String? - description
65
+ )
66
+
67
+ # Block/yield documentation.
68
+ #
69
+ # Captures @yield, @yieldparam, and @yieldreturn together.
70
+ YieldDoc = Data.define(
71
+ :description, # String? - @yield description
72
+ :params, # Array[ParamDoc] - @yieldparam entries
73
+ :return_type, # String? - @yieldreturn type
74
+ :return_desc, # String? - @yieldreturn description
75
+ :block_type # String? - RBS block signature like "^(Batch) -> void"
76
+ )
77
+
78
+ # Exception documentation.
79
+ RaiseDoc = Data.define(
80
+ :type, # String - exception class name
81
+ :description # String? - when/why raised
82
+ )
83
+
84
+ # Return type documentation.
85
+ ReturnDoc = Data.define(
86
+ :type, # String? - return type
87
+ :description # String? - what it returns
88
+ )
89
+
90
+ # Example documentation.
91
+ ExampleDoc = Data.define(
92
+ :name, # String? - example name/title
93
+ :code # String - example code
94
+ )
95
+
96
+ # Cross-reference (@see tag).
97
+ SeeDoc = Data.define(
98
+ :target, # String - what to see (class, method, URL)
99
+ :text # String? - additional context
100
+ )
101
+
102
+ # Instance variable documentation.
103
+ IvarDoc = Data.define(
104
+ :name, # String - ivar name without @
105
+ :type, # String? - RBS type
106
+ :description # String? - description
107
+ )
108
+
109
+ # Constant documentation.
110
+ ConstantDoc = Data.define(
111
+ :name, # String - constant name
112
+ :value, # String? - constant value (stringified)
113
+ :type, # String? - RBS type if declared
114
+ :description # String? - docstring
115
+ )
116
+
117
+ # Type alias documentation.
118
+ TypeAliasDoc = Data.define(
119
+ :name, # String - alias name
120
+ :definition, # String - RBS type definition
121
+ :description, # String? - description
122
+ :namespace # String - where defined
123
+ )
124
+
125
+ # Method signature overload.
126
+ OverloadDoc = Data.define(
127
+ :signature, # String - full RBS signature
128
+ :description # String? - description for this overload
129
+ )
130
+
131
+ # Method documentation - comprehensive capture of all method info.
132
+ MethodDoc = Data.define(
133
+ # Identity
134
+ :name, # Symbol - method name
135
+ :scope, # Symbol - :class or :instance
136
+ :visibility, # Symbol - :public, :protected, :private
137
+ :signature, # String - Ruby signature from YARD
138
+
139
+ # Documentation
140
+ :docstring, # String? - main docstring
141
+ :params, # Array[ParamDoc] - parameters
142
+ :options, # Array[OptionDoc] - @option entries
143
+ :returns, # ReturnDoc? - return info
144
+ :yields, # YieldDoc? - block/yield info
145
+ :raises, # Array[RaiseDoc] - exceptions
146
+ :examples, # Array[ExampleDoc] - @example tags
147
+ :notes, # Array[String] - @note entries
148
+ :see_also, # Array[SeeDoc] - @see entries
149
+
150
+ # Metadata
151
+ :api, # String? - @api value (private, public, internal)
152
+ :deprecated, # String? - deprecation message or true/"" if just tagged
153
+ :abstract, # bool - is abstract?
154
+ :since, # String? - @since version
155
+ :todo, # String? - @todo message
156
+
157
+ # RBS-specific
158
+ :rbs_signature, # String? - full RBS signature
159
+ :overloads, # Array[OverloadDoc] - method overloads from RBS
160
+
161
+ # Source
162
+ :source, # String? - source code
163
+ :source_body_lines, # Integer? - body line count (for inline display threshold)
164
+ :attr_type, # Symbol? - :reader, :writer, :accessor if attr method
165
+ :file, # String? - source file
166
+ :line, # Integer? - line number
167
+
168
+ # Spec integration
169
+ :spec_examples, # Array[Hash] - examples from specs
170
+ :spec_behaviors # Array[String] - behavior descriptions
171
+ )
172
+
173
+ # Attribute documentation (synthesized from reader/writer pairs).
174
+ AttributeDoc = Data.define(
175
+ :name, # String - attribute name
176
+ :type, # String? - type from RBS or YARD
177
+ :description, # String? - description
178
+ :mode, # Symbol - :read, :write, :read_write
179
+ :reader, # MethodDoc? - reader method doc
180
+ :writer # MethodDoc? - writer method doc
181
+ )
182
+
183
+ # Class or module documentation.
184
+ NamespaceDoc = Data.define(
185
+ # Identity
186
+ :name, # String - short name
187
+ :path, # String - full path (Foo::Bar)
188
+ :type, # Symbol - :class or :module
189
+ :superclass, # String? - superclass path (classes only)
190
+
191
+ # Documentation
192
+ :docstring, # String? - main docstring
193
+ :examples, # Array[ExampleDoc] - @example tags
194
+ :notes, # Array[String] - @note entries
195
+ :see_also, # Array[SeeDoc] - @see entries
196
+
197
+ # Metadata
198
+ :api, # String? - @api value
199
+ :deprecated, # String? - deprecation message
200
+ :abstract, # bool - is abstract?
201
+ :since, # String? - @since version
202
+ :todo, # String? - @todo message
203
+
204
+ # Relationships
205
+ :includes, # Array[String] - included modules
206
+ :extends, # Array[String] - extended modules
207
+
208
+ # Members
209
+ :constants, # Array[ConstantDoc]
210
+ :type_aliases, # Array[TypeAliasDoc] - local type aliases
211
+ :ivars, # Array[IvarDoc] - instance variables
212
+ :attributes, # Array[AttributeDoc] - synthesized attributes
213
+ :methods, # Array[MethodDoc] - public methods
214
+ :private_methods, # Array[MethodDoc] - private/protected (may be minimal)
215
+
216
+ # Source
217
+ :file, # String? - source file
218
+ :line, # Integer? - start line
219
+ :end_line, # Integer? - end line
220
+ :rbs_file, # String? - corresponding RBS file
221
+
222
+ # Spec integration
223
+ :spec_examples, # Hash? - class-level spec examples
224
+
225
+ # Cross-references (populated after extraction)
226
+ :referenced_types # Array[TypeAliasDoc] - types used by this class
227
+ )
228
+
229
+ # Documentation for a single source file.
230
+ #
231
+ # Groups all namespaces (classes/modules) defined in one Ruby file.
232
+ # This is the primary unit for per-file documentation output.
233
+ FileDoc = Data.define(
234
+ :path, # String - source file path (relative to project root)
235
+ :namespaces, # Array[NamespaceDoc] - classes/modules in this file
236
+ :type_aliases, # Array[TypeAliasDoc] - type aliases defined in this file
237
+ :line_count # Integer? - total lines in source file
238
+ ) do
239
+ # Short filename for display (e.g., "attributes.rb")
240
+ def filename = File.basename(path)
241
+
242
+ # Directory portion (e.g., "lib/archema")
243
+ def dirname = File.dirname(path)
244
+
245
+ # Main namespace - the one that best represents this file's purpose.
246
+ #
247
+ # Selection order:
248
+ # 1. Namespace whose name matches filename (query.rb -> Query)
249
+ # 2. Module (often the container for nested classes)
250
+ # 3. Shortest path (top-level namespace)
251
+ # 4. Most content as tiebreaker
252
+ def primary_namespace
253
+ return namespaces.first if namespaces.size == 1
254
+ return nil if namespaces.empty?
255
+
256
+ basename = File.basename(path, ".rb")
257
+ # Convert snake_case to variations for matching
258
+ name_variants = [
259
+ basename, # document_model
260
+ basename.gsub("_", ""), # documentmodel
261
+ basename.split("_").map(&:capitalize).join # DocumentModel
262
+ ]
263
+
264
+ # Try to find namespace whose name matches filename
265
+ name_match = namespaces.find do |n|
266
+ name_variants.any? { |v| n.name.downcase == v.downcase }
267
+ end
268
+ return name_match if name_match
269
+
270
+ # Prefer modules (they're usually the container)
271
+ modules_list = namespaces.select { |n| n.type == :module }
272
+ if modules_list.any?
273
+ # Among modules, prefer shortest path (top-level)
274
+ return modules_list.min_by { |n| n.path.count("::") }
275
+ end
276
+
277
+ # Among classes, prefer shortest path
278
+ namespaces.min_by { |n| n.path.count("::") }
279
+ end
280
+
281
+ def classes = namespaces.select { |n| n.type == :class }
282
+ def modules = namespaces.select { |n| n.type == :module }
283
+ end
284
+
285
+ # Complete documentation structure for a project.
286
+ ProjectDoc = Data.define(
287
+ :title, # String - project title
288
+ :description, # String? - project description
289
+ :namespaces, # Array[NamespaceDoc] - all documented classes/modules
290
+ :files, # Array[FileDoc] - per-file documentation (for per-file output)
291
+ :type_aliases, # Hash[String, Array[TypeAliasDoc]] - global type aliases
292
+ :generated_at # Time - generation timestamp
293
+ ) do
294
+ def classes = namespaces.select { |n| n.type == :class }
295
+ def modules = namespaces.select { |n| n.type == :module }
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Detects when documentation is out of sync with source code.
6
+ #
7
+ # Compares what would be generated against existing files.
8
+ # Useful in CI pipelines to enforce documentation currency.
9
+ class DriftChecker
10
+ def initialize(
11
+ output,
12
+ namespace_strip,
13
+ include_specs,
14
+ verbose,
15
+ logger,
16
+ root: Dir.pwd,
17
+ github_repo: nil,
18
+ github_branch: "main",
19
+ project_title: "API Documentation",
20
+ inline_source_threshold: 10
21
+ )
22
+ @output = output
23
+ @namespace_strip = namespace_strip
24
+ @include_specs = include_specs
25
+ @verbose = verbose
26
+ @logger = logger
27
+ @renderer = Renderer.new(
28
+ namespace_strip: namespace_strip,
29
+ include_specs: include_specs,
30
+ root: root,
31
+ github_repo: github_repo,
32
+ github_branch: github_branch,
33
+ project_title: project_title,
34
+ inline_source_threshold: inline_source_threshold
35
+ )
36
+ end
37
+
38
+ # Check for drift between source and existing documentation.
39
+ #
40
+ # @param structure [Hash] Documentation structure from Extractor
41
+ # @raise [SystemExit] Exits with code 1 if drift is detected
42
+ def check(structure)
43
+ @renderer.register_classes(structure)
44
+
45
+ drifted = []
46
+ missing = []
47
+ orphaned = find_orphaned_files(structure)
48
+
49
+ check_index(structure, drifted, missing)
50
+ check_objects(structure[:classes] + structure[:modules], drifted, missing)
51
+
52
+ report_results(drifted, missing, orphaned)
53
+ end
54
+
55
+ private
56
+
57
+ def check_index(structure, drifted, missing)
58
+ path = File.join(@output, "index.md")
59
+ expected = @renderer.render_index(structure)
60
+ check_file(path, expected, drifted, missing)
61
+ end
62
+
63
+ def check_objects(objects, drifted, missing)
64
+ objects.each do |obj|
65
+ next unless obj[:needs_regeneration]
66
+
67
+ path = output_path(obj[:path])
68
+ expected = obj[:type] == :class ? @renderer.render_class(obj) : @renderer.render_module(obj)
69
+ check_file(path, expected, drifted, missing)
70
+ end
71
+ end
72
+
73
+ def check_file(path, expected, drifted, missing)
74
+ if File.exist?(path)
75
+ actual = File.read(path)
76
+ drifted << path if content_changed?(actual, expected)
77
+ else
78
+ missing << path
79
+ end
80
+ end
81
+
82
+ def find_orphaned_files(structure)
83
+ return [] unless File.directory?(@output)
84
+
85
+ expected_files = Set.new
86
+ expected_files << File.join(@output, "index.md")
87
+
88
+ (structure[:classes] + structure[:modules]).each do |obj|
89
+ expected_files << output_path(obj[:path])
90
+ end
91
+
92
+ actual_files = Dir.glob("#{@output}/**/*.md")
93
+ actual_files.reject { |f| expected_files.include?(f) }
94
+ end
95
+
96
+ def content_changed?(old, new) = normalize(old) != normalize(new)
97
+
98
+ def normalize(content)
99
+ content
100
+ .gsub(/^generated: .+$/, "generated: TIMESTAMP")
101
+ .gsub(/\n{2,}/, "\n\n")
102
+ .strip
103
+ end
104
+
105
+ def report_results(drifted, missing, orphaned)
106
+ total_issues = drifted.size + missing.size + orphaned.size
107
+
108
+ if total_issues.zero?
109
+ @logger.info " No drift detected. Documentation is up to date."
110
+ return
111
+ end
112
+
113
+ @logger.warn "Documentation drift detected!"
114
+ @logger.warn ""
115
+ report_list("Drifted (content changed)", drifted)
116
+ report_list("Missing (new classes)", missing)
117
+ report_list("Orphaned (classes removed)", orphaned)
118
+ @logger.warn ""
119
+ @logger.warn "Run 'chiridion refresh' to update documentation."
120
+
121
+ exit 1
122
+ end
123
+
124
+ def report_list(label, files)
125
+ return if files.empty?
126
+
127
+ @logger.warn " #{label}:"
128
+ files.each { |f| @logger.warn " - #{f}" }
129
+ end
130
+
131
+ def output_path(class_path)
132
+ stripped = @namespace_strip ? class_path.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : class_path
133
+ parts = stripped.split("::")
134
+ kebab_parts = parts.map { |p| to_kebab_case(p) }
135
+ File.join(@output, *kebab_parts[0..-2], "#{kebab_parts.last}.md")
136
+ end
137
+
138
+ def to_kebab_case(str)
139
+ str.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
140
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
141
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
142
+ .downcase
143
+ end
144
+ end
145
+ end
146
+ end