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,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
|