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