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.
- checksums.yaml +7 -0
- data/.ace-defaults/docs/config.yml +169 -0
- data/.ace-defaults/docs/multi-subject-example.md +130 -0
- data/.ace-defaults/docs/single-subject-example.md +150 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-docs.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-docs.yml +34 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-docs.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-docs.yml +19 -0
- data/CHANGELOG.md +1082 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +14 -0
- data/exe/ace-docs +14 -0
- data/handbook/guides/documentation/ruby.md +16 -0
- data/handbook/guides/documentation/rust.md +35 -0
- data/handbook/guides/documentation/typescript.md +18 -0
- data/handbook/guides/documentation.g.md +437 -0
- data/handbook/guides/documents-embedded-sync.g.md +473 -0
- data/handbook/guides/documents-embedding.g.md +276 -0
- data/handbook/guides/markdown-style.g.md +290 -0
- data/handbook/prompts/ace-change-analyzer.system.md +113 -0
- data/handbook/prompts/ace-change-analyzer.user.md +95 -0
- data/handbook/prompts/document-analysis.md +74 -0
- data/handbook/prompts/document-analysis.system.md +129 -0
- data/handbook/prompts/markdown-style.system.md +113 -0
- data/handbook/skills/as-docs-create-adr/SKILL.md +35 -0
- data/handbook/skills/as-docs-create-api/SKILL.md +35 -0
- data/handbook/skills/as-docs-create-user/SKILL.md +35 -0
- data/handbook/skills/as-docs-maintain-adrs/SKILL.md +35 -0
- data/handbook/skills/as-docs-squash-changelog/SKILL.md +42 -0
- data/handbook/skills/as-docs-update/SKILL.md +36 -0
- data/handbook/skills/as-docs-update-blueprint/SKILL.md +28 -0
- data/handbook/skills/as-docs-update-roadmap/SKILL.md +24 -0
- data/handbook/skills/as-docs-update-tools/SKILL.md +36 -0
- data/handbook/skills/as-docs-update-usage/SKILL.md +26 -0
- data/handbook/templates/code-docs/javascript-jsdoc.template.md +102 -0
- data/handbook/templates/code-docs/ruby-yard.template.md +85 -0
- data/handbook/templates/project-docs/README.template.md +73 -0
- data/handbook/templates/project-docs/architecture.template.md +300 -0
- data/handbook/templates/project-docs/blueprint.template.md +165 -0
- data/handbook/templates/project-docs/context/ownership.yml +160 -0
- data/handbook/templates/project-docs/decisions/adr.template.md +60 -0
- data/handbook/templates/project-docs/prd.template.md +144 -0
- data/handbook/templates/project-docs/roadmap/roadmap.template.md +47 -0
- data/handbook/templates/project-docs/vision.template.md +233 -0
- data/handbook/templates/user-docs/user-guide.template.md +107 -0
- data/handbook/workflow-instructions/docs/create-adr.wf.md +334 -0
- data/handbook/workflow-instructions/docs/create-api.wf.md +448 -0
- data/handbook/workflow-instructions/docs/create-cookbook.wf.md +434 -0
- data/handbook/workflow-instructions/docs/create-user.wf.md +399 -0
- data/handbook/workflow-instructions/docs/maintain-adrs.wf.md +589 -0
- data/handbook/workflow-instructions/docs/squash-changelog.wf.md +246 -0
- data/handbook/workflow-instructions/docs/update-blueprint.wf.md +361 -0
- data/handbook/workflow-instructions/docs/update-context.wf.md +336 -0
- data/handbook/workflow-instructions/docs/update-roadmap.wf.md +421 -0
- data/handbook/workflow-instructions/docs/update-tools.wf.md +307 -0
- data/handbook/workflow-instructions/docs/update-usage.wf.md +710 -0
- data/handbook/workflow-instructions/docs/update.wf.md +418 -0
- data/lib/ace/docs/atoms/diff_filterer.rb +131 -0
- data/lib/ace/docs/atoms/frontmatter_free_matcher.rb +20 -0
- data/lib/ace/docs/atoms/git_date_resolver.rb +16 -0
- data/lib/ace/docs/atoms/readme_metadata_inferrer.rb +60 -0
- data/lib/ace/docs/atoms/terminology_extractor.rb +308 -0
- data/lib/ace/docs/atoms/time_range_calculator.rb +96 -0
- data/lib/ace/docs/atoms/timestamp_parser.rb +106 -0
- data/lib/ace/docs/atoms/type_inferrer.rb +70 -0
- data/lib/ace/docs/cli/commands/analyze.rb +351 -0
- data/lib/ace/docs/cli/commands/analyze_consistency.rb +185 -0
- data/lib/ace/docs/cli/commands/discover.rb +75 -0
- data/lib/ace/docs/cli/commands/scope_options.rb +71 -0
- data/lib/ace/docs/cli/commands/status.rb +241 -0
- data/lib/ace/docs/cli/commands/update.rb +198 -0
- data/lib/ace/docs/cli/commands/validate.rb +225 -0
- data/lib/ace/docs/cli.rb +60 -0
- data/lib/ace/docs/models/analysis_report.rb +120 -0
- data/lib/ace/docs/models/consistency_report.rb +259 -0
- data/lib/ace/docs/models/document.rb +354 -0
- data/lib/ace/docs/molecules/change_detector.rb +389 -0
- data/lib/ace/docs/molecules/document_loader.rb +133 -0
- data/lib/ace/docs/molecules/frontmatter_manager.rb +85 -0
- data/lib/ace/docs/molecules/git_date_resolver.rb +30 -0
- data/lib/ace/docs/organisms/cross_document_analyzer.rb +274 -0
- data/lib/ace/docs/organisms/document_registry.rb +318 -0
- data/lib/ace/docs/organisms/validator.rb +164 -0
- data/lib/ace/docs/prompts/compact_diff_prompt.rb +119 -0
- data/lib/ace/docs/prompts/consistency_prompt.rb +286 -0
- data/lib/ace/docs/prompts/document_analysis_prompt.rb +389 -0
- data/lib/ace/docs/version.rb +7 -0
- data/lib/ace/docs.rb +82 -0
- data/lib/test.rb +4 -0
- 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
|