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,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "colorize"
|
|
7
|
+
require "ace/core/molecules/prompt_cache_manager"
|
|
8
|
+
require_relative "../organisms/document_registry"
|
|
9
|
+
require_relative "../prompts/consistency_prompt"
|
|
10
|
+
require_relative "../models/consistency_report"
|
|
11
|
+
|
|
12
|
+
# Try to load ace-llm
|
|
13
|
+
begin
|
|
14
|
+
require "ace/llm"
|
|
15
|
+
rescue LoadError
|
|
16
|
+
# Will be handled with clear error message during execution
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module Ace
|
|
20
|
+
module Docs
|
|
21
|
+
module Organisms
|
|
22
|
+
# Orchestrates cross-document consistency analysis using LLM
|
|
23
|
+
class CrossDocumentAnalyzer
|
|
24
|
+
attr_reader :registry, :options
|
|
25
|
+
|
|
26
|
+
def initialize(options = {})
|
|
27
|
+
@options = options
|
|
28
|
+
@registry = Organisms::DocumentRegistry.new(
|
|
29
|
+
project_root: options[:project_root],
|
|
30
|
+
scope_globs: options[:scope_globs]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Analyze documents for consistency issues
|
|
35
|
+
# @param pattern [String, nil] glob pattern to filter documents
|
|
36
|
+
# @return [ConsistencyReport] analysis results
|
|
37
|
+
def analyze(pattern = nil)
|
|
38
|
+
# Load documents
|
|
39
|
+
puts "Loading documents..." if @options[:verbose]
|
|
40
|
+
documents = load_documents(pattern)
|
|
41
|
+
|
|
42
|
+
if documents.empty?
|
|
43
|
+
puts "No documents found to analyze.".yellow
|
|
44
|
+
return nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Create standardized session directory using PromptCacheManager
|
|
48
|
+
session_dir = Ace::Core::Molecules::PromptCacheManager.create_session(
|
|
49
|
+
"ace-docs",
|
|
50
|
+
"analyze-consistency"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
puts "Analyzing #{documents.count} documents for consistency issues...".cyan
|
|
54
|
+
puts "Session directory: #{session_dir}".yellow
|
|
55
|
+
|
|
56
|
+
# Save document list
|
|
57
|
+
puts "Saving document list..." if @options[:verbose]
|
|
58
|
+
save_document_list(documents, session_dir)
|
|
59
|
+
|
|
60
|
+
# Prepare document paths (no need to load content, ace-bundle will do it)
|
|
61
|
+
puts "Preparing document paths..." if @options[:verbose]
|
|
62
|
+
document_data = prepare_document_paths(documents)
|
|
63
|
+
puts " Documents to analyze: #{document_data.size}" if @options[:verbose]
|
|
64
|
+
|
|
65
|
+
# Build prompts
|
|
66
|
+
puts "Building analysis prompts..." if @options[:verbose]
|
|
67
|
+
prompt_builder = Prompts::ConsistencyPrompt.new
|
|
68
|
+
prompts = prompt_builder.build(document_data, @options, session_dir: session_dir)
|
|
69
|
+
|
|
70
|
+
# Save prompts
|
|
71
|
+
puts "Saving prompts to session directory..." if @options[:verbose]
|
|
72
|
+
save_prompts(prompts, session_dir)
|
|
73
|
+
|
|
74
|
+
# Execute LLM query
|
|
75
|
+
puts "\nExecuting LLM analysis..." if @options[:verbose]
|
|
76
|
+
puts "This may take a few minutes for large document sets..." if documents.count > 10
|
|
77
|
+
response = execute_llm_query(prompts, session_dir)
|
|
78
|
+
|
|
79
|
+
# Response is already saved to report.md by ace-llm's output option
|
|
80
|
+
|
|
81
|
+
# Save metadata for reference
|
|
82
|
+
save_metadata(documents, pattern, session_dir)
|
|
83
|
+
|
|
84
|
+
# Display session info
|
|
85
|
+
puts "\nAnalysis saved to: #{session_dir}".green
|
|
86
|
+
|
|
87
|
+
# Return the report path
|
|
88
|
+
response
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Load documents based on pattern
|
|
94
|
+
def load_documents(pattern)
|
|
95
|
+
all_docs = @registry.all
|
|
96
|
+
|
|
97
|
+
return all_docs unless pattern
|
|
98
|
+
|
|
99
|
+
# Filter documents by pattern
|
|
100
|
+
all_docs.select do |doc|
|
|
101
|
+
rel = doc.relative_path || doc.path
|
|
102
|
+
File.fnmatch?(pattern, rel, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
103
|
+
File.fnmatch?(pattern, doc.path, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
104
|
+
File.fnmatch?(pattern, File.basename(doc.path), File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Prepare document paths for analysis
|
|
109
|
+
def prepare_document_paths(documents)
|
|
110
|
+
# Just return a hash of paths to empty string (ace-bundle will load the actual content)
|
|
111
|
+
# This maintains compatibility with the prompt builder interface
|
|
112
|
+
document_paths = {}
|
|
113
|
+
|
|
114
|
+
documents.each do |doc|
|
|
115
|
+
# Only include files that actually exist
|
|
116
|
+
if File.exist?(doc.path)
|
|
117
|
+
document_paths[doc.path] = "" # Empty content, ace-bundle will load it
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
document_paths
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Execute LLM query with the prompts
|
|
125
|
+
def execute_llm_query(prompts, session_dir)
|
|
126
|
+
# Check if ace-llm is available
|
|
127
|
+
unless defined?(Ace::LLM)
|
|
128
|
+
raise "ace-llm gem not available. Please install it with: gem install ace-llm"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Determine timeout based on document count
|
|
132
|
+
timeout = determine_timeout
|
|
133
|
+
|
|
134
|
+
# Determine model (use config or default)
|
|
135
|
+
# Check both llm_model and llm.model in config
|
|
136
|
+
model = @options[:model] ||
|
|
137
|
+
Ace::Docs.config["llm_model"] ||
|
|
138
|
+
Ace::Docs.config.dig("llm", "model") ||
|
|
139
|
+
"glite" # Default to glite (fast model)
|
|
140
|
+
|
|
141
|
+
puts "Executing LLM query (model: #{model}, timeout: #{timeout}s)..." if @options[:verbose]
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
# Determine output path for saving response
|
|
145
|
+
report_path = File.join(session_dir, "report.md")
|
|
146
|
+
|
|
147
|
+
# Call LLM via QueryInterface with native output saving
|
|
148
|
+
result = Ace::LLM::QueryInterface.query(
|
|
149
|
+
model,
|
|
150
|
+
prompts[:user],
|
|
151
|
+
system: prompts[:system],
|
|
152
|
+
temperature: 0.3, # Lower temperature for more consistent analysis
|
|
153
|
+
timeout: timeout,
|
|
154
|
+
output: report_path, # Save response directly as report
|
|
155
|
+
format: "text", # Save as text/markdown format
|
|
156
|
+
force: true # Overwrite if exists
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Check if we got a valid result
|
|
160
|
+
unless result && result[:text]
|
|
161
|
+
raise "LLM query failed to return text content"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Return the report path (not the content)
|
|
165
|
+
report_path
|
|
166
|
+
rescue => e
|
|
167
|
+
raise "#{e.message}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Determine timeout based on document count
|
|
172
|
+
def determine_timeout
|
|
173
|
+
return @options[:timeout] if @options[:timeout]
|
|
174
|
+
|
|
175
|
+
doc_count = @registry.all.count
|
|
176
|
+
|
|
177
|
+
case doc_count
|
|
178
|
+
when 0..10
|
|
179
|
+
600 # 10 minutes minimum
|
|
180
|
+
when 11..50
|
|
181
|
+
900 # 15 minutes for medium sets
|
|
182
|
+
else
|
|
183
|
+
1200 # 20 minutes for large sets
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Save document list to session directory
|
|
188
|
+
def save_document_list(documents, session_dir)
|
|
189
|
+
document_list = documents.map do |doc|
|
|
190
|
+
{
|
|
191
|
+
path: doc.path,
|
|
192
|
+
type: doc.doc_type,
|
|
193
|
+
purpose: doc.purpose,
|
|
194
|
+
last_updated: doc.last_updated
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
document_list_path = File.join(session_dir, "documents.json")
|
|
199
|
+
File.write(document_list_path, JSON.pretty_generate(document_list))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Save prompts to session directory using standardized names
|
|
203
|
+
def save_prompts(prompts, session_dir)
|
|
204
|
+
# Save system prompt with standardized name
|
|
205
|
+
Ace::Core::Molecules::PromptCacheManager.save_system_prompt(
|
|
206
|
+
format_prompt(prompts[:system], "System Prompt"),
|
|
207
|
+
session_dir
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Save user prompt with standardized name
|
|
211
|
+
Ace::Core::Molecules::PromptCacheManager.save_user_prompt(
|
|
212
|
+
format_prompt(prompts[:user], "User Prompt"),
|
|
213
|
+
session_dir
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Format prompt for saving
|
|
218
|
+
def format_prompt(content, title)
|
|
219
|
+
<<~PROMPT
|
|
220
|
+
# #{title}
|
|
221
|
+
|
|
222
|
+
Generated: #{Time.now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
#{content}
|
|
227
|
+
PROMPT
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Save LLM response to session directory
|
|
231
|
+
def save_llm_response(response, session_dir)
|
|
232
|
+
response_path = File.join(session_dir, "llm-response.json")
|
|
233
|
+
|
|
234
|
+
# Try to parse as JSON for pretty formatting
|
|
235
|
+
begin
|
|
236
|
+
parsed = JSON.parse(response)
|
|
237
|
+
File.write(response_path, JSON.pretty_generate(parsed))
|
|
238
|
+
rescue JSON::ParserError
|
|
239
|
+
# If not JSON, save as plain text
|
|
240
|
+
File.write(response_path, response)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Save report to session directory
|
|
245
|
+
def save_report(report, session_dir)
|
|
246
|
+
# Save markdown report
|
|
247
|
+
report_path = File.join(session_dir, "report.md")
|
|
248
|
+
File.write(report_path, report.to_markdown)
|
|
249
|
+
|
|
250
|
+
# Save JSON report
|
|
251
|
+
report_json_path = File.join(session_dir, "report.json")
|
|
252
|
+
File.write(report_json_path, report.to_json)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Save metadata to session directory
|
|
256
|
+
def save_metadata(documents, pattern, session_dir)
|
|
257
|
+
metadata = {
|
|
258
|
+
"analysis_type" => "consistency",
|
|
259
|
+
"generated_at" => Time.now.iso8601,
|
|
260
|
+
"document_count" => documents.count,
|
|
261
|
+
"pattern" => pattern,
|
|
262
|
+
"options" => @options,
|
|
263
|
+
"session_dir" => session_dir,
|
|
264
|
+
"ace_docs_version" => Ace::Docs::VERSION
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
metadata_path = File.join(session_dir, "metadata.yml")
|
|
268
|
+
require "yaml"
|
|
269
|
+
File.write(metadata_path, metadata.to_yaml)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/fs"
|
|
4
|
+
require_relative "../molecules/document_loader"
|
|
5
|
+
require_relative "../models/document"
|
|
6
|
+
require_relative "../atoms/type_inferrer"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Docs
|
|
10
|
+
module Organisms
|
|
11
|
+
# Discovers and indexes all managed documents in the project
|
|
12
|
+
class DocumentRegistry
|
|
13
|
+
attr_reader :documents, :config
|
|
14
|
+
|
|
15
|
+
# Initialize the document registry
|
|
16
|
+
# @param project_root [String, nil] Project root directory
|
|
17
|
+
# @param config [Hash, nil] Optional config override (for testing)
|
|
18
|
+
def initialize(project_root: nil, config: nil, scope_globs: nil)
|
|
19
|
+
@project_root = project_root || determine_project_root
|
|
20
|
+
@config = config || Ace::Docs.config
|
|
21
|
+
@scope_globs = Array(scope_globs).compact
|
|
22
|
+
@documents = []
|
|
23
|
+
discover_documents
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Refresh the registry by rediscovering documents
|
|
27
|
+
def refresh
|
|
28
|
+
@documents = []
|
|
29
|
+
discover_documents
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Find all managed documents
|
|
33
|
+
def all
|
|
34
|
+
@documents.dup
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Find documents by type
|
|
38
|
+
def by_type(doc_type)
|
|
39
|
+
@documents.select { |doc| doc.doc_type == doc_type }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Find documents needing update
|
|
43
|
+
def needing_update
|
|
44
|
+
@documents.select(&:needs_update?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Find documents by freshness status
|
|
48
|
+
def by_freshness(status)
|
|
49
|
+
@documents.select { |doc| doc.freshness_status == status }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Find document by path
|
|
53
|
+
def find_by_path(path)
|
|
54
|
+
return nil unless File.exist?(path)
|
|
55
|
+
|
|
56
|
+
real_path = File.realpath(path)
|
|
57
|
+
@documents.find { |doc| File.exist?(doc.path) && File.realpath(doc.path) == real_path }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get document types configuration
|
|
61
|
+
def document_types
|
|
62
|
+
@config["document_types"] || {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get global validation rules
|
|
66
|
+
def global_rules
|
|
67
|
+
@config["global_rules"] || {}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Group documents by type
|
|
71
|
+
def grouped_by_type
|
|
72
|
+
@documents.group_by(&:doc_type)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Group documents by directory
|
|
76
|
+
def grouped_by_directory
|
|
77
|
+
@documents.group_by { |doc| File.dirname(doc.path) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get statistics about the registry
|
|
81
|
+
def stats
|
|
82
|
+
{
|
|
83
|
+
total: @documents.size,
|
|
84
|
+
by_type: @documents.group_by(&:doc_type).transform_values(&:size),
|
|
85
|
+
by_freshness: @documents.group_by(&:freshness_status).transform_values(&:size),
|
|
86
|
+
needing_update: needing_update.size,
|
|
87
|
+
managed: @documents.count(&:managed?)
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def determine_project_root
|
|
94
|
+
Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def discover_documents
|
|
98
|
+
# First, discover documents with explicit frontmatter
|
|
99
|
+
discover_explicit_documents
|
|
100
|
+
|
|
101
|
+
# Then, discover documents matching type patterns
|
|
102
|
+
discover_configured_documents
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def discover_explicit_documents
|
|
106
|
+
# Search for all markdown files in the project
|
|
107
|
+
all_md_files = if @scope_globs.empty?
|
|
108
|
+
Dir.glob(File.join(@project_root, "**/*.md"))
|
|
109
|
+
else
|
|
110
|
+
@scope_globs.flat_map do |pattern|
|
|
111
|
+
Dir.glob(File.join(@project_root, pattern))
|
|
112
|
+
end.uniq
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Load those with ace-docs frontmatter
|
|
116
|
+
all_md_files.each do |path|
|
|
117
|
+
next if ignored_path?(path)
|
|
118
|
+
next unless in_scope?(path)
|
|
119
|
+
|
|
120
|
+
doc = Molecules::DocumentLoader.load_file(path)
|
|
121
|
+
next unless doc&.managed?
|
|
122
|
+
|
|
123
|
+
# Avoid duplicates
|
|
124
|
+
unless @documents.any? { |d| d.path == doc.path }
|
|
125
|
+
@documents << doc
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def discover_configured_documents
|
|
131
|
+
return unless document_types.any?
|
|
132
|
+
|
|
133
|
+
document_types.each do |type_name, type_config|
|
|
134
|
+
paths = type_config["paths"] || []
|
|
135
|
+
defaults = type_config["defaults"] || {}
|
|
136
|
+
|
|
137
|
+
# Separate inclusion and exclusion patterns
|
|
138
|
+
include_patterns = []
|
|
139
|
+
exclude_patterns = []
|
|
140
|
+
|
|
141
|
+
paths.each do |pattern|
|
|
142
|
+
if pattern.start_with?("!")
|
|
143
|
+
# Exclusion pattern (remove the !)
|
|
144
|
+
exclude_patterns << pattern[1..]
|
|
145
|
+
else
|
|
146
|
+
# Inclusion pattern
|
|
147
|
+
include_patterns << pattern
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# First collect all matching files from inclusion patterns
|
|
152
|
+
all_matching_files = []
|
|
153
|
+
include_patterns.each do |pattern|
|
|
154
|
+
matching_files = Dir.glob(File.join(@project_root, pattern))
|
|
155
|
+
all_matching_files.concat(matching_files)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Then filter out excluded files
|
|
159
|
+
exclude_patterns.each do |pattern|
|
|
160
|
+
excluded_files = Dir.glob(File.join(@project_root, pattern))
|
|
161
|
+
all_matching_files -= excluded_files
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Process the final list of files
|
|
165
|
+
all_matching_files.uniq.each do |path|
|
|
166
|
+
next if ignored_path?(path)
|
|
167
|
+
next unless in_scope?(path)
|
|
168
|
+
next if @documents.any? { |d| d.path == path }
|
|
169
|
+
|
|
170
|
+
# Load the document
|
|
171
|
+
doc = Molecules::DocumentLoader.load_file(path)
|
|
172
|
+
|
|
173
|
+
# If it doesn't have frontmatter, check if we should track it anyway
|
|
174
|
+
if doc.nil? && File.exist?(path) && path.end_with?(".md")
|
|
175
|
+
# Create a minimal document for tracking
|
|
176
|
+
content = File.read(path)
|
|
177
|
+
doc = Models::Document.new(
|
|
178
|
+
path: path,
|
|
179
|
+
frontmatter: {
|
|
180
|
+
"doc-type" => type_name,
|
|
181
|
+
"purpose" => "Auto-discovered #{type_name} document",
|
|
182
|
+
"update" => defaults
|
|
183
|
+
},
|
|
184
|
+
content: content
|
|
185
|
+
)
|
|
186
|
+
elsif doc && !doc.managed?
|
|
187
|
+
# Document has partial frontmatter but missing doc-type or purpose
|
|
188
|
+
# Augment it with inferred values
|
|
189
|
+
augmented_frontmatter = doc.frontmatter.dup
|
|
190
|
+
|
|
191
|
+
# Infer doc-type using priority hierarchy
|
|
192
|
+
inferred_type = Atoms::TypeInferrer.resolve(
|
|
193
|
+
path,
|
|
194
|
+
pattern_type: type_name,
|
|
195
|
+
frontmatter_type: augmented_frontmatter["doc-type"]
|
|
196
|
+
)
|
|
197
|
+
augmented_frontmatter["doc-type"] ||= inferred_type if inferred_type
|
|
198
|
+
|
|
199
|
+
# Infer purpose if missing
|
|
200
|
+
augmented_frontmatter["purpose"] ||= infer_purpose_from_content(doc)
|
|
201
|
+
|
|
202
|
+
# Merge defaults for update config if needed
|
|
203
|
+
augmented_frontmatter["update"] = if augmented_frontmatter["update"]
|
|
204
|
+
defaults.merge(augmented_frontmatter["update"])
|
|
205
|
+
else
|
|
206
|
+
defaults
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Create new document with augmented frontmatter
|
|
210
|
+
doc = Models::Document.new(
|
|
211
|
+
path: doc.path,
|
|
212
|
+
frontmatter: augmented_frontmatter,
|
|
213
|
+
content: doc.content
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
@documents << doc if doc
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def ignored_path?(path)
|
|
223
|
+
# Start with default ignored patterns
|
|
224
|
+
# For tmp/, build a specific pattern matching <project_root>/tmp/
|
|
225
|
+
tmp_dir = File.join(@project_root, "tmp")
|
|
226
|
+
ignored_patterns = [
|
|
227
|
+
%r{/\.git/},
|
|
228
|
+
%r{/node_modules/},
|
|
229
|
+
%r{/vendor/},
|
|
230
|
+
%r{^#{Regexp.escape(tmp_dir)}/}, # Only ignore <project_root>/tmp/
|
|
231
|
+
%r{/coverage/},
|
|
232
|
+
%r{/_legacy/},
|
|
233
|
+
%r{/\.ace-taskflow/done/}
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
# Add patterns from config if available
|
|
237
|
+
if @config && @config["ignore"]
|
|
238
|
+
config_patterns = @config["ignore"].map do |pattern|
|
|
239
|
+
# Convert glob patterns to regex
|
|
240
|
+
# Remove leading ! for negation patterns (handle separately)
|
|
241
|
+
if pattern.start_with?("!")
|
|
242
|
+
nil # Skip negation patterns here
|
|
243
|
+
else
|
|
244
|
+
glob_to_regex(pattern)
|
|
245
|
+
end
|
|
246
|
+
end.compact
|
|
247
|
+
ignored_patterns.concat(config_patterns)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
ignored_patterns.any? { |pattern| path.match?(pattern) }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def in_scope?(path)
|
|
254
|
+
return true if @scope_globs.empty?
|
|
255
|
+
|
|
256
|
+
rel = path.sub(/^#{Regexp.escape(@project_root)}\/?/, "")
|
|
257
|
+
@scope_globs.any? do |pattern|
|
|
258
|
+
File.fnmatch?(pattern, rel, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def glob_to_regex(glob_pattern)
|
|
263
|
+
# Convert glob pattern to regex, anchored to project root
|
|
264
|
+
# This ensures patterns like "tmp/**" match <project_root>/tmp/**, not system /tmp/
|
|
265
|
+
|
|
266
|
+
# First replace the glob wildcards with placeholders
|
|
267
|
+
regex_str = glob_pattern
|
|
268
|
+
.gsub("**", "\x00DOUBLESTAR\x00")
|
|
269
|
+
.gsub("*", "\x00STAR\x00")
|
|
270
|
+
|
|
271
|
+
# Escape special regex characters
|
|
272
|
+
regex_str = regex_str.gsub(/([.+?^${}()\[\]\\|])/) { |m| "\\#{m}" }
|
|
273
|
+
|
|
274
|
+
# Now replace the placeholders with regex equivalents
|
|
275
|
+
regex_str = regex_str
|
|
276
|
+
.gsub("\x00DOUBLESTAR\x00", ".*") # ** matches any characters including /
|
|
277
|
+
.gsub("\x00STAR\x00", "[^/]*") # * matches within a single directory
|
|
278
|
+
|
|
279
|
+
# Anchor to project root unless pattern starts with ** (which means "anywhere under project")
|
|
280
|
+
regex_str = if glob_pattern.start_with?("**/")
|
|
281
|
+
# Pattern like "**/tmp/**" means "anywhere under project root"
|
|
282
|
+
"#{Regexp.escape(@project_root)}/#{regex_str}"
|
|
283
|
+
else
|
|
284
|
+
# Pattern like "tmp/**" means "at project root"
|
|
285
|
+
"^#{Regexp.escape(@project_root)}/#{regex_str}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
Regexp.new(regex_str)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def infer_purpose_from_content(document)
|
|
292
|
+
# Try to extract purpose from document content or metadata
|
|
293
|
+
|
|
294
|
+
# 1. Check if frontmatter has 'name' field (common in workflow files)
|
|
295
|
+
if document.frontmatter["name"]
|
|
296
|
+
name = document.frontmatter["name"]
|
|
297
|
+
return "#{name} workflow instruction"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# 2. Check if frontmatter has 'description' field
|
|
301
|
+
if document.frontmatter["description"]
|
|
302
|
+
return document.frontmatter["description"]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# 3. Try to extract from first H1 heading
|
|
306
|
+
if document.content && document.content =~ /^#\s+(.+)$/
|
|
307
|
+
heading = $1.strip
|
|
308
|
+
return heading unless heading.empty?
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# 4. Fallback to filename-based description
|
|
312
|
+
filename = File.basename(document.path, ".*")
|
|
313
|
+
"#{filename.gsub(/[-_]/, " ").capitalize} documentation"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|