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,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "date"
5
+ require "fileutils"
6
+ require "yaml"
7
+ require "ace/git"
8
+ require "ace/support/fs"
9
+ require "ace/b36ts"
10
+
11
+ module Ace
12
+ module Docs
13
+ module Molecules
14
+ # Analyzes git history and file changes for documents
15
+ # Delegates diff operations to ace-git for consistency
16
+ class ChangeDetector
17
+ # Get git diff for a document since a specific date or commit
18
+ # @param document [Document] The document to analyze
19
+ # @param since [String, Date] Date or commit to diff from
20
+ # @param options [Hash] Options for diff generation
21
+ # @return [Hash] Diff result with content and metadata
22
+ # For single subject: returns hash with :diff key containing single diff
23
+ # For multi-subject: returns hash with :diffs key containing {name => content}
24
+ def self.get_diff_for_document(document, since: nil, options: {})
25
+ return empty_diff_result unless document.path
26
+
27
+ # Determine the since parameter
28
+ since_param = determine_since(document, since)
29
+
30
+ # Check if document has multi-subject configuration
31
+ if document.multi_subject?
32
+ # Generate separate diffs for each subject
33
+ diffs_hash = get_diffs_for_subjects(document, since_param, options)
34
+
35
+ {
36
+ document_path: document.path,
37
+ document_type: document.doc_type,
38
+ since: since_param,
39
+ diffs: diffs_hash,
40
+ multi_subject: true,
41
+ has_changes: diffs_hash.values.any? { |diff| !diff.strip.empty? },
42
+ timestamp: Time.now.iso8601,
43
+ options: options
44
+ }
45
+ else
46
+ # Single subject - backward compatible behavior
47
+ filters = document.subject_diff_filters
48
+ if filters && !filters.empty?
49
+ options = options.merge(paths: filters)
50
+ end
51
+
52
+ diff_content = generate_git_diff(since_param, options)
53
+
54
+ {
55
+ document_path: document.path,
56
+ document_type: document.doc_type,
57
+ since: since_param,
58
+ diff: diff_content,
59
+ multi_subject: false,
60
+ has_changes: !diff_content.strip.empty?,
61
+ timestamp: Time.now.iso8601,
62
+ options: options
63
+ }
64
+ end
65
+ end
66
+
67
+ # Generate diffs for multiple subjects
68
+ # @param document [Document] The document with multi-subject configuration
69
+ # @param since [String] Date or commit to diff from
70
+ # @param options [Hash] Base options for diff generation
71
+ # @return [Hash] Hash mapping subject names to diff content {name => diff_string}
72
+ def self.get_diffs_for_subjects(document, since, options = {})
73
+ subject_configs = document.subject_configurations
74
+
75
+ result = {}
76
+ subject_configs.each do |subject|
77
+ name = subject[:name]
78
+ filters = subject[:filters]
79
+
80
+ # Build options for this subject
81
+ subject_options = options.merge(paths: filters)
82
+
83
+ # Generate diff for this subject
84
+ diff_content = generate_git_diff(since, subject_options)
85
+
86
+ # Store diff (even if empty - caller can decide whether to keep)
87
+ result[name] = diff_content
88
+ end
89
+
90
+ result
91
+ end
92
+
93
+ # Get combined diff for multiple documents
94
+ # @param documents [Array<Document>] Documents to analyze
95
+ # @param since [String, Date] Date or commit to diff from
96
+ # @param options [Hash] Options for diff generation
97
+ # @return [Hash] Combined diff results
98
+ def self.get_diff_for_documents(documents, since: nil, options: {})
99
+ # Get document-specific diffs with subject filtering
100
+ since_param = since || default_since_date
101
+
102
+ document_diffs = documents.map do |doc|
103
+ # Extract subject diff filters for this document
104
+ doc_options = options.dup
105
+ filters = doc.subject_diff_filters
106
+ if filters && !filters.empty?
107
+ doc_options = doc_options.merge(paths: filters)
108
+ end
109
+
110
+ # Get filtered diff for this document
111
+ diff_content = generate_git_diff(since_param, doc_options)
112
+
113
+ {
114
+ document: doc,
115
+ diff: diff_content,
116
+ has_changes: !diff_content.strip.empty?
117
+ }
118
+ end
119
+
120
+ {
121
+ total_documents: documents.size,
122
+ documents_with_changes: document_diffs.count { |d| d[:has_changes] },
123
+ since: since_param,
124
+ timestamp: Time.now.iso8601,
125
+ options: options,
126
+ document_diffs: document_diffs
127
+ }
128
+ end
129
+
130
+ # Generate batch diff for analysis
131
+ # @param documents [Array<Document>] Documents to analyze
132
+ # @param since [String] Time range for diff
133
+ # @param options [Hash] Options for diff generation
134
+ # @return [String] Raw git diff
135
+ def self.generate_batch_diff(documents, since, options = {})
136
+ # Generate the full codebase diff for the time period
137
+ generate_git_diff(since, options)
138
+ end
139
+
140
+ # Save diff analysis to cache folder with session structure
141
+ # @param diff_result [Hash] Diff analysis result
142
+ # @return [String] Path to saved analysis file
143
+ def self.save_diff_to_cache(diff_result)
144
+ cache_dir = ".ace-local/docs"
145
+ compact_id = Ace::B36ts.encode(Time.now)
146
+ session_dir = File.join(cache_dir, "diff-#{compact_id}")
147
+
148
+ FileUtils.mkdir_p(session_dir)
149
+
150
+ # Save raw git diff
151
+ if diff_result[:diff] && !diff_result[:diff].empty?
152
+ raw_diff_path = File.join(session_dir, "repo-diff.diff")
153
+ File.write(raw_diff_path, diff_result[:diff])
154
+ end
155
+
156
+ # Save formatted analysis report
157
+ analysis_path = File.join(session_dir, "analysis.md")
158
+ content = format_diff_for_saving(diff_result)
159
+ File.write(analysis_path, content)
160
+
161
+ # Save metadata
162
+ metadata_path = File.join(session_dir, "metadata.yml")
163
+ metadata = {
164
+ "generated" => diff_result[:timestamp],
165
+ "since" => diff_result[:since],
166
+ "document_count" => diff_result[:total_documents] || 1,
167
+ "has_changes" => diff_result[:has_changes] || (diff_result[:documents_with_changes] || 0) > 0,
168
+ "options" => diff_result[:options] || {}
169
+ }
170
+ File.write(metadata_path, metadata.to_yaml)
171
+
172
+ analysis_path
173
+ end
174
+
175
+ # Check if files have been renamed or moved
176
+ # @param since [String] Date or commit to check from
177
+ # @return [Array<Hash>] List of renamed/moved files
178
+ def self.detect_renames(since: nil)
179
+ since_param = since || default_since_date
180
+ since_ref = resolve_since_to_commit(since_param)
181
+ cmd = "git diff --name-status --diff-filter=R #{since_ref}..HEAD"
182
+
183
+ stdout = execute_git_command(cmd)
184
+ return [] if stdout.strip.empty?
185
+
186
+ renames = []
187
+ stdout.each_line do |line|
188
+ if line.start_with?("R")
189
+ parts = line.strip.split(/\s+/, 3)
190
+ if parts.length >= 3
191
+ old_path, new_path = parts[1], parts[2]
192
+ renames << {old: old_path, new: new_path}
193
+ end
194
+ end
195
+ end
196
+
197
+ renames
198
+ end
199
+
200
+ private
201
+
202
+ def self.empty_diff_result
203
+ {
204
+ document_path: nil,
205
+ diff: "",
206
+ has_changes: false,
207
+ timestamp: Time.now.iso8601
208
+ }
209
+ end
210
+
211
+ def self.determine_since(document, since)
212
+ # If explicit since provided, use it
213
+ return format_since(since) if since
214
+
215
+ # Use document's last updated date if available
216
+ if document.last_updated
217
+ return document.last_updated.strftime("%Y-%m-%d")
218
+ end
219
+
220
+ # Default to 7 days ago
221
+ default_since_date
222
+ end
223
+
224
+ def self.format_since(since)
225
+ case since
226
+ when Date
227
+ since.strftime("%Y-%m-%d")
228
+ when Time
229
+ since.strftime("%Y-%m-%d")
230
+ when String
231
+ since
232
+ else
233
+ default_since_date
234
+ end
235
+ end
236
+
237
+ def self.default_since_date
238
+ (Date.today - 7).strftime("%Y-%m-%d")
239
+ end
240
+
241
+ def self.generate_git_diff(since, options = {})
242
+ # Warn about deprecated option keys (migrated from ace-git-diff to ace-git)
243
+ warn_deprecated_options(options)
244
+
245
+ # Delegate to ace-git for consistent filtering and configuration
246
+ diff_options = build_diff_options(since, options)
247
+
248
+ result = Ace::Git::Organisms::DiffOrchestrator.generate(diff_options)
249
+ result.content
250
+ rescue => e
251
+ warn "ace-git failed: #{e.message}" if ENV["DEBUG"]
252
+ ""
253
+ end
254
+
255
+ # Build standardized diff options for ace-git API
256
+ # Centralizes option construction and default handling
257
+ # @param since [String] Date or commit to diff from
258
+ # @param options [Hash] Raw options (may contain paths, exclude_renames, exclude_moves)
259
+ # @return [Hash] Options formatted for ace-git DiffOrchestrator
260
+ def self.build_diff_options(since, options = {})
261
+ # Map legacy keys to new keys if new keys are not provided
262
+ exclude_renames = options.fetch(:exclude_renames) do
263
+ options.key?(:include_renames) ? !options[:include_renames] : false
264
+ end
265
+
266
+ exclude_moves = options.fetch(:exclude_moves) do
267
+ options.key?(:include_moves) ? !options[:include_moves] : false
268
+ end
269
+
270
+ {
271
+ since: since,
272
+ paths: options[:paths],
273
+ exclude_renames: exclude_renames,
274
+ exclude_moves: exclude_moves
275
+ }
276
+ end
277
+ private_class_method :build_diff_options
278
+
279
+ # Warn about deprecated option keys from ace-git-diff API
280
+ def self.warn_deprecated_options(options)
281
+ return unless options.key?(:include_renames) || options.key?(:include_moves)
282
+
283
+ warn "[ace-docs] DEPRECATED: Use exclude_renames/exclude_moves instead of " \
284
+ "include_renames/include_moves. These keys will be removed in v1.0."
285
+ end
286
+ private_class_method :warn_deprecated_options
287
+
288
+ def self.resolve_since_to_commit(since)
289
+ # If it looks like a commit SHA, use as-is
290
+ return since if /^[0-9a-f]{7,40}$/i.match?(since)
291
+
292
+ # It's a date - find the first commit since that date
293
+ cmd = "git log --since=\"#{since}\" --format=%H --reverse --all"
294
+ stdout = execute_git_command(cmd)
295
+
296
+ if !stdout.strip.empty?
297
+ first_commit = stdout.strip.split("\n").first
298
+
299
+ # Get parent of first commit to include all changes since date
300
+ parent_cmd = "git rev-parse #{first_commit}~1 2>/dev/null"
301
+ parent_stdout = execute_git_command(parent_cmd)
302
+
303
+ if !parent_stdout.strip.empty?
304
+ return parent_stdout.strip
305
+ else
306
+ # First commit has no parent (initial commit), use it directly
307
+ return first_commit
308
+ end
309
+ end
310
+
311
+ # Fallback: use date string and let git handle it
312
+ since
313
+ end
314
+
315
+ def self.git_root
316
+ @git_root ||= begin
317
+ stdout, _, status = Open3.capture3("git rev-parse --show-toplevel")
318
+ # Use ProjectRootFinder as fallback to support both main repos and git worktrees
319
+ status.success? ? stdout.strip : Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
320
+ end
321
+ end
322
+
323
+ def self.format_diff_for_saving(diff_result)
324
+ content = []
325
+ content << "# Diff Analysis Report"
326
+ content << "Generated: #{diff_result[:timestamp]}"
327
+ content << "Since: #{diff_result[:since]}"
328
+ content << ""
329
+
330
+ if diff_result[:document_diffs]
331
+ # Multiple documents
332
+ content << "## Summary"
333
+ content << "- Total documents analyzed: #{diff_result[:total_documents]}"
334
+ content << "- Documents with changes: #{diff_result[:documents_with_changes]}"
335
+ content << ""
336
+
337
+ diff_result[:document_diffs].each do |doc_diff|
338
+ doc = doc_diff[:document]
339
+ content << "## #{doc.display_name}"
340
+ content << "- Type: #{doc.doc_type}"
341
+ content << "- Purpose: #{doc.purpose}"
342
+ content << "- Has changes: #{doc_diff[:has_changes] ? "Yes" : "No"}"
343
+ content << ""
344
+
345
+ if doc_diff[:has_changes]
346
+ content << "### Relevant Changes"
347
+ content << "```diff"
348
+ content << doc_diff[:diff]
349
+ content << "```"
350
+ else
351
+ content << "No relevant changes detected."
352
+ end
353
+ content << ""
354
+ end
355
+ else
356
+ # Single document
357
+ content << "## Document: #{diff_result[:document_path]}"
358
+ content << "- Type: #{diff_result[:document_type]}"
359
+ content << "- Has changes: #{diff_result[:has_changes] ? "Yes" : "No"}"
360
+ content << ""
361
+
362
+ if diff_result[:has_changes]
363
+ content << "### Git Diff"
364
+ content << "```diff"
365
+ content << diff_result[:diff]
366
+ content << "```"
367
+ else
368
+ content << "No changes detected."
369
+ end
370
+ end
371
+
372
+ content.join("\n")
373
+ end
374
+
375
+ # Execute git command (protected for testing)
376
+ # @param cmd [String, Array] Git command to execute
377
+ # @return [String] Command output or empty string on failure
378
+ def self.execute_git_command(cmd)
379
+ if cmd.is_a?(Array)
380
+ stdout, _stderr, status = Open3.capture3(*cmd, chdir: git_root)
381
+ else
382
+ stdout, _stderr, status = Open3.capture3(cmd, chdir: git_root)
383
+ end
384
+ status.success? ? stdout : ""
385
+ end
386
+ end
387
+ end
388
+ end
389
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/markdown"
4
+ require_relative "../models/document"
5
+ require_relative "../atoms/frontmatter_free_matcher"
6
+ require_relative "../atoms/readme_metadata_inferrer"
7
+ require_relative "git_date_resolver"
8
+
9
+ module Ace
10
+ module Docs
11
+ module Molecules
12
+ # Loads document files with frontmatter parsing
13
+ class DocumentLoader
14
+ # Load a single document from file
15
+ # @param path [String] Path to the markdown file
16
+ # @return [Document, nil] Document object or nil if loading fails
17
+ def self.load_file(path)
18
+ return nil unless File.exist?(path)
19
+ return nil unless path.end_with?(".md")
20
+
21
+ content = File.read(path)
22
+ doc = Ace::Support::Markdown::Models::MarkdownDocument.parse(content, file_path: path)
23
+
24
+ if doc.frontmatter.empty?
25
+ return load_frontmatter_free_document(path, content)
26
+ end
27
+
28
+ Models::Document.new(
29
+ path: path,
30
+ frontmatter: doc.frontmatter,
31
+ content: doc.raw_body
32
+ )
33
+ rescue => e
34
+ if e.message.include?("No frontmatter found")
35
+ return load_frontmatter_free_document(path, content)
36
+ end
37
+
38
+ warn "Error loading document #{path}: #{e.message}"
39
+ nil
40
+ end
41
+
42
+ # Load multiple documents from paths
43
+ # @param paths [Array<String>] Array of file paths
44
+ # @return [Array<Document>] Array of loaded documents
45
+ def self.load_files(paths)
46
+ paths.map { |path| load_file(path) }.compact
47
+ end
48
+
49
+ # Load all documents from a directory
50
+ # @param directory [String] Directory path
51
+ # @param recursive [Boolean] Whether to search recursively
52
+ # @return [Array<Document>] Array of loaded documents
53
+ def self.load_directory(directory, recursive: true)
54
+ return [] unless File.directory?(directory)
55
+
56
+ pattern = recursive ? "**/*.md" : "*.md"
57
+ md_files = Dir.glob(File.join(directory, pattern))
58
+
59
+ load_files(md_files)
60
+ end
61
+
62
+ # Load documents matching a glob pattern
63
+ # @param pattern [String] Glob pattern
64
+ # @param base_dir [String] Base directory for the pattern
65
+ # @return [Array<Document>] Array of loaded documents
66
+ def self.load_glob(pattern, base_dir: Dir.pwd)
67
+ md_files = Dir.glob(File.join(base_dir, pattern))
68
+ load_files(md_files)
69
+ end
70
+
71
+ # Check if a file has ace-docs frontmatter
72
+ # @param path [String] File path
73
+ # @return [Boolean] true if file has valid ace-docs frontmatter
74
+ def self.managed_document?(path)
75
+ return false unless File.exist?(path)
76
+ return false unless path.end_with?(".md")
77
+
78
+ content = File.read(path)
79
+ doc = Ace::Support::Markdown::Models::MarkdownDocument.parse(content)
80
+
81
+ if doc.frontmatter.empty?
82
+ return frontmatter_free?(path)
83
+ end
84
+
85
+ # Check if has doc-type field (ace-docs requirement)
86
+ !doc.frontmatter.empty? && doc.frontmatter["doc-type"]
87
+ rescue
88
+ frontmatter_free?(path)
89
+ end
90
+
91
+ def self.load_frontmatter_free_document(path, content)
92
+ return nil unless frontmatter_free?(path)
93
+
94
+ inferred_frontmatter = Atoms::ReadmeMetadataInferrer.infer(
95
+ path: path,
96
+ content: content,
97
+ last_updated: Molecules::GitDateResolver.last_updated_for(path)
98
+ )
99
+ return nil unless inferred_frontmatter
100
+
101
+ Models::Document.new(
102
+ path: path,
103
+ frontmatter: inferred_frontmatter,
104
+ content: content
105
+ )
106
+ end
107
+ private_class_method :load_frontmatter_free_document
108
+
109
+ def self.frontmatter_free?(path)
110
+ patterns = Ace::Docs.config["frontmatter_free"] || []
111
+ Atoms::FrontmatterFreeMatcher.match?(path, patterns: patterns, project_root: Dir.pwd)
112
+ end
113
+ private_class_method :frontmatter_free?
114
+
115
+ # Load document or return error document
116
+ # @param path [String] File path
117
+ # @return [Document] Document object (may be empty with error metadata)
118
+ def self.load_or_error(path)
119
+ doc = load_file(path)
120
+ return doc if doc
121
+
122
+ # Return error document
123
+ Models::Document.new(
124
+ path: path,
125
+ frontmatter: {
126
+ "error" => "Failed to load document"
127
+ }
128
+ )
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "ace/support/markdown"
6
+ require_relative "../atoms/timestamp_parser"
7
+
8
+ module Ace
9
+ module Docs
10
+ module Molecules
11
+ # Updates document frontmatter fields
12
+ # Now delegates to ace-support-markdown's DocumentEditor for safe operations
13
+ class FrontmatterManager
14
+ # Update frontmatter fields in a document
15
+ # @param document [Document] Document to update
16
+ # @param updates [Hash] Fields to update
17
+ # @return [Boolean] true if successful
18
+ def self.update_document(document, updates)
19
+ return false unless document.path && File.exist?(document.path)
20
+
21
+ # Process updates to handle special values and nested keys
22
+ processed_updates = process_updates(updates)
23
+
24
+ # Use DocumentEditor for safe, atomic updates with backup
25
+ editor = Ace::Support::Markdown::Organisms::DocumentEditor.new(document.path)
26
+ editor.update_frontmatter(processed_updates)
27
+ result = editor.save!(backup: true, validate_before: false)
28
+
29
+ result[:success]
30
+ rescue => e
31
+ warn "Error updating #{document.path}: #{e.message}"
32
+ false
33
+ end
34
+
35
+ # Update multiple documents
36
+ # @param documents [Array<Document>] Documents to update
37
+ # @param updates [Hash] Fields to update
38
+ # @return [Integer] Number of successfully updated documents
39
+ def self.update_documents(documents, updates)
40
+ documents.count { |doc| update_document(doc, updates) }
41
+ end
42
+
43
+ private
44
+
45
+ def self.process_updates(updates)
46
+ processed = {}
47
+
48
+ updates.each do |key, value|
49
+ # Handle special key mappings (convert to dot notation for DocumentEditor)
50
+ mapped_key = case key
51
+ when "last-updated", "last_updated"
52
+ "ace-docs.last-updated"
53
+ when "last-checked", "last_checked"
54
+ "ace-docs.last-checked"
55
+ when "version"
56
+ "metadata.version"
57
+ else
58
+ key
59
+ end
60
+
61
+ processed[mapped_key] = process_value(value)
62
+ end
63
+
64
+ processed
65
+ end
66
+
67
+ def self.process_value(value)
68
+ # Handle special values
69
+ case value
70
+ when "today"
71
+ Date.today.strftime("%Y-%m-%d")
72
+ when "now"
73
+ Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") # ISO 8601 UTC format
74
+ when Atoms::TimestampParser::DATE_ONLY_PATTERN
75
+ value # Already a date-only string
76
+ when Atoms::TimestampParser::ISO8601_UTC_PATTERN
77
+ value # Already ISO 8601 UTC
78
+ else
79
+ value
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "date"
5
+
6
+ module Ace
7
+ module Docs
8
+ module Molecules
9
+ # Resolves last commit date for a file path.
10
+ class GitDateResolver
11
+ def self.last_updated_for(path)
12
+ return nil if path.nil? || path.to_s.empty?
13
+
14
+ args = [
15
+ "git", "log", "-1", "--format=%cs", "--", path.to_s
16
+ ]
17
+ stdout, _stderr, status = Open3.capture3(*args)
18
+ return nil unless status.success?
19
+
20
+ value = stdout.strip
21
+ return nil if value.empty?
22
+
23
+ Date.parse(value)
24
+ rescue
25
+ nil
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end