ace-docs 0.31.0

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 (91) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/docs/config.yml +169 -0
  3. data/.ace-defaults/docs/multi-subject-example.md +130 -0
  4. data/.ace-defaults/docs/single-subject-example.md +150 -0
  5. data/.ace-defaults/nav/protocols/guide-sources/ace-docs.yml +10 -0
  6. data/.ace-defaults/nav/protocols/prompt-sources/ace-docs.yml +34 -0
  7. data/.ace-defaults/nav/protocols/tmpl-sources/ace-docs.yml +10 -0
  8. data/.ace-defaults/nav/protocols/wfi-sources/ace-docs.yml +19 -0
  9. data/CHANGELOG.md +1082 -0
  10. data/LICENSE +21 -0
  11. data/README.md +40 -0
  12. data/Rakefile +14 -0
  13. data/exe/ace-docs +14 -0
  14. data/handbook/guides/documentation/ruby.md +16 -0
  15. data/handbook/guides/documentation/rust.md +35 -0
  16. data/handbook/guides/documentation/typescript.md +18 -0
  17. data/handbook/guides/documentation.g.md +437 -0
  18. data/handbook/guides/documents-embedded-sync.g.md +473 -0
  19. data/handbook/guides/documents-embedding.g.md +276 -0
  20. data/handbook/guides/markdown-style.g.md +290 -0
  21. data/handbook/prompts/ace-change-analyzer.system.md +113 -0
  22. data/handbook/prompts/ace-change-analyzer.user.md +95 -0
  23. data/handbook/prompts/document-analysis.md +74 -0
  24. data/handbook/prompts/document-analysis.system.md +129 -0
  25. data/handbook/prompts/markdown-style.system.md +113 -0
  26. data/handbook/skills/as-docs-create-adr/SKILL.md +35 -0
  27. data/handbook/skills/as-docs-create-api/SKILL.md +35 -0
  28. data/handbook/skills/as-docs-create-user/SKILL.md +35 -0
  29. data/handbook/skills/as-docs-maintain-adrs/SKILL.md +35 -0
  30. data/handbook/skills/as-docs-squash-changelog/SKILL.md +42 -0
  31. data/handbook/skills/as-docs-update/SKILL.md +36 -0
  32. data/handbook/skills/as-docs-update-blueprint/SKILL.md +28 -0
  33. data/handbook/skills/as-docs-update-roadmap/SKILL.md +24 -0
  34. data/handbook/skills/as-docs-update-tools/SKILL.md +36 -0
  35. data/handbook/skills/as-docs-update-usage/SKILL.md +26 -0
  36. data/handbook/templates/code-docs/javascript-jsdoc.template.md +102 -0
  37. data/handbook/templates/code-docs/ruby-yard.template.md +85 -0
  38. data/handbook/templates/project-docs/README.template.md +73 -0
  39. data/handbook/templates/project-docs/architecture.template.md +300 -0
  40. data/handbook/templates/project-docs/blueprint.template.md +165 -0
  41. data/handbook/templates/project-docs/context/ownership.yml +160 -0
  42. data/handbook/templates/project-docs/decisions/adr.template.md +60 -0
  43. data/handbook/templates/project-docs/prd.template.md +144 -0
  44. data/handbook/templates/project-docs/roadmap/roadmap.template.md +47 -0
  45. data/handbook/templates/project-docs/vision.template.md +233 -0
  46. data/handbook/templates/user-docs/user-guide.template.md +107 -0
  47. data/handbook/workflow-instructions/docs/create-adr.wf.md +334 -0
  48. data/handbook/workflow-instructions/docs/create-api.wf.md +448 -0
  49. data/handbook/workflow-instructions/docs/create-cookbook.wf.md +434 -0
  50. data/handbook/workflow-instructions/docs/create-user.wf.md +399 -0
  51. data/handbook/workflow-instructions/docs/maintain-adrs.wf.md +589 -0
  52. data/handbook/workflow-instructions/docs/squash-changelog.wf.md +246 -0
  53. data/handbook/workflow-instructions/docs/update-blueprint.wf.md +361 -0
  54. data/handbook/workflow-instructions/docs/update-context.wf.md +336 -0
  55. data/handbook/workflow-instructions/docs/update-roadmap.wf.md +421 -0
  56. data/handbook/workflow-instructions/docs/update-tools.wf.md +307 -0
  57. data/handbook/workflow-instructions/docs/update-usage.wf.md +710 -0
  58. data/handbook/workflow-instructions/docs/update.wf.md +418 -0
  59. data/lib/ace/docs/atoms/diff_filterer.rb +131 -0
  60. data/lib/ace/docs/atoms/frontmatter_free_matcher.rb +20 -0
  61. data/lib/ace/docs/atoms/git_date_resolver.rb +16 -0
  62. data/lib/ace/docs/atoms/readme_metadata_inferrer.rb +60 -0
  63. data/lib/ace/docs/atoms/terminology_extractor.rb +308 -0
  64. data/lib/ace/docs/atoms/time_range_calculator.rb +96 -0
  65. data/lib/ace/docs/atoms/timestamp_parser.rb +106 -0
  66. data/lib/ace/docs/atoms/type_inferrer.rb +70 -0
  67. data/lib/ace/docs/cli/commands/analyze.rb +351 -0
  68. data/lib/ace/docs/cli/commands/analyze_consistency.rb +185 -0
  69. data/lib/ace/docs/cli/commands/discover.rb +75 -0
  70. data/lib/ace/docs/cli/commands/scope_options.rb +71 -0
  71. data/lib/ace/docs/cli/commands/status.rb +241 -0
  72. data/lib/ace/docs/cli/commands/update.rb +198 -0
  73. data/lib/ace/docs/cli/commands/validate.rb +225 -0
  74. data/lib/ace/docs/cli.rb +60 -0
  75. data/lib/ace/docs/models/analysis_report.rb +120 -0
  76. data/lib/ace/docs/models/consistency_report.rb +259 -0
  77. data/lib/ace/docs/models/document.rb +354 -0
  78. data/lib/ace/docs/molecules/change_detector.rb +389 -0
  79. data/lib/ace/docs/molecules/document_loader.rb +133 -0
  80. data/lib/ace/docs/molecules/frontmatter_manager.rb +85 -0
  81. data/lib/ace/docs/molecules/git_date_resolver.rb +30 -0
  82. data/lib/ace/docs/organisms/cross_document_analyzer.rb +274 -0
  83. data/lib/ace/docs/organisms/document_registry.rb +318 -0
  84. data/lib/ace/docs/organisms/validator.rb +164 -0
  85. data/lib/ace/docs/prompts/compact_diff_prompt.rb +119 -0
  86. data/lib/ace/docs/prompts/consistency_prompt.rb +286 -0
  87. data/lib/ace/docs/prompts/document_analysis_prompt.rb +389 -0
  88. data/lib/ace/docs/version.rb +7 -0
  89. data/lib/ace/docs.rb +82 -0
  90. data/lib/test.rb +4 -0
  91. metadata +347 -0
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require_relative "../atoms/timestamp_parser"
6
+
7
+ module Ace
8
+ module Docs
9
+ module Models
10
+ # Model representing a managed document with frontmatter and content
11
+ class Document
12
+ attr_accessor :path, :frontmatter, :content, :doc_type, :purpose,
13
+ :update_config, :context_config, :rules, :metadata
14
+
15
+ def initialize(path: nil, frontmatter: {}, content: "")
16
+ @path = path
17
+ @frontmatter = frontmatter || {}
18
+ @content = content || ""
19
+
20
+ # Extract key fields from frontmatter
21
+ @doc_type = @frontmatter["doc-type"]
22
+ @purpose = @frontmatter["purpose"]
23
+ @update_config = @frontmatter["update"] || {}
24
+ @context_config = @frontmatter["context"] || {}
25
+ @rules = @frontmatter["rules"] || {}
26
+ @metadata = @frontmatter["metadata"] || {}
27
+
28
+ # Extract ace-docs namespace configuration
29
+ @ace_docs_config = @frontmatter["ace-docs"] || {}
30
+ end
31
+
32
+ # Check if document is managed by ace-docs
33
+ def managed?
34
+ !@doc_type.nil? && !@purpose.nil?
35
+ end
36
+
37
+ # Get the update frequency configuration
38
+ def update_frequency
39
+ @update_config["frequency"] || "on-change"
40
+ end
41
+
42
+ # Get the last updated date or datetime
43
+ #
44
+ # Polymorphic Return Type:
45
+ # - Date object for date-only timestamps (YYYY-MM-DD)
46
+ # - Time object (UTC) for ISO 8601 timestamps (YYYY-MM-DDTHH:MM:SSZ)
47
+ #
48
+ # This preserves the precision of the original timestamp format. When comparing
49
+ # dates for freshness calculations, Time objects are converted to Date objects.
50
+ #
51
+ # @return [Date, Time, nil] The last updated timestamp, or nil if not set
52
+ def last_updated
53
+ # Try ace-docs namespace first
54
+ date_str = @ace_docs_config["last-updated"]
55
+
56
+ return nil unless date_str
57
+
58
+ result = case date_str
59
+ when Date, Time
60
+ date_str # Return as-is
61
+ when String
62
+ Atoms::TimestampParser.parse_timestamp(date_str)
63
+ end
64
+
65
+ # Ensure Time objects are in UTC
66
+ result.is_a?(Time) ? result.utc : result
67
+ rescue ArgumentError
68
+ nil
69
+ end
70
+
71
+ # Get the last checked date or datetime
72
+ #
73
+ # Polymorphic Return Type:
74
+ # - Date object for date-only timestamps (YYYY-MM-DD)
75
+ # - Time object (UTC) for ISO 8601 timestamps (YYYY-MM-DDTHH:MM:SSZ)
76
+ #
77
+ # @return [Date, Time, nil] The last checked timestamp, or nil if not set
78
+ # @see #last_updated for detailed behavior documentation
79
+ def last_checked
80
+ date_str = @ace_docs_config["last-checked"] || @update_config["last-checked"]
81
+ return nil unless date_str
82
+
83
+ result = case date_str
84
+ when Date, Time
85
+ date_str # Return as-is
86
+ when String
87
+ Atoms::TimestampParser.parse_timestamp(date_str)
88
+ end
89
+
90
+ # Ensure Time objects are in UTC
91
+ result.is_a?(Time) ? result.utc : result
92
+ rescue ArgumentError
93
+ nil
94
+ end
95
+
96
+ # Check if document needs updating based on frequency and last updated date
97
+ def needs_update?
98
+ return true unless last_updated
99
+
100
+ # Convert Time to Date for comparison
101
+ last_updated_date = last_updated.is_a?(Time) ? last_updated.to_date : last_updated
102
+ days_since_update = (Date.today - last_updated_date).to_i
103
+
104
+ case update_frequency
105
+ when "daily"
106
+ days_since_update >= 1
107
+ when "weekly"
108
+ days_since_update >= 7
109
+ when "monthly"
110
+ days_since_update >= 30
111
+ when "on-change"
112
+ false # Only update when changes detected
113
+ else
114
+ false
115
+ end
116
+ end
117
+
118
+ # Get the freshness status
119
+ # Uses configurable thresholds from .ace-defaults/docs/config.yml
120
+ # Follows ADR-022 pattern for configuration loading
121
+ def freshness_status
122
+ return :unknown unless last_updated
123
+
124
+ # Convert Time to Date for comparison
125
+ last_updated_date = last_updated.is_a?(Time) ? last_updated.to_date : last_updated
126
+ days_since_update = (Date.today - last_updated_date).to_i
127
+
128
+ thresholds = freshness_thresholds
129
+
130
+ case update_frequency
131
+ when "daily"
132
+ if days_since_update == 0
133
+ :current
134
+ elsif days_since_update <= thresholds[:daily_stale]
135
+ :stale
136
+ else
137
+ :outdated
138
+ end
139
+ when "weekly"
140
+ if days_since_update <= thresholds[:weekly_current]
141
+ :current
142
+ elsif days_since_update <= thresholds[:weekly_stale]
143
+ :stale
144
+ else
145
+ :outdated
146
+ end
147
+ when "monthly"
148
+ if days_since_update <= thresholds[:monthly_current]
149
+ :current
150
+ elsif days_since_update <= thresholds[:monthly_stale]
151
+ :stale
152
+ else
153
+ :outdated
154
+ end
155
+ when "on-change"
156
+ :current # Always current for on-change documents
157
+ else
158
+ :unknown
159
+ end
160
+ end
161
+
162
+ # Get freshness thresholds from configuration
163
+ # Falls back to historical defaults if config not available
164
+ # Supports frequency-specific thresholds from .ace-defaults/docs/config.yml
165
+ # @return [Hash] Threshold values for different update frequencies
166
+ def freshness_thresholds
167
+ config_thresholds = Ace::Docs.config["default_freshness_days"] || {}
168
+
169
+ # Extract frequency-specific thresholds with historical defaults
170
+ daily_config = config_thresholds["daily"] || {}
171
+ weekly_config = config_thresholds["weekly"] || {}
172
+ monthly_config = config_thresholds["monthly"] || {}
173
+
174
+ {
175
+ # Daily frequency: current=today (0 days), stale within 2 days
176
+ daily_stale: daily_config["stale"] || 2,
177
+ # Weekly frequency: historical defaults 7/14 days
178
+ weekly_current: weekly_config["current"] || 7,
179
+ weekly_stale: weekly_config["stale"] || 14,
180
+ # Monthly frequency: historical defaults 30/45 days
181
+ monthly_current: monthly_config["current"] || 30,
182
+ monthly_stale: monthly_config["stale"] || 45
183
+ }
184
+ end
185
+
186
+ # Get the focus hints for LLM analysis
187
+ def focus_hints
188
+ @update_config["focus"] || {}
189
+ end
190
+
191
+ # Get the ace-docs configuration namespace
192
+ attr_reader :ace_docs_config
193
+
194
+ # Get subject diff filters
195
+ # @return [Array<String>] Flat array of path filters for single subject
196
+ def subject_diff_filters
197
+ # Try new format first
198
+ filters = @ace_docs_config.dig("subject", "diff", "filters")
199
+ return filters if filters && !filters.empty?
200
+
201
+ []
202
+ end
203
+
204
+ # Check if document has multi-subject configuration
205
+ # @return [Boolean] True if subject is an array of hashes
206
+ def multi_subject?
207
+ subject_config = @ace_docs_config["subject"]
208
+ subject_config.is_a?(Array)
209
+ end
210
+
211
+ # Get structured subject configurations for multi-subject support
212
+ # @return [Array<Hash>] Array of {name: String, filters: Array<String>}
213
+ def subject_configurations
214
+ subject_config = @ace_docs_config["subject"]
215
+
216
+ if subject_config.is_a?(Array)
217
+ # Multi-subject format: array of single-key hashes
218
+ # [ { "code" => { "diff" => { "filters" => [...] } } }, { "docs" => {...} } ]
219
+ subject_config.map do |subject_hash|
220
+ # Each item should be a hash with one key (the subject name)
221
+ name = subject_hash.keys.first
222
+ config = subject_hash[name] || {}
223
+ filters = config.dig("diff", "filters") || []
224
+
225
+ {
226
+ name: name,
227
+ filters: filters
228
+ }
229
+ end.reject { |s| s[:filters].empty? }
230
+ else
231
+ # No valid subject configuration
232
+ []
233
+ end
234
+ end
235
+
236
+ # Get context keywords for LLM analysis
237
+ def context_keywords
238
+ @ace_docs_config.dig("context", "keywords") || []
239
+ end
240
+
241
+ # Get the context preset
242
+ def context_preset
243
+ # Try new ace-docs namespace first
244
+ preset = @ace_docs_config.dig("context", "preset")
245
+ return preset if preset
246
+
247
+ # Fall back to legacy format
248
+ @context_config["preset"]
249
+ end
250
+
251
+ # Get additional context includes
252
+ def context_includes
253
+ @context_config["includes"] || []
254
+ end
255
+
256
+ # Get the maximum line count rule
257
+ def max_lines
258
+ @rules["max-lines"]
259
+ end
260
+
261
+ # Get required sections
262
+ def required_sections
263
+ @rules["sections"] || []
264
+ end
265
+
266
+ # Get documents to avoid duplication from
267
+ def no_duplicate_from
268
+ @rules["no-duplicate-from"] || []
269
+ end
270
+
271
+ # Get auto-generation rules
272
+ def auto_generate
273
+ @rules["auto-generate"] || []
274
+ end
275
+
276
+ # Get the document title from content
277
+ def title
278
+ # Extract first heading from content
279
+ match = @content.match(/^#\s+(.+)$/m)
280
+ match ? match[1].strip : File.basename(@path || "untitled", ".md")
281
+ end
282
+
283
+ # Get display name for document
284
+ def display_name
285
+ @path ? File.basename(@path) : "untitled.md"
286
+ end
287
+
288
+ # Get relative path from current directory
289
+ def relative_path
290
+ return nil unless @path
291
+
292
+ begin
293
+ require "pathname"
294
+ # Try to get a nice relative path from current directory
295
+ pwd_path = Pathname.new(Dir.pwd)
296
+ file_path = Pathname.new(@path)
297
+
298
+ # If file is under current directory or we can compute relative path
299
+ relative = file_path.relative_path_from(pwd_path).to_s
300
+
301
+ # If relative path goes up too many levels, just use absolute
302
+ if relative.start_with?("../../../")
303
+ @path
304
+ else
305
+ relative
306
+ end
307
+ rescue
308
+ # If we can't compute relative path, use absolute
309
+ @path
310
+ end
311
+ end
312
+
313
+ # Convert to hash for serialization
314
+ def to_h
315
+ {
316
+ path: @path,
317
+ doc_type: @doc_type,
318
+ purpose: @purpose,
319
+ title: title,
320
+ last_updated: last_updated&.to_s,
321
+ update_frequency: update_frequency,
322
+ needs_update: needs_update?,
323
+ freshness: freshness_status,
324
+ rules: @rules,
325
+ context: @context_config,
326
+ metadata: @metadata
327
+ }
328
+ end
329
+
330
+ # Format for display
331
+ def to_s
332
+ "Document: #{display_name} (#{@doc_type || "untyped"})"
333
+ end
334
+
335
+ # Check equality
336
+ def ==(other)
337
+ return false unless other.is_a?(Document)
338
+ @path == other.path
339
+ end
340
+
341
+ # Generate hash code
342
+ def hash
343
+ @path.hash
344
+ end
345
+
346
+ # Make documents comparable by path
347
+ def <=>(other)
348
+ return nil unless other.is_a?(Document)
349
+ (@path || "") <=> (other.path || "")
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end